1
0
mirror of https://github.com/ppy/osu.git synced 2025-01-18 06:22:56 +08:00

Merge branch 'pp-dev' into rhythm-considerations

This commit is contained in:
StanR 2025-01-17 03:02:59 +05:00 committed by GitHub
commit ef96583e44
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 325 additions and 112 deletions

View File

@ -9,7 +9,7 @@
]
},
"nvika": {
"version": "3.0.0",
"version": "4.0.0",
"commands": [
"nvika"
]

View File

@ -15,22 +15,22 @@ namespace osu.Game.Rulesets.Osu.Tests
{
protected override string ResourceAssembly => "osu.Game.Rulesets.Osu.Tests";
[TestCase(6.718709884850683d, 239, "diffcalc-test")]
[TestCase(1.4485749025771304d, 54, "zero-length-sliders")]
[TestCase(0.42630400627180914d, 4, "very-fast-slider")]
[TestCase(6.7331304290522747d, 239, "diffcalc-test")]
[TestCase(1.4602604078137214d, 54, "zero-length-sliders")]
[TestCase(0.43052813047866129d, 4, "very-fast-slider")]
[TestCase(0.14143808967817237d, 2, "nan-slider")]
public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expectedStarRating, expectedMaxCombo, name);
[TestCase(9.6343245007055653d, 239, "diffcalc-test")]
[TestCase(1.7550169162648608d, 54, "zero-length-sliders")]
[TestCase(0.55231632896800109d, 4, "very-fast-slider")]
[TestCase(9.6779397290273756d, 239, "diffcalc-test")]
[TestCase(1.7691451263718989d, 54, "zero-length-sliders")]
[TestCase(0.55785578988249407d, 4, "very-fast-slider")]
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime());
[TestCase(6.718709884850683d, 239, "diffcalc-test")]
[TestCase(1.4485749025771304d, 54, "zero-length-sliders")]
[TestCase(0.42630400627180914d, 4, "very-fast-slider")]
[TestCase(6.7331304290522747d, 239, "diffcalc-test")]
[TestCase(1.4602604078137214d, 54, "zero-length-sliders")]
[TestCase(0.43052813047866129d, 4, "very-fast-slider")]
public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic());

View File

@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
public static class AimEvaluator
{
private const double wide_angle_multiplier = 1.5;
private const double acute_angle_multiplier = 2.7;
private const double acute_angle_multiplier = 2.6;
private const double slider_multiplier = 1.35;
private const double velocity_change_multiplier = 0.75;
private const double wiggle_multiplier = 1.02;
@ -80,17 +80,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
double angleBonus = Math.Min(currVelocity, prevVelocity);
wideAngleBonus = calcWideAngleBonus(currAngle);
acuteAngleBonus = calcAcuteAngleBonus(currAngle);
// Penalize angle repetition.
wideAngleBonus *= 1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3));
acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, Math.Pow(calcAcuteAngleBonus(lastAngle), 3)));
// Apply full wide angle bonus for distance more than one diameter
wideAngleBonus *= angleBonus * DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, 0, diameter);
// Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter
acuteAngleBonus = calcAcuteAngleBonus(currAngle) *
angleBonus *
DifficultyCalculationUtils.Smootherstep(DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.StrainTime, 2), 300, 400) *
DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, diameter, diameter * 2);
// Penalize wide angles if they're repeated, reducing the penalty as the lastAngle gets more acute.
wideAngleBonus *= angleBonus * (1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3)));
// Penalize acute angles if they're repeated, reducing the penalty as the lastAngle gets more obtuse.
acuteAngleBonus *= 0.03 + 0.97 * (1 - Math.Min(acuteAngleBonus, Math.Pow(calcAcuteAngleBonus(lastAngle), 3)));
acuteAngleBonus *= angleBonus *
DifficultyCalculationUtils.Smootherstep(DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.StrainTime, 2), 300, 400) *
DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, diameter, diameter * 2);
// Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle
// https://www.desmos.com/calculator/dp0v0nvowc
@ -140,8 +142,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
return aimStrain;
}
private static 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 static double calcWideAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(40), double.DegreesToRadians(140));
private static double calcAcuteAngleBonus(double angle) => 1 - calcWideAngleBonus(angle);
private static double calcAcuteAngleBonus(double angle) => DifficultyCalculationUtils.Smoothstep(angle, double.DegreesToRadians(140), double.DegreesToRadians(40));
}
}

View File

