1
0
mirror of https://github.com/ppy/osu.git synced 2026-06-08 17:54:18 +08:00

Add basic flow evaluation (#36902)

https://pp.huismetbenen.nl/rankings/players/stanr-aimsep
This commit is contained in:
StanR
2026-03-10 02:48:27 +05:00
committed by GitHub
Unverified
parent 8efb10dd2e
commit c2e9b052e7
6 changed files with 199 additions and 50 deletions
@@ -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
/// <summary>
/// Evaluates the difficulty of aiming the current object, based on:
/// <list type="bullet">
/// <item><description>distance between the previous and current object</description></item>
/// </list>
/// Evaluates the difficulty of fast aiming
/// </summary>
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)));
@@ -0,0 +1,117 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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;
/// <summary>
/// Evaluates difficulty of "flow aim" - aiming pattern where player doesn't stop their cursor on every object and instead "flows" through them.
/// </summary>
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);
}
}
}
@@ -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
/// <summary>
@@ -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));
}
}
@@ -116,6 +116,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
/// </summary>
public double? Angle { get; private set; }
public double? AngularVelocity { get; private set; }
/// <summary>
/// Selective bonus for maps with higher circle size.
/// </summary>
@@ -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);
}
}
}
+56 -23
View File
@@ -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<double> sliderStrains = new List<double>();
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)
@@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
/// </summary>
public class Speed : HarmonicSkill
{
private double skillMultiplier => 1.07;
private double skillMultiplier => 1.05;
private readonly List<double> sliderStrains = new List<double>();