diff --git a/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs index 91bc537902..c9831aad6d 100644 --- a/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs @@ -13,11 +13,11 @@ namespace osu.Game.Rulesets.Catch.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Catch"; - [TestCase(3.8664391043534758, "diffcalc-test")] + [TestCase(3.8701854263563118d, "diffcalc-test")] public void Test(double expected, string name) => base.Test(expected, name); - protected override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new CatchDifficultyCalculator(new CatchRuleset(), beatmap); + protected override LegacyDifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new CatchDifficultyCalculator(new CatchRuleset(), beatmap); protected override Ruleset CreateRuleset() => new CatchRuleset(); } diff --git a/osu.Game.Rulesets.Catch/CatchRuleset.cs b/osu.Game.Rulesets.Catch/CatchRuleset.cs index a69070e93e..c539a47897 100644 --- a/osu.Game.Rulesets.Catch/CatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/CatchRuleset.cs @@ -110,7 +110,7 @@ namespace osu.Game.Rulesets.Catch public override Drawable CreateIcon() => new SpriteIcon { Icon = FontAwesome.fa_osu_fruits_o }; - public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new CatchDifficultyCalculator(this, beatmap); + public override LegacyDifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new CatchDifficultyCalculator(this, beatmap); public override int? LegacyID => 2; diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs index c6518cbf8a..75f5b18607 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Difficulty; -using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Catch.Difficulty { @@ -10,10 +9,5 @@ namespace osu.Game.Rulesets.Catch.Difficulty { public double ApproachRate; public int MaxCombo; - - public CatchDifficultyAttributes(Mod[] mods, double starRating) - : base(mods, starRating) - { - } } } diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs index a0b813478d..89f3a8c25e 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs @@ -1,148 +1,86 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// 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.Beatmaps; -using osu.Game.Rulesets.Difficulty; -using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Catch.Difficulty.Preprocessing; +using osu.Game.Rulesets.Catch.Difficulty.Skills; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Catch.Difficulty { public class CatchDifficultyCalculator : DifficultyCalculator { - /// - /// In milliseconds. For difficulty calculation we will only look at the highest strain value in each time interval of size STRAIN_STEP. - /// This is to eliminate higher influence of stream over aim by simply having more HitObjects with high strain. - /// The higher this value, the less strains there will be, indirectly giving long beatmaps an advantage. - /// - private const double strain_step = 750; - - /// - /// The weighting of each strain value decays to this number * it's previous value - /// - private const double decay_weight = 0.94; - private const double star_scaling_factor = 0.145; + protected override int SectionLength => 750; + + private readonly float halfCatchWidth; + public CatchDifficultyCalculator(Ruleset ruleset, WorkingBeatmap beatmap) : base(ruleset, beatmap) { + var catcher = new CatcherArea.Catcher(beatmap.BeatmapInfo.BaseDifficulty); + halfCatchWidth = catcher.CatchWidth * 0.5f; } - protected override DifficultyAttributes Calculate(IBeatmap beatmap, Mod[] mods, double timeRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) { - if (!beatmap.HitObjects.Any()) - return new CatchDifficultyAttributes(mods, 0); + if (beatmap.HitObjects.Count == 0) + return new CatchDifficultyAttributes { Mods = mods }; - var catcher = new CatcherArea.Catcher(beatmap.BeatmapInfo.BaseDifficulty); - float halfCatchWidth = catcher.CatchWidth * 0.5f; + // this is the same as osu!, so there's potential to share the implementation... maybe + double preempt = BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.ApproachRate, 1800, 1200, 450) / clockRate; - var difficultyHitObjects = new List(); - - foreach (var hitObject in beatmap.HitObjects) + return new CatchDifficultyAttributes { + StarRating = Math.Sqrt(skills[0].DifficultyValue()) * star_scaling_factor, + Mods = mods, + ApproachRate = preempt > 1200.0 ? -(preempt - 1800.0) / 120.0 : -(preempt - 1200.0) / 150.0 + 5.0, + MaxCombo = beatmap.HitObjects.Count(h => h is Fruit) + beatmap.HitObjects.OfType().SelectMany(j => j.NestedHitObjects).Count(h => !(h is TinyDroplet)) + }; + } + + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + { + CatchHitObject lastObject = null; + + foreach (var hitObject in beatmap.HitObjects.OfType()) + { + if (lastObject == null) + { + lastObject = hitObject; + continue; + } + switch (hitObject) { // We want to only consider fruits that contribute to the combo. Droplets are addressed as accuracy and spinners are not relevant for "skill" calculations. case Fruit fruit: - difficultyHitObjects.Add(new CatchDifficultyHitObject(fruit, halfCatchWidth)); + yield return new CatchDifficultyHitObject(fruit, lastObject, clockRate, halfCatchWidth); + lastObject = hitObject; break; case JuiceStream _: - difficultyHitObjects.AddRange(hitObject.NestedHitObjects.OfType().Where(o => !(o is TinyDroplet)).Select(o => new CatchDifficultyHitObject(o, halfCatchWidth))); + foreach (var nested in hitObject.NestedHitObjects.OfType().Where(o => !(o is TinyDroplet))) + { + yield return new CatchDifficultyHitObject(nested, lastObject, clockRate, halfCatchWidth); + lastObject = nested; + } break; } } - - difficultyHitObjects.Sort((a, b) => a.BaseHitObject.StartTime.CompareTo(b.BaseHitObject.StartTime)); - - if (!calculateStrainValues(difficultyHitObjects, timeRate)) - return new CatchDifficultyAttributes(mods, 0); - - // this is the same as osu!, so there's potential to share the implementation... maybe - double preempt = BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.ApproachRate, 1800, 1200, 450) / timeRate; - double starRating = Math.Sqrt(calculateDifficulty(difficultyHitObjects, timeRate)) * star_scaling_factor; - - return new CatchDifficultyAttributes(mods, starRating) - { - ApproachRate = preempt > 1200.0 ? -(preempt - 1800.0) / 120.0 : -(preempt - 1200.0) / 150.0 + 5.0, - MaxCombo = difficultyHitObjects.Count - }; } - private bool calculateStrainValues(List objects, double timeRate) + protected override Skill[] CreateSkills(IBeatmap beatmap) => new Skill[] { - CatchDifficultyHitObject lastObject = null; - - if (!objects.Any()) return false; - - // Traverse hitObjects in pairs to calculate the strain value of NextHitObject from the strain value of CurrentHitObject and environment. - foreach (var currentObject in objects) - { - if (lastObject != null) - currentObject.CalculateStrains(lastObject, timeRate); - - lastObject = currentObject; - } - - return true; - } - - private double calculateDifficulty(List objects, double timeRate) - { - // The strain step needs to be adjusted for the algorithm to be considered equal with speed changing mods - double actualStrainStep = strain_step * timeRate; - - // Find the highest strain value within each strain step - var highestStrains = new List(); - double intervalEndTime = actualStrainStep; - double maximumStrain = 0; // We need to keep track of the maximum strain in the current interval - - CatchDifficultyHitObject previousHitObject = null; - foreach (CatchDifficultyHitObject hitObject in objects) - { - // While we are beyond the current interval push the currently available maximum to our strain list - while (hitObject.BaseHitObject.StartTime > intervalEndTime) - { - highestStrains.Add(maximumStrain); - - // The maximum strain of the next interval is not zero by default! We need to take the last hitObject we encountered, take its strain and apply the decay - // until the beginning of the next interval. - if (previousHitObject == null) - { - maximumStrain = 0; - } - else - { - double decay = Math.Pow(CatchDifficultyHitObject.DECAY_BASE, (intervalEndTime - previousHitObject.BaseHitObject.StartTime) / 1000); - maximumStrain = previousHitObject.Strain * decay; - } - - // Go to the next time interval - intervalEndTime += actualStrainStep; - } - - // Obtain maximum strain - maximumStrain = Math.Max(hitObject.Strain, maximumStrain); - - previousHitObject = hitObject; - } - - // Build the weighted sum over the highest strains for each interval - double difficulty = 0; - double weight = 1; - highestStrains.Sort((a, b) => b.CompareTo(a)); // Sort from highest to lowest strain. - - foreach (double strain in highestStrains) - { - difficulty += weight * strain; - weight *= decay_weight; - } - - return difficulty; - } + new Movement(), + }; } } diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyHitObject.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyHitObject.cs deleted file mode 100644 index fb16e117b1..0000000000 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyHitObject.cs +++ /dev/null @@ -1,130 +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.Catch.Objects; -using osu.Game.Rulesets.Catch.UI; -using osuTK; - -namespace osu.Game.Rulesets.Catch.Difficulty -{ - public class CatchDifficultyHitObject - { - internal static readonly double DECAY_BASE = 0.20; - private const float normalized_hitobject_radius = 41.0f; - private const float absolute_player_positioning_error = 16f; - private readonly float playerPositioningError; - - internal CatchHitObject BaseHitObject; - - /// - /// Measures jump difficulty. CtB doesn't have something like button pressing speed or accuracy - /// - internal double Strain = 1; - - /// - /// This is required to keep track of lazy player movement (always moving only as far as necessary) - /// Without this quick repeat sliders / weirdly shaped streams might become ridiculously overrated - /// - internal float PlayerPositionOffset; - internal float LastMovement; - - internal float NormalizedPosition; - internal float ActualNormalizedPosition => NormalizedPosition + PlayerPositionOffset; - - internal CatchDifficultyHitObject(CatchHitObject baseHitObject, float catcherWidthHalf) - { - BaseHitObject = baseHitObject; - - // We will scale everything by this factor, so we can assume a uniform CircleSize among beatmaps. - float scalingFactor = normalized_hitobject_radius / catcherWidthHalf; - - playerPositioningError = absolute_player_positioning_error; // * scalingFactor; - NormalizedPosition = baseHitObject.X * CatchPlayfield.BASE_WIDTH * scalingFactor; - } - - private const double direction_change_bonus = 12.5; - internal void CalculateStrains(CatchDifficultyHitObject previousHitObject, double timeRate) - { - // Rather simple, but more specialized things are inherently inaccurate due to the big difference playstyles and opinions make. - // See Taiko feedback thread. - double timeElapsed = (BaseHitObject.StartTime - previousHitObject.BaseHitObject.StartTime) / timeRate; - double decay = Math.Pow(DECAY_BASE, timeElapsed / 1000); - - // Update new position with lazy movement. - PlayerPositionOffset = - MathHelper.Clamp( - previousHitObject.ActualNormalizedPosition, - NormalizedPosition - (normalized_hitobject_radius - playerPositioningError), - NormalizedPosition + (normalized_hitobject_radius - playerPositioningError)) // Obtain new lazy position, but be stricter by allowing for an error of a certain degree of the player. - - NormalizedPosition; // Subtract HitObject position to obtain offset - - LastMovement = DistanceTo(previousHitObject); - double addition = spacingWeight(LastMovement); - - if (NormalizedPosition < previousHitObject.NormalizedPosition) - { - LastMovement = -LastMovement; - } - - CatchHitObject previousHitCircle = previousHitObject.BaseHitObject; - - double additionBonus = 0; - double sqrtTime = Math.Sqrt(Math.Max(timeElapsed, 25)); - - // Direction changes give an extra point! - if (Math.Abs(LastMovement) > 0.1) - { - if (Math.Abs(previousHitObject.LastMovement) > 0.1 && Math.Sign(LastMovement) != Math.Sign(previousHitObject.LastMovement)) - { - double bonus = direction_change_bonus / sqrtTime; - - // Weight bonus by how - double bonusFactor = Math.Min(playerPositioningError, Math.Abs(LastMovement)) / playerPositioningError; - - // We want time to play a role twice here! - addition += bonus * bonusFactor; - - // Bonus for tougher direction switches and "almost" hyperdashes at this point - if (previousHitCircle != null && previousHitCircle.DistanceToHyperDash <= 10.0f / CatchPlayfield.BASE_WIDTH) - { - additionBonus += 0.3 * bonusFactor; - } - } - - // Base bonus for every movement, giving some weight to streams. - addition += 7.5 * Math.Min(Math.Abs(LastMovement), normalized_hitobject_radius * 2) / (normalized_hitobject_radius * 6) / sqrtTime; - } - - // Bonus for "almost" hyperdashes at corner points - if (previousHitCircle != null && previousHitCircle.DistanceToHyperDash <= 10.0f / CatchPlayfield.BASE_WIDTH) - { - if (!previousHitCircle.HyperDash) - { - additionBonus += 1.0; - } - else - { - // After a hyperdash we ARE in the correct position. Always! - PlayerPositionOffset = 0; - } - - addition *= 1.0 + additionBonus * ((10 - previousHitCircle.DistanceToHyperDash * CatchPlayfield.BASE_WIDTH) / 10); - } - - addition *= 850.0 / Math.Max(timeElapsed, 25); - - Strain = previousHitObject.Strain * decay + addition; - } - - private static double spacingWeight(float distance) - { - return Math.Pow(distance, 1.3) / 500; - } - - internal float DistanceTo(CatchDifficultyHitObject other) - { - return Math.Abs(ActualNormalizedPosition - other.ActualNormalizedPosition); - } - } -} diff --git a/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs b/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs new file mode 100644 index 0000000000..6d00bb27b5 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs @@ -0,0 +1,39 @@ +// 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.Catch.Objects; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Rulesets.Catch.Difficulty.Preprocessing +{ + public class CatchDifficultyHitObject : DifficultyHitObject + { + private const float normalized_hitobject_radius = 41.0f; + + public new CatchHitObject BaseObject => (CatchHitObject)base.BaseObject; + + public new CatchHitObject LastObject => (CatchHitObject)base.LastObject; + + public readonly float NormalizedPosition; + + /// + /// Milliseconds elapsed since the start time of the previous , with a minimum of 25ms. + /// + public readonly double StrainTime; + + public CatchDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, float halfCatcherWidth) + : base(hitObject, lastObject, clockRate) + { + // We will scale everything by this factor, so we can assume a uniform CircleSize among beatmaps. + var scalingFactor = normalized_hitobject_radius / halfCatcherWidth; + + NormalizedPosition = BaseObject.X * CatchPlayfield.BASE_WIDTH * scalingFactor; + + // Every strain interval is hard capped at the equivalent of 600 BPM streaming speed as a safety measure + StrainTime = Math.Max(25, DeltaTime); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs b/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs new file mode 100644 index 0000000000..e06ca08fbe --- /dev/null +++ b/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs @@ -0,0 +1,82 @@ +// 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.Catch.Difficulty.Preprocessing; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Difficulty.Skills +{ + public class Movement : Skill + { + private const float absolute_player_positioning_error = 16f; + private const float normalized_hitobject_radius = 41.0f; + private const double direction_change_bonus = 12.5; + + protected override double SkillMultiplier => 850; + protected override double StrainDecayBase => 0.2; + + protected override double DecayWeight => 0.94; + + private float lastPlayerPosition; + private float lastDistanceMoved; + + protected override double StrainValueOf(DifficultyHitObject current) + { + var catchCurrent = (CatchDifficultyHitObject)current; + + float playerPosition = MathHelper.Clamp( + lastPlayerPosition, + catchCurrent.NormalizedPosition - (normalized_hitobject_radius - absolute_player_positioning_error), + catchCurrent.NormalizedPosition + (normalized_hitobject_radius - absolute_player_positioning_error) + ); + + float distanceMoved = playerPosition - lastPlayerPosition; + + double distanceAddition = Math.Pow(Math.Abs(distanceMoved), 1.3) / 500; + double sqrtStrain = Math.Sqrt(catchCurrent.StrainTime); + + double bonus = 0; + + // Direction changes give an extra point! + if (Math.Abs(distanceMoved) > 0.1) + { + if (Math.Abs(lastDistanceMoved) > 0.1 && Math.Sign(distanceMoved) != Math.Sign(lastDistanceMoved)) + { + double bonusFactor = Math.Min(absolute_player_positioning_error, Math.Abs(distanceMoved)) / absolute_player_positioning_error; + + distanceAddition += direction_change_bonus / sqrtStrain * bonusFactor; + + // Bonus for tougher direction switches and "almost" hyperdashes at this point + if (catchCurrent.LastObject.DistanceToHyperDash <= 10 / CatchPlayfield.BASE_WIDTH) + bonus = 0.3 * bonusFactor; + } + + // Base bonus for every movement, giving some weight to streams. + distanceAddition += 7.5 * Math.Min(Math.Abs(distanceMoved), normalized_hitobject_radius * 2) / (normalized_hitobject_radius * 6) / sqrtStrain; + } + + // Bonus for "almost" hyperdashes at corner points + if (catchCurrent.LastObject.DistanceToHyperDash <= 10.0f / CatchPlayfield.BASE_WIDTH) + { + if (!catchCurrent.LastObject.HyperDash) + bonus += 1.0; + else + { + // After a hyperdash we ARE in the correct position. Always! + playerPosition = catchCurrent.NormalizedPosition; + } + + distanceAddition *= 1.0 + bonus * ((10 - catchCurrent.LastObject.DistanceToHyperDash * CatchPlayfield.BASE_WIDTH) / 10); + } + + lastPlayerPosition = playerPosition; + lastDistanceMoved = distanceMoved; + + return distanceAddition / catchCurrent.StrainTime; + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs index 05e2df796c..61ee322ce2 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs @@ -13,11 +13,11 @@ namespace osu.Game.Rulesets.Mania.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Mania"; - [TestCase(2.2676066895468976, "diffcalc-test")] + [TestCase(2.3683365342338796d, "diffcalc-test")] public void Test(double expected, string name) => base.Test(expected, name); - protected override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new ManiaDifficultyCalculator(new ManiaRuleset(), beatmap); + protected override LegacyDifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new ManiaDifficultyCalculator(new ManiaRuleset(), beatmap); protected override Ruleset CreateRuleset() => new ManiaRuleset(); } diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs index 2f614ea14b..3ff665d2c8 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs @@ -2,17 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Difficulty; -using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Mania.Difficulty { public class ManiaDifficultyAttributes : DifficultyAttributes { public double GreatHitWindow; - - public ManiaDifficultyAttributes(Mod[] mods, double starRating) - : base(mods, starRating) - { - } } } diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs index b8588dbce2..59fed1031f 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs @@ -1,34 +1,24 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// 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.Beatmaps; using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Difficulty.Preprocessing; +using osu.Game.Rulesets.Mania.Difficulty.Skills; using osu.Game.Rulesets.Mania.Mods; -using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Mania.Difficulty { - internal class ManiaDifficultyCalculator : DifficultyCalculator + public class ManiaDifficultyCalculator : DifficultyCalculator { private const double star_scaling_factor = 0.018; - /// - /// In milliseconds. For difficulty calculation we will only look at the highest strain value in each time interval of size strain_step. - /// This is to eliminate higher influence of stream over aim by simply having more HitObjects with high strain. - /// The higher this value, the less strains there will be, indirectly giving long beatmaps an advantage. - /// - private const double strain_step = 400; - - /// - /// The weighting of each strain value decays to this number * it's previous value - /// - private const double decay_weight = 0.9; - private readonly bool isForCurrentRuleset; public ManiaDifficultyCalculator(Ruleset ruleset, WorkingBeatmap beatmap) @@ -37,108 +27,70 @@ namespace osu.Game.Rulesets.Mania.Difficulty isForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.Equals(ruleset.RulesetInfo); } - protected override DifficultyAttributes Calculate(IBeatmap beatmap, Mod[] mods, double timeRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) { - if (!beatmap.HitObjects.Any()) - return new ManiaDifficultyAttributes(mods, 0); + if (beatmap.HitObjects.Count == 0) + return new ManiaDifficultyAttributes { Mods = mods }; - var difficultyHitObjects = new List(); - - int columnCount = ((ManiaBeatmap)beatmap).TotalColumns; - - // Sort DifficultyHitObjects by StartTime of the HitObjects - just to make sure. - // Note: Stable sort is done so that the ordering of hitobjects with equal start times doesn't change - difficultyHitObjects.AddRange(beatmap.HitObjects.Select(h => new ManiaHitObjectDifficulty((ManiaHitObject)h, columnCount)).OrderBy(h => h.BaseHitObject.StartTime)); - - if (!calculateStrainValues(difficultyHitObjects, timeRate)) - return new ManiaDifficultyAttributes(mods, 0); - - double starRating = calculateDifficulty(difficultyHitObjects, timeRate) * star_scaling_factor; - - return new ManiaDifficultyAttributes(mods, starRating) + return new ManiaDifficultyAttributes { - // Todo: This int cast is temporary to achieve 1:1 results with osu!stable, and should be remoevd in the future - GreatHitWindow = (int)(beatmap.HitObjects.First().HitWindows.Great / 2) / timeRate + StarRating = difficultyValue(skills) * star_scaling_factor, + Mods = mods, + // Todo: This int cast is temporary to achieve 1:1 results with osu!stable, and should be removed in the future + GreatHitWindow = (int)(beatmap.HitObjects.First().HitWindows.Great / 2) / clockRate, }; } - private bool calculateStrainValues(List objects, double timeRate) + private double difficultyValue(Skill[] skills) { - // Traverse hitObjects in pairs to calculate the strain value of NextHitObject from the strain value of CurrentHitObject and environment. - using (var hitObjectsEnumerator = objects.GetEnumerator()) + // Preprocess the strains to find the maximum overall + individual (aggregate) strain from each section + var overall = skills.OfType().Single(); + var aggregatePeaks = new List(Enumerable.Repeat(0.0, overall.StrainPeaks.Count)); + + foreach (var individual in skills.OfType()) { - if (!hitObjectsEnumerator.MoveNext()) - return false; - - ManiaHitObjectDifficulty current = hitObjectsEnumerator.Current; - - // First hitObject starts at strain 1. 1 is the default for strain values, so we don't need to set it here. See DifficultyHitObject. - while (hitObjectsEnumerator.MoveNext()) + for (int i = 0; i < individual.StrainPeaks.Count; i++) { - var next = hitObjectsEnumerator.Current; - next?.CalculateStrains(current, timeRate); - current = next; + double aggregate = individual.StrainPeaks[i] + overall.StrainPeaks[i]; + + if (aggregate > aggregatePeaks[i]) + aggregatePeaks[i] = aggregate; } - - return true; - } - } - - private double calculateDifficulty(List objects, double timeRate) - { - double actualStrainStep = strain_step * timeRate; - - // Find the highest strain value within each strain step - List highestStrains = new List(); - double intervalEndTime = actualStrainStep; - double maximumStrain = 0; // We need to keep track of the maximum strain in the current interval - - ManiaHitObjectDifficulty previousHitObject = null; - foreach (var hitObject in objects) - { - // While we are beyond the current interval push the currently available maximum to our strain list - while (hitObject.BaseHitObject.StartTime > intervalEndTime) - { - highestStrains.Add(maximumStrain); - - // The maximum strain of the next interval is not zero by default! We need to take the last hitObject we encountered, take its strain and apply the decay - // until the beginning of the next interval. - if (previousHitObject == null) - { - maximumStrain = 0; - } - else - { - double individualDecay = Math.Pow(ManiaHitObjectDifficulty.INDIVIDUAL_DECAY_BASE, (intervalEndTime - previousHitObject.BaseHitObject.StartTime) / 1000); - double overallDecay = Math.Pow(ManiaHitObjectDifficulty.OVERALL_DECAY_BASE, (intervalEndTime - previousHitObject.BaseHitObject.StartTime) / 1000); - maximumStrain = previousHitObject.IndividualStrain * individualDecay + previousHitObject.OverallStrain * overallDecay; - } - - // Go to the next time interval - intervalEndTime += actualStrainStep; - } - - // Obtain maximum strain - double strain = hitObject.IndividualStrain + hitObject.OverallStrain; - maximumStrain = Math.Max(strain, maximumStrain); - - previousHitObject = hitObject; } - // Build the weighted sum over the highest strains for each interval + aggregatePeaks.Sort((a, b) => b.CompareTo(a)); // Sort from highest to lowest strain. + double difficulty = 0; double weight = 1; - highestStrains.Sort((a, b) => b.CompareTo(a)); // Sort from highest to lowest strain. - foreach (double strain in highestStrains) + // Difficulty is the weighted sum of the highest strains from every section. + foreach (double strain in aggregatePeaks) { - difficulty += weight * strain; - weight *= decay_weight; + difficulty += strain * weight; + weight *= 0.9; } return difficulty; } + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + { + for (int i = 1; i < beatmap.HitObjects.Count; i++) + yield return new ManiaDifficultyHitObject(beatmap.HitObjects[i], beatmap.HitObjects[i - 1], clockRate); + } + + protected override Skill[] CreateSkills(IBeatmap beatmap) + { + int columnCount = ((ManiaBeatmap)beatmap).TotalColumns; + + var skills = new List { new Overall(columnCount) }; + + for (int i = 0; i < columnCount; i++) + skills.Add(new Individual(i, columnCount)); + + return skills.ToArray(); + } + protected override Mod[] DifficultyAdjustmentMods { get diff --git a/osu.Game.Rulesets.Mania/Difficulty/Preprocessing/ManiaDifficultyHitObject.cs b/osu.Game.Rulesets.Mania/Difficulty/Preprocessing/ManiaDifficultyHitObject.cs new file mode 100644 index 0000000000..29ba934e9f --- /dev/null +++ b/osu.Game.Rulesets.Mania/Difficulty/Preprocessing/ManiaDifficultyHitObject.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Rulesets.Mania.Difficulty.Preprocessing +{ + public class ManiaDifficultyHitObject : DifficultyHitObject + { + public new ManiaHitObject BaseObject => (ManiaHitObject)base.BaseObject; + + public ManiaDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate) + : base(hitObject, lastObject, clockRate) + { + } + } +} diff --git a/osu.Game.Rulesets.Mania/Difficulty/Skills/Individual.cs b/osu.Game.Rulesets.Mania/Difficulty/Skills/Individual.cs new file mode 100644 index 0000000000..059cd39641 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Difficulty/Skills/Individual.cs @@ -0,0 +1,47 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Mania.Difficulty.Preprocessing; +using osu.Game.Rulesets.Mania.Objects; + +namespace osu.Game.Rulesets.Mania.Difficulty.Skills +{ + public class Individual : Skill + { + protected override double SkillMultiplier => 1; + protected override double StrainDecayBase => 0.125; + + private readonly double[] holdEndTimes; + + private readonly int column; + + public Individual(int column, int columnCount) + { + this.column = column; + + holdEndTimes = new double[columnCount]; + } + + protected override double StrainValueOf(DifficultyHitObject current) + { + var maniaCurrent = (ManiaDifficultyHitObject)current; + var endTime = (maniaCurrent.BaseObject as HoldNote)?.EndTime ?? maniaCurrent.BaseObject.StartTime; + + try + { + if (maniaCurrent.BaseObject.Column != column) + return 0; + + // We give a slight bonus if something is held meanwhile + return holdEndTimes.Any(t => t > endTime) ? 2.5 : 2; + } + finally + { + holdEndTimes[maniaCurrent.BaseObject.Column] = endTime; + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Difficulty/Skills/Overall.cs b/osu.Game.Rulesets.Mania/Difficulty/Skills/Overall.cs new file mode 100644 index 0000000000..ed25173d38 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Difficulty/Skills/Overall.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 osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Mania.Difficulty.Preprocessing; +using osu.Game.Rulesets.Mania.Objects; + +namespace osu.Game.Rulesets.Mania.Difficulty.Skills +{ + public class Overall : Skill + { + protected override double SkillMultiplier => 1; + protected override double StrainDecayBase => 0.3; + + private readonly double[] holdEndTimes; + + private readonly int columnCount; + + public Overall(int columnCount) + { + this.columnCount = columnCount; + + holdEndTimes = new double[columnCount]; + } + + protected override double StrainValueOf(DifficultyHitObject current) + { + var maniaCurrent = (ManiaDifficultyHitObject)current; + var endTime = (maniaCurrent.BaseObject as HoldNote)?.EndTime ?? maniaCurrent.BaseObject.StartTime; + + double holdFactor = 1.0; // Factor in case something else is held + double holdAddition = 0; // Addition to the current note in case it's a hold and has to be released awkwardly + + for (int i = 0; i < columnCount; i++) + { + // If there is at least one other overlapping end or note, then we get an addition, buuuuuut... + if (current.BaseObject.StartTime < holdEndTimes[i] && endTime > holdEndTimes[i]) + holdAddition = 1.0; + + // ... this addition only is valid if there is _no_ other note with the same ending. + // Releasing multiple notes at the same time is just as easy as releasing one + if (endTime == holdEndTimes[i]) + holdAddition = 0; + + // We give a slight bonus if something is held meanwhile + if (holdEndTimes[i] > endTime) + holdFactor = 1.25; + } + + holdEndTimes[maniaCurrent.BaseObject.Column] = endTime; + + return (1 + holdAddition) * holdFactor; + } + } +} diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index 57728dd134..d86ee19802 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -156,7 +156,7 @@ namespace osu.Game.Rulesets.Mania public override Drawable CreateIcon() => new SpriteIcon { Icon = FontAwesome.fa_osu_mania_o }; - public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new ManiaDifficultyCalculator(this, beatmap); + public override LegacyDifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new ManiaDifficultyCalculator(this, beatmap); public override int? LegacyID => 3; diff --git a/osu.Game.Rulesets.Mania/Objects/ManiaHitObjectDifficulty.cs b/osu.Game.Rulesets.Mania/Objects/ManiaHitObjectDifficulty.cs deleted file mode 100644 index b6ea8c8665..0000000000 --- a/osu.Game.Rulesets.Mania/Objects/ManiaHitObjectDifficulty.cs +++ /dev/null @@ -1,112 +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 osu.Game.Rulesets.Objects.Types; -using System; - -namespace osu.Game.Rulesets.Mania.Objects -{ - internal class ManiaHitObjectDifficulty - { - /// - /// Factor by how much individual / overall strain decays per second. - /// - /// - /// These values are results of tweaking a lot and taking into account general feedback. - /// - internal const double INDIVIDUAL_DECAY_BASE = 0.125; - internal const double OVERALL_DECAY_BASE = 0.30; - - internal ManiaHitObject BaseHitObject; - - private readonly int beatmapColumnCount; - - private readonly double endTime; - private readonly double[] heldUntil; - - /// - /// Measures jacks or more generally: repeated presses of the same button - /// - private readonly double[] individualStrains; - - internal double IndividualStrain - { - get - { - return individualStrains[BaseHitObject.Column]; - } - - set - { - individualStrains[BaseHitObject.Column] = value; - } - } - - /// - /// Measures note density in a way - /// - internal double OverallStrain = 1; - - public ManiaHitObjectDifficulty(ManiaHitObject baseHitObject, int columnCount) - { - BaseHitObject = baseHitObject; - - endTime = (baseHitObject as IHasEndTime)?.EndTime ?? baseHitObject.StartTime; - - beatmapColumnCount = columnCount; - heldUntil = new double[beatmapColumnCount]; - individualStrains = new double[beatmapColumnCount]; - - for (int i = 0; i < beatmapColumnCount; ++i) - { - individualStrains[i] = 0; - heldUntil[i] = 0; - } - } - - internal void CalculateStrains(ManiaHitObjectDifficulty previousHitObject, double timeRate) - { - // TODO: Factor in holds - double timeElapsed = (BaseHitObject.StartTime - previousHitObject.BaseHitObject.StartTime) / timeRate; - double individualDecay = Math.Pow(INDIVIDUAL_DECAY_BASE, timeElapsed / 1000); - double overallDecay = Math.Pow(OVERALL_DECAY_BASE, timeElapsed / 1000); - - double holdFactor = 1.0; // Factor to all additional strains in case something else is held - double holdAddition = 0; // Addition to the current note in case it's a hold and has to be released awkwardly - - // Fill up the heldUntil array - for (int i = 0; i < beatmapColumnCount; ++i) - { - heldUntil[i] = previousHitObject.heldUntil[i]; - - // If there is at least one other overlapping end or note, then we get an addition, buuuuuut... - if (BaseHitObject.StartTime < heldUntil[i] && endTime > heldUntil[i]) - { - holdAddition = 1.0; - } - - // ... this addition only is valid if there is _no_ other note with the same ending. Releasing multiple notes at the same time is just as easy as releasing 1 - if (endTime == heldUntil[i]) - { - holdAddition = 0; - } - - // We give a slight bonus to everything if something is held meanwhile - if (heldUntil[i] > endTime) - { - holdFactor = 1.25; - } - - // Decay individual strains - individualStrains[i] = previousHitObject.individualStrains[i] * individualDecay; - } - - heldUntil[BaseHitObject.Column] = endTime; - - // Increase individual strain in own column - IndividualStrain += 2.0 * holdFactor; - - OverallStrain = previousHitObject.OverallStrain * overallDecay + (1.0 + holdAddition) * holdFactor; - } - } -} diff --git a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs index e55dc1f902..edf3f35304 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs @@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Osu.Tests public void Test(double expected, string name) => base.Test(expected, name); - protected override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new OsuDifficultyCalculator(new OsuRuleset(), beatmap); + protected override LegacyDifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new OsuDifficultyCalculator(new OsuRuleset(), beatmap); protected override Ruleset CreateRuleset() => new OsuRuleset(); } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs index fd54dc0260..6e991a1d08 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Difficulty; -using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Osu.Difficulty { @@ -13,10 +12,5 @@ namespace osu.Game.Rulesets.Osu.Difficulty public double ApproachRate; public double OverallDifficulty; public int MaxCombo; - - public OsuDifficultyAttributes(Mod[] mods, double starRating) - : base(mods, starRating) - { - } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 3d0a1dc266..e2a1542574 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -1,10 +1,13 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// 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.Beatmaps; using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Difficulty.Skills; @@ -15,7 +18,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty { public class OsuDifficultyCalculator : DifficultyCalculator { - private const int section_length = 400; private const double difficulty_multiplier = 0.0675; public OsuDifficultyCalculator(Ruleset ruleset, WorkingBeatmap beatmap) @@ -23,58 +25,27 @@ namespace osu.Game.Rulesets.Osu.Difficulty { } - protected override DifficultyAttributes Calculate(IBeatmap beatmap, Mod[] mods, double timeRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) { - if (!beatmap.HitObjects.Any()) - return new OsuDifficultyAttributes(mods, 0); - - OsuDifficultyBeatmap difficultyBeatmap = new OsuDifficultyBeatmap(beatmap.HitObjects.Cast().ToList(), timeRate); - Skill[] skills = - { - new Aim(), - new Speed() - }; - - double sectionLength = section_length * timeRate; - - // The first object doesn't generate a strain, so we begin with an incremented section end - double currentSectionEnd = Math.Ceiling(beatmap.HitObjects.First().StartTime / sectionLength) * sectionLength; - - foreach (OsuDifficultyHitObject h in difficultyBeatmap) - { - while (h.BaseObject.StartTime > currentSectionEnd) - { - foreach (Skill s in skills) - { - s.SaveCurrentPeak(); - s.StartNewSectionFrom(currentSectionEnd); - } - - currentSectionEnd += sectionLength; - } - - foreach (Skill s in skills) - s.Process(h); - } - - // The peak strain will not be saved for the last section in the above loop - foreach (Skill s in skills) - s.SaveCurrentPeak(); + if (beatmap.HitObjects.Count == 0) + return new OsuDifficultyAttributes { Mods = mods }; double aimRating = Math.Sqrt(skills[0].DifficultyValue()) * difficulty_multiplier; double speedRating = Math.Sqrt(skills[1].DifficultyValue()) * difficulty_multiplier; double starRating = aimRating + speedRating + Math.Abs(aimRating - speedRating) / 2; // Todo: These int casts are temporary to achieve 1:1 results with osu!stable, and should be removed in the future - double hitWindowGreat = (int)(beatmap.HitObjects.First().HitWindows.Great / 2) / timeRate; - double preempt = (int)BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.ApproachRate, 1800, 1200, 450) / timeRate; + double hitWindowGreat = (int)(beatmap.HitObjects.First().HitWindows.Great / 2) / clockRate; + double preempt = (int)BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.ApproachRate, 1800, 1200, 450) / clockRate; int maxCombo = beatmap.HitObjects.Count; // Add the ticks + tail of the slider. 1 is subtracted because the head circle would be counted twice (once for the slider itself in the line above) maxCombo += beatmap.HitObjects.OfType().Sum(s => s.NestedHitObjects.Count - 1); - return new OsuDifficultyAttributes(mods, starRating) + return new OsuDifficultyAttributes { + StarRating = starRating, + Mods = mods, AimStrain = aimRating, SpeedStrain = speedRating, ApproachRate = preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5, @@ -83,6 +54,26 @@ namespace osu.Game.Rulesets.Osu.Difficulty }; } + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + { + // 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. + for (int i = 1; i < beatmap.HitObjects.Count; i++) + { + var lastLast = i > 1 ? beatmap.HitObjects[i - 2] : null; + var last = beatmap.HitObjects[i - 1]; + var current = beatmap.HitObjects[i]; + + yield return new OsuDifficultyHitObject(current, lastLast, last, clockRate); + } + } + + protected override Skill[] CreateSkills(IBeatmap beatmap) => new Skill[] + { + new Aim(), + new Speed() + }; + protected override Mod[] DifficultyAdjustmentMods => new Mod[] { new OsuModDoubleTime(), diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyBeatmap.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyBeatmap.cs deleted file mode 100644 index 068564d50c..0000000000 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyBeatmap.cs +++ /dev/null @@ -1,50 +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; -using System.Collections.Generic; -using System.Linq; -using osu.Game.Rulesets.Osu.Objects; - -namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing -{ - /// - /// An enumerable container wrapping input as - /// which contains extra data required for difficulty calculation. - /// - public class OsuDifficultyBeatmap : IEnumerable - { - private readonly IEnumerator difficultyObjects; - - /// - /// Creates an enumerator, which preprocesses a list of s recieved as input, wrapping them as - /// which contains extra data required for difficulty calculation. - /// - public OsuDifficultyBeatmap(List objects, double timeRate) - { - // Sort OsuHitObjects by StartTime - they are not correctly ordered in some cases. - // This should probably happen before the objects reach the difficulty calculator. - difficultyObjects = createDifficultyObjectEnumerator(objects.OrderBy(h => h.StartTime).ToList(), timeRate); - } - - /// - /// Returns an enumerator that enumerates all s in the . - /// - public IEnumerator GetEnumerator() => difficultyObjects; - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - - private IEnumerator createDifficultyObjectEnumerator(List objects, double timeRate) - { - // 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. - for (int i = 1; i < objects.Count; i++) - { - var lastLast = i > 1 ? objects[i - 2] : null; - var last = objects[i - 1]; - var current = objects[i]; - - yield return new OsuDifficultyHitObject(lastLast, last, current, timeRate); - } - } - } -} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 1ec12adb3b..930c711783 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -1,24 +1,20 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// 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.Preprocessing; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Objects; using osuTK; namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing { - /// - /// A wrapper around extending it with additional data required for difficulty calculation. - /// - public class OsuDifficultyHitObject + public class OsuDifficultyHitObject : DifficultyHitObject { private const int normalized_radius = 52; - /// - /// The this refers to. - /// - public OsuHitObject BaseObject { get; } + protected new OsuHitObject BaseObject => (OsuHitObject)base.BaseObject; /// /// Normalized distance from the end position of the previous to the start position of this . @@ -30,40 +26,30 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing /// public double TravelDistance { get; private set; } - /// - /// Milliseconds elapsed since the StartTime of the previous . - /// - public double DeltaTime { get; private set; } - - /// - /// Milliseconds elapsed since the start time of the previous , with a minimum of 50ms. - /// - public double StrainTime { get; private set; } - /// /// Angle the player has to take to hit this . /// Calculated as the angle between the circles (current-2, current-1, current). /// public double? Angle { get; private set; } + /// + /// Milliseconds elapsed since the start time of the previous , with a minimum of 50ms. + /// + public readonly double StrainTime; + private readonly OsuHitObject lastLastObject; private readonly OsuHitObject lastObject; - private readonly double timeRate; - /// - /// Initializes the object calculating extra data required for difficulty calculation. - /// - public OsuDifficultyHitObject(OsuHitObject lastLastObject, OsuHitObject lastObject, OsuHitObject currentObject, double timeRate) + public OsuDifficultyHitObject(HitObject hitObject, HitObject lastLastObject, HitObject lastObject, double clockRate) + : base(hitObject, lastObject, clockRate) { - this.lastLastObject = lastLastObject; - this.lastObject = lastObject; - this.timeRate = timeRate; - - BaseObject = currentObject; + this.lastLastObject = (OsuHitObject)lastLastObject; + this.lastObject = (OsuHitObject)lastObject; setDistances(); - setTimingValues(); - // Calculate angle here + + // Every strain interval is hard capped at the equivalent of 375 BPM streaming speed as a safety measure + StrainTime = Math.Max(50, DeltaTime); } private void setDistances() @@ -102,14 +88,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing } } - private void setTimingValues() - { - DeltaTime = (BaseObject.StartTime - lastObject.StartTime) / timeRate; - - // Every strain interval is hard capped at the equivalent of 375 BPM streaming speed as a safety measure - StrainTime = Math.Max(50, DeltaTime); - } - private void computeSliderCursorPosition(Slider slider) { if (slider.LazyEndPosition != null) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index b5e57985e9..e74f4933b2 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -2,7 +2,10 @@ // 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.Osu.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Difficulty.Skills { @@ -17,33 +20,40 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills protected override double SkillMultiplier => 26.25; protected override double StrainDecayBase => 0.15; - protected override double StrainValueOf(OsuDifficultyHitObject current) + protected override double StrainValueOf(DifficultyHitObject current) { + if (current.BaseObject is Spinner) + return 0; + + var osuCurrent = (OsuDifficultyHitObject)current; + double result = 0; - const double scale = 90; - - double applyDiminishingExp(double val) => Math.Pow(val, 0.99); - if (Previous.Count > 0) { - if (current.Angle != null && current.Angle.Value > angle_bonus_begin) + var osuPrevious = (OsuDifficultyHitObject)Previous[0]; + + if (osuCurrent.Angle != null && osuCurrent.Angle.Value > angle_bonus_begin) { + const double scale = 90; + var angleBonus = Math.Sqrt( - Math.Max(Previous[0].JumpDistance - scale, 0) - * Math.Pow(Math.Sin(current.Angle.Value - angle_bonus_begin), 2) - * Math.Max(current.JumpDistance - scale, 0)); - result = 1.5 * applyDiminishingExp(Math.Max(0, angleBonus)) / Math.Max(timing_threshold, Previous[0].StrainTime); + Math.Max(osuPrevious.JumpDistance - scale, 0) + * Math.Pow(Math.Sin(osuCurrent.Angle.Value - angle_bonus_begin), 2) + * Math.Max(osuCurrent.JumpDistance - scale, 0)); + result = 1.5 * applyDiminishingExp(Math.Max(0, angleBonus)) / Math.Max(timing_threshold, osuPrevious.StrainTime); } } - double jumpDistanceExp = applyDiminishingExp(current.JumpDistance); - double travelDistanceExp = applyDiminishingExp(current.TravelDistance); + double jumpDistanceExp = applyDiminishingExp(osuCurrent.JumpDistance); + double travelDistanceExp = applyDiminishingExp(osuCurrent.TravelDistance); return Math.Max( - result + (jumpDistanceExp + travelDistanceExp + Math.Sqrt(travelDistanceExp * jumpDistanceExp)) / Math.Max(current.StrainTime, timing_threshold), - (Math.Sqrt(travelDistanceExp * jumpDistanceExp) + jumpDistanceExp + travelDistanceExp) / current.StrainTime + result + (jumpDistanceExp + travelDistanceExp + Math.Sqrt(travelDistanceExp * jumpDistanceExp)) / Math.Max(osuCurrent.StrainTime, timing_threshold), + (Math.Sqrt(travelDistanceExp * jumpDistanceExp) + jumpDistanceExp + travelDistanceExp) / osuCurrent.StrainTime ); } + + private double applyDiminishingExp(double val) => Math.Pow(val, 0.99); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index e78691ce53..46a81a9480 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -2,7 +2,10 @@ // 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.Osu.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Difficulty.Skills { @@ -11,6 +14,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// public class Speed : Skill { + private const double single_spacing_threshold = 125; + private const double angle_bonus_begin = 5 * Math.PI / 6; private const double pi_over_4 = Math.PI / 4; private const double pi_over_2 = Math.PI / 2; @@ -22,9 +27,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills private const double max_speed_bonus = 45; // ~330BPM private const double speed_balancing_factor = 40; - protected override double StrainValueOf(OsuDifficultyHitObject current) + protected override double StrainValueOf(DifficultyHitObject current) { - double distance = Math.Min(SINGLE_SPACING_THRESHOLD, current.TravelDistance + current.JumpDistance); + if (current.BaseObject is Spinner) + return 0; + + var osuCurrent = (OsuDifficultyHitObject)current; + + double distance = Math.Min(single_spacing_threshold, osuCurrent.TravelDistance + osuCurrent.JumpDistance); double deltaTime = Math.Max(max_speed_bonus, current.DeltaTime); double speedBonus = 1.0; @@ -32,20 +42,20 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills speedBonus = 1 + Math.Pow((min_speed_bonus - deltaTime) / speed_balancing_factor, 2); double angleBonus = 1.0; - if (current.Angle != null && current.Angle.Value < angle_bonus_begin) + if (osuCurrent.Angle != null && osuCurrent.Angle.Value < angle_bonus_begin) { - angleBonus = 1 + Math.Pow(Math.Sin(1.5 * (angle_bonus_begin - current.Angle.Value)), 2) / 3.57; - if (current.Angle.Value < pi_over_2) + angleBonus = 1 + Math.Pow(Math.Sin(1.5 * (angle_bonus_begin - osuCurrent.Angle.Value)), 2) / 3.57; + if (osuCurrent.Angle.Value < pi_over_2) { angleBonus = 1.28; - if (distance < 90 && current.Angle.Value < pi_over_4) + if (distance < 90 && osuCurrent.Angle.Value < pi_over_4) angleBonus += (1 - angleBonus) * Math.Min((90 - distance) / 10, 1); else if (distance < 90) - angleBonus += (1 - angleBonus) * Math.Min((90 - distance) / 10, 1) * Math.Sin((pi_over_2 - current.Angle.Value) / pi_over_4); + angleBonus += (1 - angleBonus) * Math.Min((90 - distance) / 10, 1) * Math.Sin((pi_over_2 - osuCurrent.Angle.Value) / pi_over_4); } } - return (1 + (speedBonus - 1) * 0.75) * angleBonus * (0.95 + speedBonus * Math.Pow(distance / SINGLE_SPACING_THRESHOLD, 3.5)) / current.StrainTime; + return (1 + (speedBonus - 1) * 0.75) * angleBonus * (0.95 + speedBonus * Math.Pow(distance / single_spacing_threshold, 3.5)) / osuCurrent.StrainTime; } } } diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index 200f4af3da..e7dbe98919 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -133,7 +133,7 @@ namespace osu.Game.Rulesets.Osu public override Drawable CreateIcon() => new SpriteIcon { Icon = FontAwesome.fa_osu_osu_o }; - public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new OsuDifficultyCalculator(this, beatmap); + public override LegacyDifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new OsuDifficultyCalculator(this, beatmap); public override PerformanceCalculator CreatePerformanceCalculator(WorkingBeatmap beatmap, ScoreInfo score) => new OsuPerformanceCalculator(this, beatmap, score); diff --git a/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs index 299f84fb1f..a26b184766 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs @@ -13,12 +13,12 @@ namespace osu.Game.Rulesets.Taiko.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Taiko"; - [TestCase(2.9811336589467095, "diffcalc-test")] - [TestCase(2.9811336589467095, "diffcalc-test-strong")] + [TestCase(2.9811338051242915d, "diffcalc-test")] + [TestCase(2.9811338051242915d, "diffcalc-test-strong")] public void Test(double expected, string name) => base.Test(expected, name); - protected override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new TaikoDifficultyCalculator(new TaikoRuleset(), beatmap); + protected override LegacyDifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new TaikoDifficultyCalculator(new TaikoRuleset(), beatmap); protected override Ruleset CreateRuleset() => new TaikoRuleset(); } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs new file mode 100644 index 0000000000..24345275c1 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing +{ + public class TaikoDifficultyHitObject : DifficultyHitObject + { + public readonly bool HasTypeChange; + + public TaikoDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate) + : base(hitObject, lastObject, clockRate) + { + HasTypeChange = lastObject is RimHit != hitObject is RimHit; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Strain.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Strain.cs new file mode 100644 index 0000000000..c6fe273b50 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Strain.cs @@ -0,0 +1,95 @@ +// 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.Taiko.Difficulty.Preprocessing; +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Skills +{ + public class Strain : Skill + { + private const double rhythm_change_base_threshold = 0.2; + private const double rhythm_change_base = 2.0; + + protected override double SkillMultiplier => 1; + protected override double StrainDecayBase => 0.3; + + private ColourSwitch lastColourSwitch = ColourSwitch.None; + + private int sameColourCount = 1; + + protected override double StrainValueOf(DifficultyHitObject current) + { + double addition = 1; + + // We get an extra addition if we are not a slider or spinner + if (current.LastObject is Hit && current.BaseObject is Hit && current.DeltaTime < 1000) + { + if (hasColourChange(current)) + addition += 0.75; + + if (hasRhythmChange(current)) + addition += 1; + } + else + { + lastColourSwitch = ColourSwitch.None; + sameColourCount = 1; + } + + double additionFactor = 1; + + // Scale the addition factor linearly from 0.4 to 1 for DeltaTime from 0 to 50 + if (current.DeltaTime < 50) + additionFactor = 0.4 + 0.6 * current.DeltaTime / 50; + + return additionFactor * addition; + } + + private bool hasRhythmChange(DifficultyHitObject current) + { + // We don't want a division by zero if some random mapper decides to put two HitObjects at the same time. + if (current.DeltaTime == 0 || Previous.Count == 0 || Previous[0].DeltaTime == 0) + return false; + + double timeElapsedRatio = Math.Max(Previous[0].DeltaTime / current.DeltaTime, current.DeltaTime / Previous[0].DeltaTime); + + if (timeElapsedRatio >= 8) + return false; + + double difference = Math.Log(timeElapsedRatio, rhythm_change_base) % 1.0; + + return difference > rhythm_change_base_threshold && difference < 1 - rhythm_change_base_threshold; + } + + private bool hasColourChange(DifficultyHitObject current) + { + var taikoCurrent = (TaikoDifficultyHitObject)current; + + if (!taikoCurrent.HasTypeChange) + { + sameColourCount++; + return false; + } + + var oldColourSwitch = lastColourSwitch; + var newColourSwitch = sameColourCount % 2 == 0 ? ColourSwitch.Even : ColourSwitch.Odd; + + lastColourSwitch = newColourSwitch; + sameColourCount = 1; + + // We only want a bonus if the parity of the color switch changes + return oldColourSwitch != ColourSwitch.None && oldColourSwitch != newColourSwitch; + } + + private enum ColourSwitch + { + None, + Even, + Odd + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs index 3770f9601a..75d3807bba 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Difficulty; -using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Taiko.Difficulty { @@ -10,10 +9,5 @@ namespace osu.Game.Rulesets.Taiko.Difficulty { public double GreatHitWindow; public int MaxCombo; - - public TaikoDifficultyAttributes(Mod[] mods, double starRating) - : base(mods, starRating) - { - } } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index 2322446666..685ad9949b 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -1,137 +1,51 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// 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.Beatmaps; using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; +using osu.Game.Rulesets.Taiko.Difficulty.Skills; using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Rulesets.Taiko.Objects; namespace osu.Game.Rulesets.Taiko.Difficulty { - internal class TaikoDifficultyCalculator : DifficultyCalculator + public class TaikoDifficultyCalculator : DifficultyCalculator { private const double star_scaling_factor = 0.04125; - /// - /// In milliseconds. For difficulty calculation we will only look at the highest strain value in each time interval of size STRAIN_STEP. - /// This is to eliminate higher influence of stream over aim by simply having more HitObjects with high strain. - /// The higher this value, the less strains there will be, indirectly giving long beatmaps an advantage. - /// - private const double strain_step = 400; - - /// - /// The weighting of each strain value decays to this number * it's previous value - /// - private const double decay_weight = 0.9; - public TaikoDifficultyCalculator(Ruleset ruleset, WorkingBeatmap beatmap) : base(ruleset, beatmap) { } - protected override DifficultyAttributes Calculate(IBeatmap beatmap, Mod[] mods, double timeRate) + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) { - if (!beatmap.HitObjects.Any()) - return new TaikoDifficultyAttributes(mods, 0); + if (beatmap.HitObjects.Count == 0) + return new TaikoDifficultyAttributes { Mods = mods }; - var difficultyHitObjects = new List(); - - foreach (var hitObject in beatmap.HitObjects) - difficultyHitObjects.Add(new TaikoHitObjectDifficulty((TaikoHitObject)hitObject)); - - // Sort DifficultyHitObjects by StartTime of the HitObjects - just to make sure. - difficultyHitObjects.Sort((a, b) => a.BaseHitObject.StartTime.CompareTo(b.BaseHitObject.StartTime)); - - if (!calculateStrainValues(difficultyHitObjects, timeRate)) - return new TaikoDifficultyAttributes(mods, 0); - - double starRating = calculateDifficulty(difficultyHitObjects, timeRate) * star_scaling_factor; - - return new TaikoDifficultyAttributes(mods, starRating) + return new TaikoDifficultyAttributes { - // Todo: This int cast is temporary to achieve 1:1 results with osu!stable, and should be remoevd in the future - GreatHitWindow = (int)(beatmap.HitObjects.First().HitWindows.Great / 2) / timeRate, - MaxCombo = beatmap.HitObjects.Count(h => h is Hit) + StarRating = skills.Single().DifficultyValue() * star_scaling_factor, + Mods = mods, + // Todo: This int cast is temporary to achieve 1:1 results with osu!stable, and should be removed in the future + GreatHitWindow = (int)(beatmap.HitObjects.First().HitWindows.Great / 2) / clockRate, + MaxCombo = beatmap.HitObjects.Count(h => h is Hit), }; } - private bool calculateStrainValues(List objects, double timeRate) + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) { - // Traverse hitObjects in pairs to calculate the strain value of NextHitObject from the strain value of CurrentHitObject and environment. - using (var hitObjectsEnumerator = objects.GetEnumerator()) - { - if (!hitObjectsEnumerator.MoveNext()) return false; - - TaikoHitObjectDifficulty current = hitObjectsEnumerator.Current; - - // First hitObject starts at strain 1. 1 is the default for strain values, so we don't need to set it here. See DifficultyHitObject. - while (hitObjectsEnumerator.MoveNext()) - { - var next = hitObjectsEnumerator.Current; - next?.CalculateStrains(current, timeRate); - current = next; - } - - return true; - } + for (int i = 1; i < beatmap.HitObjects.Count; i++) + yield return new TaikoDifficultyHitObject(beatmap.HitObjects[i], beatmap.HitObjects[i - 1], clockRate); } - private double calculateDifficulty(List objects, double timeRate) - { - double actualStrainStep = strain_step * timeRate; - - // Find the highest strain value within each strain step - List highestStrains = new List(); - double intervalEndTime = actualStrainStep; - double maximumStrain = 0; // We need to keep track of the maximum strain in the current interval - - TaikoHitObjectDifficulty previousHitObject = null; - foreach (var hitObject in objects) - { - // While we are beyond the current interval push the currently available maximum to our strain list - while (hitObject.BaseHitObject.StartTime > intervalEndTime) - { - highestStrains.Add(maximumStrain); - - // The maximum strain of the next interval is not zero by default! We need to take the last hitObject we encountered, take its strain and apply the decay - // until the beginning of the next interval. - if (previousHitObject == null) - { - maximumStrain = 0; - } - else - { - double decay = Math.Pow(TaikoHitObjectDifficulty.DECAY_BASE, (intervalEndTime - previousHitObject.BaseHitObject.StartTime) / 1000); - maximumStrain = previousHitObject.Strain * decay; - } - - // Go to the next time interval - intervalEndTime += actualStrainStep; - } - - // Obtain maximum strain - maximumStrain = Math.Max(hitObject.Strain, maximumStrain); - - previousHitObject = hitObject; - } - - // Build the weighted sum over the highest strains for each interval - double difficulty = 0; - double weight = 1; - highestStrains.Sort((a, b) => b.CompareTo(a)); // Sort from highest to lowest strain. - - foreach (double strain in highestStrains) - { - difficulty += weight * strain; - weight *= decay_weight; - } - - return difficulty; - } + protected override Skill[] CreateSkills(IBeatmap beatmap) => new Skill[] { new Strain() }; protected override Mod[] DifficultyAdjustmentMods => new Mod[] { diff --git a/osu.Game.Rulesets.Taiko/Objects/TaikoHitObjectDifficulty.cs b/osu.Game.Rulesets.Taiko/Objects/TaikoHitObjectDifficulty.cs deleted file mode 100644 index 46dd7aa87f..0000000000 --- a/osu.Game.Rulesets.Taiko/Objects/TaikoHitObjectDifficulty.cs +++ /dev/null @@ -1,127 +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; - -namespace osu.Game.Rulesets.Taiko.Objects -{ - internal class TaikoHitObjectDifficulty - { - /// - /// Factor by how much individual / overall strain decays per second. - /// - /// - /// These values are results of tweaking a lot and taking into account general feedback. - /// - internal const double DECAY_BASE = 0.30; - - private const double type_change_bonus = 0.75; - private const double rhythm_change_bonus = 1.0; - private const double rhythm_change_base_threshold = 0.2; - private const double rhythm_change_base = 2.0; - - internal TaikoHitObject BaseHitObject; - - /// - /// Measures note density in a way - /// - internal double Strain = 1; - - private double timeElapsed; - private int sameTypeSince = 1; - - private bool isRim => BaseHitObject is RimHit; - - public TaikoHitObjectDifficulty(TaikoHitObject baseHitObject) - { - BaseHitObject = baseHitObject; - } - - internal void CalculateStrains(TaikoHitObjectDifficulty previousHitObject, double timeRate) - { - // Rather simple, but more specialized things are inherently inaccurate due to the big difference playstyles and opinions make. - // See Taiko feedback thread. - timeElapsed = (BaseHitObject.StartTime - previousHitObject.BaseHitObject.StartTime) / timeRate; - double decay = Math.Pow(DECAY_BASE, timeElapsed / 1000); - - double addition = 1; - - // Only if we are no slider or spinner we get an extra addition - if (previousHitObject.BaseHitObject is Hit && BaseHitObject is Hit - && BaseHitObject.StartTime - previousHitObject.BaseHitObject.StartTime < 1000) // And we only want to check out hitobjects which aren't so far in the past - { - addition += typeChangeAddition(previousHitObject); - addition += rhythmChangeAddition(previousHitObject); - } - - double additionFactor = 1.0; - // Scale AdditionFactor linearly from 0.4 to 1 for TimeElapsed from 0 to 50 - if (timeElapsed < 50.0) - additionFactor = 0.4 + 0.6 * timeElapsed / 50.0; - - Strain = previousHitObject.Strain * decay + addition * additionFactor; - } - - private TypeSwitch lastTypeSwitchEven = TypeSwitch.None; - private double typeChangeAddition(TaikoHitObjectDifficulty previousHitObject) - { - // If we don't have the same hit type, trigger a type change! - if (previousHitObject.isRim != isRim) - { - lastTypeSwitchEven = previousHitObject.sameTypeSince % 2 == 0 ? TypeSwitch.Even : TypeSwitch.Odd; - - // We only want a bonus if the parity of the type switch changes! - switch (previousHitObject.lastTypeSwitchEven) - { - case TypeSwitch.Even: - if (lastTypeSwitchEven == TypeSwitch.Odd) - return type_change_bonus; - break; - case TypeSwitch.Odd: - if (lastTypeSwitchEven == TypeSwitch.Even) - return type_change_bonus; - break; - } - } - // No type change? Increment counter and keep track of last type switch - else - { - lastTypeSwitchEven = previousHitObject.lastTypeSwitchEven; - sameTypeSince = previousHitObject.sameTypeSince + 1; - } - - return 0; - } - - private double rhythmChangeAddition(TaikoHitObjectDifficulty previousHitObject) - { - // We don't want a division by zero if some random mapper decides to put 2 HitObjects at the same time. - if (timeElapsed == 0 || previousHitObject.timeElapsed == 0) - return 0; - - double timeElapsedRatio = Math.Max(previousHitObject.timeElapsed / timeElapsed, timeElapsed / previousHitObject.timeElapsed); - - if (timeElapsedRatio >= 8) - return 0; - - double difference = Math.Log(timeElapsedRatio, rhythm_change_base) % 1.0; - - if (isWithinChangeThreshold(difference)) - return rhythm_change_bonus; - - return 0; - } - - private bool isWithinChangeThreshold(double value) - { - return value > rhythm_change_base_threshold && value < 1 - rhythm_change_base_threshold; - } - - private enum TypeSwitch - { - None, - Even, - Odd - } - } -} diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index 7851a2f919..b4becae7c2 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -110,7 +110,7 @@ namespace osu.Game.Rulesets.Taiko public override Drawable CreateIcon() => new SpriteIcon { Icon = FontAwesome.fa_osu_taiko_o }; - public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new TaikoDifficultyCalculator(this, beatmap); + public override LegacyDifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new TaikoDifficultyCalculator(this, beatmap); public override PerformanceCalculator CreatePerformanceCalculator(WorkingBeatmap beatmap, ScoreInfo score) => new TaikoPerformanceCalculator(this, beatmap, score); diff --git a/osu.Game.Rulesets.Taiko/osu.Game.Rulesets.Taiko.csproj b/osu.Game.Rulesets.Taiko/osu.Game.Rulesets.Taiko.csproj index 002d6a7e8d..656ebcc7c2 100644 --- a/osu.Game.Rulesets.Taiko/osu.Game.Rulesets.Taiko.csproj +++ b/osu.Game.Rulesets.Taiko/osu.Game.Rulesets.Taiko.csproj @@ -10,4 +10,4 @@ - \ No newline at end of file + diff --git a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs index 795c2244a2..cc73cd54a2 100644 --- a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs +++ b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs @@ -15,7 +15,7 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestNoMods() { - var combinations = new TestDifficultyCalculator().CreateDifficultyAdjustmentModCombinations(); + var combinations = new TestLegacyDifficultyCalculator().CreateDifficultyAdjustmentModCombinations(); Assert.AreEqual(1, combinations.Length); Assert.IsTrue(combinations[0] is ModNoMod); @@ -24,7 +24,7 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestSingleMod() { - var combinations = new TestDifficultyCalculator(new ModA()).CreateDifficultyAdjustmentModCombinations(); + var combinations = new TestLegacyDifficultyCalculator(new ModA()).CreateDifficultyAdjustmentModCombinations(); Assert.AreEqual(2, combinations.Length); Assert.IsTrue(combinations[0] is ModNoMod); @@ -34,7 +34,7 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestDoubleMod() { - var combinations = new TestDifficultyCalculator(new ModA(), new ModB()).CreateDifficultyAdjustmentModCombinations(); + var combinations = new TestLegacyDifficultyCalculator(new ModA(), new ModB()).CreateDifficultyAdjustmentModCombinations(); Assert.AreEqual(4, combinations.Length); Assert.IsTrue(combinations[0] is ModNoMod); @@ -49,7 +49,7 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestIncompatibleMods() { - var combinations = new TestDifficultyCalculator(new ModA(), new ModIncompatibleWithA()).CreateDifficultyAdjustmentModCombinations(); + var combinations = new TestLegacyDifficultyCalculator(new ModA(), new ModIncompatibleWithA()).CreateDifficultyAdjustmentModCombinations(); Assert.AreEqual(3, combinations.Length); Assert.IsTrue(combinations[0] is ModNoMod); @@ -60,7 +60,7 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestDoubleIncompatibleMods() { - var combinations = new TestDifficultyCalculator(new ModA(), new ModB(), new ModIncompatibleWithA(), new ModIncompatibleWithAAndB()).CreateDifficultyAdjustmentModCombinations(); + var combinations = new TestLegacyDifficultyCalculator(new ModA(), new ModB(), new ModIncompatibleWithA(), new ModIncompatibleWithAAndB()).CreateDifficultyAdjustmentModCombinations(); Assert.AreEqual(8, combinations.Length); Assert.IsTrue(combinations[0] is ModNoMod); @@ -83,7 +83,7 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestIncompatibleThroughBaseType() { - var combinations = new TestDifficultyCalculator(new ModAofA(), new ModIncompatibleWithAofA()).CreateDifficultyAdjustmentModCombinations(); + var combinations = new TestLegacyDifficultyCalculator(new ModAofA(), new ModIncompatibleWithAofA()).CreateDifficultyAdjustmentModCombinations(); Assert.AreEqual(3, combinations.Length); Assert.IsTrue(combinations[0] is ModNoMod); @@ -136,9 +136,9 @@ namespace osu.Game.Tests.NonVisual public override Type[] IncompatibleMods => new[] { typeof(ModA), typeof(ModB) }; } - private class TestDifficultyCalculator : DifficultyCalculator + private class TestLegacyDifficultyCalculator : LegacyDifficultyCalculator { - public TestDifficultyCalculator(params Mod[] mods) + public TestLegacyDifficultyCalculator(params Mod[] mods) : base(null, null) { DifficultyAdjustmentMods = mods; @@ -146,7 +146,7 @@ namespace osu.Game.Tests.NonVisual protected override Mod[] DifficultyAdjustmentMods { get; } - protected override DifficultyAttributes Calculate(IBeatmap beatmap, Mod[] mods, double timeRate) => throw new NotImplementedException(); + protected override DifficultyAttributes Calculate(IBeatmap beatmap, Mod[] mods, double clockRate) => throw new NotImplementedException(); } } } diff --git a/osu.Game.Tests/NonVisual/LimitedCapacityStackTest.cs b/osu.Game.Tests/NonVisual/LimitedCapacityStackTest.cs new file mode 100644 index 0000000000..1c78b63499 --- /dev/null +++ b/osu.Game.Tests/NonVisual/LimitedCapacityStackTest.cs @@ -0,0 +1,115 @@ +// 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 NUnit.Framework; +using osu.Game.Rulesets.Difficulty.Utils; + +namespace osu.Game.Tests.NonVisual +{ + [TestFixture] + public class LimitedCapacityStackTest + { + private const int capacity = 3; + + private LimitedCapacityStack stack; + + [SetUp] + public void Setup() + { + stack = new LimitedCapacityStack(capacity); + } + + [Test] + public void TestEmptyStack() + { + Assert.AreEqual(0, stack.Count); + + Assert.Throws(() => + { + int unused = stack[0]; + }); + + int count = 0; + foreach (var unused in stack) + count++; + + Assert.AreEqual(0, count); + } + + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + public void TestInRangeElements(int count) + { + // e.g. 0 -> 1 -> 2 + for (int i = 0; i < count; i++) + stack.Push(i); + + Assert.AreEqual(count, stack.Count); + + // e.g. 2 -> 1 -> 0 (reverse order) + for (int i = 0; i < stack.Count; i++) + Assert.AreEqual(count - 1 - i, stack[i]); + + // e.g. indices 3, 4, 5, 6 (out of range) + for (int i = stack.Count; i < stack.Count + capacity; i++) + { + Assert.Throws(() => + { + int unused = stack[i]; + }); + } + } + + [TestCase(4)] + [TestCase(5)] + [TestCase(6)] + public void TestOverflowElements(int count) + { + // e.g. 0 -> 1 -> 2 -> 3 + for (int i = 0; i < count; i++) + stack.Push(i); + + Assert.AreEqual(capacity, stack.Count); + + // e.g. 3 -> 2 -> 1 (reverse order) + for (int i = 0; i < stack.Count; i++) + Assert.AreEqual(count - 1 - i, stack[i]); + + // e.g. indices 3, 4, 5, 6 (out of range) + for (int i = stack.Count; i < stack.Count + capacity; i++) + { + Assert.Throws(() => + { + int unused = stack[i]; + }); + } + } + + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + [TestCase(4)] + [TestCase(5)] + [TestCase(6)] + public void TestEnumerator(int count) + { + // e.g. 0 -> 1 -> 2 -> 3 + for (int i = 0; i < count; i++) + stack.Push(i); + + int enumeratorCount = 0; + int expectedValue = count - 1; + + foreach (var item in stack) + { + Assert.AreEqual(expectedValue, item); + enumeratorCount++; + expectedValue--; + } + + Assert.AreEqual(stack.Count, enumeratorCount); + } + } +} diff --git a/osu.Game.Tests/Visual/TestCaseMultiScreen.cs b/osu.Game.Tests/Visual/TestCaseMultiScreen.cs index fc4037f58b..804e3c5b1f 100644 --- a/osu.Game.Tests/Visual/TestCaseMultiScreen.cs +++ b/osu.Game.Tests/Visual/TestCaseMultiScreen.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using NUnit.Framework; -using osu.Framework.Screens; using osu.Game.Screens.Multi; using osu.Game.Screens.Multi.Lounge; using osu.Game.Screens.Multi.Lounge.Components; @@ -26,8 +25,6 @@ namespace osu.Game.Tests.Visual Multiplayer multi = new Multiplayer(); AddStep(@"show", () => LoadScreen(multi)); - AddWaitStep(5); - AddStep(@"exit", multi.Exit); } } } diff --git a/osu.Game/Beatmaps/DummyWorkingBeatmap.cs b/osu.Game/Beatmaps/DummyWorkingBeatmap.cs index 0aa1697bf8..eb9e221ca4 100644 --- a/osu.Game/Beatmaps/DummyWorkingBeatmap.cs +++ b/osu.Game/Beatmaps/DummyWorkingBeatmap.cs @@ -54,7 +54,7 @@ namespace osu.Game.Beatmaps public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new DummyBeatmapConverter { Beatmap = beatmap }; - public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => null; + public override LegacyDifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => null; public override string Description => "dummy"; diff --git a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs index d7654462d5..d808ee528e 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs @@ -7,8 +7,13 @@ namespace osu.Game.Rulesets.Difficulty { public class DifficultyAttributes { - public readonly Mod[] Mods; - public readonly double StarRating; + public Mod[] Mods; + + public double StarRating; + + public DifficultyAttributes() + { + } public DifficultyAttributes(Mod[] mods, double starRating) { diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index 818d24508a..29ec9aae25 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -1,56 +1,64 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// 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.IEnumerableExtensions; -using osu.Framework.Timing; using osu.Game.Beatmaps; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Difficulty { - public abstract class DifficultyCalculator + public abstract class DifficultyCalculator : LegacyDifficultyCalculator { - private readonly Ruleset ruleset; - private readonly WorkingBeatmap beatmap; + /// + /// The length of each strain section. + /// + protected virtual int SectionLength => 400; protected DifficultyCalculator(Ruleset ruleset, WorkingBeatmap beatmap) + : base(ruleset, beatmap) { - this.ruleset = ruleset; - this.beatmap = beatmap; } - /// - /// Calculates the difficulty of the beatmap using a specific mod combination. - /// - /// The mods that should be applied to the beatmap. - /// A structure describing the difficulty of the beatmap. - public DifficultyAttributes Calculate(params Mod[] mods) + protected override DifficultyAttributes Calculate(IBeatmap beatmap, Mod[] mods, double clockRate) { - beatmap.Mods.Value = mods; - IBeatmap playableBeatmap = beatmap.GetPlayableBeatmap(ruleset.RulesetInfo); + var skills = CreateSkills(beatmap); - var clock = new StopwatchClock(); - mods.OfType().ForEach(m => m.ApplyToClock(clock)); + if (!beatmap.HitObjects.Any()) + return CreateDifficultyAttributes(beatmap, mods, skills, clockRate); - return Calculate(playableBeatmap, mods, clock.Rate); - } + var difficultyHitObjects = CreateDifficultyHitObjects(beatmap, clockRate).OrderBy(h => h.BaseObject.StartTime).ToList(); - /// - /// Calculates the difficulty of the beatmap using all mod combinations applicable to the beatmap. - /// - /// A collection of structures describing the difficulty of the beatmap for each mod combination. - public IEnumerable CalculateAll() - { - foreach (var combination in CreateDifficultyAdjustmentModCombinations()) + double sectionLength = SectionLength * clockRate; + + // The first object doesn't generate a strain, so we begin with an incremented section end + double currentSectionEnd = Math.Ceiling(beatmap.HitObjects.First().StartTime / sectionLength) * sectionLength; + + foreach (DifficultyHitObject h in difficultyHitObjects) { - if (combination is MultiMod multi) - yield return Calculate(multi.Mods); - else - yield return Calculate(combination); + while (h.BaseObject.StartTime > currentSectionEnd) + { + foreach (Skill s in skills) + { + s.SaveCurrentPeak(); + s.StartNewSectionFrom(currentSectionEnd); + } + + currentSectionEnd += sectionLength; + } + + foreach (Skill s in skills) + s.Process(h); } + + // The peak strain will not be saved for the last section in the above loop + foreach (Skill s in skills) + s.SaveCurrentPeak(); + + return CreateDifficultyAttributes(beatmap, mods, skills, clockRate); } /// @@ -96,12 +104,27 @@ namespace osu.Game.Rulesets.Difficulty protected virtual Mod[] DifficultyAdjustmentMods => Array.Empty(); /// - /// Calculates the difficulty of a using a specific combination. + /// Creates to describe beatmap's calculated difficulty. /// - /// The to compute the difficulty for. - /// The s that should be applied. - /// The rate of time in . - /// A structure containing the difficulty attributes. - protected abstract DifficultyAttributes Calculate(IBeatmap beatmap, Mod[] mods, double timeRate); + /// The whose difficulty was calculated. + /// 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); + + /// + /// 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. + /// The enumerated s. + protected abstract IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate); + + /// + /// Creates the s to calculate the difficulty of an . + /// + /// The whose difficulty will be calculated.The s. + protected abstract Skill[] CreateSkills(IBeatmap beatmap); } } diff --git a/osu.Game/Rulesets/Difficulty/LegacyDifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/LegacyDifficultyCalculator.cs new file mode 100644 index 0000000000..a1324601aa --- /dev/null +++ b/osu.Game/Rulesets/Difficulty/LegacyDifficultyCalculator.cs @@ -0,0 +1,107 @@ +// 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.IEnumerableExtensions; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Difficulty +{ + public abstract class LegacyDifficultyCalculator + { + private readonly Ruleset ruleset; + private readonly WorkingBeatmap beatmap; + + protected LegacyDifficultyCalculator(Ruleset ruleset, WorkingBeatmap beatmap) + { + this.ruleset = ruleset; + this.beatmap = beatmap; + } + + /// + /// Calculates the difficulty of the beatmap using a specific mod combination. + /// + /// The mods that should be applied to the beatmap. + /// A structure describing the difficulty of the beatmap. + public DifficultyAttributes Calculate(params Mod[] mods) + { + beatmap.Mods.Value = mods; + IBeatmap playableBeatmap = beatmap.GetPlayableBeatmap(ruleset.RulesetInfo); + + var clock = new StopwatchClock(); + mods.OfType().ForEach(m => m.ApplyToClock(clock)); + + return Calculate(playableBeatmap, mods, clock.Rate); + } + + /// + /// Calculates the difficulty of the beatmap using all mod combinations applicable to the beatmap. + /// + /// A collection of structures describing the difficulty of the beatmap for each mod combination. + public IEnumerable CalculateAll() + { + foreach (var combination in CreateDifficultyAdjustmentModCombinations()) + { + if (combination is MultiMod multi) + yield return Calculate(multi.Mods); + else + yield return Calculate(combination); + } + } + + /// + /// Creates all combinations which adjust the difficulty. + /// + public Mod[] CreateDifficultyAdjustmentModCombinations() + { + return createDifficultyAdjustmentModCombinations(Enumerable.Empty(), DifficultyAdjustmentMods).ToArray(); + + IEnumerable createDifficultyAdjustmentModCombinations(IEnumerable currentSet, Mod[] adjustmentSet, int currentSetCount = 0, int adjustmentSetStart = 0) + { + switch (currentSetCount) + { + case 0: + // Initial-case: Empty current set + yield return new ModNoMod(); + break; + case 1: + yield return currentSet.Single(); + break; + default: + yield return new MultiMod(currentSet.ToArray()); + break; + } + + // Apply mods in the adjustment set recursively. Using the entire adjustment set would result in duplicate multi-mod mod + // combinations in further recursions, so a moving subset is used to eliminate this effect + for (int i = adjustmentSetStart; i < adjustmentSet.Length; i++) + { + var adjustmentMod = adjustmentSet[i]; + if (currentSet.Any(c => c.IncompatibleMods.Any(m => m.IsInstanceOfType(adjustmentMod)))) + continue; + + foreach (var combo in createDifficultyAdjustmentModCombinations(currentSet.Append(adjustmentMod), adjustmentSet, currentSetCount + 1, i + 1)) + yield return combo; + } + } + } + + /// + /// Retrieves all s which adjust the difficulty. + /// + protected virtual Mod[] DifficultyAdjustmentMods => Array.Empty(); + + /// + /// Calculates the difficulty of a using a specific combination. + /// + /// The to compute the difficulty for. + /// The s that should be applied. + /// The rate at which the gameplay clock is run at. + /// A structure containing the difficulty attributes. + protected abstract DifficultyAttributes Calculate(IBeatmap beatmap, Mod[] mods, double clockRate); + } +} diff --git a/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs b/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs new file mode 100644 index 0000000000..ebbffb5143 --- /dev/null +++ b/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs @@ -0,0 +1,41 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Rulesets.Difficulty.Preprocessing +{ + /// + /// Wraps a and provides additional information to be used for difficulty calculation. + /// + public class DifficultyHitObject + { + /// + /// The this wraps. + /// + public readonly HitObject BaseObject; + + /// + /// The last which occurs before . + /// + public readonly HitObject LastObject; + + /// + /// Amount of time elapsed between and . + /// + public readonly double DeltaTime; + + /// + /// Creates a new . + /// + /// The which this wraps. + /// The last which occurs before in the beatmap. + /// The rate at which the gameplay clock is run at. + public DifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate) + { + BaseObject = hitObject; + LastObject = lastObject; + DeltaTime = (hitObject.StartTime - lastObject.StartTime) / clockRate; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Skill.cs b/osu.Game/Rulesets/Difficulty/Skills/Skill.cs similarity index 68% rename from osu.Game.Rulesets.Osu/Difficulty/Skills/Skill.cs rename to osu.Game/Rulesets/Difficulty/Skills/Skill.cs index 2f23552eb9..e8020ed185 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Skill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/Skill.cs @@ -3,20 +3,21 @@ using System; using System.Collections.Generic; -using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; -using osu.Game.Rulesets.Osu.Difficulty.Utils; -using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; -namespace osu.Game.Rulesets.Osu.Difficulty.Skills +namespace osu.Game.Rulesets.Difficulty.Skills { /// - /// Used to processes strain values of s, keep track of strain levels caused by the processed objects + /// Used to processes strain values of s, keep track of strain levels caused by the processed objects /// and to calculate a final difficulty value representing the difficulty of hitting all the processed objects. /// public abstract class Skill { - protected const double SINGLE_SPACING_THRESHOLD = 125; - protected const double STREAM_SPACING_THRESHOLD = 110; + /// + /// The peak strain for each section of the beatmap. + /// + public IList StrainPeaks => strainPeaks; /// /// Strain values are multiplied by this number for the given skill. Used to balance the value of different skills between each other. @@ -30,22 +31,27 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills protected abstract double StrainDecayBase { get; } /// - /// s that were processed previously. They can affect the strain values of the following objects. + /// The weight by which each strain value decays. /// - protected readonly History Previous = new History(2); // Contained objects not used yet + protected virtual double DecayWeight => 0.9; + + /// + /// s that were processed previously. They can affect the strain values of the following objects. + /// + protected readonly LimitedCapacityStack Previous = new LimitedCapacityStack(2); // Contained objects not used yet private double currentStrain = 1; // We keep track of the strain level at all times throughout the beatmap. private double currentSectionPeak = 1; // We also keep track of the peak strain level in the current section. + private readonly List strainPeaks = new List(); /// - /// Process an and update current strain values accordingly. + /// Process a and update current strain values accordingly. /// - public void Process(OsuDifficultyHitObject current) + public void Process(DifficultyHitObject current) { currentStrain *= strainDecay(current.DeltaTime); - if (!(current.BaseObject is Spinner)) - currentStrain += StrainValueOf(current) * SkillMultiplier; + currentStrain += StrainValueOf(current) * SkillMultiplier; currentSectionPeak = Math.Max(currentStrain, currentSectionPeak); @@ -64,7 +70,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// /// Sets the initial strain level for a new section. /// - /// The beginning of the new section in milliseconds + /// The beginning of the new section in milliseconds. public void StartNewSectionFrom(double offset) { // The maximum strain of the new section is not zero by default, strain decays as usual regardless of section boundaries. @@ -74,7 +80,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills } /// - /// Returns the calculated difficulty value representing all processed s. + /// Returns the calculated difficulty value representing all processed s. /// public double DifficultyValue() { @@ -87,16 +93,16 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills foreach (double strain in strainPeaks) { difficulty += strain * weight; - weight *= 0.9; + weight *= DecayWeight; } return difficulty; } /// - /// Calculates the strain value of an . This value is affected by previously processed objects. + /// Calculates the strain value of a . This value is affected by previously processed objects. /// - protected abstract double StrainValueOf(OsuDifficultyHitObject current); + protected abstract double StrainValueOf(DifficultyHitObject current); private double strainDecay(double ms) => Math.Pow(StrainDecayBase, ms / 1000); } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Utils/History.cs b/osu.Game/Rulesets/Difficulty/Utils/LimitedCapacityStack.cs similarity index 68% rename from osu.Game.Rulesets.Osu/Difficulty/Utils/History.cs rename to osu.Game/Rulesets/Difficulty/Utils/LimitedCapacityStack.cs index e39351087e..95b7d9b19d 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Utils/History.cs +++ b/osu.Game/Rulesets/Difficulty/Utils/LimitedCapacityStack.cs @@ -5,14 +5,16 @@ using System; using System.Collections; using System.Collections.Generic; -namespace osu.Game.Rulesets.Osu.Difficulty.Utils +namespace osu.Game.Rulesets.Difficulty.Utils { /// - /// An indexed stack with Push() only, which disposes items at the bottom after the capacity is full. - /// Indexing starts at the top of the stack. + /// An indexed stack with limited depth. Indexing starts at the top of the stack. /// - public class History : IEnumerable + public class LimitedCapacityStack : IEnumerable { + /// + /// The number of elements in the stack. + /// public int Count { get; private set; } private readonly T[] array; @@ -20,10 +22,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Utils private int marker; // Marks the position of the most recently added item. /// - /// Initializes a new instance of the History class that is empty and has the specified capacity. + /// Constructs a new . /// - /// The number of items the History can hold. - public History(int capacity) + /// The number of items the stack can hold. + public LimitedCapacityStack(int capacity) { if (capacity < 0) throw new ArgumentOutOfRangeException(); @@ -34,8 +36,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Utils } /// - /// The most recently added item is returned at index 0. + /// Retrieves the item at an index in the stack. /// + /// The index of the item to retrieve. The top of the stack is returned at index 0. public T this[int i] { get @@ -52,11 +55,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Utils } /// - /// Adds the item as the most recent one in the history. - /// The oldest item is disposed if the history is full. + /// Pushes an item to this . /// - public void Push(T item) // Overwrite the oldest item instead of shifting every item by one with every addition. + /// The item to push. + public void Push(T item) { + // Overwrite the oldest item instead of shifting every item by one with every addition. if (marker == 0) marker = capacity - 1; else diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs index ffab0abebf..75643a85dc 100644 --- a/osu.Game/Rulesets/Ruleset.cs +++ b/osu.Game/Rulesets/Ruleset.cs @@ -71,7 +71,7 @@ namespace osu.Game.Rulesets /// The . public virtual IBeatmapProcessor CreateBeatmapProcessor(IBeatmap beatmap) => null; - public abstract DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap); + public abstract LegacyDifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap); public virtual PerformanceCalculator CreatePerformanceCalculator(WorkingBeatmap beatmap, ScoreInfo score) => null; diff --git a/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs b/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs index 108fa8ff71..85ae1958ef 100644 --- a/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs +++ b/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs @@ -44,7 +44,7 @@ namespace osu.Game.Tests.Beatmaps return Assembly.LoadFrom(Path.Combine(localPath, $"{ResourceAssembly}.dll")).GetManifestResourceStream($@"{ResourceAssembly}.Resources.{name}"); } - protected abstract DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap); + protected abstract LegacyDifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap); protected abstract Ruleset CreateRuleset(); }