@ -52,12 +52,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
var currentObj = (OsuDifficultyHitObject)current.Previous(i);
var currentHitObject = (OsuHitObject)(currentObj.BaseObject);
cumulativeStrainTime += lastObj.StrainTime;
if (!(currentObj.BaseObject is Spinner))
{
double jumpDistance = (osuHitObject.StackedPosition - currentHitObject.StackedEndPosition).Length;
cumulativeStrainTime += lastObj.StrainTime;
// We want to nerf objects that can be easily seen within the Flashlight circle radius.
if (i == 0)
smallDistNerf = Math.Min(1.0, jumpDistance / 75.0);

View File

@ -2,9 +2,13 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
@ -14,7 +18,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
private const double single_spacing_threshold = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25; // 1.25 circles distance between centers
private const double min_speed_bonus = 200; // 200 BPM 1/4th
private const double speed_balancing_factor = 40;
private const double distance_multiplier = 0.94;
private const double distance_multiplier = 0.9;
/// <summary>
/// Evaluates the difficulty of tapping the current object, based on:
@ -24,7 +28,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
/// <item><description>and how easily they can be cheesed.</description></item>
/// </list>
/// </summary>
public static double EvaluateDifficultyOf(DifficultyHitObject current)
public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnlyList<Mod> mods)
{
if (current.BaseObject is Spinner)
return 0;
@ -56,6 +60,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
// Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold
double distanceBonus = Math.Pow(distance / single_spacing_threshold, 3.95) * distance_multiplier;
if (mods.OfType<OsuModAutopilot>().Any())
distanceBonus = 0;
// Base difficulty with all bonuses
double difficulty = (1 + speedBonus + distanceBonus) * 1000 / strainTime;

View File

@ -62,21 +62,33 @@ namespace osu.Game.Rulesets.Osu.Difficulty
/// <summary>
/// The perceived approach rate inclusive of rate-adjusting mods (DT/HT/etc).
/// </summary>
/// <remarks>
/// Rate-adjusting mods don't directly affect the approach rate difficulty value, but have a perceived effect as a result of adjusting audio timing.
/// </remarks>
[JsonProperty("approach_rate")]
public double ApproachRate { get; set; }
/// <summary>
/// The perceived overall difficulty inclusive of rate-adjusting mods (DT/HT/etc).
/// </summary>
/// <remarks>
/// Rate-adjusting mods don't directly affect the overall difficulty value, but have a perceived effect as a result of adjusting audio timing.
/// </remarks>
[JsonProperty("overall_difficulty")]
public double OverallDifficulty { get; set; }
/// <summary>
/// The perceived hit window for a GREAT hit inclusive of rate-adjusting mods (DT/HT/etc).
/// </summary>
[JsonProperty("great_hit_window")]
public double GreatHitWindow { get; set; }
/// <summary>
/// The perceived hit window for an OK hit inclusive of rate-adjusting mods (DT/HT/etc).
/// </summary>
[JsonProperty("ok_hit_window")]
public double OkHitWindow { get; set; }
/// <summary>
/// The perceived hit window for a MEH hit inclusive of rate-adjusting mods (DT/HT/etc).
/// </summary>
[JsonProperty("meh_hit_window")]
public double MehHitWindow { get; set; }
/// <summary>
/// The beatmap's drain rate. This doesn't scale with rate-adjusting mods.
/// </summary>
@ -107,6 +119,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
yield return (ATTRIB_ID_OVERALL_DIFFICULTY, OverallDifficulty);
yield return (ATTRIB_ID_APPROACH_RATE, ApproachRate);
yield return (ATTRIB_ID_DIFFICULTY, StarRating);
yield return (ATTRIB_ID_GREAT_HIT_WINDOW, GreatHitWindow);
if (ShouldSerializeFlashlightDifficulty())
yield return (ATTRIB_ID_FLASHLIGHT, FlashlightDifficulty);
@ -117,6 +130,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
yield return (ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT, SpeedDifficultStrainCount);
yield return (ATTRIB_ID_SPEED_NOTE_COUNT, SpeedNoteCount);
yield return (ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT, AimDifficultSliderCount);
yield return (ATTRIB_ID_OK_HIT_WINDOW, OkHitWindow);
yield return (ATTRIB_ID_MEH_HIT_WINDOW, MehHitWindow);
}
public override void FromDatabaseAttributes(IReadOnlyDictionary<int, double> values, IBeatmapOnlineInfo onlineInfo)
@ -128,12 +144,15 @@ namespace osu.Game.Rulesets.Osu.Difficulty
OverallDifficulty = values[ATTRIB_ID_OVERALL_DIFFICULTY];
ApproachRate = values[ATTRIB_ID_APPROACH_RATE];
StarRating = values[ATTRIB_ID_DIFFICULTY];
GreatHitWindow = values[ATTRIB_ID_GREAT_HIT_WINDOW];
FlashlightDifficulty = values.GetValueOrDefault(ATTRIB_ID_FLASHLIGHT);
SliderFactor = values[ATTRIB_ID_SLIDER_FACTOR];
AimDifficultStrainCount = values[ATTRIB_ID_AIM_DIFFICULT_STRAIN_COUNT];
SpeedDifficultStrainCount = values[ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT];
SpeedNoteCount = values[ATTRIB_ID_SPEED_NOTE_COUNT];
AimDifficultSliderCount = values[ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT];
OkHitWindow = values[ATTRIB_ID_OK_HIT_WINDOW];
MehHitWindow = values[ATTRIB_ID_MEH_HIT_WINDOW];
DrainRate = onlineInfo.DrainRate;
HitCircleCount = onlineInfo.CircleCount;
SliderCount = onlineInfo.SliderCount;

