diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs new file mode 100644 index 000000000000..03a81dfbf548 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/ReadingEvaluator.cs @@ -0,0 +1,224 @@ +// 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; +using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators +{ + // Main class with some util functions + public static class ReadingEvaluator + { + private const double reading_window_size = 3000; + + private const double overlap_multiplier = 1; + + private const double slider_body_length_multiplier = 1.3; + + public static double EvaluateDensityOf(DifficultyHitObject current, bool applyDistanceNerf = true, bool applySliderbodyDensity = true, double angleNerfMultiplier = 1.0) + { + var currObj = (OsuDifficultyHitObject)current; + + double density = 0; + double densityAnglesNerf = -2; // we have threshold of 2 + + // Despite being called prev, it's actually more late in time + OsuDifficultyHitObject prevObj0 = currObj; + + var readingObjects = currObj.ReadingObjects; + + for (int i = 0; i < readingObjects.Count; i++) + { + var loopObj = readingObjects[i].HitObject; + + if (loopObj.Index < 1) + continue; // Don't look on the first object of the map + + double loopDifficulty = currObj.OpacityAt(loopObj.BaseObject.StartTime, false); + + // Small distances means objects may be cheesed, so it doesn't matter whether they are arranged confusingly + if (applyDistanceNerf) loopDifficulty *= (DifficultyCalculationUtils.Logistic(-(loopObj.LazyJumpDistance - 80) / 10) + 0.2) / 1.2; + + // Additional buff for long sliderbodies + if (applySliderbodyDensity && loopObj.BaseObject is Slider slider) + { + // In radiuses, with minimal of 1 + double sliderBodyLength = Math.Max(1, slider.Velocity * slider.SpanDuration / slider.Radius); + + // Bandaid to fix abuze + sliderBodyLength = Math.Min(sliderBodyLength, 1 + slider.LazyTravelDistance / 8); + + // The maximum is 3x buff + double sliderBodyBuff = Math.Log10(sliderBodyLength); + + // Limit the max buff to prevent abuse with very long sliders. + // With explicit coverage of cases like one very long slider on the map, or just very few objects visible before/after. + double maxBuff = 0.5; + if (i > 0) maxBuff += 1; + if (i < readingObjects.Count - 1) maxBuff += 1; + + loopDifficulty *= 1 + slider_body_length_multiplier * Math.Min(sliderBodyBuff, maxBuff); + } + + // Reduce density bonus for this object if they're too apart in time + // Nerf starts on 1500ms and reaches maximum (*=0) on 3000ms + double timeBetweenCurrAndLoopObj = currObj.StartTime - loopObj.StartTime; + loopDifficulty *= getTimeNerfFactor(timeBetweenCurrAndLoopObj); + + // Only if next object is slower, representing break from many notes in a row + if (loopObj.StrainTime > prevObj0.StrainTime) + { + // Get rhythm similarity: 1 on same rhythms, 0.5 on 1/4 to 1/2 + double rhythmSimilarity = DifficultyCalculationUtils.GetRatio(loopObj.StrainTime, prevObj0.StrainTime); + + // Make differentiation going from 1/4 to 1/2 and bigger difference + // To 1/3 to 1/2 and smaller difference + rhythmSimilarity = Math.Clamp(rhythmSimilarity, 0.5, 0.75); + rhythmSimilarity = 4 * (rhythmSimilarity - 0.5); + + // Reduce density for this objects if rhythms are different + loopDifficulty *= rhythmSimilarity; + } + + density += loopDifficulty; + + // Angles nerf + // Why it's /2 + 0.5? + // Because there was a bug initially that made angle predictability to be from 0.5 to 1 + // And removing this bug caused balance to be destroyed + double angleNerf = (loopObj.AnglePredictability / 2) + 0.5; + + densityAnglesNerf += angleNerf * loopDifficulty * angleNerfMultiplier; + + prevObj0 = loopObj; + } + + // Apply angles nerf + density -= Math.Max(0, densityAnglesNerf); + return density; + } + + public static double EvaluateOverlapDifficultyOf(DifficultyHitObject current) + { + var currObj = (OsuDifficultyHitObject)current; + double screenOverlapDifficulty = 0; + + if (currObj.ReadingObjects.Count == 0) + return 0; + + var overlapDifficulties = new List<(OsuDifficultyHitObject HitObject, double Difficulty)>(); + var readingObjects = currObj.ReadingObjects; + + // Find initial overlap values + for (int i = 0; i < readingObjects.Count; i++) + { + var loopObj = readingObjects[i].HitObject; + var loopReadingObjects = (List)loopObj.ReadingObjects; + + if (loopReadingObjects.Count == 0) + continue; + + double targetStartTime = currObj.StartTime - currObj.Preempt; + double overlapness = boundBinarySearch(loopReadingObjects, targetStartTime); + + if (overlapness > 0) overlapDifficulties.Add((loopObj, overlapness)); + } + + if (overlapDifficulties.Count == 0) + return 0; + + var sortedDifficulties = overlapDifficulties.OrderByDescending(d => d.Difficulty).ToList(); + + // Nerf overlap values of easier notes that are in the same place as hard notes + for (int i = 0; i < sortedDifficulties.Count; i++) + { + var harderObject = sortedDifficulties[i]; + + // Look for all easier objects + for (int j = i + 1; j < sortedDifficulties.Count; j++) + { + var easierObject = sortedDifficulties[j]; + + // Get the overlap value + double overlapValue; + + // OverlapValues dict only contains prev objects, so be sure to use right object + if (harderObject.HitObject.Index > easierObject.HitObject.Index) + harderObject.HitObject.OverlapValues.TryGetValue(easierObject.HitObject.Index, out overlapValue); + else + easierObject.HitObject.OverlapValues.TryGetValue(harderObject.HitObject.Index, out overlapValue); + + // Nerf easier object if it overlaps in the same place as hard one + easierObject.Difficulty *= Math.Pow(1 - overlapValue, 2); + } + } + + const double decay_weight = 0.5; + const double threshold = 0.6; + double weight = 1.0; + + // Sum the overlap values to get difficulty + foreach (var diffObject in sortedDifficulties.Where(d => d.Difficulty > threshold).OrderByDescending(d => d.Difficulty)) + { + // Add weighted difficulty + screenOverlapDifficulty += Math.Max(0, diffObject.Difficulty - threshold) * weight; + weight *= decay_weight; + } + + return overlap_multiplier * Math.Max(0, screenOverlapDifficulty); + } + + public static double EvaluateDifficultyOf(DifficultyHitObject current) + { + if (current.BaseObject is Spinner || current.Index == 0) + return 0; + + double difficulty = Math.Pow(4 * Math.Log(Math.Max(1, EvaluateDensityOf(current, true, true))), 2.5); + + double overlapBonus = EvaluateOverlapDifficultyOf(current) * difficulty; + difficulty += overlapBonus; + + return difficulty; + } + + public static double EvaluateAimingDensityFactorOf(DifficultyHitObject current) + { + double difficulty = EvaluateDensityOf(current, true, false, 0.5); + + return Math.Max(0, Math.Pow(difficulty, 1.37) - 1); + } + + // This factor nerfs AR below 0 as extra safety measure + private static double getTimeNerfFactor(double deltaTime) => Math.Clamp(2 - deltaTime / (reading_window_size / 2), 0, 1); + + // Finds the overlapness of the last object for which StartTime lower than target + private static double boundBinarySearch(List arr, double target) + { + int low = 0; + int high = arr.Count; + + int result = -1; + + while (low < high) + { + int mid = low + (high - low) / 2; + + if (arr[mid].HitObject.StartTime >= target) + { + result = mid; + low = mid + 1; + } + else high = mid - 1; + } + + if (result == -1) return 0; + + return arr[result].Overlapness; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs index 395f581b6539..e20b52ea2bea 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs @@ -39,6 +39,12 @@ public class OsuDifficultyAttributes : DifficultyAttributes [JsonProperty("speed_note_count")] public double SpeedNoteCount { get; set; } + /// + /// The difficulty corresponding to the reading skill. Low AR branch. + /// + [JsonProperty("reading_low_ar_difficulty")] + public double ReadingDifficultyLowAr { get; set; } + /// /// The difficulty corresponding to the flashlight skill. /// @@ -59,6 +65,9 @@ public class OsuDifficultyAttributes : DifficultyAttributes [JsonProperty("speed_difficult_strain_count")] public double SpeedDifficultStrainCount { get; set; } + [JsonProperty("low_ar_difficult_strain_count")] + public double LowArDifficultStrainCount { get; set; } + /// /// The perceived approach rate inclusive of rate-adjusting mods (DT/HT/etc). /// @@ -119,10 +128,9 @@ public class OsuDifficultyAttributes : DifficultyAttributes 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); + yield return (ATTRIB_ID_FLASHLIGHT, FlashlightDifficulty); yield return (ATTRIB_ID_SLIDER_FACTOR, SliderFactor); diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 1505c5159251..c11d9efa4603 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -23,7 +23,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty public class OsuDifficultyCalculator : DifficultyCalculator { private const double difficulty_multiplier = 0.0675; - + public const double SUM_POWER = 1.1; + public const double FL_SUM_POWER = 1.5; public override int Version => 20241007; public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) @@ -44,19 +45,23 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat var aimWithoutSliders = skills.OfType().Single(a => !a.IncludeSliders); double aimRatingNoSliders = Math.Sqrt(aimWithoutSliders.DifficultyValue()) * difficulty_multiplier; 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; + var readingLowAr = skills.OfType().Single(); + double readingLowArRating = Math.Sqrt(readingLowAr.DifficultyValue()) * difficulty_multiplier; + double lowArDifficultyStrainCount = readingLowAr.CountTopWeightedStrains(); + + double flashlightRating = Math.Sqrt(skills.OfType().Single().DifficultyValue()) * difficulty_multiplier; if (mods.Any(m => m is OsuModTouchDevice)) { aimRating = Math.Pow(aimRating, 0.8); flashlightRating = Math.Pow(flashlightRating, 0.8); + readingLowArRating = Math.Pow(readingLowArRating, 0.9); } if (mods.Any(h => h is OsuModRelax)) @@ -64,6 +69,7 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat aimRating *= 0.9; speedRating = 0.0; flashlightRating *= 0.7; + readingLowArRating *= 0.95; } else if (mods.Any(h => h is OsuModAutopilot)) { @@ -72,19 +78,25 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat flashlightRating *= 0.4; } - double baseAimPerformance = OsuStrainSkill.DifficultyToPerformance(aimRating); - double baseSpeedPerformance = OsuStrainSkill.DifficultyToPerformance(speedRating); - double baseFlashlightPerformance = 0.0; + double aimPerformance = OsuStrainSkill.DifficultyToPerformance(aimRating); + double speedPerformance = OsuStrainSkill.DifficultyToPerformance(speedRating); + + // Cognition + double readingLowArPerformance = ReadingLowAr.DifficultyToPerformance(readingLowArRating); + double readingArPerformance = readingLowArPerformance; - if (mods.Any(h => h is OsuModFlashlight)) - baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating); + double potentialFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating); + double flashlightPerformance = mods.Any(h => h is OsuModFlashlight) ? potentialFlashlightPerformance : 0; - double basePerformance = - Math.Pow( - Math.Pow(baseAimPerformance, 1.1) + - Math.Pow(baseSpeedPerformance, 1.1) + - Math.Pow(baseFlashlightPerformance, 1.1), 1.0 / 1.1 - ); + double flashlightArPerformance = Math.Pow(Math.Pow(flashlightPerformance, FL_SUM_POWER) + Math.Pow(readingArPerformance, FL_SUM_POWER), 1.0 / FL_SUM_POWER); + + double cognitionPerformance = flashlightArPerformance; + double mechanicalPerformance = Math.Pow(Math.Pow(aimPerformance, SUM_POWER) + Math.Pow(speedPerformance, SUM_POWER), 1.0 / SUM_POWER); + + // Limit cognition by full memorisation difficulty, what is assumed to be mechanicalPerformance + flashlightPerformance + cognitionPerformance = OsuPerformanceCalculator.AdjustCognitionPerformance(cognitionPerformance, mechanicalPerformance, potentialFlashlightPerformance); + + double basePerformance = mechanicalPerformance + cognitionPerformance; double starRating = basePerformance > 0.00001 ? Math.Cbrt(OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER) * 0.027 * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4) @@ -112,11 +124,13 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat AimDifficultSliderCount = difficultSliders, SpeedDifficulty = speedRating, SpeedNoteCount = speedNotes, + ReadingDifficultyLowAr = readingLowArRating, FlashlightDifficulty = flashlightRating, SliderFactor = sliderFactor, AimDifficultStrainCount = aimDifficultyStrainCount, SpeedDifficultStrainCount = speedDifficultyStrainCount, - ApproachRate = preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5, + LowArDifficultStrainCount = lowArDifficultyStrainCount, + ApproachRate = IBeatmapDifficultyInfo.InverseDifficultyRange(preempt, 1800, 1200, 450), OverallDifficulty = (80 - hitWindowGreat) / 6, GreatHitWindow = hitWindowGreat, OkHitWindow = hitWindowOk, @@ -152,12 +166,11 @@ protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clo { new Aim(mods, true), new Aim(mods, false), - new Speed(mods) + new Speed(mods), + new Flashlight(mods), + new ReadingLowAr(mods), }; - if (mods.Any(h => h is OsuModFlashlight)) - skills.Add(new Flashlight(mods)); - return skills.ToArray(); } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs index de4491a31b5b..d23bfd840f78 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs @@ -18,8 +18,8 @@ public class OsuPerformanceAttributes : PerformanceAttributes [JsonProperty("accuracy")] public double Accuracy { get; set; } - [JsonProperty("flashlight")] - public double Flashlight { get; set; } + [JsonProperty("cognition")] + public double Cognition { get; set; } [JsonProperty("effective_miss_count")] public double EffectiveMissCount { get; set; } @@ -35,7 +35,7 @@ public override IEnumerable GetAttributesForDisplay yield return new PerformanceDisplayAttribute(nameof(Aim), "Aim", Aim); yield return new PerformanceDisplayAttribute(nameof(Speed), "Speed", Speed); yield return new PerformanceDisplayAttribute(nameof(Accuracy), "Accuracy", Accuracy); - yield return new PerformanceDisplayAttribute(nameof(Flashlight), "Flashlight Bonus", Flashlight); + yield return new PerformanceDisplayAttribute(nameof(Cognition), "Cognition", Cognition); } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index f19118063094..499131bd6563 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -15,7 +15,7 @@ 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. + public const double PERFORMANCE_BASE_MULTIPLIER = 1.114; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. private bool usingClassicSliderAccuracy; @@ -112,28 +112,41 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s // As we're adding Oks and Mehs to an approximated number of combo breaks the result can be higher than total hits in specific scenarios (which breaks some calculations) so we need to clamp it. effectiveMissCount = Math.Min(effectiveMissCount + countOk * okMultiplier + countMeh * mehMultiplier, totalHits); } - + speedDeviation = calculateSpeedDeviation(osuAttributes); + const double power = OsuDifficultyCalculator.SUM_POWER; + double aimValue = computeAimValue(score, osuAttributes); double speedValue = computeSpeedValue(score, osuAttributes); - double accuracyValue = computeAccuracyValue(score, osuAttributes); + double mechanicalValue = Math.Pow(Math.Pow(aimValue, power) + Math.Pow(speedValue, power), 1.0 / power); + + // Cognition + double lowArValue = computeReadingLowArValue(score, osuAttributes); + double flashlightValue = computeFlashlightValue(score, osuAttributes); - double totalValue = - Math.Pow( - Math.Pow(aimValue, 1.1) + - Math.Pow(speedValue, 1.1) + - Math.Pow(accuracyValue, 1.1) + - Math.Pow(flashlightValue, 1.1), 1.0 / 1.1 - ) * multiplier; + // Reduce AR reading bonus if FL is present + const double fl_power = OsuDifficultyCalculator.FL_SUM_POWER; + + double flashlightArValue = score.Mods.Any(h => h is OsuModFlashlight) + ? Math.Pow(Math.Pow(flashlightValue, fl_power) + Math.Pow(lowArValue, fl_power), 1.0 / fl_power) + : lowArValue; + + double cognitionValue = flashlightArValue; + cognitionValue = AdjustCognitionPerformance(cognitionValue, mechanicalValue, flashlightValue); + + double accuracyValue = computeAccuracyValue(score, osuAttributes); + + // Add cognition value without LP-sum cuz otherwise it makes balancing harder + double totalValue = (Math.Pow(Math.Pow(mechanicalValue, power) + Math.Pow(accuracyValue, power), 1.0 / power) + cognitionValue) * multiplier; return new OsuPerformanceAttributes { Aim = aimValue, Speed = speedValue, Accuracy = accuracyValue, - Flashlight = flashlightValue, + Cognition = cognitionValue, EffectiveMissCount = effectiveMissCount, SpeedDeviation = speedDeviation, Total = totalValue @@ -180,8 +193,6 @@ private double computeAimValue(ScoreInfo score, OsuDifficultyAttributes attribut double approachRateFactor = 0.0; if (attributes.ApproachRate > 10.33) approachRateFactor = 0.3 * (attributes.ApproachRate - 10.33); - else if (attributes.ApproachRate < 8.0) - approachRateFactor = 0.05 * (8.0 - attributes.ApproachRate); if (score.Mods.Any(h => h is OsuModRelax)) approachRateFactor = 0.0; @@ -275,7 +286,7 @@ private double computeAccuracyValue(ScoreInfo score, OsuDifficultyAttributes att // Lots of arbitrary values from testing. // Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution. - double accuracyValue = Math.Pow(1.52163, attributes.OverallDifficulty) * Math.Pow(betterAccuracyPercentage, 24) * 2.83; + double accuracyValue = Math.Pow(1.52163, attributes.OverallDifficulty) * Math.Pow(betterAccuracyPercentage, 24) * 2.92; // Bonus for many hitcircles - it's harder to keep good accuracy up for longer. accuracyValue *= Math.Min(1.15, Math.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.3)); @@ -289,14 +300,28 @@ private double computeAccuracyValue(ScoreInfo score, OsuDifficultyAttributes att if (score.Mods.Any(m => m is OsuModFlashlight)) accuracyValue *= 1.02; + // Visual indication bonus + double visualBonus = 0.1 * DifficultyCalculationUtils.Logistic(attributes.ApproachRate - 8.0); + + // Buff if OD is way lower than AR + double arOdDelta = Math.Max(0, attributes.OverallDifficulty - attributes.ApproachRate); + + // This one is goes from 0.0 on delta=0 to 1.0 somewhere around delta=3.4 + double deltaBonus = (1 - Math.Pow(0.95, Math.Pow(arOdDelta, 4))); + + // Nerf delta bonus on OD lower than 10 and 9 + if (attributes.OverallDifficulty < 10) + deltaBonus *= Math.Pow(attributes.OverallDifficulty / 10, 2); + if (attributes.OverallDifficulty < 9) + deltaBonus *= Math.Pow(attributes.OverallDifficulty / 9, 4); + + accuracyValue *= 1 + visualBonus * (1 + 2 * deltaBonus); + return accuracyValue; } private double computeFlashlightValue(ScoreInfo score, OsuDifficultyAttributes attributes) { - if (!score.Mods.Any(h => h is OsuModFlashlight)) - return 0.0; - double flashlightValue = Flashlight.DifficultyToPerformance(attributes.FlashlightDifficulty); // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses. @@ -317,6 +342,37 @@ private double computeFlashlightValue(ScoreInfo score, OsuDifficultyAttributes a return flashlightValue; } + private double computeReadingLowArValue(ScoreInfo score, OsuDifficultyAttributes attributes) + { + double readingValue = ReadingLowAr.DifficultyToPerformance(attributes.ReadingDifficultyLowAr); + + // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses. + if (effectiveMissCount > 0) + readingValue *= calculateMissPenalty(effectiveMissCount, attributes.LowArDifficultStrainCount); + + // Scale the reading value with accuracy _harshly_. Additional note: it would have it's own curve in Statistical Accuracy rework. + readingValue *= accuracy * accuracy; + // It is important to also consider accuracy difficulty when doing that. + readingValue *= Math.Pow(0.98 + Math.Pow(attributes.OverallDifficulty, 2) / 2500, 2); + + return readingValue; + } + + // Limits reading difficulty by the difficulty of full-memorisation (assumed to be mechanicalPerformance + flashlightPerformance + 25) + // Desmos graph assuming that x = cognitionPerformance, while y = mechanicalPerformance + flaslightPerformance + // https://www.desmos.com/3d/vjygrxtkqs + public static double AdjustCognitionPerformance(double cognitionPerformance, double mechanicalPerformance, double flashlightPerformance) + { + // Assuming that less than 25 pp is not worthy for memory + double capPerformance = mechanicalPerformance + flashlightPerformance + 25; + + double ratio = cognitionPerformance / capPerformance; + if (ratio > 50) return capPerformance; + + ratio = DifficultyCalculationUtils.Softmin(ratio * 10, 10, 5) / 10; + return ratio * capPerformance; + } + /// /// Estimates player's deviation on speed notes using , assuming worst-case. /// Treats all speed notes as hit circles. @@ -423,6 +479,7 @@ private double calculateSpeedHighDeviationNerf(OsuDifficultyAttributes attribute // 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; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 5e4c5c1ee9fa..f809e8e5df7b 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -4,7 +4,9 @@ using System; using System.Collections.Generic; using System.Linq; +using osu.Framework.Extensions.ObjectExtensions; using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; @@ -34,6 +36,11 @@ public class OsuDifficultyHitObject : DifficultyHitObject /// public readonly double StrainTime; + /// + /// Saved version of to decrease overhead. + /// + public readonly Vector2 StackedPosition; + /// /// Normalised distance from the "lazy" end position of the previous to the start position of this . /// @@ -78,22 +85,54 @@ public class OsuDifficultyHitObject : DifficultyHitObject /// /// Angle the player has to take to hit this . /// Calculated as the angle between the circles (current-2, current-1, current). + /// Ranges from 0 to PI /// public double? Angle { get; private set; } + /// + /// Signed version of the Angle. + /// Potentially should be used for more accurate angle bonuses + /// Ranges from -PI to PI + /// + public double? AngleSigned { get; private set; } + /// /// Retrieves the full hit window for a Great . /// public double HitWindowGreat { get; private set; } + /// + /// Predictabiliy of the angle. Gives high values only in exceptionally repetitive patterns. + /// + public double AnglePredictability { get; private set; } + + /// + /// Objects that was visible after the note was hit together with cumulative overlapping difficulty. Saved for optimization to avoid O(x^4) time complexity. + /// + public IList ReadingObjects { get; private set; } + + /// + /// NON ZERO overlap values for each visible object on the moment this object appeared. Key is . Saved for optimization. + /// + public IDictionary OverlapValues { get; private set; } + + /// + /// Time in ms between appearence of this and moment to click on it. + /// + public readonly double Preempt; + private readonly OsuHitObject? lastLastObject; private readonly OsuHitObject lastObject; public OsuDifficultyHitObject(HitObject hitObject, HitObject lastObject, HitObject? lastLastObject, double clockRate, List objects, int index) : base(hitObject, lastObject, clockRate, objects, index) { - this.lastLastObject = lastLastObject as OsuHitObject; + OsuHitObject currObject = (OsuHitObject)hitObject; this.lastObject = (OsuHitObject)lastObject; + this.lastLastObject = lastLastObject as OsuHitObject; + + StackedPosition = currObject.StackedPosition; + Preempt = BaseObject.TimePreempt / clockRate; // Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects. StrainTime = Math.Max(DeltaTime, MIN_DELTA_TIME); @@ -108,11 +147,309 @@ public OsuDifficultyHitObject(HitObject hitObject, HitObject lastObject, HitObje } setDistances(clockRate); + + AnglePredictability = CalculateAnglePredictability(); + + (ReadingObjects, OverlapValues) = getReadingObjects(); + } + + private (IList, IDictionary) getReadingObjects() + { + double totalOverlapnessDifficulty = 0; + double currentTime = DeltaTime; + List historicTimes = []; + List historicAngles = []; + + OsuDifficultyHitObject prevObject = this; + + // The fastest way to do it I've seen so far. Still - one of the slowest parts of the reading calc + var visibleObjects = retrieveCurrentVisibleObjects(this); + + var readingObjects = new List(visibleObjects.Count); + OverlapValues = new Dictionary(); + + for (int loopIndex = 0; loopIndex < visibleObjects.Count; loopIndex++) + { + var loopObj = visibleObjects[loopIndex]; + + // Overlapness with this object + double currentOverlapness = calculateOverlapness(this, loopObj); + + // Save it for future use. Saving only non-zero to make it faster + if (currentOverlapness > 0) OverlapValues[loopObj.Index] = currentOverlapness; + + if (prevObject.Angle.IsNull()) + { + currentTime += prevObject.DeltaTime; + continue; + } + + // Previous angle because order is reversed; + double angle = (double)prevObject.Angle; + + // Overlapness between current and prev to make streams have 0 buff + prevObject.OverlapValues.TryGetValue(loopObj.Index, out double instantOverlapness); + + // Nerf overlaps on wide angles + double angleFactor = 1; + angleFactor += (-Math.Cos(angle) + 1) / 2; // =2 for wide angles, =1 for acute angles + instantOverlapness = Math.Min(1, (0.5 + instantOverlapness) * angleFactor); // wide angles are more predictable + + currentOverlapness *= (1 - instantOverlapness) * 2; // wide angles will have close-to-zero buff + + // Control overlap repetitivness + if (currentOverlapness > 0) + { + currentOverlapness *= getOpacitiyMultiplier(loopObj); // Increase stability by using opacity + + double currentMinOverlapness = currentOverlapness; + double cumulativeTimeWithCurrent = currentTime; + + // For every cumulative time with current + for (int i = historicTimes.Count - 1; i >= 0; i--) + { + double cumulativeTimeWithoutCurrent = 0; + + // Get every possible cumulative time without current + for (int j = i; j >= 0; j--) + { + cumulativeTimeWithoutCurrent += historicTimes[j]; + + // Check how similar cumulative times are + double potentialMinOverlapness = currentOverlapness * getTimeDifference(cumulativeTimeWithCurrent, cumulativeTimeWithoutCurrent); + potentialMinOverlapness *= 1 - getAngleSimilarity(angle, historicAngles[j]) * (1 - getTimeDifference(loopObj.StrainTime, prevObject.StrainTime)); + currentMinOverlapness = Math.Min(currentMinOverlapness, potentialMinOverlapness); + + // Check how similar current time with cumulative time + potentialMinOverlapness = currentOverlapness * getTimeDifference(currentTime, cumulativeTimeWithoutCurrent); + potentialMinOverlapness *= 1 - getAngleSimilarity(angle, historicAngles[j]) * (1 - getTimeDifference(loopObj.StrainTime, prevObject.StrainTime)); + currentMinOverlapness = Math.Min(currentMinOverlapness, potentialMinOverlapness); + + // Starting from this point - we will never have better match, so stop searching + if (cumulativeTimeWithoutCurrent >= cumulativeTimeWithCurrent) + break; + } + + cumulativeTimeWithCurrent += historicTimes[i]; + } + + currentOverlapness = currentMinOverlapness; + + historicTimes.Add(currentTime); + historicAngles.Add(angle); + + currentTime = prevObject.DeltaTime; + } + else + { + currentTime += prevObject.DeltaTime; + } + + totalOverlapnessDifficulty += currentOverlapness; + + ReadingObject newObj = new ReadingObject(loopObj, totalOverlapnessDifficulty); + readingObjects.Add(newObj); + prevObject = loopObj; + } + + return (readingObjects, OverlapValues); + } + + private double getOpacitiyMultiplier(OsuDifficultyHitObject loopObj) + { + const double threshold = 0.3; + + // Get raw opacity + double opacity = OpacityAt(loopObj.BaseObject.StartTime, false); + + opacity = Math.Min(1, opacity + threshold); // object with opacity 0.7 are still perfectly visible + opacity -= threshold; // return opacity 0 objects back to 0 + opacity /= 1 - threshold; // fix scaling to be 0-1 again + opacity = Math.Sqrt(opacity); // change curve + + return opacity; + } + + private static double getTimeDifference(double timeA, double timeB) + { + double similarity = DifficultyCalculationUtils.GetRatio(timeA, timeB); + if (Math.Max(timeA, timeB) == 0) similarity = 1; + + if (similarity < 0.75) return 1.0; + if (similarity > 0.9) return 0.0; + + return (Math.Cos((similarity - 0.75) * Math.PI / 0.15) + 1) / 2; // drops from 1 to 0 as similarity increase from 0.75 to 0.9 + } + + private static double getAngleSimilarity(double angle1, double angle2) + { + double difference = Math.Abs(angle1 - angle2); + const double threeshold = Math.PI / 12; + + if (difference > threeshold) return 0; + + return 1 - difference / threeshold; + } + + private static double calculateOverlapness(OsuDifficultyHitObject odho1, OsuDifficultyHitObject odho2) + { + const double area_coef = 0.85; + + double distance = Vector2.Distance(odho1.StackedPosition, odho2.StackedPosition); // Distance func is kinda slow for some reason + double radius = odho1.BaseObject.Radius; + + double distanceSqr = distance * distance; + double radiusSqr = radius * radius; + + if (distance > radius * 2) + return 0; + + double s1 = Math.Acos(distance / (2 * radius)) * radiusSqr; // Area of sector + double s2 = distance * Math.Sqrt(radiusSqr - distanceSqr / 4) / 2; // Area of triangle + + double overlappingAreaNormalized = (s1 - s2) * 2 / (Math.PI * radiusSqr); + + // Don't ask me how I got this value, looks oddly similar to PI - 3 + const double stack_distance_ratio = 0.1414213562373; + + double perfectStackBuff = (stack_distance_ratio - distance / radius) / stack_distance_ratio; // scale from 0 on normal stack to 1 on perfect stack + perfectStackBuff = Math.Max(perfectStackBuff, 0); // can't be negative + + return overlappingAreaNormalized * area_coef + perfectStackBuff * (1 - area_coef); + } + + private static List retrieveCurrentVisibleObjects(OsuDifficultyHitObject current) + { + var visibleObjects = new List(); + + for (int i = 0; i < current.Index; i++) + { + OsuDifficultyHitObject hitObject = (OsuDifficultyHitObject)current.Previous(i); + + if (hitObject.IsNull() || + hitObject.StartTime < current.StartTime - current.Preempt) + break; + + visibleObjects.Add(hitObject); + } + + return visibleObjects; + } + + public double CalculateAnglePredictability() + { + OsuDifficultyHitObject? prevObj0 = (OsuDifficultyHitObject?)Previous(0); + OsuDifficultyHitObject? prevObj1 = (OsuDifficultyHitObject?)Previous(1); + OsuDifficultyHitObject? prevObj2 = (OsuDifficultyHitObject?)Previous(2); + + if (Angle.IsNull() || prevObj0.IsNull() || prevObj0.Angle.IsNull()) + return 1.0; + + double angleDifference = Math.Abs(prevObj0.Angle.Value - Angle.Value); + + // Assume that very low spacing difference means that angles don't matter + if (prevObj0.LazyJumpDistance < NORMALISED_RADIUS) + angleDifference *= Math.Pow(prevObj0.LazyJumpDistance / NORMALISED_RADIUS, 2); + if (LazyJumpDistance < NORMALISED_RADIUS) + angleDifference *= Math.Pow(LazyJumpDistance / NORMALISED_RADIUS, 2); + + // Now research previous angles + double angleDifferencePrev = 0; + + // How close the smallest angle of curr and prev is to 0 + double zeroAngleFactor = 1.0; + + // Nerf alternating angles case + if (prevObj1.IsNotNull() && prevObj2.IsNotNull() && prevObj1.Angle.IsNotNull()) + { + angleDifferencePrev = Math.Abs(prevObj1.Angle.Value - Angle.Value); + zeroAngleFactor = Math.Pow(1 - Math.Min(Angle.Value, prevObj0.Angle.Value) / Math.PI, 10); + } + + // Will be close to 1 if angleDifferencePrev is close to 0 + double rescaleFactor = Math.Pow(1 - angleDifferencePrev / Math.PI, 5); + + // 0 on different rhythm, 1 on same rhythm + double rhythmFactor = 1 - getTimeDifference(StrainTime, prevObj0.StrainTime); + + if (prevObj1.IsNotNull()) + rhythmFactor *= 1 - getTimeDifference(prevObj0.StrainTime, prevObj1.StrainTime); + if (prevObj1.IsNotNull() && prevObj2.IsNotNull()) + rhythmFactor *= 1 - getTimeDifference(prevObj1.StrainTime, prevObj2.StrainTime); + + // Get the base - how much alternating difference is lower than current difference + double prevAngleAdjust = Math.Max(angleDifference - angleDifferencePrev, 0); + + // Don't apply the nerf when angleDifferencePrev is too high + prevAngleAdjust *= rescaleFactor; + + // Don't apply the nerf if rhythm is changing + prevAngleAdjust *= rhythmFactor; + + // Don't apply the nerf if neither of previous angles isn't close to 0 + prevAngleAdjust *= zeroAngleFactor; + + angleDifference -= prevAngleAdjust; + + // Explicit nerf for same pattern repeating + OsuDifficultyHitObject? prevObj3 = (OsuDifficultyHitObject?)Previous(3); + OsuDifficultyHitObject? prevObj4 = (OsuDifficultyHitObject?)Previous(4); + OsuDifficultyHitObject? prevObj5 = (OsuDifficultyHitObject?)Previous(5); + + // 3-3 repeat + double similarityBy3 = getGeneralSimilarity(this, prevObj2) * getGeneralSimilarity(prevObj0, prevObj3) * getGeneralSimilarity(prevObj1, prevObj4); + + // 4-4 repeat, only first 3 are checked, this is enough + double similarityBy4 = getGeneralSimilarity(this, prevObj3) * getGeneralSimilarity(prevObj0, prevObj4) * getGeneralSimilarity(prevObj1, prevObj5); + + // Bandaid to fix Rubik's Cube +EZ + double wideness = 0; + + if (Angle!.Value > Math.PI * 0.5) + { + // Goes from 0 to 1 as angle increasing from 90 degrees to 180 + wideness = (Angle.Value / Math.PI - 0.5) * 2; + + // Transform into cubic scaling + wideness = 1 - Math.Pow(1 - wideness, 3); + } + + // Angle difference will be considered as 2 times lower if angle is wide + angleDifference /= 1 + wideness; + + // Angle difference more than 15 degrees gets no penalty + double adjustedAngleDifference = Math.Min(Math.PI / 12, angleDifference); + double predictability = Math.Cos(Math.Min(Math.PI / 2, 6 * adjustedAngleDifference)) * rhythmFactor; + + // Punish for big pattern similarity + return 1 - (1 - predictability) * (1 - Math.Max(similarityBy3, similarityBy4)); + } + + private double getGeneralSimilarity(OsuDifficultyHitObject? o1, OsuDifficultyHitObject? o2) + { + if (o1 == null || o2 == null) + return 1; + + if (o1.AngleSigned == null || o2.AngleSigned == null) + return o1.AngleSigned == o2.AngleSigned ? 1 : 0; + + double timeSimilarity = 1 - getTimeDifference(o1.StrainTime, o2.StrainTime); + + double angleDelta = Math.Abs((double)o1.AngleSigned - (double)o2.AngleSigned); + angleDelta = Math.Clamp(angleDelta - 0.1, 0, 0.15); + double angleSimilarity = 1 - angleDelta / 0.15; + + double distanceDelta = Math.Abs(o1.LazyJumpDistance - o2.LazyJumpDistance) / NORMALISED_RADIUS; + double distanceSimilarity = 1 / Math.Max(1, distanceDelta); + + return timeSimilarity * angleSimilarity * distanceSimilarity; } public double OpacityAt(double time, bool hidden) { - if (time > BaseObject.StartTime) + var baseObject = BaseObject; + + if (time > baseObject.StartTime) { // Consider a hitobject as being invisible when its start time is passed. // In reality the hitobject will be visible beyond its start time up until its hittable window has passed, @@ -120,14 +457,14 @@ public double OpacityAt(double time, bool hidden) return 0.0; } - double fadeInStartTime = BaseObject.StartTime - BaseObject.TimePreempt; - double fadeInDuration = BaseObject.TimeFadeIn; + double fadeInStartTime = baseObject.StartTime - baseObject.TimePreempt; + double fadeInDuration = baseObject.TimeFadeInRaw; if (hidden) { // Taken from OsuModHidden. - double fadeOutStartTime = BaseObject.StartTime - BaseObject.TimePreempt + BaseObject.TimeFadeIn; - double fadeOutDuration = BaseObject.TimePreempt * OsuModHidden.FADE_OUT_DURATION_MULTIPLIER; + double fadeOutStartTime = baseObject.StartTime - baseObject.TimePreempt + baseObject.TimeFadeInRaw; + double fadeOutDuration = baseObject.TimePreempt * OsuModHidden.FADE_OUT_DURATION_MULTIPLIER; return Math.Min ( @@ -227,7 +564,8 @@ private void setDistances(double clockRate) float dot = Vector2.Dot(v1, v2); float det = v1.X * v2.Y - v1.Y * v2.X; - Angle = Math.Abs(Math.Atan2(det, dot)); + AngleSigned = Math.Atan2(det, dot); + Angle = Math.Abs(AngleSigned.Value); } } @@ -348,5 +686,17 @@ private Vector2 getEndCursorPosition(OsuHitObject hitObject) return pos; } + + public struct ReadingObject + { + public OsuDifficultyHitObject HitObject; + public double Overlapness; + + public ReadingObject(OsuDifficultyHitObject hitObject, double overlapness) + { + HitObject = hitObject; + Overlapness = overlapness; + } + } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs index 6c839eac3fef..affecbc3bf44 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs @@ -24,7 +24,7 @@ public Flashlight(Mod[] mods) hasHiddenMod = mods.Any(m => m is OsuModHidden); } - private double skillMultiplier => 0.05512; + private double skillMultiplier => 0.053; private double strainDecayBase => 0.15; private double currentStrain; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs new file mode 100644 index 000000000000..fe042c0c6c39 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Reading.cs @@ -0,0 +1,82 @@ +// 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.Framework.Utils; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Difficulty.Evaluators; + +namespace osu.Game.Rulesets.Osu.Difficulty.Skills +{ + public class ReadingLowAr : StrainSkill + { + private double skillMultiplier => 1.22; + private double aimComponentMultiplier => 0.4; + + public ReadingLowAr(Mod[] mods) + : base(mods) + { + } + + private double strainDecayBase => 0.15; + private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); + + private double currentDensityAimStrain; + + protected override double StrainValueAt(DifficultyHitObject current) + { + double densityReadingDifficulty = ReadingEvaluator.EvaluateDifficultyOf(current); + double densityAimingFactor = ReadingEvaluator.EvaluateAimingDensityFactorOf(current); + + // Reward slideraim but not bigger than 2 * sliderless aim + double aimDifficulty = Math.Max(AimEvaluator.EvaluateDifficultyOf(current, true), 2 * AimEvaluator.EvaluateDifficultyOf(current, true)); + + currentDensityAimStrain *= strainDecay(current.DeltaTime); + currentDensityAimStrain += densityAimingFactor * aimDifficulty * aimComponentMultiplier; + + double totalDensityDifficulty = (currentDensityAimStrain + densityReadingDifficulty) * skillMultiplier; + return totalDensityDifficulty; + } + + protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => 0; + + private double reducedNoteCount => 5; + private double reducedNoteBaseline => 0.7; + + public override double DifficultyValue() + { + // Sections with 0 difficulty are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). + // These sections will not contribute to the difficulty. + var peaks = ObjectStrains.Where(p => p > 0); + + List values = peaks.OrderByDescending(d => d).ToList(); + + for (int i = 0; i < Math.Min(values.Count, reducedNoteCount); i++) + { + double scale = Math.Log10(Interpolation.Lerp(1, 10, Math.Clamp(i / reducedNoteCount, 0, 1))); + values[i] *= Interpolation.Lerp(reducedNoteBaseline, 1.0, scale); + } + + values = values.OrderByDescending(d => d).ToList(); + + double difficulty = 0; + + // Difficulty is the weighted sum of the highest strains from every section. + // We're sorting from highest to lowest strain. + for (int i = 0; i < values.Count; i++) + { + difficulty += values[i] / (i + 1); + } + + return difficulty; + } + + public static double DifficultyToPerformance(double difficulty) => Math.Max( + Math.Max(Math.Pow(difficulty, 1.5) * 20, Math.Pow(difficulty, 2) * 17.0), + Math.Max(Math.Pow(difficulty, 3) * 10.5, Math.Pow(difficulty, 4) * 6.00)); + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs index 9623d1999b65..047fb7e1331b 100644 --- a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs @@ -48,6 +48,7 @@ public abstract class OsuHitObject : HitObject, IHasComboInformation, IHasPositi public double TimePreempt { get; set; } = 600; public double TimeFadeIn = 400; + public double TimeFadeInRaw = 400; private HitObjectProperty position; @@ -176,6 +177,7 @@ protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, I // Note that this doesn't exactly match the AR>10 visuals as they're classically known, but it feels good. // This adjustment is necessary for AR>10, otherwise TimePreempt can become smaller leading to hitcircles not fully fading in. TimeFadeIn = 400 * Math.Min(1, TimePreempt / PREEMPT_MIN); + TimeFadeInRaw = TimeFadeIn; Scale = LegacyRulesetExtensions.CalculateScaleFromCircleSize(difficulty.CircleSize, true); } diff --git a/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs b/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs index 78df8a139b83..4e9cc577a2bc 100644 --- a/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs +++ b/osu.Game/Rulesets/Difficulty/Utils/DifficultyCalculationUtils.cs @@ -102,5 +102,9 @@ public static double ReverseLerp(double x, double start, double end) { return Math.Clamp((x - start) / (end - start), 0.0, 1.0); } + + public static double Softmin(double a, double b, double power = Math.E) => a * b / Math.Log(Math.Pow(power, a) + Math.Pow(power, b), power); + + public static double GetRatio(double a, double b) => Math.Min(a, b) / Math.Max(a, b); } }