diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 5da6df236e..a5071f0441 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; @@ -15,12 +13,16 @@ using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Difficulty.Skills; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Scoring; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Difficulty { public class OsuDifficultyCalculator : DifficultyCalculator { + private const double performance_base_multiplier = 1.15; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. private const double difficulty_multiplier = 0.0675; + private const double star_rating_multiplier = 0.0265; public override int Version => 20250306; @@ -29,53 +31,65 @@ namespace osu.Game.Rulesets.Osu.Difficulty { } + public static double CalculateDifficultyMultiplier(Mod[] mods, int totalHits, int spinnerCount) + { + double multiplier = performance_base_multiplier; + + if (mods.Any(m => m is OsuModSpunOut) && totalHits > 0) + multiplier *= 1.0 - Math.Pow((double)spinnerCount / totalHits, 0.85); + + return multiplier; + } + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) { if (beatmap.HitObjects.Count == 0) return new OsuDifficultyAttributes { Mods = mods }; var aim = skills.OfType().Single(a => a.IncludeSliders); - double aimRating = Math.Sqrt(aim.DifficultyValue()) * difficulty_multiplier; - double aimDifficultyStrainCount = aim.CountTopWeightedStrains(); + var aimWithoutSliders = skills.OfType().Single(a => !a.IncludeSliders); + var speed = skills.OfType().Single(); + var flashlight = skills.OfType().SingleOrDefault(); + + double speedNotes = speed.RelevantNoteCount(); + + double aimDifficultStrainCount = aim.CountTopWeightedStrains(); + double speedDifficultStrainCount = speed.CountTopWeightedStrains(); + double difficultSliders = aim.GetDifficultSliders(); - var aimWithoutSliders = skills.OfType().Single(a => !a.IncludeSliders); - double aimRatingNoSliders = Math.Sqrt(aimWithoutSliders.DifficultyValue()) * difficulty_multiplier; + double preempt = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450) / clockRate; + double approachRate = preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5; + + HitWindows hitWindows = new OsuHitWindows(); + hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); + + double hitWindowGreat = hitWindows.WindowFor(HitResult.Great) / clockRate; + + double overallDifficulty = (80 - hitWindowGreat) / 6; + + int hitCircleCount = beatmap.HitObjects.Count(h => h is HitCircle); + int sliderCount = beatmap.HitObjects.Count(h => h is Slider); + int spinnerCount = beatmap.HitObjects.Count(h => h is Spinner); + + int totalHits = beatmap.HitObjects.Count; + + double drainRate = beatmap.Difficulty.DrainRate; + + double aimRating = computeAimRating(aim.DifficultyValue(), mods, totalHits, approachRate, overallDifficulty); + double aimRatingNoSliders = computeAimRating(aimWithoutSliders.DifficultyValue(), mods, totalHits, approachRate, overallDifficulty); + double speedRating = computeSpeedRating(speed.DifficultyValue(), mods, totalHits, approachRate, overallDifficulty); + + double flashlightRating = 0.0; + + if (flashlight is not null) + flashlightRating = computeFlashlightRating(flashlight.DifficultyValue(), mods, totalHits, overallDifficulty); + double sliderFactor = aimRating > 0 ? aimRatingNoSliders / aimRating : 1; - var speed = skills.OfType().Single(); - double speedRating = Math.Sqrt(speed.DifficultyValue()) * difficulty_multiplier; - double speedNotes = speed.RelevantNoteCount(); - double speedDifficultyStrainCount = speed.CountTopWeightedStrains(); - - var flashlight = skills.OfType().SingleOrDefault(); - double flashlightRating = flashlight == null ? 0.0 : Math.Sqrt(flashlight.DifficultyValue()) * difficulty_multiplier; - - if (mods.Any(m => m is OsuModTouchDevice)) - { - aimRating = Math.Pow(aimRating, 0.8); - flashlightRating = Math.Pow(flashlightRating, 0.8); - } - - if (mods.Any(h => h is OsuModRelax)) - { - aimRating *= 0.9; - 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); - double baseFlashlightPerformance = 0.0; - - if (mods.Any(h => h is OsuModFlashlight)) - baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating); + double baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating); double basePerformance = Math.Pow( @@ -84,16 +98,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty Math.Pow(baseFlashlightPerformance, 1.1), 1.0 / 1.1 ); + double multiplier = CalculateDifficultyMultiplier(mods, totalHits, spinnerCount); + double starRating = basePerformance > 0.00001 - ? Math.Cbrt(OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER) * 0.027 * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4) + ? Math.Cbrt(multiplier) * star_rating_multiplier * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4) : 0; - double drainRate = beatmap.Difficulty.DrainRate; - - int hitCirclesCount = beatmap.HitObjects.Count(h => h is HitCircle); - int sliderCount = beatmap.HitObjects.Count(h => h is Slider); - int spinnerCount = beatmap.HitObjects.Count(h => h is Spinner); - OsuDifficultyAttributes attributes = new OsuDifficultyAttributes { StarRating = starRating, @@ -104,11 +114,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty SpeedNoteCount = speedNotes, FlashlightDifficulty = flashlightRating, SliderFactor = sliderFactor, - AimDifficultStrainCount = aimDifficultyStrainCount, - SpeedDifficultStrainCount = speedDifficultyStrainCount, + AimDifficultStrainCount = aimDifficultStrainCount, + SpeedDifficultStrainCount = speedDifficultStrainCount, DrainRate = drainRate, MaxCombo = beatmap.GetMaxCombo(), - HitCircleCount = hitCirclesCount, + HitCircleCount = hitCircleCount, SliderCount = sliderCount, SpinnerCount = spinnerCount, }; @@ -116,6 +126,109 @@ namespace osu.Game.Rulesets.Osu.Difficulty return attributes; } + private double computeAimRating(double aimDifficultyValue, Mod[] mods, int totalHits, double approachRate, double overallDifficulty) + { + if (mods.Any(m => m is OsuModAutopilot)) + return 0; + + double aimRating = Math.Sqrt(aimDifficultyValue) * difficulty_multiplier; + + if (mods.Any(m => m is OsuModTouchDevice)) + aimRating = Math.Pow(aimRating, 0.8); + + if (mods.Any(m => m is OsuModRelax)) + aimRating *= 0.9; + + double ratingMultiplier = 1.0; + + double approachRateLengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + + (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); + + double approachRateFactor = 0.0; + if (approachRate > 10.33) + approachRateFactor = 0.3 * (approachRate - 10.33); + else if (approachRate < 8.0) + approachRateFactor = 0.05 * (8.0 - approachRate); + + if (mods.Any(h => h is OsuModRelax)) + approachRateFactor = 0.0; + + ratingMultiplier *= 1.0 + approachRateFactor * approachRateLengthBonus; // Buff for longer maps with high AR. + + if (mods.Any(m => m is OsuModHidden)) + { + // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. + ratingMultiplier *= 1.0 + 0.04 * (12.0 - approachRate); + } + + // It is important to consider accuracy difficulty when scaling with accuracy. + ratingMultiplier *= 0.98 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 2500; + + return aimRating * Math.Cbrt(ratingMultiplier); + } + + private double computeSpeedRating(double speedDifficultyValue, Mod[] mods, int totalHits, double approachRate, double overallDifficulty) + { + if (mods.Any(m => m is OsuModRelax)) + return 0; + + double speedRating = Math.Sqrt(speedDifficultyValue) * difficulty_multiplier; + + if (mods.Any(m => m is OsuModAutopilot)) + speedRating *= 0.5; + + double ratingMultiplier = 1.0; + + double approachRateLengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + + (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); + + double approachRateFactor = 0.0; + if (approachRate > 10.33) + approachRateFactor = 0.3 * (approachRate - 10.33); + + if (mods.Any(m => m is OsuModAutopilot)) + approachRateFactor = 0.0; + + ratingMultiplier *= 1.0 + approachRateFactor * approachRateLengthBonus; // Buff for longer maps with high AR. + + if (mods.Any(m => m is OsuModHidden)) + { + // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. + ratingMultiplier *= 1.0 + 0.04 * (12.0 - approachRate); + } + + ratingMultiplier *= 0.95 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 750; + + return speedRating * Math.Cbrt(ratingMultiplier); + } + + private double computeFlashlightRating(double flashlightDifficultyValue, Mod[] mods, int totalHits, double overallDifficulty) + { + if (!mods.Any(m => m is OsuModFlashlight)) + return 0; + + double flashlightRating = Math.Sqrt(flashlightDifficultyValue) * difficulty_multiplier; + + if (mods.Any(m => m is OsuModTouchDevice)) + flashlightRating = Math.Pow(flashlightRating, 0.8); + + if (mods.Any(m => m is OsuModRelax)) + flashlightRating *= 0.7; + else if (mods.Any(m => m is OsuModAutopilot)) + flashlightRating *= 0.4; + + double ratingMultiplier = 1.0; + + // Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius. + ratingMultiplier *= 0.7 + 0.1 * Math.Min(1.0, totalHits / 200.0) + + (totalHits > 200 ? 0.2 * Math.Min(1.0, (totalHits - 200) / 200.0) : 0.0); + + // It is important to consider accuracy difficulty when scaling with accuracy. + ratingMultiplier *= 0.98 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 2500; + + return flashlightRating * Math.Sqrt(ratingMultiplier); + } + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) { List objects = new List(); @@ -153,7 +266,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty new OsuModEasy(), new OsuModHardRock(), new OsuModFlashlight(), - new MultiMod(new OsuModFlashlight(), new OsuModHidden()) + new OsuModHidden(), + new OsuModSpunOut(), }; } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 7e2d68b9d8..3ff6af9b0b 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -21,8 +21,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty { public class OsuPerformanceCalculator : PerformanceCalculator { - public const double PERFORMANCE_BASE_MULTIPLIER = 1.15; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. - private bool usingClassicSliderAccuracy; private double accuracy; @@ -126,14 +124,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty effectiveMissCount = Math.Max(countMiss, effectiveMissCount); effectiveMissCount = Math.Min(totalHits, effectiveMissCount); - double multiplier = PERFORMANCE_BASE_MULTIPLIER; + double multiplier = OsuDifficultyCalculator.CalculateDifficultyMultiplier(score.Mods, totalHits, osuAttributes.SpinnerCount); if (score.Mods.Any(m => m is OsuModNoFail)) multiplier *= Math.Max(0.90, 1.0 - 0.02 * effectiveMissCount); - if (score.Mods.Any(m => m is OsuModSpunOut) && totalHits > 0) - multiplier *= 1.0 - Math.Pow((double)osuAttributes.SpinnerCount / totalHits, 0.85); - if (score.Mods.Any(h => h is OsuModRelax)) { // https://www.desmos.com/calculator/vspzsop6td @@ -210,28 +205,16 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (effectiveMissCount > 0) aimValue *= calculateMissPenalty(effectiveMissCount, attributes.AimDifficultStrainCount); - double approachRateFactor = 0.0; - if (approachRate > 10.33) - approachRateFactor = 0.3 * (approachRate - 10.33); - else if (approachRate < 8.0) - approachRateFactor = 0.05 * (8.0 - approachRate); - - if (score.Mods.Any(h => h is OsuModRelax)) - approachRateFactor = 0.0; - - aimValue *= 1.0 + approachRateFactor * lengthBonus; // Buff for longer maps with high AR. - + // TC bonuses are excluded when blinds is present as the increased visual difficulty is unimportant when notes cannot be seen. 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)) + else if (score.Mods.Any(m => m is OsuModTraceable)) { // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. aimValue *= 1.0 + 0.04 * (12.0 - approachRate); } aimValue *= accuracy; - // It is important to consider accuracy difficulty when scaling with accuracy. - aimValue *= 0.98 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 2500; return aimValue; } @@ -250,21 +233,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (effectiveMissCount > 0) speedValue *= calculateMissPenalty(effectiveMissCount, attributes.SpeedDifficultStrainCount); - double approachRateFactor = 0.0; - if (approachRate > 10.33) - approachRateFactor = 0.3 * (approachRate - 10.33); - - if (score.Mods.Any(h => h is OsuModAutopilot)) - approachRateFactor = 0.0; - - speedValue *= 1.0 + approachRateFactor * lengthBonus; // Buff for longer maps with high AR. - + // TC bonuses are excluded when blinds is present as the increased visual difficulty is unimportant when notes cannot be seen. 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. speedValue *= 1.12; } - else if (score.Mods.Any(m => m is OsuModHidden || m is OsuModTraceable)) + else if (score.Mods.Any(m => m is OsuModTraceable)) { // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. speedValue *= 1.0 + 0.04 * (12.0 - approachRate); @@ -281,7 +256,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(Math.Max(0, overallDifficulty), 2) / 750) * Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - overallDifficulty) / 2); + speedValue *= Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - overallDifficulty) / 2); return speedValue; } @@ -338,14 +313,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty flashlightValue *= getComboScalingFactor(attributes); - // Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius. - flashlightValue *= 0.7 + 0.1 * Math.Min(1.0, totalHits / 200.0) + - (totalHits > 200 ? 0.2 * Math.Min(1.0, (totalHits - 200) / 200.0) : 0.0); - // 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(Math.Max(0, overallDifficulty), 2) / 2500; return flashlightValue; }