View File

@ -63,6 +63,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty
speedRating = 0.0;
flashlightRating *= 0.7;
}
else if (mods.Any(h => h is OsuModAutopilot))
{
speedRating *= 0.5;
aimRating = 0.0;
flashlightRating *= 0.4;
}
double baseAimPerformance = OsuStrainSkill.DifficultyToPerformance(aimRating);
double baseSpeedPerformance = OsuStrainSkill.DifficultyToPerformance(speedRating);
@ -93,6 +99,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty
hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
double hitWindowGreat = hitWindows.WindowFor(HitResult.Great) / clockRate;
double hitWindowOk = hitWindows.WindowFor(HitResult.Ok) / clockRate;
double hitWindowMeh = hitWindows.WindowFor(HitResult.Meh) / clockRate;
OsuDifficultyAttributes attributes = new OsuDifficultyAttributes
{
@ -108,6 +116,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
SpeedDifficultStrainCount = speedDifficultyStrainCount,
ApproachRate = preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5,
OverallDifficulty = (80 - hitWindowGreat) / 6,
GreatHitWindow = hitWindowGreat,
OkHitWindow = hitWindowOk,
MehHitWindow = hitWindowMeh,
DrainRate = drainRate,
MaxCombo = beatmap.GetMaxCombo(),
HitCircleCount = hitCirclesCount,

View File

@ -24,6 +24,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
[JsonProperty("effective_miss_count")]
public double EffectiveMissCount { get; set; }
[JsonProperty("speed_deviation")]
public double? SpeedDeviation { get; set; }
public override IEnumerable<PerformanceDisplayAttribute> GetAttributesForDisplay()
{
foreach (var attribute in base.GetAttributesForDisplay())

View File

@ -5,10 +5,12 @@ using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Difficulty.Skills;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Utils;
namespace osu.Game.Rulesets.Osu.Difficulty
{
@ -40,6 +42,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty
/// </summary>
private double effectiveMissCount;
private double? speedDeviation;
public OsuPerformanceCalculator()
: base(new OsuRuleset())
{
@ -110,10 +114,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty
effectiveMissCount = Math.Min(effectiveMissCount + countOk * okMultiplier + countMeh * mehMultiplier, totalHits);
}
speedDeviation = calculateSpeedDeviation(osuAttributes);
double aimValue = computeAimValue(score, osuAttributes);
double speedValue = computeSpeedValue(score, osuAttributes);
double accuracyValue = computeAccuracyValue(score, osuAttributes);
double flashlightValue = computeFlashlightValue(score, osuAttributes);
double totalValue =
Math.Pow(
Math.Pow(aimValue, 1.1) +
@ -129,12 +136,16 @@ namespace osu.Game.Rulesets.Osu.Difficulty
Accuracy = accuracyValue,
Flashlight = flashlightValue,
EffectiveMissCount = effectiveMissCount,
SpeedDeviation = speedDeviation,
Total = totalValue
};
}
private double computeAimValue(ScoreInfo score, OsuDifficultyAttributes attributes)
{
if (score.Mods.Any(h => h is OsuModAutopilot))
return 0.0;
double aimDifficulty = attributes.AimDifficulty;
if (attributes.SliderCount > 0 && attributes.AimDifficultSliderCount > 0)
@ -188,14 +199,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty
aimValue *= accuracy;
// It is important to consider accuracy difficulty when scaling with accuracy.
aimValue *= 0.98 + Math.Pow(attributes.OverallDifficulty, 2) / 2500;
aimValue *= 0.98 + Math.Pow(Math.Max(0, attributes.OverallDifficulty), 2) / 2500;
return aimValue;
}
private double computeSpeedValue(ScoreInfo score, OsuDifficultyAttributes attributes)
{
if (score.Mods.Any(h => h is OsuModRelax))
if (score.Mods.Any(h => h is OsuModRelax) || speedDeviation == null)
return 0.0;
double speedValue = OsuStrainSkill.DifficultyToPerformance(attributes.SpeedDifficulty);
@ -211,6 +222,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
if (attributes.ApproachRate > 10.33)
approachRateFactor = 0.3 * (attributes.ApproachRate - 10.33);
if (score.Mods.Any(h => h is OsuModAutopilot))
approachRateFactor = 0.0;
speedValue *= 1.0 + approachRateFactor * lengthBonus; // Buff for longer maps with high AR.
if (score.Mods.Any(m => m is OsuModBlinds))
@ -224,6 +238,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
speedValue *= 1.0 + 0.04 * (12.0 - attributes.ApproachRate);
}
double speedHighDeviationMultiplier = calculateSpeedHighDeviationNerf(attributes);
speedValue *= speedHighDeviationMultiplier;
// Calculate accuracy assuming the worst case scenario
double relevantTotalDiff = totalHits - attributes.SpeedNoteCount;
double relevantCountGreat = Math.Max(0, countGreat - relevantTotalDiff);
@ -232,10 +249,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double relevantAccuracy = attributes.SpeedNoteCount == 0 ? 0 : (relevantCountGreat * 6.0 + relevantCountOk * 2.0 + relevantCountMeh) / (attributes.SpeedNoteCount * 6.0);
// Scale the speed value with accuracy and OD.
speedValue *= (0.95 + Math.Pow(attributes.OverallDifficulty, 2) / 750) * Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - attributes.OverallDifficulty) / 2);
// Scale the speed value with # of 50s to punish doubletapping.
speedValue *= Math.Pow(0.99, countMeh < totalHits / 500.0 ? 0 : countMeh - totalHits / 500.0);
speedValue *= (0.95 + Math.Pow(Math.Max(0, attributes.OverallDifficulty), 2) / 750) * Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - attributes.OverallDifficulty) / 2);
return speedValue;
}
@ -299,17 +313,121 @@ namespace osu.Game.Rulesets.Osu.Difficulty
// Scale the flashlight value with accuracy _slightly_.
flashlightValue *= 0.5 + accuracy / 2.0;
// It is important to also consider accuracy difficulty when doing that.
flashlightValue *= 0.98 + Math.Pow(attributes.OverallDifficulty, 2) / 2500;
flashlightValue *= 0.98 + Math.Pow(Math.Max(0, attributes.OverallDifficulty), 2) / 2500;
return flashlightValue;
}
/// <summary>
/// Estimates player's deviation on speed notes using <see cref="calculateDeviation"/>, assuming worst-case.
/// Treats all speed notes as hit circles.
/// </summary>
private double? calculateSpeedDeviation(OsuDifficultyAttributes attributes)
{
if (totalSuccessfulHits == 0)
return null;
// Calculate accuracy assuming the worst case scenario
double speedNoteCount = attributes.SpeedNoteCount;
speedNoteCount += (totalHits - attributes.SpeedNoteCount) * 0.1;
// Assume worst case: all mistakes were on speed notes
double relevantCountMiss = Math.Min(countMiss, speedNoteCount);
double relevantCountMeh = Math.Min(countMeh, speedNoteCount - relevantCountMiss);
double relevantCountOk = Math.Min(countOk, speedNoteCount - relevantCountMiss - relevantCountMeh);
double relevantCountGreat = Math.Max(0, speedNoteCount - relevantCountMiss - relevantCountMeh - relevantCountOk);
return calculateDeviation(attributes, relevantCountGreat, relevantCountOk, relevantCountMeh, relevantCountMiss);
}
/// <summary>
/// Estimates the player's tap deviation based on the OD, given number of greats, oks, mehs and misses,
/// assuming the player's mean hit error is 0. The estimation is consistent in that two SS scores on the same map with the same settings
/// will always return the same deviation. Misses are ignored because they are usually due to misaiming.
/// Greats and oks are assumed to follow a normal distribution, whereas mehs are assumed to follow a uniform distribution.
/// </summary>
private double? calculateDeviation(OsuDifficultyAttributes attributes, double relevantCountGreat, double relevantCountOk, double relevantCountMeh, double relevantCountMiss)
{
if (relevantCountGreat + relevantCountOk + relevantCountMeh <= 0)
return null;
double objectCount = relevantCountGreat + relevantCountOk + relevantCountMeh + relevantCountMiss;
double hitWindowGreat = attributes.GreatHitWindow;
double hitWindowOk = attributes.OkHitWindow;
double hitWindowMeh = attributes.MehHitWindow;
// The probability that a player hits a circle is unknown, but we can estimate it to be
// the number of greats on circles divided by the number of circles, and then add one
// to the number of circles as a bias correction.
double n = Math.Max(1, objectCount - relevantCountMiss - relevantCountMeh);
const double z = 2.32634787404; // 99% critical value for the normal distribution (one-tailed).
// Proportion of greats hit on circles, ignoring misses and 50s.
double p = relevantCountGreat / n;
// We can be 99% confident that p is at least this value.
double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4);
// Compute the deviation assuming greats and oks are normally distributed, and mehs are uniformly distributed.
// Begin with greats and oks first. Ignoring mehs, we can be 99% confident that the deviation is not higher than:
double deviation = hitWindowGreat / (Math.Sqrt(2) * SpecialFunctions.ErfInv(pLowerBound));
double randomValue = Math.Sqrt(2 / Math.PI) * hitWindowOk * Math.Exp(-0.5 * Math.Pow(hitWindowOk / deviation, 2))
/ (deviation * SpecialFunctions.Erf(hitWindowOk / (Math.Sqrt(2) * deviation)));
deviation *= Math.Sqrt(1 - randomValue);
// Value deviation approach as greatCount approaches 0
double limitValue = hitWindowOk / Math.Sqrt(3);
// If precision is not enough to compute true deviation - use limit value
if (pLowerBound == 0 || randomValue >= 1 || deviation > limitValue)
deviation = limitValue;
// Then compute the variance for mehs.
double mehVariance = (hitWindowMeh * hitWindowMeh + hitWindowOk * hitWindowMeh + hitWindowOk * hitWindowOk) / 3;
// Find the total deviation.
deviation = Math.Sqrt(((relevantCountGreat + relevantCountOk) * Math.Pow(deviation, 2) + relevantCountMeh * mehVariance) / (relevantCountGreat + relevantCountOk + relevantCountMeh));
return deviation;
}
// Calculates multiplier for speed to account for improper tapping based on the deviation and speed difficulty
// https://www.desmos.com/calculator/dmogdhzofn
private double calculateSpeedHighDeviationNerf(OsuDifficultyAttributes attributes)
{
if (speedDeviation == null)
return 0;
double speedValue = OsuStrainSkill.DifficultyToPerformance(attributes.SpeedDifficulty);
// Decides a point where the PP value achieved compared to the speed deviation is assumed to be tapped improperly. Any PP above this point is considered "excess" speed difficulty.
// This is used to cause PP above the cutoff to scale logarithmically towards the original speed value thus nerfing the value.
double excessSpeedDifficultyCutoff = 100 + 220 * Math.Pow(22 / speedDeviation.Value, 6.5);
if (speedValue <= excessSpeedDifficultyCutoff)
return 1.0;
const double scale = 50;
double adjustedSpeedValue = scale * (Math.Log((speedValue - excessSpeedDifficultyCutoff) / scale + 1) + excessSpeedDifficultyCutoff / scale);
// 220 UR and less are considered tapped correctly to ensure that normal scores will be punished as little as possible
double lerp = 1 - DifficultyCalculationUtils.ReverseLerp(speedDeviation.Value, 22.0, 27.0);
adjustedSpeedValue = double.Lerp(adjustedSpeedValue, speedValue, lerp);
return adjustedSpeedValue / speedValue;
}
// Miss penalty assumes that a player will miss on the hardest parts of a map,
// so we use the amount of relatively difficult sections to adjust miss penalty
// to make it more punishing on maps with lower amount of hard sections.
private double calculateMissPenalty(double missCount, double difficultStrainCount) => 0.96 / ((missCount / (4 * Math.Pow(Math.Log(difficultStrainCount), 0.94))) + 1);
private double getComboScalingFactor(OsuDifficultyAttributes attributes) => attributes.MaxCombo <= 0 ? 1.0 : Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(attributes.MaxCombo, 0.8), 1.0);
private int totalHits => countGreat + countOk + countMeh + countMiss;
private int totalSuccessfulHits => countGreat + countOk + countMeh;
private int totalImperfectHits => countOk + countMeh + countMiss;
}
}

