diff --git a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs index c0a6d3a755..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.718709884850683d, 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(9.6343245007055653d, 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.718709884850683d, 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 fdf94719ed..e279ed889a 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs @@ -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.1 + 0.9 * (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)); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs index e5e9769081..769220ece0 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs @@ -18,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: diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs index b46f5258bd..f41413b9bb 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs @@ -78,21 +78,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. /// @@ -123,6 +135,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); @@ -133,6 +146,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 values, IBeatmapOnlineInfo onlineInfo) @@ -144,12 +160,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 78a1c95066..1f5334d198 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -105,6 +105,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 { @@ -122,6 +124,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty SpeedTopWeightedSliderFactor = speedTopWeightedSliderFactor, 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 8f09147c9f..462c47c420 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -10,6 +10,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 { @@ -41,6 +42,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty /// private double effectiveMissCount; + private double? speedDeviation; + public OsuPerformanceCalculator() : base(new OsuRuleset()) { @@ -111,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) + @@ -130,6 +136,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty Accuracy = accuracyValue, Flashlight = flashlightValue, EffectiveMissCount = effectiveMissCount, + SpeedDeviation = speedDeviation, Total = totalValue }; } @@ -195,14 +202,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); @@ -237,6 +244,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); @@ -245,10 +255,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; } @@ -312,11 +319,113 @@ 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. @@ -334,6 +443,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty } 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/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index 10d5c1e27e..240a360cb9 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -16,7 +16,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// public class Speed : OsuStrainSkill { - private double skillMultiplier => 1.430; + private double skillMultiplier => 1.46; private double strainDecayBase => 0.3; private double currentStrain; diff --git a/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs index ba247c68d4..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.0950934814938953d, 200, "diffcalc-test")] - [TestCase(3.0950934814938953d, 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.0839365008715403d, 200, "diffcalc-test")] - [TestCase(4.0839365008715403d, 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/Difficulty/DifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs index f5ed5a180b..1d6cee043b 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs @@ -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; /// /// 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 497a1f8234..aeccf2fd55 100644 --- a/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs +++ b/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs @@ -66,6 +66,19 @@ namespace osu.Game.Rulesets.Difficulty.Utils /// 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) ///