// 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.Beatmaps; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mods; 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.026; public override int Version => 20241007; public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) : base(ruleset, beatmap) { } 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 }; 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; var aim = (Aim)skills.Single(s => s is Aim aimSkill && aimSkill.WithSliders); var aimNoSliders = (Aim)skills.Single(s => s is Aim aimSkill && !aimSkill.WithSliders); var speed = (Speed)skills.Single(s => s is Speed); var flashlight = (Flashlight?)skills.SingleOrDefault(s => s is Flashlight); double speedNotes = speed.RelevantNoteCount(); double aimDifficultyStrainCount = aim.CountTopWeightedStrains(); double speedDifficultyStrainCount = speed.CountTopWeightedStrains(); double difficultSliders = aim.GetDifficultSliders(); double aimRating = computeAimRating(aim.DifficultyValue(), mods, totalHits, approachRate, overallDifficulty); double aimRatingNoSliders = computeAimRating(aimNoSliders.DifficultyValue(), mods, totalHits, approachRate, overallDifficulty); double speedRating = computeSpeedRating(speed.DifficultyValue(), mods, totalHits, approachRate); double flashlightRating = 0.0; if (flashlight is not null) flashlightRating = computeFlashlightRating(flashlight.DifficultyValue(), mods, totalHits, overallDifficulty); double sliderFactor = aimRating > 0 ? aimRatingNoSliders / aimRating : 1; double baseAimPerformance = OsuStrainSkill.DifficultyToPerformance(aimRating); double baseSpeedPerformance = OsuStrainSkill.DifficultyToPerformance(speedRating); double baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating); double basePerformance = Math.Pow( Math.Pow(baseAimPerformance, 1.1) + Math.Pow(baseSpeedPerformance, 1.1) + Math.Pow(baseFlashlightPerformance, 1.1), 1.0 / 1.1 ); double multiplier = CalculateDifficultyMultiplier(mods, totalHits, spinnerCount); double starRating = basePerformance > 0.00001 ? Math.Cbrt(multiplier) * star_rating_multiplier * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4) : 0; OsuDifficultyAttributes attributes = new OsuDifficultyAttributes { StarRating = starRating, Mods = mods, AimDifficulty = aimRating, AimDifficultSliderCount = difficultSliders, SpeedDifficulty = speedRating, SpeedNoteCount = speedNotes, FlashlightDifficulty = flashlightRating, SliderFactor = sliderFactor, AimDifficultStrainCount = aimDifficultyStrainCount, SpeedDifficultStrainCount = speedDifficultyStrainCount, ApproachRate = approachRate, OverallDifficulty = overallDifficulty, DrainRate = drainRate, MaxCombo = beatmap.GetMaxCombo(), HitCircleCount = hitCircleCount, SliderCount = sliderCount, SpinnerCount = spinnerCount, }; 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 lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); ratingMultiplier *= lengthBonus; 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 * lengthBonus; // Buff for longer maps with high AR. if (mods.Any(m => m is OsuModHidden || 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. ratingMultiplier *= 1.0 + 0.04 * (12.0 - approachRate); } // It is important to consider accuracy difficulty when scaling with accuracy. ratingMultiplier *= 0.98 + Math.Pow(overallDifficulty, 2) / 2500; return aimRating * Math.Cbrt(ratingMultiplier); } private double computeSpeedRating(double speedDifficultyValue, Mod[] mods, int totalHits, double approachRate) { 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 lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); ratingMultiplier *= lengthBonus; 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 * lengthBonus; // Buff for longer maps with high AR. if (mods.Any(m => m is OsuModBlinds)) { // Increasing the speed value by object count for Blinds isn't ideal, so the minimum buff is given. ratingMultiplier *= 1.12; } else if (mods.Any(m => m is OsuModHidden || 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. ratingMultiplier *= 1.0 + 0.04 * (12.0 - approachRate); } 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(overallDifficulty, 2) / 2500; return flashlightRating * Math.Cbrt(ratingMultiplier); } protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) { List objects = new List(); // The first jump is formed by the first two hitobjects of the map. // If the map has less than two OsuHitObjects, the enumerator will not return anything. for (int i = 1; i < beatmap.HitObjects.Count; i++) { var lastLast = i > 1 ? beatmap.HitObjects[i - 2] : null; objects.Add(new OsuDifficultyHitObject(beatmap.HitObjects[i], beatmap.HitObjects[i - 1], lastLast, clockRate, objects, objects.Count)); } return objects; } protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) { var skills = new List { new Aim(mods, true), new Aim(mods, false), new Speed(mods) }; if (mods.Any(h => h is OsuModFlashlight)) skills.Add(new Flashlight(mods)); return skills.ToArray(); } protected override Mod[] DifficultyAdjustmentMods => new Mod[] { new OsuModTouchDevice(), new OsuModDoubleTime(), new OsuModHalfTime(), new OsuModEasy(), new OsuModHardRock(), new OsuModFlashlight(), new MultiMod(new OsuModFlashlight(), new OsuModHidden()) }; } }