View File

@ -26,7 +26,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
private double currentStrain;
private double skillMultiplier => 25.18;
private double skillMultiplier => 25.6;
private double strainDecayBase => 0.15;
private readonly List<double> sliderStrains = new List<double>();

View File

@ -15,7 +15,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
/// </summary>
public class Speed : OsuStrainSkill
{
private double skillMultiplier => 1.430;
private double skillMultiplier => 1.46;
private double strainDecayBase => 0.3;
private double currentStrain;
@ -35,7 +35,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
protected override double StrainValueAt(DifficultyHitObject current)
{
currentStrain *= strainDecay(((OsuDifficultyHitObject)current).StrainTime);
currentStrain += SpeedEvaluator.EvaluateDifficultyOf(current) * skillMultiplier;
currentStrain += SpeedEvaluator.EvaluateDifficultyOf(current, Mods) * skillMultiplier;
currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current);

View File

@ -14,13 +14,13 @@ namespace osu.Game.Rulesets.Taiko.Tests
{
protected override string ResourceAssembly => "osu.Game.Rulesets.Taiko";
[TestCase(3.0950934814938953d, 200, "diffcalc-test")]
[TestCase(3.0950934814938953d, 200, "diffcalc-test-strong")]
[TestCase(3.3172381854905493d, 200, "diffcalc-test")]
[TestCase(3.3172381854905493d, 200, "diffcalc-test-strong")]
public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expectedStarRating, expectedMaxCombo, name);
[TestCase(4.0839365008715403d, 200, "diffcalc-test")]
[TestCase(4.0839365008715403d, 200, "diffcalc-test-strong")]
[TestCase(4.4640702427013101d, 200, "diffcalc-test")]
[TestCase(4.4640702427013101d, 200, "diffcalc-test-strong")]
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new TaikoModDoubleTime());

