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:
+7
-16
@@ -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
-10
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user