diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AgilityEvaluator.cs
similarity index 53%
rename from osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedAimEvaluator.cs
rename to osu.Game.Rulesets.Osu/Difficulty/Evaluators/AgilityEvaluator.cs
index 246aa151f5..2d0d2897a2 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedAimEvaluator.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AgilityEvaluator.cs
@@ -3,20 +3,18 @@
using System;
using osu.Game.Rulesets.Difficulty.Preprocessing;
+using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
{
- public static class SpeedAimEvaluator
+ public static class AgilityEvaluator
{
- public const double SINGLE_SPACING_THRESHOLD = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25; // 1.25 circles distance between centers
+ private const double distance_cap = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25; // 1.25 circles distance between centers
///
- /// Evaluates the difficulty of aiming the current object, based on:
- ///
- /// - distance between the previous and current object
- ///
+ /// Evaluates the difficulty of fast aiming
///
public static double EvaluateDifficultyOf(DifficultyHitObject current)
{
@@ -29,20 +27,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
double travelDistance = osuPrevObj?.LazyTravelDistance ?? 0;
double distance = travelDistance + osuCurrObj.LazyJumpDistance;
- // Cap distance at single_spacing_threshold
- distance = Math.Min(distance, SINGLE_SPACING_THRESHOLD);
+ double distanceScaled = Math.Min(distance, distance_cap) / distance_cap;
- // Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold
- double distanceBonus = Math.Pow(distance / SINGLE_SPACING_THRESHOLD, 2.9);
-
- // Apply increased high circle size bonus
- distanceBonus *= Math.Pow(osuCurrObj.SmallCircleBonus, 1.5);
-
- double strain = distanceBonus * 1000 / osuCurrObj.AdjustedDeltaTime;
+ double strain = distanceScaled * 1000 / osuCurrObj.AdjustedDeltaTime;
strain *= highBpmBonus(osuCurrObj.AdjustedDeltaTime);
- return strain;
+ return strain * DifficultyCalculationUtils.Smootherstep(distance, 0, OsuDifficultyHitObject.NORMALISED_RADIUS);
}
private static double highBpmBonus(double ms) => 1 / (1 - Math.Pow(0.3, Math.Pow(ms / 1000, 0.9)));
diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlowAimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlowAimEvaluator.cs
new file mode 100644
index 0000000000..61451c4f93
--- /dev/null
+++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlowAimEvaluator.cs
@@ -0,0 +1,117 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using osu.Game.Rulesets.Difficulty.Preprocessing;
+using osu.Game.Rulesets.Difficulty.Utils;
+using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
+using osu.Game.Rulesets.Osu.Objects;
+using osuTK;
+
+namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
+{
+ public static class FlowAimEvaluator
+ {
+ private const double velocity_change_multiplier = 2.0;
+
+ ///
+ /// Evaluates difficulty of "flow aim" - aiming pattern where player doesn't stop their cursor on every object and instead "flows" through them.
+ ///
+ public static double EvaluateDifficultyOf(DifficultyHitObject current, bool withSliderTravelDistance)
+ {
+ if (current.BaseObject is Spinner || current.Index <= 1 || current.Previous(0).BaseObject is Spinner)
+ return 0;
+
+ var osuCurrObj = (OsuDifficultyHitObject)current;
+ var osuLastObj = (OsuDifficultyHitObject)current.Previous(0);
+ var osuLastLastObj = (OsuDifficultyHitObject)current.Previous(1);
+
+ double currDistance = withSliderTravelDistance ? osuCurrObj.LazyJumpDistance : osuCurrObj.JumpDistance;
+ double prevDistance = withSliderTravelDistance ? osuLastObj.LazyJumpDistance : osuLastObj.JumpDistance;
+
+ double currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime;
+
+ if (osuLastObj.BaseObject is Slider && withSliderTravelDistance)
+ {
+ // If the last object is a slider, then we extend the travel velocity through the slider into the current object.
+ double sliderDistance = osuLastObj.LazyTravelDistance + osuCurrObj.LazyJumpDistance;
+ currVelocity = Math.Max(currVelocity, sliderDistance / osuCurrObj.AdjustedDeltaTime);
+ }
+
+ double prevVelocity = prevDistance / osuLastObj.AdjustedDeltaTime;
+
+ double flowDifficulty = currVelocity;
+
+ // Apply high circle size bonus to the base velocity
+ flowDifficulty *= osuCurrObj.SmallCircleBonus;
+
+ // Rhythm changes are harder to flow
+ flowDifficulty *= 1 + Math.Min(0.25,
+ Math.Pow((Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) - Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime)) / 50, 4));
+
+ if (osuCurrObj.AngularVelocity != null)
+ {
+ // Low angular velocity flow (angles are consistent) is easier to follow than erratic flow
+ flowDifficulty *= 0.8 + Math.Sqrt(osuCurrObj.AngularVelocity.Value / 270.0);
+ }
+
+ // If all three notes are overlapping - don't reward bonuses as you don't have to do additional movement
+ double overlappedNotesWeight = 1;
+
+ if (current.Index > 2)
+ {
+ double o1 = calculateOverlapFactor(osuCurrObj, osuLastObj);
+ double o2 = calculateOverlapFactor(osuCurrObj, osuLastLastObj);
+ double o3 = calculateOverlapFactor(osuLastObj, osuLastLastObj);
+
+ overlappedNotesWeight = 1 - o1 * o2 * o3;
+ }
+
+ if (osuCurrObj.Angle != null && osuLastObj.Angle != null)
+ {
+ // Acute angles are also hard to flow
+ // We square root velocity to make acute angle switches in streams aren't having difficulty higher than snap
+ flowDifficulty += Math.Sqrt(currVelocity) *
+ SnapAimEvaluator.CalcAcuteAngleBonus(osuCurrObj.Angle.Value) *
+ overlappedNotesWeight;
+ }
+
+ if (Math.Max(prevVelocity, currVelocity) != 0)
+ {
+ if (withSliderTravelDistance)
+ {
+ currVelocity = currDistance / osuCurrObj.AdjustedDeltaTime;
+ prevVelocity = prevDistance / osuLastObj.AdjustedDeltaTime;
+ }
+
+ // Scale with ratio of difference compared to 0.5 * max dist.
+ double distRatio = DifficultyCalculationUtils.Smoothstep(Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity), 0, 1);
+
+ // Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing.
+ double overlapVelocityBuff = Math.Min(OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25 / Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime),
+ Math.Abs(prevVelocity - currVelocity));
+
+ flowDifficulty += overlapVelocityBuff * distRatio * velocity_change_multiplier;
+ }
+
+ if (osuCurrObj.BaseObject is Slider)
+ {
+ // Include slider velocity to make velocity more consistent with snap
+ flowDifficulty += osuCurrObj.TravelDistance / osuCurrObj.TravelTime;
+ }
+
+ // Final velocity is being raised to a power because flow difficulty scales harder with both high distance and time, and we want to account for that
+ return Math.Pow(flowDifficulty, 1.45);
+ }
+
+ private static double calculateOverlapFactor(OsuDifficultyHitObject first, OsuDifficultyHitObject second)
+ {
+ var firstBase = (OsuHitObject)first.BaseObject;
+ var secondBase = (OsuHitObject)second.BaseObject;
+ double objectRadius = firstBase.Radius;
+
+ double distance = Vector2.Distance(firstBase.StackedPosition, secondBase.StackedPosition);
+ return Math.Clamp(1 - Math.Pow(Math.Max(distance - objectRadius, 0) / objectRadius, 2), 0, 1);
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SnapAimEvaluator.cs
similarity index 91%
rename from osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs
rename to osu.Game.Rulesets.Osu/Difficulty/Evaluators/SnapAimEvaluator.cs
index 9f9bbb7eb3..58bf9dc48e 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SnapAimEvaluator.cs
@@ -9,12 +9,12 @@ using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
{
- public static class AimEvaluator
+ public static class SnapAimEvaluator
{
- private const double wide_angle_multiplier = 1.35;
+ private const double wide_angle_multiplier = 1.3;
private const double acute_angle_multiplier = 2.5;
private const double slider_multiplier = 1.9;
- private const double velocity_change_multiplier = 1.1;
+ private const double velocity_change_multiplier = 1.0;
private const double wiggle_multiplier = 1.02; // WARNING: Increasing this multiplier beyond 1.02 reduces difficulty as distance increases. Refer to the desmos link above the wiggle bonus calculation
///
@@ -78,24 +78,23 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
if (Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) < 1.25 * Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime)) // If rhythms are the same.
{
- acuteAngleBonus = calcAcuteAngleBonus(currAngle);
+ acuteAngleBonus = CalcAcuteAngleBonus(currAngle);
// Penalize angle repetition.
- acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, Math.Pow(calcAcuteAngleBonus(lastAngle), 3)));
+ acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, Math.Pow(CalcAcuteAngleBonus(lastAngle), 3)));
// Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter
acuteAngleBonus *= angleBonus *
DifficultyCalculationUtils.Smootherstep(DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.AdjustedDeltaTime, 2), 300, 400) *
- DifficultyCalculationUtils.Smootherstep(currDistance, diameter, diameter * 2);
+ DifficultyCalculationUtils.Smootherstep(currDistance, 0, diameter * 2);
}
wideAngleBonus = calcWideAngleBonus(currAngle);
// Penalize angle repetition.
- wideAngleBonus *= 1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3));
+ wideAngleBonus *= 0.25 + 0.75 * (1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3)));
- // Apply full wide angle bonus for distance more than SINGLE_SPACING_THRESHOLD
- wideAngleBonus *= angleBonus * DifficultyCalculationUtils.Smoothstep(currDistance, 0, SpeedAimEvaluator.SINGLE_SPACING_THRESHOLD);
+ wideAngleBonus *= angleBonus;
// Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle
// https://www.desmos.com/calculator/dp0v0nvowc
@@ -176,6 +175,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
private static double calcWideAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(40), double.DegreesToRadians(140));
- private static double calcAcuteAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(140), double.DegreesToRadians(40));
+ public static double CalcAcuteAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(140), double.DegreesToRadians(40));
}
}
diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs
index 33bb6b29c8..848816503d 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs
@@ -116,6 +116,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
///
public double? Angle { get; private set; }
+ public double? AngularVelocity { get; private set; }
+
///
/// Selective bonus for maps with higher circle size.
///
@@ -260,6 +262,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
double sliderAngle = calculateSliderAngle(lastDifficultyObject!, lastLastCursorPosition);
Angle = Math.Min(angle, sliderAngle);
+
+ if (lastLastDifficultyObject.Angle != null)
+ {
+ double angleDifference = Math.Abs(Angle.Value - lastLastDifficultyObject.Angle.Value);
+ double angleDifferenceAdjusted = Math.Sin(angleDifference / 2) * 180.0;
+ AngularVelocity = angleDifferenceAdjusted / (AdjustedDeltaTime * 0.1);
+ }
}
}
diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs
index 9865d40b77..101dd2250a 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs
@@ -28,57 +28,90 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
IncludeSliders = includeSliders;
}
- private double currentAimStrain;
- private double currentSpeedStrain;
+ private double currentStrain;
- private double skillMultiplierAim => 65.2;
- private double skillMultiplierSpeed => 2.8;
+ private double skillMultiplierSnap => 65.2;
+ private double skillMultiplierAgility => 2.7;
+ private double skillMultiplierFlow => 262.0;
private double skillMultiplierTotal => 1.0;
private double meanExponent => 1.2;
private readonly List sliderStrains = new List();
- private double strainDecayAim(double ms) => Math.Pow(0.15, ms / 1000);
- private double strainDecaySpeed(double ms) => Math.Pow(0.3, ms / 1000);
+ private double strainDecay(double ms) => Math.Pow(0.15, ms / 1000);
protected override double CalculateInitialStrain(double time, DifficultyHitObject current) =>
- DifficultyCalculationUtils.Norm(meanExponent,
- currentAimStrain * strainDecayAim(time - current.Previous(0).StartTime),
- currentSpeedStrain * strainDecaySpeed(time - current.Previous(0).StartTime)) * skillMultiplierTotal;
+ currentStrain * strainDecay(time - current.Previous(0).StartTime);
protected override double StrainValueAt(DifficultyHitObject current)
{
- double decayAim = strainDecayAim(((OsuDifficultyHitObject)current).AdjustedDeltaTime);
- double decaySpeed = strainDecaySpeed(((OsuDifficultyHitObject)current).AdjustedDeltaTime);
+ double decay = strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime);
- double aimDifficulty = AimEvaluator.EvaluateDifficultyOf(current, IncludeSliders);
- double speedDifficulty = SpeedAimEvaluator.EvaluateDifficultyOf(current);
+ double snapDifficulty = SnapAimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplierSnap;
+ double agilityDifficulty = AgilityEvaluator.EvaluateDifficultyOf(current) * skillMultiplierAgility;
+ double flowDifficulty = FlowAimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplierFlow;
if (Mods.Any(m => m is OsuModTouchDevice))
{
- aimDifficulty = Math.Pow(aimDifficulty, 0.76);
- speedDifficulty = Math.Pow(speedDifficulty, 0.95);
+ snapDifficulty = Math.Pow(snapDifficulty, 0.89);
+ // we don't adjust agility here since agility represents TD difficulty in a decent enough way
+ flowDifficulty = Math.Pow(flowDifficulty, 1.1);
}
if (Mods.Any(m => m is OsuModRelax))
{
- speedDifficulty *= 0.0;
+ agilityDifficulty *= 0.0;
+ flowDifficulty *= 0.1;
}
- currentAimStrain *= decayAim;
- currentAimStrain += aimDifficulty * (1 - decayAim) * skillMultiplierAim;
+ double totalDifficulty = calculateTotalValue(snapDifficulty, agilityDifficulty, flowDifficulty);
- currentSpeedStrain *= decaySpeed;
- currentSpeedStrain += speedDifficulty * (1 - decaySpeed) * skillMultiplierSpeed;
-
- double totalStrain = DifficultyCalculationUtils.Norm(meanExponent, currentAimStrain, currentSpeedStrain) * skillMultiplierTotal;
+ currentStrain *= decay;
+ currentStrain += totalDifficulty * (1 - decay);
if (current.BaseObject is Slider)
- sliderStrains.Add(totalStrain);
+ sliderStrains.Add(currentStrain);
+
+ return currentStrain;
+ }
+
+ private double calculateTotalValue(double snapDifficulty, double agilityDifficulty, double flowDifficulty)
+ {
+ // We compare flow to combined snap and agility because snap by itself doesn't have enough difficulty to be above flow on streams
+ // Agility on the other hand is supposed to measure the rate of cursor velocity changes while snapping
+ // So snapping every circle on a stream requires an enormous amount of agility at which point it's easier to flow
+ double combinedSnapDifficulty = DifficultyCalculationUtils.Norm(meanExponent, snapDifficulty, agilityDifficulty);
+
+ double pSnap = calculateSnapFlowProbability(flowDifficulty / combinedSnapDifficulty);
+ double pFlow = 1 - pSnap;
+
+ double totalDifficulty = combinedSnapDifficulty * pSnap + flowDifficulty * pFlow;
+
+ double totalStrain = totalDifficulty * skillMultiplierTotal;
return totalStrain;
}
+ // A function that turns the ratio of snap : flow into the probability of snapping/flowing
+ // It has the constraints:
+ // P(snap) + P(flow) = 1 (the object is always either snapped or flowed)
+ // P(snap) = f(snap/flow), P(flow) = f(flow/snap) (ie snap and flow are symmetric and reversible)
+ // Therefore: f(x) + f(1/x) = 1
+ // 0 <= f(x) <= 1 (cannot have negative or greater than 100% probability of snapping or flowing)
+ // This logistic function is a solution, which fits nicely with the general idea of interpolation and provides a tuneable constant
+ private static double calculateSnapFlowProbability(double ratio)
+ {
+ const double k = 7.27;
+
+ if (ratio == 0)
+ return 0;
+
+ if (double.IsNaN(ratio))
+ return 1;
+
+ return DifficultyCalculationUtils.Logistic(-k * Math.Log(ratio));
+ }
+
public double GetDifficultSliders()
{
if (sliderStrains.Count == 0)
diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs
index 211d9d57b9..a50a5cfab6 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs
@@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
///
public class Speed : HarmonicSkill
{
- private double skillMultiplier => 1.07;
+ private double skillMultiplier => 1.05;
private readonly List sliderStrains = new List();