View File

@ -36,18 +36,70 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
return 2 * (1 - DifficultyCalculationUtils.Logistic(exponent: Math.E * repeatingHitPattern.RepetitionInterval - 2 * Math.E));
}
/// <summary>
/// Calculates a consistency penalty based on the number of consecutive consistent intervals,
/// considering the delta time between each colour sequence.
/// </summary>
/// <param name="hitObject">The current hitObject to consider.</param>
/// <param name="threshold"> The allowable margin of error for determining whether ratios are consistent.</param>
/// <param name="maxObjectsToCheck">The maximum objects to check per count of consistent ratio.</param>
private static double consistentRatioPenalty(TaikoDifficultyHitObject hitObject, double threshold = 0.01, int maxObjectsToCheck = 64)
{
int consistentRatioCount = 0;
double totalRatioCount = 0.0;
TaikoDifficultyHitObject current = hitObject;
for (int i = 0; i < maxObjectsToCheck; i++)
{
// Break if there is no valid previous object
if (current.Index <= 1)
break;
var previousHitObject = (TaikoDifficultyHitObject)current.Previous(1);
double currentRatio = current.Rhythm.Ratio;
double previousRatio = previousHitObject.Rhythm.Ratio;
// A consistent interval is defined as the percentage difference between the two rhythmic ratios with the margin of error.
if (Math.Abs(1 - currentRatio / previousRatio) <= threshold)
{
consistentRatioCount++;
totalRatioCount += currentRatio;
break;
}
// Move to the previous object
current = previousHitObject;
}
// Ensure no division by zero
double ratioPenalty = 1 - totalRatioCount / (consistentRatioCount + 1) * 0.80;
return ratioPenalty;
}
/// <summary>
/// Evaluate the difficulty of the first hitobject within a colour streak.
/// </summary>
public static double EvaluateDifficultyOf(DifficultyHitObject hitObject)
{
TaikoDifficultyHitObjectColour colour = ((TaikoDifficultyHitObject)hitObject).Colour;
var taikoObject = (TaikoDifficultyHitObject)hitObject;
TaikoDifficultyHitObjectColour colour = taikoObject.Colour;
double difficulty = 0.0d;
if (colour.MonoStreak?.FirstHitObject == hitObject) // Difficulty for MonoStreak
difficulty += EvaluateDifficultyOf(colour.MonoStreak);
if (colour.AlternatingMonoPattern?.FirstHitObject == hitObject) // Difficulty for AlternatingMonoPattern
difficulty += EvaluateDifficultyOf(colour.AlternatingMonoPattern);
if (colour.RepeatingHitPattern?.FirstHitObject == hitObject) // Difficulty for RepeatingHitPattern
difficulty += EvaluateDifficultyOf(colour.RepeatingHitPattern);
double consistencyPenalty = consistentRatioPenalty(taikoObject);
difficulty *= consistencyPenalty;
return difficulty;
}
}

