diff --git a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs
index efda3fa369..9af5051f45 100644
--- a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs
+++ b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs
@@ -15,22 +15,22 @@ namespace osu.Game.Rulesets.Osu.Tests
{
protected override string ResourceAssembly => "osu.Game.Rulesets.Osu.Tests";
- [TestCase(6.7171144000821119d, 239, "diffcalc-test")]
- [TestCase(1.4485749025771304d, 54, "zero-length-sliders")]
- [TestCase(0.42630400627180914d, 4, "very-fast-slider")]
+ [TestCase(6.6860329680488437d, 239, "diffcalc-test")]
+ [TestCase(1.4485740324170036d, 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(8.9825709931204205d, 239, "diffcalc-test")]
- [TestCase(1.7550169162648608d, 54, "zero-length-sliders")]
- [TestCase(0.55231632896800109d, 4, "very-fast-slider")]
+ [TestCase(9.6300773538770041d, 239, "diffcalc-test")]
+ [TestCase(1.7550155729445993d, 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.7171144000821119d, 239, "diffcalc-test")]
- [TestCase(1.4485749025771304d, 54, "zero-length-sliders")]
- [TestCase(0.42630400627180914d, 4, "very-fast-slider")]
+ [TestCase(6.6860329680488437d, 239, "diffcalc-test")]
+ [TestCase(1.4485740324170036d, 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());
diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs
index 9816f6d0a4..e279ed889a 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs
@@ -12,9 +12,10 @@ 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 = 1.95;
+ 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;
///
/// Evaluates the difficulty of aiming the current object, based on:
@@ -64,6 +65,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
double acuteAngleBonus = 0;
double sliderBonus = 0;
double velocityChangeBonus = 0;
+ double wiggleBonus = 0;
double aimStrain = currVelocity; // Start strain with regular velocity.
@@ -73,7 +75,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
{
double currAngle = osuCurrObj.Angle.Value;
double lastAngle = osuLastObj.Angle.Value;
- double lastLastAngle = osuLastLastObj.Angle.Value;
// Rewarding angles, take the smaller velocity as base.
double angleBonus = Math.Min(currVelocity, prevVelocity);
@@ -81,20 +82,27 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
wideAngleBonus = calcWideAngleBonus(currAngle);
acuteAngleBonus = calcAcuteAngleBonus(currAngle);
- if (DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.StrainTime, 2) < 300) // 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, diameter * 1.25 / 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.LazyJumpDistance, radius, diameter) - radius) / radius), 2); // Buff distance exceeding radius up to diameter.
- }
+ // Penalize angle repetition.
+ wideAngleBonus *= 1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3));
+ acuteAngleBonus *= 0.1 + 0.9 * (1 - Math.Min(acuteAngleBonus, Math.Pow(calcAcuteAngleBonus(lastAngle), 3)));
- // 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 lastLastAngle gets more obtuse.
- acuteAngleBonus *= 0.5 + 0.5 * (1 - Math.Min(acuteAngleBonus, Math.Pow(calcAcuteAngleBonus(lastLastAngle), 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 *= 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
+ wiggleBonus = angleBonus
+ * DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, radius, diameter)
+ * Math.Pow(DifficultyCalculationUtils.ReverseLerp(osuCurrObj.LazyJumpDistance, diameter * 3, diameter), 1.8)
+ * DifficultyCalculationUtils.Smootherstep(currAngle, double.DegreesToRadians(110), double.DegreesToRadians(60))
+ * DifficultyCalculationUtils.Smootherstep(osuLastObj.LazyJumpDistance, radius, diameter)
+ * Math.Pow(DifficultyCalculationUtils.ReverseLerp(osuLastObj.LazyJumpDistance, diameter * 3, diameter), 1.8)
+ * DifficultyCalculationUtils.Smootherstep(lastAngle, double.DegreesToRadians(110), double.DegreesToRadians(60));
}
}
@@ -122,6 +130,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
sliderBonus = osuLastObj.TravelDistance / osuLastObj.TravelTime;
}
+ aimStrain += wiggleBonus * wiggle_multiplier;
+
// Add in acute angle bonus or wide angle bonus + velocity change bonus, whichever is larger.
aimStrain += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier + velocityChangeBonus * velocity_change_multiplier);
@@ -132,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));
}
}
diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs
index a5f6468f17..769220ece0 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs
@@ -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;
///
/// Evaluates the difficulty of tapping the current object, based on:
@@ -24,7 +28,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
/// - and how easily they can be cheesed.
///
///
- public static double EvaluateDifficultyOf(DifficultyHitObject current)
+ public static double EvaluateDifficultyOf(DifficultyHitObject current, IReadOnlyList 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().Any())
+ distanceBonus = 0;
+
// Base difficulty with all bonuses
double difficulty = (1 + speedBonus + distanceBonus) * 1000 / strainTime;
diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs
index 7423109da5..1d7fc1cf54 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs
@@ -8,6 +8,7 @@ using Newtonsoft.Json;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Mods;
+using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Difficulty
{
@@ -25,6 +26,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty
[JsonProperty("aim_difficulty_factor")]
public double AimDifficultyFactor { get; set; }
+ ///
+ /// The number of s weighted by difficulty.
+ ///
+ [JsonProperty("aim_difficult_slider_count")]
+ public double AimDifficultSliderCount { get; set; }
+
///
/// The difficulty corresponding to the speed skill.
///
@@ -67,21 +74,33 @@ namespace osu.Game.Rulesets.Osu.Difficulty
///
/// The perceived approach rate inclusive of rate-adjusting mods (DT/HT/etc).
///
- ///
- /// Rate-adjusting mods don't directly affect the approach rate difficulty value, but have a perceived effect as a result of adjusting audio timing.
- ///
[JsonProperty("approach_rate")]
public double ApproachRate { get; set; }
///
/// The perceived overall difficulty inclusive of rate-adjusting mods (DT/HT/etc).
///
- ///
- /// Rate-adjusting mods don't directly affect the overall difficulty value, but have a perceived effect as a result of adjusting audio timing.
- ///
[JsonProperty("overall_difficulty")]
public double OverallDifficulty { get; set; }
+ ///
+ /// The perceived hit window for a GREAT hit inclusive of rate-adjusting mods (DT/HT/etc).
+ ///
+ [JsonProperty("great_hit_window")]
+ public double GreatHitWindow { get; set; }
+
+ ///
+ /// The perceived hit window for an OK hit inclusive of rate-adjusting mods (DT/HT/etc).
+ ///
+ [JsonProperty("ok_hit_window")]
+ public double OkHitWindow { get; set; }
+
+ ///
+ /// The perceived hit window for a MEH hit inclusive of rate-adjusting mods (DT/HT/etc).
+ ///
+ [JsonProperty("meh_hit_window")]
+ public double MehHitWindow { get; set; }
+
///
/// The beatmap's drain rate. This doesn't scale with rate-adjusting mods.
///
@@ -112,6 +131,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);
@@ -121,6 +141,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty
yield return (ATTRIB_ID_AIM_DIFFICULT_STRAIN_COUNT, AimDifficultStrainCount);
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 values, IBeatmapOnlineInfo onlineInfo)
@@ -132,11 +156,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;
diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
index 5c02bb6832..3c3906ebc1 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
@@ -40,12 +40,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double aimRatingNoSliders = Math.Sqrt(skills[1].DifficultyValue()) * difficulty_multiplier;
double speedRating = Math.Sqrt(skills[2].DifficultyValue()) * difficulty_multiplier;
double speedNotes = ((Speed)skills[2]).RelevantNoteCount();
+ double difficultSliders = ((Aim)skills[0]).GetDifficultSliders();
+ double flashlightRating = 0.0;
double aimDifficultyFactor = skills[0].DifficultyFactor();
double speedDifficultyFactor = skills[2].DifficultyFactor();
- double flashlightRating = 0.0;
-
if (mods.Any(h => h is OsuModFlashlight))
flashlightRating = Math.Sqrt(skills[3].DifficultyValue()) * difficulty_multiplier;
@@ -66,6 +66,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);
@@ -96,6 +102,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
{
@@ -103,6 +111,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
Mods = mods,
AimDifficulty = aimRating,
AimDifficultyFactor = aimDifficultyFactor,
+ AimDifficultSliderCount = difficultSliders,
SpeedDifficulty = speedRating,
SpeedDifficultyFactor = speedDifficultyFactor,
SpeedNoteCount = speedNotes,
@@ -112,6 +121,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,
diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs
index 0aeaf7669f..de4491a31b 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs
@@ -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 GetAttributesForDisplay()
{
foreach (var attribute in base.GetAttributesForDisplay())
diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs
index 5fbe72476a..69dceeffa2 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs
@@ -9,6 +9,7 @@ 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 +41,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty
///
private double effectiveMissCount;
+ private double? speedDeviation;
+
public OsuPerformanceCalculator()
: base(new OsuRuleset())
{
@@ -110,10 +113,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,6 +135,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
Accuracy = accuracyValue,
Flashlight = flashlightValue,
EffectiveMissCount = effectiveMissCount,
+ SpeedDeviation = speedDeviation,
Total = totalValue
};
}
@@ -138,7 +145,33 @@ namespace osu.Game.Rulesets.Osu.Difficulty
private double computeAimValue(ScoreInfo score, OsuDifficultyAttributes attributes)
{
- double aimValue = OsuStrainSkill.DifficultyToPerformance(attributes.AimDifficulty);
+ if (score.Mods.Any(h => h is OsuModAutopilot))
+ return 0.0;
+
+ double aimDifficulty = attributes.AimDifficulty;
+
+ if (attributes.SliderCount > 0 && attributes.AimDifficultSliderCount > 0)
+ {
+ double estimateImproperlyFollowedDifficultSliders;
+
+ if (usingClassicSliderAccuracy)
+ {
+ // When the score is considered classic (regardless if it was made on old client or not) we consider all missing combo to be dropped difficult sliders
+ int maximumPossibleDroppedSliders = totalImperfectHits;
+ estimateImproperlyFollowedDifficultSliders = Math.Clamp(Math.Min(maximumPossibleDroppedSliders, attributes.MaxCombo - scoreMaxCombo), 0, attributes.AimDifficultSliderCount);
+ }
+ else
+ {
+ // We add tick misses here since they too mean that the player didn't follow the slider properly
+ // We however aren't adding misses here because missing slider heads has a harsh penalty by itself and doesn't mean that the rest of the slider wasn't followed properly
+ estimateImproperlyFollowedDifficultSliders = Math.Min(countSliderEndsDropped + countSliderTickMiss, attributes.AimDifficultSliderCount);
+ }
+
+ double sliderNerfFactor = (1 - attributes.SliderFactor) * Math.Pow(1 - estimateImproperlyFollowedDifficultSliders / attributes.AimDifficultSliderCount, 3) + attributes.SliderFactor;
+ aimDifficulty *= sliderNerfFactor;
+ }
+
+ double aimValue = OsuStrainSkill.DifficultyToPerformance(aimDifficulty);
double hardHits = totalHits * attributes.AimDifficultyFactor;
@@ -167,6 +200,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty
if (effectiveMissCount > 0)
aimValue *= calculateMissPenalty(effectiveMissCount, attributes.AimDifficultStrainCount);
+ aimValue *= 1.0 + approachRateFactor * lengthBonus; // Buff for longer maps with high AR.
+
if (score.Mods.Any(m => m is OsuModBlinds))
aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * attributes.DrainRate * attributes.DrainRate);
else if (score.Mods.Any(m => m is OsuModHidden || m is OsuModTraceable))
@@ -175,40 +210,16 @@ namespace osu.Game.Rulesets.Osu.Difficulty
aimValue *= 1.0 + 0.04 * (12.0 - attributes.ApproachRate);
}
- // We assume 15% of sliders in a map are difficult since there's no way to tell from the performance calculator.
- double estimateDifficultSliders = attributes.SliderCount * 0.15;
-
- if (attributes.SliderCount > 0)
- {
- double estimateImproperlyFollowedDifficultSliders;
-
- if (usingClassicSliderAccuracy)
- {
- // When the score is considered classic (regardless if it was made on old client or not) we consider all missing combo to be dropped difficult sliders
- int maximumPossibleDroppedSliders = totalImperfectHits;
- estimateImproperlyFollowedDifficultSliders = Math.Clamp(Math.Min(maximumPossibleDroppedSliders, attributes.MaxCombo - scoreMaxCombo), 0, estimateDifficultSliders);
- }
- else
- {
- // We add tick misses here since they too mean that the player didn't follow the slider properly
- // We however aren't adding misses here because missing slider heads has a harsh penalty by itself and doesn't mean that the rest of the slider wasn't followed properly
- estimateImproperlyFollowedDifficultSliders = Math.Clamp(countSliderEndsDropped + countSliderTickMiss, 0, estimateDifficultSliders);
- }
-
- double sliderNerfFactor = (1 - attributes.SliderFactor) * Math.Pow(1 - estimateImproperlyFollowedDifficultSliders / estimateDifficultSliders, 3) + attributes.SliderFactor;
- aimValue *= sliderNerfFactor;
- }
-
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);
@@ -235,6 +246,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty
if (effectiveMissCount > 0)
speedValue *= calculateMissPenalty(effectiveMissCount, attributes.SpeedDifficultStrainCount);
+ 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))
{
// Increasing the speed value by object count for Blinds isn't ideal, so the minimum buff is given.
@@ -246,6 +262,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);
@@ -254,10 +273,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;
}
@@ -321,17 +337,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;
}
+ ///
+ /// Estimates player's deviation on speed notes using , assuming worst-case.
+ /// Treats all speed notes as hit circles.
+ ///
+ 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ 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);
+
+ // 200 UR and less are considered tapped correctly to ensure that normal scores will be punished as little as possible
+ double lerp = 1 - Math.Clamp((speedDeviation.Value - 20) / (24 - 20), 0, 1);
+ 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;
}
}
diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs
index e48601daf3..93fc8709ad 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs
@@ -2,9 +2,12 @@
// 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.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Evaluators;
+using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Difficulty.Skills
{
@@ -25,6 +28,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
private double skillMultiplier => 25.18;
private double strainDecayBase => 0.15;
+ private readonly List sliderStrains = new List();
+
private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000);
protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => currentStrain * strainDecay(time - current.Previous(0).StartTime);
@@ -35,7 +40,26 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
currentStrain += AimEvaluator.EvaluateDifficultyOf(current, withSliders) * skillMultiplier;
+ if (current.BaseObject is Slider)
+ {
+ sliderStrains.Add(currentStrain);
+ }
+
return currentStrain;
}
+
+ public double GetDifficultSliders()
+ {
+ if (sliderStrains.Count == 0)
+ return 0;
+
+ double[] sortedStrains = sliderStrains.OrderDescending().ToArray();
+
+ double maxSliderStrain = sortedStrains.Max();
+ if (maxSliderStrain == 0)
+ return 0;
+
+ return sortedStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxSliderStrain * 12.0 - 6.0))));
+ }
}
}
diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs
index 0080ad40b4..24f1f90ec7 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs
@@ -15,18 +15,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
///
public class Speed : OsuStrainSkill
{
- public Speed(Mod[] mods)
- : base(mods)
- {
- }
-
- protected override int ReducedSectionCount => 5;
- private double skillMultiplier => 1.430;
+ private double skillMultiplier => 1.46;
private double strainDecayBase => 0.3;
private double currentStrain;
private double currentRhythm;
+ protected override int ReducedSectionCount => 5;
+
+ public Speed(Mod[] mods)
+ : base(mods)
+ {
+ }
+
private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000);
protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => (currentStrain * currentRhythm) * strainDecay(time - current.Previous(0).StartTime);
@@ -34,8 +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);
diff --git a/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs
index 09d6540f72..de3bec5fcf 100644
--- a/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs
+++ b/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs
@@ -14,13 +14,13 @@ namespace osu.Game.Rulesets.Taiko.Tests
{
protected override string ResourceAssembly => "osu.Game.Rulesets.Taiko";
- [TestCase(3.0920212594351191d, 200, "diffcalc-test")]
- [TestCase(3.0920212594351191d, 200, "diffcalc-test-strong")]
+ [TestCase(2.837609165845338d, 200, "diffcalc-test")]
+ [TestCase(2.837609165845338d, 200, "diffcalc-test-strong")]
public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expectedStarRating, expectedMaxCombo, name);
- [TestCase(4.0789820318081444d, 200, "diffcalc-test")]
- [TestCase(4.0789820318081444d, 200, "diffcalc-test-strong")]
+ [TestCase(3.8005218640444949, 200, "diffcalc-test")]
+ [TestCase(3.8005218640444949, 200, "diffcalc-test-strong")]
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new TaikoModDoubleTime());
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs
index 25428c8b2f..3ff5b87fb6 100644
--- a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs
+++ b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs
@@ -36,18 +36,70 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
return 2 * (1 - DifficultyCalculationUtils.Logistic(exponent: Math.E * repeatingHitPattern.RepetitionInterval - 2 * Math.E));
}
+ ///
+ /// Calculates a consistency penalty based on the number of consecutive consistent intervals,
+ /// considering the delta time between each colour sequence.
+ ///
+ /// The current hitObject to consider.
+ /// The allowable margin of error for determining whether ratios are consistent.
+ /// The maximum objects to check per count of consistent ratio.
+ 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;
+ }
+
+ ///
+ /// Evaluate the difficulty of the first hitobject within a colour streak.
+ ///
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;
}
}
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ReadingEvaluator.cs b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ReadingEvaluator.cs
new file mode 100644
index 0000000000..a6a1513842
--- /dev/null
+++ b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ReadingEvaluator.cs
@@ -0,0 +1,43 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Game.Rulesets.Difficulty.Utils;
+using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
+
+namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
+{
+ public static class ReadingEvaluator
+ {
+ private readonly struct VelocityRange
+ {
+ public double Min { get; }
+ public double Max { get; }
+ public double Center => (Max + Min) / 2;
+ public double Range => Max - Min;
+
+ public VelocityRange(double min, double max)
+ {
+ Min = min;
+ Max = max;
+ }
+ }
+
+ ///
+ /// Calculates the influence of higher slider velocities on hitobject difficulty.
+ /// The bonus is determined based on the EffectiveBPM, shifting within a defined range
+ /// between the upper and lower boundaries to reflect how increased slider velocity impacts difficulty.
+ ///
+ /// The hit object to evaluate.
+ /// The reading difficulty value for the given hit object.
+ public static double EvaluateDifficultyOf(TaikoDifficultyHitObject noteObject)
+ {
+ double effectiveBPM = noteObject.EffectiveBPM;
+
+ var highVelocity = new VelocityRange(480, 640);
+ var midVelocity = new VelocityRange(360, 480);
+
+ return 1.0 * DifficultyCalculationUtils.Logistic(effectiveBPM, highVelocity.Center, 1.0 / (highVelocity.Range / 10))
+ + 0.5 * DifficultyCalculationUtils.Logistic(effectiveBPM, midVelocity.Center, 1.0 / (midVelocity.Range / 10));
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs
new file mode 100644
index 0000000000..3a294f7123
--- /dev/null
+++ b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/RhythmEvaluator.cs
@@ -0,0 +1,149 @@
+// 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 System.Collections.Generic;
+using osu.Game.Rulesets.Difficulty.Preprocessing;
+using osu.Game.Rulesets.Difficulty.Utils;
+using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
+using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm;
+using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data;
+
+namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
+{
+ public class RhythmEvaluator
+ {
+ ///
+ /// Multiplier for a given denominator term.
+ ///
+ private static double termPenalty(double ratio, int denominator, double power, double multiplier)
+ {
+ return -multiplier * Math.Pow(Math.Cos(denominator * Math.PI * ratio), power);
+ }
+
+ ///
+ /// Calculates the difficulty of a given ratio using a combination of periodic penalties and bonuses.
+ ///
+ private static double ratioDifficulty(double ratio, int terms = 8)
+ {
+ double difficulty = 0;
+
+ for (int i = 1; i <= terms; ++i)
+ {
+ difficulty += termPenalty(ratio, i, 2, 1);
+ }
+
+ difficulty += terms;
+
+ // Give bonus to near-1 ratios
+ difficulty += DifficultyCalculationUtils.BellCurve(ratio, 1, 0.7);
+
+ // Penalize ratios that are VERY near 1
+ difficulty -= DifficultyCalculationUtils.BellCurve(ratio, 1, 0.5);
+
+ return difficulty / Math.Sqrt(8);
+ }
+
+ ///
+ /// Determines if the changes in hit object intervals is consistent based on a given threshold.
+ ///
+ private static double repeatedIntervalPenalty(SameRhythmHitObjects sameRhythmHitObjects, double hitWindow, double threshold = 0.1)
+ {
+ double longIntervalPenalty = sameInterval(sameRhythmHitObjects, 3);
+
+ double shortIntervalPenalty = sameRhythmHitObjects.Children.Count < 6
+ ? sameInterval(sameRhythmHitObjects, 4)
+ : 1.0; // Returns a non-penalty if there are 6 or more notes within an interval.
+
+ // Scale penalties dynamically based on hit object duration relative to hitWindow.
+ double penaltyScaling = Math.Max(1 - sameRhythmHitObjects.Duration / (hitWindow * 2), 0.5);
+
+ return Math.Min(longIntervalPenalty, shortIntervalPenalty) * penaltyScaling;
+
+ double sameInterval(SameRhythmHitObjects startObject, int intervalCount)
+ {
+ List intervals = new List();
+ var currentObject = startObject;
+
+ for (int i = 0; i < intervalCount && currentObject != null; i++)
+ {
+ intervals.Add(currentObject.HitObjectInterval);
+ currentObject = currentObject.Previous;
+ }
+
+ intervals.RemoveAll(interval => interval == null);
+
+ if (intervals.Count < intervalCount)
+ return 1.0; // No penalty if there aren't enough valid intervals.
+
+ for (int i = 0; i < intervals.Count; i++)
+ {
+ for (int j = i + 1; j < intervals.Count; j++)
+ {
+ double ratio = intervals[i]!.Value / intervals[j]!.Value;
+ if (Math.Abs(1 - ratio) <= threshold) // If any two intervals are similar, apply a penalty.
+ return 0.3;
+ }
+ }
+
+ return 1.0; // No penalty if all intervals are different.
+ }
+ }
+
+ private static double evaluateDifficultyOf(SameRhythmHitObjects sameRhythmHitObjects, double hitWindow)
+ {
+ double intervalDifficulty = ratioDifficulty(sameRhythmHitObjects.HitObjectIntervalRatio);
+ double? previousInterval = sameRhythmHitObjects.Previous?.HitObjectInterval;
+
+ // If a previous interval exists and there are multiple hit objects in the sequence:
+ if (previousInterval != null && sameRhythmHitObjects.Children.Count > 1)
+ {
+ double expectedDurationFromPrevious = (double)previousInterval * sameRhythmHitObjects.Children.Count;
+ double durationDifference = sameRhythmHitObjects.Duration - expectedDurationFromPrevious;
+
+ if (durationDifference > 0)
+ {
+ intervalDifficulty *= DifficultyCalculationUtils.Logistic(
+ durationDifference / hitWindow,
+ midpointOffset: 0.7,
+ multiplier: 1.5,
+ maxValue: 1);
+ }
+ }
+
+ // Apply consistency penalty.
+ intervalDifficulty *= repeatedIntervalPenalty(sameRhythmHitObjects, hitWindow);
+
+ // Penalise patterns that can be hit within a single hit window.
+ intervalDifficulty *= DifficultyCalculationUtils.Logistic(
+ sameRhythmHitObjects.Duration / hitWindow,
+ midpointOffset: 0.6,
+ multiplier: 1,
+ maxValue: 1);
+
+ return Math.Pow(intervalDifficulty, 0.75);
+ }
+
+ private static double evaluateDifficultyOf(SamePatterns samePatterns)
+ {
+ return ratioDifficulty(samePatterns.IntervalRatio);
+ }
+
+ ///
+ /// Evaluate the difficulty of a hitobject considering its interval change.
+ ///
+ public static double EvaluateDifficultyOf(DifficultyHitObject hitObject, double hitWindow)
+ {
+ TaikoDifficultyHitObjectRhythm rhythm = ((TaikoDifficultyHitObject)hitObject).Rhythm;
+ double difficulty = 0.0d;
+
+ if (rhythm.SameRhythmHitObjects?.FirstHitObject == hitObject) // Difficulty for SameRhythmHitObjects
+ difficulty += evaluateDifficultyOf(rhythm.SameRhythmHitObjects, hitWindow);
+
+ if (rhythm.SamePatterns?.FirstHitObject == hitObject) // Difficulty for SamePatterns
+ difficulty += 0.5 * evaluateDifficultyOf(rhythm.SamePatterns);
+
+ return difficulty;
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Reading/EffectiveBPM.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Reading/EffectiveBPM.cs
new file mode 100644
index 0000000000..17e05d5fbf
--- /dev/null
+++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Reading/EffectiveBPM.cs
@@ -0,0 +1,50 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.ControlPoints;
+
+namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Reading
+{
+ public class EffectiveBPMPreprocessor
+ {
+ private readonly IList noteObjects;
+ private readonly double globalSliderVelocity;
+
+ public EffectiveBPMPreprocessor(IBeatmap beatmap, List noteObjects)
+ {
+ this.noteObjects = noteObjects;
+ globalSliderVelocity = beatmap.Difficulty.SliderMultiplier;
+ }
+
+ ///
+ /// Calculates and sets the effective BPM and slider velocity for each note object, considering clock rate and scroll speed.
+ ///
+ public void ProcessEffectiveBPM(ControlPointInfo controlPointInfo, double clockRate)
+ {
+ foreach (var currentNoteObject in noteObjects)
+ {
+ double startTime = currentNoteObject.StartTime * clockRate;
+
+ // Retrieve the timing point at the note's start time
+ TimingControlPoint currentControlPoint = controlPointInfo.TimingPointAt(startTime);
+
+ // Calculate the slider velocity at the note's start time.
+ double currentSliderVelocity = calculateSliderVelocity(controlPointInfo, startTime, clockRate);
+ currentNoteObject.CurrentSliderVelocity = currentSliderVelocity;
+
+ currentNoteObject.EffectiveBPM = currentControlPoint.BPM * currentSliderVelocity;
+ }
+ }
+
+ ///
+ /// Calculates the slider velocity based on control point info and clock rate.
+ ///
+ private double calculateSliderVelocity(ControlPointInfo controlPointInfo, double startTime, double clockRate)
+ {
+ var activeEffectControlPoint = controlPointInfo.EffectPointAt(startTime);
+ return globalSliderVelocity * (activeEffectControlPoint.ScrollSpeed) * clockRate;
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SamePatterns.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SamePatterns.cs
new file mode 100644
index 0000000000..50839c4561
--- /dev/null
+++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SamePatterns.cs
@@ -0,0 +1,55 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using System.Linq;
+
+namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data
+{
+ ///
+ /// Represents grouped by their 's interval.
+ ///
+ public class SamePatterns : SameRhythm
+ {
+ public SamePatterns? Previous { get; private set; }
+
+ ///
+ /// The between children within this group.
+ /// If there is only one child, this will have the value of the first child's .
+ ///
+ public double ChildrenInterval => Children.Count > 1 ? Children[1].Interval : Children[0].Interval;
+
+ ///
+ /// The ratio of between this and the previous . In the
+ /// case where there is no previous , this will have a value of 1.
+ ///
+ public double IntervalRatio => ChildrenInterval / Previous?.ChildrenInterval ?? 1.0d;
+
+ public TaikoDifficultyHitObject FirstHitObject => Children[0].FirstHitObject;
+
+ public IEnumerable AllHitObjects => Children.SelectMany(child => child.Children);
+
+ private SamePatterns(SamePatterns? previous, List data, ref int i)
+ : base(data, ref i, 5)
+ {
+ Previous = previous;
+
+ foreach (TaikoDifficultyHitObject hitObject in AllHitObjects)
+ {
+ hitObject.Rhythm.SamePatterns = this;
+ }
+ }
+
+ public static void GroupPatterns(List data)
+ {
+ List samePatterns = new List();
+
+ // Index does not need to be incremented, as it is handled within the SameRhythm constructor.
+ for (int i = 0; i < data.Count;)
+ {
+ SamePatterns? previous = samePatterns.Count > 0 ? samePatterns[^1] : null;
+ samePatterns.Add(new SamePatterns(previous, data, ref i));
+ }
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SameRhythm.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SameRhythm.cs
new file mode 100644
index 0000000000..b1ca22595b
--- /dev/null
+++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SameRhythm.cs
@@ -0,0 +1,73 @@
+// 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 System.Collections.Generic;
+
+namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data
+{
+ ///
+ /// A base class for grouping s by their interval. In edges where an interval change
+ /// occurs, the is added to the group with the smaller interval.
+ ///
+ public abstract class SameRhythm
+ where ChildType : IHasInterval
+ {
+ public IReadOnlyList Children { get; private set; }
+
+ ///
+ /// Determines if the intervals between two child objects are within a specified margin of error,
+ /// indicating that the intervals are effectively "flat" or consistent.
+ ///
+ private bool isFlat(ChildType current, ChildType previous, double marginOfError)
+ {
+ return Math.Abs(current.Interval - previous.Interval) <= marginOfError;
+ }
+
+ ///
+ /// Create a new from a list of s, and add
+ /// them to the list until the end of the group.
+ ///
+ /// The list of s.
+ ///
+ /// Index in to start adding children. This will be modified and should be passed into
+ /// the next 's constructor.
+ ///
+ ///
+ /// The margin of error for the interval, within of which no interval change is considered to have occured.
+ ///
+ protected SameRhythm(List data, ref int i, double marginOfError)
+ {
+ List children = new List();
+ Children = children;
+ children.Add(data[i]);
+ i++;
+
+ for (; i < data.Count - 1; i++)
+ {
+ // An interval change occured, add the current data if the next interval is larger.
+ if (!isFlat(data[i], data[i + 1], marginOfError))
+ {
+ if (data[i + 1].Interval > data[i].Interval + marginOfError)
+ {
+ children.Add(data[i]);
+ i++;
+ }
+
+ return;
+ }
+
+ // No interval change occured
+ children.Add(data[i]);
+ }
+
+ // Check if the last two objects in the data form a "flat" rhythm pattern within the specified margin of error.
+ // If true, add the current object to the group and increment the index to process the next object.
+ if (data.Count > 2 && isFlat(data[^1], data[^2], marginOfError))
+ {
+ children.Add(data[i]);
+ i++;
+ }
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SameRhythmHitObjects.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SameRhythmHitObjects.cs
new file mode 100644
index 0000000000..0ccc6da026
--- /dev/null
+++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/Data/SameRhythmHitObjects.cs
@@ -0,0 +1,94 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using osu.Game.Rulesets.Difficulty.Preprocessing;
+
+namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data
+{
+ ///
+ /// Represents a group of s with no rhythm variation.
+ ///
+ public class SameRhythmHitObjects : SameRhythm, IHasInterval
+ {
+ public TaikoDifficultyHitObject FirstHitObject => Children[0];
+
+ public SameRhythmHitObjects? Previous;
+
+ ///
+ /// of the first hit object.
+ ///
+ public double StartTime => Children[0].StartTime;
+
+ ///
+ /// The interval between the first and final hit object within this group.
+ ///
+ public double Duration => Children[^1].StartTime - Children[0].StartTime;
+
+ ///
+ /// The interval in ms of each hit object in this . This is only defined if there is
+ /// more than two hit objects in this .
+ ///
+ public double? HitObjectInterval;
+
+ ///
+ /// The ratio of between this and the previous . In the
+ /// case where one or both of the is undefined, this will have a value of 1.
+ ///
+ public double HitObjectIntervalRatio = 1;
+
+ ///
+ /// The interval between the of this and the previous .
+ ///
+ public double Interval { get; private set; } = double.PositiveInfinity;
+
+ public SameRhythmHitObjects(SameRhythmHitObjects? previous, List data, ref int i)
+ : base(data, ref i, 5)
+ {
+ Previous = previous;
+
+ foreach (var hitObject in Children)
+ {
+ hitObject.Rhythm.SameRhythmHitObjects = this;
+
+ // Pass the HitObjectInterval to each child.
+ hitObject.HitObjectInterval = HitObjectInterval;
+ }
+
+ calculateIntervals();
+ }
+
+ public static List GroupHitObjects(List data)
+ {
+ List flatPatterns = new List();
+
+ // Index does not need to be incremented, as it is handled within SameRhythm's constructor.
+ for (int i = 0; i < data.Count;)
+ {
+ SameRhythmHitObjects? previous = flatPatterns.Count > 0 ? flatPatterns[^1] : null;
+ flatPatterns.Add(new SameRhythmHitObjects(previous, data, ref i));
+ }
+
+ return flatPatterns;
+ }
+
+ private void calculateIntervals()
+ {
+ // Calculate the average interval between hitobjects, or null if there are fewer than two.
+ HitObjectInterval = Children.Count < 2 ? null : (Children[^1].StartTime - Children[0].StartTime) / (Children.Count - 1);
+
+ // If both the current and previous intervals are available, calculate the ratio.
+ if (Previous?.HitObjectInterval != null && HitObjectInterval != null)
+ {
+ HitObjectIntervalRatio = HitObjectInterval.Value / Previous.HitObjectInterval.Value;
+ }
+
+ if (Previous == null)
+ {
+ return;
+ }
+
+ Interval = StartTime - Previous.StartTime;
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/IHasInterval.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/IHasInterval.cs
new file mode 100644
index 0000000000..8f3917cbde
--- /dev/null
+++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/IHasInterval.cs
@@ -0,0 +1,13 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm
+{
+ ///
+ /// The interface for hitobjects that provide an interval value.
+ ///
+ public interface IHasInterval
+ {
+ double Interval { get; }
+ }
+}
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/TaikoDifficultyHitObjectRhythm.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/TaikoDifficultyHitObjectRhythm.cs
index a273d7e2ea..beb7bfe5f6 100644
--- a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/TaikoDifficultyHitObjectRhythm.cs
+++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/TaikoDifficultyHitObjectRhythm.cs
@@ -1,35 +1,98 @@
// 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 System.Linq;
+using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data;
+
namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm
{
///
- /// Represents a rhythm change in a taiko map.
+ /// Stores rhythm data for a .
///
public class TaikoDifficultyHitObjectRhythm
{
///
- /// The difficulty multiplier associated with this rhythm change.
+ /// The group of hit objects with consistent rhythm that this object belongs to.
///
- public readonly double Difficulty;
+ public SameRhythmHitObjects? SameRhythmHitObjects;
///
- /// The ratio of current
- /// to previous for the rhythm change.
+ /// The larger pattern of rhythm groups that this object is part of.
+ ///
+ public SamePatterns? SamePatterns;
+
+ ///
+ /// The ratio of current
+ /// to previous for the rhythm change.
/// A above 1 indicates a slow-down; a below 1 indicates a speed-up.
///
public readonly double Ratio;
+ ///
+ /// List of most common rhythm changes in taiko maps. Based on how each object's interval compares to the previous object.
+ ///
+ ///
+ /// The general guidelines for the values are:
+ ///
+ /// - rhythm changes with ratio closer to 1 (that are not 1) are harder to play,
+ /// - speeding up is generally harder than slowing down (with exceptions of rhythm changes requiring a hand switch).
+ ///
+ ///
+ private static readonly TaikoDifficultyHitObjectRhythm[] common_rhythms =
+ {
+ new TaikoDifficultyHitObjectRhythm(1, 1),
+ new TaikoDifficultyHitObjectRhythm(2, 1),
+ new TaikoDifficultyHitObjectRhythm(1, 2),
+ new TaikoDifficultyHitObjectRhythm(3, 1),
+ new TaikoDifficultyHitObjectRhythm(1, 3),
+ new TaikoDifficultyHitObjectRhythm(3, 2),
+ new TaikoDifficultyHitObjectRhythm(2, 3),
+ new TaikoDifficultyHitObjectRhythm(5, 4),
+ new TaikoDifficultyHitObjectRhythm(4, 5)
+ };
+
+ ///
+ /// Initialises a new instance of s,
+ /// calculating the closest rhythm change and its associated difficulty for the current hit object.
+ ///
+ /// The current being processed.
+ public TaikoDifficultyHitObjectRhythm(TaikoDifficultyHitObject current)
+ {
+ var previous = current.Previous(0);
+
+ if (previous == null)
+ {
+ Ratio = 1;
+ return;
+ }
+
+ TaikoDifficultyHitObjectRhythm closestRhythm = getClosestRhythm(current.DeltaTime, previous.DeltaTime);
+ Ratio = closestRhythm.Ratio;
+ }
+
///
/// Creates an object representing a rhythm change.
///
/// The numerator for .
/// The denominator for
- /// The difficulty multiplier associated with this rhythm change.
- public TaikoDifficultyHitObjectRhythm(int numerator, int denominator, double difficulty)
+ private TaikoDifficultyHitObjectRhythm(int numerator, int denominator)
{
Ratio = numerator / (double)denominator;
- Difficulty = difficulty;
+ }
+
+ ///
+ /// Determines the closest rhythm change from that matches the timing ratio
+ /// between the current and previous intervals.
+ ///
+ /// The time difference between the current hit object and the previous one.
+ /// The time difference between the previous hit object and the one before it.
+ /// The closest matching rhythm from .
+ private TaikoDifficultyHitObjectRhythm getClosestRhythm(double currentDeltaTime, double previousDeltaTime)
+ {
+ double ratio = currentDeltaTime / previousDeltaTime;
+ return common_rhythms.OrderBy(x => Math.Abs(x.Ratio - ratio)).First();
}
}
}
+
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs
index 4aaee50c18..dfcd08ed94 100644
--- a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs
+++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs
@@ -1,7 +1,6 @@
// 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 System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing;
@@ -15,7 +14,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
///
/// Represents a single hit object in taiko difficulty calculation.
///
- public class TaikoDifficultyHitObject : DifficultyHitObject
+ public class TaikoDifficultyHitObject : DifficultyHitObject, IHasInterval
{
///
/// The list of all of the same colour as this in the beatmap.
@@ -42,12 +41,29 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
///
public readonly TaikoDifficultyHitObjectRhythm Rhythm;
+ ///
+ /// The interval between this hit object and the surrounding hit objects in its rhythm group.
+ ///
+ public double? HitObjectInterval { get; set; }
+
///
/// Colour data for this hit object. This is used by colour evaluator to calculate colour difficulty, but can be used
/// by other skills in the future.
///
public readonly TaikoDifficultyHitObjectColour Colour;
+ ///
+ /// The adjusted BPM of this hit object, based on its slider velocity and scroll speed.
+ ///
+ public double EffectiveBPM;
+
+ ///
+ /// The current slider velocity of this hit object.
+ ///
+ public double CurrentSliderVelocity;
+
+ public double Interval => DeltaTime;
+
///
/// Creates a new difficulty hit object.
///
@@ -71,7 +87,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
// Create the Colour object, its properties should be filled in by TaikoDifficultyPreprocessor
Colour = new TaikoDifficultyHitObjectColour();
- Rhythm = getClosestRhythm(lastObject, lastLastObject, clockRate);
+
+ // Create a Rhythm object, its properties are filled in by TaikoDifficultyHitObjectRhythm
+ Rhythm = new TaikoDifficultyHitObjectRhythm(this);
switch ((hitObject as Hit)?.Type)
{
@@ -95,43 +113,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing
}
}
- ///
- /// List of most common rhythm changes in taiko maps.
- ///
- ///
- /// The general guidelines for the values are:
- ///
- /// - rhythm changes with ratio closer to 1 (that are not 1) are harder to play,
- /// - speeding up is generally harder than slowing down (with exceptions of rhythm changes requiring a hand switch).
- ///
- ///
- private static readonly TaikoDifficultyHitObjectRhythm[] common_rhythms =
- {
- new TaikoDifficultyHitObjectRhythm(1, 1, 0.0),
- new TaikoDifficultyHitObjectRhythm(2, 1, 0.3),
- new TaikoDifficultyHitObjectRhythm(1, 2, 0.5),
- new TaikoDifficultyHitObjectRhythm(3, 1, 0.3),
- new TaikoDifficultyHitObjectRhythm(1, 3, 0.35),
- new TaikoDifficultyHitObjectRhythm(3, 2, 0.6), // purposefully higher (requires hand switch in full alternating gameplay style)
- new TaikoDifficultyHitObjectRhythm(2, 3, 0.4),
- new TaikoDifficultyHitObjectRhythm(5, 4, 0.5),
- new TaikoDifficultyHitObjectRhythm(4, 5, 0.7)
- };
-
- ///
- /// Returns the closest rhythm change from required to hit this object.
- ///
- /// The gameplay preceding this one.
- /// The gameplay preceding .
- /// The rate of the gameplay clock.
- private TaikoDifficultyHitObjectRhythm getClosestRhythm(HitObject lastObject, HitObject lastLastObject, double clockRate)
- {
- double prevLength = (lastObject.StartTime - lastLastObject.StartTime) / clockRate;
- double ratio = DeltaTime / prevLength;
-
- return common_rhythms.OrderBy(x => Math.Abs(x.Ratio - ratio)).First();
- }
-
public TaikoDifficultyHitObject? PreviousMono(int backwardsIndex) => monoDifficultyHitObjects?.ElementAtOrDefault(MonoIndex - (backwardsIndex + 1));
public TaikoDifficultyHitObject? NextMono(int forwardsIndex) => monoDifficultyHitObjects?.ElementAtOrDefault(MonoIndex + (forwardsIndex + 1));
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Reading.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Reading.cs
new file mode 100644
index 0000000000..9de058f289
--- /dev/null
+++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Reading.cs
@@ -0,0 +1,44 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Game.Rulesets.Difficulty.Preprocessing;
+using osu.Game.Rulesets.Difficulty.Skills;
+using osu.Game.Rulesets.Mods;
+using osu.Game.Rulesets.Taiko.Difficulty.Evaluators;
+using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
+using osu.Game.Rulesets.Taiko.Objects;
+
+namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
+{
+ ///
+ /// Calculates the reading coefficient of taiko difficulty.
+ ///
+ public class Reading : StrainDecaySkill
+ {
+ protected override double SkillMultiplier => 1.0;
+ protected override double StrainDecayBase => 0.4;
+
+ private double currentStrain;
+
+ public Reading(Mod[] mods)
+ : base(mods)
+ {
+ }
+
+ protected override double StrainValueOf(DifficultyHitObject current)
+ {
+ // Drum Rolls and Swells are exempt.
+ if (current.BaseObject is not Hit)
+ {
+ return 0.0;
+ }
+
+ var taikoObject = (TaikoDifficultyHitObject)current;
+
+ currentStrain *= StrainDecayBase;
+ currentStrain += ReadingEvaluator.EvaluateDifficultyOf(taikoObject) * SkillMultiplier;
+
+ return currentStrain;
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs
index e76af13686..4fe1ea693e 100644
--- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs
+++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs
@@ -1,13 +1,11 @@
// 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.Skills;
+using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods;
-using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
-using osu.Game.Rulesets.Taiko.Objects;
-using osu.Game.Utils;
+using osu.Game.Rulesets.Taiko.Difficulty.Evaluators;
namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
{
@@ -16,158 +14,25 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
///
public class Rhythm : StrainDecaySkill
{
- protected override double SkillMultiplier => 10;
- protected override double StrainDecayBase => 0;
+ protected override double SkillMultiplier => 1.0;
+ protected override double StrainDecayBase => 0.4;
- ///
- /// The note-based decay for rhythm strain.
- ///
- ///
- /// is not used here, as it's time- and not note-based.
- ///
- private const double strain_decay = 0.96;
+ private readonly double greatHitWindow;
- ///
- /// Maximum number of entries in .
- ///
- private const int rhythm_history_max_length = 8;
-
- ///
- /// Contains the last changes in note sequence rhythms.
- ///
- private readonly LimitedCapacityQueue rhythmHistory = new LimitedCapacityQueue(rhythm_history_max_length);
-
- ///
- /// Contains the rolling rhythm strain.
- /// Used to apply per-note decay.
- ///
- private double currentStrain;
-
- ///
- /// Number of notes since the last rhythm change has taken place.
- ///
- private int notesSinceRhythmChange;
-
- public Rhythm(Mod[] mods)
+ public Rhythm(Mod[] mods, double greatHitWindow)
: base(mods)
{
+ this.greatHitWindow = greatHitWindow;
}
protected override double StrainValueOf(DifficultyHitObject current)
{
- // drum rolls and swells are exempt.
- if (!(current.BaseObject is Hit))
- {
- resetRhythmAndStrain();
- return 0.0;
- }
+ double difficulty = RhythmEvaluator.EvaluateDifficultyOf(current, greatHitWindow);
- currentStrain *= strain_decay;
+ // To prevent abuse of exceedingly long intervals between awkward rhythms, we penalise its difficulty.
+ difficulty *= DifficultyCalculationUtils.Logistic(current.DeltaTime, 350, -1 / 25.0, 0.5) + 0.5;
- TaikoDifficultyHitObject hitObject = (TaikoDifficultyHitObject)current;
- notesSinceRhythmChange += 1;
-
- // rhythm difficulty zero (due to rhythm not changing) => no rhythm strain.
- if (hitObject.Rhythm.Difficulty == 0.0)
- {
- return 0.0;
- }
-
- double objectStrain = hitObject.Rhythm.Difficulty;
-
- objectStrain *= repetitionPenalties(hitObject);
- objectStrain *= patternLengthPenalty(notesSinceRhythmChange);
- objectStrain *= speedPenalty(hitObject.DeltaTime);
-
- // careful - needs to be done here since calls above read this value
- notesSinceRhythmChange = 0;
-
- currentStrain += objectStrain;
- return currentStrain;
- }
-
- ///
- /// Returns a penalty to apply to the current hit object caused by repeating rhythm changes.
- ///
- ///
- /// Repetitions of more recent patterns are associated with a higher penalty.
- ///
- /// The current hit object being considered.
- private double repetitionPenalties(TaikoDifficultyHitObject hitObject)
- {
- double penalty = 1;
-
- rhythmHistory.Enqueue(hitObject);
-
- for (int mostRecentPatternsToCompare = 2; mostRecentPatternsToCompare <= rhythm_history_max_length / 2; mostRecentPatternsToCompare++)
- {
- for (int start = rhythmHistory.Count - mostRecentPatternsToCompare - 1; start >= 0; start--)
- {
- if (!samePattern(start, mostRecentPatternsToCompare))
- continue;
-
- int notesSince = hitObject.Index - rhythmHistory[start].Index;
- penalty *= repetitionPenalty(notesSince);
- break;
- }
- }
-
- return penalty;
- }
-
- ///
- /// Determines whether the rhythm change pattern starting at is a repeat of any of the
- /// .
- ///
- private bool samePattern(int start, int mostRecentPatternsToCompare)
- {
- for (int i = 0; i < mostRecentPatternsToCompare; i++)
- {
- if (rhythmHistory[start + i].Rhythm != rhythmHistory[rhythmHistory.Count - mostRecentPatternsToCompare + i].Rhythm)
- return false;
- }
-
- return true;
- }
-
- ///
- /// Calculates a single rhythm repetition penalty.
- ///
- /// Number of notes since the last repetition of a rhythm change.
- private static double repetitionPenalty(int notesSince) => Math.Min(1.0, 0.032 * notesSince);
-
- ///
- /// Calculates a penalty based on the number of notes since the last rhythm change.
- /// Both rare and frequent rhythm changes are penalised.
- ///
- /// Number of notes since the last rhythm change.
- private static double patternLengthPenalty(int patternLength)
- {
- double shortPatternPenalty = Math.Min(0.15 * patternLength, 1.0);
- double longPatternPenalty = Math.Clamp(2.5 - 0.15 * patternLength, 0.0, 1.0);
- return Math.Min(shortPatternPenalty, longPatternPenalty);
- }
-
- ///
- /// Calculates a penalty for objects that do not require alternating hands.
- ///
- /// Time (in milliseconds) since the last hit object.
- private double speedPenalty(double deltaTime)
- {
- if (deltaTime < 80) return 1;
- if (deltaTime < 210) return Math.Max(0, 1.4 - 0.005 * deltaTime);
-
- resetRhythmAndStrain();
- return 0.0;
- }
-
- ///
- /// Resets the rolling strain value and counter.
- ///
- private void resetRhythmAndStrain()
- {
- currentStrain = 0.0;
- notesSinceRhythmChange = 0;
+ return difficulty;
}
}
}
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs
index c8f0448767..ef729e1f07 100644
--- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs
+++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs
@@ -10,6 +10,24 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
{
public class TaikoDifficultyAttributes : DifficultyAttributes
{
+ ///
+ /// The difficulty corresponding to the rhythm skill.
+ ///
+ [JsonProperty("rhythm_difficulty")]
+ public double RhythmDifficulty { get; set; }
+
+ ///
+ /// The difficulty corresponding to the reading skill.
+ ///
+ [JsonProperty("reading_difficulty")]
+ public double ReadingDifficulty { get; set; }
+
+ ///
+ /// The difficulty corresponding to the colour skill.
+ ///
+ [JsonProperty("colour_difficulty")]
+ public double ColourDifficulty { get; set; }
+
///
/// The difficulty corresponding to the stamina skill.
///
@@ -22,23 +40,14 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
[JsonProperty("mono_stamina_factor")]
public double MonoStaminaFactor { get; set; }
- ///
- /// The difficulty corresponding to the rhythm skill.
- ///
- [JsonProperty("rhythm_difficulty")]
- public double RhythmDifficulty { get; set; }
+ [JsonProperty("reading_difficult_strains")]
+ public double ReadingTopStrains { get; set; }
- ///
- /// The difficulty corresponding to the colour skill.
- ///
- [JsonProperty("colour_difficulty")]
- public double ColourDifficulty { get; set; }
+ [JsonProperty("colour_difficult_strains")]
+ public double ColourTopStrains { get; set; }
- ///
- /// The difficulty corresponding to the hardest parts of the map.
- ///
- [JsonProperty("peak_difficulty")]
- public double PeakDifficulty { get; set; }
+ [JsonProperty("stamina_difficult_strains")]
+ public double StaminaTopStrains { get; set; }
///
/// The perceived hit window for a GREAT hit inclusive of rate-adjusting mods (DT/HT/etc).
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs
index 7f2558c406..f8ff6f6065 100644
--- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs
+++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs
@@ -8,10 +8,13 @@ using osu.Game.Beatmaps;
using osu.Game.Rulesets.Difficulty;
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.Scoring;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour;
+using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Reading;
+using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data;
using osu.Game.Rulesets.Taiko.Difficulty.Skills;
using osu.Game.Rulesets.Taiko.Mods;
using osu.Game.Rulesets.Taiko.Scoring;
@@ -21,7 +24,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
public class TaikoDifficultyCalculator : DifficultyCalculator
{
private const double difficulty_multiplier = 0.084375;
- private const double rhythm_skill_multiplier = 0.2 * difficulty_multiplier;
+ private const double rhythm_skill_multiplier = 1.24 * 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;
@@ -34,9 +38,13 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate)
{
+ HitWindows hitWindows = new HitWindows();
+ hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
+
return new Skill[]
{
- new Rhythm(mods),
+ new Rhythm(mods, hitWindows.WindowFor(HitResult.Great) / clockRate),
+ new Reading(mods),
new Colour(mods),
new Stamina(mods, false),
new Stamina(mods, true)
@@ -53,21 +61,36 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate)
{
- List difficultyHitObjects = new List();
- List centreObjects = new List();
- List rimObjects = new List();
- List noteObjects = new List();
+ var hitWindows = new HitWindows();
+ hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
+ var difficultyHitObjects = new List();
+ var centreObjects = new List();
+ var rimObjects = new List();
+ var noteObjects = new List();
+ EffectiveBPMPreprocessor bpmLoader = new EffectiveBPMPreprocessor(beatmap, noteObjects);
+
+ // Generate TaikoDifficultyHitObjects from the beatmap's hit objects.
for (int i = 2; i < beatmap.HitObjects.Count; i++)
{
- difficultyHitObjects.Add(
- new TaikoDifficultyHitObject(
- beatmap.HitObjects[i], beatmap.HitObjects[i - 1], beatmap.HitObjects[i - 2], clockRate, difficultyHitObjects,
- centreObjects, rimObjects, noteObjects, difficultyHitObjects.Count)
- );
+ difficultyHitObjects.Add(new TaikoDifficultyHitObject(
+ beatmap.HitObjects[i],
+ beatmap.HitObjects[i - 1],
+ beatmap.HitObjects[i - 2],
+ clockRate,
+ difficultyHitObjects,
+ centreObjects,
+ rimObjects,
+ noteObjects,
+ difficultyHitObjects.Count
+ ));
}
+ var groupedHitObjects = SameRhythmHitObjects.GroupHitObjects(noteObjects);
+
TaikoColourDifficultyPreprocessor.ProcessAndAssign(difficultyHitObjects);
+ SamePatterns.GroupPatterns(groupedHitObjects);
+ bpmLoader.ProcessEffectiveBPM(beatmap.ControlPointInfo, clockRate);
return difficultyHitObjects;
}
@@ -77,27 +100,36 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
if (beatmap.HitObjects.Count == 0)
return new TaikoDifficultyAttributes { Mods = mods };
- Colour colour = (Colour)skills.First(x => x is Colour);
+ bool isRelax = mods.Any(h => h is TaikoModRelax);
+
Rhythm rhythm = (Rhythm)skills.First(x => x is Rhythm);
+ Reading reading = (Reading)skills.First(x => x is Reading);
+ Colour colour = (Colour)skills.First(x => x is Colour);
Stamina stamina = (Stamina)skills.First(x => x is Stamina);
Stamina singleColourStamina = (Stamina)skills.Last(x => x is Stamina);
- double colourRating = colour.DifficultyValue() * colour_skill_multiplier;
double rhythmRating = rhythm.DifficultyValue() * rhythm_skill_multiplier;
+ double readingRating = reading.DifficultyValue() * reading_skill_multiplier;
+ double colourRating = colour.DifficultyValue() * colour_skill_multiplier;
double staminaRating = stamina.DifficultyValue() * stamina_skill_multiplier;
double monoStaminaRating = singleColourStamina.DifficultyValue() * stamina_skill_multiplier;
double monoStaminaFactor = staminaRating == 0 ? 1 : Math.Pow(monoStaminaRating / staminaRating, 5);
- double combinedRating = combinedDifficultyValue(rhythm, colour, stamina);
+ double colourDifficultStrains = colour.CountTopWeightedStrains();
+ double readingDifficultStrains = reading.CountTopWeightedStrains();
+ double staminaDifficultStrains = stamina.CountTopWeightedStrains();
+
+ double combinedRating = combinedDifficultyValue(rhythm, reading, colour, stamina, isRelax);
double starRating = rescale(combinedRating * 1.4);
- // TODO: This is temporary measure as we don't detect abuse of multiple-input playstyles of converts within the current system.
+ // 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.925;
- // For maps with low colour variance and high stamina requirement, multiple inputs are more likely to be abused.
- if (colourRating < 2 && staminaRating > 8)
- starRating *= 0.80;
+ starRating *= 0.825;
+
+ // For maps with relax, multiple inputs are more likely to be abused.
+ if (isRelax)
+ starRating *= 0.60;
}
HitWindows hitWindows = new TaikoHitWindows();
@@ -107,11 +139,14 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
{
StarRating = starRating,
Mods = mods,
+ RhythmDifficulty = rhythmRating,
+ ReadingDifficulty = readingRating,
+ ColourDifficulty = colourRating,
StaminaDifficulty = staminaRating,
MonoStaminaFactor = monoStaminaFactor,
- RhythmDifficulty = rhythmRating,
- ColourDifficulty = colourRating,
- PeakDifficulty = combinedRating,
+ ReadingTopStrains = readingDifficultStrains,
+ ColourTopStrains = colourDifficultStrains,
+ StaminaTopStrains = staminaDifficultStrains,
GreatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate,
OkHitWindow = hitWindows.WindowFor(HitResult.Ok) / clockRate,
MaxCombo = beatmap.GetMaxCombo(),
@@ -120,17 +155,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
return attributes;
}
- ///
- /// Applies a final re-scaling of the star rating.
- ///
- /// The raw star rating value before re-scaling.
- private double rescale(double sr)
- {
- if (sr < 0) return sr;
-
- return 10.43 * Math.Log(sr / 8 + 1);
- }
-
///
/// Returns the combined star rating of the beatmap, calculated using peak strains from all sections of the map.
///
@@ -138,22 +162,29 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
/// For each section, the peak strains of all separate skills are combined into a single peak strain for the section.
/// The resulting partial rating of the beatmap is a weighted sum of the combined peaks (higher peaks are weighted more).
///
- private double combinedDifficultyValue(Rhythm rhythm, Colour colour, Stamina stamina)
+ private double combinedDifficultyValue(Rhythm rhythm, Reading reading, Colour colour, Stamina stamina, bool isRelax)
{
List peaks = new List();
- var colourPeaks = colour.GetCurrentStrainPeaks().ToList();
var rhythmPeaks = rhythm.GetCurrentStrainPeaks().ToList();
+ var readingPeaks = reading.GetCurrentStrainPeaks().ToList();
+ var colourPeaks = colour.GetCurrentStrainPeaks().ToList();
var staminaPeaks = stamina.GetCurrentStrainPeaks().ToList();
for (int i = 0; i < colourPeaks.Count; i++)
{
- double colourPeak = colourPeaks[i] * colour_skill_multiplier;
double rhythmPeak = rhythmPeaks[i] * rhythm_skill_multiplier;
+ double readingPeak = readingPeaks[i] * reading_skill_multiplier;
+ double colourPeak = colourPeaks[i] * colour_skill_multiplier;
double staminaPeak = staminaPeaks[i] * stamina_skill_multiplier;
- double peak = norm(1.5, colourPeak, staminaPeak);
- peak = norm(2, peak, rhythmPeak);
+ if (isRelax)
+ {
+ colourPeak = 0; // There is no colour difficulty in relax.
+ staminaPeak /= 1.5; // Stamina difficulty is decreased with an increased available finger count.
+ }
+
+ double peak = DifficultyCalculationUtils.Norm(2, DifficultyCalculationUtils.Norm(1.5, colourPeak, staminaPeak), rhythmPeak, readingPeak);
// Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
// These sections will not contribute to the difficulty.
@@ -174,10 +205,14 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
}
///
- /// Returns the p-norm of an n-dimensional vector.
+ /// Applies a final re-scaling of the star rating.
///
- /// The value of p to calculate the norm for.
- /// The coefficients of the vector.
- private double norm(double p, params double[] values) => Math.Pow(values.Sum(x => Math.Pow(x, p)), 1 / p);
+ /// The raw star rating value before re-scaling.
+ private double rescale(double sr)
+ {
+ if (sr < 0) return sr;
+
+ return 10.43 * Math.Log(sr / 8 + 1);
+ }
}
}
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs
index c672b7a1d9..5da18e7963 100644
--- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs
+++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs
@@ -73,7 +73,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes)
{
- double difficultyValue = Math.Pow(5 * Math.Max(1.0, attributes.StarRating / 0.115) - 4.0, 2.25) / 1150.0;
+ 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 lengthBonus = 1 + 0.1 * Math.Min(1.0, totalHits / 1500.0);
difficultyValue *= lengthBonus;
@@ -86,9 +87,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
if (score.Mods.Any(m => m is ModHidden))
difficultyValue *= 1.025;
- if (score.Mods.Any(m => m is ModHardRock))
- difficultyValue *= 1.10;
-
if (score.Mods.Any(m => m is ModFlashlight))
difficultyValue *= Math.Max(1, 1.050 - Math.Min(attributes.MonoStaminaFactor / 50, 1) * lengthBonus);
@@ -97,7 +95,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 = 300 - 100 * attributes.MonoStaminaFactor;
+ double accScalingShift = 400 - 100 * attributes.MonoStaminaFactor;
return difficultyValue * Math.Pow(SpecialFunctions.Erf(accScalingShift / (Math.Sqrt(2) * estimatedUnstableRate.Value)), accScalingExponent);
}
@@ -133,6 +131,11 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
const double z = 2.32634787404; // 99% critical value for the normal distribution (one-tailed).
+ double? deviationGreatWindow = calcDeviationGreatWindow();
+ double? deviationGoodWindow = calcDeviationGoodWindow();
+
+ return deviationGreatWindow is null ? deviationGoodWindow : Math.Min(deviationGreatWindow.Value, deviationGoodWindow!.Value);
+
// The upper bound on deviation, calculated with the ratio of 300s to objects, and the great hit window.
double? calcDeviationGreatWindow()
{
@@ -159,7 +162,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
double n = totalHits;
// Proportion of greats + goods hit.
- double p = totalSuccessfulHits / n;
+ 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);
@@ -167,14 +170,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
// We can be 99% confident that the deviation is not higher than:
return h100 / (Math.Sqrt(2) * SpecialFunctions.ErfInv(pLowerBound));
}
-
- double? deviationGreatWindow = calcDeviationGreatWindow();
- double? deviationGoodWindow = calcDeviationGoodWindow();
-
- if (deviationGreatWindow is null)
- return deviationGoodWindow;
-
- return Math.Min(deviationGreatWindow.Value, deviationGoodWindow!.Value);
}
private int totalHits => countGreat + countOk + countMeh + countMiss;
diff --git a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs
index 7b6bc37a61..1d6cee043b 100644
--- a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs
+++ b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs
@@ -30,6 +30,8 @@ namespace osu.Game.Rulesets.Difficulty
protected const int ATTRIB_ID_AIM_DIFFICULT_STRAIN_COUNT = 25;
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;
///
/// The mods which were applied to the beatmap.
diff --git a/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs b/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs
index b9efcd683d..aeccf2fd55 100644
--- a/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs
+++ b/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs
@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System;
+using System.Linq;
namespace osu.Game.Rulesets.Difficulty.Utils
{
@@ -46,5 +47,60 @@ namespace osu.Game.Rulesets.Difficulty.Utils
/// Exponent
/// The output of logistic function
public static double Logistic(double exponent, double maxValue = 1) => maxValue / (1 + Math.Exp(exponent));
+
+ ///
+ /// Returns the p-norm of an n-dimensional vector (https://en.wikipedia.org/wiki/Norm_(mathematics))
+ ///
+ /// The value of p to calculate the norm for.
+ /// The coefficients of the vector.
+ /// The p-norm of the vector.
+ public static double Norm(double p, params double[] values) => Math.Pow(values.Sum(x => Math.Pow(x, p)), 1 / p);
+
+ ///
+ /// Calculates a Gaussian-based bell curve function (https://en.wikipedia.org/wiki/Gaussian_function)
+ ///
+ /// Value to calculate the function for
+ /// The mean (center) of the bell curve
+ /// The width (spread) of the curve
+ /// Multiplier to adjust the curve's height
+ /// The output of the bell curve function of
+ 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)));
+
+ ///
+ /// Smoothstep function (https://en.wikipedia.org/wiki/Smoothstep)
+ ///
+ /// Value to calculate the function for
+ /// Value at which function returns 0
+ /// Value at which function returns 1
+ 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);
+ }
+
+ ///
+ /// Smootherstep function (https://en.wikipedia.org/wiki/Smoothstep#Variations)
+ ///
+ /// Value to calculate the function for
+ /// Value at which function returns 0
+ /// Value at which function returns 1
+ public static double Smootherstep(double x, double start, double end)
+ {
+ x = Math.Clamp((x - start) / (end - start), 0.0, 1.0);
+
+ return x * x * x * (x * (6.0 * x - 15.0) + 10.0);
+ }
+
+ ///
+ /// Reverse linear interpolation function (https://en.wikipedia.org/wiki/Linear_interpolation)
+ ///
+ /// Value to calculate the function for
+ /// Value at which function returns 0
+ /// Value at which function returns 1
+ public static double ReverseLerp(double x, double start, double end)
+ {
+ return Math.Clamp((x - start) / (end - start), 0.0, 1.0);
+ }
}
}