diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs index 312d3d5e9a64..c7851cc12f02 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs @@ -19,13 +19,13 @@ public EmptyFreeformDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap b { } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) { return new DifficultyAttributes(mods, 0); } - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty(); + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) => Enumerable.Empty(); - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => Array.Empty(); + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => Array.Empty(); } } diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs index f6addab279e7..852576958446 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs @@ -19,13 +19,13 @@ public PippidonDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatma { } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) { return new DifficultyAttributes(mods, 0); } - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty(); + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) => Enumerable.Empty(); - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => Array.Empty(); + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => Array.Empty(); } } diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs index a4dc1762d520..17139218a52d 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs @@ -19,13 +19,13 @@ public EmptyScrollingDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap { } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) { return new DifficultyAttributes(mods, 0); } - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty(); + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) => Enumerable.Empty(); - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => Array.Empty(); + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => Array.Empty(); } } diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs index f6addab279e7..852576958446 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs @@ -19,13 +19,13 @@ public PippidonDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatma { } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) { return new DifficultyAttributes(mods, 0); } - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty(); + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) => Enumerable.Empty(); - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => Array.Empty(); + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => Array.Empty(); } } diff --git a/osu.Game.Benchmarks/BenchmarkMathPow.cs b/osu.Game.Benchmarks/BenchmarkMathPow.cs new file mode 100644 index 000000000000..0efcce095576 --- /dev/null +++ b/osu.Game.Benchmarks/BenchmarkMathPow.cs @@ -0,0 +1,36 @@ +// 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 BenchmarkDotNet.Attributes; +using osu.Game.Rulesets.Difficulty.Utils; + +namespace osu.Game.Benchmarks +{ + public class BenchmarkMathPow : BenchmarkTest + { + [Params(0, 1, 1.25, 2.0, 3, 5)] + public double Exponent { get; set; } + + [Benchmark] + public double MathPow() + { + return Math.Pow(1.299995, Exponent); + } + + [Benchmark] + public double DiffUtilsPowDouble() + { + return DiffUtils.Pow(1.299995, Exponent); + } + + [Benchmark] + public double DiffUtilsPowInt() + { + if ((int)Exponent != Exponent) + throw new NotSupportedException(); + + return DiffUtils.Pow(1.299995, (int)Exponent); + } + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs index 6a70173c4a1a..2a9c2cee64ac 100644 --- a/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs @@ -14,11 +14,11 @@ public class CatchDifficultyCalculatorTest : DifficultyCalculatorTest { protected override string ResourceAssembly => "osu.Game.Rulesets.Catch.Tests"; - [TestCase(4.0505463516206195d, 127, "diffcalc-test")] + [TestCase(4.039861734717169d, 127, "diffcalc-test")] public void Test(double expectedStarRating, int expectedMaxCombo, string name) => base.Test(expectedStarRating, expectedMaxCombo, name); - [TestCase(5.1696411260785498d, 127, "diffcalc-test")] + [TestCase(5.1527173897800873d, 127, "diffcalc-test")] public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new CatchModDoubleTime()); diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs index dd69b5de128a..9260ef44da23 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs @@ -15,6 +15,7 @@ using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mods; +using osu.Game.Utils; namespace osu.Game.Rulesets.Catch.Difficulty { @@ -22,8 +23,6 @@ public class CatchDifficultyCalculator : DifficultyCalculator { private const double difficulty_multiplier = 4.59; - private float halfCatcherWidth; - public override int Version => 20251020; public CatchDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) @@ -31,7 +30,7 @@ public CatchDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) { } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) { if (beatmap.HitObjects.Count == 0) return new CatchDifficultyAttributes { Mods = mods }; @@ -46,11 +45,18 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat return attributes; } - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) { CatchHitObject? lastObject = null; - List objects = new List(); + List objects = new List(beatmap.HitObjects.Count); + + double clockRate = ModUtils.CalculateRateWithMods(mods); + + float halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.Difficulty) * 0.5f; + + // For circle sizes above 5.5, reduce the catcher width further to simulate imperfect gameplay. + halfCatcherWidth *= 1 - (Math.Max(0, beatmap.Difficulty.CircleSize - 5.5f) * 0.0625f); // In 2B beatmaps, it is possible that a normal Fruit is placed in the middle of a JuiceStream. foreach (var hitObject in CatchBeatmap.GetPalpableObjects(beatmap.HitObjects)) @@ -68,16 +74,11 @@ protected override IEnumerable CreateDifficultyHitObjects(I return objects; } - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) { - halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.Difficulty) * 0.5f; - - // For circle sizes above 5.5, reduce the catcher width further to simulate imperfect gameplay. - halfCatcherWidth *= 1 - (Math.Max(0, beatmap.Difficulty.CircleSize - 5.5f) * 0.0625f); - return new Skill[] { - new Movement(mods, halfCatcherWidth, clockRate), + new Movement(mods), }; } diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs index 4b8bcb435cd0..a9c6ce3dd729 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs @@ -6,6 +6,7 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Scoring.Legacy; @@ -37,7 +38,7 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s numMiss = score.GetCountMiss() ?? 0; // HitResult.Miss PLUS HitResult.LargeTickMiss // We are heavily relying on aim in catch the beat - double value = Math.Pow(5.0 * Math.Max(1.0, catchAttributes.StarRating / 0.0049) - 4.0, 2.0) / 100000.0; + double value = DiffUtils.Pow(5.0 * Math.Max(1.0, catchAttributes.StarRating / 0.0049) - 4.0, 2.0) / 100000.0; // Longer maps are worth more. "Longer" means how many hits there are which can contribute to combo int numTotalHits = totalComboHits(); @@ -47,11 +48,11 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s (numTotalHits > 2500 ? Math.Log10(numTotalHits / 2500.0) * 0.475 : 0.0); value *= lengthBonus; - value *= Math.Pow(0.97, numMiss); + value *= DiffUtils.Pow(0.97, numMiss); // Combo scaling if (catchAttributes.MaxCombo > 0) - value *= Math.Min(Math.Pow(score.MaxCombo, 0.35) / Math.Pow(catchAttributes.MaxCombo, 0.35), 1.0); + value *= Math.Min(DiffUtils.Pow(score.MaxCombo, 0.35) / DiffUtils.Pow(catchAttributes.MaxCombo, 0.35), 1.0); var difficulty = score.BeatmapInfo!.Difficulty.Clone(); @@ -86,7 +87,7 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s if (score.Mods.Any(m => m is ModFlashlight)) value *= 1.35 * lengthBonus; - value *= Math.Pow(accuracy(), 5.5); + value *= DiffUtils.Pow(accuracy(), 5.5); if (score.Mods.Any(m => m is ModNoFail)) value *= Math.Max(0.90, 1.0 - 0.02 * numMiss); diff --git a/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs b/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs index 618b18394341..12d455478a4d 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs @@ -4,6 +4,7 @@ using System; using osu.Game.Rulesets.Catch.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; namespace osu.Game.Rulesets.Catch.Difficulty.Evaluators { @@ -11,15 +12,19 @@ public static class MovementEvaluator { private const double direction_change_bonus = 21.0; - public static double EvaluateDifficultyOf(DifficultyHitObject current, double catcherSpeedMultiplier) + public static double EvaluateDifficultyOf(DifficultyHitObject current) { var catchCurrent = (CatchDifficultyHitObject)current; var catchLast = (CatchDifficultyHitObject)current.Previous(0); var catchLastLast = (CatchDifficultyHitObject)current.Previous(1); + // In catch, clockrate adjustments do not only affect the timings of hitobjects, + // but also the speed of the player's catcher, which has an impact on difficulty + double catcherSpeedMultiplier = current.ClockRate; + double weightedStrainTime = catchCurrent.StrainTime + 13 + (3 / catcherSpeedMultiplier); - double distanceAddition = (Math.Pow(Math.Abs(catchCurrent.DistanceMoved), 1.3) / 510); + double distanceAddition = (DiffUtils.Pow(Math.Abs(catchCurrent.DistanceMoved), 1.3) / 510); double sqrtStrain = Math.Sqrt(weightedStrainTime); double edgeDashBonus = 0; @@ -32,7 +37,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, double ca double bonusFactor = Math.Min(50, Math.Abs(catchCurrent.DistanceMoved)) / 50; double antiflowFactor = Math.Max(Math.Min(70, Math.Abs(catchLast.DistanceMoved)) / 70, 0.38); - distanceAddition += direction_change_bonus / Math.Sqrt(catchLast.StrainTime + 16) * bonusFactor * antiflowFactor * Math.Max(1 - Math.Pow(weightedStrainTime / 1000, 3), 0); + distanceAddition += direction_change_bonus / Math.Sqrt(catchLast.StrainTime + 16) * bonusFactor * antiflowFactor * Math.Max(1 - DiffUtils.Pow(weightedStrainTime / 1000, 3), 0); } // Base bonus for every movement, giving some weight to streams. @@ -40,6 +45,30 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, double ca / (CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH * 6) / sqrtStrain; } + // Linear spacing nerf. + double linearSpacingCount = 0; + + for (int i = 0; i < Math.Min(current.Index, 10); i++) + { + var catchPrevObj = (CatchDifficultyHitObject)catchCurrent.Previous(i); + + // Only same direction movements matter as they do not take any additional inputs. + if (Math.Sign(catchCurrent.DistanceMoved) != Math.Sign(catchPrevObj.DistanceMoved) || catchCurrent.DistanceMoved == 0 || catchPrevObj.DistanceMoved == 0) + break; + + double currentSpacing = Math.Abs(catchCurrent.DistanceMoved / catchCurrent.StrainTime); + double prevSpacing = Math.Abs(catchPrevObj.DistanceMoved / catchPrevObj.StrainTime); + + double relativeDifference = Math.Abs(currentSpacing / prevSpacing - 1); + + if (relativeDifference > 0.05) + break; + + linearSpacingCount++; + } + + distanceAddition *= DiffUtils.Pow(0.7, linearSpacingCount); + // Bonus for edge dashes. if (catchCurrent.LastObject.DistanceToHyperDash <= 20.0f) { @@ -47,7 +76,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, double ca edgeDashBonus += 5.7; distanceAddition *= 1.0 + edgeDashBonus * ((20 - catchCurrent.LastObject.DistanceToHyperDash) / 20) - * Math.Pow((Math.Min(catchCurrent.StrainTime * catcherSpeedMultiplier, 265) / 265), 1.5); // Edge Dashes are easier at lower ms values + * DiffUtils.Pow((Math.Min(catchCurrent.StrainTime * catcherSpeedMultiplier, 265) / 265), 1.5); // Edge Dashes are easier at lower ms values } // There is an edge case where horizontal back and forth sliders create "buzz" patterns which are repeated "movements" with a distance lower than diff --git a/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs b/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs index 90055b9aa384..332ef7e17bf9 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs @@ -17,28 +17,14 @@ public class Movement : StrainDecaySkill protected override int SectionLength => 750; - protected readonly float HalfCatcherWidth; - - /// - /// The speed multiplier applied to the player's catcher. - /// - private readonly double catcherSpeedMultiplier; - - public Movement(Mod[] mods, float halfCatcherWidth, double clockRate) + public Movement(Mod[] mods) : base(mods) { - HalfCatcherWidth = halfCatcherWidth; - - // In catch, clockrate adjustments do not only affect the timings of hitobjects, - // but also the speed of the player's catcher, which has an impact on difficulty - // TODO: Support variable clockrates caused by mods such as ModTimeRamp - // (perhaps by using IApplicableToRate within the CatchDifficultyHitObject constructor to set a catcher speed for each object before processing) - catcherSpeedMultiplier = clockRate; } protected override double StrainValueOf(DifficultyHitObject current) { - return MovementEvaluator.EvaluateDifficultyOf(current, catcherSpeedMultiplier); + return MovementEvaluator.EvaluateDifficultyOf(current); } } } diff --git a/osu.Game.Rulesets.Mania/Difficulty/Evaluators/OverallStrainEvaluator.cs b/osu.Game.Rulesets.Mania/Difficulty/Evaluators/OverallStrainEvaluator.cs index 97782f764485..526e6a42f2e7 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/Evaluators/OverallStrainEvaluator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/Evaluators/OverallStrainEvaluator.cs @@ -53,7 +53,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current) // 0.0 +--------+-+---------------> Release Difference / ms // release_threshold if (isOverlapping) - holdAddition = DifficultyCalculationUtils.Logistic(x: closestEndTime, multiplier: 0.27, midpointOffset: release_threshold); + holdAddition = DiffUtils.Logistic(x: closestEndTime, multiplier: 0.27, midpointOffset: release_threshold); return (1 + holdAddition) * holdFactor; } diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs index bcf16e68088c..9b8e79edd167 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs @@ -19,6 +19,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; +using osu.Game.Utils; namespace osu.Game.Rulesets.Mania.Difficulty { @@ -36,7 +37,7 @@ public ManiaDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) isForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.MatchesOnlineID(ruleset); } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) { if (beatmap.HitObjects.Count == 0) return new ManiaDifficultyAttributes { Mods = mods }; @@ -62,14 +63,16 @@ private static int maxComboForObject(HitObject hitObject) return 1; } - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) { var sortedObjects = beatmap.HitObjects.ToArray(); int totalColumns = ((ManiaBeatmap)beatmap).TotalColumns; + double clockRate = ModUtils.CalculateRateWithMods(mods); + LegacySortHelper.Sort(sortedObjects, Comparer.Create((a, b) => (int)Math.Round(a.StartTime) - (int)Math.Round(b.StartTime))); - List objects = new List(); + List objects = new List(beatmap.HitObjects.Count); List[] perColumnObjects = new List[totalColumns]; for (int column = 0; column < totalColumns; column++) @@ -88,7 +91,7 @@ protected override IEnumerable CreateDifficultyHitObjects(I // Sorting is done in CreateDifficultyHitObjects, since the full list of hitobjects is required. protected override IEnumerable SortObjects(IEnumerable input) => input; - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[] + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[] { new Strain(mods, ((ManiaBeatmap)Beatmap).TotalColumns) }; diff --git a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs index 037b7e35110a..5b682fdd9fae 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs @@ -4,6 +4,7 @@ using System; 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; @@ -52,6 +53,6 @@ protected override double CalculateInitialStrain(double offset, DifficultyHitObj + 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); + => value * DiffUtils.Pow(decayBase, deltaTime / 1000); } } diff --git a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs index e7a6d8ecffc2..0d648ae2a0be 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs @@ -15,25 +15,49 @@ public class OsuDifficultyCalculatorTest : DifficultyCalculatorTest { protected override string ResourceAssembly => "osu.Game.Rulesets.Osu.Tests"; - [TestCase(6.6232533278125061d, 239, "diffcalc-test")] - [TestCase(1.5045783545699611d, 54, "zero-length-sliders")] - [TestCase(0.43333836671191595d, 4, "very-fast-slider")] - [TestCase(0.13841532030395723d, 2, "nan-slider")] + [TestCase(6.5243170265483581d, 239, "diffcalc-test")] + [TestCase(1.3280410795791415d, 54, "zero-length-sliders")] + [TestCase(0.40867325147697559d, 4, "very-fast-slider")] + [TestCase(0.87058175794353554d, 6, "nan-slider")] public void Test(double expectedStarRating, int expectedMaxCombo, string name) => base.Test(expectedStarRating, expectedMaxCombo, name); - [TestCase(9.6491691624112761d, 239, "diffcalc-test")] - [TestCase(1.756936832498702d, 54, "zero-length-sliders")] - [TestCase(0.57771197086735004d, 4, "very-fast-slider")] + [TestCase(9.4677607900646308d, 239, "diffcalc-test")] + [TestCase(1.6856612715618886d, 54, "zero-length-sliders")] + [TestCase(0.53588473186572561d, 4, "very-fast-slider")] public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime()); - [TestCase(6.6232533278125061d, 239, "diffcalc-test")] - [TestCase(1.5045783545699611d, 54, "zero-length-sliders")] - [TestCase(0.43333836671191595d, 4, "very-fast-slider")] + [TestCase(6.5243170265483581d, 239, "diffcalc-test")] + [TestCase(1.3280410795791415d, 54, "zero-length-sliders")] + [TestCase(0.40867325147697559d, 4, "very-fast-slider")] 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.Tests/Resources/Testing/Beatmaps/nan-slider-expected-conversion.json b/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/nan-slider-expected-conversion.json index 86a4a278f14a..1ce08dc13009 100755 --- a/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/nan-slider-expected-conversion.json +++ b/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/nan-slider-expected-conversion.json @@ -1 +1 @@ -{"Mappings":[{"StartTime":77497.0,"Objects":[{"StartTime":77497.0,"EndTime":77497.0,"X":298.0,"Y":290.0},{"StartTime":77533.0,"EndTime":77533.0,"X":276.162567,"Y":293.0336}]}]} \ No newline at end of file +{"Mappings":[{"StartTime":76911.0,"Objects":[{"StartTime":76911.0,"EndTime":76911.0,"X":283.402,"Y":275.402}]},{"StartTime":77053.0,"Objects":[{"StartTime":77053.0,"EndTime":77053.0,"X":287.0515,"Y":279.0515}]},{"StartTime":77196.0,"Objects":[{"StartTime":77196.0,"EndTime":77196.0,"X":290.701019,"Y":282.701019}]},{"StartTime":77339.0,"Objects":[{"StartTime":77339.0,"EndTime":77339.0,"X":294.3505,"Y":286.3505}]},{"StartTime":77497.0,"Objects":[{"StartTime":77497.0,"EndTime":77497.0,"X":298.0,"Y":290.0,"StackOffset":{"X":0.0,"Y":0.0}},{"StartTime":77533.0,"EndTime":77533.0,"X":276.162567,"Y":293.0336,"StackOffset":{"X":0.0,"Y":0.0}}]}]} \ No newline at end of file diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/nan-slider.osu b/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/nan-slider.osu index fa545a761484..84200f9c4aa3 100755 --- a/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/nan-slider.osu +++ b/osu.Game.Rulesets.Osu.Tests/Resources/Testing/Beatmaps/nan-slider.osu @@ -9,10 +9,15 @@ SliderMultiplier:2 SliderTickRate:1 [TimingPoints] +76911,285.7142857142857,4,1,0,100,1,8 77211,-100,4,3,50,70,0,0 77497,8.40402703648439,4,3,51,70,1,8 77497,NaN,4,3,51,70,0,8 77498,285.714285714286,4,3,51,70,1,0 [HitObjects] +298,290,76911,5,0,1:0:0:0: +298,290,77053,1,0,1:0:0:0: +298,290,77196,1,0,1:0:0:0: +298,290,77339,1,0,1:0:0:0: 298,290,77497,6,0,B|234:298|192:279|192:279|180:299|180:299|205:311|238:318|238:318|230:347|217:371|217:371|137:370|80:340|80:340|65:259|73:143|102:68|102:68|149:49|199:34|199:34|213:54|213:54|267:38|324:40|324:40|332:18|332:18|385:20|435:27|435:27|480:93|517:204|521:286|521:286|474:329|396:350|396:350|377:329|363:302|363:302|393:287|415:271|415:271|398:254|398:254|362:282|299:290,1,1723.66345596313,10|0,1:0|3:0,3:0:0:0: diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs new file mode 100644 index 000000000000..bd5204faaf8d --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/AgilityEvaluator.cs @@ -0,0 +1,43 @@ +// 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; + +namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim +{ + public static class AgilityEvaluator + { + /// + /// Evaluates the difficulty of fast aiming + /// + public static double EvaluateDifficultyOf(DifficultyHitObject current) + { + if (current.BaseObject is Spinner) + return 0; + + const double distance_cap = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.2; // 1.2 circles distance between centers + + var osuCurrObj = (OsuDifficultyHitObject)current; + var osuPrevObj = current.Index > 0 ? (OsuDifficultyHitObject)current.Previous(0) : null; + + double travelDistance = osuPrevObj?.LazyTravelDistance ?? 0; + double distance = travelDistance + osuCurrObj.LazyJumpDistance; + + double distanceScaled = Math.Min(distance, distance_cap) / distance_cap; + + double agilityDifficulty = distanceScaled * 1000 / osuCurrObj.AdjustedDeltaTime; + + agilityDifficulty *= DiffUtils.Pow(osuCurrObj.SmallCircleBonus, 1.5); + + agilityDifficulty *= highBpmBonus(osuCurrObj.AdjustedDeltaTime); + + return agilityDifficulty; + } + + private static double highBpmBonus(double ms) => 1 / (1 - DiffUtils.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 new file mode 100644 index 000000000000..c7f17b3f8c87 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/FlowAimEvaluator.cs @@ -0,0 +1,132 @@ +// 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.Aim +{ + public static class FlowAimEvaluator + { + /// + /// 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; + + const double velocity_change_multiplier = 0.52; + + var osuCurrObj = (OsuDifficultyHitObject)current; + var osuLastObj = (OsuDifficultyHitObject)current.Previous(0); + var osuLastLastObj = (OsuDifficultyHitObject)current.Previous(1); + var osuNextObj = (OsuDifficultyHitObject)current.Next(); + + // Calculate if current/previous sliders are overlapping with the next object (2B sliders) + bool isCurrentSlider2B = osuCurrObj.EndTime - osuCurrObj.StartTime > osuNextObj?.AdjustedDeltaTime; + bool isLastSlider2B = osuLastObj.EndTime - osuLastObj.StartTime > osuCurrObj.AdjustedDeltaTime; + + double currDistance = withSliderTravelDistance && !isCurrentSlider2B ? osuCurrObj.LazyJumpDistance : osuCurrObj.JumpDistance; + double prevDistance = withSliderTravelDistance && !isLastSlider2B ? osuLastObj.LazyJumpDistance : osuLastObj.JumpDistance; + + double currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime; + + if (osuLastObj.BaseObject is Slider && !isLastSlider2B && 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. + // 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, + 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); + + // Low angular velocity flow (angles are consistent) is easier to follow than erratic flow + flowDifficulty *= 0.8 + Math.Sqrt(angularVelocity / 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) + { + // Acute angles are also hard to flow + flowDifficulty += currVelocity * + SnapAimEvaluator.CalcAngleAcuteness(osuCurrObj.Angle.Value) * + overlappedNotesWeight; + } + + 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 (osuCurrObj.BaseObject is Slider && withSliderTravelDistance) + { + // 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 + 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 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 - DiffUtils.Pow(Math.Max(distance - objectRadius, 0) / objectRadius, 2), 0, 1); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs new file mode 100644 index 000000000000..15ea3a3e06e7 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Aim/SnapAimEvaluator.cs @@ -0,0 +1,227 @@ +// 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; + +namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim +{ + public static class SnapAimEvaluator + { + /// + /// Evaluates the difficulty of aiming the current object, based on: + /// + /// cursor velocity to the current object, + /// angle difficulty, + /// sharp velocity increases, + /// and slider difficulty. + /// + /// + public static double EvaluateDifficultyOf(DifficultyHitObject current, bool withSliderTravelDistance) + { + if (current.BaseObject is Spinner || current.Index <= 1 || current.Previous(0).BaseObject is Spinner) + return 0; + + const double wide_angle_multiplier = 9.67; + const double acute_angle_multiplier = 2.41; + const double slider_multiplier = 1.5; + const double velocity_change_multiplier = 0.9; + + // WARNING: Increasing this multiplier beyond 1.02 reduces difficulty as distance increases. Refer to the desmos link above the wiggle bonus calculation + const double wiggle_multiplier = 1.02; + + var osuCurrObj = (OsuDifficultyHitObject)current; + var osuLastObj = (OsuDifficultyHitObject)current.Previous(0); + var osuLast2Obj = (OsuDifficultyHitObject)current.Previous(2); + var osuNextObj = (OsuDifficultyHitObject)current.Next(); + + const int radius = OsuDifficultyHitObject.NORMALISED_RADIUS; + const int diameter = OsuDifficultyHitObject.NORMALISED_DIAMETER; + + // Calculate if current/previous sliders are overlapping with the next object (2B sliders) + bool isCurrentSlider2B = osuCurrObj.EndTime - osuCurrObj.StartTime > osuNextObj?.AdjustedDeltaTime; + bool isLastSlider2B = osuLastObj.EndTime - osuLastObj.StartTime > osuCurrObj.AdjustedDeltaTime; + + // Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle. + double currDistance = withSliderTravelDistance && !isCurrentSlider2B ? osuCurrObj.LazyJumpDistance : osuCurrObj.JumpDistance; + double currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime; + + // But 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 && !isLastSlider2B && withSliderTravelDistance) + { + double sliderDistance = osuLastObj.LazyTravelDistance + osuCurrObj.LazyJumpDistance; + + currVelocity = Math.Max(currVelocity, sliderDistance / osuCurrObj.AdjustedDeltaTime); + } + + double prevDistance = withSliderTravelDistance && !isLastSlider2B ? osuLastObj.LazyJumpDistance : osuLastObj.JumpDistance; + double prevVelocity = prevDistance / osuLastObj.AdjustedDeltaTime; + + double snapDifficulty = currVelocity; // Start difficulty with regular velocity. + + // Penalize angle repetition. + snapDifficulty *= 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 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 = CalcAngleAcuteness(currAngle); + + // 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, DiffUtils.Pow(CalcAngleAcuteness(lastAngle), 3))); + + // Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter + acuteAngleBonus *= velocityInfluence * DiffUtils.Smootherstep(DiffUtils.MillisecondsToBPM(osuCurrObj.AdjustedDeltaTime, 2), 300, 400) * + DiffUtils.Smootherstep(currDistance, 0, diameter * 2); + } + + double wideAngleBonus = calcAngleWideness(currAngle); + + // 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, DiffUtils.Pow(calcAngleWideness(lastAngle), 3))); + + // Rescaling velocity for the wide angle bonus + const double wide_angle_time_scale = 1.45; + double wideAngleCurrVelocity = currDistance / DiffUtils.Pow(osuCurrObj.AdjustedDeltaTime, wide_angle_time_scale); + double wideAnglePrevVelocity = prevDistance / DiffUtils.Pow(osuLastObj.AdjustedDeltaTime, wide_angle_time_scale); + + if (osuLastObj.BaseObject is Slider && withSliderTravelDistance) + { + double sliderDistance = osuLastObj.LazyTravelDistance + osuCurrObj.LazyJumpDistance; + wideAngleCurrVelocity = Math.Max(wideAngleCurrVelocity, sliderDistance / DiffUtils.Pow(osuCurrObj.AdjustedDeltaTime, wide_angle_time_scale)); + } + + wideAngleBonus *= Math.Min(wideAngleCurrVelocity, wideAnglePrevVelocity); + + if (osuLast2Obj != null) + { + // If objects just go back and forth through a middle point - don't give as much wide bonus + // Use Previous(2) and Previous(0) because angles calculation is done prevprev-prev-curr, so any object's angle's center point is always the previous object + var lastBaseObject = (OsuHitObject)osuLastObj.BaseObject; + var last2BaseObject = (OsuHitObject)osuLast2Obj.BaseObject; + + float distance = (last2BaseObject.StackedPosition - lastBaseObject.StackedPosition).Length; + + if (distance < 1) + { + wideAngleBonus *= 1 - 0.55 * (1 - distance); + } + } + + // Add in acute angle bonus or wide angle bonus, whichever is larger. + snapDifficulty += 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 + * DiffUtils.Smootherstep(currDistance, radius, diameter) + * DiffUtils.Pow(DiffUtils.ReverseLerp(currDistance, diameter * 3, diameter), 1.8) + * DiffUtils.Smootherstep(currAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)) + * DiffUtils.Smootherstep(prevDistance, radius, diameter) + * DiffUtils.Pow(DiffUtils.ReverseLerp(prevDistance, diameter * 3, diameter), 1.8) + * DiffUtils.Smootherstep(lastAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)); + + snapDifficulty += wiggleBonus * wiggle_multiplier; + } + + if (Math.Max(prevVelocity, currVelocity) != 0) + { + if (withSliderTravelDistance) + { + // We want to use just the object jump without slider velocity when awarding differences + 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(diameter * 1.25 / Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), Math.Abs(prevVelocity - currVelocity)); + + double velocityChangeBonus = overlapVelocityBuff * distRatio; + + // Penalize for rhythm changes. + velocityChangeBonus *= DiffUtils.Pow(Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) / Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), 2); + + snapDifficulty += velocityChangeBonus * velocity_change_multiplier; + } + + // Reward sliders based on velocity. + if (osuCurrObj.BaseObject is Slider && withSliderTravelDistance) + { + double sliderBonus = osuCurrObj.TravelDistance / osuCurrObj.TravelTime; + snapDifficulty += (sliderBonus < 1 ? sliderBonus : DiffUtils.Pow(sliderBonus, 0.75)) * slider_multiplier; + } + + // Apply high circle size bonus + snapDifficulty *= osuCurrObj.SmallCircleBonus; + + snapDifficulty *= highBpmBonus(osuCurrObj.AdjustedDeltaTime); + + return snapDifficulty; + } + + private static double highBpmBonus(double ms) => 1 / (1 - DiffUtils.Pow(0.03, DiffUtils.Pow(ms / 1000, 0.65))); + + private static double vectorAngleRepetition(OsuDifficultyHitObject current, OsuDifficultyHitObject previous) + { + if (current.Angle == null || previous.Angle == null) + return 1; + + const double note_limit = 6; + const double maximum_repetition_nerf = 0.15; + const double maximum_vector_influence = 0.5; + + double constantAngleCount = 0; + + for (int index = 0; index < note_limit; index++) + { + OsuDifficultyHitObject? prevObj = (OsuDifficultyHitObject)current.Previous(index); + + if (prevObj == null) + break; + + // Only consider vectors in the same jump section, stopping to change rhythm ruins momentum + if (Math.Max(current.AdjustedDeltaTime, prevObj.AdjustedDeltaTime) > 1.1 * Math.Min(current.AdjustedDeltaTime, prevObj.AdjustedDeltaTime)) + break; + + if (prevObj.NormalisedVectorAngle != null && current.NormalisedVectorAngle != null) + { + double angleDifference = Math.Abs(current.NormalisedVectorAngle.Value - prevObj.NormalisedVectorAngle.Value); + // Refer to this desmos for tuning, constants need to be precise so that values stay within the range of 0 and 1. + // https://www.desmos.com/calculator/a8jesv5sv2 + constantAngleCount += Math.Cos(8 * Math.Min(double.DegreesToRadians(11.25), angleDifference)); + } + } + + double vectorRepetition = DiffUtils.Pow(Math.Min(0.5 / constantAngleCount, 1), 2); + + double stackFactor = DiffUtils.Smootherstep(current.LazyJumpDistance, 0, OsuDifficultyHitObject.NORMALISED_DIAMETER); + + double currAngle = current.Angle.Value; + double lastAngle = previous.Angle.Value; + + double angleDifferenceAdjusted = Math.Cos(2 * Math.Min(double.DegreesToRadians(45), Math.Abs(currAngle - lastAngle) * stackFactor)); + + double baseNerf = 1 - maximum_repetition_nerf * CalcAngleAcuteness(lastAngle) * angleDifferenceAdjusted; + + return DiffUtils.Pow(baseNerf + (1 - baseNerf) * vectorRepetition * maximum_vector_influence * stackFactor, 2); + } + + private static double calcAngleWideness(double angle) => DiffUtils.Smoothstep(angle, double.DegreesToRadians(40), double.DegreesToRadians(140)); + + public static double CalcAngleAcuteness(double angle) => DiffUtils.Smoothstep(angle, double.DegreesToRadians(140), double.DegreesToRadians(40)); + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs deleted file mode 100644 index dcf8ac0fedbd..000000000000 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ /dev/null @@ -1,172 +0,0 @@ -// 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; - -namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators -{ - public static class AimEvaluator - { - private const double wide_angle_multiplier = 1.5; - private const double acute_angle_multiplier = 2.55; - private const double slider_multiplier = 1.35; - private const double velocity_change_multiplier = 0.75; - private const double wiggle_multiplier = 1.02; - - /// - /// Evaluates the difficulty of aiming the current object, based on: - /// - /// cursor velocity to the current object, - /// angle difficulty, - /// sharp velocity increases, - /// and slider difficulty. - /// - /// - 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); - var osuLast2Obj = (OsuDifficultyHitObject)current.Previous(2); - - const int radius = OsuDifficultyHitObject.NORMALISED_RADIUS; - const int diameter = OsuDifficultyHitObject.NORMALISED_DIAMETER; - - // Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle. - double currVelocity = osuCurrObj.LazyJumpDistance / osuCurrObj.AdjustedDeltaTime; - - // But 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) - { - double travelVelocity = osuLastObj.TravelDistance / osuLastObj.TravelTime; // calculate the slider velocity from slider head to slider end. - double movementVelocity = osuCurrObj.MinimumJumpDistance / osuCurrObj.MinimumJumpTime; // calculate the movement velocity from slider end to current object - - currVelocity = Math.Max(currVelocity, movementVelocity + travelVelocity); // take the larger total combined velocity. - } - - // As above, do the same for the previous hitobject. - double prevVelocity = osuLastObj.LazyJumpDistance / osuLastObj.AdjustedDeltaTime; - - if (osuLastLastObj.BaseObject is Slider && withSliderTravelDistance) - { - double travelVelocity = osuLastLastObj.TravelDistance / osuLastLastObj.TravelTime; - double movementVelocity = osuLastObj.MinimumJumpDistance / osuLastObj.MinimumJumpTime; - - prevVelocity = Math.Max(prevVelocity, movementVelocity + travelVelocity); - } - - double wideAngleBonus = 0; - double acuteAngleBonus = 0; - double sliderBonus = 0; - double velocityChangeBonus = 0; - double wiggleBonus = 0; - - double aimStrain = currVelocity; // Start strain with regular velocity. - - 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); - - if (Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) < 1.25 * Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime)) // If rhythms are the same. - { - acuteAngleBonus = calcAcuteAngleBonus(currAngle); - - // Penalize angle repetition. - 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(osuCurrObj.LazyJumpDistance, diameter, diameter * 2); - } - - wideAngleBonus = calcWideAngleBonus(currAngle); - - // Penalize angle repetition. - wideAngleBonus *= 1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3)); - - // Apply full wide angle bonus for distance more than one diameter - wideAngleBonus *= angleBonus * DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, 0, diameter); - - // 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(osuCurrObj.LazyJumpDistance, radius, diameter) - * Math.Pow(DifficultyCalculationUtils.ReverseLerp(osuCurrObj.LazyJumpDistance, diameter * 3, diameter), 1.8) - * DifficultyCalculationUtils.Smootherstep(currAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)) - * DifficultyCalculationUtils.Smootherstep(osuLastObj.LazyJumpDistance, radius, diameter) - * Math.Pow(DifficultyCalculationUtils.ReverseLerp(osuLastObj.LazyJumpDistance, diameter * 3, diameter), 1.8) - * DifficultyCalculationUtils.Smootherstep(lastAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)); - - if (osuLast2Obj != null) - { - // If objects just go back and forth through a middle point - don't give as much wide bonus - // Use Previous(2) and Previous(0) because angles calculation is done prevprev-prev-curr, so any object's angle's center point is always the previous object - var lastBaseObject = (OsuHitObject)osuLastObj.BaseObject; - var last2BaseObject = (OsuHitObject)osuLast2Obj.BaseObject; - - float distance = (last2BaseObject.StackedPosition - lastBaseObject.StackedPosition).Length; - - if (distance < 1) - { - wideAngleBonus *= 1 - 0.35 * (1 - distance); - } - } - } - - if (Math.Max(prevVelocity, currVelocity) != 0) - { - // We want to use the average velocity over the whole object when awarding differences, not the individual jump and slider path velocities. - prevVelocity = (osuLastObj.LazyJumpDistance + osuLastLastObj.TravelDistance) / osuLastObj.AdjustedDeltaTime; - currVelocity = (osuCurrObj.LazyJumpDistance + osuLastObj.TravelDistance) / osuCurrObj.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(diameter * 1.25 / Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), Math.Abs(prevVelocity - currVelocity)); - - velocityChangeBonus = overlapVelocityBuff * distRatio; - - // Penalize for rhythm changes. - velocityChangeBonus *= Math.Pow(Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) / Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), 2); - } - - if (osuLastObj.BaseObject is Slider) - { - // Reward sliders based on velocity. - sliderBonus = osuLastObj.TravelDistance / osuLastObj.TravelTime; - } - - 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); - - // Apply high circle size bonus - aimStrain *= osuCurrObj.SmallCircleBonus; - - // Add in additional slider velocity bonus. - if (withSliderTravelDistance) - aimStrain += sliderBonus * slider_multiplier; - - return aimStrain; - } - - 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)); - } -} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs index 55192df7af93..a6d13be579ec 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs @@ -2,22 +2,19 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators { public static class FlashlightEvaluator { - private const double max_opacity_bonus = 0.4; - private const double hidden_bonus = 0.2; - - private const double min_velocity = 0.5; - private const double slider_multiplier = 1.3; - - private const double min_angle_multiplier = 0.2; - /// /// Evaluates the difficulty of memorising and hitting an object, based on: /// @@ -28,11 +25,19 @@ public static class FlashlightEvaluator /// and whether the hidden mod is enabled. /// /// - public static double EvaluateDifficultyOf(DifficultyHitObject current, bool hidden) + public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnlyList mods) { if (current.BaseObject is Spinner) return 0; + const double max_opacity_bonus = 0.4; + const double hidden_bonus = 0.2; + + const double min_velocity = 0.5; + const double slider_multiplier = 1.3; + + const double min_angle_multiplier = 0.2; + var osuCurrent = (OsuDifficultyHitObject)current; var osuHitObject = (OsuHitObject)(osuCurrent.BaseObject); @@ -40,7 +45,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool hidd double smallDistNerf = 1.0; double cumulativeStrainTime = 0.0; - double result = 0.0; + double flashlightDifficulty = 0.0; OsuDifficultyHitObject lastObj = osuCurrent; @@ -66,9 +71,9 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool hidd double stackNerf = Math.Min(1.0, (currentObj.LazyJumpDistance / scalingFactor) / 25.0); // Bonus based on how visible the object is. - double opacityBonus = 1.0 + max_opacity_bonus * (1.0 - osuCurrent.OpacityAt(currentHitObject.StartTime, hidden)); + double opacityBonus = 1.0 + max_opacity_bonus * (1.0 - osuCurrent.OpacityAt(currentHitObject.StartTime, mods.OfType().Any(m => !m.OnlyFadeApproachCircles.Value))); - result += stackNerf * opacityBonus * scalingFactor * jumpDistance / cumulativeStrainTime; + flashlightDifficulty += stackNerf * opacityBonus * scalingFactor * jumpDistance / cumulativeStrainTime; if (currentObj.Angle != null && osuCurrent.Angle != null) { @@ -81,14 +86,14 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool hidd lastObj = currentObj; } - result = Math.Pow(smallDistNerf * result, 2.0); + flashlightDifficulty = DiffUtils.Pow(smallDistNerf * flashlightDifficulty, 2); // Additional bonus for Hidden due to there being no approach circles. - if (hidden) - result *= 1.0 + hidden_bonus; + if (mods.OfType().Any()) + flashlightDifficulty *= 1.0 + hidden_bonus; // Nerf patterns with repeated angles. - result *= min_angle_multiplier + (1.0 - min_angle_multiplier) / (angleRepeatCount + 1.0); + flashlightDifficulty *= min_angle_multiplier + (1.0 - min_angle_multiplier) / (angleRepeatCount + 1.0); double sliderBonus = 0.0; @@ -98,7 +103,7 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool hidd double pixelTravelDistance = osuCurrent.LazyTravelDistance / scalingFactor; // Reward sliders based on velocity. - sliderBonus = Math.Pow(Math.Max(0.0, pixelTravelDistance / osuCurrent.TravelTime - min_velocity), 0.5); + sliderBonus = DiffUtils.Pow(Math.Max(0.0, pixelTravelDistance / osuCurrent.TravelTime - min_velocity), 0.5); // Longer sliders require more memorisation. sliderBonus *= pixelTravelDistance; @@ -108,9 +113,9 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool hidd sliderBonus /= (osuSlider.RepeatCount + 1); } - result += sliderBonus * slider_multiplier; + flashlightDifficulty += sliderBonus * slider_multiplier; - return result; + return flashlightDifficulty; } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs new file mode 100644 index 000000000000..99826ed4170b --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs @@ -0,0 +1,275 @@ +// 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 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 ReadingEvaluator + { + private const double reading_window_size = 3000; // 3 seconds + private const double distance_influence_threshold = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.5; // 1.5 circles distance between centers + + public static double EvaluateDifficultyOf(DifficultyHitObject current, bool hidden) + { + if (current.BaseObject is Spinner || current.Index == 0) + return 0; + + var currObj = (OsuDifficultyHitObject)current; + var nextObj = (OsuDifficultyHitObject)current.Next(0); + + double velocity = Math.Max(1, currObj.LazyJumpDistance / currObj.AdjustedDeltaTime); // Only allow velocity to buff + + double currentVisibleObjectDensity = retrieveCurrentVisibleObjectDensity(currObj); + double pastObjectDifficultyInfluence = getPastObjectDifficultyInfluence(currObj); + + double constantAngleNerfFactor = getConstantAngleNerfFactor(currObj); + + double noteDensityDifficulty = calculateDensityDifficulty(nextObj, velocity, constantAngleNerfFactor, pastObjectDifficultyInfluence, currentVisibleObjectDensity); + + double hiddenDifficulty = hidden + ? calculateHiddenDifficulty(currObj, pastObjectDifficultyInfluence, currentVisibleObjectDensity, velocity, constantAngleNerfFactor) + : 0; + + double preemptDifficulty = calculatePreemptDifficulty(velocity, constantAngleNerfFactor, currObj.Preempt); + + double readingDifficulty = DiffUtils.Norm(1.5, preemptDifficulty, hiddenDifficulty, noteDensityDifficulty); + + // Having less time to process information is harder + readingDifficulty *= highBpmBonus(currObj.AdjustedDeltaTime); + + return readingDifficulty; + } + + /// + /// Calculates the density difficulty of the current object and how hard it is to aim it because of it based on: + /// + /// cursor velocity to the current object, + /// how many times the current object's angle was repeated, + /// density of objects visible when the current object appears, + /// density of objects visible when the current object needs to be clicked, + /// /// + /// + private static double calculateDensityDifficulty(OsuDifficultyHitObject? nextObj, double velocity, double constantAngleNerfFactor, + double pastObjectDifficultyInfluence, double currentVisibleObjectDensity) + { + const double density_multiplier = 2.4; + const double density_difficulty_base = 2.5; + + // Consider future densities too because it can make the path the cursor takes less clear + double futureObjectDifficultyInfluence = Math.Sqrt(currentVisibleObjectDensity); + + if (nextObj != null) + { + // Reduce difficulty if movement to next object is small + futureObjectDifficultyInfluence *= DiffUtils.Smootherstep(nextObj.LazyJumpDistance, 15, distance_influence_threshold); + } + + // Value higher note densities exponentially + double noteDensityDifficulty = DiffUtils.Pow(pastObjectDifficultyInfluence + futureObjectDifficultyInfluence, 1.7) * 0.4 * constantAngleNerfFactor * velocity; + + // Award only denser than average maps. + noteDensityDifficulty = Math.Max(0, noteDensityDifficulty - density_difficulty_base); + + // Apply a soft cap to general density reading to account for partial memorization + noteDensityDifficulty = DiffUtils.Pow(noteDensityDifficulty, 0.45) * density_multiplier; + + return noteDensityDifficulty; + } + + /// + /// Calculates the difficulty of aiming the current object when the approach rate is very high based on: + /// + /// cursor velocity to the current object, + /// how many times the current object's angle was repeated, + /// how many milliseconds elapse between the approach circle appearing and touching the inner circle + /// + /// + private static double calculatePreemptDifficulty(double velocity, double constantAngleNerfFactor, double preempt) + { + const double preempt_balancing_factor = 140000; + const double preempt_starting_point = 500; // AR 9.66 in milliseconds + + // Arbitrary curve for the base value preempt difficulty should have as approach rate increases. + // https://www.desmos.com/calculator/c175335a71 + double preemptDifficulty = DiffUtils.Pow((preempt_starting_point - preempt + Math.Abs(preempt - preempt_starting_point)) / 2, 2.5) / preempt_balancing_factor; + + preemptDifficulty *= constantAngleNerfFactor * velocity; + + return preemptDifficulty; + } + + /// + /// Calculates the difficulty of aiming the current object when the hidden mod is active based on: + /// + /// cursor velocity to the current object, + /// time the current object spends invisible, + /// density of objects visible when the current object appears, + /// density of objects visible when the current object needs to be clicked, + /// how many times the current object's angle was repeated, + /// if the current object is perfectly stacked to the previous one + /// + /// + private static double calculateHiddenDifficulty(OsuDifficultyHitObject currObj, double pastObjectDifficultyInfluence, double currentVisibleObjectDensity, double velocity, + double constantAngleNerfFactor) + { + const double hidden_multiplier = 0.28; + + // Higher preempt means that time spent invisible is higher too, we want to reward that + double preemptFactor = DiffUtils.Pow(currObj.Preempt, 2.2) * 0.01; + + // Account for both past and current densities + double densityFactor = DiffUtils.Pow(currentVisibleObjectDensity + pastObjectDifficultyInfluence, 3.3) * 3; + + double hiddenDifficulty = (preemptFactor + densityFactor) * constantAngleNerfFactor * velocity * 0.01; + + // Apply a soft cap to general HD reading to account for partial memorization + hiddenDifficulty = DiffUtils.Pow(hiddenDifficulty, 0.4) * hidden_multiplier; + + var previousObj = (OsuDifficultyHitObject)currObj.Previous(0); + + // Buff perfect stacks only if current note is completely invisible at the time you click the previous note. + if (currObj.LazyJumpDistance == 0 && currObj.OpacityAt(previousObj.BaseObject.StartTime, true) == 0 && previousObj.StartTime > currObj.StartTime - currObj.Preempt) + hiddenDifficulty += hidden_multiplier * 2500 / DiffUtils.Pow(currObj.AdjustedDeltaTime, 1.5); // Perfect stacks are harder the less time between notes + + return hiddenDifficulty; + } + + private static double getPastObjectDifficultyInfluence(OsuDifficultyHitObject currObj) + { + double pastObjectDifficultyInfluence = 0; + + foreach (var loopObj in retrievePastVisibleObjects(currObj)) + { + double loopDifficulty = currObj.OpacityAt(loopObj.BaseObject.StartTime, false); + + // When aiming an object small distances mean previous objects may be cheesed, so it doesn't matter whether they were arranged confusingly. + loopDifficulty *= DiffUtils.Smootherstep(loopObj.LazyJumpDistance, 15, distance_influence_threshold); + + // Account less for objects close to the max reading window + double timeBetweenCurrAndLoopObj = currObj.StartTime - loopObj.StartTime; + double timeNerfFactor = getTimeNerfFactor(timeBetweenCurrAndLoopObj); + + loopDifficulty *= timeNerfFactor; + pastObjectDifficultyInfluence += loopDifficulty; + } + + return pastObjectDifficultyInfluence; + } + + // Returns a list of objects that are visible on screen at the point in time the current object becomes visible. + private static IEnumerable retrievePastVisibleObjects(OsuDifficultyHitObject current) + { + for (int i = 0; i < current.Index; i++) + { + OsuDifficultyHitObject hitObject = (OsuDifficultyHitObject)current.Previous(i); + + if (hitObject == null || + current.StartTime - hitObject.StartTime > reading_window_size || + hitObject.StartTime < current.StartTime - current.Preempt) // Current object not visible at the time object needs to be clicked + break; + + yield return hitObject; + } + } + + // Returns the density of objects visible at the point in time the current object needs to be clicked capped by the reading window. + private static double retrieveCurrentVisibleObjectDensity(OsuDifficultyHitObject current) + { + double visibleObjectCount = 0; + + OsuDifficultyHitObject? hitObject = (OsuDifficultyHitObject)current.Next(0); + + while (hitObject != null) + { + if (hitObject.StartTime - current.StartTime > reading_window_size || + current.StartTime < hitObject.StartTime - hitObject.Preempt) // Object not visible at the time current object needs to be clicked. + break; + + double timeBetweenCurrAndLoopObj = hitObject.StartTime - current.StartTime; + double timeNerfFactor = getTimeNerfFactor(timeBetweenCurrAndLoopObj); + + visibleObjectCount += hitObject.OpacityAt(current.BaseObject.StartTime, false) * timeNerfFactor; + + hitObject = (OsuDifficultyHitObject?)hitObject.Next(0); + } + + return visibleObjectCount; + } + + // Returns a factor of how often the current object's angle has been repeated in a certain time frame. + // It does this by checking the difference in angle between current and past objects and sums them based on a range of similarity. + // https://www.desmos.com/calculator/eb057a4822 + private static double getConstantAngleNerfFactor(OsuDifficultyHitObject current) + { + const double minimum_angle_relevancy_time = 2000; // 2 seconds + const double maximum_angle_relevancy_time = 200; + + double constantAngleCount = 0; + 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); + + if (loopObj == null) + break; + + // Account less for objects that are close to the time limit. + double longIntervalFactor = 1 - DiffUtils.ReverseLerp(loopObj.AdjustedDeltaTime, maximum_angle_relevancy_time, minimum_angle_relevancy_time); + + if (loopObj.Angle != null && current.Angle != null) + { + 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 *= DiffUtils.ReverseLerp(Math.Min(loopObj.Angle.Value, loopObjPrev0.Angle.Value) * 180 / Math.PI, 20, 5); + weight *= DiffUtils.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 = DiffUtils.Smootherstep(loopObj.LazyJumpDistance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS); + + 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); + } + + // Returns a nerfing factor for when objects are very distant in time, affecting reading less. + 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 - DiffUtils.Pow(0.8, ms / 1000)); + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs deleted file mode 100644 index 9349083951e1..000000000000 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs +++ /dev/null @@ -1,234 +0,0 @@ -// 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.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 RhythmEvaluator - { - private const int history_time_max = 5 * 1000; // 5 seconds - private const int history_objects_max = 32; - private const double rhythm_overall_multiplier = 1.0; - private const double rhythm_ratio_multiplier = 15.0; - - /// - /// Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current . - /// - public static double EvaluateDifficultyOf(DifficultyHitObject current) - { - if (current.BaseObject is Spinner) - return 0; - - var currentOsuObject = (OsuDifficultyHitObject)current; - - double rhythmComplexitySum = 0; - - double deltaDifferenceEpsilon = ((OsuDifficultyHitObject)current).HitWindowGreat * 0.3; - - var island = new Island(deltaDifferenceEpsilon); - var previousIsland = new Island(deltaDifferenceEpsilon); - - // we can't use dictionary here because we need to compare island with a tolerance - // which is impossible to pass into the hash comparer - var islandCounts = new List<(Island Island, int Count)>(); - - double startRatio = 0; // store the ratio of the current start of an island to buff for tighter rhythms - - bool firstDeltaSwitch = false; - - int historicalNoteCount = Math.Min(current.Index, history_objects_max); - - int rhythmStart = 0; - - while (rhythmStart < historicalNoteCount - 2 && current.StartTime - current.Previous(rhythmStart).StartTime < history_time_max) - rhythmStart++; - - OsuDifficultyHitObject prevObj = (OsuDifficultyHitObject)current.Previous(rhythmStart); - OsuDifficultyHitObject lastObj = (OsuDifficultyHitObject)current.Previous(rhythmStart + 1); - - // we go from the furthest object back to the current one - for (int i = rhythmStart; i > 0; i--) - { - OsuDifficultyHitObject currObj = (OsuDifficultyHitObject)current.Previous(i - 1); - - // scales note 0 to 1 from history to now - double timeDecay = (history_time_max - (current.StartTime - currObj.StartTime)) / history_time_max; - double noteDecay = (double)(historicalNoteCount - i) / historicalNoteCount; - - double currHistoricalDecay = Math.Min(noteDecay, timeDecay); // either we're limited by time or limited by object count. - - // Use custom cap value to ensure that that at this point delta time is actually zero - double currDelta = Math.Max(currObj.DeltaTime, 1e-7); - double prevDelta = Math.Max(prevObj.DeltaTime, 1e-7); - double lastDelta = Math.Max(lastObj.DeltaTime, 1e-7); - - // calculate how much current delta difference deserves a rhythm bonus - // this function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e 100 and 200) - double deltaDifference = Math.Max(prevDelta, currDelta) / Math.Min(prevDelta, currDelta); - - // Take only the fractional part of the value since we're only interested in punishing multiples - double deltaDifferenceFraction = deltaDifference - Math.Truncate(deltaDifference); - - double currRatio = 1.0 + rhythm_ratio_multiplier * Math.Min(0.5, DifficultyCalculationUtils.SmoothstepBellCurve(deltaDifferenceFraction)); - - // reduce ratio bonus if delta difference is too big - double differenceMultiplier = Math.Clamp(2.0 - deltaDifference / 8.0, 0.0, 1.0); - - double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - deltaDifferenceEpsilon) / deltaDifferenceEpsilon); - - double effectiveRatio = windowPenalty * currRatio * differenceMultiplier; - - if (firstDeltaSwitch) - { - if (Math.Abs(prevDelta - currDelta) < deltaDifferenceEpsilon) - { - // island is still progressing - island.AddDelta((int)currDelta); - } - else - { - // bpm change is into slider, this is easy acc window - if (currObj.BaseObject is Slider) - effectiveRatio *= 0.125; - - // bpm change was from a slider, this is easier typically than circle -> circle - // unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders - if (prevObj.BaseObject is Slider) - effectiveRatio *= 0.3; - - // repeated island polarity (2 -> 4, 3 -> 5) - if (island.IsSimilarPolarity(previousIsland)) - effectiveRatio *= 0.5; - - // previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this. - if (lastDelta > prevDelta + deltaDifferenceEpsilon && prevDelta > currDelta + deltaDifferenceEpsilon) - effectiveRatio *= 0.125; - - // repeated island size (ex: triplet -> triplet) - // TODO: remove this nerf since its staying here only for balancing purposes because of the flawed ratio calculation - if (previousIsland.DeltaCount == island.DeltaCount) - effectiveRatio *= 0.5; - - var islandCount = islandCounts.FirstOrDefault(x => x.Island.Equals(island)); - - if (islandCount != default) - { - int countIndex = islandCounts.IndexOf(islandCount); - - // only add island to island counts if they're going one after another - if (previousIsland.Equals(island)) - islandCount.Count++; - - // repeated island (ex: triplet -> triplet) - double power = DifficultyCalculationUtils.Logistic(island.Delta, maxValue: 2.75, multiplier: 0.24, midpointOffset: 58.33); - effectiveRatio *= Math.Min(3.0 / islandCount.Count, Math.Pow(1.0 / islandCount.Count, power)); - - islandCounts[countIndex] = (islandCount.Island, islandCount.Count); - } - else - { - islandCounts.Add((island, 1)); - } - - // scale down the difficulty if the object is doubletappable - double doubletapness = prevObj.GetDoubletapness(currObj); - effectiveRatio *= 1 - doubletapness * 0.75; - - rhythmComplexitySum += Math.Sqrt(effectiveRatio * startRatio) * currHistoricalDecay; - - startRatio = effectiveRatio; - - previousIsland = island; - - if (prevDelta + deltaDifferenceEpsilon < currDelta) // we're slowing down, stop counting - firstDeltaSwitch = false; // if we're speeding up, this stays true and we keep counting island size. - - island = new Island((int)currDelta, deltaDifferenceEpsilon); - } - } - else if (prevDelta > currDelta + deltaDifferenceEpsilon) // we're speeding up - { - // Begin counting island until we change speed again. - firstDeltaSwitch = true; - - // bpm change is into slider, this is easy acc window - if (currObj.BaseObject is Slider) - effectiveRatio *= 0.6; - - // bpm change was from a slider, this is easier typically than circle -> circle - // unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders - if (prevObj.BaseObject is Slider) - effectiveRatio *= 0.6; - - startRatio = effectiveRatio; - - island = new Island((int)currDelta, deltaDifferenceEpsilon); - } - - lastObj = prevObj; - prevObj = currObj; - } - - double rhythmDifficulty = Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though) - rhythmDifficulty *= 1 - currentOsuObject.GetDoubletapness((OsuDifficultyHitObject)current.Next(0)); - - return rhythmDifficulty; - } - - private class Island : IEquatable - { - private readonly double deltaDifferenceEpsilon; - - public Island(double epsilon) - { - deltaDifferenceEpsilon = epsilon; - } - - public Island(int delta, double epsilon) - { - deltaDifferenceEpsilon = epsilon; - Delta = Math.Max(delta, OsuDifficultyHitObject.MIN_DELTA_TIME); - DeltaCount++; - } - - public int Delta { get; private set; } = int.MaxValue; - public int DeltaCount { get; private set; } - - public void AddDelta(int delta) - { - if (Delta == int.MaxValue) - Delta = Math.Max(delta, OsuDifficultyHitObject.MIN_DELTA_TIME); - - DeltaCount++; - } - - public bool IsSimilarPolarity(Island other) - { - // TODO: consider islands to be of similar polarity only if they're having the same average delta (we don't want to consider 3 singletaps similar to a triple) - // naively adding delta check here breaks _a lot_ of maps because of the flawed ratio calculation - return DeltaCount % 2 == other.DeltaCount % 2; - } - - public bool Equals(Island? other) - { - if (other == null) - return false; - - return Math.Abs(Delta - other.Delta) < deltaDifferenceEpsilon && - DeltaCount == other.DeltaCount; - } - - public override string ToString() - { - return $"{Delta}x{DeltaCount}"; - } - } - } -} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs new file mode 100644 index 000000000000..498b130991e3 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/RhythmEvaluator.cs @@ -0,0 +1,274 @@ +// 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.Runtime.CompilerServices; +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.Speed +{ + public static class RhythmEvaluator + { + /// + /// Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current . + /// + public static double EvaluateDifficultyOf(DifficultyHitObject current) + { + if (current.BaseObject is Spinner) + return 0; + + const int history_time_max = 5 * 1000; // 5 seconds + const int history_objects_max = 32; + const double rhythm_overall_multiplier = 0.95; + + double rhythmComplexitySum = 0; + + double deltaDifferenceEpsilon = ((OsuDifficultyHitObject)current).HitWindowGreat * 0.3; + + var island = new Island(int.MaxValue); + var previousIsland = new Island(int.MaxValue); + + var islands = new List(); + + double startDifficulty = 0; // store the difficulty of the current start of an island to buff for tighter rhythms + + bool firstDeltaSwitch = false; + + int historicalNoteCount = Math.Min(current.Index, history_objects_max); + + int rhythmStart = 0; + + while (rhythmStart < historicalNoteCount - 2 && current.StartTime - current.Previous(rhythmStart).StartTime < history_time_max) + rhythmStart++; + + OsuDifficultyHitObject prevObj = (OsuDifficultyHitObject)current.Previous(rhythmStart); + OsuDifficultyHitObject prevPrevObj = (OsuDifficultyHitObject)current.Previous(rhythmStart + 1); + + // we go from the furthest object back to the current one + for (int i = rhythmStart; i > 0; i--) + { + OsuDifficultyHitObject currObj = (OsuDifficultyHitObject)current.Previous(i - 1); + + if (currObj.BaseObject is Spinner) + continue; + + // scales note 0 to 1 from history to now + double timeDecay = (history_time_max - (current.StartTime - currObj.StartTime)) / history_time_max; + double noteDecay = (double)(historicalNoteCount - i) / historicalNoteCount; + + double currHistoricalDecay = Math.Min(noteDecay, timeDecay); // either we're limited by time or limited by object count. + + // Use custom cap value to ensure that at this point delta time is actually zero + const double delta_min_value = 1e-7; + + double currDelta = Math.Max(currObj.DeltaTime, delta_min_value); + double prevDelta = Math.Max(prevObj.DeltaTime, delta_min_value); + + double deltaDifference = Math.Abs(prevDelta - currDelta); + + // Make sure to always have the current island initialised - if we don't do it here it will only initialise on the next rhythm change + if (island.Delta == int.MaxValue) + island = new Island((int)currDelta); + + // calculate how much current delta difference deserves a rhythm bonus + // this function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e 100 and 200) + double deltaDifferenceRatio = Math.Max(prevDelta, currDelta) / Math.Min(prevDelta, currDelta); + + // reduce ratio bonus if delta difference is too big + double differenceMultiplier = Math.Clamp(2.0 - deltaDifferenceRatio / 8.0, 0.0, 1.0); + + double windowPenalty = Math.Clamp((deltaDifference - deltaDifferenceEpsilon) / deltaDifferenceEpsilon, 0, 1); + + double effectiveDifficulty = getEffectiveDifficulty(deltaDifferenceRatio) * windowPenalty * differenceMultiplier; + + // if previous object is a slider it might be easier to tap since you don't have to do a whole tapping motion + // while a full deltatime might end up some weird ratio the "unpress->tap" motion might be simple + // for example a slider-circle-circle pattern should be evaluated as a regular triple and not as a single->double + if (prevObj.BaseObject is Slider) + { + double sliderLazyEndDelta = currObj.MinimumJumpTime; + double sliderLazyDeltaDifferenceRatio = Math.Max(sliderLazyEndDelta, currDelta) / Math.Min(sliderLazyEndDelta, currDelta); + + double sliderRealEndDelta = currObj.LastObjectEndDeltaTime; + double sliderRealDeltaDifferenceRatio = Math.Max(sliderRealEndDelta, currDelta) / Math.Min(sliderRealEndDelta, currDelta); + + double sliderEffectiveDifficulty = Math.Min(getEffectiveDifficulty(sliderLazyDeltaDifferenceRatio), getEffectiveDifficulty(sliderRealDeltaDifferenceRatio)); + effectiveDifficulty = Math.Min(sliderEffectiveDifficulty, effectiveDifficulty); + } + + if (deltaDifference < deltaDifferenceEpsilon) + { + // island is still progressing + island.AddDelta((int)currDelta); + } + + if (firstDeltaSwitch) + { + if (deltaDifference > deltaDifferenceEpsilon) + { + // bpm change is into slider, this is easy acc window + if (currObj.BaseObject is Slider) + effectiveDifficulty *= 0.5; + + // repeated island polarity (2 -> 4, 3 -> 5) + if (island.IsSimilarPolarity(previousIsland, deltaDifferenceEpsilon)) + effectiveDifficulty *= 0.5; + + // previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this. + if (Math.Max(prevPrevObj.DeltaTime, delta_min_value) > prevDelta + deltaDifferenceEpsilon && prevDelta > currDelta + deltaDifferenceEpsilon) + effectiveDifficulty *= 0.125; + + // repeated island size (ex: triplet -> triplet) + // TODO: remove this nerf since its staying here only for balancing purposes because of the flawed ratio calculation + if (previousIsland.DeltaCount == island.DeltaCount) + effectiveDifficulty *= 0.5; + + bool isSpeedingUp = prevDelta > currDelta + deltaDifferenceEpsilon; + + if (isSpeedingUp) + effectiveDifficulty *= 0.65; + + bool found = false; + + foreach (var existingIsland in islands) + { + if (existingIsland.AlmostEquals(island, deltaDifferenceEpsilon)) + { + // only increase island occurrences if they're going one after another + if (previousIsland.AlmostEquals(island, deltaDifferenceEpsilon)) + existingIsland.Occurrences++; + + // repeated island (ex: triplet -> triplet) + double power = DiffUtils.Logistic(island.Delta, maxValue: 2.75, multiplier: 0.24, midpointOffset: 58.33); + effectiveDifficulty *= Math.Min(3.0 / existingIsland.Occurrences, DiffUtils.Pow(1.0 / existingIsland.Occurrences, power)); + + found = true; + break; + } + } + + if (!found && island.DeltaCount > 0) + islands.Add(island); + + // scale down the difficulty if the object is double-tappable + effectiveDifficulty *= 1 - prevObj.CalculateDoubleTapFeasibility(currObj) * 0.75; + + if (island.DeltaCount > 1) + { + rhythmComplexitySum += Math.Sqrt(effectiveDifficulty * startDifficulty) * currHistoricalDecay; + } + else + { + // constant difficulty for single-note islands + rhythmComplexitySum += 0.7 * currHistoricalDecay; + } + + startDifficulty = effectiveDifficulty; + + if (prevDelta + deltaDifferenceEpsilon < currDelta) // we're slowing down, stop counting + firstDeltaSwitch = false; // if we're speeding up, this stays true and we keep counting island size. + + previousIsland = island; + island = new Island((int)currDelta); + } + } + else if (prevDelta > currDelta + deltaDifferenceEpsilon) // we're speeding up + { + // Begin counting island until we change speed again. + firstDeltaSwitch = true; + + // bpm change is into slider, this is easy acc window + if (currObj.BaseObject is Slider) + effectiveDifficulty *= 0.6; + + // bpm change was from a slider, this is easier typically than circle -> circle + // unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders + if (prevObj.BaseObject is Slider) + effectiveDifficulty *= 0.6; + + startDifficulty = effectiveDifficulty; + + island = new Island((int)currDelta); + } + + prevPrevObj = prevObj; + prevObj = currObj; + } + + // If the current island is long we don't want the sum to have as big of an effect + rhythmComplexitySum *= DiffUtils.ReverseLerp(island.DeltaCount, 22, 3); + + return Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though) + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static double getEffectiveDifficulty(double deltaDifferenceRatio) + { + const double rhythm_ratio_difficulty_multiplier = 26.0; + + // Take only the fractional part of the value since we're only interested in punishing multiples + double deltaDifferenceFraction = deltaDifferenceRatio - Math.Truncate(deltaDifferenceRatio); + + return 1.0 + rhythm_ratio_difficulty_multiplier * Math.Min(0.5, DiffUtils.SmoothstepBellCurve(deltaDifferenceFraction)); + } + + /// + /// An island is a group of consecutive objects with the same delta time. + /// + private class Island + { + /// + /// Delta time of every object in this island + /// + public int Delta { get; private set; } + + /// + /// How long the island is + /// + public int DeltaCount { get; private set; } = 1; + + /// + /// How many times island already occured + /// + public int Occurrences { get; set; } = 1; + + public Island(int delta) + { + Delta = Math.Max(delta, OsuDifficultyHitObject.MIN_DELTA_TIME); + } + + public void AddDelta(int delta) + { + if (Delta == int.MaxValue) + Delta = Math.Max(delta, OsuDifficultyHitObject.MIN_DELTA_TIME); + + DeltaCount++; + } + + public bool IsSimilarPolarity(Island other, double epsilon) + { + // single delta islands shouldn't be compared + if (DeltaCount <= 1 || other.DeltaCount <= 1) + return false; + + return Math.Abs(Delta - other.Delta) < epsilon && + DeltaCount % 2 == other.DeltaCount % 2; + } + + public bool AlmostEquals(Island other, double epsilon) + { + return Math.Abs(Delta - other.Delta) < epsilon && + DeltaCount == other.DeltaCount; + } + + public override string ToString() + { + return $"{Delta}x{DeltaCount}"; + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/SpeedEvaluator.cs new file mode 100644 index 000000000000..7caa03a0b9c6 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/Speed/SpeedEvaluator.cs @@ -0,0 +1,56 @@ +// 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; + +namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed +{ + public static class SpeedEvaluator + { + /// + /// Evaluates the difficulty of tapping the current object, based on: + /// + /// time between pressing the previous and current object, + /// and how easily they can be cheesed. + /// + /// + public static double EvaluateDifficultyOf(DifficultyHitObject current) + { + if (current.BaseObject is Spinner) + return 0; + + const double min_speed_bonus = 200; // 200 BPM 1/4th + const double speed_balancing_factor = 40; + + var osuCurrObj = (OsuDifficultyHitObject)current; + + double strainTime = osuCurrObj.AdjustedDeltaTime; + double doubleTapFeasibility = 1.0 - osuCurrObj.CalculateDoubleTapFeasibility((OsuDifficultyHitObject?)osuCurrObj.Next(0)); + + // Cap deltatime to the OD 300 hitwindow. + // 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap. + strainTime /= Math.Clamp((strainTime / osuCurrObj.HitWindowGreat) / 0.93, 0.92, 1); + + // speedBonus will be 0.0 for BPM < 200 + double speedBonus = 0.0; + + // Add additional scaling bonus for streams/bursts higher than 200bpm + if (DiffUtils.MillisecondsToBPM(strainTime) > min_speed_bonus) + speedBonus = 0.75 * DiffUtils.Pow((DiffUtils.BPMToMilliseconds(min_speed_bonus) - strainTime) / speed_balancing_factor, 2); + + // Base difficulty with all bonuses + double speedDifficulty = (1 + speedBonus) * 1000 / strainTime; + + speedDifficulty *= highBpmBonus(osuCurrObj.AdjustedDeltaTime); + + // Apply penalty if there's doubletappable doubles + return speedDifficulty * doubleTapFeasibility; + } + + private static double highBpmBonus(double ms) => 1 / (1 - DiffUtils.Pow(0.3, ms / 1000)); + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs deleted file mode 100644 index a58c1d36853e..000000000000 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs +++ /dev/null @@ -1,76 +0,0 @@ -// 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.Game.Rulesets.Difficulty.Preprocessing; -using osu.Game.Rulesets.Difficulty.Utils; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; -using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Rulesets.Osu.Objects; - -namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators -{ - public static class SpeedEvaluator - { - private const double single_spacing_threshold = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25; // 1.25 circles distance between centers - private const double min_speed_bonus = 200; // 200 BPM 1/4th - private const double speed_balancing_factor = 40; - private const double distance_multiplier = 0.8; - - /// - /// Evaluates the difficulty of tapping the current object, based on: - /// - /// time between pressing the previous and current object, - /// distance between those objects, - /// and how easily they can be cheesed. - /// - /// - public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnlyList mods) - { - if (current.BaseObject is Spinner) - return 0; - - // derive strainTime for calculation - var osuCurrObj = (OsuDifficultyHitObject)current; - var osuPrevObj = current.Index > 0 ? (OsuDifficultyHitObject)current.Previous(0) : null; - - double strainTime = osuCurrObj.AdjustedDeltaTime; - double doubletapness = 1.0 - osuCurrObj.GetDoubletapness((OsuDifficultyHitObject?)osuCurrObj.Next(0)); - - // Cap deltatime to the OD 300 hitwindow. - // 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap. - strainTime /= Math.Clamp((strainTime / osuCurrObj.HitWindowGreat) / 0.93, 0.92, 1); - - // speedBonus will be 0.0 for BPM < 200 - double speedBonus = 0.0; - - // Add additional scaling bonus for streams/bursts higher than 200bpm - if (DifficultyCalculationUtils.MillisecondsToBPM(strainTime) > min_speed_bonus) - speedBonus = 0.75 * Math.Pow((DifficultyCalculationUtils.BPMToMilliseconds(min_speed_bonus) - strainTime) / speed_balancing_factor, 2); - - double travelDistance = osuPrevObj?.TravelDistance ?? 0; - double distance = travelDistance + osuCurrObj.MinimumJumpDistance; - - // Cap distance at single_spacing_threshold - distance = Math.Min(distance, single_spacing_threshold); - - // Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold - double distanceBonus = Math.Pow(distance / single_spacing_threshold, 3.95) * distance_multiplier; - - // Apply reduced small circle bonus because flow aim difficulty on small circles doesn't scale as hard as jumps - distanceBonus *= Math.Sqrt(osuCurrObj.SmallCircleBonus); - - if (mods.OfType().Any()) - distanceBonus = 0; - - // Base difficulty with all bonuses - double difficulty = (1 + speedBonus + distanceBonus) * 1000 / strainTime; - - // Apply penalty if there's doubletappable doubles - return difficulty * doubletapness; - } - } -} diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs index 9cab45414266..bfbacd0a864b 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs @@ -45,6 +45,12 @@ public class OsuDifficultyAttributes : DifficultyAttributes [JsonProperty("flashlight_difficulty")] public double FlashlightDifficulty { get; set; } + /// + /// The difficulty corresponding to the reading skill. + /// + [JsonProperty("reading_difficulty")] + public double ReadingDifficulty { get; set; } + /// /// Describes how much of is contributed to by hitcircles or sliders. /// A value closer to 1.0 indicates most of is contributed by hitcircles. @@ -75,6 +81,9 @@ public class OsuDifficultyAttributes : DifficultyAttributes [JsonProperty("speed_difficult_strain_count")] public double SpeedDifficultStrainCount { get; set; } + [JsonProperty("reading_difficult_note_count")] + public double ReadingDifficultNoteCount { get; set; } + [JsonProperty("nested_score_per_object")] public double NestedScorePerObject { get; set; } @@ -84,11 +93,6 @@ public class OsuDifficultyAttributes : DifficultyAttributes [JsonProperty("maximum_legacy_combo_score")] public double MaximumLegacyComboScore { get; set; } - /// - /// The beatmap's drain rate. This doesn't scale with rate-adjusting mods. - /// - public double DrainRate { get; set; } - /// /// The number of hitcircles in the beatmap. /// @@ -111,6 +115,7 @@ public class OsuDifficultyAttributes : DifficultyAttributes yield return (ATTRIB_ID_AIM, AimDifficulty); yield return (ATTRIB_ID_SPEED, SpeedDifficulty); + yield return (ATTRIB_ID_READING, ReadingDifficulty); yield return (ATTRIB_ID_DIFFICULTY, StarRating); if (ShouldSerializeFlashlightDifficulty()) @@ -127,6 +132,7 @@ public class OsuDifficultyAttributes : DifficultyAttributes yield return (ATTRIB_ID_NESTED_SCORE_PER_OBJECT, NestedScorePerObject); yield return (ATTRIB_ID_LEGACY_SCORE_BASE_MULTIPLIER, LegacyScoreBaseMultiplier); yield return (ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE, MaximumLegacyComboScore); + yield return (ATTRIB_ID_READING_DIFFICULT_NOTE_COUNT, ReadingDifficultNoteCount); } public override void FromDatabaseAttributes(IReadOnlyDictionary values, IBeatmapOnlineInfo onlineInfo) @@ -135,6 +141,7 @@ public override void FromDatabaseAttributes(IReadOnlyDictionary val AimDifficulty = values[ATTRIB_ID_AIM]; SpeedDifficulty = values[ATTRIB_ID_SPEED]; + ReadingDifficulty = values[ATTRIB_ID_READING]; StarRating = values[ATTRIB_ID_DIFFICULTY]; FlashlightDifficulty = values.GetValueOrDefault(ATTRIB_ID_FLASHLIGHT); SliderFactor = values[ATTRIB_ID_SLIDER_FACTOR]; @@ -147,7 +154,7 @@ public override void FromDatabaseAttributes(IReadOnlyDictionary val NestedScorePerObject = values[ATTRIB_ID_NESTED_SCORE_PER_OBJECT]; LegacyScoreBaseMultiplier = values[ATTRIB_ID_LEGACY_SCORE_BASE_MULTIPLIER]; MaximumLegacyComboScore = values[ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE]; - DrainRate = onlineInfo.DrainRate; + ReadingDifficultNoteCount = values[ATTRIB_ID_READING_DIFFICULT_NOTE_COUNT]; HitCircleCount = onlineInfo.CircleCount; SliderCount = onlineInfo.SliderCount; SpinnerCount = onlineInfo.SpinnerCount; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 504fddbb711a..7afe5391df11 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -8,21 +8,19 @@ using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Difficulty.Skills; using osu.Game.Rulesets.Osu.Difficulty.Utils; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Rulesets.Osu.Scoring; -using osu.Game.Rulesets.Scoring; +using osu.Game.Utils; namespace osu.Game.Rulesets.Osu.Difficulty { public class OsuDifficultyCalculator : DifficultyCalculator { - private const double star_rating_multiplier = 0.0265; - public override int Version => 20251020; public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) @@ -30,23 +28,7 @@ public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) { } - public static double CalculateRateAdjustedApproachRate(double approachRate, double clockRate) - { - double preempt = IBeatmapDifficultyInfo.DifficultyRange(approachRate, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN) / clockRate; - return IBeatmapDifficultyInfo.InverseDifficultyRange(preempt, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN); - } - - public static double CalculateRateAdjustedOverallDifficulty(double overallDifficulty, double clockRate) - { - HitWindows hitWindows = new OsuHitWindows(); - hitWindows.SetDifficulty(overallDifficulty); - - double hitWindowGreat = hitWindows.WindowFor(HitResult.Great) / clockRate; - - return (79.5 - hitWindowGreat) / 6; - } - - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) { if (beatmap.HitObjects.Count == 0) return new OsuDifficultyAttributes { Mods = mods }; @@ -55,66 +37,61 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat var aimWithoutSliders = skills.OfType().Single(a => !a.IncludeSliders); var speed = skills.OfType().Single(); var flashlight = skills.OfType().SingleOrDefault(); + var reading = skills.OfType().Single(); + + double aimDifficultyValue = aim.DifficultyValue(); + double aimNoSlidersDifficultyValue = aimWithoutSliders.DifficultyValue(); + double speedDifficultyValue = speed.DifficultyValue(); + double readingDifficultyValue = reading.DifficultyValue(); - double speedNotes = speed.RelevantNoteCount(); + double aimDifficultStrainCount = aim.CountTopWeightedStrains(aimDifficultyValue); + double speedDifficultStrainCount = speed.CountTopWeightedObjectDifficulties(speedDifficultyValue); + double readingDifficultNoteCount = reading.CountTopWeightedObjectDifficulties(readingDifficultyValue); - double aimDifficultStrainCount = aim.CountTopWeightedStrains(); - double speedDifficultStrainCount = speed.CountTopWeightedStrains(); + double speedNotes = speed.RelevantObjectCount(); - double aimNoSlidersTopWeightedSliderCount = aimWithoutSliders.CountTopWeightedSliders(); - double aimNoSlidersDifficultStrainCount = aimWithoutSliders.CountTopWeightedStrains(); + double aimNoSlidersTopWeightedSliderCount = aimWithoutSliders.CountTopWeightedSliders(aimNoSlidersDifficultyValue); + double aimNoSlidersDifficultStrainCount = aimWithoutSliders.CountTopWeightedStrains(aimNoSlidersDifficultyValue); double aimTopWeightedSliderFactor = aimNoSlidersTopWeightedSliderCount / Math.Max(1, aimNoSlidersDifficultStrainCount - aimNoSlidersTopWeightedSliderCount); - double speedTopWeightedSliderCount = speed.CountTopWeightedSliders(); + double speedTopWeightedSliderCount = speed.CountTopWeightedSliders(speedDifficultyValue); double speedTopWeightedSliderFactor = speedTopWeightedSliderCount / Math.Max(1, speedDifficultStrainCount - speedTopWeightedSliderCount); double difficultSliders = aim.GetDifficultSliders(); - double approachRate = CalculateRateAdjustedApproachRate(beatmap.Difficulty.ApproachRate, clockRate); - double overallDifficulty = CalculateRateAdjustedOverallDifficulty(beatmap.Difficulty.OverallDifficulty, clockRate); - int hitCircleCount = beatmap.HitObjects.Count(h => h is HitCircle); int sliderCount = beatmap.HitObjects.Count(h => h is Slider); int spinnerCount = beatmap.HitObjects.Count(h => h is Spinner); int totalHits = beatmap.HitObjects.Count; - double drainRate = beatmap.Difficulty.DrainRate; - - double aimDifficultyValue = aim.DifficultyValue(); - double aimNoSlidersDifficultyValue = aimWithoutSliders.DifficultyValue(); - double speedDifficultyValue = speed.DifficultyValue(); - - double mechanicalDifficultyRating = calculateMechanicalDifficultyRating(aimDifficultyValue, speedDifficultyValue); - double sliderFactor = aimDifficultyValue > 0 ? OsuRatingCalculator.CalculateDifficultyRating(aimNoSlidersDifficultyValue) / OsuRatingCalculator.CalculateDifficultyRating(aimDifficultyValue) : 1; - - var osuRatingCalculator = new OsuRatingCalculator(mods, totalHits, approachRate, overallDifficulty, mechanicalDifficultyRating, sliderFactor); + double sliderFactor = aimDifficultyValue > 0 + ? calculateAimDifficultyRating(aimNoSlidersDifficultyValue) / calculateAimDifficultyRating(aimDifficultyValue) + : 1; - double aimRating = osuRatingCalculator.ComputeAimRating(aimDifficultyValue); - double speedRating = osuRatingCalculator.ComputeSpeedRating(speedDifficultyValue); + double aimRating = calculateAimDifficultyRating(aimDifficultyValue); + double speedRating = calculateDifficultyRating(speedDifficultyValue); + double readingRating = calculateDifficultyRating(readingDifficultyValue); double flashlightRating = 0.0; if (flashlight is not null) - flashlightRating = osuRatingCalculator.ComputeFlashlightRating(flashlight.DifficultyValue()); + flashlightRating = calculateDifficultyRating(flashlight.DifficultyValue()); double sliderNestedScorePerObject = LegacyScoreUtils.CalculateNestedScorePerObject(beatmap, totalHits); - double legacyScoreBaseMultiplier = LegacyScoreUtils.CalculateDifficultyPeppyStars(beatmap); + double legacyScoreBaseMultiplier = LegacyScoreUtils.CalculateDifficultyPeppyStars(WorkingBeatmap.Beatmap); var simulator = new OsuLegacyScoreSimulator(); var scoreAttributes = simulator.Simulate(WorkingBeatmap, beatmap); - double baseAimPerformance = OsuStrainSkill.DifficultyToPerformance(aimRating); - double baseSpeedPerformance = OsuStrainSkill.DifficultyToPerformance(speedRating); + double baseAimPerformance = OsuPerformanceCalculator.DifficultyToPerformance(aimRating); + double baseSpeedPerformance = HarmonicSkill.DifficultyToPerformance(speedRating); + double baseReadingPerformance = HarmonicSkill.DifficultyToPerformance(readingRating); double baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating); + double baseCognitionPerformance = SumCognitionDifficulty(baseReadingPerformance, baseFlashlightPerformance); - double basePerformance = - Math.Pow( - Math.Pow(baseAimPerformance, 1.1) + - Math.Pow(baseSpeedPerformance, 1.1) + - Math.Pow(baseFlashlightPerformance, 1.1), 1.0 / 1.1 - ); + double basePerformance = DiffUtils.Norm(OsuPerformanceCalculator.PERFORMANCE_NORM_EXPONENT, baseAimPerformance, baseSpeedPerformance, baseCognitionPerformance); double starRating = calculateStarRating(basePerformance); @@ -127,12 +104,13 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat SpeedDifficulty = speedRating, SpeedNoteCount = speedNotes, FlashlightDifficulty = flashlightRating, + ReadingDifficulty = readingRating, SliderFactor = sliderFactor, AimDifficultStrainCount = aimDifficultStrainCount, SpeedDifficultStrainCount = speedDifficultStrainCount, + ReadingDifficultNoteCount = readingDifficultNoteCount, AimTopWeightedSliderFactor = aimTopWeightedSliderFactor, SpeedTopWeightedSliderFactor = speedTopWeightedSliderFactor, - DrainRate = drainRate, MaxCombo = beatmap.GetMaxCombo(), HitCircleCount = hitCircleCount, SliderCount = sliderCount, @@ -145,27 +123,32 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat return attributes; } - private double calculateMechanicalDifficultyRating(double aimDifficultyValue, double speedDifficultyValue) + public static double SumCognitionDifficulty(double reading, double flashlight) { - double aimValue = OsuStrainSkill.DifficultyToPerformance(OsuRatingCalculator.CalculateDifficultyRating(aimDifficultyValue)); - double speedValue = OsuStrainSkill.DifficultyToPerformance(OsuRatingCalculator.CalculateDifficultyRating(speedDifficultyValue)); + if (reading <= 0) + return flashlight; - double totalValue = Math.Pow(Math.Pow(aimValue, 1.1) + Math.Pow(speedValue, 1.1), 1 / 1.1); + if (flashlight <= 0) + return reading; - return calculateStarRating(totalValue); + // Nerf flashlight value in cognition sum when reading is greater than flashlight + return DiffUtils.Norm(OsuPerformanceCalculator.PERFORMANCE_NORM_EXPONENT, reading, flashlight * Math.Clamp(flashlight / reading, 0.25, 1.0)); } + private double calculateAimDifficultyRating(double difficultyValue) => DiffUtils.Pow(difficultyValue, 0.63) * 0.02275; + + private double calculateDifficultyRating(double difficultyValue) => Math.Sqrt(difficultyValue) * 0.0675; + private double calculateStarRating(double basePerformance) { - if (basePerformance <= 0.00001) - return 0; - - return Math.Cbrt(OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER) * star_rating_multiplier * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4); + return Math.Cbrt(basePerformance * OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER); } - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) { - List objects = new List(); + List objects = new List(beatmap.HitObjects.Count); + + double clockRate = ModUtils.CalculateRateWithMods(mods); // The first jump is formed by the first two hitobjects of the map. // If the map has less than two OsuHitObjects, the enumerator will not return anything. @@ -177,17 +160,18 @@ protected override IEnumerable CreateDifficultyHitObjects(I return objects; } - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) { var skills = new List { new Aim(mods, true), new Aim(mods, false), - new Speed(mods) + new Speed(mods), + new Reading(mods) }; if (mods.Any(h => h is OsuModFlashlight)) - skills.Add(new Flashlight(mods)); + skills.Add(new Flashlight(mods, beatmap.HitObjects.Count)); return skills.ToArray(); } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs index 0d406ea72a60..f7f52b1c03f0 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Scoring; @@ -115,12 +116,16 @@ private double calculateMaximumComboBasedMissCount() double missCount = 0; + // If sliders in the map are hard - it's likely for player to drop sliderends + // If map has easy sliders - it's more likely for player to sliderbreak + double likelyMissedSliderendPortion = 0.04 + 0.06 * DiffUtils.Pow(Math.Min(attributes.AimTopWeightedSliderFactor, 1), 2); + // Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it - // In classic scores we can't know the amount of dropped sliders so we estimate to 10% of all sliders on the map - double fullComboThreshold = attributes.MaxCombo - 0.1 * attributes.SliderCount; + // In classic scores we can't know the amount of dropped sliders so we estimate it + double fullComboThreshold = attributes.MaxCombo - Math.Min(4 + likelyMissedSliderendPortion * attributes.SliderCount, attributes.SliderCount); if (score.MaxCombo < fullComboThreshold) - missCount = Math.Pow(fullComboThreshold / Math.Max(1.0, score.MaxCombo), 2.5); + missCount = DiffUtils.Pow(fullComboThreshold / Math.Max(1.0, score.MaxCombo), 2.5); // In classic scores there can't be more misses than a sum of all non-perfect judgements missCount = Math.Min(missCount, totalImperfectHits); diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs index 8577eff11ff5..e4a64cd81d6b 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs @@ -21,6 +21,9 @@ public class OsuPerformanceAttributes : PerformanceAttributes [JsonProperty("flashlight")] public double Flashlight { get; set; } + [JsonProperty("reading")] + public double Reading { get; set; } + [JsonProperty("effective_miss_count")] public double EffectiveMissCount { get; set; } @@ -48,6 +51,7 @@ public override IEnumerable GetAttributesForDisplay yield return new PerformanceDisplayAttribute(nameof(Speed), "Speed", Speed); yield return new PerformanceDisplayAttribute(nameof(Accuracy), "Accuracy", Accuracy); yield return new PerformanceDisplayAttribute(nameof(Flashlight), "Flashlight Bonus", Flashlight); + yield return new PerformanceDisplayAttribute(nameof(Reading), "Reading", Reading); } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 741ddb3d4fdd..eb721be5c070 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -5,12 +5,15 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu.Scoring; -using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Osu.Difficulty.Skills; using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Utils; @@ -19,7 +22,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty { public class OsuPerformanceCalculator : PerformanceCalculator { - public const double PERFORMANCE_BASE_MULTIPLIER = 1.14; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. + public const double PERFORMANCE_BASE_MULTIPLIER = 1.12; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. + public const double PERFORMANCE_NORM_EXPONENT = 1.1; private bool usingClassicSliderAccuracy; private bool usingScoreV2; @@ -50,14 +54,18 @@ public class OsuPerformanceCalculator : PerformanceCalculator private double greatHitWindow; private double okHitWindow; private double mehHitWindow; + private double overallDifficulty; private double approachRate; + private double drainRate; private double? speedDeviation; private double aimEstimatedSliderBreaks; private double speedEstimatedSliderBreaks; + public static double DifficultyToPerformance(double difficulty) => 4.0 * DiffUtils.Pow(difficulty, 3); + public OsuPerformanceCalculator() : base(new OsuRuleset()) { @@ -93,13 +101,14 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s okHitWindow = hitWindows.WindowFor(HitResult.Ok) / clockRate; mehHitWindow = hitWindows.WindowFor(HitResult.Meh) / clockRate; - approachRate = OsuDifficultyCalculator.CalculateRateAdjustedApproachRate(difficulty.ApproachRate, clockRate); - overallDifficulty = OsuDifficultyCalculator.CalculateRateAdjustedOverallDifficulty(difficulty.OverallDifficulty, clockRate); + approachRate = calculateRateAdjustedApproachRate(difficulty.ApproachRate, clockRate); + overallDifficulty = (79.5 - greatHitWindow) / 6; + drainRate = difficulty.DrainRate; double comboBasedEstimatedMissCount = calculateComboBasedEstimatedMissCount(osuAttributes); double? scoreBasedEstimatedMissCount = null; - if (usingClassicSliderAccuracy && score.LegacyTotalScore != null) + if (usingClassicSliderAccuracy && !usingScoreV2 && score.LegacyTotalScore != null) { var legacyScoreMissCalculator = new OsuLegacyScoreMissCalculator(score, osuAttributes); scoreBasedEstimatedMissCount = legacyScoreMissCalculator.Calculate(); @@ -115,13 +124,19 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s effectiveMissCount = Math.Max(countMiss, effectiveMissCount); effectiveMissCount = Math.Min(totalHits, effectiveMissCount); + if (effectiveMissCount > 0) + { + aimEstimatedSliderBreaks = calculateEstimatedSliderBreaks(osuAttributes.AimTopWeightedSliderFactor, osuAttributes); + speedEstimatedSliderBreaks = calculateEstimatedSliderBreaks(osuAttributes.SpeedTopWeightedSliderFactor, osuAttributes); + } + double multiplier = PERFORMANCE_BASE_MULTIPLIER; if (score.Mods.Any(m => m is OsuModNoFail)) multiplier *= Math.Max(0.90, 1.0 - 0.02 * effectiveMissCount); if (score.Mods.Any(m => m is OsuModSpunOut) && totalHits > 0) - multiplier *= 1.0 - Math.Pow((double)osuAttributes.SpinnerCount / totalHits, 0.85); + multiplier *= 1.0 - DiffUtils.Pow((double)osuAttributes.SpinnerCount / totalHits, 0.85); if (score.Mods.Any(h => h is OsuModRelax)) { @@ -129,7 +144,7 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s // we use OD13.3 as maximum since it's the value at which great hitwidow becomes 0 // this is well beyond currently maximum achievable OD which is 12.17 (DTx2 + DA with OD11) double okMultiplier = 0.75 * Math.Max(0.0, overallDifficulty > 0.0 ? 1 - overallDifficulty / 13.33 : 1.0); - double mehMultiplier = Math.Max(0.0, overallDifficulty > 0.0 ? 1 - Math.Pow(overallDifficulty / 13.33, 5) : 1.0); + double mehMultiplier = Math.Max(0.0, overallDifficulty > 0.0 ? 1 - DiffUtils.Pow(overallDifficulty / 13.33, 5) : 1.0); // As we're adding Oks and Mehs to an approximated number of combo breaks the result can be higher than total hits in specific scenarios (which breaks some calculations) so we need to clamp it. effectiveMissCount = Math.Min(effectiveMissCount + countOk * okMultiplier + countMeh * mehMultiplier, totalHits); @@ -140,15 +155,12 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s double aimValue = computeAimValue(score, osuAttributes); double speedValue = computeSpeedValue(score, osuAttributes); double accuracyValue = computeAccuracyValue(score, osuAttributes); + + double readingValue = computeReadingValue(osuAttributes); double flashlightValue = computeFlashlightValue(score, osuAttributes); + double cognitionValue = OsuDifficultyCalculator.SumCognitionDifficulty(readingValue, flashlightValue); - double totalValue = - Math.Pow( - Math.Pow(aimValue, 1.1) + - Math.Pow(speedValue, 1.1) + - Math.Pow(accuracyValue, 1.1) + - Math.Pow(flashlightValue, 1.1), 1.0 / 1.1 - ) * multiplier; + double totalValue = DiffUtils.Norm(PERFORMANCE_NORM_EXPONENT, aimValue, speedValue, accuracyValue, cognitionValue) * multiplier; return new OsuPerformanceAttributes { @@ -156,6 +168,7 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s Speed = speedValue, Accuracy = accuracyValue, Flashlight = flashlightValue, + Reading = readingValue, EffectiveMissCount = effectiveMissCount, ComboBasedEstimatedMissCount = comboBasedEstimatedMissCount, ScoreBasedEstimatedMissCount = scoreBasedEstimatedMissCount, @@ -190,20 +203,18 @@ private double computeAimValue(ScoreInfo score, OsuDifficultyAttributes attribut estimateImproperlyFollowedDifficultSliders = Math.Clamp(countSliderEndsDropped + countSliderTickMiss, 0, attributes.AimDifficultSliderCount); } - double sliderNerfFactor = (1 - attributes.SliderFactor) * Math.Pow(1 - estimateImproperlyFollowedDifficultSliders / attributes.AimDifficultSliderCount, 3) + attributes.SliderFactor; + double sliderNerfFactor = (1 - attributes.SliderFactor) * DiffUtils.Pow(1 - estimateImproperlyFollowedDifficultSliders / attributes.AimDifficultSliderCount, 3) + attributes.SliderFactor; aimDifficulty *= sliderNerfFactor; } - double aimValue = OsuStrainSkill.DifficultyToPerformance(aimDifficulty); + double aimValue = DifficultyToPerformance(aimDifficulty); - double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + + double lengthBonus = 0.95 + 0.35 * Math.Min(1.0, totalHits / 2000.0) + (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); aimValue *= lengthBonus; if (effectiveMissCount > 0) { - aimEstimatedSliderBreaks = calculateEstimatedSliderBreaks(attributes.AimTopWeightedSliderFactor, attributes); - double relevantMissCount = Math.Min(effectiveMissCount + aimEstimatedSliderBreaks, totalImperfectHits + countSliderTickMiss); aimValue *= calculateMissPenalty(relevantMissCount, attributes.AimDifficultStrainCount); @@ -211,10 +222,10 @@ private double computeAimValue(ScoreInfo score, OsuDifficultyAttributes attribut // 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)) - aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * attributes.DrainRate * attributes.DrainRate); + aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * DiffUtils.Pow(accuracy, 16)) * (1 - 0.003 * drainRate * drainRate); else if (score.Mods.Any(m => m is OsuModTraceable)) { - aimValue *= 1.0 + OsuRatingCalculator.CalculateVisibilityBonus(score.Mods, approachRate, sliderFactor: attributes.SliderFactor); + aimValue *= 1.0 + calculateTraceableBonus(attributes.SliderFactor); } aimValue *= accuracy; @@ -227,44 +238,33 @@ private double computeSpeedValue(ScoreInfo score, OsuDifficultyAttributes attrib if (score.Mods.Any(h => h is OsuModRelax) || speedDeviation == null) return 0.0; - double speedValue = OsuStrainSkill.DifficultyToPerformance(attributes.SpeedDifficulty); - - double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + - (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); - speedValue *= lengthBonus; + double speedValue = HarmonicSkill.DifficultyToPerformance(attributes.SpeedDifficulty); if (effectiveMissCount > 0) { - speedEstimatedSliderBreaks = calculateEstimatedSliderBreaks(attributes.SpeedTopWeightedSliderFactor, attributes); - double relevantMissCount = Math.Min(effectiveMissCount + speedEstimatedSliderBreaks, totalImperfectHits + countSliderTickMiss); speedValue *= calculateMissPenalty(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 + OsuRatingCalculator.CalculateVisibilityBonus(score.Mods, approachRate); - } double speedHighDeviationMultiplier = calculateSpeedHighDeviationNerf(attributes); speedValue *= speedHighDeviationMultiplier; - // Calculate accuracy assuming the worst case scenario - double relevantTotalDiff = Math.Max(0, totalHits - attributes.SpeedNoteCount); - double relevantCountGreat = Math.Max(0, countGreat - relevantTotalDiff); - double relevantCountOk = Math.Max(0, countOk - Math.Max(0, relevantTotalDiff - countGreat)); - double relevantCountMeh = Math.Max(0, countMeh - Math.Max(0, relevantTotalDiff - countGreat - countOk)); - double relevantAccuracy = attributes.SpeedNoteCount == 0 ? 0 : (relevantCountGreat * 6.0 + relevantCountOk * 2.0 + relevantCountMeh) / (attributes.SpeedNoteCount * 6.0); + // An effective hit window is created based on the speed SR. The higher the speed difficulty, the shorter the hit window. + // For example, a speed SR of 4.0 leads to an effective hit window of 20ms, which is OD 10. + double effectiveHitWindow = 20 * DiffUtils.Pow(4 / attributes.SpeedDifficulty, 0.35); - // Scale the speed value with accuracy and OD. - speedValue *= Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - overallDifficulty) / 2); + // Find the proportion of 300s on speed notes assuming the hit window was the effective hit window. + double effectiveAccuracy = DiffUtils.Erf(effectiveHitWindow / (double)speedDeviation); + + // Scale speed value by normalized accuracy. + speedValue *= DiffUtils.Pow(effectiveAccuracy, 2); return speedValue; } @@ -291,23 +291,22 @@ private double computeAccuracyValue(ScoreInfo score, OsuDifficultyAttributes att // Lots of arbitrary values from testing. // Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution. - double accuracyValue = Math.Pow(1.52163, overallDifficulty) * Math.Pow(betterAccuracyPercentage, 24) * 2.83; + double accuracyValue = DiffUtils.Pow(1.52163, overallDifficulty) * DiffUtils.Pow(betterAccuracyPercentage, 24) * 2.83; // Bonus for many hitcircles - it's harder to keep good accuracy up for longer. - accuracyValue *= Math.Min(1.15, Math.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.3)); + accuracyValue *= amountHitObjectsWithAccuracy < 1000 + ? DiffUtils.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.3) + : DiffUtils.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.1); // Increasing the accuracy value by object count for Blinds isn't ideal, so the minimum buff is given. if (score.Mods.Any(m => m is OsuModBlinds)) accuracyValue *= 1.14; - else if (score.Mods.Any(m => m is OsuModHidden || m is OsuModTraceable)) + else if (score.Mods.Any(m => m is OsuModTraceable)) { // Decrease bonus for AR > 10 - accuracyValue *= 1 + 0.08 * DifficultyCalculationUtils.ReverseLerp(approachRate, 11.5, 10); + accuracyValue *= 1 + 0.08 * DiffUtils.ReverseLerp(approachRate, 11.5, 10); } - if (score.Mods.Any(m => m is OsuModFlashlight)) - accuracyValue *= 1.02; - return accuracyValue; } @@ -320,7 +319,7 @@ private double computeFlashlightValue(ScoreInfo score, OsuDifficultyAttributes a // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses. if (effectiveMissCount > 0) - flashlightValue *= 0.97 * Math.Pow(1 - Math.Pow(effectiveMissCount / totalHits, 0.775), Math.Pow(effectiveMissCount, .875)); + flashlightValue *= 0.97 * DiffUtils.Pow(1 - DiffUtils.Pow(effectiveMissCount / totalHits, 0.775), DiffUtils.Pow(effectiveMissCount, .875)); flashlightValue *= getComboScalingFactor(attributes); @@ -330,6 +329,19 @@ private double computeFlashlightValue(ScoreInfo score, OsuDifficultyAttributes a return flashlightValue; } + private double computeReadingValue(OsuDifficultyAttributes attributes) + { + double readingValue = HarmonicSkill.DifficultyToPerformance(attributes.ReadingDifficulty); + + if (effectiveMissCount > 0) + readingValue *= calculateMissPenalty(effectiveMissCount + aimEstimatedSliderBreaks, attributes.ReadingDifficultNoteCount); + + // Scale the reading value with accuracy _harshly_. + readingValue *= DiffUtils.Pow(accuracy, 3); + + return readingValue; + } + private double calculateComboBasedEstimatedMissCount(OsuDifficultyAttributes attributes) { if (attributes.SliderCount <= 0) @@ -339,9 +351,13 @@ private double calculateComboBasedEstimatedMissCount(OsuDifficultyAttributes att if (usingClassicSliderAccuracy) { + // If sliders in the map are hard - it's likely for player to drop sliderends + // If map has easy sliders - it's more likely for player to sliderbreak + double likelyMissedSliderendPortion = 0.04 + 0.06 * DiffUtils.Pow(Math.Min(attributes.AimTopWeightedSliderFactor, 1), 2); + // Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it - // In classic scores we can't know the amount of dropped sliders so we estimate to 10% of all sliders on the map - double fullComboThreshold = attributes.MaxCombo - 0.1 * attributes.SliderCount; + // In classic scores we can't know the amount of dropped sliders so we estimate it + double fullComboThreshold = attributes.MaxCombo - Math.Min(4 + likelyMissedSliderendPortion * attributes.SliderCount, attributes.SliderCount); if (scoreMaxCombo < fullComboThreshold) missCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo); @@ -376,19 +392,22 @@ private double calculateComboBasedEstimatedMissCount(OsuDifficultyAttributes att private double calculateEstimatedSliderBreaks(double topWeightedSliderFactor, OsuDifficultyAttributes attributes) { - if (!usingClassicSliderAccuracy || countOk == 0) + int nonMissMistakes = countOk + countMeh; + + if (!usingClassicSliderAccuracy || nonMissMistakes == 0) return 0; double missedComboPercent = 1.0 - (double)scoreMaxCombo / attributes.MaxCombo; - double estimatedSliderBreaks = Math.Min(countOk, effectiveMissCount * topWeightedSliderFactor); + double estimatedSliderBreaks = Math.Min(nonMissMistakes, effectiveMissCount * topWeightedSliderFactor); - // Scores with more Oks are more likely to have slider breaks. - double okAdjustment = ((countOk - estimatedSliderBreaks) + 0.5) / countOk; + // Scores with more Oks and Mehs are more likely to have slider breaks. + // We add an arbitrary value to both sides of the division to make it more stable on extreme ends. + double nonMissMistakeAdjustment = (nonMissMistakes - estimatedSliderBreaks + 4.5) / (nonMissMistakes + 4); // There is a low probability of extra slider breaks on effective miss counts close to 1, as score based calculations are good at indicating if only a single break occurred. - estimatedSliderBreaks *= DifficultyCalculationUtils.Smoothstep(effectiveMissCount, 1, 2); + estimatedSliderBreaks *= DiffUtils.Smoothstep(effectiveMissCount, 1, 2); - return estimatedSliderBreaks * okAdjustment * DifficultyCalculationUtils.Logistic(missedComboPercent, 0.33, 15); + return estimatedSliderBreaks * nonMissMistakeAdjustment * DiffUtils.Logistic(missedComboPercent, 0.33, 15); } /// @@ -440,12 +459,12 @@ private double calculateEstimatedSliderBreaks(double topWeightedSliderFactor, Os if (pLowerBound > 0.01) { // Compute deviation assuming greats and oks are normally distributed. - deviation = greatHitWindow / (Math.Sqrt(2) * DifficultyCalculationUtils.ErfInv(pLowerBound)); + deviation = greatHitWindow / (DiffUtils.SQRT2 * DiffUtils.ErfInv(pLowerBound)); // Subtract the deviation provided by tails that land outside the ok hit window from the deviation computed above. // This is equivalent to calculating the deviation of a normal distribution truncated at +-okHitWindow. - double okHitWindowTailAmount = Math.Sqrt(2 / Math.PI) * okHitWindow * Math.Exp(-0.5 * Math.Pow(okHitWindow / deviation, 2)) - / (deviation * DifficultyCalculationUtils.Erf(okHitWindow / (Math.Sqrt(2) * deviation))); + double okHitWindowTailAmount = Math.Sqrt(2 / Math.PI) * okHitWindow * Math.Exp(-0.5 * DiffUtils.Pow(okHitWindow / deviation, 2)) + / (deviation * DiffUtils.Erf(okHitWindow / (DiffUtils.SQRT2 * deviation))); deviation *= Math.Sqrt(1 - okHitWindowTailAmount); } @@ -458,7 +477,7 @@ private double calculateEstimatedSliderBreaks(double topWeightedSliderFactor, Os // Compute and add the variance for mehs, assuming that they are uniformly distributed. double mehVariance = (mehHitWindow * mehHitWindow + okHitWindow * mehHitWindow + okHitWindow * okHitWindow) / 3; - deviation = Math.Sqrt(((relevantCountGreat + relevantCountOk) * Math.Pow(deviation, 2) + relevantCountMeh * mehVariance) / (relevantCountGreat + relevantCountOk + relevantCountMeh)); + deviation = Math.Sqrt(((relevantCountGreat + relevantCountOk) * DiffUtils.Pow(deviation, 2) + relevantCountMeh * mehVariance) / (relevantCountGreat + relevantCountOk + relevantCountMeh)); return deviation; } @@ -470,11 +489,11 @@ private double calculateSpeedHighDeviationNerf(OsuDifficultyAttributes attribute if (speedDeviation == null) return 0; - double speedValue = OsuStrainSkill.DifficultyToPerformance(attributes.SpeedDifficulty); + double speedValue = HarmonicSkill.DifficultyToPerformance(attributes.SpeedDifficulty); // Decides a point where the PP value achieved compared to the speed deviation is assumed to be tapped improperly. Any PP above this point is considered "excess" speed difficulty. // This is used to cause PP above the cutoff to scale logarithmically towards the original speed value thus nerfing the value. - double excessSpeedDifficultyCutoff = 100 + 220 * Math.Pow(22 / speedDeviation.Value, 6.5); + double excessSpeedDifficultyCutoff = 100 + 220 * DiffUtils.Pow(22 / speedDeviation.Value, 6.5); if (speedValue <= excessSpeedDifficultyCutoff) return 1.0; @@ -483,17 +502,47 @@ private double calculateSpeedHighDeviationNerf(OsuDifficultyAttributes attribute double adjustedSpeedValue = scale * (Math.Log((speedValue - excessSpeedDifficultyCutoff) / scale + 1) + excessSpeedDifficultyCutoff / scale); // 220 UR and less are considered tapped correctly to ensure that normal scores will be punished as little as possible - double lerp = 1 - DifficultyCalculationUtils.ReverseLerp(speedDeviation.Value, 22.0, 27.0); + double lerp = 1 - DiffUtils.ReverseLerp(speedDeviation.Value, 22.0, 27.0); adjustedSpeedValue = double.Lerp(adjustedSpeedValue, speedValue, lerp); return adjustedSpeedValue / speedValue; } + /// + /// Calculates a visibility bonus that is applicable to Traceable. + /// + private double calculateTraceableBonus(double sliderFactor = 1) + { + // We want to reward slider aim less, more so at lower AR + double highApproachRateSliderVisibilityFactor = 0.5 + (DiffUtils.Pow(sliderFactor, 6) / 2); + double lowApproachRateSliderVisibilityFactor = DiffUtils.Pow(sliderFactor, 6); + + // 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.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.025 * (1 - DiffUtils.Pow(1.5, approachRate)) * lowApproachRateSliderVisibilityFactor; + + return traceableBonus; + } + // Miss penalty assumes that a player will miss on the hardest parts of a map, // so we use the amount of relatively difficult sections to adjust miss penalty // to make it more punishing on maps with lower amount of hard sections. - private double calculateMissPenalty(double missCount, double difficultStrainCount) => 0.96 / ((missCount / (4 * Math.Pow(Math.Log(difficultStrainCount), 0.94))) + 1); - private double getComboScalingFactor(OsuDifficultyAttributes attributes) => attributes.MaxCombo <= 0 ? 1.0 : Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(attributes.MaxCombo, 0.8), 1.0); + private double calculateMissPenalty(double missCount, double difficultStrainCount) => 0.93 / (missCount / (4 * Math.Log(difficultStrainCount)) + 1); + private double getComboScalingFactor(OsuDifficultyAttributes attributes) => attributes.MaxCombo <= 0 ? 1.0 : Math.Min(DiffUtils.Pow(scoreMaxCombo, 0.8) / DiffUtils.Pow(attributes.MaxCombo, 0.8), 1.0); + + private double calculateRateAdjustedApproachRate(double approachRate, double clockRate) + { + double preempt = IBeatmapDifficultyInfo.DifficultyRange(approachRate, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN) / clockRate; + return IBeatmapDifficultyInfo.InverseDifficultyRange(preempt, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN); + } private int totalHits => countGreat + countOk + countMeh + countMiss; private int totalSuccessfulHits => countGreat + countOk + countMeh; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs deleted file mode 100644 index 2a050c0920cf..000000000000 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuRatingCalculator.cs +++ /dev/null @@ -1,213 +0,0 @@ -// 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.Linq; -using osu.Game.Rulesets.Difficulty.Utils; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu.Mods; - -namespace osu.Game.Rulesets.Osu.Difficulty -{ - public class OsuRatingCalculator - { - private const double difficulty_multiplier = 0.0675; - - private readonly Mod[] mods; - private readonly int totalHits; - private readonly double approachRate; - private readonly double overallDifficulty; - private readonly double mechanicalDifficultyRating; - private readonly double sliderFactor; - - public OsuRatingCalculator(Mod[] mods, int totalHits, double approachRate, double overallDifficulty, double mechanicalDifficultyRating, double sliderFactor) - { - this.mods = mods; - this.totalHits = totalHits; - this.approachRate = approachRate; - this.overallDifficulty = overallDifficulty; - this.mechanicalDifficultyRating = mechanicalDifficultyRating; - this.sliderFactor = sliderFactor; - } - - public double ComputeAimRating(double aimDifficultyValue) - { - if (mods.Any(m => m is OsuModAutopilot)) - return 0; - - double aimRating = CalculateDifficultyRating(aimDifficultyValue); - - if (mods.Any(m => m is OsuModTouchDevice)) - aimRating = Math.Pow(aimRating, 0.8); - - if (mods.Any(m => m is OsuModRelax)) - aimRating *= 0.9; - - if (mods.Any(m => m is OsuModMagnetised)) - { - float magnetisedStrength = mods.OfType().First().AttractionStrength.Value; - aimRating *= 1.0 - magnetisedStrength; - } - - double ratingMultiplier = 1.0; - - double approachRateLengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + - (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); - - double approachRateFactor = 0.0; - if (approachRate > 10.33) - approachRateFactor = 0.3 * (approachRate - 10.33); - else if (approachRate < 8.0) - approachRateFactor = 0.05 * (8.0 - approachRate); - - if (mods.Any(h => h is OsuModRelax)) - approachRateFactor = 0.0; - - ratingMultiplier += approachRateFactor * approachRateLengthBonus; // Buff for longer maps with high AR. - - if (mods.Any(m => m is OsuModHidden)) - { - double visibilityFactor = calculateAimVisibilityFactor(approachRate); - ratingMultiplier += CalculateVisibilityBonus(mods, approachRate, visibilityFactor, sliderFactor); - } - - // It is important to consider accuracy difficulty when scaling with accuracy. - ratingMultiplier *= 0.98 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 2500; - - return aimRating * Math.Cbrt(ratingMultiplier); - } - - public double ComputeSpeedRating(double speedDifficultyValue) - { - if (mods.Any(m => m is OsuModRelax)) - return 0; - - double speedRating = CalculateDifficultyRating(speedDifficultyValue); - - if (mods.Any(m => m is OsuModAutopilot)) - speedRating *= 0.5; - - if (mods.Any(m => m is OsuModMagnetised)) - { - // reduce speed rating because of the speed distance scaling, with maximum reduction being 0.7x - float magnetisedStrength = mods.OfType().First().AttractionStrength.Value; - speedRating *= 1.0 - magnetisedStrength * 0.3; - } - - double ratingMultiplier = 1.0; - - double approachRateLengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + - (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); - - double approachRateFactor = 0.0; - if (approachRate > 10.33) - approachRateFactor = 0.3 * (approachRate - 10.33); - - if (mods.Any(m => m is OsuModAutopilot)) - approachRateFactor = 0.0; - - ratingMultiplier += approachRateFactor * approachRateLengthBonus; // Buff for longer maps with high AR. - - if (mods.Any(m => m is OsuModHidden)) - { - double visibilityFactor = calculateSpeedVisibilityFactor(approachRate); - ratingMultiplier += CalculateVisibilityBonus(mods, approachRate, visibilityFactor); - } - - ratingMultiplier *= 0.95 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 750; - - return speedRating * Math.Cbrt(ratingMultiplier); - } - - public double ComputeFlashlightRating(double flashlightDifficultyValue) - { - if (!mods.Any(m => m is OsuModFlashlight)) - return 0; - - double flashlightRating = CalculateDifficultyRating(flashlightDifficultyValue); - - if (mods.Any(m => m is OsuModTouchDevice)) - flashlightRating = Math.Pow(flashlightRating, 0.8); - - if (mods.Any(m => m is OsuModRelax)) - flashlightRating *= 0.7; - else if (mods.Any(m => m is OsuModAutopilot)) - flashlightRating *= 0.4; - - if (mods.Any(m => m is OsuModMagnetised)) - { - float magnetisedStrength = mods.OfType().First().AttractionStrength.Value; - flashlightRating *= 1.0 - magnetisedStrength; - } - - if (mods.Any(m => m is OsuModDeflate)) - { - float deflateInitialScale = mods.OfType().First().StartScale.Value; - flashlightRating *= Math.Clamp(DifficultyCalculationUtils.ReverseLerp(deflateInitialScale, 11, 1), 0.1, 1); - } - - double ratingMultiplier = 1.0; - - // Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius. - ratingMultiplier *= 0.7 + 0.1 * Math.Min(1.0, totalHits / 200.0) + - (totalHits > 200 ? 0.2 * Math.Min(1.0, (totalHits - 200) / 200.0) : 0.0); - - // It is important to consider accuracy difficulty when scaling with accuracy. - ratingMultiplier *= 0.98 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 2500; - - return flashlightRating * Math.Sqrt(ratingMultiplier); - } - - private double calculateAimVisibilityFactor(double approachRate) - { - const double ar_factor_end_point = 11.5; - - double mechanicalDifficultyFactor = DifficultyCalculationUtils.ReverseLerp(mechanicalDifficultyRating, 5, 10); - double arFactorStartingPoint = double.Lerp(9, 10.33, mechanicalDifficultyFactor); - - return DifficultyCalculationUtils.ReverseLerp(approachRate, ar_factor_end_point, arFactorStartingPoint); - } - - private double calculateSpeedVisibilityFactor(double approachRate) - { - const double ar_factor_end_point = 11.5; - - double mechanicalDifficultyFactor = DifficultyCalculationUtils.ReverseLerp(mechanicalDifficultyRating, 5, 10); - double arFactorStartingPoint = double.Lerp(10, 10.33, mechanicalDifficultyFactor); - - return DifficultyCalculationUtils.ReverseLerp(approachRate, ar_factor_end_point, arFactorStartingPoint); - } - - /// - /// Calculates a visibility bonus that is applicable to Hidden and Traceable. - /// - public static double CalculateVisibilityBonus(Mod[] mods, double approachRate, double visibilityFactor = 1, double sliderFactor = 1) - { - // NOTE: TC's effect is only noticeable in performance calculations until lazer mods are accounted for server-side. - bool isAlwaysPartiallyVisible = mods.OfType().Any(m => m.OnlyFadeApproachCircles.Value) || mods.OfType().Any(); - - // Start from normal curve, rewarding lower AR up to AR7 - // TC forcefully requires a lower reading bonus for now as it's post-applied in PP which makes it multiplicative with the regular AR bonuses - // This means it has an advantage over HD, so we decrease the multiplier to compensate - // This should be removed once we're able to apply TC bonuses in SR (depends on real-time difficulty calculations being possible) - double readingBonus = (isAlwaysPartiallyVisible ? 0.025 : 0.04) * (12.0 - Math.Max(approachRate, 7)); - - readingBonus *= visibilityFactor; - - // We want to reward slideraim on low AR less - double sliderVisibilityFactor = Math.Pow(sliderFactor, 3); - - // For AR up to 0 - reduce reward for very low ARs when object is visible - if (approachRate < 7) - readingBonus += (isAlwaysPartiallyVisible ? 0.02 : 0.045) * (7.0 - Math.Max(approachRate, 0)) * sliderVisibilityFactor; - - // Starting from AR0 - cap values so they won't grow to infinity - if (approachRate < 0) - readingBonus += (isAlwaysPartiallyVisible ? 0.01 : 0.1) * (1 - Math.Pow(1.5, approachRate)) * sliderVisibilityFactor; - - return readingBonus; - } - - public static double CalculateDifficultyRating(double difficultyValue) => Math.Sqrt(difficultyValue) * difficulty_multiplier; - } -} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 5e9fc10ef877..ced184299bf8 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -5,10 +5,10 @@ using System.Collections.Generic; using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Rulesets.Scoring; using osuTK; namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing @@ -35,6 +35,22 @@ public class OsuDifficultyHitObject : DifficultyHitObject /// public readonly double AdjustedDeltaTime; + /// + /// Amount of time elapsed between lastDifficultyObject's and capped to a minimum of ms. + /// + public double LastObjectEndDeltaTime { get; private set; } + + /// + /// Time (in ms) between the object first appearing and the time it needs to be clicked. + /// adjusted by clock rate. + /// + public double Preempt => BaseObject.TimePreempt / ClockRate; + + /// + /// Normalised distance from the start position of the previous to the start position of this . + /// + public double JumpDistance { get; private set; } + /// /// Normalised distance from the "lazy" end position of the previous to the start position of this . /// @@ -101,37 +117,27 @@ public class OsuDifficultyHitObject : DifficultyHitObject public double? Angle { get; private set; } /// - /// Retrieves the full hit window for a Great . + /// Angle of the vector created between current and current-1 + /// normalised to consider symmetrical vectors in any axis to be the same angle. /// - public double HitWindowGreat { get; private set; } + public double? NormalisedVectorAngle { get; private set; } /// /// Selective bonus for maps with higher circle size. /// - public double SmallCircleBonus { get; private set; } + public double SmallCircleBonus => Math.Max(1.0, 1.0 + (30 - BaseObject.Radius) / 70); - private readonly OsuDifficultyHitObject? lastLastDifficultyObject; - private readonly OsuDifficultyHitObject? lastDifficultyObject; + /// + /// Object's immediate OverallDifficulty value calculated from the raw hitwindow. + /// + public double OverallDifficulty => (79.5 - HitWindowGreat / 2) / 6; public OsuDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, List objects, int index) : base(hitObject, lastObject, clockRate, objects, index) { - lastLastDifficultyObject = index > 1 ? (OsuDifficultyHitObject)objects[index - 2] : null; - lastDifficultyObject = index > 0 ? (OsuDifficultyHitObject)objects[index - 1] : null; - // Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects. AdjustedDeltaTime = Math.Max(DeltaTime, MIN_DELTA_TIME); - - SmallCircleBonus = Math.Max(1.0, 1.0 + (30 - BaseObject.Radius) / 40); - - if (BaseObject is Slider sliderObject) - { - HitWindowGreat = 2 * sliderObject.HeadCircle.HitWindows.WindowFor(HitResult.Great) / clockRate; - } - else - { - HitWindowGreat = 2 * BaseObject.HitWindows.WindowFor(HitResult.Great) / clockRate; - } + LastObjectEndDeltaTime = Previous() is DifficultyHitObject last ? Math.Max(StartTime - last.EndTime, MIN_DELTA_TIME) : AdjustedDeltaTime; computeSliderCursorPosition(); setDistances(clockRate); @@ -148,7 +154,9 @@ public double OpacityAt(double time, bool hidden) } double fadeInStartTime = BaseObject.StartTime - BaseObject.TimePreempt; - double fadeInDuration = BaseObject.TimeFadeIn; + + // Equal to `OsuHitObject.TimeFadeIn` minus any adjustments from the HD mod. + double fadeInDuration = 400 * Math.Min(1, BaseObject.TimePreempt / OsuHitObject.PREEMPT_MIN); if (hidden) { @@ -169,19 +177,22 @@ public double OpacityAt(double time, bool hidden) /// /// Returns how possible is it to doubletap this object together with the next one and get perfect judgement in range from 0 to 1 /// - public double GetDoubletapness(OsuDifficultyHitObject? osuNextObj) + public double CalculateDoubleTapFeasibility(OsuDifficultyHitObject? nextObj) { - if (osuNextObj != null) - { - double currDeltaTime = Math.Max(1, DeltaTime); - double nextDeltaTime = Math.Max(1, osuNextObj.DeltaTime); - double deltaDifference = Math.Abs(nextDeltaTime - currDeltaTime); - double speedRatio = currDeltaTime / Math.Max(currDeltaTime, deltaDifference); - double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / HitWindowGreat), 2); - return 1.0 - Math.Pow(speedRatio, 1 - windowRatio); - } + if (nextObj == null) return 0; - return 0; + double currDeltaTime = Math.Max(1, DeltaTime); + double nextDeltaTime = Math.Max(1, nextObj.DeltaTime); + + double deltaDifference = Math.Abs(nextDeltaTime - currDeltaTime); + + double speedRatio = currDeltaTime / Math.Max(currDeltaTime, deltaDifference); + double windowRatio = DiffUtils.Pow(Math.Min(1, currDeltaTime / HitWindowGreat), 5); + + // Can't doubletap if circles don't intersect + double distanceFactor = DiffUtils.Pow(DiffUtils.ReverseLerp(LazyJumpDistance, NORMALISED_DIAMETER, NORMALISED_RADIUS), 2); + + return 1.0 - DiffUtils.Pow(speedRatio, distanceFactor * (1 - windowRatio)); } private void setDistances(double clockRate) @@ -189,10 +200,12 @@ private void setDistances(double clockRate) if (BaseObject is Slider currentSlider) { // Bonus for repeat sliders until a better per nested object strain system can be achieved. - TravelDistance = LazyTravelDistance * Math.Pow(1 + currentSlider.RepeatCount / 2.5, 1.0 / 2.5); + TravelDistance = LazyTravelDistance * Math.Max(1, DiffUtils.Pow(currentSlider.RepeatCount, 0.3)); TravelTime = Math.Max(LazyTravelTime / clockRate, MIN_DELTA_TIME); } + MinimumJumpTime = AdjustedDeltaTime; + // We don't need to calculate either angle or distance when one of the last->curr objects is a spinner if (BaseObject is Spinner || LastObject is Spinner) return; @@ -200,10 +213,13 @@ private void setDistances(double clockRate) // We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps. float scalingFactor = NORMALISED_RADIUS / (float)BaseObject.Radius; + var lastDifficultyObject = Previous() as OsuDifficultyHitObject; + var lastLastDifficultyObject = Previous(1) as OsuDifficultyHitObject; + Vector2 lastCursorPosition = lastDifficultyObject != null ? getEndCursorPosition(lastDifficultyObject) : LastObject.StackedPosition; - LazyJumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length; - MinimumJumpTime = AdjustedDeltaTime; + JumpDistance = (LastObject.StackedPosition - BaseObject.StackedPosition).Length * scalingFactor; + LazyJumpDistance = (BaseObject.StackedPosition - lastCursorPosition).Length * scalingFactor; MinimumJumpDistance = LazyJumpDistance; if (LastObject is Slider lastSlider && lastDifficultyObject != null) @@ -239,15 +255,18 @@ private void setDistances(double clockRate) if (lastLastDifficultyObject != null && lastLastDifficultyObject.BaseObject is not Spinner) { + if (lastDifficultyObject!.BaseObject is Slider prevSlider && lastDifficultyObject.TravelDistance > 0) + lastCursorPosition = prevSlider.HeadCircle.StackedPosition; + Vector2 lastLastCursorPosition = getEndCursorPosition(lastLastDifficultyObject); - Vector2 v1 = lastLastCursorPosition - LastObject.StackedPosition; - Vector2 v2 = BaseObject.StackedPosition - lastCursorPosition; + double angle = calculateAngle(BaseObject.StackedPosition, lastCursorPosition, lastLastCursorPosition); + double sliderAngle = calculateSliderAngle(lastDifficultyObject, lastLastCursorPosition); - float dot = Vector2.Dot(v1, v2); - float det = v1.X * v2.Y - v1.Y * v2.X; + Vector2 v = BaseObject.StackedPosition - lastCursorPosition; + NormalisedVectorAngle = Math.Atan2(Math.Abs(v.Y), Math.Abs(v.X)); - Angle = Math.Abs(Math.Atan2(det, dot)); + Angle = Math.Min(angle, sliderAngle); } } @@ -359,6 +378,30 @@ private void computeSliderCursorPosition() } } + private double calculateSliderAngle(OsuDifficultyHitObject lastDifficultyObject, Vector2 lastLastCursorPosition) + { + Vector2 lastCursorPosition = getEndCursorPosition(lastDifficultyObject); + + if (lastDifficultyObject.BaseObject is Slider prevSlider && lastDifficultyObject.TravelDistance > 0) + { + OsuHitObject secondLastNestedObject = (OsuHitObject)prevSlider.NestedHitObjects[^2]; + lastLastCursorPosition = secondLastNestedObject.StackedPosition; + } + + return calculateAngle(BaseObject.StackedPosition, lastCursorPosition, lastLastCursorPosition); + } + + private double calculateAngle(Vector2 currentPosition, Vector2 lastPosition, Vector2 lastLastPosition) + { + Vector2 v1 = lastLastPosition - lastPosition; + Vector2 v2 = currentPosition - lastPosition; + + float dot = Vector2.Dot(v1, v2); + float det = v1.X * v2.Y - v1.Y * v2.X; + + return Math.Abs(Math.Atan2(det, dot)); + } + private Vector2 getEndCursorPosition(OsuDifficultyHitObject difficultyHitObject) { return difficultyHitObject.LazyEndPosition ?? difficultyHitObject.BaseObject.StackedPosition; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 5816d27a5e81..11e78c00bbf1 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -4,10 +4,14 @@ using System; using System.Collections.Generic; using System.Linq; +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.Mods; -using osu.Game.Rulesets.Osu.Difficulty.Evaluators; -using osu.Game.Rulesets.Osu.Difficulty.Utils; +using osu.Game.Rulesets.Osu.Difficulty.Evaluators.Aim; +using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Difficulty.Skills @@ -15,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// /// Represents the skill required to correctly aim at every object in the map with a uniform CircleSize and normalized distances. /// - public class Aim : OsuStrainSkill + public class Aim : VariableLengthStrainSkill { public readonly bool IncludeSliders; @@ -27,19 +31,33 @@ public Aim(Mod[] mods, bool includeSliders) private double currentStrain; - private double skillMultiplier => 26; - private double strainDecayBase => 0.15; + /// + /// The number of sections with the highest strains, which the peak strain reductions will apply to. + /// This is done in order to decrease their impact on the overall difficulty of the map for this skill. + /// + private int reducedSectionTime => 4000; + + /// + /// The baseline multiplier applied to the section with the biggest strain. + /// + private const double reduced_strain_baseline = 0.727; private readonly List sliderStrains = new List(); - private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); + private double strainDecay(double ms) => DiffUtils.Pow(0.2, ms / 1000); - protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => currentStrain * strainDecay(time - current.Previous(0).StartTime); + protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => + currentStrain * strainDecay(time - current.Previous(0).StartTime); protected override double StrainValueAt(DifficultyHitObject current) { - currentStrain *= strainDecay(current.DeltaTime); - currentStrain += AimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplier; + if (Mods.Any(m => m is OsuModAutopilot)) + return 0; + + double decay = strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime); + + currentStrain *= decay; + currentStrain += calculateAdjustedDifficulty(current) * (1 - decay); if (current.BaseObject is Slider) sliderStrains.Add(currentStrain); @@ -47,6 +65,82 @@ protected override double StrainValueAt(DifficultyHitObject current) return currentStrain; } + private double calculateAdjustedDifficulty(DifficultyHitObject current) + { + const double skill_multiplier_snap = 70.9; + const double skill_multiplier_agility = 2.35; + const double skill_multiplier_flow = 242.0; + + double snapDifficulty = SnapAimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skill_multiplier_snap; + double agilityDifficulty = AgilityEvaluator.EvaluateDifficultyOf(current) * skill_multiplier_agility; + double flowDifficulty = FlowAimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skill_multiplier_flow; + + double totalDifficulty = calculateTotalValue(snapDifficulty, agilityDifficulty, flowDifficulty); + + if (Mods.Any(m => m is OsuModMagnetised)) + { + float magnetisedStrength = Mods.OfType().First().AttractionStrength.Value; + totalDifficulty *= 1.0 - magnetisedStrength; + } + + totalDifficulty *= 0.985 + DiffUtils.Pow(Math.Max(0, ((OsuDifficultyHitObject)current).OverallDifficulty), 2) / 4000; + + return totalDifficulty; + } + + private double calculateTotalValue(double snapDifficulty, double agilityDifficulty, double flowDifficulty) + { + const double skill_multiplier_total = 1.12; + const double combined_snap_norm_exponent = 1.2; + + // 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 = DiffUtils.Norm(combined_snap_norm_exponent, 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 = DiffUtils.Pow(snapDifficulty, 0.89); + combinedSnapDifficulty = DiffUtils.Norm(combined_snap_norm_exponent, snapDifficulty, agilityDifficulty); + } + + if (Mods.Any(m => m is OsuModRelax)) + { + combinedSnapDifficulty *= 0.75; + flowDifficulty *= 0.6; + } + + double totalDifficulty = combinedSnapDifficulty * pSnap + flowDifficulty * pFlow; + + double totalStrain = totalDifficulty * skill_multiplier_total; + + 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 DiffUtils.Logistic(-k * Math.Log(ratio)); + } + public double GetDifficultSliders() { if (sliderStrains.Count == 0) @@ -60,6 +154,97 @@ public double GetDifficultSliders() return sliderStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxSliderStrain * 12.0 - 6.0)))); } - public double CountTopWeightedSliders() => OsuStrainUtils.CountTopWeightedSliders(sliderStrains, DifficultyValue()); + public double CountTopWeightedSliders(double difficultyValue) + { + if (sliderStrains.Count == 0) + return 0; + + double consistentTopStrain = difficultyValue * (1 - DecayWeight); // What would the top strain be if all strain values were identical + + if (consistentTopStrain == 0) + return 0; + + // Use a weighted sum of all strains. Constants are arbitrary and give nice values + return sliderStrains.Sum(s => DiffUtils.Logistic(s / consistentTopStrain, 0.88, 10, 1.1)); + } + + public override double DifficultyValue() + { + double difficulty = 0; + double time = 0; + + var strains = getReducedStrainPeaks(); + + // Difficulty is a continuous weighted sum of the sorted strains + foreach (StrainPeak strain in strains) + { + /* 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 = DiffUtils.Pow(DecayWeight, startTime) - DiffUtils.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 + strain.SectionLength / MaxSectionLength; + + double weight = DiffUtils.Pow(DecayWeight, startTime) - DiffUtils.Pow(DecayWeight, endTime); + + difficulty += strain.Value * weight; + time = endTime; + } + + return difficulty / (1 - DecayWeight); + } + + /// + /// Returns a sorted enumerable of strain peaks with the highest values reduced. + /// + /// + private IEnumerable getReducedStrainPeaks() + { + // 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. + + List strains = GetCurrentStrainPeaks() + .Where(p => p.Value > 0) + .ToList(); + + const int chunk_size = 20; + double time = 0; + int skipCount = 0; + + // We are reducing the highest strains first to account for extreme difficulty spikes + // Strains are split into 20ms chunks to try to mitigate inconsistencies caused by reducing strains + while (strains.Count > skipCount && time < reducedSectionTime) + { + StrainPeak strain = strains[skipCount]; + + for (double addedTime = 0; addedTime < strain.SectionLength; addedTime += chunk_size) + { + double scale = Math.Log10(Interpolation.Lerp(1, 10, Math.Clamp((time + addedTime) / reducedSectionTime, 0, 1))); + + // intentionally add at end and sort afterwards, should be cheaper. + strains.Add(new StrainPeak( + strain.Value * Interpolation.Lerp(reduced_strain_baseline, 1.0, scale), + Math.Min(chunk_size, strain.SectionLength - addedTime) + )); + } + + time += strain.SectionLength; + skipCount++; + } + + return strains.Skip(skipCount).OrderByDescending(p => p.Value); + } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs index 6c839eac3fef..85666881ad70 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs @@ -5,8 +5,10 @@ using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Evaluators; +using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Mods; namespace osu.Game.Rulesets.Osu.Difficulty.Skills @@ -16,33 +18,74 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// public class Flashlight : StrainSkill { - private readonly bool hasHiddenMod; + private readonly int totalObjects; - public Flashlight(Mod[] mods) + public Flashlight(Mod[] mods, int totalObjects) : base(mods) { - hasHiddenMod = mods.Any(m => m is OsuModHidden); + this.totalObjects = totalObjects; } - private double skillMultiplier => 0.05512; - private double strainDecayBase => 0.15; - private double currentStrain; - private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); + private double strainDecay(double ms) => DiffUtils.Pow(0.15, ms / 1000); protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => currentStrain * strainDecay(time - current.Previous(0).StartTime); protected override double StrainValueAt(DifficultyHitObject current) { + const double skill_multiplier = 0.058; + + if (!Mods.Any(m => m is OsuModFlashlight)) + return 0; + currentStrain *= strainDecay(current.DeltaTime); - currentStrain += FlashlightEvaluator.EvaluateDifficultyOf(current, hasHiddenMod) * skillMultiplier; + currentStrain += calculateAdjustedDifficulty(current) * skill_multiplier; return currentStrain; } - public override double DifficultyValue() => GetCurrentStrainPeaks().Sum(); + private double calculateAdjustedDifficulty(DifficultyHitObject current) + { + double difficulty = FlashlightEvaluator.EvaluateDifficultyOf(current, Mods); + + if (Mods.Any(m => m is OsuModTouchDevice)) + difficulty = DiffUtils.Pow(difficulty, 0.9); + + if (Mods.Any(m => m is OsuModMagnetised)) + { + float magnetisedStrength = Mods.OfType().First().AttractionStrength.Value; + difficulty *= 1.0 - magnetisedStrength; + } + + if (Mods.Any(m => m is OsuModDeflate)) + { + float deflateInitialScale = Mods.OfType().First().StartScale.Value; + difficulty *= Math.Clamp(DiffUtils.ReverseLerp(deflateInitialScale, 11, 1), 0.1, 1); + } + + if (Mods.Any(m => m is OsuModRelax)) + difficulty *= 0.7; + + if (Mods.Any(m => m is OsuModAutopilot)) + difficulty *= 0.4; + + difficulty *= 0.985 + DiffUtils.Pow(Math.Max(0, ((OsuDifficultyHitObject)current).OverallDifficulty), 2) / 4000; + + return difficulty; + } + + public override double DifficultyValue() + { + double sum = GetCurrentStrainPeaks().Sum(); + + // Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius. + sum *= 0.7 + 0.1 * Math.Min(1.0, totalObjects / 200.0) + + (totalObjects > 200 ? 0.2 * Math.Min(1.0, (totalObjects - 200) / 200.0) : 0.0); + + return sum; + } - public static double DifficultyToPerformance(double difficulty) => 25 * Math.Pow(difficulty, 2); + public static double DifficultyToPerformance(double difficulty) => 25 * DiffUtils.Pow(difficulty, 2); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs deleted file mode 100644 index 6823512cef12..000000000000 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs +++ /dev/null @@ -1,62 +0,0 @@ -// 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 osu.Game.Rulesets.Difficulty.Skills; -using osu.Game.Rulesets.Mods; -using System.Linq; -using osu.Framework.Utils; - -namespace osu.Game.Rulesets.Osu.Difficulty.Skills -{ - public abstract class OsuStrainSkill : StrainSkill - { - /// - /// The number of sections with the highest strains, which the peak strain reductions will apply to. - /// This is done in order to decrease their impact on the overall difficulty of the map for this skill. - /// - protected virtual int ReducedSectionCount => 10; - - /// - /// The baseline multiplier applied to the section with the biggest strain. - /// - protected virtual double ReducedStrainBaseline => 0.75; - - protected OsuStrainSkill(Mod[] mods) - : base(mods) - { - } - - public override double DifficultyValue() - { - double difficulty = 0; - double weight = 1; - - // 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 > 0); - - List strains = peaks.OrderDescending().ToList(); - - // We are reducing the highest strains first to account for extreme difficulty spikes - for (int i = 0; i < Math.Min(strains.Count, ReducedSectionCount); i++) - { - double scale = Math.Log10(Interpolation.Lerp(1, 10, Math.Clamp((float)i / ReducedSectionCount, 0, 1))); - strains[i] *= Interpolation.Lerp(ReducedStrainBaseline, 1.0, scale); - } - - // Difficulty is the weighted sum of the highest strains from every section. - // We're sorting from highest to lowest strain. - foreach (double strain in strains.OrderDescending()) - { - difficulty += strain * weight; - weight *= DecayWeight; - } - - return difficulty; - } - - public static double DifficultyToPerformance(double difficulty) => Math.Pow(5.0 * Math.Max(1.0, difficulty / 0.0675) - 4.0, 3.0) / 100000.0; - } -} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs new file mode 100644 index 000000000000..b28352131aef --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs @@ -0,0 +1,127 @@ +// 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.Utils; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Difficulty.Evaluators; +using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Mods; + +namespace osu.Game.Rulesets.Osu.Difficulty.Skills +{ + public class Reading : HarmonicSkill + { + private readonly List objectList = new List(); + + private readonly bool hasHiddenMod; + + public Reading(Mod[] mods) + : base(mods) + { + hasHiddenMod = mods.OfType().Any(m => !m.OnlyFadeApproachCircles.Value); + } + + private double currentStrain; + + private double strainDecay(double ms) => DiffUtils.Pow(0.8, ms / 1000); + + protected override double ObjectDifficultyOf(DifficultyHitObject current) + { + const double skill_multiplier = 2.5; + + objectList.Add(current); + + double decay = strainDecay(current.DeltaTime); + + currentStrain *= decay; + currentStrain += calculateAdjustedDifficulty(current) * (1 - decay) * skill_multiplier; + + return currentStrain; + } + + private double calculateAdjustedDifficulty(DifficultyHitObject current) + { + double difficulty = ReadingEvaluator.EvaluateDifficultyOf(current, hasHiddenMod); + + if (Mods.Any(m => m is OsuModTouchDevice)) + difficulty = DiffUtils.Pow(difficulty, 0.89); + + if (Mods.Any(m => m is OsuModMagnetised)) + { + float magnetisedStrength = Mods.OfType().First().AttractionStrength.Value; + difficulty *= 1.0 - magnetisedStrength; + } + + if (Mods.Any(m => m is OsuModRelax)) + difficulty *= 0.4; + + if (Mods.Any(m => m is OsuModAutopilot)) + difficulty *= 0.1; + + difficulty *= 0.825 + DiffUtils.Pow(Math.Max(0, ((OsuDifficultyHitObject)current).OverallDifficulty), 2.2) / 1125.0; + + return difficulty; + } + + protected override List GetTransformedDifficulties(List difficulties) + { + difficulties = difficulties.Where(v => v > 0).ToList(); + + const double reduced_difficulty_base_line = 0.0; // Assume the first seconds are completely memorised + + int reducedNoteCount = calculateReducedNoteCount(); + + for (int i = 0; i < Math.Min(difficulties.Count, reducedNoteCount); i++) + { + double scale = Math.Log10(Interpolation.Lerp(1, 10, Math.Clamp((double)i / reducedNoteCount, 0, 1))); + difficulties[i] *= Interpolation.Lerp(reduced_difficulty_base_line, 1.0, scale); + } + + return difficulties; + } + + private int calculateReducedNoteCount() + { + const double reduced_difficulty_duration = 60 * 1000; + + if (objectList.Count == 0) + return 0; + + double reducedDuration = objectList.First().StartTime + reduced_difficulty_duration; + + int reducedNoteCount = 0; + + foreach (var hitObject in objectList) + { + if (hitObject.StartTime > reducedDuration) + break; + + reducedNoteCount++; + } + + return reducedNoteCount; + } + + public override double CountTopWeightedObjectDifficulties(double difficultyValue) + { + if (ObjectDifficulties.Count == 0) + return 0.0; + + if (ObjectWeightSum == 0) + return 0.0; + + double consistentTopNote = difficultyValue / ObjectWeightSum; // What would the top difficulty be if all object difficulties were identical + + if (consistentTopNote == 0) + return 0; + + return ObjectDifficulties.Sum(d => DiffUtils.Logistic(d / consistentTopNote, 1.15, 5, 1.1)); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index 8fe3df43470e..a4432d445816 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -3,46 +3,50 @@ using System; using System.Collections.Generic; +using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu.Difficulty.Evaluators; +using osu.Game.Rulesets.Osu.Difficulty.Evaluators.Speed; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; -using System.Linq; -using osu.Game.Rulesets.Osu.Difficulty.Utils; namespace osu.Game.Rulesets.Osu.Difficulty.Skills { /// /// Represents the skill required to press keys with regards to keeping up with the speed at which objects need to be hit. /// - public class Speed : OsuStrainSkill + public class Speed : HarmonicSkill { - private double skillMultiplier => 1.47; - private double strainDecayBase => 0.3; + private readonly List sliderStrains = new List(); private double currentStrain; - private double currentRhythm; - private readonly List sliderStrains = new List(); - - protected override int ReducedSectionCount => 5; + protected override double HarmonicScale => 20; + protected override double DecayExponent => 0.9; public Speed(Mod[] mods) : base(mods) { } - private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); - - protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => (currentStrain * currentRhythm) * strainDecay(time - current.Previous(0).StartTime); + private double strainDecay(double ms) => DiffUtils.Pow(0.3, ms / 1000); - protected override double StrainValueAt(DifficultyHitObject current) + protected override double ObjectDifficultyOf(DifficultyHitObject current) { - currentStrain *= strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime); - currentStrain += SpeedEvaluator.EvaluateDifficultyOf(current, Mods) * skillMultiplier; + const double skill_multiplier = 1.16; - currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current); + if (Mods.Any(m => m is OsuModRelax)) + return 0; + + double decay = strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime); + + currentStrain *= decay; + currentStrain += calculateAdjustedDifficulty(current) * (1 - decay) * skill_multiplier; + + double currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current); double totalStrain = currentStrain * currentRhythm; @@ -52,18 +56,44 @@ protected override double StrainValueAt(DifficultyHitObject current) return totalStrain; } - public double RelevantNoteCount() + private double calculateAdjustedDifficulty(DifficultyHitObject current) + { + double difficulty = SpeedEvaluator.EvaluateDifficultyOf(current); + + if (Mods.Any(m => m is OsuModAutopilot)) + difficulty *= 0.5; + + return difficulty; + } + + public double RelevantObjectCount() { - if (ObjectStrains.Count == 0) + if (ObjectDifficulties.Count == 0) return 0; - double maxStrain = ObjectStrains.Max(); + double maxStrain = ObjectDifficulties.Max(); + if (maxStrain == 0) return 0; - return ObjectStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxStrain * 12.0 - 6.0)))); + return ObjectDifficulties.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxStrain * 12.0 - 6.0)))); } - public double CountTopWeightedSliders() => OsuStrainUtils.CountTopWeightedSliders(sliderStrains, DifficultyValue()); + public double CountTopWeightedSliders(double difficultyValue) + { + if (sliderStrains.Count == 0) + return 0; + + if (ObjectWeightSum == 0) + return 0.0; + + double consistentTopObject = difficultyValue / ObjectWeightSum; // What would the top note be if all note values were identical + + if (consistentTopObject == 0) + return 0; + + // Use a weighted sum of all notes. Constants are arbitrary and give nice values + return sliderStrains.Sum(s => DiffUtils.Logistic(s / consistentTopObject, 0.88, 10, 1.1)); + } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Utils/LegacyScoreUtils.cs b/osu.Game.Rulesets.Osu/Difficulty/Utils/LegacyScoreUtils.cs index df1683fb2946..183f2e53c4fb 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Utils/LegacyScoreUtils.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Utils/LegacyScoreUtils.cs @@ -19,25 +19,28 @@ public static double CalculateNestedScorePerObject(IBeatmap beatmap, int objectC const double big_tick_score = 30; const double small_tick_score = 10; - var sliders = beatmap.HitObjects.OfType().ToArray(); - - // 1 for head, 1 for tail - int amountOfBigTicks = sliders.Length * 2; - - // Add slider repeats - amountOfBigTicks += sliders.Select(s => s.RepeatCount).Sum(); - - int amountOfSmallTicks = sliders.Select(s => s.NestedHitObjects.Count(nho => nho is SliderTick)).Sum(); - - double sliderScore = amountOfBigTicks * big_tick_score + amountOfSmallTicks * small_tick_score; - + int amountOfBigTicks = 0; + int amountOfSmallTicks = 0; double spinnerScore = 0; - foreach (var spinner in beatmap.HitObjects.OfType()) + foreach (var obj in beatmap.HitObjects) { - spinnerScore += calculateSpinnerScore(spinner); + switch (obj) + { + case Slider s: + // 1 for head, 1 for tail, plus repeats + amountOfBigTicks += 2 + s.RepeatCount; + amountOfSmallTicks += s.NestedHitObjects.Count(nho => nho is SliderTick); + break; + + case Spinner sp: + spinnerScore += calculateSpinnerScore(sp); + break; + } } + double sliderScore = amountOfBigTicks * big_tick_score + amountOfSmallTicks * small_tick_score; + return (sliderScore + spinnerScore) / objectCount; } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Utils/OsuStrainUtils.cs b/osu.Game.Rulesets.Osu/Difficulty/Utils/OsuStrainUtils.cs deleted file mode 100644 index 8a78192ee4cf..000000000000 --- a/osu.Game.Rulesets.Osu/Difficulty/Utils/OsuStrainUtils.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Generic; -using System.Linq; -using osu.Game.Rulesets.Difficulty.Utils; - -namespace osu.Game.Rulesets.Osu.Difficulty.Utils -{ - public static class OsuStrainUtils - { - public static double CountTopWeightedSliders(IReadOnlyCollection sliderStrains, double difficultyValue) - { - if (sliderStrains.Count == 0) - return 0; - - double consistentTopStrain = difficultyValue / 10; // What would the top strain be if all strain values were identical - - if (consistentTopStrain == 0) - return 0; - - // Use a weighted sum of all strains. Constants are arbitrary and give nice values - return sliderStrains.Sum(s => DifficultyCalculationUtils.Logistic(s / consistentTopStrain, 0.88, 10, 1.1)); - } - } -} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs index d8d30e3fef50..5e9089651698 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs @@ -61,7 +61,7 @@ private static double consistentRatioPenalty(TaikoDifficultyHitObject hitObject, // As a fallback, calculate the maximum deviation from the average of the recent ratios to ensure slightly off-snapped objects don't bypass the penalty. double maxRatioDeviation = recentRatios.Max(r => Math.Abs(r - recentRatios.Average())); - double consistentRatioPenalty = 0.7 + 0.3 * DifficultyCalculationUtils.Smootherstep(maxRatioDeviation, 0.0, 1.0); + double consistentRatioPenalty = 0.7 + 0.3 * DiffUtils.Smootherstep(maxRatioDeviation, 0.0, 1.0); return consistentRatioPenalty; } @@ -91,12 +91,12 @@ public static double EvaluateDifficultyOf(DifficultyHitObject hitObject) } private static double evaluateMonoStreakDifficulty(MonoStreak monoStreak) => - DifficultyCalculationUtils.Logistic(exponent: Math.E * monoStreak.Index - 2 * Math.E) * evaluateAlternatingMonoPatternDifficulty(monoStreak.Parent) * 0.5; + DiffUtils.Logistic(exponent: Math.E * monoStreak.Index - 2 * Math.E) * evaluateAlternatingMonoPatternDifficulty(monoStreak.Parent) * 0.5; private static double evaluateAlternatingMonoPatternDifficulty(AlternatingMonoPattern alternatingMonoPattern) => - DifficultyCalculationUtils.Logistic(exponent: Math.E * alternatingMonoPattern.Index - 2 * Math.E) * evaluateRepeatingHitPatternsDifficulty(alternatingMonoPattern.Parent); + DiffUtils.Logistic(exponent: Math.E * alternatingMonoPattern.Index - 2 * Math.E) * evaluateRepeatingHitPatternsDifficulty(alternatingMonoPattern.Parent); private static double evaluateRepeatingHitPatternsDifficulty(RepeatingHitPatterns repeatingHitPattern) => - 2 * (1 - DifficultyCalculationUtils.Logistic(exponent: Math.E * repeatingHitPattern.RepetitionInterval - 2 * Math.E)); + 2 * (1 - DiffUtils.Logistic(exponent: Math.E * repeatingHitPattern.RepetitionInterval - 2 * Math.E)); } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ReadingEvaluator.cs b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ReadingEvaluator.cs index 58719796133f..4092c4e688eb 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ReadingEvaluator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ReadingEvaluator.cs @@ -38,17 +38,17 @@ public static double EvaluateDifficultyOf(TaikoDifficultyHitObject noteObject) // Apply a cap to prevent outlier values on maps that exceed the editor's parameters. double effectiveBPM = Math.Max(1.0, noteObject.EffectiveBPM); - double midVelocityDifficulty = 0.5 * DifficultyCalculationUtils.Logistic(effectiveBPM, midVelocity.Center, 1.0 / (midVelocity.Range / 10)); + double midVelocityDifficulty = 0.5 * DiffUtils.Logistic(effectiveBPM, midVelocity.Center, 1.0 / (midVelocity.Range / 10)); // Expected DeltaTime is the DeltaTime this note would need to be spaced equally to a base slider velocity 1/4 note. double expectedDeltaTime = 21000.0 / effectiveBPM; double objectDensity = expectedDeltaTime / Math.Max(1.0, noteObject.DeltaTime); // High density is penalised at high velocity as it is generally considered easier to read. See https://www.desmos.com/calculator/u63f3ntdsi - double densityPenalty = DifficultyCalculationUtils.Logistic(objectDensity, 0.925, 15); + double densityPenalty = DiffUtils.Logistic(objectDensity, 0.925, 15); double highVelocityDifficulty = (1.0 - 0.33 * densityPenalty) - * DifficultyCalculationUtils.Logistic(effectiveBPM, highVelocity.Center + 8 * densityPenalty, (1.0 + 0.5 * densityPenalty) / (highVelocity.Range / 10)); + * DiffUtils.Logistic(effectiveBPM, highVelocity.Center + 8 * densityPenalty, (1.0 + 0.5 * densityPenalty) / (highVelocity.Range / 10)); return midVelocityDifficulty + highVelocityDifficulty; } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs index 3b3aea07f314..5bf46611d0ff 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs @@ -8,33 +8,41 @@ using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data; +using osu.Game.Rulesets.Taiko.Objects; namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators { - public class RhythmEvaluator + public static class RhythmEvaluator { /// /// Evaluate the difficulty of a hitobject considering its interval change. /// - public static double EvaluateDifficultyOf(DifficultyHitObject hitObject, double hitWindow) + public static double EvaluateDifficultyOf(DifficultyHitObject hitObject) { + if (hitObject.BaseObject is not Hit) + return 0; + TaikoRhythmData rhythmData = ((TaikoDifficultyHitObject)hitObject).RhythmData; double difficulty = 0.0d; double sameRhythm = 0; double samePattern = 0; double intervalPenalty = 0; + double gapPenalty = 0; + + double hitWindow = hitObject.HitWindowGreat; if (rhythmData.SameRhythmGroupedHitObjects?.FirstHitObject == hitObject) // Difficulty for SameRhythmGroupedHitObjects { sameRhythm += 10.0 * evaluateDifficultyOf(rhythmData.SameRhythmGroupedHitObjects, hitWindow); intervalPenalty = repeatedIntervalPenalty(rhythmData.SameRhythmGroupedHitObjects, hitWindow); + gapPenalty = longGapPenalty(rhythmData.SameRhythmGroupedHitObjects.Previous); } if (rhythmData.SamePatternsGroupedHitObjects?.FirstHitObject == hitObject) // Difficulty for SamePatternsGroupedHitObjects samePattern += 1.15 * ratioDifficulty(rhythmData.SamePatternsGroupedHitObjects.IntervalRatio); - difficulty += Math.Max(sameRhythm, samePattern) * intervalPenalty; + difficulty += Math.Max(sameRhythm, samePattern) * intervalPenalty * gapPenalty; return difficulty; } @@ -54,22 +62,22 @@ private static double evaluateDifficultyOf(SameRhythmHitObjectGrouping sameRhyth if (durationDifference > 0) { - intervalDifficulty *= DifficultyCalculationUtils.Logistic( + intervalDifficulty *= DiffUtils.Logistic( durationDifference / hitWindow, - midpointOffset: 0.7, - multiplier: 1.0, + midpointOffset: 0.35, + multiplier: 2, maxValue: 1); } } // Penalise patterns that can be hit within a single hit window. - intervalDifficulty *= DifficultyCalculationUtils.Logistic( + intervalDifficulty *= DiffUtils.Logistic( sameRhythmGroupedHitObjects.Duration / hitWindow, - midpointOffset: 0.6, - multiplier: 1, + midpointOffset: 0.3, + multiplier: 2, maxValue: 1); - return Math.Pow(intervalDifficulty, 0.75); + return DiffUtils.Pow(intervalDifficulty, 0.75); } /// @@ -118,6 +126,32 @@ double sameInterval(SameRhythmHitObjectGrouping startObject, int intervalCount) } } + /// + /// Frequent rhythm changes containing long gaps (i.e. 1/4 + 1/6 with 1/2 gaps) award more difficulty than expected. + /// Due to limitations of the current rhythm evaluation, these cases are targeted and penalised. + /// The previous hit object grouping is used as often the rhythm change *two* rhythms after a long gap awards the unexpected difficulty. + /// + private static double longGapPenalty(SameRhythmHitObjectGrouping? previous) + { + if (previous == null) + return 1.0; + + double gapInterval = previous.FirstHitObject.DeltaTime; + double rhythmInterval = previous.HitObjectInterval ?? gapInterval; + double rhythmLength = previous.HitObjects.Count; + + // The ratio of the gap before this rhythm to the rhythm itself. + double gapRatio = gapInterval / Math.Max(rhythmInterval, 1); + + // The gap ratio normalised to represent if the gap is long. + double gapFactor = DiffUtils.Logistic(gapRatio, 1.75, 20); + + // The length in objects of this rhythm normalised to represent if the rhythm change is frequent enough to be penalised. + double lengthFactor = DiffUtils.ReverseLerp(rhythmLength, 8, 2); + + return 1.0 - 0.75 * gapFactor * lengthFactor; + } + /// /// Calculates the difficulty of a given ratio using a combination of periodic penalties and bonuses. /// @@ -136,10 +170,10 @@ private static double ratioDifficulty(double ratio, int terms = 8) difficulty += terms / (1 + ratio); // Give bonus to near-1 ratios - difficulty += DifficultyCalculationUtils.BellCurve(ratio, 1, 0.5); + difficulty += DiffUtils.BellCurve(ratio, 1, 0.5); // Penalize ratios that are VERY near 1 - difficulty -= DifficultyCalculationUtils.BellCurve(ratio, 1, 0.3); + difficulty -= DiffUtils.BellCurve(ratio, 1, 0.3); difficulty = Math.Max(difficulty, 0); difficulty /= Math.Sqrt(8); @@ -151,6 +185,6 @@ private static double ratioDifficulty(double ratio, int terms = 8) /// Multiplier for a given denominator term. /// private static double termPenalty(double ratio, int denominator, double power, double multiplier) => - -multiplier * Math.Pow(Math.Cos(denominator * Math.PI * ratio), power); + -multiplier * DiffUtils.Pow(Math.Cos(denominator * Math.PI * ratio), power); } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs index f407e13ff1d6..f9bb38688d6f 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs @@ -7,10 +7,10 @@ using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Taiko.Difficulty.Evaluators; -using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm; using osu.Game.Rulesets.Taiko.Difficulty.Utils; +using osu.Game.Rulesets.Taiko.Objects; namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing { diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Reading.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Reading.cs index 7be1107b7042..7e3006dfbf19 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Reading.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Reading.cs @@ -37,7 +37,7 @@ protected override double StrainValueOf(DifficultyHitObject current) var taikoObject = (TaikoDifficultyHitObject)current; int index = taikoObject.ColourData.MonoStreak?.HitObjects.IndexOf(taikoObject) ?? 0; - currentStrain *= DifficultyCalculationUtils.Logistic(index, 4, -1 / 25.0, 0.5) + 0.5; + currentStrain *= DiffUtils.Logistic(index, 4, -1 / 25.0, 0.5) + 0.5; currentStrain *= StrainDecayBase; currentStrain += ReadingEvaluator.EvaluateDifficultyOf(taikoObject) * SkillMultiplier; diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs index 45d0d0a5480f..d04d50ee880d 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs @@ -17,21 +17,18 @@ public class Rhythm : StrainDecaySkill protected override double SkillMultiplier => 1.0; protected override double StrainDecayBase => 0.4; - private readonly double greatHitWindow; - - public Rhythm(Mod[] mods, double greatHitWindow) + public Rhythm(Mod[] mods) : base(mods) { - this.greatHitWindow = greatHitWindow; } protected override double StrainValueOf(DifficultyHitObject current) { - double difficulty = RhythmEvaluator.EvaluateDifficultyOf(current, greatHitWindow); + double difficulty = RhythmEvaluator.EvaluateDifficultyOf(current); // To prevent abuse of exceedingly long intervals between awkward rhythms, we penalise its difficulty. double staminaDifficulty = StaminaEvaluator.EvaluateDifficultyOf(current) - 0.5; // Remove base strain - difficulty *= DifficultyCalculationUtils.Logistic(staminaDifficulty, 1 / 15.0, 50.0); + difficulty *= DiffUtils.Logistic(staminaDifficulty, 1 / 15.0, 50.0); return difficulty; } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs index 5e18163fe0ec..023d7b6574a0 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs @@ -1,7 +1,6 @@ // 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.Skills; using osu.Game.Rulesets.Difficulty.Utils; @@ -16,8 +15,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills /// public class Stamina : StrainSkill { - private double skillMultiplier => 1.1; - private double strainDecayBase => 0.4; + private const double skill_multiplier = 1.1; + private const double strain_decay_base = 0.4; public readonly bool SingleColourStamina; private readonly bool isConvert; @@ -37,18 +36,18 @@ public Stamina(Mod[] mods, bool singleColourStamina, bool isConvert) this.isConvert = isConvert; } - private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); + private double strainDecay(double ms) => DiffUtils.Pow(strain_decay_base, ms / 1000); protected override double StrainValueAt(DifficultyHitObject current) { currentStrain *= strainDecay(current.DeltaTime); - double staminaDifficulty = StaminaEvaluator.EvaluateDifficultyOf(current) * skillMultiplier; + double staminaDifficulty = StaminaEvaluator.EvaluateDifficultyOf(current) * skill_multiplier; // Safely prevents previous strains from shifting as new notes are added. var currentObject = current as TaikoDifficultyHitObject; int index = currentObject?.ColourData.MonoStreak?.HitObjects.IndexOf(currentObject) ?? 0; - double monoLengthBonus = isConvert ? 1.0 : 1.0 + 0.5 * DifficultyCalculationUtils.ReverseLerp(index, 5, 20); + double monoLengthBonus = isConvert ? 1.0 : 1.0 + 0.5 * DiffUtils.ReverseLerp(index, 5, 20); // Mono-streak bonus is only applied to colour-based stamina to reward longer sequences of same-colour hits within patterns. if (!SingleColourStamina) @@ -58,7 +57,7 @@ protected override double StrainValueAt(DifficultyHitObject current) // For converted maps, difficulty often comes entirely from long mono streams with no colour variation. // To avoid over-rewarding these maps based purely on stamina strain, we dampen the strain value once the index exceeds 10. - return SingleColourStamina ? DifficultyCalculationUtils.Logistic(-(index - 10) / 2.0, currentStrain) : currentStrain; + return SingleColourStamina ? DiffUtils.Logistic(-(index - 10) / 2.0, currentStrain) : currentStrain; } protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index edd26819f503..dab87c235758 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -10,20 +10,19 @@ using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm; using osu.Game.Rulesets.Taiko.Difficulty.Skills; using osu.Game.Rulesets.Taiko.Mods; -using osu.Game.Rulesets.Taiko.Scoring; +using osu.Game.Utils; namespace osu.Game.Rulesets.Taiko.Difficulty { public class TaikoDifficultyCalculator : DifficultyCalculator { private const double difficulty_multiplier = 0.084375; - private const double rhythm_skill_multiplier = 0.750 * difficulty_multiplier; + private const double rhythm_skill_multiplier = 0.770 * difficulty_multiplier; private const double reading_skill_multiplier = 0.100 * difficulty_multiplier; private const double colour_skill_multiplier = 0.375 * difficulty_multiplier; private const double stamina_skill_multiplier = 0.445 * difficulty_multiplier; @@ -41,17 +40,14 @@ public TaikoDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) { } - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) { - HitWindows hitWindows = new TaikoHitWindows(); - hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); - isConvert = beatmap.BeatmapInfo.Ruleset.OnlineID == 0; isRelax = mods.Any(h => h is TaikoModRelax); return new Skill[] { - new Rhythm(mods, hitWindows.WindowFor(HitResult.Great) / clockRate), + new Rhythm(mods), new Reading(mods), new Colour(mods), new Stamina(mods, false, isConvert), @@ -67,13 +63,15 @@ protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clo new TaikoModHardRock(), }; - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) { var difficultyHitObjects = new List(); var centreObjects = new List(); var rimObjects = new List(); var noteObjects = new List(); + double clockRate = ModUtils.CalculateRateWithMods(mods); + // Generate TaikoDifficultyHitObjects from the beatmap's hit objects. for (int i = 2; i < beatmap.HitObjects.Count; i++) { @@ -97,7 +95,7 @@ protected override IEnumerable CreateDifficultyHitObjects(I return difficultyHitObjects; } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) { if (beatmap.HitObjects.Count == 0) return new TaikoDifficultyAttributes { Mods = mods }; @@ -108,19 +106,21 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat var stamina = skills.OfType().Single(s => !s.SingleColourStamina); var singleColourStamina = skills.OfType().Single(s => s.SingleColourStamina); + double staminaDifficultyValue = stamina.DifficultyValue(); + double rhythmSkill = rhythm.DifficultyValue() * rhythm_skill_multiplier; double readingSkill = reading.DifficultyValue() * reading_skill_multiplier; double colourSkill = colour.DifficultyValue() * colour_skill_multiplier; - double staminaSkill = stamina.DifficultyValue() * stamina_skill_multiplier; + double staminaSkill = staminaDifficultyValue * stamina_skill_multiplier; double monoStaminaSkill = singleColourStamina.DifficultyValue() * stamina_skill_multiplier; - double monoStaminaFactor = staminaSkill == 0 ? 1 : Math.Pow(monoStaminaSkill / staminaSkill, 5); + double monoStaminaFactor = staminaSkill == 0 ? 1 : DiffUtils.Pow(monoStaminaSkill / staminaSkill, 5); - double staminaDifficultStrains = stamina.CountTopWeightedStrains(); + double staminaDifficultStrains = stamina.CountTopWeightedStrains(staminaDifficultyValue); // As we don't have pattern integration in osu!taiko, we apply the other two skills relative to rhythm. - patternMultiplier = Math.Pow(staminaSkill * colourSkill, 0.10); + patternMultiplier = DiffUtils.Pow(staminaSkill * colourSkill, 0.10); - strainLengthBonus = 1 + 0.15 * DifficultyCalculationUtils.ReverseLerp(staminaDifficultStrains, 1000, 1555); + strainLengthBonus = 1 + 0.15 * DiffUtils.ReverseLerp(staminaDifficultStrains, 1000, 1555); double combinedRating = combinedDifficultyValue(rhythm, reading, colour, stamina, out double consistencyFactor); double starRating = rescale(combinedRating * 1.4); @@ -184,10 +184,10 @@ private double combinedDifficultyValue(Rhythm rhythm, Reading reading, Colour co } List hitObjectStrainPeaks = combinePeaks( - rhythm.GetObjectStrains().ToList(), - reading.GetObjectStrains().ToList(), - colour.GetObjectStrains().ToList(), - stamina.GetObjectStrains().ToList() + rhythm.GetObjectDifficulties(), + reading.GetObjectDifficulties(), + colour.GetObjectDifficulties(), + stamina.GetObjectDifficulties() ); if (hitObjectStrainPeaks.Count == 0) @@ -209,7 +209,7 @@ private double combinedDifficultyValue(Rhythm rhythm, Reading reading, Colour co /// /// Combines lists of peak strains from multiple skills into a list of single peak strains for each section. /// - private List combinePeaks(List rhythmPeaks, List readingPeaks, List colourPeaks, List staminaPeaks) + private List combinePeaks(IReadOnlyList rhythmPeaks, IReadOnlyList readingPeaks, IReadOnlyList colourPeaks, IReadOnlyList staminaPeaks) { var combinedPeaks = new List(); @@ -221,7 +221,7 @@ private List combinePeaks(List rhythmPeaks, List reading double staminaPeak = staminaPeaks[i] * stamina_skill_multiplier * strainLengthBonus; staminaPeak /= isConvert || isRelax ? 1.5 : 1.0; // Available finger count is increased by 150%, thus we adjust accordingly. - double peak = DifficultyCalculationUtils.Norm(2, DifficultyCalculationUtils.Norm(1.5, colourPeak, staminaPeak), rhythmPeak, readingPeak); + double peak = DiffUtils.Norm(2, DiffUtils.Norm(1.5, colourPeak, staminaPeak), rhythmPeak, readingPeak); // 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. diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs index df9da49c4b80..bbb24ff732de 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs @@ -90,18 +90,18 @@ private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes double rhythmMaximumUnstableRate = computeDeviationUpperBound(0.8) * 10; // The fraction of star rating made up by rhythm difficulty, normalised to represent rhythm's perceived contribution to star rating. - double rhythmFactor = DifficultyCalculationUtils.ReverseLerp(attributes.RhythmDifficulty / attributes.StarRating, 0.15, 0.4); + double rhythmFactor = DiffUtils.ReverseLerp(attributes.RhythmDifficulty / attributes.StarRating, 0.15, 0.4); // A penalty removing improperly played rhythm difficulty from star rating based on estimated unstable rate. - double rhythmPenalty = 1 - DifficultyCalculationUtils.Logistic( + double rhythmPenalty = 1 - DiffUtils.Logistic( estimatedUnstableRate.Value, midpointOffset: (rhythmExpectedUnstableRate + rhythmMaximumUnstableRate) / 2, multiplier: 10 / (rhythmMaximumUnstableRate - rhythmExpectedUnstableRate), - maxValue: 0.25 * Math.Pow(rhythmFactor, 3) + maxValue: 0.25 * DiffUtils.Pow(rhythmFactor, 3) ); double baseDifficulty = 5 * Math.Max(1.0, attributes.StarRating * rhythmPenalty / 0.110) - 4.0; - double difficultyValue = Math.Min(Math.Pow(baseDifficulty, 3) / 69052.51, Math.Pow(baseDifficulty, 2.25) / 1250.0); + double difficultyValue = Math.Min(DiffUtils.Pow(baseDifficulty, 3) / 69052.51, DiffUtils.Pow(baseDifficulty, 2.25) / 1250.0); difficultyValue *= 1 + 0.10 * Math.Max(0, attributes.StarRating - 10); @@ -111,7 +111,7 @@ private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes // Scales miss penalty by the total difficult hits of a map, making misses more punishing on maps with less total difficulty. double missPenalty = 0.97 + 0.03 * totalDifficultHits / (totalDifficultHits + 1500); - difficultyValue *= Math.Pow(missPenalty, countMiss); + difficultyValue *= DiffUtils.Pow(missPenalty, countMiss); if (score.Mods.Any(m => m is ModHidden)) { @@ -139,7 +139,7 @@ private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes double monoAccScalingExponent = 2 + attributes.MonoStaminaFactor; double monoAccScalingShift = 500 - 100 * (attributes.MonoStaminaFactor * 3); - return difficultyValue * Math.Pow(DifficultyCalculationUtils.Erf(monoAccScalingShift / (Math.Sqrt(2) * estimatedUnstableRate.Value)), monoAccScalingExponent); + return difficultyValue * DiffUtils.Pow(DiffUtils.Erf(monoAccScalingShift / (DiffUtils.SQRT2 * estimatedUnstableRate.Value)), monoAccScalingExponent); } private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert) @@ -147,10 +147,10 @@ private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes a if (greatHitWindow <= 0 || estimatedUnstableRate == null) return 0; - double accuracyValue = 470 * Math.Pow(0.9885, estimatedUnstableRate.Value); + double accuracyValue = 470 * DiffUtils.Pow(0.9885, estimatedUnstableRate.Value); // Scales up the bonus for lower unstable rate as star rating increases. - accuracyValue *= 1 + Math.Pow(50 / estimatedUnstableRate.Value, 2) * Math.Pow(attributes.StarRating, 2.8) / 600; + accuracyValue *= 1 + DiffUtils.Pow(50 / estimatedUnstableRate.Value, 2) * DiffUtils.Pow(attributes.StarRating, 2.8) / 600; if (score.Mods.Any(m => m is ModHidden) && !isConvert) accuracyValue *= 1.075; @@ -159,7 +159,7 @@ private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes a accuracyValue *= 1 + 0.3 * totalDifficultHits / (totalDifficultHits + 4000); // Applies a bonus to maps with more total memory required with HDFL. - double memoryLengthBonus = Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3)); + double memoryLengthBonus = Math.Min(1.15, DiffUtils.Pow(totalHits / 1500.0, 0.3)); if (score.Mods.Any(m => m is ModFlashlight) && score.Mods.Any(m => m is ModHidden) && !isConvert) accuracyValue *= Math.Max(1.0, 1.05 * memoryLengthBonus); @@ -185,7 +185,7 @@ private double computeDeviationUpperBound(double accuracy) double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4); // We can be 99% confident that the deviation is not higher than: - return greatHitWindow / (Math.Sqrt(2) * DifficultyCalculationUtils.ErfInv(pLowerBound)); + return greatHitWindow / (DiffUtils.SQRT2 * DiffUtils.ErfInv(pLowerBound)); } private int totalHits => countGreat + countOk + countMeh + countMiss; diff --git a/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs b/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs index 7e40d575bc90..2fa765edd7a5 100644 --- a/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs +++ b/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Objects; @@ -22,7 +23,7 @@ public TaikoScoreProcessor() protected override double ComputeTotalScore(double comboProgress, double accuracyProgress, double bonusPortion) { return 250000 * comboProgress - + 750000 * Math.Pow(Accuracy.Value, 3.6) * accuracyProgress + + 750000 * DiffUtils.Pow(Accuracy.Value, 3.6) * accuracyProgress + bonusPortion; } diff --git a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs index 1a056e8387ef..eecabbab6684 100644 --- a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs +++ b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs @@ -218,17 +218,17 @@ public TestLegacyDifficultyCalculator(params Mod[] mods) protected override Mod[] DifficultyAdjustmentMods { get; } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) { throw new NotImplementedException(); } - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) { throw new NotImplementedException(); } - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) { throw new NotImplementedException(); } diff --git a/osu.Game.Tests/NonVisual/TestSceneTimedDifficultyCalculation.cs b/osu.Game.Tests/NonVisual/TestSceneTimedDifficultyCalculation.cs index f860cd097a72..4da5ba13cb6b 100644 --- a/osu.Game.Tests/NonVisual/TestSceneTimedDifficultyCalculation.cs +++ b/osu.Game.Tests/NonVisual/TestSceneTimedDifficultyCalculation.cs @@ -15,6 +15,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI; using osu.Game.Tests.Beatmaps; +using osu.Game.Utils; namespace osu.Game.Tests.NonVisual { @@ -172,12 +173,14 @@ public TestDifficultyCalculator(IWorkingBeatmap beatmap) { } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) => new TestDifficultyAttributes { Objects = beatmap.HitObjects.ToArray() }; - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) { - List objects = new List(); + List objects = new List(beatmap.HitObjects.Count); + + double clockRate = ModUtils.CalculateRateWithMods(mods); foreach (var obj in beatmap.HitObjects.OfType()) { @@ -191,7 +194,7 @@ protected override IEnumerable CreateDifficultyHitObjects(I return objects; } - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[] { new PassThroughSkill(mods) }; + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[] { new PassThroughSkill(mods) }; private class PassThroughSkill : Skill { @@ -200,8 +203,9 @@ public PassThroughSkill(Mod[] mods) { } - public override void Process(DifficultyHitObject current) + protected override double ProcessInternal(DifficultyHitObject current) { + return 0; } public override double DifficultyValue() => 1; diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs index a50f1cca2934..70b6a741fe21 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs @@ -145,7 +145,6 @@ public void TestLengthAndStarRatingUpdated() AddStep("Get working beatmap", () => working = Game.BeatmapManager.GetWorkingBeatmap(EditorBeatmap.BeatmapInfo, true)); AddAssert("Beatmap length is zero", () => working.BeatmapInfo.Length == 0); - checkDifficultyIncreased(); AddStep("Move forward", () => InputManager.Key(Key.Right)); AddStep("Place another hitcircle", () => InputManager.Click(MouseButton.Left)); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapAttributeText.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapAttributeText.cs index 5db8b30bedea..a841de592da0 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapAttributeText.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapAttributeText.cs @@ -208,13 +208,13 @@ public TestDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) { } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) => new DifficultyAttributes(mods, mods.OfType().SingleOrDefault()?.Difficulty.Value ?? 0); - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) => Array.Empty(); - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => Array.Empty(); } diff --git a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs index 5e431dc35728..c98e2137ac10 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs @@ -33,6 +33,8 @@ public class DifficultyAttributes protected const int ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE = 41; protected const int ATTRIB_ID_RHYTHM_DIFFICULTY = 43; protected const int ATTRIB_ID_CONSISTENCY_FACTOR = 45; + protected const int ATTRIB_ID_READING = 47; + protected const int ATTRIB_ID_READING_DIFFICULT_NOTE_COUNT = 49; /// /// The mods which were applied to the beatmap. diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index 64d57ad4109d..9b28045ed30e 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -34,7 +34,6 @@ public abstract class DifficultyCalculator protected readonly IWorkingBeatmap WorkingBeatmap; private Mod[] playableMods; - private double clockRate; private readonly IRulesetInfo ruleset; @@ -74,10 +73,10 @@ public DifficultyAttributes Calculate([NotNull] IEnumerable mods, Cancellat // ReSharper disable once PossiblyMistakenUseOfCancellationToken preProcess(mods, cancellationToken); - var skills = CreateSkills(Beatmap, playableMods, clockRate); + var skills = CreateSkills(Beatmap, playableMods); if (!Beatmap.HitObjects.Any()) - return CreateDifficultyAttributes(Beatmap, playableMods, skills, clockRate); + return CreateDifficultyAttributes(Beatmap, playableMods, skills); foreach (var hitObject in getDifficultyHitObjects()) { @@ -88,7 +87,7 @@ public DifficultyAttributes Calculate([NotNull] IEnumerable mods, Cancellat } } - return CreateDifficultyAttributes(Beatmap, playableMods, skills, clockRate); + return CreateDifficultyAttributes(Beatmap, playableMods, skills); } /// @@ -121,7 +120,7 @@ public List CalculateTimed([NotNull] IEnumerable if (!Beatmap.HitObjects.Any()) return attribs; - var skills = CreateSkills(Beatmap, playableMods, clockRate); + var skills = CreateSkills(Beatmap, playableMods); var progressiveBeatmap = new ProgressiveCalculationBeatmap(Beatmap); var difficultyObjects = getDifficultyHitObjects().ToArray(); @@ -142,7 +141,7 @@ public List CalculateTimed([NotNull] IEnumerable currentIndex++; } - attribs.Add(new TimedDifficultyAttributes(obj.GetEndTime(), CreateDifficultyAttributes(progressiveBeatmap, playableMods, skills, clockRate))); + attribs.Add(new TimedDifficultyAttributes(obj.GetEndTime(), CreateDifficultyAttributes(progressiveBeatmap, playableMods, skills))); } return attribs; @@ -174,7 +173,7 @@ public IEnumerable CalculateAllLegacyCombinations(Cancella /// /// Retrieves the s to calculate against. /// - private IEnumerable getDifficultyHitObjects() => SortObjects(CreateDifficultyHitObjects(Beatmap, clockRate)); + private IEnumerable getDifficultyHitObjects() => SortObjects(CreateDifficultyHitObjects(Beatmap, playableMods)); /// /// Performs required tasks before every calculation. @@ -185,8 +184,6 @@ private void preProcess([NotNull] IEnumerable mods, CancellationToken cance { playableMods = mods.Select(m => m.DeepClone()).ToArray(); Beatmap = WorkingBeatmap.GetPlayableBeatmap(ruleset, playableMods, cancellationToken); - - clockRate = ModUtils.CalculateRateWithMods(playableMods); } /// @@ -277,16 +274,15 @@ static IEnumerable createDifficultyAdjustmentModCombinations(ReadOnlyMemory /// This may differ from in the case of timed calculation. /// The s that difficulty was calculated with. /// The skills which processed the beatmap. - /// The rate at which the gameplay clock is run at. - protected abstract DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate); + protected abstract DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills); /// /// Enumerates s to be processed from s in the . /// /// The providing the s to enumerate. - /// The rate at which the gameplay clock is run at. + /// Mods to create difficulty objects with. /// The enumerated s. - protected abstract IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate); + protected abstract IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods); /// /// Creates the s to calculate the difficulty of an . @@ -294,9 +290,8 @@ static IEnumerable createDifficultyAdjustmentModCombinations(ReadOnlyMemory /// The whose difficulty will be calculated. /// This may differ from in the case of timed calculation. /// Mods to calculate difficulty with. - /// Clockrate to calculate difficulty with. /// The s. - protected abstract Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate); + protected abstract Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods); /// /// Used to calculate timed difficulty attributes, where only a subset of hitobjects should be visible at any point in time. diff --git a/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs b/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs index 117ea431a0a2..8cacf582e5c5 100644 --- a/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs +++ b/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Difficulty.Preprocessing { @@ -45,6 +46,13 @@ public class DifficultyHitObject /// public readonly double EndTime; + /// + /// Beatmap playback rate. + /// + public readonly double ClockRate; + + public readonly double HitWindowGreat; + /// /// Creates a new . /// @@ -62,18 +70,44 @@ public DifficultyHitObject(HitObject hitObject, HitObject lastObject, double clo DeltaTime = (hitObject.StartTime - lastObject.StartTime) / clockRate; StartTime = hitObject.StartTime / clockRate; EndTime = hitObject.GetEndTime() / clockRate; + ClockRate = clockRate; + HitWindowGreat = HitWindow(HitResult.Great); } - public DifficultyHitObject Previous(int backwardsIndex) + public DifficultyHitObject Previous(int skipCount = 0) { - int index = Index - (backwardsIndex + 1); + int index = Index - (skipCount + 1); return index >= 0 && index < difficultyHitObjects.Count ? difficultyHitObjects[index] : null; } - public DifficultyHitObject Next(int forwardsIndex) + public DifficultyHitObject Next(int skipCount = 0) { - int index = Index + (forwardsIndex + 1); + int index = Index + (skipCount + 1); return index >= 0 && index < difficultyHitObjects.Count ? difficultyHitObjects[index] : null; } + + /// + /// Retrieves the full rate-adjusted hit window for a . + /// + protected double HitWindow(HitResult hitResult) => 2 * getRawHitWindow(hitResult) / ClockRate; + + /// + /// Retrieves the hit window for a . + /// + private double getRawHitWindow(HitResult hitResult) + { + // Try to get HitWindows from nested hit objects + // This is important for objects such as Slider in osu! where the object itself has HitWindows set to Empty, but the nested SliderHead has proper hit windows + if (BaseObject.HitWindows != HitWindows.Empty) + return BaseObject.HitWindows.WindowFor(hitResult); + + foreach (var nestedHitObject in BaseObject.NestedHitObjects) + { + if (nestedHitObject.HitWindows != HitWindows.Empty) + return nestedHitObject.HitWindows.WindowFor(hitResult); + } + + return 0; + } } } diff --git a/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs new file mode 100644 index 000000000000..c6e081272efa --- /dev/null +++ b/osu.Game/Rulesets/Difficulty/Skills/HarmonicSkill.cs @@ -0,0 +1,103 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Difficulty.Skills +{ + public abstract class HarmonicSkill : Skill + { + /// + /// The sum of object weights, calculated during summation. + /// Required for any calculations which need to normalise difficulty value. + /// + protected double ObjectWeightSum; + + /// + /// Scaling factor applied as HarmonicScale / (1 + index) during weight calculations. + /// A higher value will increase the influence of the hardest object difficulties during summation. + /// + protected virtual double HarmonicScale => 1.0; + + /// + /// Exponent that controls the rate of which decay increases as the index increases. + /// Values closer to 1 decay faster whilst lower values give more weight to lower object difficulties. + /// + protected virtual double DecayExponent => 0.9; + + protected HarmonicSkill(Mod[] mods) + : base(mods) + { + } + + /// + /// Returns the difficulty value of the current . This value is calculated with or without respect to previous objects. + /// + protected abstract double ObjectDifficultyOf(DifficultyHitObject current); + + protected sealed override double ProcessInternal(DifficultyHitObject current) + => ObjectDifficultyOf(current); + + /// + /// Transforms the object difficulties specifically for final difficulty summation. + /// This can be used to decrease weight of certain objects based on a skill-specific criteria. + /// + protected virtual List GetTransformedDifficulties(List difficulties) => difficulties; + + public override double DifficultyValue() + { + if (ObjectDifficulties.Count == 0) + return 0; + + // Objects with 0 difficulty are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). + // These objects will not contribute to the difficulty. + var difficulties = ObjectDifficulties; + + if (difficulties.Count == 0) + return 0; + + difficulties = GetTransformedDifficulties(difficulties); + + double difficulty = 0; + int index = 0; + + foreach (double obj in difficulties.OrderDescending().Where(v => v > 0)) + { + // Use a harmonic sum that considers each object of the map according to a predefined weight. + double weight = (1 + (HarmonicScale / (1 + index))) / (DiffUtils.Pow(index, DecayExponent) + 1 + (HarmonicScale / (1 + index))); + + ObjectWeightSum += weight; + + difficulty += obj * weight; + index += 1; + } + + return difficulty; + } + + /// + /// Calculates the number of object difficulties weighted against the top object difficulty. + /// + public virtual double CountTopWeightedObjectDifficulties(double difficultyValue) + { + if (ObjectDifficulties.Count == 0) + return 0.0; + + if (ObjectWeightSum == 0) + return 0.0; + + double consistentTopObject = difficultyValue / ObjectWeightSum; // What would the top difficulty be if all object difficulties were identical + + if (consistentTopObject == 0) + return 0; + + return ObjectDifficulties.Sum(d => DiffUtils.Logistic(d / consistentTopObject, 0.88, 10, 1.1)); + } + + public static double DifficultyToPerformance(double difficulty) => 4.0 * DiffUtils.Pow(difficulty, 3); + } +} diff --git a/osu.Game/Rulesets/Difficulty/Skills/Skill.cs b/osu.Game/Rulesets/Difficulty/Skills/Skill.cs index 8b8892113b8a..cf45104c942c 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/Skill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/Skill.cs @@ -20,6 +20,11 @@ public abstract class Skill /// protected IReadOnlyList Mods => mods; + /// + /// List of calculated per-object difficulties, populated by Process + /// + protected readonly List ObjectDifficulties = new List(); + private readonly Mod[] mods; protected Skill(Mod[] mods) @@ -31,11 +36,19 @@ protected Skill(Mod[] mods) /// Process a . /// /// The to process. - public abstract void Process(DifficultyHitObject current); + public void Process(DifficultyHitObject current) + { + double difficultyValue = ProcessInternal(current); + ObjectDifficulties.Add(difficultyValue); + } + + protected abstract double ProcessInternal(DifficultyHitObject current); /// /// Returns the calculated difficulty value representing all s that have been processed up to this point. /// public abstract double DifficultyValue(); + + public IReadOnlyList GetObjectDifficulties() => ObjectDifficulties; } } diff --git a/osu.Game/Rulesets/Difficulty/Skills/StrainDecaySkill.cs b/osu.Game/Rulesets/Difficulty/Skills/StrainDecaySkill.cs index 8fab61ed6269..431d2faba2ed 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/StrainDecaySkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/StrainDecaySkill.cs @@ -1,8 +1,8 @@ // 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.Mods; namespace osu.Game.Rulesets.Difficulty.Skills @@ -49,6 +49,6 @@ protected override double StrainValueAt(DifficultyHitObject current) /// protected abstract double StrainValueOf(DifficultyHitObject current); - private double strainDecay(double ms) => Math.Pow(StrainDecayBase, ms / 1000); + private double strainDecay(double ms) => DiffUtils.Pow(StrainDecayBase, ms / 1000); } } diff --git a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs index b6272bf56b85..e26ce15dd26c 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs @@ -29,7 +29,6 @@ public abstract class StrainSkill : Skill private double currentSectionEnd; private readonly List strainPeaks = new List(); - protected readonly List ObjectStrains = new List(); // Store individual strains protected StrainSkill(Mod[] mods) : base(mods) @@ -44,7 +43,7 @@ protected StrainSkill(Mod[] mods) /// /// Process a and update current strain values accordingly. /// - public sealed override void Process(DifficultyHitObject current) + protected sealed override double ProcessInternal(DifficultyHitObject current) { // The first object doesn't generate a strain, so we begin with an incremented section end if (current.Index == 0) @@ -60,26 +59,25 @@ public sealed override void Process(DifficultyHitObject current) double strain = StrainValueAt(current); currentSectionPeak = Math.Max(strain, currentSectionPeak); - // Store the strain value for the object - ObjectStrains.Add(strain); + return strain; } /// /// 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() + public double CountTopWeightedStrains(double difficultyValue) { - if (ObjectStrains.Count == 0) + if (ObjectDifficulties.Count == 0) return 0.0; - double consistentTopStrain = DifficultyValue() / 10; // What would the top strain be if all strain values were identical + double consistentTopStrain = difficultyValue * (1 - DecayWeight); // What would the top strain be if all strain values were identical if (consistentTopStrain == 0) - return ObjectStrains.Count; + return ObjectDifficulties.Count; // Use a weighted sum of all strains. Constants are arbitrary and give nice values - return ObjectStrains.Sum(s => 1.1 / (1 + Math.Exp(-10 * (s / consistentTopStrain - 0.88)))); + return ObjectDifficulties.Sum(s => 1.1 / (1 + Math.Exp(-10 * (s / consistentTopStrain - 0.88)))); } /// @@ -116,8 +114,6 @@ private void startNewSectionFrom(double time, DifficultyHitObject current) /// public IEnumerable GetCurrentStrainPeaks() => strainPeaks.Append(currentSectionPeak); - public IEnumerable GetObjectStrains() => ObjectStrains; - /// /// Returns the calculated difficulty value representing all s that have been processed up to this point. /// diff --git a/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs new file mode 100644 index 000000000000..c94f469a1c9e --- /dev/null +++ b/osu.Game/Rulesets/Difficulty/Skills/VariableLengthStrainSkill.cs @@ -0,0 +1,256 @@ +// 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 . + /// + /// + /// This class intends to replace eventually as it fixes bugs with that implementation. + /// Has not yet been applied globally as it changes resultant PP values in ways which may require discretion. + /// + public abstract class VariableLengthStrainSkill : Skill + { + /// + /// The weight by which each strain value decays. + /// + protected readonly double DecayWeight; + + /// + /// The maximum length of each strain section. + /// + protected readonly int MaxSectionLength; + + 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 readonly double maxStoredLength; + + 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)>(); + + /// + /// Create a new . + /// + /// The mods. + /// The weight by which each strain value decays. + /// The maximum length of each strain section. + protected VariableLengthStrainSkill(Mod[] mods, double decayWeight = 0.9, int maxSectionLength = 400) + : base(mods) + { + DecayWeight = decayWeight; + MaxSectionLength = maxSectionLength; + + maxStoredLength = 11 / (1 - DecayWeight); + } + + /// + /// 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. + // `maxStoredLength` dictates for us how many sections will preserve at least 99.999% of the difficulty value. + while (totalLength > maxStoredLength * MaxSectionLength) + { + totalLength -= strainPeaks[^1].SectionLength; + strainPeaks.RemoveAt(strainPeaks.Count - 1); + } + } + + /// + /// 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); + + private bool peaksFinalised; + + /// + /// Returns a live enumerable of the peak strains for each section of the beatmap, + /// including the peak of the current section. + /// + public IEnumerable GetCurrentStrainPeaks() + { + if (!peaksFinalised) + { + saveCurrentPeak(currentSectionEnd - currentSectionBegin); + peaksFinalised = true; + } + + return strainPeaks; + } + + /// + /// 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; } + + // Reverse sort, highest is first. + public int CompareTo(StrainPeak other) => other.Value.CompareTo(Value); + } + } +} diff --git a/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs b/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs similarity index 80% rename from osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs rename to osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs index c813627d5162..03d712a75e74 100644 --- a/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs +++ b/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs @@ -2,18 +2,23 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Linq; +using System.Runtime.CompilerServices; namespace osu.Game.Rulesets.Difficulty.Utils { - public static partial class DifficultyCalculationUtils + public static partial class DiffUtils { + /// + /// Square root of 2 + /// + public const double SQRT2 = 1.4142135623730950; + /// /// Converts BPM value into milliseconds /// /// Beats per minute /// Which rhythm delimiter to use, default is 1/4 - /// BPM conveted to milliseconds + /// BPM converted to milliseconds public static double BPMToMilliseconds(double bpm, int delimiter = 4) { return 60000.0 / delimiter / bpm; @@ -24,7 +29,7 @@ public static double BPMToMilliseconds(double bpm, int delimiter = 4) /// /// Milliseconds /// Which rhythm delimiter to use, default is 1/4 - /// Milliseconds conveted to beats per minute + /// Milliseconds converted to beats per minute public static double MillisecondsToBPM(double ms, int delimiter = 4) { return 60000.0 / (ms * delimiter); @@ -54,7 +59,15 @@ public static double MillisecondsToBPM(double ms, int delimiter = 4) /// The value of p to calculate the norm for. /// The coefficients of the vector. /// The p-norm of the vector. - public static double Norm(double p, params double[] values) => Math.Pow(values.Sum(x => Math.Pow(x, p)), 1 / p); + public static double Norm(double p, params double[] values) + { + double sum = 0; + + foreach (double x in values) + sum += Pow(x, p); + + return Pow(sum, 1.0 / p); + } /// /// Calculates a Gaussian-based bell curve function (https://en.wikipedia.org/wiki/Gaussian_function) @@ -64,22 +77,34 @@ public static double MillisecondsToBPM(double ms, int delimiter = 4) /// The width (spread) of the curve /// Multiplier to adjust the curve's height /// The output of the bell curve function of - public static double BellCurve(double x, double mean, double width, double multiplier = 1.0) => multiplier * Math.Exp(Math.E * -(Math.Pow(x - mean, 2) / Math.Pow(width, 2))); + public static double BellCurve(double x, double mean, double width, double multiplier = 1.0) => multiplier * Math.Exp(Math.E * -(Pow(x - mean, 2) / Pow(width, 2))); /// - /// Calculates a Smoothstep Bellcurve that returns returns 1 for x = mean, and smoothly reducing it's value to 0 over width + /// Calculates a Smoothstep bell curve that returns 1 for x = mean, and smoothly reducing it's value to 0 over width /// /// Value to calculate the function for /// Value of x, for which return value will be the highest (=1) /// Range [mean - width, mean + width] where function will change values /// The output of the smoothstep bell curve function of - public static double SmoothstepBellCurve(double x, double mean = 0.5, double width = 0.5) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static double SmoothstepBellCurve(double x, double mean, double width) { x -= mean; x = x > 0 ? (width - x) : (width + x); return Smoothstep(x, 0, width); } + /// + /// Calculates a Smoothstep bell curve that returns 1 for x = mean, and smoothly reducing it's value to 0 over width + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static double SmoothstepBellCurve(double x) + { + x = 0.5 - Math.Abs(x - 0.5); + x = Math.Clamp(x * 2.0, 0.0, 1.0); + return x * x * (3.0 - 2.0 * x); + } + /// /// Smoothstep function (https://en.wikipedia.org/wiki/Smoothstep) /// @@ -179,12 +204,27 @@ public static double ErfInv(double x) double baseApprox = Math.Sqrt(t1 * t1 - t2) - t1; // Correction reduces max error from -0.005 to -0.00045. - double c = x >= 0.85 ? Math.Pow((x - 0.85) / 0.293, 8) : 0; + double c = x >= 0.85 ? Pow((x - 0.85) / 0.293, 8) : 0; double erfInv = sgn * (Math.Sqrt(baseApprox) + c); return erfInv; } + // In actual debug testing it's very rare for a (double, double) call to end up with a rounded int value in the first place. + // Making an explicit overload is slightly faster than running the `switch` in such cases. + public static double Pow(double x, double exponent) => Math.Pow(x, exponent); + + public static double Pow(double x, int exponent) => exponent switch + { + 0 => 1, + 1 => x, + 2 => x * x, + 3 => x * x * x, + 4 => x * x * x * x, + 5 => x * x * x * x * x, // This is the largest value used in diffcalc right now. + _ => Math.Pow(x, exponent) + }; + /// /// Inverse complementary error function (https://en.wikipedia.org/wiki/Error_function) /// 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))