View File

@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
// Interval is capped at a very small value to prevent infinite values.
interval = Math.Max(interval, 1);
return 30 / interval;
return 20 / interval;
}
/// <summary>
@ -59,16 +59,15 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
// Find the previous hit object hit by the current finger, which is n notes prior, n being the number of
// available fingers.
TaikoDifficultyHitObject taikoCurrent = (TaikoDifficultyHitObject)current;
TaikoDifficultyHitObject? keyPrevious = taikoCurrent.PreviousMono(availableFingersFor(taikoCurrent) - 1);
if (keyPrevious == null)
{
// There is no previous hit object hit by the current finger
return 0.0;
}
TaikoDifficultyHitObject? taikoPrevious = current.Previous(1) as TaikoDifficultyHitObject;
TaikoDifficultyHitObject? previousMono = taikoCurrent.PreviousMono(availableFingersFor(taikoCurrent) - 1);
double objectStrain = 0.5; // Add a base strain to all objects
objectStrain += speedBonus(taikoCurrent.StartTime - keyPrevious.StartTime);
if (taikoPrevious == null) return objectStrain;
if (previousMono != null)
objectStrain += speedBonus(taikoCurrent.StartTime - previousMono.StartTime) + 0.5 * speedBonus(taikoCurrent.StartTime - taikoPrevious.StartTime);
return objectStrain;
}
}

View File

@ -3,6 +3,7 @@
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Taiko.Difficulty.Evaluators;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
@ -34,6 +35,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
}
var taikoObject = (TaikoDifficultyHitObject)current;
int index = taikoObject.Colour.MonoStreak?.HitObjects.IndexOf(taikoObject) ?? 0;
currentStrain *= DifficultyCalculationUtils.Logistic(index, 4, -1 / 25.0, 0.5) + 0.5;
currentStrain *= StrainDecayBase;
currentStrain += ReadingEvaluator.EvaluateDifficultyOf(taikoObject) * SkillMultiplier;

