diff --git a/osucatch-editor-realtimeviewer/BeatmapConverter.cs b/osucatch-editor-realtimeviewer/BeatmapConverter.cs index db4eb5f..a78fae7 100644 --- a/osucatch-editor-realtimeviewer/BeatmapConverter.cs +++ b/osucatch-editor-realtimeviewer/BeatmapConverter.cs @@ -1,12 +1,15 @@ -using osu.Game.Beatmaps; +using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Legacy; using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Catch.Difficulty; +using osu.Game.Rulesets.Catch.Difficulty.Preprocessing; using osu.Game.Rulesets.Catch.Difficulty.Skills; using osu.Game.Rulesets.Catch.Mods; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mods; @@ -196,40 +199,56 @@ public void CalDistanceToNext(IBeatmap beatmap, PalpableCatchHitObject hitObject } + /// + /// Calculates the star rating of using the version + /// difficulty calculation pipeline + /// ( + MovementEvaluator + ), + /// and writes the per-object star rating to . + /// public double CalDifficulty(IBeatmap beatmap, List comboHitObjects) { const double difficulty_multiplier = 4.59; - const float normalized_hitobject_radius = 41.0f; + const double clock_rate = 1.0; + float halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.Difficulty) * 0.5f; // For circle sizes above 5.5, reduce the catcher width further to simulate imperfect gameplay. halfCatcherWidth *= 1 - (Math.Max(0, beatmap.Difficulty.CircleSize - 5.5f) * 0.0625f); - // We will scale everything by this factor, so we can assume a uniform CircleSize among beatmaps. - float scalingFactor = normalized_hitobject_radius / halfCatcherWidth; - StrainSkill skill = new Movement(halfCatcherWidth, 1); + var skill = new Movement(NoMod); - for (int i = 0; i < comboHitObjects.Count; i++) - { - CalDifficultyToLast(skill, comboHitObjects[i], scalingFactor); - } + CalDifficultyToLast(skill, comboHitObjects, halfCatcherWidth, clock_rate); return Math.Sqrt(skill.DifficultyValue()) * difficulty_multiplier; } - public void CalDifficultyToLast(StrainSkill skill, PalpableCatchHitObject hitObject, float scalingFactor) + /// + /// Feeds all combo-contributing objects of the beatmap through the difficulty pipeline. + /// + public void CalDifficultyToLast(Movement skill, List comboHitObjects, float halfCatcherWidth, double clockRate) { - if (hitObject.lastObject == null) + List difficultyHitObjects = new List(comboHitObjects.Count); + + for (int i = 0; i < comboHitObjects.Count; i++) { - hitObject.DifficultyToLast = 0; - return; + var hitObject = comboHitObjects[i]; + var lastObject = hitObject.lastObject; + + if (lastObject == null) + { + hitObject.DifficultyToLast = 0; + continue; + } + + difficultyHitObjects.Add(new CatchDifficultyHitObject(hitObject, lastObject, clockRate, halfCatcherWidth, difficultyHitObjects, difficultyHitObjects.Count)); } - hitObject.NormalizedPosition = hitObject.EffectiveX * scalingFactor; - hitObject.LastNormalizedPosition = hitObject.lastObject.EffectiveX * scalingFactor; - // Every strain interval is hard capped at the equivalent of 375 BPM streaming speed as a safety measure - hitObject.DeltaTime = hitObject.StartTime - hitObject.lastObject.StartTime; - hitObject.StrainTime = Math.Max(40, hitObject.DeltaTime); - skill.Process(hitObject); + foreach (var difficultyHitObject in difficultyHitObjects) + { + skill.Process(difficultyHitObject); + + var hitObject = (PalpableCatchHitObject)difficultyHitObject.BaseObject; + hitObject.DifficultyToLast = difficultyHitObject.DifficultyToLast; + } } private void CalFruitCountInCombo(List comboHitObjects) diff --git a/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs b/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs new file mode 100644 index 0000000..7d4ad53 --- /dev/null +++ b/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.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; + +namespace osu.Game.Rulesets.Catch.Difficulty +{ + public class CatchDifficultyAttributes : DifficultyAttributes + { + public override IEnumerable<(int attributeId, object value)> ToDatabaseAttributes() + { + foreach (var v in base.ToDatabaseAttributes()) + yield return v; + + // Todo: osu!catch should not output star rating in the 'aim' attribute. + yield return (ATTRIB_ID_AIM, StarRating); + } + } +} diff --git a/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs b/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs new file mode 100644 index 0000000..d08fdf8 --- /dev/null +++ b/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs @@ -0,0 +1,126 @@ +// 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.Beatmaps; +using osu.Game.Rulesets.Catch.Beatmaps; +using osu.Game.Rulesets.Catch.Difficulty.Preprocessing;using osu.Game.Rulesets.Catch.Difficulty.Skills; +using osu.Game.Rulesets.Catch.Mods; +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 +{ + /// + /// The osu!catch difficulty calculator. + /// + /// + /// The difficulty calculation in this viewer is performed per-hitobject (see + /// osucatch_editor_realtimeviewer.BeatmapConverter.CalDifficulty) so that a star rating + /// can be drawn beside each fruit, but it walks the exact same + /// / / + /// pipeline as the official calculator below. + /// + public class CatchDifficultyCalculator : DifficultyCalculator + { + private const double difficulty_multiplier = 4.59; + + /// + /// osu!catch difficulty calculation version. + /// + /// + /// Version history: + /// 20260706 osu!catch star rating update: movement difficulty split into + /// (preprocessing), + /// (evaluation) and (skill), adding the linear spacing nerf and + /// the three-object buzz pattern detection. + /// + public override int Version => 20260706; + + public CatchDifficultyCalculator(Ruleset ruleset, IWorkingBeatmap beatmap) + : base(ruleset, beatmap) + { + } + + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills) + { + if (beatmap.HitObjects.Count == 0 || skills.Length == 0) + return new CatchDifficultyAttributes { Mods = mods }; + + CatchDifficultyAttributes attributes = new CatchDifficultyAttributes + { + StarRating = Math.Sqrt(skills.OfType().Single().DifficultyValue()) * difficulty_multiplier, + Mods = mods, + MaxCombo = getMaxCombo(beatmap), + }; + + return attributes; + } + + protected override List CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + { + CatchHitObject? lastObject = null; + + List objects = new List(beatmap.HitObjects.Count); + + float halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.Difficulty) * 0.5f; + + // For circle sizes above 5.5, reduce the catcher width further to simulate imperfect gameplay. + halfCatcherWidth *= 1 - (Math.Max(0, beatmap.Difficulty.CircleSize - 5.5f) * 0.0625f); + + // In 2B beatmaps, it is possible that a normal Fruit is placed in the middle of a JuiceStream. + foreach (var hitObject in CatchBeatmap.GetPalpableObjects(beatmap.HitObjects)) + { + // We want to only consider fruits that contribute to the combo. + if (hitObject is Banana || hitObject is TinyDroplet) + continue; + + if (lastObject != null) + objects.Add(new CatchDifficultyHitObject(hitObject, lastObject, clockRate, halfCatcherWidth, objects, objects.Count)); + + lastObject = hitObject; + } + + return objects; + } + + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) + { + return new Skill[] + { + new Movement(mods), + }; + } + + // This project's mod set only contains EZ / HR, neither of which adjusts the gameplay clock rate. + protected override Mod[] DifficultyAdjustmentMods => new Mod[] + { + new CatchModEasy(), + new CatchModHardRock(), + }; + + /// + /// Returns the maximum achievable combo for the beatmap. + /// + /// + /// Only fruits and droplets contribute to the combo; bananas and tiny droplets do not. + /// + private static int getMaxCombo(IBeatmap beatmap) + { + int combo = 0; + + foreach (var hitObject in CatchBeatmap.GetPalpableObjects(beatmap.HitObjects)) + { + if (hitObject is Banana || hitObject is TinyDroplet) + continue; + + combo++; + } + + return combo; + } + } +} diff --git a/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs b/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs new file mode 100644 index 0000000..9f103b8 --- /dev/null +++ b/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/Evaluators/MovementEvaluator.cs @@ -0,0 +1,94 @@ +// 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.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; + +namespace osu.Game.Rulesets.Catch.Difficulty.Evaluators +{ + public static class MovementEvaluator + { + private const double direction_change_bonus = 21.0; + + public static double EvaluateDifficultyOf(DifficultyHitObject current) + { + var catchCurrent = (CatchDifficultyHitObject)current; + var catchLast = (CatchDifficultyHitObject)current.Previous(0); + var catchLastLast = (CatchDifficultyHitObject)current.Previous(1); + + // In catch, clockrate adjustments do not only affect the timings of hitobjects, + // but also the speed of the player's catcher, which has an impact on difficulty + double catcherSpeedMultiplier = current.ClockRate; + + double weightedStrainTime = catchCurrent.StrainTime + 13 + (3 / catcherSpeedMultiplier); + + double distanceAddition = (DiffUtils.Pow(Math.Abs(catchCurrent.DistanceMoved), 1.3) / 510); + double sqrtStrain = Math.Sqrt(weightedStrainTime); + + double edgeDashBonus = 0; + + // Direction change bonus. + if (Math.Abs(catchCurrent.DistanceMoved) > 0.1) + { + if (current.Index >= 1 && Math.Abs(catchLast.DistanceMoved) > 0.1 && Math.Sign(catchCurrent.DistanceMoved) != Math.Sign(catchLast.DistanceMoved)) + { + double bonusFactor = Math.Min(50, Math.Abs(catchCurrent.DistanceMoved)) / 50; + double antiflowFactor = Math.Max(Math.Min(70, Math.Abs(catchLast.DistanceMoved)) / 70, 0.38); + + distanceAddition += direction_change_bonus / Math.Sqrt(catchLast.StrainTime + 16) * bonusFactor * antiflowFactor * Math.Max(1 - DiffUtils.Pow(weightedStrainTime / 1000, 3), 0); + } + + // Base bonus for every movement, giving some weight to streams. + distanceAddition += 12.5 * Math.Min(Math.Abs(catchCurrent.DistanceMoved), CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH * 2) + / (CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH * 6) / sqrtStrain; + } + + // Linear spacing nerf. + int linearSpacingCount = 0; + + for (int i = 0; i < Math.Min(current.Index, 10); i++) + { + var catchPrevObj = (CatchDifficultyHitObject)catchCurrent.Previous(i); + + // Only same direction movements matter as they do not take any additional inputs. + if (Math.Sign(catchCurrent.DistanceMoved) != Math.Sign(catchPrevObj.DistanceMoved) || catchCurrent.DistanceMoved == 0 || catchPrevObj.DistanceMoved == 0) + break; + + double currentSpacing = Math.Abs(catchCurrent.DistanceMoved / catchCurrent.StrainTime); + double prevSpacing = Math.Abs(catchPrevObj.DistanceMoved / catchPrevObj.StrainTime); + + double relativeDifference = Math.Abs(currentSpacing / prevSpacing - 1); + + if (relativeDifference > 0.05) + break; + + linearSpacingCount++; + } + + distanceAddition *= DiffUtils.Pow(0.7, linearSpacingCount); + + // Bonus for edge dashes. + if (catchCurrent.LastObject.DistanceToHyperDash <= 20.0f) + { + if (!catchCurrent.LastObject.HyperDash) + edgeDashBonus += 5.7; + + distanceAddition *= 1.0 + edgeDashBonus * ((20 - catchCurrent.LastObject.DistanceToHyperDash) / 20) + * DiffUtils.Pow((Math.Min(catchCurrent.StrainTime * catcherSpeedMultiplier, 265) / 265), 1.5); // Edge Dashes are easier at lower ms values + } + + // There is an edge case where horizontal back and forth sliders create "buzz" patterns which are repeated "movements" with a distance lower than + // the platter's width but high enough to be considered a movement due to the absolute_player_positioning_error and NORMALIZED_HALF_CATCHER_WIDTH offsets + // We are detecting this exact scenario. The first back and forth is counted but all subsequent ones are nullified. + // To achieve that, we need to store the exact distances (distance ignoring absolute_player_positioning_error and NORMALIZED_HALF_CATCHER_WIDTH) + if (current.Index >= 2 && Math.Abs(catchCurrent.ExactDistanceMoved) <= CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH * 2 + && catchCurrent.ExactDistanceMoved == -catchLast.ExactDistanceMoved && catchLast.ExactDistanceMoved == -catchLastLast.ExactDistanceMoved + && catchCurrent.StrainTime == catchLast.StrainTime && catchLast.StrainTime == catchLastLast.StrainTime) + distanceAddition = 0; + + return distanceAddition / weightedStrainTime; + } + } +} diff --git a/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs b/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs new file mode 100644 index 0000000..18b24f7 --- /dev/null +++ b/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs @@ -0,0 +1,97 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Rulesets.Catch.Difficulty.Preprocessing +{ + public class CatchDifficultyHitObject : DifficultyHitObject + { + public const float NORMALIZED_HALF_CATCHER_WIDTH = 41.0f; + private const float absolute_player_positioning_error = 16.0f; + + public new PalpableCatchHitObject BaseObject => (PalpableCatchHitObject)base.BaseObject; + + public new PalpableCatchHitObject LastObject => (PalpableCatchHitObject)base.LastObject; + + /// + /// Normalized position of . + /// + public readonly float NormalizedPosition; + + /// + /// Normalized position of . + /// + public readonly float LastNormalizedPosition; + + /// + /// Normalized position of the player required to catch , assuming the player moves as little as possible. + /// + public float PlayerPosition { get; private set; } + + /// + /// Normalized position of the player after catching . + /// + public float LastPlayerPosition { get; private set; } + + /// + /// Normalized distance between and . + /// + /// + /// The sign of the value indicates the direction of the movement: negative is left and positive is right. + /// + public float DistanceMoved { get; private set; } + + /// + /// Normalized distance the player has to move from in order to catch at its . + /// + /// + /// The sign of the value indicates the direction of the movement: negative is left and positive is right. + /// + public float ExactDistanceMoved { get; private set; } + + /// + /// Milliseconds elapsed since the start time of the previous , with a minimum of 40ms. + /// + public readonly double StrainTime; + + public CatchDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, float halfCatcherWidth, List objects, int index) + : base(hitObject, lastObject, clockRate, objects, index) + { + // We will scale everything by this factor, so we can assume a uniform CircleSize among beatmaps. + float scalingFactor = NORMALIZED_HALF_CATCHER_WIDTH / halfCatcherWidth; + + NormalizedPosition = BaseObject.EffectiveX * scalingFactor; + LastNormalizedPosition = LastObject.EffectiveX * scalingFactor; + + // Every strain interval is hard capped at the equivalent of 375 BPM streaming speed as a safety measure + StrainTime = Math.Max(40, DeltaTime); + + setMovementState(); + } + + private void setMovementState() + { + LastPlayerPosition = Index == 0 ? LastNormalizedPosition : ((CatchDifficultyHitObject)Previous(0)).PlayerPosition; + + PlayerPosition = Math.Clamp( + LastPlayerPosition, + NormalizedPosition - (NORMALIZED_HALF_CATCHER_WIDTH - absolute_player_positioning_error), + NormalizedPosition + (NORMALIZED_HALF_CATCHER_WIDTH - absolute_player_positioning_error) + ); + + DistanceMoved = PlayerPosition - LastPlayerPosition; + + // For the exact position we consider that the catcher is in the correct position for both objects + ExactDistanceMoved = NormalizedPosition - LastPlayerPosition; + + // After a hyperdash we ARE in the correct position. Always! + if (LastObject.HyperDash) + PlayerPosition = NormalizedPosition; + } + } +} diff --git a/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs b/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs index cf11146..2a05d38 100644 --- a/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs +++ b/osucatch-editor-realtimeviewer/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs @@ -1,17 +1,15 @@ // 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.Catch.Objects; +using osu.Game.Rulesets.Catch.Difficulty.Evaluators; +using osu.Game.Rulesets.Catch.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Catch.Difficulty.Skills { - public class Movement : StrainDecaySkill + public class Movement : StrainDecaySkill { - private const float absolute_player_positioning_error = 16f; - private const float normalized_hitobject_radius = 41.0f; - private const double direction_change_bonus = 21.0; - protected override double SkillMultiplier => 1; protected override double StrainDecayBase => 0.2; @@ -19,105 +17,14 @@ public class Movement : StrainDecaySkill protected override int SectionLength => 750; - protected readonly float HalfCatcherWidth; - - private float? lastPlayerPosition; - private float lastDistanceMoved; - private float lastExactDistanceMoved; - private double lastStrainTime; - private bool isInBuzzSection; - - /// - /// The speed multiplier applied to the player's catcher. - /// - private readonly double catcherSpeedMultiplier; - - public Movement(float halfCatcherWidth, double clockRate) - : base() + public Movement(Mod[] mods) + : base(mods) { - HalfCatcherWidth = halfCatcherWidth; - - // In catch, clockrate adjustments do not only affect the timings of hitobjects, - // but also the speed of the player's catcher, which has an impact on difficulty - // TODO: Support variable clockrates caused by mods such as ModTimeRamp - // (perhaps by using IApplicableToRate within the CatchDifficultyHitObject constructor to set a catcher speed for each object before processing) - catcherSpeedMultiplier = clockRate; } - protected override double StrainValueOf(PalpableCatchHitObject catchCurrent) + protected override double StrainValueOf(CatchDifficultyHitObject current) { - lastPlayerPosition ??= catchCurrent.LastNormalizedPosition; - - float playerPosition = Math.Clamp( - lastPlayerPosition.Value, - catchCurrent.NormalizedPosition - (normalized_hitobject_radius - absolute_player_positioning_error), - catchCurrent.NormalizedPosition + (normalized_hitobject_radius - absolute_player_positioning_error) - ); - - float distanceMoved = playerPosition - lastPlayerPosition.Value; - - // For the exact position we consider that the catcher is in the correct position for both objects - float exactDistanceMoved = catchCurrent.NormalizedPosition - lastPlayerPosition.Value; - - double weightedStrainTime = catchCurrent.StrainTime + 13 + (3 / catcherSpeedMultiplier); - - double distanceAddition = (Math.Pow(Math.Abs(distanceMoved), 1.3) / 510); - double sqrtStrain = Math.Sqrt(weightedStrainTime); - - double edgeDashBonus = 0; - - // Direction change bonus. - if (Math.Abs(distanceMoved) > 0.1) - { - if (Math.Abs(lastDistanceMoved) > 0.1 && Math.Sign(distanceMoved) != Math.Sign(lastDistanceMoved)) - { - double bonusFactor = Math.Min(50, Math.Abs(distanceMoved)) / 50; - double antiflowFactor = Math.Max(Math.Min(70, Math.Abs(lastDistanceMoved)) / 70, 0.38); - - distanceAddition += direction_change_bonus / Math.Sqrt(lastStrainTime + 16) * bonusFactor * antiflowFactor * Math.Max(1 - Math.Pow(weightedStrainTime / 1000, 3), 0); - } - - // Base bonus for every movement, giving some weight to streams. - distanceAddition += 12.5 * Math.Min(Math.Abs(distanceMoved), normalized_hitobject_radius * 2) / (normalized_hitobject_radius * 6) / sqrtStrain; - } - - // Bonus for edge dashes. - if (catchCurrent.lastObject != null && catchCurrent.lastObject.DistanceToHyperDash <= 20.0f) - { - if (!catchCurrent.lastObject.HyperDash) - edgeDashBonus += 5.7; - else - { - // After a hyperdash we ARE in the correct position. Always! - playerPosition = catchCurrent.NormalizedPosition; - } - - distanceAddition *= 1.0 + edgeDashBonus * ((20 - catchCurrent.lastObject.DistanceToHyperDash) / 20) - * Math.Pow((Math.Min(catchCurrent.StrainTime * catcherSpeedMultiplier, 265) / 265), 1.5); // Edge Dashes are easier at lower ms values - } - - // There is an edge case where horizontal back and forth sliders create "buzz" patterns which are repeated "movements" with a distance lower than - // the platter's width but high enough to be considered a movement due to the absolute_player_positioning_error and normalized_hitobject_radius offsets - // We are detecting this exact scenario. The first back and forth is counted but all subsequent ones are nullified. - // To achieve that, we need to store the exact distances (distance ignoring absolute_player_positioning_error and normalized_hitobject_radius) - if (Math.Abs(exactDistanceMoved) <= HalfCatcherWidth * 2 && exactDistanceMoved == -lastExactDistanceMoved && catchCurrent.StrainTime == lastStrainTime) - { - if (isInBuzzSection) - distanceAddition = 0; - else - isInBuzzSection = true; - } - else - { - isInBuzzSection = false; - } - - lastPlayerPosition = playerPosition; - lastDistanceMoved = distanceMoved; - lastStrainTime = catchCurrent.StrainTime; - lastExactDistanceMoved = exactDistanceMoved; - - return distanceAddition / weightedStrainTime; + return MovementEvaluator.EvaluateDifficultyOf(current); } } -} \ No newline at end of file +} diff --git a/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs b/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs new file mode 100644 index 0000000..80c11ad --- /dev/null +++ b/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs @@ -0,0 +1,62 @@ +// 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.Mods; + +namespace osu.Game.Rulesets.Difficulty +{ + /// + /// Describes the difficulty of a beatmap, as output by a . + /// + public class DifficultyAttributes + { + protected const int ATTRIB_ID_AIM = 1; + protected const int ATTRIB_ID_SPEED = 3; + protected const int ATTRIB_ID_MAX_COMBO = 9; + protected const int ATTRIB_ID_DIFFICULTY = 11; + + /// + /// The mods which were applied to the beatmap. + /// + public Mod[] Mods { get; set; } = Array.Empty(); + + /// + /// The combined star rating of all skills. + /// + public double StarRating { get; set; } + + /// + /// The maximum achievable combo. + /// + public int MaxCombo { get; set; } + + /// + /// Creates new . + /// + public DifficultyAttributes() + { + } + + /// + /// Creates new . + /// + /// The mods which were applied to the beatmap. + /// The combined star rating of all skills. + public DifficultyAttributes(Mod[] mods, double starRating) + { + Mods = mods; + StarRating = starRating; + } + + /// + /// Converts this to osu-web compatible database attribute mappings. + /// + /// + /// See: osu_difficulty_attribs table. + /// + public virtual IEnumerable<(int attributeId, object value)> ToDatabaseAttributes() + { + yield return (ATTRIB_ID_MAX_COMBO, MaxCombo); + } + } +} diff --git a/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs new file mode 100644 index 0000000..36c7323 --- /dev/null +++ b/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -0,0 +1,166 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading; +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 +{ + /// + /// A beatmap difficulty calculator. + /// + public abstract class DifficultyCalculator + { + /// + /// The beatmap for which difficulty is being calculated. + /// + protected readonly IBeatmap Beatmap; + + private readonly Mod[] mods; + + protected DifficultyCalculator(Ruleset ruleset, IWorkingBeatmap beatmap) + { + mods = Array.Empty(); + + Beatmap = beatmap.GetPlayableBeatmap(ruleset, mods); + } + + /// + /// The difficulty calculation version. + /// + /// + /// This is used to determine which difficulty values are outdated and need to be recalculated. + /// + public abstract int Version { get; } + + /// + /// Mods for which the difficulty calculation is allowed to be customised by. + /// + protected abstract Mod[] DifficultyAdjustmentMods { get; } + + /// + /// Calculates the difficulty of the beatmap using all mod combinations applicable to the beatmap. + /// + /// A collection of difficulty attributes. + public DifficultyAttributes Calculate() + { + return Calculate(mods); + } + + /// + /// 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(Mod[] mods) + { + return ComputeDifficultyAttributes(mods); + } + + /// + /// Calculates the difficulty of the beatmap using all mod combinations applicable to the beatmap. + /// + /// The cancellation token. + /// A collection of difficulty attributes. + public IEnumerable CalculateAll(CancellationToken cancellationToken = default) + { + foreach (var combination in CreateDifficultyAdjustmentModCombinations()) + { + if (cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(); + + yield return Calculate(combination); + } + } + + /// + /// Computes the difficulty attributes for the beatmap. + /// + /// The mods to apply. + /// The difficulty attributes. + private DifficultyAttributes ComputeDifficultyAttributes(Mod[] mods) + { + IBeatmap beatmap = Beatmap.Clone(); + + foreach (var mod in mods.OfType()) + mod.ApplyToDifficulty(beatmap.Difficulty); + + // This embedded port of the difficulty framework only supports osu!catch, whose + // difficulty adjustment mods (EZ / HR) never change the gameplay clock rate. + const double clock_rate = 1.0; + + if (beatmap.HitObjects.Count == 0) + return CreateDifficultyAttributes(beatmap, mods, Array.Empty()); + + var difficultyHitObjects = CreateDifficultyHitObjects(beatmap, clock_rate); + var skills = CreateSkills(beatmap, mods); + + if (difficultyHitObjects.Count > 0) + { + foreach (var skill in skills) + { + foreach (var difficultyHitObject in difficultyHitObjects) + skill.Process(difficultyHitObject); + } + } + + return CreateDifficultyAttributes(beatmap, mods, skills); + } + + /// + /// Creates all combinations which customise the difficulty calculation. + /// + /// + /// All combinations will contain at least one mod from . + /// + public IEnumerable CreateDifficultyAdjustmentModCombinations() + { + Mod[] adjustmentMods = DifficultyAdjustmentMods; + + if (adjustmentMods.Length == 0) + yield break; + + // Generate all non-empty subsets of the adjustment mods (2^n - 1 combinations). + for (int i = 1; i < 1 << adjustmentMods.Length; i++) + { + var combination = new List(); + + for (int j = 0; j < adjustmentMods.Length; j++) + { + if ((i & (1 << j)) > 0) + combination.Add(adjustmentMods[j]); + } + + yield return combination.ToArray(); + } + } + + /// + /// Creates the to be returned for a beatmap. + /// + /// The beatmap to create the attributes for. + /// The mods to apply. + /// The skills which processed the beatmap. + /// The difficulty attributes. + protected abstract DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills); + + /// + /// Creates the s to be processed. + /// + /// The beatmap to create the objects for. + /// The rate at which the gameplay clock is run at. + /// The difficulty hit objects. + protected abstract List CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate); + + /// + /// Creates the s to process the s with. + /// + /// The beatmap to create the skills for. + /// The mods to apply. + /// The skills. + protected abstract Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods); + } +} diff --git a/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs b/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs new file mode 100644 index 0000000..fe04cf1 --- /dev/null +++ b/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs @@ -0,0 +1,98 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable disable + +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Rulesets.Difficulty.Preprocessing +{ + /// + /// Wraps a and provides additional information to be used for difficulty calculation. + /// + /// + /// Ported from osu! (ppy/osu) for the embedded difficulty calculation framework. + /// The upstream hit window / HitResult members are intentionally omitted, as this project + /// only embeds the parts of osu! required for osu!catch star rating calculation. + /// + public class DifficultyHitObject + { + private readonly IReadOnlyList difficultyHitObjects; + + /// + /// The index of this in the list of all s. + /// + public int Index; + + /// + /// The this wraps. + /// + public readonly HitObject BaseObject; + + /// + /// The last which occurs before . + /// + public readonly HitObject LastObject; + + /// + /// Amount of time elapsed between and , adjusted by clockrate. + /// + public readonly double DeltaTime; + + /// + /// Clockrate adjusted start time of . + /// + public readonly double StartTime; + + /// + /// Clockrate adjusted end time of . + /// + public readonly double EndTime; + + /// + /// Beatmap playback rate. + /// + public readonly double ClockRate; + + /// + /// The star rating contribution of , as calculated by the strain skill which processed it. + /// + /// + /// This project-specific extra is used to draw the "difficulty stars" label beside hitobjects, + /// and is not part of the upstream osu! difficulty framework. + /// + public double DifficultyToLast; + + /// + /// 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. + /// The list of s in the current beatmap. + /// The index of this in list. + public DifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, List objects, int index) + { + difficultyHitObjects = objects; + Index = index; + BaseObject = hitObject; + LastObject = lastObject; + DeltaTime = (hitObject.StartTime - lastObject.StartTime) / clockRate; + StartTime = hitObject.StartTime / clockRate; + EndTime = hitObject.GetEndTime() / clockRate; + ClockRate = clockRate; + } + + public DifficultyHitObject Previous(int skipCount = 0) + { + int index = Index - (skipCount + 1); + return index >= 0 && index < difficultyHitObjects.Count ? difficultyHitObjects[index] : null; + } + + public DifficultyHitObject Next(int skipCount = 0) + { + int index = Index + (skipCount + 1); + return index >= 0 && index < difficultyHitObjects.Count ? difficultyHitObjects[index] : null; + } + } +} diff --git a/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Skills/Skill.cs b/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Skills/Skill.cs new file mode 100644 index 0000000..be86c81 --- /dev/null +++ b/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Skills/Skill.cs @@ -0,0 +1,53 @@ +// 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.Mods; + +namespace osu.Game.Rulesets.Difficulty.Skills +{ + /// + /// A bare minimal abstract skill for fully custom skill implementations. + /// + /// + /// This class should be considered a "processing" class and not persisted. + /// + public abstract class Skill + { + /// + /// Mods for use in skill calculations. + /// + protected IReadOnlyList Mods => mods; + + /// + /// List of calculated per-object difficulties, populated by Process + /// + protected readonly List ObjectDifficulties = new List(); + + private readonly Mod[] mods; + + protected Skill(Mod[] mods) + { + this.mods = mods; + } + + /// + /// Process a . + /// + /// The to process. + public virtual void Process(DifficultyHitObject current) + { + double difficultyValue = ProcessInternal(current); + ObjectDifficulties.Add(difficultyValue); + } + + protected abstract double ProcessInternal(DifficultyHitObject current); + + /// + /// Returns the calculated difficulty value representing all s that have been processed up to this point. + /// + public abstract double DifficultyValue(); + + public IReadOnlyList GetObjectDifficulties() => ObjectDifficulties; + } +} diff --git a/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Skills/StrainDecaySkill.cs b/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Skills/StrainDecaySkill.cs index bd99ac2..2208eed 100644 --- a/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Skills/StrainDecaySkill.cs +++ b/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Skills/StrainDecaySkill.cs @@ -1,7 +1,8 @@ -// 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 osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Difficulty.Skills { @@ -9,7 +10,8 @@ namespace osu.Game.Rulesets.Difficulty.Skills /// 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 StrainDecaySkill : StrainSkill + public abstract class StrainDecaySkill : StrainSkill + where TObject : DifficultyHitObject { /// /// Strain values are multiplied by this number for the given skill. Used to balance the value of different skills between each other. @@ -27,14 +29,14 @@ public abstract class StrainDecaySkill : StrainSkill /// protected double CurrentStrain { get; private set; } - protected StrainDecaySkill() - : base() + protected StrainDecaySkill(Mod[] mods) + : base(mods) { } - protected override double CalculateInitialStrain(double time, PalpableCatchHitObject current) => CurrentStrain * strainDecay(time - ((current.lastObject == null) ? 0.0 : current.lastObject.StartTime)); + protected override double CalculateInitialStrain(double time, TObject current) => CurrentStrain * strainDecay(time - current.Previous(0).StartTime); - protected override double StrainValueAt(PalpableCatchHitObject current) + protected override double StrainValueAt(TObject current) { CurrentStrain *= strainDecay(current.DeltaTime); CurrentStrain += StrainValueOf(current) * SkillMultiplier; @@ -45,7 +47,7 @@ protected override double StrainValueAt(PalpableCatchHitObject current) /// /// Calculates the strain value of a . This value is affected by previously processed objects. /// - protected abstract double StrainValueOf(PalpableCatchHitObject current); + protected abstract double StrainValueOf(TObject current); private double strainDecay(double ms) => Math.Pow(StrainDecayBase, ms / 1000); } diff --git a/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs b/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs index 71492d9..8f6af0a 100644 --- a/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs +++ b/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs @@ -1,7 +1,8 @@ -// 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 osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Difficulty.Skills { @@ -9,10 +10,12 @@ namespace osu.Game.Rulesets.Difficulty.Skills /// 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 StrainSkill + /// + /// The ruleset-specific implementation processed by this skill. + /// + public abstract class StrainSkill : Skill + where TObject : DifficultyHitObject { - private const double difficulty_multiplier = 4.59; - /// /// The weight by which each strain value decays. /// @@ -25,37 +28,53 @@ public abstract class StrainSkill private double currentSectionPeak; // We also keep track of the peak strain level in the current section. private double currentSectionEnd; + private bool firstObjectProcessed; private readonly List strainPeaks = new List(); - protected StrainSkill() - : base() + protected StrainSkill(Mod[] mods) + : base(mods) { } /// /// Returns the strain value at . This value is calculated with or without respect to previous objects. /// - protected abstract double StrainValueAt(PalpableCatchHitObject current); + protected abstract double StrainValueAt(TObject current); /// /// Process a and update current strain values accordingly. /// - public void Process(PalpableCatchHitObject current) + public override void Process(DifficultyHitObject current) { - // The first object doesn't generate a strain, so we begin with an incremented section end - if (current.CurrentCombo == 0) - currentSectionEnd = Math.Ceiling(current.StartTime / SectionLength) * SectionLength; + var catchCurrent = (TObject)current; - while (current.StartTime > currentSectionEnd) + // The first object doesn't generate a strain, so we begin with an incremented section end. + // (Upstream keys off a per-object "first object" flag set on the difficulty hit object; this + // embedded port tracks it here instead, as the upstream flag is not part of the reduced port.) + if (!firstObjectProcessed) + { + currentSectionEnd = Math.Ceiling(catchCurrent.StartTime / SectionLength) * SectionLength; + firstObjectProcessed = true; + } + + while (catchCurrent.StartTime > currentSectionEnd) { saveCurrentPeak(); - startNewSectionFrom(currentSectionEnd, current); + startNewSectionFrom(currentSectionEnd, catchCurrent); currentSectionEnd += SectionLength; } - double strain = StrainValueAt(current); - current.DifficultyToLast = Math.Sqrt(strain / (1 - DecayWeight)) * difficulty_multiplier; + // StrainValueAt mutates the skill's internal strain state, so it must be called exactly once + // per object. Calling it again for the per-object bookkeeping would apply the strain decay a + // second time and double-count the strain value, inflating every strain peak. + double strain = StrainValueAt(catchCurrent); + + ObjectDifficulties.Add(strain); + + // The per-object star rating shown beside hitobjects in the viewer. + catchCurrent.DifficultyToLast = Math.Sqrt(strain / (1 - DecayWeight)) * 4.59; + currentSectionPeak = Math.Max(strain, currentSectionPeak); } @@ -72,7 +91,7 @@ private void saveCurrentPeak() /// /// The beginning of the new section in milliseconds. /// The current hit object. - private void startNewSectionFrom(double time, PalpableCatchHitObject current) + private void startNewSectionFrom(double time, TObject current) { // The maximum strain of the new section is not zero by default // This means we need to capture the strain level at the beginning of the new section, and use that as the initial peak level. @@ -85,7 +104,7 @@ private void startNewSectionFrom(double time, PalpableCatchHitObject current) /// The time to retrieve the peak strain at. /// The current hit object. /// The peak strain. - protected abstract double CalculateInitialStrain(double time, PalpableCatchHitObject current); + protected abstract double CalculateInitialStrain(double time, TObject current); /// /// Returns a live enumerable of the peak strains for each section of the beatmap, @@ -96,7 +115,7 @@ private void startNewSectionFrom(double time, PalpableCatchHitObject current) /// /// Returns the calculated difficulty value representing all s that have been processed up to this point. /// - public double DifficultyValue() + public override double DifficultyValue() { double difficulty = 0; double weight = 1; @@ -115,5 +134,12 @@ public double DifficultyValue() return difficulty; } + + /// + /// Not used: computes the strain directly so that + /// (which mutates the internal strain state) is invoked exactly once per object. + /// + protected override double ProcessInternal(DifficultyHitObject current) + => throw new NotSupportedException($"{nameof(StrainSkill)} computes strain in {nameof(Process)}."); } } diff --git a/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs b/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs new file mode 100644 index 0000000..86399f9 --- /dev/null +++ b/osucatch-editor-realtimeviewer/osu.Game/Rulesets/Difficulty/Utils/DiffUtils.cs @@ -0,0 +1,114 @@ +// 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.Difficulty.Utils +{ + /// + /// Shared mathematical helpers used by difficulty calculation. + /// + /// + /// Ported from osu! (ppy/osu). Only the helpers required by the osu!catch difficulty + /// calculation are embedded here; see upstream DiffUtils for the full set. + /// + public static class DiffUtils + { + /// + /// Square root of 2 + /// + public const double SQRT2 = 1.4142135623730950; + + /// + /// Converts BPM value into milliseconds + /// + /// Beats per minute + /// Which rhythm delimiter to use, default is 1/4 + /// BPM converted to milliseconds + public static double BPMToMilliseconds(double bpm, int delimiter = 4) => 60000.0 / delimiter / bpm; + + /// + /// Converts milliseconds value into a BPM value + /// + /// Milliseconds + /// Which rhythm delimiter to use, default is 1/4 + /// Milliseconds converted to beats per minute + public static double MillisecondsToBPM(double ms, int delimiter = 4) => 60000.0 / (ms * delimiter); + + /// + /// Calculates a S-shaped logistic function (https://en.wikipedia.org/wiki/Logistic_function) + /// + /// Value to calculate the function for + /// How much the function midpoint is offset from zero + /// Growth rate of the function + /// Maximum value returnable by the function + /// The output of logistic function of + public static double Logistic(double x, double midpointOffset, double multiplier, double maxValue = 1) => maxValue / (1 + Math.Exp(multiplier * (midpointOffset - x))); + + /// + /// Returns the p-norm of an n-dimensional vector (https://en.wikipedia.org/wiki/Norm_(mathematics)) + /// + /// The value of p to calculate the norm for. + /// The coefficients of the vector. + /// The p-norm of the vector. + public static double Norm(double p, params double[] values) + { + double sum = 0; + + foreach (double x in values) + sum += Pow(x, p); + + return Pow(sum, 1.0 / p); + } + + // In actual debug testing it's very rare for a (double, double) call to end up with a rounded int value in the first place. + // Making an explicit overload is slightly faster than running the `switch` in such cases. + public static double Pow(double x, double exponent) => Math.Pow(x, exponent); + + public static double Pow(double x, int exponent) => exponent switch + { + 0 => 1, + 1 => x, + 2 => x * x, + 3 => x * x * x, + 4 => x * x * x * x, + 5 => x * x * x * x * x, // This is the largest value used in diffcalc right now. + _ => Math.Pow(x, exponent) + }; + + /// + /// Error function (https://en.wikipedia.org/wiki/Error_function) + /// + /// Abramowitz and Stegun formula 7.1.26 approximation. + public static double Erf(double x) + { + if (x == 0) + return 0; + + if (double.IsPositiveInfinity(x)) + return 1; + + if (double.IsNegativeInfinity(x)) + return -1; + + if (double.IsNaN(x)) + return double.NaN; + + double t = 1.0 / (1.0 + 0.3275911 * Math.Abs(x)); + double tau = t * (0.254829592 + + t * (-0.284496736 + + t * (1.421413741 + + t * (-1.453152027 + + t * 1.061405429)))); + + double erf = 1.0 - tau * Math.Exp(-x * x); + + return x >= 0 ? erf : -erf; + } + + /// + /// Complementary error function (https://en.wikipedia.org/wiki/Error_function) + /// + public static double Erfc(double x) => 1 - Erf(x); + } +}