diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ReadingEvaluator.cs b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ReadingEvaluator.cs index 58719796133f..0c9c25b630ab 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ReadingEvaluator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ReadingEvaluator.cs @@ -2,8 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; +using osu.Game.Rulesets.Taiko.Mods; namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators { @@ -25,32 +28,143 @@ public VelocityRange(double min, double max) /// /// Calculates the influence of higher slider velocities on hitobject difficulty. - /// The bonus is determined based on the EffectiveBPM, shifting within a defined range - /// between the upper and lower boundaries to reflect how increased slider velocity impacts difficulty. + /// The bonus is determined based on the EffectiveBPM, object density and the effects of mods. /// /// The hit object to evaluate. + /// The mods which were applied to the beatmap. /// The reading difficulty value for the given hit object. - public static double EvaluateDifficultyOf(TaikoDifficultyHitObject noteObject) + public static double EvaluateDifficultyOf(TaikoDifficultyHitObject noteObject, Mod[] mods, bool isHidden) { - var highVelocity = new VelocityRange(480, 640); - var midVelocity = new VelocityRange(360, 480); + bool isFlashlight = mods.Any(m => m is TaikoModFlashlight); - // Apply a cap to prevent outlier values on maps that exceed the editor's parameters. - double effectiveBPM = Math.Max(1.0, noteObject.EffectiveBPM); + // With HDFL, all note objects are invisible and give the maximum reading difficulty + if (isHidden && isFlashlight) + return 1.0; - double midVelocityDifficulty = 0.5 * DifficultyCalculationUtils.Logistic(effectiveBPM, midVelocity.Center, 1.0 / (midVelocity.Range / 10)); + double velocityDifficulty = calculateVelocityDifficulty(noteObject, mods, isHidden); + double densityDifficulty = calculateDensityDifficulty(noteObject); - // Expected DeltaTime is the DeltaTime this note would need to be spaced equally to a base slider velocity 1/4 note. - double expectedDeltaTime = 21000.0 / effectiveBPM; - double objectDensity = expectedDeltaTime / Math.Max(1.0, noteObject.DeltaTime); + double difficulty = Math.Max(velocityDifficulty, densityDifficulty); + + // With hidden, all notes award a base difficulty + if (isHidden) + difficulty = 0.25 + 0.75 * difficulty; + + return difficulty; + } + + private static double calculateVelocityDifficulty(TaikoDifficultyHitObject noteObject, Mod[] mods, bool isHidden) + { + double highVelocityDifficulty = 0.0; + double timeInvisibleDifficulty = 0.0; + + // To allow high velocity sections at lower actual BPM to award similar difficulty to high BPM sections with more frequent objects, + // a bonus is applied to the high velocity range at lower object density + double densityBonus = calculateHighVelocityDensityBonus(noteObject); + + var highVelocity = new VelocityRange( + 420 - 140 * densityBonus, + 1000 - 320 * densityBonus + ); + + highVelocityDifficulty = DifficultyCalculationUtils.Logistic( + noteObject.EffectiveBPM * calculateHighVelocityModMultiplier(mods, isHidden), + highVelocity.Center, + 10.0 / highVelocity.Range + ); + + // With hidden, notes that stay invisible for longer before being hit are harder to read + if (isHidden) { + var lowVelocity = new VelocityRange(280, 125); + + timeInvisibleDifficulty = DifficultyCalculationUtils.Logistic( + noteObject.EffectiveBPM * calculateTimeInvisibleModMultiplier(mods), + lowVelocity.Center, + 10.0 / lowVelocity.Range + ); + } + + return Math.Max(highVelocityDifficulty, timeInvisibleDifficulty); + } + + private static double calculateHighVelocityDensityBonus(TaikoDifficultyHitObject noteObject) + { + double density = calculateObjectDensity(noteObject); + + // Single note gaps in otherwise dense sections would overly award the bonus for low density + // As a result, the higher density out of both the current and previous note is used + var prevNoteObject = (TaikoDifficultyHitObject) noteObject.Previous(0); + + if (prevNoteObject != null) + { + double prevDensity = calculateObjectDensity(prevNoteObject); + return DifficultyCalculationUtils.Smoothstep(Math.Max(density, prevDensity), 0.9, 0.35); + } + + return DifficultyCalculationUtils.Smoothstep(density, 0.9, 0.35); + } + + private static double calculateHighVelocityModMultiplier(Mod[] mods, bool isHidden) + { + bool isFlashlight = mods.Any(m => m is TaikoModFlashlight); + bool isEasy = mods.Any(m => m is TaikoModEasy); - // High density is penalised at high velocity as it is generally considered easier to read. See https://www.desmos.com/calculator/u63f3ntdsi - double densityPenalty = DifficultyCalculationUtils.Logistic(objectDensity, 0.925, 15); + double multiplier = 1.0; + + if (isHidden) + { + // With hidden enabled, the playfield is limited from the expected 1560px wide (equivalent to 16:9) to only 1080px (4:3) + // This is not the case with the classic mod enabled, but due to current limitations this is penalised in performance calculation instead + // Considerations for HDHRCL are currently out of scope + multiplier *= 1560.0 / 1080.0; + + // Notes fading out after a short time with hidden means their velocity is essentially higher. With easy enabled, notes fade out after longer. + // Both of these values are arbitrary and based on feedback + if (isEasy) + multiplier *= 1.1; + else + multiplier *= 1.2; + } + + // With flashlight, the visible playfield is limited from the expected 1560px wide to around 468px + // Considerations for combo and smaller flashlights are currently out of scope + if (isFlashlight) + multiplier *= 1560.0 / 468.0; + + return multiplier; + } + + private static double calculateTimeInvisibleModMultiplier(Mod[] mods) + { + bool isEasy = mods.Any(m => m is TaikoModEasy); - double highVelocityDifficulty = (1.0 - 0.33 * densityPenalty) - * DifficultyCalculationUtils.Logistic(effectiveBPM, highVelocity.Center + 8 * densityPenalty, (1.0 + 0.5 * densityPenalty) / (highVelocity.Range / 10)); + double multiplier = 1.0; + + // With easy enabled, notes fade out later and are invisible for less time. This is equivalent to their effective BPM being higher + if (isEasy) + multiplier *= 1.35; + + return multiplier; + } + + private static double calculateDensityDifficulty(TaikoDifficultyHitObject noteObject) + { + // Notes at very high density are harder to read + return Math.Pow( + DifficultyCalculationUtils.Logistic(calculateObjectDensity(noteObject), 3.5, 1.5), + 3.0 + ); + } + + private static double calculateObjectDensity(TaikoDifficultyHitObject noteObject) + { + if (noteObject.EffectiveBPM == 0 || noteObject.DeltaTime == 0) + return 1.0; + + // Expected DeltaTime is the DeltaTime this note would need to be spaced equally to a base slider velocity 1/4 note. + double expectedDeltaTime = 21000.0 / noteObject.EffectiveBPM; - return midVelocityDifficulty + highVelocityDifficulty; + return expectedDeltaTime / noteObject.DeltaTime; } } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Reading.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Reading.cs index 7be1107b7042..7e907b2ea602 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Reading.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Reading.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.Difficulty.Evaluators; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; +using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Rulesets.Taiko.Objects; namespace osu.Game.Rulesets.Taiko.Difficulty.Skills @@ -21,9 +23,14 @@ public class Reading : StrainDecaySkill private double currentStrain; - public Reading(Mod[] mods) + private Mod[] mods; + public readonly bool HiddenDifficultyOnly; + + public Reading(Mod[] mods, bool HiddenDifficultyOnly) : base(mods) { + this.mods = mods; + this.HiddenDifficultyOnly = HiddenDifficultyOnly; } protected override double StrainValueOf(DifficultyHitObject current) @@ -34,13 +41,29 @@ protected override double StrainValueOf(DifficultyHitObject current) return 0.0; } + bool isHidden = mods.Any(m => m is TaikoModHidden); + var taikoObject = (TaikoDifficultyHitObject)current; int index = taikoObject.ColourData.MonoStreak?.HitObjects.IndexOf(taikoObject) ?? 0; currentStrain *= DifficultyCalculationUtils.Logistic(index, 4, -1 / 25.0, 0.5) + 0.5; - currentStrain *= StrainDecayBase; - currentStrain += ReadingEvaluator.EvaluateDifficultyOf(taikoObject) * SkillMultiplier; + + double difficulty = ReadingEvaluator.EvaluateDifficultyOf(taikoObject, mods, isHidden); + + if (HiddenDifficultyOnly) + { + double hiddenDifficulty = 0.0; + + if (isHidden) + hiddenDifficulty = difficulty - ReadingEvaluator.EvaluateDifficultyOf(taikoObject, mods, false); + + currentStrain += hiddenDifficulty * SkillMultiplier; + } + else + { + currentStrain += difficulty * SkillMultiplier; + } return currentStrain; } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs index c5cc04449cd6..b1b264fbbe43 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs @@ -25,8 +25,15 @@ public class TaikoDifficultyAttributes : DifficultyAttributes /// /// The difficulty corresponding to the reading skill. /// + [JsonProperty("reading_difficulty")] public double ReadingDifficulty { get; set; } + /// + /// Contribution to reading difficulty from the hidden mod. + /// + [JsonProperty("hidden_reading_difficulty")] + public double HiddenReadingDifficulty { get; set; } + /// /// The difficulty corresponding to the colour skill. /// diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index 64af2861eca2..aee630981c29 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -23,12 +23,13 @@ public class TaikoDifficultyCalculator : DifficultyCalculator { private const double difficulty_multiplier = 0.084375; private const double rhythm_skill_multiplier = 0.750 * difficulty_multiplier; - private const double reading_skill_multiplier = 0.100 * difficulty_multiplier; + private const double reading_skill_multiplier = 0.200 * difficulty_multiplier; private const double colour_skill_multiplier = 0.375 * difficulty_multiplier; private const double stamina_skill_multiplier = 0.445 * difficulty_multiplier; private double strainLengthBonus; private double patternMultiplier; + private double readingLengthPenalty; private bool isRelax; private bool isConvert; @@ -48,7 +49,8 @@ protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) return new Skill[] { new Rhythm(mods), - new Reading(mods), + new Reading(mods, false), + new Reading(mods, true), new Colour(mods), new Stamina(mods, false, isConvert), new Stamina(mods, true, isConvert) @@ -61,6 +63,7 @@ protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) new TaikoModHalfTime(), new TaikoModEasy(), new TaikoModHardRock(), + new TaikoModHidden(), }; protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, Mod[] mods) @@ -101,27 +104,34 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat return new TaikoDifficultyAttributes { Mods = mods }; var rhythm = skills.OfType().Single(); - var reading = skills.OfType().Single(); + var reading = skills.OfType().Single(s => !s.HiddenDifficultyOnly); + var hiddenReading = skills.OfType().Single(s => s.HiddenDifficultyOnly); var colour = skills.OfType().Single(); var stamina = skills.OfType().Single(s => !s.SingleColourStamina); var singleColourStamina = skills.OfType().Single(s => s.SingleColourStamina); double staminaDifficultyValue = stamina.DifficultyValue(); + double readingDifficultyValue = reading.DifficultyValue(); double rhythmSkill = rhythm.DifficultyValue() * rhythm_skill_multiplier; double readingSkill = reading.DifficultyValue() * reading_skill_multiplier; + double hiddenReadingSkill = hiddenReading.DifficultyValue() * reading_skill_multiplier; double colourSkill = colour.DifficultyValue() * colour_skill_multiplier; double staminaSkill = staminaDifficultyValue * stamina_skill_multiplier; double monoStaminaSkill = singleColourStamina.DifficultyValue() * stamina_skill_multiplier; double monoStaminaFactor = staminaSkill == 0 ? 1 : Math.Pow(monoStaminaSkill / staminaSkill, 5); double staminaDifficultStrains = stamina.CountTopWeightedStrains(staminaDifficultyValue); + double readingDifficultStrains = reading.CountTopWeightedStrains(readingDifficultyValue); // As we don't have pattern integration in osu!taiko, we apply the other two skills relative to rhythm. patternMultiplier = Math.Pow(staminaSkill * colourSkill, 0.10); strainLengthBonus = 1 + 0.15 * DifficultyCalculationUtils.ReverseLerp(staminaDifficultStrains, 1000, 1555); + // Apply a penalty to small amounts of reading that can be memorised. + readingLengthPenalty = DifficultyCalculationUtils.ReverseLerp(readingDifficultStrains, 0, 150); + double combinedRating = combinedDifficultyValue(rhythm, reading, colour, stamina, out double consistencyFactor); double starRating = rescale(combinedRating * 1.4); @@ -130,6 +140,7 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat double rhythmDifficulty = rhythmSkill * skillRating; double readingDifficulty = readingSkill * skillRating; + double hiddenReadingDifficulty = hiddenReadingSkill * skillRating; double colourDifficulty = colourSkill * skillRating; double staminaDifficulty = staminaSkill * skillRating; double mechanicalDifficulty = colourDifficulty + staminaDifficulty; // Mechanical difficulty is the sum of colour and stamina difficulties. @@ -141,6 +152,7 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat MechanicalDifficulty = mechanicalDifficulty, RhythmDifficulty = rhythmDifficulty, ReadingDifficulty = readingDifficulty, + HiddenReadingDifficulty = hiddenReadingDifficulty, ColourDifficulty = colourDifficulty, StaminaDifficulty = staminaDifficulty, MonoStaminaFactor = monoStaminaFactor, @@ -216,7 +228,7 @@ private List combinePeaks(IReadOnlyList rhythmPeaks, IReadOnlyLi for (int i = 0; i < colourPeaks.Count; i++) { double rhythmPeak = rhythmPeaks[i] * rhythm_skill_multiplier * patternMultiplier; - double readingPeak = readingPeaks[i] * reading_skill_multiplier; + double readingPeak = readingPeaks[i] * reading_skill_multiplier * readingLengthPenalty; double colourPeak = isRelax ? 0 : colourPeaks[i] * colour_skill_multiplier; // There is no colour difficulty in relax. double staminaPeak = staminaPeaks[i] * stamina_skill_multiplier * strainLengthBonus; staminaPeak /= isConvert || isRelax ? 1.5 : 1.0; // Available finger count is increased by 150%, thus we adjust accordingly. diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs index df9da49c4b80..a0190dbb3c2b 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs @@ -82,25 +82,12 @@ private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes if (estimatedUnstableRate == null || totalDifficultHits == 0) return 0; - // The estimated unstable rate for 100% accuracy, at which all rhythm difficulty has been played successfully. - double rhythmExpectedUnstableRate = computeDeviationUpperBound(1.0) * 10; + double penalisedStarRating = attributes.StarRating * calculateImproperlyPlayedRhythmPenalty(attributes.RhythmDifficulty, attributes.StarRating); - // The unstable rate at which it can be assumed all rhythm difficulty has been ignored. - // 0.8 represents 80% of total hits being greats, or 90% accuracy in-game - double rhythmMaximumUnstableRate = computeDeviationUpperBound(0.8) * 10; + if (!isClassic) + penalisedStarRating *= calculateLazerHiddenReadingPenalty(attributes.HiddenReadingDifficulty, attributes.StarRating); - // The fraction of star rating made up by rhythm difficulty, normalised to represent rhythm's perceived contribution to star rating. - double rhythmFactor = DifficultyCalculationUtils.ReverseLerp(attributes.RhythmDifficulty / attributes.StarRating, 0.15, 0.4); - - // A penalty removing improperly played rhythm difficulty from star rating based on estimated unstable rate. - double rhythmPenalty = 1 - DifficultyCalculationUtils.Logistic( - estimatedUnstableRate.Value, - midpointOffset: (rhythmExpectedUnstableRate + rhythmMaximumUnstableRate) / 2, - multiplier: 10 / (rhythmMaximumUnstableRate - rhythmExpectedUnstableRate), - maxValue: 0.25 * Math.Pow(rhythmFactor, 3) - ); - - double baseDifficulty = 5 * Math.Max(1.0, attributes.StarRating * rhythmPenalty / 0.110) - 4.0; + double baseDifficulty = 5 * Math.Max(1.0, penalisedStarRating / 0.110) - 4.0; double difficultyValue = Math.Min(Math.Pow(baseDifficulty, 3) / 69052.51, Math.Pow(baseDifficulty, 2.25) / 1250.0); difficultyValue *= 1 + 0.10 * Math.Max(0, attributes.StarRating - 10); @@ -113,33 +100,41 @@ private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes double missPenalty = 0.97 + 0.03 * totalDifficultHits / (totalDifficultHits + 1500); difficultyValue *= Math.Pow(missPenalty, countMiss); - if (score.Mods.Any(m => m is ModHidden)) - { - double hiddenBonus = isConvert ? 0.025 : 0.1; + // Scale accuracy more harshly on nearly-completely mono (single coloured) speed maps. + double monoAccScalingExponent = 2 + attributes.MonoStaminaFactor; + double monoAccScalingShift = 500 - 100 * (attributes.MonoStaminaFactor * 3); + + return difficultyValue * Math.Pow(DifficultyCalculationUtils.Erf(monoAccScalingShift / (Math.Sqrt(2) * estimatedUnstableRate.Value)), monoAccScalingExponent); + } - // Hidden+flashlight plays are excluded from reading-based penalties to hidden. - if (!score.Mods.Any(m => m is ModFlashlight)) - { - // A penalty is applied to the bonus for hidden on non-classic scores, as the playfield can be made wider to make fast reading easier. - if (!isClassic) - hiddenBonus *= 0.2; + // A penalty removing improperly played rhythm difficulty from star rating based on estimated unstable rate. + private double calculateImproperlyPlayedRhythmPenalty(double rhythmDifficulty, double starRating) + { + // The estimated unstable rate for 100% accuracy, at which all rhythm difficulty has been played successfully. + double rhythmExpectedUnstableRate = computeDeviationUpperBound(1.0) * 10; - // A penalty is applied to classic easy+hidden scores, as notes disappear later making fast reading easier. - if (score.Mods.Any(m => m is ModEasy) && isClassic) - hiddenBonus *= 0.5; - } + // The unstable rate at which it can be assumed all rhythm difficulty has been ignored. + // 0.8 represents 80% of total hits being greats, or 90% accuracy in-game + double rhythmMaximumUnstableRate = computeDeviationUpperBound(0.8) * 10; - difficultyValue *= 1 + hiddenBonus; - } + // The fraction of star rating made up by rhythm difficulty, normalised to represent rhythm's perceived contribution to star rating. + double rhythmFactor = DifficultyCalculationUtils.ReverseLerp(rhythmDifficulty / starRating, 0.15, 0.4); - if (score.Mods.Any(m => m is ModFlashlight)) - difficultyValue *= Math.Max(1, 1.050 - Math.Min(attributes.MonoStaminaFactor / 50, 1) * lengthBonus); + return 1 - DifficultyCalculationUtils.Logistic( + estimatedUnstableRate.Value, + midpointOffset: (rhythmExpectedUnstableRate + rhythmMaximumUnstableRate) / 2, + multiplier: 10 / (rhythmMaximumUnstableRate - rhythmExpectedUnstableRate), + maxValue: 0.25 * Math.Pow(rhythmFactor, 3) + ); + } - // Scale accuracy more harshly on nearly-completely mono (single coloured) speed maps. - double monoAccScalingExponent = 2 + attributes.MonoStaminaFactor; - double monoAccScalingShift = 500 - 100 * (attributes.MonoStaminaFactor * 3); + // A penalty removing hidden reading difficulty unfairly awarded by playing on lazer from star rating. + private double calculateLazerHiddenReadingPenalty(double hiddenDifficulty, double starRating) + { + // The fraction of star rating made up by hidden reading difficulty, normalised to represent hidden reading's perceived contribution to star rating. + double hiddenFactor = DifficultyCalculationUtils.ReverseLerp(hiddenDifficulty / starRating, 0.1, 0.35); - return difficultyValue * Math.Pow(DifficultyCalculationUtils.Erf(monoAccScalingShift / (Math.Sqrt(2) * estimatedUnstableRate.Value)), monoAccScalingExponent); + return 1 - 0.15 * Math.Pow(hiddenFactor, 1.5); } private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert) @@ -152,18 +147,12 @@ private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes a // Scales up the bonus for lower unstable rate as star rating increases. accuracyValue *= 1 + Math.Pow(50 / estimatedUnstableRate.Value, 2) * Math.Pow(attributes.StarRating, 2.8) / 600; - if (score.Mods.Any(m => m is ModHidden) && !isConvert) - accuracyValue *= 1.075; + if (score.Mods.Any(m => m is ModHidden)) + accuracyValue *= 1.1; // Applies a bonus to maps with more total difficulty, calculating this with a map's total hits and consistency factor. accuracyValue *= 1 + 0.3 * totalDifficultHits / (totalDifficultHits + 4000); - // Applies a bonus to maps with more total memory required with HDFL. - double memoryLengthBonus = Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3)); - - if (score.Mods.Any(m => m is ModFlashlight) && score.Mods.Any(m => m is ModHidden) && !isConvert) - accuracyValue *= Math.Max(1.0, 1.05 * memoryLengthBonus); - return accuracyValue; }