View File

@ -4,6 +4,7 @@
using System;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Taiko.Difficulty.Evaluators;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
@ -44,10 +45,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
var currentObject = current as TaikoDifficultyHitObject;
int index = currentObject?.Colour.MonoStreak?.HitObjects.IndexOf(currentObject) ?? 0;
if (singleColourStamina)
return currentStrain / (1 + Math.Exp(-(index - 10) / 2.0));
double monolengthBonus = 1 + Math.Min(Math.Max((index - 5) / 50.0, 0), 0.30);
return currentStrain;
if (singleColourStamina)
return DifficultyCalculationUtils.Logistic(-(index - 10) / 2.0, currentStrain);
return currentStrain * monolengthBonus;
}
protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => singleColourStamina ? 0 : currentStrain * strainDecay(time - current.Previous(0).StartTime);

View File

@ -40,8 +40,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
[JsonProperty("mono_stamina_factor")]
public double MonoStaminaFactor { get; set; }
[JsonProperty("reading_difficult_strains")]
public double ReadingTopStrains { get; set; }
[JsonProperty("rhythm_difficult_strains")]
public double RhythmTopStrains { get; set; }
[JsonProperty("colour_difficult_strains")]
public double ColourTopStrains { get; set; }

View File

@ -24,10 +24,13 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
public class TaikoDifficultyCalculator : DifficultyCalculator
{
private const double difficulty_multiplier = 0.084375;
private const double rhythm_skill_multiplier = 1.24 * difficulty_multiplier;
private const double rhythm_skill_multiplier = 0.65 * difficulty_multiplier;
private const double reading_skill_multiplier = 0.100 * difficulty_multiplier;
private const double colour_skill_multiplier = 0.375 * difficulty_multiplier;
private const double stamina_skill_multiplier = 0.375 * difficulty_multiplier;
private const double stamina_skill_multiplier = 0.445 * difficulty_multiplier;
private double strainLengthBonus;
private double patternMultiplier;
public override int Version => 20241007;
@ -116,8 +119,16 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
double monoStaminaFactor = staminaRating == 0 ? 1 : Math.Pow(monoStaminaRating / staminaRating, 5);
double colourDifficultStrains = colour.CountTopWeightedStrains();
double readingDifficultStrains = reading.CountTopWeightedStrains();
double staminaDifficultStrains = stamina.CountTopWeightedStrains();
double rhythmDifficultStrains = rhythm.CountTopWeightedStrains();
// Due to constraints of strain in cases where difficult strain values don't shift with range changes, we manually apply clockrate.
double staminaDifficultStrains = stamina.CountTopWeightedStrains() * clockRate;
// As we don't have pattern integration in osu!taiko, we apply the other two skills relative to rhythm.
patternMultiplier = Math.Pow(staminaRating * colourRating, 0.10);
strainLengthBonus = 1
+ Math.Min(Math.Max((staminaDifficultStrains - 1350) / 5000, 0), 0.15)
+ Math.Min(Math.Max((staminaRating - 7.0) / 1.0, 0), 0.05);
double combinedRating = combinedDifficultyValue(rhythm, reading, colour, stamina, isRelax);
double starRating = rescale(combinedRating * 1.4);
@ -125,7 +136,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
// Converts are penalised outside the scope of difficulty calculation, as our assumptions surrounding standard play-styles becomes out-of-scope.
if (beatmap.BeatmapInfo.Ruleset.OnlineID == 0)
{
starRating *= 0.825;
starRating *= 0.7;
// For maps with relax, multiple inputs are more likely to be abused.
if (isRelax)
@ -144,7 +155,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
ColourDifficulty = colourRating,
StaminaDifficulty = staminaRating,
MonoStaminaFactor = monoStaminaFactor,
ReadingTopStrains = readingDifficultStrains,
RhythmTopStrains = rhythmDifficultStrains,
ColourTopStrains = colourDifficultStrains,
StaminaTopStrains = staminaDifficultStrains,
GreatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate,
@ -173,10 +184,10 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
for (int i = 0; i < colourPeaks.Count; i++)
{
double rhythmPeak = rhythmPeaks[i] * rhythm_skill_multiplier;
double rhythmPeak = rhythmPeaks[i] * rhythm_skill_multiplier * patternMultiplier;
double readingPeak = readingPeaks[i] * reading_skill_multiplier;
double colourPeak = colourPeaks[i] * colour_skill_multiplier;
double staminaPeak = staminaPeaks[i] * stamina_skill_multiplier;
double staminaPeak = staminaPeaks[i] * stamina_skill_multiplier * strainLengthBonus;
if (isRelax)
{

View File

@ -73,8 +73,10 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes)
{
double baseDifficulty = 5 * Math.Max(1.0, attributes.StarRating / 0.115) - 4.0;
double difficultyValue = Math.Min(Math.Pow(baseDifficulty, 3) / 69052.51, Math.Pow(baseDifficulty, 2.25) / 1150.0);
double baseDifficulty = 5 * Math.Max(1.0, attributes.StarRating / 0.110) - 4.0;
double difficultyValue = Math.Min(Math.Pow(baseDifficulty, 3) / 69052.51, Math.Pow(baseDifficulty, 2.25) / 1250.0);
difficultyValue *= 1 + 0.10 * Math.Max(0, attributes.StarRating - 10);
double lengthBonus = 1 + 0.1 * Math.Min(1.0, totalHits / 1500.0);
difficultyValue *= lengthBonus;
@ -95,7 +97,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
// Scale accuracy more harshly on nearly-completely mono (single coloured) speed maps.
double accScalingExponent = 2 + attributes.MonoStaminaFactor;
double accScalingShift = 400 - 100 * attributes.MonoStaminaFactor;
double accScalingShift = 500 - 100 * attributes.MonoStaminaFactor;
return difficultyValue * Math.Pow(SpecialFunctions.Erf(accScalingShift / (Math.Sqrt(2) * estimatedUnstableRate.Value)), accScalingExponent);
}
@ -123,53 +125,21 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
/// </summary>
private double? computeDeviationUpperBound(TaikoDifficultyAttributes attributes)
{
if (totalSuccessfulHits == 0 || attributes.GreatHitWindow <= 0)
if (countGreat == 0 || attributes.GreatHitWindow <= 0)
return null;
double h300 = attributes.GreatHitWindow;
double h100 = attributes.OkHitWindow;
const double z = 2.32634787404; // 99% critical value for the normal distribution (one-tailed).
double? deviationGreatWindow = calcDeviationGreatWindow();
double? deviationGoodWindow = calcDeviationGoodWindow();
double n = totalHits;
return deviationGreatWindow is null ? deviationGoodWindow : Math.Min(deviationGreatWindow.Value, deviationGoodWindow!.Value);
// Proportion of greats hit.
double p = countGreat / n;
// The upper bound on deviation, calculated with the ratio of 300s to objects, and the great hit window.
double? calcDeviationGreatWindow()
{
if (countGreat == 0) return null;
// We can be 99% confident that p is at least this value.
double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4);
double n = totalHits;
// Proportion of greats hit.
double p = countGreat / n;
// We can be 99% confident that p is at least this value.
double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4);
// We can be 99% confident that the deviation is not higher than:
return h300 / (Math.Sqrt(2) * SpecialFunctions.ErfInv(pLowerBound));
}
// The upper bound on deviation, calculated with the ratio of 300s + 100s to objects, and the good hit window.
// This will return a lower value than the first method when the number of 100s is high, but the miss count is low.
double? calcDeviationGoodWindow()
{
if (totalSuccessfulHits == 0) return null;
double n = totalHits;
// Proportion of greats + goods hit.
double p = Math.Max(0, totalSuccessfulHits - 0.0005 * countOk) / n;
// We can be 99% confident that p is at least this value.
double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4);
// We can be 99% confident that the deviation is not higher than:
return h100 / (Math.Sqrt(2) * SpecialFunctions.ErfInv(pLowerBound));
}
// We can be 99% confident that the deviation is not higher than:
return attributes.GreatHitWindow / (Math.Sqrt(2) * SpecialFunctions.ErfInv(pLowerBound));
}
private int totalHits => countGreat + countOk + countMeh + countMiss;

