From d382760b761bfe27ecc7adc6b2de2ab6a3fedc3b Mon Sep 17 00:00:00 2001 From: tsunyoku Date: Tue, 30 Jun 2026 23:03:30 +0100 Subject: [PATCH 1/2] Decompose flow aim evaluator into several methods --- .../Evaluators/Aim/FlowAimEvaluator.cs | 155 ++++++++++-------- 1 file changed, 91 insertions(+), 64 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs index 3707d4061342..a670d71c723e 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs @@ -20,24 +20,13 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with if (current.BaseObject is Spinner || current.Index <= 1 || current.Previous(0).BaseObject is Spinner) return 0; - const double velocity_change_multiplier = 0.52; - 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 currVelocity = calculateCurrentVelocity(osuCurrObj, osuLastObj, currDistance, withSliderTravelDistance); double prevVelocity = prevDistance / osuLastObj.AdjustedDeltaTime; double flowDifficulty = currVelocity; @@ -46,71 +35,109 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with // We use reduced CS bonus here because the bonus was made for an evaluator with a different d/t scaling flowDifficulty *= Math.Sqrt(osuCurrObj.SmallCircleBonus); - // Rhythm changes are harder to flow - flowDifficulty *= 1 + Math.Min(0.25, + flowDifficulty *= calculateRhythmChangeBonus(osuCurrObj, osuLastObj); + flowDifficulty *= calculateAngularVelocityBonus(osuCurrObj, osuLastObj); + + // If all three notes are overlapping - don't reward bonuses as you don't have to do additional movement + double overlappedNotesWeight = calculateOverlappedNotesWeight(osuCurrObj, osuLastObj); + + flowDifficulty += calculateAcuteAngleBonus(osuCurrObj, currVelocity, overlappedNotesWeight); + flowDifficulty += calculateVelocityChangeBonus(osuCurrObj, osuLastObj, currVelocity, prevVelocity, currDistance, overlappedNotesWeight, withSliderTravelDistance); + flowDifficulty += calculateSliderBonus(osuCurrObj, withSliderTravelDistance); + + // 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 + flowDifficulty = DiffUtils.Pow(flowDifficulty, 1.45); + + // Reduce difficulty for low spacing since spacing below radius is always to be flowed + return flowDifficulty * DiffUtils.Smootherstep(currDistance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS); + } + + private static double calculateRhythmChangeBonus(OsuDifficultyHitObject osuCurrObj, OsuDifficultyHitObject osuLastObj) => + 1 + Math.Min(0.25, DiffUtils.Pow((Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) - Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime)) / 50, 4)); - if (osuCurrObj.Angle != null && osuLastObj.Angle != null) - { - double angleDifference = Math.Abs(osuCurrObj.Angle.Value - osuLastObj.Angle.Value); - double angleDifferenceAdjusted = Math.Sin(angleDifference / 2) * 180.0; - double angularVelocity = angleDifferenceAdjusted / (osuCurrObj.AdjustedDeltaTime * 0.1); + /// + /// Scales flow difficulty by angular velocity. + /// This nerfs consistent angles whilst buffing "erratic" flow. + /// + private static double calculateAngularVelocityBonus(OsuDifficultyHitObject osuCurrObj, OsuDifficultyHitObject osuLastObj) + { + if (osuCurrObj.Angle == null || osuLastObj.Angle == null) + return 1; - // Low angular velocity flow (angles are consistent) is easier to follow than erratic flow - flowDifficulty *= 0.8 + Math.Sqrt(angularVelocity / 270.0); - } + double angleDifference = Math.Abs(osuCurrObj.Angle.Value - osuLastObj.Angle.Value); + double angleDifferenceAdjusted = Math.Sin(angleDifference / 2) * 180.0; + double angularVelocity = angleDifferenceAdjusted / (osuCurrObj.AdjustedDeltaTime * 0.1); - // If all three notes are overlapping - don't reward bonuses as you don't have to do additional movement - double overlappedNotesWeight = 1; + return 0.8 + Math.Sqrt(angularVelocity / 270.0); + } - if (current.Index > 2) - { - double o1 = calculateOverlapFactor(osuCurrObj, osuLastObj); - double o2 = calculateOverlapFactor(osuCurrObj, osuLastLastObj); - double o3 = calculateOverlapFactor(osuLastObj, osuLastLastObj); + private static double calculateAcuteAngleBonus(OsuDifficultyHitObject osuCurrObj, double currVelocity, double overlappedNotesWeight) + { + if (osuCurrObj.Angle == null) + return 0; - overlappedNotesWeight = 1 - o1 * o2 * o3; - } + return currVelocity * SnapAimEvaluator.CalcAngleAcuteness(osuCurrObj.Angle.Value) * overlappedNotesWeight; + } - if (osuCurrObj.Angle != null) - { - // Acute angles are also hard to flow - flowDifficulty += currVelocity * - SnapAimEvaluator.CalcAngleAcuteness(osuCurrObj.Angle.Value) * - overlappedNotesWeight; - } + private static double calculateVelocityChangeBonus(OsuDifficultyHitObject osuCurrObj, OsuDifficultyHitObject osuLastObj, double currVelocity, double prevVelocity, + double currDistance, double overlappedNotesWeight, bool withSliderTravelDistance) + { + const double velocity_change_multiplier = 0.52; - if (Math.Max(prevVelocity, currVelocity) != 0) - { - if (withSliderTravelDistance) - { - currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime; - } - - // Scale with ratio of difference compared to 0.5 * max dist. - double distRatio = DiffUtils.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 * - overlappedNotesWeight * - velocity_change_multiplier; - } + if (Math.Max(prevVelocity, currVelocity) == 0) + return 0; + + if (withSliderTravelDistance) + currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime; + + // Scale with ratio of difference compared to 0.5 * max dist. + double distRatio = DiffUtils.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)); - if (osuCurrObj.BaseObject is Slider && withSliderTravelDistance) + return overlapVelocityBuff * distRatio * overlappedNotesWeight * velocity_change_multiplier; + } + + private static double calculateSliderBonus(OsuDifficultyHitObject osuCurrObj, bool withSliderTravelDistance) + { + if (osuCurrObj.BaseObject is not Slider || !withSliderTravelDistance) + return 0; + + return osuCurrObj.TravelDistance / osuCurrObj.TravelTime; + } + + private static double calculateCurrentVelocity(OsuDifficultyHitObject osuCurrObj, OsuDifficultyHitObject osuLastObj, double currDistance, bool withSliderTravelDistance) + { + double currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime; + + // If the last object is a slider, then we extend the travel velocity through the slider into the current object. + if (osuLastObj.BaseObject is Slider && withSliderTravelDistance) { - // Include slider velocity to make velocity more consistent with snap - flowDifficulty += osuCurrObj.TravelDistance / osuCurrObj.TravelTime; + double sliderDistance = osuLastObj.LazyTravelDistance + osuCurrObj.LazyJumpDistance; + currVelocity = Math.Max(currVelocity, sliderDistance / osuCurrObj.AdjustedDeltaTime); } - // 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 - flowDifficulty = DiffUtils.Pow(flowDifficulty, 1.45); + return currVelocity; + } - // Reduce difficulty for low spacing since spacing below radius is always to be flowed - return flowDifficulty * DiffUtils.Smootherstep(currDistance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS); + /// + /// Reduces bonuses when the current and previous two objects all overlap, as no additional movement is required. + /// + private static double calculateOverlappedNotesWeight(OsuDifficultyHitObject osuCurrObj, OsuDifficultyHitObject osuLastObj) + { + if (osuCurrObj.Index <= 2) + return 1; + + var osuLastLastObj = (OsuDifficultyHitObject)osuCurrObj.Previous(1); + + double o1 = calculateOverlapFactor(osuCurrObj, osuLastObj); + double o2 = calculateOverlapFactor(osuCurrObj, osuLastLastObj); + double o3 = calculateOverlapFactor(osuLastObj, osuLastLastObj); + + return 1 - o1 * o2 * o3; } private static double calculateOverlapFactor(OsuDifficultyHitObject first, OsuDifficultyHitObject second) From 38384534831f8db09bd26d0dff42f8ad91c10652 Mon Sep 17 00:00:00 2001 From: James Wilson Date: Thu, 2 Jul 2026 17:40:55 +0100 Subject: [PATCH 2/2] Move slider checks outside of slider bonus method --- .../Difficulty/Evaluators/Aim/FlowAimEvaluator.cs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs index a670d71c723e..592c9a121bcc 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs @@ -43,7 +43,11 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with flowDifficulty += calculateAcuteAngleBonus(osuCurrObj, currVelocity, overlappedNotesWeight); flowDifficulty += calculateVelocityChangeBonus(osuCurrObj, osuLastObj, currVelocity, prevVelocity, currDistance, overlappedNotesWeight, withSliderTravelDistance); - flowDifficulty += calculateSliderBonus(osuCurrObj, withSliderTravelDistance); + + if (osuCurrObj.BaseObject is Slider && withSliderTravelDistance) + { + flowDifficulty += calculateSliderBonus(osuCurrObj); + } // 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 flowDifficulty = DiffUtils.Pow(flowDifficulty, 1.45); @@ -101,13 +105,8 @@ private static double calculateVelocityChangeBonus(OsuDifficultyHitObject osuCur return overlapVelocityBuff * distRatio * overlappedNotesWeight * velocity_change_multiplier; } - private static double calculateSliderBonus(OsuDifficultyHitObject osuCurrObj, bool withSliderTravelDistance) - { - if (osuCurrObj.BaseObject is not Slider || !withSliderTravelDistance) - return 0; - - return osuCurrObj.TravelDistance / osuCurrObj.TravelTime; - } + private static double calculateSliderBonus(OsuDifficultyHitObject osuCurrObj) + => osuCurrObj.TravelDistance / osuCurrObj.TravelTime; private static double calculateCurrentVelocity(OsuDifficultyHitObject osuCurrObj, OsuDifficultyHitObject osuLastObj, double currDistance, bool withSliderTravelDistance) {