diff --git a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs index e7a6d8ecffc2..94768c15a515 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs @@ -34,6 +34,30 @@ public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxComb public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic()); + [TestCase(239, "diffcalc-test")] + [TestCase(54, "zero-length-sliders")] + [TestCase(4, "very-fast-slider")] + public void TestOffsetChanges(int expectedMaxCombo, string name) + { + const double offset_iterations = 400; + var beatmap = GetBeatmap(name); + + var attributes = CreateDifficultyCalculator(beatmap).Calculate(); + double expectedStarRating = attributes.StarRating; + + for (int i = 0; i < offset_iterations; i++) + { + foreach (var beatmapHitObject in beatmap.Beatmap.HitObjects) + beatmapHitObject.StartTime++; + + attributes = CreateDifficultyCalculator(beatmap).Calculate(); + + // Platform-dependent math functions (Pow, Cbrt, Exp, etc) may result in minute differences. + Assert.That(attributes.StarRating, Is.EqualTo(expectedStarRating).Within(0.00001)); + Assert.That(attributes.MaxCombo, Is.EqualTo(expectedMaxCombo)); + } + } + protected override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new OsuDifficultyCalculator(new OsuRuleset().RulesetInfo, beatmap); protected override Ruleset CreateRuleset() => new OsuRuleset(); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs index 10ec462198d7..8e3c9d01bc98 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs @@ -3,7 +3,6 @@ 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; @@ -11,7 +10,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim { public static class AgilityEvaluator { - private const double distance_cap = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25; // 1.25 circles distance between centers + private const double distance_cap = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.2; // 1.25 circles distance between centers /// /// Evaluates the difficulty of fast aiming @@ -31,13 +30,13 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) double strain = distanceScaled * 1000 / osuCurrObj.AdjustedDeltaTime; - strain *= osuCurrObj.SmallCircleBonus; + strain *= Math.Pow(osuCurrObj.SmallCircleBonus, 1.5); strain *= highBpmBonus(osuCurrObj.AdjustedDeltaTime); - return strain * DifficultyCalculationUtils.Smootherstep(distance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS); + return strain; } - private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.3, Math.Pow(ms / 1000, 0.9))); + private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.2, ms / 1000)); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs index d50e84f00b6b..30f45a3226a5 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs @@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim { public static class FlowAimEvaluator { - private const double velocity_change_multiplier = 2.0; + private const double velocity_change_multiplier = 0.52; /// /// Evaluates difficulty of "flow aim" - aiming pattern where player doesn't stop their cursor on every object and instead "flows" through them. @@ -44,7 +44,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with // Apply high circle size bonus to the base velocity. // We use reduced CS bonus here because the bonus was made for an evaluator with a different d/t scaling - flowDifficulty *= Math.Pow(osuCurrObj.SmallCircleBonus, 0.75); + flowDifficulty *= Math.Sqrt(osuCurrObj.SmallCircleBonus); // Rhythm changes are harder to flow flowDifficulty *= 1 + Math.Min(0.25, @@ -77,7 +77,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with // 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) * + SnapAimEvaluator.CalcAngleAcuteness(osuCurrObj.Angle.Value) * overlappedNotesWeight; } @@ -108,7 +108,10 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with } // 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); + flowDifficulty = Math.Pow(flowDifficulty, 1.45); + + // Reduce difficulty for low spacing since spacing below radius is always to be flowed + return flowDifficulty * DifficultyCalculationUtils.Smootherstep(currDistance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS); } private static double calculateOverlapFactor(OsuDifficultyHitObject first, OsuDifficultyHitObject second) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs index 75ecfe89ecc1..a373df706cd5 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs @@ -55,51 +55,39 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with double prevDistance = withSliderTravelDistance ? osuLastObj.LazyJumpDistance : osuLastObj.JumpDistance; double prevVelocity = prevDistance / osuLastObj.AdjustedDeltaTime; - double wideAngleBonus = 0; - double acuteAngleBonus = 0; - double sliderBonus = 0; - double velocityChangeBonus = 0; - double wiggleBonus = 0; - double aimStrain = currVelocity; // Start strain with regular velocity. + // Penalize angle repetition. + aimStrain *= vectorAngleRepetition(osuCurrObj, osuLastObj); + if (osuCurrObj.Angle != null && osuLastObj.Angle != null) { double currAngle = osuCurrObj.Angle.Value; double lastAngle = osuLastObj.Angle.Value; // Rewarding angles, take the smaller velocity as base. - double angleBonus = Math.Min(currVelocity, prevVelocity); + double velocityInfluence = Math.Min(currVelocity, prevVelocity); + + double acuteAngleBonus = 0; if (Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) < 1.25 * Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime)) // If rhythms are the same. { - acuteAngleBonus = CalcAcuteAngleBonus(currAngle); + acuteAngleBonus = CalcAngleAcuteness(currAngle); - // Penalize angle repetition. - acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, Math.Pow(CalcAcuteAngleBonus(lastAngle), 3))); + // Penalize angle repetition. It is important to do it _before_ multiplying by anything because we compare raw acuteness here + acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, Math.Pow(CalcAngleAcuteness(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) * + acuteAngleBonus *= velocityInfluence * DifficultyCalculationUtils.Smootherstep(DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.AdjustedDeltaTime, 2), 300, 400) * DifficultyCalculationUtils.Smootherstep(currDistance, 0, diameter * 2); } - wideAngleBonus = calcWideAngleBonus(currAngle); + double wideAngleBonus = calcAngleWideness(currAngle); - // Penalize angle repetition. - wideAngleBonus *= 0.25 + 0.75 * (1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3))); + // Penalize angle repetition. It is important to do it _before_ multiplying by velocity because we compare raw wideness here + wideAngleBonus *= 0.25 + 0.75 * (1 - Math.Min(wideAngleBonus, Math.Pow(calcAngleWideness(lastAngle), 3))); - wideAngleBonus *= angleBonus; - - // Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle - // https://www.desmos.com/calculator/dp0v0nvowc - wiggleBonus = angleBonus - * DifficultyCalculationUtils.Smootherstep(currDistance, radius, diameter) - * Math.Pow(DifficultyCalculationUtils.ReverseLerp(currDistance, diameter * 3, diameter), 1.8) - * DifficultyCalculationUtils.Smootherstep(currAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)) - * DifficultyCalculationUtils.Smootherstep(prevDistance, radius, diameter) - * Math.Pow(DifficultyCalculationUtils.ReverseLerp(prevDistance, diameter * 3, diameter), 1.8) - * DifficultyCalculationUtils.Smootherstep(lastAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)); + wideAngleBonus *= velocityInfluence; if (osuLast2Obj != null) { @@ -115,6 +103,21 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with wideAngleBonus *= 1 - 0.55 * (1 - distance); } } + + // Add in acute angle bonus or wide angle bonus, whichever is larger. + aimStrain += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier); + + // Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle + // https://www.desmos.com/calculator/dp0v0nvowc + double wiggleBonus = velocityInfluence + * DifficultyCalculationUtils.Smootherstep(currDistance, radius, diameter) + * Math.Pow(DifficultyCalculationUtils.ReverseLerp(currDistance, diameter * 3, diameter), 1.8) + * DifficultyCalculationUtils.Smootherstep(currAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)) + * DifficultyCalculationUtils.Smootherstep(prevDistance, radius, diameter) + * Math.Pow(DifficultyCalculationUtils.ReverseLerp(prevDistance, diameter * 3, diameter), 1.8) + * DifficultyCalculationUtils.Smootherstep(lastAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)); + + aimStrain += wiggleBonus * wiggle_multiplier; } if (Math.Max(prevVelocity, currVelocity) != 0) @@ -131,44 +134,30 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool with // Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing. double overlapVelocityBuff = Math.Min(diameter * 1.25 / Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), Math.Abs(prevVelocity - currVelocity)); - velocityChangeBonus = overlapVelocityBuff * distRatio; + double velocityChangeBonus = overlapVelocityBuff * distRatio; // Penalize for rhythm changes. velocityChangeBonus *= Math.Pow(Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) / Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), 2); - } - if (osuCurrObj.BaseObject is Slider) - { - // Reward sliders based on velocity. - sliderBonus = osuCurrObj.TravelDistance / osuCurrObj.TravelTime; + aimStrain += velocityChangeBonus * velocity_change_multiplier; } - // Penalize angle repetition. - aimStrain *= vectorAngleRepetition(osuCurrObj, osuLastObj); - - aimStrain += wiggleBonus * wiggle_multiplier; - aimStrain += velocityChangeBonus * velocity_change_multiplier; - - // Add in acute angle bonus or wide angle bonus, whichever is larger. - aimStrain += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier); - - // Add in additional slider velocity bonus. - if (withSliderTravelDistance) + // Reward sliders based on velocity. + if (osuCurrObj.BaseObject is Slider && withSliderTravelDistance) + { + double sliderBonus = osuCurrObj.TravelDistance / osuCurrObj.TravelTime; aimStrain += (sliderBonus < 1 ? sliderBonus : Math.Pow(sliderBonus, 0.75)) * slider_multiplier; + } // Apply high circle size bonus aimStrain *= osuCurrObj.SmallCircleBonus; - aimStrain *= highBpmBonus(osuCurrObj.AdjustedDeltaTime, osuCurrObj.LazyJumpDistance); + aimStrain *= highBpmBonus(osuCurrObj.AdjustedDeltaTime); return aimStrain; } - // We decrease strain for distances 1 / (1 - Math.Pow(0.03, Math.Pow(ms / 1000, 0.65))) - * DifficultyCalculationUtils.Smootherstep(distance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS); + private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.03, Math.Pow(ms / 1000, 0.65))); private static double vectorAngleRepetition(OsuDifficultyHitObject current, OsuDifficultyHitObject previous) { @@ -208,13 +197,13 @@ private static double vectorAngleRepetition(OsuDifficultyHitObject current, OsuD double angleDifferenceAdjusted = Math.Cos(2 * Math.Min(double.DegreesToRadians(45), Math.Abs(currAngle - lastAngle) * stackFactor)); - double baseNerf = 1 - maximum_repetition_nerf * CalcAcuteAngleBonus(lastAngle) * angleDifferenceAdjusted; + double baseNerf = 1 - maximum_repetition_nerf * CalcAngleAcuteness(lastAngle) * angleDifferenceAdjusted; return Math.Pow(baseNerf + (1 - baseNerf) * vectorRepetition * maximum_vector_influence * stackFactor, 2); } - private static double calcWideAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(40), double.DegreesToRadians(140)); + private static double calcAngleWideness(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(40), double.DegreesToRadians(140)); - public static double CalcAcuteAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(140), double.DegreesToRadians(40)); + public static double CalcAngleAcuteness(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(140), double.DegreesToRadians(40)); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs index 926abca97125..fe6d49661b9c 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs @@ -48,6 +48,9 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool hidd double difficulty = DifficultyCalculationUtils.Norm(1.5, preemptDifficulty, hiddenDifficulty, noteDensityDifficulty); + // Having less time to process information is harder + difficulty *= highBpmBonus(currObj.AdjustedDeltaTime); + return difficulty; } @@ -208,6 +211,10 @@ private static double getConstantAngleNerfFactor(OsuDifficultyHitObject current) int index = 0; double currentTimeGap = 0; + OsuDifficultyHitObject loopObjPrev0 = current; + OsuDifficultyHitObject? loopObjPrev1 = null; + OsuDifficultyHitObject? loopObjPrev2 = null; + while (currentTimeGap < minimum_angle_relevancy_time) { var loopObj = (OsuDifficultyHitObject)current.Previous(index); @@ -221,13 +228,34 @@ private static double getConstantAngleNerfFactor(OsuDifficultyHitObject current) if (loopObj.Angle.IsNotNull() && current.Angle.IsNotNull()) { double angleDifference = Math.Abs(current.Angle.Value - loopObj.Angle.Value); + double angleDifferenceAlternating = Math.PI; + + if (loopObjPrev0.Angle != null && loopObjPrev1?.Angle != null && loopObjPrev2?.Angle != null) + { + angleDifferenceAlternating = Math.Abs(loopObjPrev1.Angle.Value - loopObj.Angle.Value); + angleDifferenceAlternating += Math.Abs(loopObjPrev2.Angle.Value - loopObjPrev0.Angle.Value); + + double weight = 1.0; + + // Be sure that one of the angles is very sharp, when other is wide + weight *= DifficultyCalculationUtils.ReverseLerp(Math.Min(loopObj.Angle.Value, loopObjPrev0.Angle.Value) * 180 / Math.PI, 20, 5); + weight *= DifficultyCalculationUtils.ReverseLerp(Math.Max(loopObj.Angle.Value, loopObjPrev0.Angle.Value) * 180 / Math.PI, 60, 120); + + // Lerp between max angle difference and rescaled alternating difference, with more harsh scaling compared to normal difference + angleDifferenceAlternating = double.Lerp(Math.PI, 0.1 * angleDifferenceAlternating, weight); + } + double stackFactor = DifficultyCalculationUtils.Smootherstep(loopObj.LazyJumpDistance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS); - constantAngleCount += Math.Cos(3 * Math.Min(double.DegreesToRadians(30), angleDifference * stackFactor)) * longIntervalFactor; + constantAngleCount += Math.Cos(3 * Math.Min(double.DegreesToRadians(30), Math.Min(angleDifference, angleDifferenceAlternating) * stackFactor)) * longIntervalFactor; } currentTimeGap = current.StartTime - loopObj.StartTime; index++; + + loopObjPrev2 = loopObjPrev1; + loopObjPrev1 = loopObjPrev0; + loopObjPrev0 = loopObj; } return Math.Clamp(2 / constantAngleCount, 0.2, 1); @@ -238,5 +266,7 @@ private static double getTimeNerfFactor(double deltaTime) { return Math.Clamp(2 - deltaTime / (reading_window_size / 2), 0, 1); } + + private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.8, ms / 1000)); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 8d2a3968f9bc..6776fe1d06ee 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -249,16 +249,11 @@ private double computeSpeedValue(ScoreInfo score, OsuDifficultyAttributes attrib speedValue *= calculateStrainCountMissPenalty(relevantMissCount, attributes.SpeedDifficultStrainCount); } - // TC bonuses are excluded when blinds is present as the increased visual difficulty is unimportant when notes cannot be seen. if (score.Mods.Any(m => m is OsuModBlinds)) { // Increasing the speed value by object count for Blinds isn't ideal, so the minimum buff is given. speedValue *= 1.12; } - else if (score.Mods.Any(m => m is OsuModTraceable)) - { - speedValue *= 1.0 + calculateTraceableBonus(); - } double speedHighDeviationMultiplier = calculateSpeedHighDeviationNerf(attributes); speedValue *= speedHighDeviationMultiplier; @@ -520,19 +515,21 @@ private double calculateSpeedHighDeviationNerf(OsuDifficultyAttributes attribute /// private double calculateTraceableBonus(double sliderFactor = 1) { - // Start from normal curve, rewarding lower AR up to AR7 - double traceableBonus = 0.025 * (12.0 - Math.Max(approachRate, 7)); + // We want to reward slider aim less, more so at lower AR + double highApproachRateSliderVisibilityFactor = 0.5 + (Math.Pow(sliderFactor, 6) / 2); + double lowApproachRateSliderVisibilityFactor = Math.Pow(sliderFactor, 6); - // We want to reward slider aim on low AR less - double sliderVisibilityFactor = Math.Pow(sliderFactor, 3); + // Start from normal curve, rewarding lower AR up to AR7 + double traceableBonus = 0.0275; + traceableBonus += 0.025 * (12.0 - Math.Max(approachRate, 7)) * highApproachRateSliderVisibilityFactor; // For AR up to 0 - reduce reward for very low ARs when object is visible if (approachRate < 7) - traceableBonus += 0.02 * (7.0 - Math.Max(approachRate, 0)) * sliderVisibilityFactor; + traceableBonus += 0.025 * (7.0 - Math.Max(approachRate, 0)) * lowApproachRateSliderVisibilityFactor; // Starting from AR0 - cap values so they won't grow to infinity if (approachRate < 0) - traceableBonus += 0.01 * (1 - Math.Pow(1.5, approachRate)) * sliderVisibilityFactor; + traceableBonus += 0.025 * (1 - Math.Pow(1.5, approachRate)) * lowApproachRateSliderVisibilityFactor; return traceableBonus; } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs index a269704dec22..9dc9b1afa430 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs @@ -31,9 +31,6 @@ public double ComputeAimRating(double aimDifficultyValue) double aimRating = Math.Pow(aimDifficultyValue, 0.63) * 0.02275; - if (mods.Any(m => m is OsuModRelax)) - aimRating *= 0.9; - if (mods.Any(m => m is OsuModMagnetised)) { float magnetisedStrength = mods.OfType().First().AttractionStrength.Value; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index e6c59188a02d..a9bad06d3f05 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -34,7 +34,7 @@ public Aim(Mod[] mods, bool includeSliders) private double skillMultiplierAgility => 10.0; private double skillMultiplierFlow => 1100; private double skillMultiplierTotal => 1.05; - private double meanExponent => 1.2; + private double combinedSnapNormExponent => 1.2; private readonly List sliderStrains = new List(); @@ -43,19 +43,19 @@ protected override double HitProbability(double skill, double difficulty) if (difficulty <= 0) return 1; if (skill <= 0) return 0; - double baseDeviation = difficulty / skill; - // at what point does the player lose the ability to aim normally - // increasing this will like high misscount scores more than ringtone maps, and vice versa - const double limit_of_proportion = 0.727; - // how quickly does the player lose the ability to aim normally at the limit of proportion - // increasing this has a similar effect as increasing the limit of proportion, but it changes how significant the effect is across maps - const double breakdown_rate = 30; - double adjustedDeviation = baseDeviation + Math.Exp(breakdown_rate * (baseDeviation - limit_of_proportion)); + double baseDeviation = difficulty / skill; + // at what point does the player lose the ability to aim normally + // increasing this will like high misscount scores more than ringtone maps, and vice versa + const double limit_of_proportion = 0.727; + // how quickly does the player lose the ability to aim normally at the limit of proportion + // increasing this has a similar effect as increasing the limit of proportion, but it changes how significant the effect is across maps + const double breakdown_rate = 30; + double adjustedDeviation = baseDeviation + Math.Exp(breakdown_rate * (baseDeviation - limit_of_proportion)); return DifficultyCalculationUtils.Erf(1 / (Math.Sqrt(2) * adjustedDeviation)); } - private double strainDecay(double ms) => Math.Pow(0.15, ms / 1000); + private double strainDecay(double ms) => Math.Pow(0.2, ms / 1000); protected override double StrainValueAt(DifficultyHitObject current) { @@ -65,18 +65,6 @@ protected override double StrainValueAt(DifficultyHitObject current) double agilityDifficulty = AgilityEvaluator.EvaluateDifficultyOf(current) * skillMultiplierAgility; double flowDifficulty = FlowAimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplierFlow; - if (Mods.Any(m => m is OsuModTouchDevice)) - { - 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)) - { - agilityDifficulty *= 0.3; - } - double totalDifficulty = calculateTotalValue(snapDifficulty, agilityDifficulty, flowDifficulty); currentStrain *= decay; @@ -93,11 +81,24 @@ private double calculateTotalValue(double snapDifficulty, double agilityDifficul // 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 combinedSnapDifficulty = DifficultyCalculationUtils.Norm(combinedSnapNormExponent, snapDifficulty, agilityDifficulty); double pSnap = calculateSnapFlowProbability(flowDifficulty / combinedSnapDifficulty); double pFlow = 1 - pSnap; + if (Mods.Any(m => m is OsuModTouchDevice)) + { + // we don't adjust agility here since agility represents TD difficulty in a decent enough way + snapDifficulty = Math.Pow(snapDifficulty, 0.89); + combinedSnapDifficulty = DifficultyCalculationUtils.Norm(combinedSnapNormExponent, snapDifficulty, agilityDifficulty); + } + + if (Mods.Any(m => m is OsuModRelax)) + { + combinedSnapDifficulty *= 0.75; + flowDifficulty *= 0.6; + } + double totalDifficulty = combinedSnapDifficulty * pSnap + flowDifficulty * pFlow; double totalStrain = totalDifficulty * skillMultiplierTotal; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs index c548da5b8d76..414cf1f1f9b8 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs @@ -37,9 +37,11 @@ protected override double ObjectDifficultyOf(DifficultyHitObject current) { objectList.Add(current); - currentDifficulty *= strainDecay(current.DeltaTime); + double decay = strainDecay(current.DeltaTime); - currentDifficulty += ReadingEvaluator.EvaluateDifficultyOf(current, hasHiddenMod) * skillMultiplier; + currentDifficulty *= decay; + + currentDifficulty += ReadingEvaluator.EvaluateDifficultyOf(current, hasHiddenMod) * (1 - decay) * skillMultiplier; return currentDifficulty; } diff --git a/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs new file mode 100644 index 000000000000..a45af7453983 --- /dev/null +++ b/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs @@ -0,0 +1,277 @@ +// 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 System.Collections.Generic; +using System.Linq; +using osu.Framework.Extensions; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Difficulty.Skills +{ + /// + /// Similar to , but instead of strains having a fixed length, strains can be any length. + /// A new is created for each . + /// + public abstract class VariableLengthStrainSkill : Skill + { + /// + /// The weight by which each strain value decays. + /// + protected virtual double DecayWeight => 0.9; + + /// + /// The maximum length of each strain section. + /// + protected virtual int MaxSectionLength => 400; + + private double currentSectionPeak; // We also keep track of the peak strain in the current section. + private double currentSectionBegin; + private double currentSectionEnd; + + /// + /// The number of `MaxSectionLength` sections calculated such that enough of the difficulty value is preserved. + /// WARNING: This should be overridden if strains are ever used outside of , + /// or if is overridden to not use the default geometric sum. This should be removed + /// in the future when a better memory-saving technique is implemented. + /// + private double maxStoredSections => 11 / (1 - DecayWeight); + + private readonly List strainPeaks = new List(); + + private double totalLength; + + /// + /// Stores previous strains so that, if a high difficulty hit object is followed by a lower + /// difficulty hit object, the high difficulty hit object gets a full strain instead of being cut short. + /// + private readonly List<(double StrainValue, double StartTime)> queuedStrains = new List<(double, double)>(); + + protected VariableLengthStrainSkill(Mod[] mods) + : base(mods) + { + } + + /// + /// Returns the strain value at . This value is calculated with or without respect to previous objects. + /// + protected abstract double StrainValueAt(DifficultyHitObject current); + + /// + /// Process a and update current strain values accordingly. + /// + protected sealed override double ProcessInternal(DifficultyHitObject current) + { + // If we're on the first object, set up the first section to end `MaxSectionLength` after it. + if (current.Index == 0) + { + currentSectionBegin = current.StartTime; + currentSectionEnd = currentSectionBegin + MaxSectionLength; + + // No work is required for first object after calculating difficulty + currentSectionPeak = StrainValueAt(current); + return currentSectionPeak; + } + + backfillPeaks(current); + + double currentStrain = StrainValueAt(current); + + // If the current strain is larger than the current peak, begin a new peak + // Otherwise, add the current strain to the queue + if (currentStrain > currentSectionPeak) + { + // Clear the queue since none of the strains inside of it will be contributing to the difficulty. + queuedStrains.Clear(); + + // End the current section with the new peak + saveCurrentPeak(current.StartTime - currentSectionBegin); + + // Set up the new section to start at the current object with the current strain + currentSectionBegin = current.StartTime; + currentSectionEnd = currentSectionBegin + MaxSectionLength; + currentSectionPeak = currentStrain; + } + else + { + // Empty the queue of smaller elements as they won't be relevant to difficulty + while (queuedStrains.Count > 0 && queuedStrains[^1].StrainValue < currentStrain) + queuedStrains.RemoveAt(queuedStrains.Count - 1); + + queuedStrains.Add((currentStrain, current.StartTime)); + } + + return currentStrain; + } + + /// + /// Fills the space between the end of the current section and the current object, if there is any. + /// + /// The object who's is backfilled to. + private void backfillPeaks(DifficultyHitObject current) + { + // If the current object starts after the current section ends + // then we want to start a new section without any harsh drop-off. + // If we have previous strains that influence the current difficulty we will prioritise those first. + // Otherwise, start with the current object's initial strain. + while (current.StartTime > currentSectionEnd) + { + // Save the current peak, marking the end of the section. + saveCurrentPeak(currentSectionEnd - currentSectionBegin); + currentSectionBegin = currentSectionEnd; + + // If we have any strains queued, then we will use those until the object falls into the new section. + if (queuedStrains.Count > 0) + { + (double strain, double startTime) = queuedStrains[0]; + queuedStrains.RemoveAt(0); + + // We want the section to end `MaxSectionLength` after the strain we're using as an influence. + // This effectively means the queued strain will exist in its own section if the gap between the queued strain and current object is large enough. + // This is required to make sure there's no harsh difficulty difference between 2 sections if there was a large gap. + currentSectionEnd = startTime + MaxSectionLength; + startNewSectionFrom(currentSectionBegin, current); + + // If the current object's peak was higher, we don't want to override it with a lower strain. + // Only use the queued strain if it contributes more difficulty. + currentSectionPeak = Math.Max(currentSectionPeak, strain); + } + // If the queue is empty then we should start the section from the current object instead. + // The queue can be empty if we're starting off of the back of a new peak, or if we drained through all the queue + // and the current object is still later than the section end. + else + { + // We don't have any prior strains to take as a reference, so end the new section `MaxSectionLength` after it starts. + currentSectionEnd = currentSectionBegin + MaxSectionLength; + startNewSectionFrom(currentSectionBegin, current); + } + } + } + + /// + /// Saves the current peak strain level to the list of strain peaks, which will be used to calculate an overall difficulty. + /// + private void saveCurrentPeak(double sectionLength) + { + strainPeaks.AddInPlace(new StrainPeak(currentSectionPeak, sectionLength)); + totalLength += sectionLength; + + // Remove from the back of our strain peaks if there's any which are too deep to contribute to difficulty. + // `maxStoredSections` dictates for us how many sections will preserve at least 99.999% of the difficulty value. + while (totalLength > maxStoredSections * MaxSectionLength) + { + totalLength -= strainPeaks[0].SectionLength; + strainPeaks.RemoveAt(0); + } + } + + /// + /// Sets the initial strain level for a new section. + /// + /// The beginning of the new section in milliseconds. + /// The current hit object. + private void startNewSectionFrom(double time, DifficultyHitObject current) + { + // The maximum strain of the new section is not zero by default + // This means we need to capture the strain level at the beginning of the new section, and use that as the initial peak level. + currentSectionPeak = CalculateInitialStrain(time, current); + } + + /// + /// Retrieves the peak strain at a point in time. + /// + /// The time to retrieve the peak strain at. + /// The current hit object. + /// The peak strain. + protected abstract double CalculateInitialStrain(double time, DifficultyHitObject current); + + /// + /// Returns a live enumerable of the peak strains for each section of the beatmap, + /// including the peak of the current section. + /// + public IEnumerable GetCurrentStrainPeaks() => strainPeaks.Append(new StrainPeak(currentSectionPeak, currentSectionEnd - currentSectionBegin)); + + /// + /// Returns the calculated difficulty value representing all s that have been processed up to this point. + /// + public override double DifficultyValue() + { + double difficulty = 0; + + // Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). + // These sections will not contribute to the difficulty. + var peaks = GetCurrentStrainPeaks().Where(p => p.Value > 0); + + List strains = peaks.OrderByDescending(p => (p.Value, p.SectionLength)).ToList(); + + // Time is measured in units of strains + double time = 0; + + // Difficulty is a continuous weighted sum of the sorted strains + for (int i = 0; i < strains.Count; i++) + { + /* Weighting function can be thought of as: + b + ∫ DecayWeight^x dx + a + where a = startTime and b = endTime + + Technically, the function below has been slightly modified from the equation above. + The real function would be + double weight = Math.Pow(DecayWeight, startTime) - Math.Pow(DecayWeight, endTime)) + ... + return difficulty / Math.Log(1 / DecayWeight) + E.g. for a DecayWeight of 0.9, we're multiplying by 10 instead of 9.49122... + + This change makes it so that a map composed solely of MaxSectionLength chunks will have the exact same value when summed in this class and StrainSkill. + Doing this ensures the relationship between strain values and difficulty values remains the same between the two classes. + */ + double startTime = time; + double endTime = time + strains[i].SectionLength; + + double weight = Math.Pow(DecayWeight, startTime) - Math.Pow(DecayWeight, endTime); + + difficulty += strains[i].Value * weight; + time = endTime; + } + + return difficulty / (1 - DecayWeight); + } + + /// + /// Calculates the number of strains weighted against the top strain. + /// The result is scaled by clock rate as it affects the total number of strains. + /// + public virtual double CountTopWeightedStrains(double difficultyValue) + { + if (ObjectDifficulties.Count == 0) + return 0.0; + + double consistentTopStrain = difficultyValue * (1 - DecayWeight); // What would the top strain be if all strain values were identical + + if (consistentTopStrain == 0) + return ObjectDifficulties.Count; + + // Use a weighted sum of all strains. Constants are arbitrary and give nice values + return ObjectDifficulties.Sum(s => 1.1 / (1 + Math.Exp(-10 * (s / consistentTopStrain - 0.88)))); + } + + /// + /// Used to store the difficulty of a section of a map. + /// + public readonly struct StrainPeak : IComparable + { + public StrainPeak(double value, double sectionLength) + { + Value = value; + SectionLength = Math.Round(sectionLength); + } + + public double Value { get; } + public double SectionLength { get; } + + public int CompareTo(StrainPeak other) => Value.CompareTo(other.Value); + } + } +} diff --git a/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs b/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs index 16434406b509..e98c34d603b0 100644 --- a/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs +++ b/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs @@ -23,16 +23,16 @@ public abstract class DifficultyCalculatorTest protected abstract string ResourceAssembly { get; } - protected void Test(double expectedStarRating, int expectedMaxCombo, string name, params Mod[] mods) + protected void Test(double? expectedStarRating, int expectedMaxCombo, string name, params Mod[] mods) { - var attributes = CreateDifficultyCalculator(getBeatmap(name)).Calculate(mods); + var attributes = CreateDifficultyCalculator(GetBeatmap(name)).Calculate(mods); // Platform-dependent math functions (Pow, Cbrt, Exp, etc) may result in minute differences. Assert.That(attributes.StarRating, Is.EqualTo(expectedStarRating).Within(0.00001)); Assert.That(attributes.MaxCombo, Is.EqualTo(expectedMaxCombo)); } - private IWorkingBeatmap getBeatmap(string name) + protected IWorkingBeatmap GetBeatmap(string name) { using (var resStream = openResource($"{resource_namespace}.{name}.osu")) using (var stream = new LineBufferedReader(resStream))