View File

@ -31,6 +31,7 @@ namespace osu.Game.Rulesets.Difficulty
protected const int ATTRIB_ID_OK_HIT_WINDOW = 27;
protected const int ATTRIB_ID_MONO_STAMINA_FACTOR = 29;
protected const int ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT = 31;
protected const int ATTRIB_ID_MEH_HIT_WINDOW = 33;
/// <summary>
/// The mods which were applied to the beatmap.

View File

@ -66,6 +66,19 @@ namespace osu.Game.Rulesets.Difficulty.Utils
/// <returns>The output of the bell curve function of <paramref name="x"/></returns>
public static double BellCurve(double x, double mean, double width, double multiplier = 1.0) => multiplier * Math.Exp(Math.E * -(Math.Pow(x - mean, 2) / Math.Pow(width, 2)));
/// <summary>
/// Smoothstep function (https://en.wikipedia.org/wiki/Smoothstep)
/// </summary>
/// <param name="x">Value to calculate the function for</param>
/// <param name="start">Value at which function returns 0</param>
/// <param name="end">Value at which function returns 1</param>
public static double Smoothstep(double x, double start, double end)
{
x = Math.Clamp((x - start) / (end - start), 0.0, 1.0);
return x * x * (3.0 - 2.0 * x);
}
/// <summary>
/// Smootherstep function (https://en.wikipedia.org/wiki/Smoothstep#Variations)
/// </summary>