diff --git a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs index 15675e74d1..7cd06c5225 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs @@ -15,13 +15,13 @@ namespace osu.Game.Rulesets.Osu.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Osu"; - [TestCase(6.5867229481955389d, "diffcalc-test")] - [TestCase(1.0416315570967911d, "zero-length-sliders")] + [TestCase(6.5295339534769958d, "diffcalc-test")] + [TestCase(1.1514260533755143d, "zero-length-sliders")] public void Test(double expected, string name) => base.Test(expected, name); - [TestCase(8.2730989071947896d, "diffcalc-test")] - [TestCase(1.2726413186221039d, "zero-length-sliders")] + [TestCase(9.047752485219954d, "diffcalc-test")] + [TestCase(1.3985711787077566d, "zero-length-sliders")] public void TestClockRateAdjusted(double expected, string name) => Test(expected, name, new OsuModDoubleTime()); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 49ac6a7af3..4b90285fd4 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -12,20 +12,21 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing { public class OsuDifficultyHitObject : DifficultyHitObject { - private const int normalized_radius = 52; + private const int normalized_radius = 50; // Change radius to 50 to make 100 the diameter. Easier for mental maths. + private const int min_delta_time = 25; protected new OsuHitObject BaseObject => (OsuHitObject)base.BaseObject; - /// - /// Milliseconds elapsed since the start time of the previous , with a minimum of 25ms to account for simultaneous s. - /// - public double StrainTime { get; private set; } - /// /// Normalized distance from the end position of the previous to the start position of this . /// public double JumpDistance { get; private set; } + /// + /// Minimum distance from the end position of the previous to the start position of this . + /// + public double MovementDistance { get; private set; } + /// /// Normalized distance between the start and end position of the previous . /// @@ -37,6 +38,21 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing /// public double? Angle { get; private set; } + /// + /// Milliseconds elapsed since the end time of the previous , with a minimum of 25ms. + /// + public double MovementTime { get; private set; } + + /// + /// Milliseconds elapsed since the start time of the previous to the end time of the same previous , with a minimum of 25ms. + /// + public double TravelTime { get; private set; } + + /// + /// Milliseconds elapsed since the start time of the previous , with a minimum of 25ms. + /// + public readonly double StrainTime; + private readonly OsuHitObject lastLastObject; private readonly OsuHitObject lastObject; @@ -46,13 +62,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing this.lastLastObject = (OsuHitObject)lastLastObject; this.lastObject = (OsuHitObject)lastObject; - setDistances(); + // Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects. + StrainTime = Math.Max(DeltaTime, min_delta_time); - // Capped to 25ms to prevent difficulty calculation breaking from simulatenous objects. - StrainTime = Math.Max(DeltaTime, 25); + setDistances(clockRate); } - private void setDistances() + private void setDistances(double clockRate) { // We don't need to calculate either angle or distance when one of the last->curr objects is a spinner if (BaseObject is Spinner || lastObject is Spinner) @@ -67,15 +83,29 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing scalingFactor *= 1 + smallCircleBonus; } + Vector2 lastCursorPosition = getEndCursorPosition(lastObject); + JumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length; + if (lastObject is Slider lastSlider) { computeSliderCursorPosition(lastSlider); TravelDistance = lastSlider.LazyTravelDistance * scalingFactor; + TravelTime = Math.Max(lastSlider.LazyTravelTime / clockRate, min_delta_time); + MovementTime = Math.Max(StrainTime - TravelTime, min_delta_time); + + // Jump distance from the slider tail to the next object, as opposed to the lazy position of JumpDistance. + float tailJumpDistance = Vector2.Subtract(lastSlider.TailCircle.StackedPosition, BaseObject.StackedPosition).Length * scalingFactor; + + // For hitobjects which continue in the direction of the slider, the player will normally follow through the slider, + // such that they're not jumping from the lazy position but rather from very close to (or the end of) the slider. + // In such cases, a leniency is applied by also considering the jump distance from the tail of the slider, and taking the minimum jump distance. + MovementDistance = Math.Min(JumpDistance, tailJumpDistance); + } + else + { + MovementTime = StrainTime; + MovementDistance = JumpDistance; } - - Vector2 lastCursorPosition = getEndCursorPosition(lastObject); - - JumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length; if (lastLastObject != null && !(lastLastObject is Spinner)) { @@ -98,7 +128,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing slider.LazyEndPosition = slider.StackedPosition; - float approxFollowCircleRadius = (float)(slider.Radius * 3); + float followCircleRadius = (float)(slider.Radius * 2.4); var computeVertex = new Action(t => { double progress = (t - slider.StartTime) / slider.SpanDuration; @@ -111,11 +141,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing var diff = slider.StackedPosition + slider.Path.PositionAt(progress) - slider.LazyEndPosition.Value; float dist = diff.Length; - if (dist > approxFollowCircleRadius) + slider.LazyTravelTime = t - slider.StartTime; + + if (dist > followCircleRadius) { // The cursor would be outside the follow circle, we need to move it diff.Normalize(); // Obtain direction of diff - dist -= approxFollowCircleRadius; + dist -= followCircleRadius; slider.LazyEndPosition += diff * dist; slider.LazyTravelDistance += dist; } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 64ca567a15..a054b46366 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -14,53 +14,96 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// public class Aim : OsuStrainSkill { - private const double angle_bonus_begin = Math.PI / 3; - private const double timing_threshold = 107; - public Aim(Mod[] mods) : base(mods) { } + protected override int HistoryLength => 2; + + private const double wide_angle_multiplier = 1.5; + private const double acute_angle_multiplier = 2.0; + private double currentStrain = 1; - private double skillMultiplier => 26.25; + private double skillMultiplier => 23.25; private double strainDecayBase => 0.15; private double strainValueOf(DifficultyHitObject current) { - if (current.BaseObject is Spinner) + if (current.BaseObject is Spinner || Previous.Count <= 1 || Previous[0].BaseObject is Spinner) return 0; - var osuCurrent = (OsuDifficultyHitObject)current; + var osuCurrObj = (OsuDifficultyHitObject)current; + var osuLastObj = (OsuDifficultyHitObject)Previous[0]; + var osuLastLastObj = (OsuDifficultyHitObject)Previous[1]; - double aimStrain = 0; + // Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle. + double currVelocity = osuCurrObj.JumpDistance / osuCurrObj.StrainTime; - if (Previous.Count > 0) + // But if the last object is a slider, then we extend the travel velocity through the slider into the current object. + if (osuLastObj.BaseObject is Slider) { - var osuPrevious = (OsuDifficultyHitObject)Previous[0]; + double movementVelocity = osuCurrObj.MovementDistance / osuCurrObj.MovementTime; // calculate the movement velocity from slider end to current object + double travelVelocity = osuCurrObj.TravelDistance / osuCurrObj.TravelTime; // calculate the slider velocity from slider head to slider end. - if (osuCurrent.Angle != null && osuCurrent.Angle.Value > angle_bonus_begin) + currVelocity = Math.Max(currVelocity, movementVelocity + travelVelocity); // take the larger total combined velocity. + } + + // As above, do the same for the previous hitobject. + double prevVelocity = osuLastObj.JumpDistance / osuLastObj.StrainTime; + + if (osuLastLastObj.BaseObject is Slider) + { + double movementVelocity = osuLastObj.MovementDistance / osuLastObj.MovementTime; + double travelVelocity = osuLastObj.TravelDistance / osuLastObj.TravelTime; + + prevVelocity = Math.Max(prevVelocity, movementVelocity + travelVelocity); + } + + double angleBonus = 0; + double aimStrain = currVelocity; // Start strain with regular velocity. + + if (Math.Max(osuCurrObj.StrainTime, osuLastObj.StrainTime) < 1.25 * Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime)) // If rhythms are the same. + { + if (osuCurrObj.Angle != null && osuLastObj.Angle != null && osuLastLastObj.Angle != null) { - const double scale = 90; + double currAngle = osuCurrObj.Angle.Value; + double lastAngle = osuLastObj.Angle.Value; + double lastLastAngle = osuLastLastObj.Angle.Value; - double angleBonus = Math.Sqrt( - Math.Max(osuPrevious.JumpDistance - scale, 0) - * Math.Pow(Math.Sin(osuCurrent.Angle.Value - angle_bonus_begin), 2) - * Math.Max(osuCurrent.JumpDistance - scale, 0)); - aimStrain = 1.4 * applyDiminishingExp(Math.Max(0, angleBonus)) / Math.Max(timing_threshold, osuPrevious.StrainTime); + // Rewarding angles, take the smaller velocity as base. + angleBonus = Math.Min(currVelocity, prevVelocity); + + double wideAngleBonus = calcWideAngleBonus(currAngle); + double acuteAngleBonus = calcAcuteAngleBonus(currAngle); + + if (osuCurrObj.StrainTime > 100) // Only buff deltaTime exceeding 300 bpm 1/2. + acuteAngleBonus = 0; + else + { + acuteAngleBonus *= calcAcuteAngleBonus(lastAngle) // Multiply by previous angle, we don't want to buff unless this is a wiggle type pattern. + * Math.Min(angleBonus, 125 / osuCurrObj.StrainTime) // The maximum velocity we buff is equal to 125 / strainTime + * Math.Pow(Math.Sin(Math.PI / 2 * Math.Min(1, (100 - osuCurrObj.StrainTime) / 25)), 2) // scale buff from 150 bpm 1/4 to 200 bpm 1/4 + * Math.Pow(Math.Sin(Math.PI / 2 * (Math.Clamp(osuCurrObj.JumpDistance, 50, 100) - 50) / 50), 2); // Buff distance exceeding 50 (radius) up to 100 (diameter). + } + + wideAngleBonus *= angleBonus * (1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3))); // Penalize wide angles if they're repeated, reducing the penalty as the lastAngle gets more acute. + acuteAngleBonus *= 0.5 + 0.5 * (1 - Math.Min(acuteAngleBonus, Math.Pow(calcAcuteAngleBonus(lastLastAngle), 3))); // Penalize acute angles if they're repeated, reducing the penalty as the lastLastAngle gets more obtuse. + + angleBonus = acuteAngleBonus * acute_angle_multiplier + wideAngleBonus * wide_angle_multiplier; // add the angle buffs together. } } - double jumpDistanceExp = applyDiminishingExp(osuCurrent.JumpDistance); - double travelDistanceExp = applyDiminishingExp(osuCurrent.TravelDistance); + aimStrain += angleBonus; // Add in angle bonus. - return Math.Max( - aimStrain + (jumpDistanceExp + travelDistanceExp + Math.Sqrt(travelDistanceExp * jumpDistanceExp)) / Math.Max(osuCurrent.StrainTime, timing_threshold), - (Math.Sqrt(travelDistanceExp * jumpDistanceExp) + jumpDistanceExp + travelDistanceExp) / osuCurrent.StrainTime - ); + return aimStrain; } + private double calcWideAngleBonus(double angle) => Math.Pow(Math.Sin(3.0 / 4 * (Math.Min(5.0 / 6 * Math.PI, Math.Max(Math.PI / 6, angle)) - Math.PI / 6)), 2); + + private double calcAcuteAngleBonus(double angle) => 1 - calcWideAngleBonus(angle); + private double applyDiminishingExp(double val) => Math.Pow(val, 0.99); private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index 9b2babb9ff..5c1c3fd253 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -79,6 +79,12 @@ namespace osu.Game.Rulesets.Osu.Objects /// internal float LazyTravelDistance; + /// + /// The time taken by the cursor upon completion of this if it was hit + /// with as few movements as possible. This is set and used by difficulty calculation. + /// + internal double LazyTravelTime; + public IList> NodeSamples { get; set; } = new List>(); [JsonIgnore]