From c2e9b052e775c96d4b8ff1e664f08345fd268d0a Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Tue, 10 Mar 2026 02:48:27 +0500 Subject: [PATCH] Add basic flow evaluation (#36902) https://pp.huismetbenen.nl/rankings/players/stanr-aimsep --- ...eedAimEvaluator.cs => AgilityEvaluator.cs} | 23 ++-- .../Difficulty/Evaluators/FlowAimEvaluator.cs | 117 ++++++++++++++++++ .../{AimEvaluator.cs => SnapAimEvaluator.cs} | 19 ++- .../Preprocessing/OsuDifficultyHitObject.cs | 9 ++ .../Difficulty/Skills/Aim.cs | 79 ++++++++---- .../Difficulty/Skills/Speed.cs | 2 +- 6 files changed, 199 insertions(+), 50 deletions(-) rename osu.Game.Rulesets.Osu/Difficulty/Evaluators/{SpeedAimEvaluator.cs => AgilityEvaluator.cs} (53%) create mode 100644 osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlowAimEvaluator.cs rename osu.Game.Rulesets.Osu/Difficulty/Evaluators/{AimEvaluator.cs => SnapAimEvaluator.cs} (91%) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AgilityEvaluator.cs similarity index 53% rename from osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedAimEvaluator.cs rename to osu.Game.Rulesets.Osu/Difficulty/Evaluators/AgilityEvaluator.cs index 246aa151f5..2d0d2897a2 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AgilityEvaluator.cs @@ -3,20 +3,18 @@ using System; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators { - public static class SpeedAimEvaluator + public static class AgilityEvaluator { - public const double SINGLE_SPACING_THRESHOLD = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25; // 1.25 circles distance between centers + private const double distance_cap = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25; // 1.25 circles distance between centers /// - /// Evaluates the difficulty of aiming the current object, based on: - /// - /// distance between the previous and current object - /// + /// Evaluates the difficulty of fast aiming /// public static double EvaluateDifficultyOf(DifficultyHitObject current) { @@ -29,20 +27,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators double travelDistance = osuPrevObj?.LazyTravelDistance ?? 0; double distance = travelDistance + osuCurrObj.LazyJumpDistance; - // Cap distance at single_spacing_threshold - distance = Math.Min(distance, SINGLE_SPACING_THRESHOLD); + double distanceScaled = Math.Min(distance, distance_cap) / distance_cap; - // Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold - double distanceBonus = Math.Pow(distance / SINGLE_SPACING_THRESHOLD, 2.9); - - // Apply increased high circle size bonus - distanceBonus *= Math.Pow(osuCurrObj.SmallCircleBonus, 1.5); - - double strain = distanceBonus * 1000 / osuCurrObj.AdjustedDeltaTime; + double strain = distanceScaled * 1000 / osuCurrObj.AdjustedDeltaTime; strain *= highBpmBonus(osuCurrObj.AdjustedDeltaTime); - return strain; + return strain * DifficultyCalculationUtils.Smootherstep(distance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS); } private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.3, Math.Pow(ms / 1000, 0.9))); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlowAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlowAimEvaluator.cs new file mode 100644 index 0000000000..61451c4f93 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlowAimEvaluator.cs @@ -0,0 +1,117 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators +{ + public static class FlowAimEvaluator + { + private const double velocity_change_multiplier = 2.0; + + /// + /// Evaluates difficulty of "flow aim" - aiming pattern where player doesn't stop their cursor on every object and instead "flows" through them. + /// + public static double EvaluateDifficultyOf(DifficultyHitObject current, bool withSliderTravelDistance) + { + if (current.BaseObject is Spinner || current.Index <= 1 || current.Previous(0).BaseObject is Spinner) + return 0; + + var osuCurrObj = (OsuDifficultyHitObject)current; + var osuLastObj = (OsuDifficultyHitObject)current.Previous(0); + var osuLastLastObj = (OsuDifficultyHitObject)current.Previous(1); + + double currDistance = withSliderTravelDistance ? osuCurrObj.LazyJumpDistance : osuCurrObj.JumpDistance; + double prevDistance = withSliderTravelDistance ? osuLastObj.LazyJumpDistance : osuLastObj.JumpDistance; + + double currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime; + + if (osuLastObj.BaseObject is Slider && withSliderTravelDistance) + { + // If the last object is a slider, then we extend the travel velocity through the slider into the current object. + double sliderDistance = osuLastObj.LazyTravelDistance + osuCurrObj.LazyJumpDistance; + currVelocity = Math.Max(currVelocity, sliderDistance / osuCurrObj.AdjustedDeltaTime); + } + + double prevVelocity = prevDistance / osuLastObj.AdjustedDeltaTime; + + double flowDifficulty = currVelocity; + + // Apply high circle size bonus to the base velocity + flowDifficulty *= osuCurrObj.SmallCircleBonus; + + // Rhythm changes are harder to flow + flowDifficulty *= 1 + Math.Min(0.25, + Math.Pow((Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) - Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime)) / 50, 4)); + + if (osuCurrObj.AngularVelocity != null) + { + // Low angular velocity flow (angles are consistent) is easier to follow than erratic flow + flowDifficulty *= 0.8 + Math.Sqrt(osuCurrObj.AngularVelocity.Value / 270.0); + } + + // If all three notes are overlapping - don't reward bonuses as you don't have to do additional movement + double overlappedNotesWeight = 1; + + if (current.Index > 2) + { + double o1 = calculateOverlapFactor(osuCurrObj, osuLastObj); + double o2 = calculateOverlapFactor(osuCurrObj, osuLastLastObj); + double o3 = calculateOverlapFactor(osuLastObj, osuLastLastObj); + + overlappedNotesWeight = 1 - o1 * o2 * o3; + } + + if (osuCurrObj.Angle != null && osuLastObj.Angle != null) + { + // Acute angles are also hard to flow + // We square root velocity to make acute angle switches in streams aren't having difficulty higher than snap + flowDifficulty += Math.Sqrt(currVelocity) * + SnapAimEvaluator.CalcAcuteAngleBonus(osuCurrObj.Angle.Value) * + overlappedNotesWeight; + } + + if (Math.Max(prevVelocity, currVelocity) != 0) + { + if (withSliderTravelDistance) + { + currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime; + prevVelocity = prevDistance / osuLastObj.AdjustedDeltaTime; + } + + // Scale with ratio of difference compared to 0.5 * max dist. + double distRatio = DifficultyCalculationUtils.Smoothstep(Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity), 0, 1); + + // Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing. + double overlapVelocityBuff = Math.Min(OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25 / Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), + Math.Abs(prevVelocity - currVelocity)); + + flowDifficulty += overlapVelocityBuff * distRatio * velocity_change_multiplier; + } + + if (osuCurrObj.BaseObject is Slider) + { + // Include slider velocity to make velocity more consistent with snap + flowDifficulty += osuCurrObj.TravelDistance / osuCurrObj.TravelTime; + } + + // Final velocity is being raised to a power because flow difficulty scales harder with both high distance and time, and we want to account for that + return Math.Pow(flowDifficulty, 1.45); + } + + private static double calculateOverlapFactor(OsuDifficultyHitObject first, OsuDifficultyHitObject second) + { + var firstBase = (OsuHitObject)first.BaseObject; + var secondBase = (OsuHitObject)second.BaseObject; + double objectRadius = firstBase.Radius; + + double distance = Vector2.Distance(firstBase.StackedPosition, secondBase.StackedPosition); + return Math.Clamp(1 - Math.Pow(Math.Max(distance - objectRadius, 0) / objectRadius, 2), 0, 1); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SnapAimEvaluator.cs similarity index 91% rename from osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs rename to osu.Game.Rulesets.Osu/Difficulty/Evaluators/SnapAimEvaluator.cs index 9f9bbb7eb3..58bf9dc48e 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SnapAimEvaluator.cs @@ -9,12 +9,12 @@ using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators { - public static class AimEvaluator + public static class SnapAimEvaluator { - private const double wide_angle_multiplier = 1.35; + private const double wide_angle_multiplier = 1.3; private const double acute_angle_multiplier = 2.5; private const double slider_multiplier = 1.9; - private const double velocity_change_multiplier = 1.1; + private const double velocity_change_multiplier = 1.0; private const double wiggle_multiplier = 1.02; // WARNING: Increasing this multiplier beyond 1.02 reduces difficulty as distance increases. Refer to the desmos link above the wiggle bonus calculation /// @@ -78,24 +78,23 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators if (Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) < 1.25 * Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime)) // If rhythms are the same. { - acuteAngleBonus = calcAcuteAngleBonus(currAngle); + acuteAngleBonus = CalcAcuteAngleBonus(currAngle); // Penalize angle repetition. - acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, Math.Pow(calcAcuteAngleBonus(lastAngle), 3))); + acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, Math.Pow(CalcAcuteAngleBonus(lastAngle), 3))); // Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter acuteAngleBonus *= angleBonus * DifficultyCalculationUtils.Smootherstep(DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.AdjustedDeltaTime, 2), 300, 400) * - DifficultyCalculationUtils.Smootherstep(currDistance, diameter, diameter * 2); + DifficultyCalculationUtils.Smootherstep(currDistance, 0, diameter * 2); } wideAngleBonus = calcWideAngleBonus(currAngle); // Penalize angle repetition. - wideAngleBonus *= 1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3)); + wideAngleBonus *= 0.25 + 0.75 * (1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3))); - // Apply full wide angle bonus for distance more than SINGLE_SPACING_THRESHOLD - wideAngleBonus *= angleBonus * DifficultyCalculationUtils.Smoothstep(currDistance, 0, SpeedAimEvaluator.SINGLE_SPACING_THRESHOLD); + wideAngleBonus *= angleBonus; // Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle // https://www.desmos.com/calculator/dp0v0nvowc @@ -176,6 +175,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators private static double calcWideAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(40), double.DegreesToRadians(140)); - private static double calcAcuteAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(140), double.DegreesToRadians(40)); + public static double CalcAcuteAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(140), double.DegreesToRadians(40)); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 33bb6b29c8..848816503d 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -116,6 +116,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing /// public double? Angle { get; private set; } + public double? AngularVelocity { get; private set; } + /// /// Selective bonus for maps with higher circle size. /// @@ -260,6 +262,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing double sliderAngle = calculateSliderAngle(lastDifficultyObject!, lastLastCursorPosition); Angle = Math.Min(angle, sliderAngle); + + if (lastLastDifficultyObject.Angle != null) + { + double angleDifference = Math.Abs(Angle.Value - lastLastDifficultyObject.Angle.Value); + double angleDifferenceAdjusted = Math.Sin(angleDifference / 2) * 180.0; + AngularVelocity = angleDifferenceAdjusted / (AdjustedDeltaTime * 0.1); + } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 9865d40b77..101dd2250a 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -28,57 +28,90 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills IncludeSliders = includeSliders; } - private double currentAimStrain; - private double currentSpeedStrain; + private double currentStrain; - private double skillMultiplierAim => 65.2; - private double skillMultiplierSpeed => 2.8; + private double skillMultiplierSnap => 65.2; + private double skillMultiplierAgility => 2.7; + private double skillMultiplierFlow => 262.0; private double skillMultiplierTotal => 1.0; private double meanExponent => 1.2; private readonly List sliderStrains = new List(); - private double strainDecayAim(double ms) => Math.Pow(0.15, ms / 1000); - private double strainDecaySpeed(double ms) => Math.Pow(0.3, ms / 1000); + private double strainDecay(double ms) => Math.Pow(0.15, ms / 1000); protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => - DifficultyCalculationUtils.Norm(meanExponent, - currentAimStrain * strainDecayAim(time - current.Previous(0).StartTime), - currentSpeedStrain * strainDecaySpeed(time - current.Previous(0).StartTime)) * skillMultiplierTotal; + currentStrain * strainDecay(time - current.Previous(0).StartTime); protected override double StrainValueAt(DifficultyHitObject current) { - double decayAim = strainDecayAim(((OsuDifficultyHitObject)current).AdjustedDeltaTime); - double decaySpeed = strainDecaySpeed(((OsuDifficultyHitObject)current).AdjustedDeltaTime); + double decay = strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime); - double aimDifficulty = AimEvaluator.EvaluateDifficultyOf(current, IncludeSliders); - double speedDifficulty = SpeedAimEvaluator.EvaluateDifficultyOf(current); + double snapDifficulty = SnapAimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplierSnap; + double agilityDifficulty = AgilityEvaluator.EvaluateDifficultyOf(current) * skillMultiplierAgility; + double flowDifficulty = FlowAimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplierFlow; if (Mods.Any(m => m is OsuModTouchDevice)) { - aimDifficulty = Math.Pow(aimDifficulty, 0.76); - speedDifficulty = Math.Pow(speedDifficulty, 0.95); + snapDifficulty = Math.Pow(snapDifficulty, 0.89); + // we don't adjust agility here since agility represents TD difficulty in a decent enough way + flowDifficulty = Math.Pow(flowDifficulty, 1.1); } if (Mods.Any(m => m is OsuModRelax)) { - speedDifficulty *= 0.0; + agilityDifficulty *= 0.0; + flowDifficulty *= 0.1; } - currentAimStrain *= decayAim; - currentAimStrain += aimDifficulty * (1 - decayAim) * skillMultiplierAim; + double totalDifficulty = calculateTotalValue(snapDifficulty, agilityDifficulty, flowDifficulty); - currentSpeedStrain *= decaySpeed; - currentSpeedStrain += speedDifficulty * (1 - decaySpeed) * skillMultiplierSpeed; - - double totalStrain = DifficultyCalculationUtils.Norm(meanExponent, currentAimStrain, currentSpeedStrain) * skillMultiplierTotal; + currentStrain *= decay; + currentStrain += totalDifficulty * (1 - decay); if (current.BaseObject is Slider) - sliderStrains.Add(totalStrain); + sliderStrains.Add(currentStrain); + + return currentStrain; + } + + private double calculateTotalValue(double snapDifficulty, double agilityDifficulty, double flowDifficulty) + { + // We compare flow to combined snap and agility because snap by itself doesn't have enough difficulty to be above flow on streams + // Agility on the other hand is supposed to measure the rate of cursor velocity changes while snapping + // So snapping every circle on a stream requires an enormous amount of agility at which point it's easier to flow + double combinedSnapDifficulty = DifficultyCalculationUtils.Norm(meanExponent, snapDifficulty, agilityDifficulty); + + double pSnap = calculateSnapFlowProbability(flowDifficulty / combinedSnapDifficulty); + double pFlow = 1 - pSnap; + + double totalDifficulty = combinedSnapDifficulty * pSnap + flowDifficulty * pFlow; + + double totalStrain = totalDifficulty * skillMultiplierTotal; return totalStrain; } + // A function that turns the ratio of snap : flow into the probability of snapping/flowing + // It has the constraints: + // P(snap) + P(flow) = 1 (the object is always either snapped or flowed) + // P(snap) = f(snap/flow), P(flow) = f(flow/snap) (ie snap and flow are symmetric and reversible) + // Therefore: f(x) + f(1/x) = 1 + // 0 <= f(x) <= 1 (cannot have negative or greater than 100% probability of snapping or flowing) + // This logistic function is a solution, which fits nicely with the general idea of interpolation and provides a tuneable constant + private static double calculateSnapFlowProbability(double ratio) + { + const double k = 7.27; + + if (ratio == 0) + return 0; + + if (double.IsNaN(ratio)) + return 1; + + return DifficultyCalculationUtils.Logistic(-k * Math.Log(ratio)); + } + public double GetDifficultSliders() { if (sliderStrains.Count == 0) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index 211d9d57b9..a50a5cfab6 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// public class Speed : HarmonicSkill { - private double skillMultiplier => 1.07; + private double skillMultiplier => 1.05; private readonly List sliderStrains = new List();