1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-16 13:03:34 +08:00

Implement difficulty evaluators in the osu! mania ruleset (#33411)

* stuff

* Implement evaluators

* Typo

* Fixes

* clarifying comment

* Fix CalculateInitialStrain

* Remove debug line

* Small code quality fix

* Address comments, slight code quality fixes

* Change comment for clarity

---------

Co-authored-by: StanR <hi@stanr.info>
This commit is contained in:
Natelytle
2025-06-27 18:46:52 -04:00
committed by GitHub
Unverified
parent d5ef8c8524
commit cf4d6bea72
5 changed files with 172 additions and 63 deletions
@@ -0,0 +1,37 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Utils;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Mania.Difficulty.Preprocessing;
namespace osu.Game.Rulesets.Mania.Difficulty.Evaluators
{
public class IndividualStrainEvaluator
{
public static double EvaluateDifficultyOf(DifficultyHitObject current)
{
var maniaCurrent = (ManiaDifficultyHitObject)current;
double startTime = maniaCurrent.StartTime;
double endTime = maniaCurrent.EndTime;
double holdFactor = 1.0; // Factor to all additional strains in case something else is held
// We award a bonus if this note starts and ends before the end of another hold note.
foreach (var maniaPrevious in maniaCurrent.PreviousHitObjects)
{
if (maniaPrevious is null)
continue;
if (Precision.DefinitelyBigger(maniaPrevious.EndTime, endTime, 1) &&
Precision.DefinitelyBigger(startTime, maniaPrevious.StartTime, 1))
{
holdFactor = 1.25;
break;
}
}
return 2.0 * holdFactor;
}
}
}
@@ -0,0 +1,61 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Utils;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mania.Difficulty.Preprocessing;
namespace osu.Game.Rulesets.Mania.Difficulty.Evaluators
{
public class OverallStrainEvaluator
{
private const double release_threshold = 30;
public static double EvaluateDifficultyOf(DifficultyHitObject current)
{
var maniaCurrent = (ManiaDifficultyHitObject)current;
double startTime = maniaCurrent.StartTime;
double endTime = maniaCurrent.EndTime;
bool isOverlapping = false;
double closestEndTime = Math.Abs(endTime - startTime); // Lowest value we can assume with the current information
double holdFactor = 1.0; // Factor to all additional strains in case something else is held
double holdAddition = 0; // Addition to the current note in case it's a hold and has to be released awkwardly
foreach (var maniaPrevious in maniaCurrent.PreviousHitObjects)
{
if (maniaPrevious is null)
continue;
// The current note is overlapped if a previous note or end is overlapping the current note body
isOverlapping |= Precision.DefinitelyBigger(maniaPrevious.EndTime, startTime, 1) &&
Precision.DefinitelyBigger(endTime, maniaPrevious.EndTime, 1) &&
Precision.DefinitelyBigger(startTime, maniaPrevious.StartTime, 1);
// We give a slight bonus to everything if something is held meanwhile
if (Precision.DefinitelyBigger(maniaPrevious.EndTime, endTime, 1) &&
Precision.DefinitelyBigger(startTime, maniaPrevious.StartTime, 1))
holdFactor = 1.25;
closestEndTime = Math.Min(closestEndTime, Math.Abs(endTime - maniaPrevious.EndTime));
}
// The hold addition is given if there was an overlap, however it is only valid if there are no other note with a similar ending.
// Releasing multiple notes is just as easy as releasing 1. Nerfs the hold addition by half if the closest release is release_threshold away.
// holdAddition
// ^
// 1.0 + - - - - - -+-----------
// | /
// 0.5 + - - - - -/ Sigmoid Curve
// | /|
// 0.0 +--------+-+---------------> Release Difference / ms
// release_threshold
if (isOverlapping)
holdAddition = DifficultyCalculationUtils.Logistic(x: closestEndTime, multiplier: 0.27, midpointOffset: release_threshold);
return (1 + holdAddition) * holdFactor;
}
}
}
@@ -65,13 +65,22 @@ namespace osu.Game.Rulesets.Mania.Difficulty
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate)
{
var sortedObjects = beatmap.HitObjects.ToArray();
int totalColumns = ((ManiaBeatmap)beatmap).TotalColumns;
LegacySortHelper<HitObject>.Sort(sortedObjects, Comparer<HitObject>.Create((a, b) => (int)Math.Round(a.StartTime) - (int)Math.Round(b.StartTime)));
List<DifficultyHitObject> objects = new List<DifficultyHitObject>();
List<DifficultyHitObject>[] perColumnObjects = new List<DifficultyHitObject>[totalColumns];
for (int column = 0; column < totalColumns; column++)
perColumnObjects[column] = new List<DifficultyHitObject>();
for (int i = 1; i < sortedObjects.Length; i++)
objects.Add(new ManiaDifficultyHitObject(sortedObjects[i], sortedObjects[i - 1], clockRate, objects, objects.Count));
{
var currentObject = new ManiaDifficultyHitObject(sortedObjects[i], sortedObjects[i - 1], clockRate, objects, perColumnObjects, objects.Count);
objects.Add(currentObject);
perColumnObjects[currentObject.Column].Add(currentObject);
}
return objects;
}
@@ -12,9 +12,59 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Preprocessing
{
public new ManiaHitObject BaseObject => (ManiaHitObject)base.BaseObject;
public ManiaDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, List<DifficultyHitObject> objects, int index)
private readonly List<DifficultyHitObject>[] perColumnObjects;
private readonly int columnIndex;
public readonly int Column;
// The hit object earlier in time than this note in each column
public readonly ManiaDifficultyHitObject?[] PreviousHitObjects;
public readonly double ColumnStrainTime;
public ManiaDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, List<DifficultyHitObject> objects, List<DifficultyHitObject>[] perColumnObjects, int index)
: base(hitObject, lastObject, clockRate, objects, index)
{
int totalColumns = perColumnObjects.Length;
this.perColumnObjects = perColumnObjects;
Column = BaseObject.Column;
columnIndex = perColumnObjects[Column].Count;
PreviousHitObjects = new ManiaDifficultyHitObject[totalColumns];
ColumnStrainTime = StartTime - PrevInColumn(0)?.StartTime ?? StartTime;
if (index > 0)
{
ManiaDifficultyHitObject prevNote = (ManiaDifficultyHitObject)objects[index - 1];
for (int i = 0; i < prevNote.PreviousHitObjects.Length; i++)
PreviousHitObjects[i] = prevNote.PreviousHitObjects[i];
// intentionally depends on processing order to match live.
PreviousHitObjects[prevNote.Column] = prevNote;
}
}
/// <summary>
/// The previous object in the same column as this <see cref="ManiaDifficultyHitObject"/>, exclusive of Long Note tails.
/// </summary>
/// <param name="backwardsIndex">The number of notes to go back.</param>
/// <returns>The object in this column <paramref name="backwardsIndex"/> notes back, or null if this is the first note in the column.</returns>
public ManiaDifficultyHitObject? PrevInColumn(int backwardsIndex)
{
int index = columnIndex - (backwardsIndex + 1);
return index >= 0 && index < perColumnObjects[Column].Count ? (ManiaDifficultyHitObject)perColumnObjects[Column][index] : null;
}
/// <summary>
/// The next object in the same column as this <see cref="ManiaDifficultyHitObject"/>, exclusive of Long Note tails.
/// </summary>
/// <param name="forwardsIndex">The number of notes to go forward.</param>
/// <returns>The object in this column <paramref name="forwardsIndex"/> notes forward, or null if this is the last note in the column.</returns>
public ManiaDifficultyHitObject? NextInColumn(int forwardsIndex)
{
int index = columnIndex + (forwardsIndex + 1);
return index >= 0 && index < perColumnObjects[Column].Count ? (ManiaDifficultyHitObject)perColumnObjects[Column][index] : null;
}
}
}
@@ -2,10 +2,9 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Utils;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mania.Difficulty.Evaluators;
using osu.Game.Rulesets.Mania.Difficulty.Preprocessing;
using osu.Game.Rulesets.Mods;
@@ -15,23 +14,17 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills
{
private const double individual_decay_base = 0.125;
private const double overall_decay_base = 0.30;
private const double release_threshold = 30;
protected override double SkillMultiplier => 1;
protected override double StrainDecayBase => 1;
private readonly double[] startTimes;
private readonly double[] endTimes;
private readonly double[] individualStrains;
private double individualStrain;
private double highestIndividualStrain;
private double overallStrain;
public Strain(Mod[] mods, int totalColumns)
: base(mods)
{
startTimes = new double[totalColumns];
endTimes = new double[totalColumns];
individualStrains = new double[totalColumns];
overallStrain = 1;
}
@@ -39,65 +32,24 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills
protected override double StrainValueOf(DifficultyHitObject current)
{
var maniaCurrent = (ManiaDifficultyHitObject)current;
double startTime = maniaCurrent.StartTime;
double endTime = maniaCurrent.EndTime;
int column = maniaCurrent.BaseObject.Column;
bool isOverlapping = false;
double closestEndTime = Math.Abs(endTime - startTime); // Lowest value we can assume with the current information
double holdFactor = 1.0; // Factor to all additional strains in case something else is held
double holdAddition = 0; // Addition to the current note in case it's a hold and has to be released awkwardly
individualStrains[maniaCurrent.Column] = applyDecay(individualStrains[maniaCurrent.Column], maniaCurrent.ColumnStrainTime, individual_decay_base);
individualStrains[maniaCurrent.Column] += IndividualStrainEvaluator.EvaluateDifficultyOf(current);
for (int i = 0; i < endTimes.Length; ++i)
{
// The current note is overlapped if a previous note or end is overlapping the current note body
isOverlapping |= Precision.DefinitelyBigger(endTimes[i], startTime, 1) &&
Precision.DefinitelyBigger(endTime, endTimes[i], 1) &&
Precision.DefinitelyBigger(startTime, startTimes[i], 1);
// Take the hardest individualStrain for notes that happen at the same time (in a chord).
// This is to ensure the order in which the notes are processed does not affect the resultant total strain.
highestIndividualStrain = maniaCurrent.DeltaTime <= 1 ? Math.Max(highestIndividualStrain, individualStrains[maniaCurrent.Column]) : individualStrains[maniaCurrent.Column];
// We give a slight bonus to everything if something is held meanwhile
if (Precision.DefinitelyBigger(endTimes[i], endTime, 1) &&
Precision.DefinitelyBigger(startTime, startTimes[i], 1))
holdFactor = 1.25;
closestEndTime = Math.Min(closestEndTime, Math.Abs(endTime - endTimes[i]));
}
// The hold addition is given if there was an overlap, however it is only valid if there are no other note with a similar ending.
// Releasing multiple notes is just as easy as releasing 1. Nerfs the hold addition by half if the closest release is release_threshold away.
// holdAddition
// ^
// 1.0 + - - - - - -+-----------
// | /
// 0.5 + - - - - -/ Sigmoid Curve
// | /|
// 0.0 +--------+-+---------------> Release Difference / ms
// release_threshold
if (isOverlapping)
holdAddition = DifficultyCalculationUtils.Logistic(x: closestEndTime, multiplier: 0.27, midpointOffset: release_threshold);
// Decay and increase individualStrains in own column
individualStrains[column] = applyDecay(individualStrains[column], startTime - startTimes[column], individual_decay_base);
individualStrains[column] += 2.0 * holdFactor;
// For notes at the same time (in a chord), the individualStrain should be the hardest individualStrain out of those columns
individualStrain = maniaCurrent.DeltaTime <= 1 ? Math.Max(individualStrain, individualStrains[column]) : individualStrains[column];
// Decay and increase overallStrain
overallStrain = applyDecay(overallStrain, current.DeltaTime, overall_decay_base);
overallStrain += (1 + holdAddition) * holdFactor;
// Update startTimes and endTimes arrays
startTimes[column] = startTime;
endTimes[column] = endTime;
overallStrain = applyDecay(overallStrain, maniaCurrent.DeltaTime, overall_decay_base);
overallStrain += OverallStrainEvaluator.EvaluateDifficultyOf(current);
// By subtracting CurrentStrain, this skill effectively only considers the maximum strain of any one hitobject within each strain section.
return individualStrain + overallStrain - CurrentStrain;
return highestIndividualStrain + overallStrain - CurrentStrain;
}
protected override double CalculateInitialStrain(double offset, DifficultyHitObject current)
=> applyDecay(individualStrain, offset - current.Previous(0).StartTime, individual_decay_base)
+ applyDecay(overallStrain, offset - current.Previous(0).StartTime, overall_decay_base);
protected override double CalculateInitialStrain(double offset, DifficultyHitObject current) =>
applyDecay(highestIndividualStrain, offset - current.Previous(0).StartTime, individual_decay_base)
+ applyDecay(overallStrain, offset - current.Previous(0).StartTime, overall_decay_base);
private double applyDecay(double value, double deltaTime, double decayBase)
=> value * Math.Pow(decayBase, deltaTime / 1000);