From 8314f656a3107b27fc82ddbad448de4c70bdf41d Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 24 Nov 2023 17:32:18 +0900 Subject: [PATCH] Encapsulate common HP logic from osu and catch HP calculations --- .../Scoring/CatchHealthProcessor.cs | 121 +------------- .../Scoring/OsuHealthProcessor.cs | 155 ++--------------- .../Scoring/LegacyDrainingHealthProcessor.cs | 158 ++++++++++++++++++ 3 files changed, 179 insertions(+), 255 deletions(-) create mode 100644 osu.Game/Rulesets/Scoring/LegacyDrainingHealthProcessor.cs diff --git a/osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs b/osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs index 6d831ad223..c3cc488941 100644 --- a/osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs +++ b/osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs @@ -1,138 +1,27 @@ // 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.Catch.Objects; -using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Catch.Scoring { - public partial class CatchHealthProcessor : DrainingHealthProcessor + public partial class CatchHealthProcessor : LegacyDrainingHealthProcessor { - public Action? OnIterationFail; - public Action? OnIterationSuccess; - - private double lowestHpEver; - private double lowestHpEnd; - private double hpRecoveryAvailable; - private double hpMultiplierNormal; - public CatchHealthProcessor(double drainStartTime) : base(drainStartTime) { } - public override void ApplyBeatmap(IBeatmap beatmap) - { - lowestHpEver = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 0.975, 0.8, 0.3); - lowestHpEnd = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 0.99, 0.9, 0.4); - hpRecoveryAvailable = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 0.04, 0.02, 0); + protected override IEnumerable EnumerateTopLevelHitObjects() => EnumerateHitObjects(Beatmap).Where(h => h is Fruit || h is Droplet || h is Banana); - base.ApplyBeatmap(beatmap); - } + protected override IEnumerable EnumerateNestedHitObjects(HitObject hitObject) => Enumerable.Empty(); - protected override void Reset(bool storeResults) - { - hpMultiplierNormal = 1; - base.Reset(storeResults); - } - - protected override double ComputeDrainRate() - { - double testDrop = 0.00025; - double currentHp; - double currentHpUncapped; - - while (true) - { - currentHp = 1; - currentHpUncapped = 1; - - double lowestHp = currentHp; - double lastTime = DrainStartTime; - int currentBreak = 0; - bool fail = false; - - List allObjects = EnumerateHitObjects(Beatmap).Where(h => h is Fruit || h is Droplet || h is Banana).ToList(); - - for (int i = 0; i < allObjects.Count; i++) - { - HitObject h = allObjects[i]; - - while (currentBreak < Beatmap.Breaks.Count && Beatmap.Breaks[currentBreak].EndTime <= h.StartTime) - { - // If two hitobjects are separated by a break period, there is no drain for the full duration between the hitobjects. - // This differs from legacy (version < 8) beatmaps which continue draining until the break section is entered, - // but this shouldn't have a noticeable impact in practice. - lastTime = h.StartTime; - currentBreak++; - } - - reduceHp(testDrop * (h.StartTime - lastTime)); - - lastTime = h.GetEndTime(); - - if (currentHp < lowestHp) - lowestHp = currentHp; - - if (currentHp <= lowestHpEver) - { - fail = true; - testDrop *= 0.96; - OnIterationFail?.Invoke($"FAILED drop {testDrop}: hp too low ({currentHp} < {lowestHpEver})"); - break; - } - - increaseHp(h); - } - - if (!fail && currentHp < lowestHpEnd) - { - fail = true; - testDrop *= 0.94; - hpMultiplierNormal *= 1.01; - OnIterationFail?.Invoke($"FAILED drop {testDrop}: end hp too low ({currentHp} < {lowestHpEnd})"); - } - - double recovery = (currentHpUncapped - 1) / allObjects.Count; - - if (!fail && recovery < hpRecoveryAvailable) - { - fail = true; - testDrop *= 0.96; - hpMultiplierNormal *= 1.01; - OnIterationFail?.Invoke($"FAILED drop {testDrop}: recovery too low ({recovery} < {hpRecoveryAvailable})"); - } - - if (!fail) - { - OnIterationSuccess?.Invoke($"PASSED drop {testDrop}"); - return testDrop; - } - } - - void reduceHp(double amount) - { - currentHpUncapped = Math.Max(0, currentHpUncapped - amount); - currentHp = Math.Max(0, currentHp - amount); - } - - void increaseHp(HitObject hitObject) - { - double amount = healthIncreaseFor(hitObject.CreateJudgement().MaxResult); - currentHpUncapped += amount; - currentHp = Math.Max(0, Math.Min(1, currentHp + amount)); - } - } - - protected override double GetHealthIncreaseFor(JudgementResult result) => healthIncreaseFor(result.Type); - - private double healthIncreaseFor(HitResult result) + protected override double GetHealthIncreaseFor(HitObject hitObject, HitResult result) { double increase = 0; @@ -162,7 +51,7 @@ private double healthIncreaseFor(HitResult result) break; } - return hpMultiplierNormal * increase; + return HpMultiplierNormal * increase; } } } diff --git a/osu.Game.Rulesets.Osu/Scoring/OsuHealthProcessor.cs b/osu.Game.Rulesets.Osu/Scoring/OsuHealthProcessor.cs index 3c124b3162..7025a7be65 100644 --- a/osu.Game.Rulesets.Osu/Scoring/OsuHealthProcessor.cs +++ b/osu.Game.Rulesets.Osu/Scoring/OsuHealthProcessor.cs @@ -1,166 +1,43 @@ // 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.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Scoring { - public partial class OsuHealthProcessor : DrainingHealthProcessor + public partial class OsuHealthProcessor : LegacyDrainingHealthProcessor { - public Action? OnIterationFail; - public Action? OnIterationSuccess; - - private double lowestHpEver; - private double lowestHpEnd; - private double hpRecoveryAvailable; - private double hpMultiplierNormal; - public OsuHealthProcessor(double drainStartTime) : base(drainStartTime) { } - public override void ApplyBeatmap(IBeatmap beatmap) + protected override IEnumerable EnumerateTopLevelHitObjects() => Beatmap.HitObjects; + + protected override IEnumerable EnumerateNestedHitObjects(HitObject hitObject) { - lowestHpEver = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 0.975, 0.8, 0.3); - lowestHpEnd = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 0.99, 0.9, 0.4); - hpRecoveryAvailable = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 0.04, 0.02, 0); - - base.ApplyBeatmap(beatmap); - } - - protected override void Reset(bool storeResults) - { - hpMultiplierNormal = 1; - base.Reset(storeResults); - } - - protected override double ComputeDrainRate() - { - double testDrop = 0.00025; - double currentHp; - double currentHpUncapped; - - while (true) + switch (hitObject) { - currentHp = 1; - currentHpUncapped = 1; + case Slider slider: + foreach (var nested in slider.NestedHitObjects) + yield return nested; - double lowestHp = currentHp; - double lastTime = DrainStartTime; - int currentBreak = 0; - bool fail = false; + break; - for (int i = 0; i < Beatmap.HitObjects.Count; i++) - { - HitObject h = Beatmap.HitObjects[i]; + case Spinner spinner: + foreach (var nested in spinner.NestedHitObjects.Where(t => t is not SpinnerBonusTick)) + yield return nested; - while (currentBreak < Beatmap.Breaks.Count && Beatmap.Breaks[currentBreak].EndTime <= h.StartTime) - { - // If two hitobjects are separated by a break period, there is no drain for the full duration between the hitobjects. - // This differs from legacy (version < 8) beatmaps which continue draining until the break section is entered, - // but this shouldn't have a noticeable impact in practice. - lastTime = h.StartTime; - currentBreak++; - } - - reduceHp(testDrop * (h.StartTime - lastTime)); - - lastTime = h.GetEndTime(); - - if (currentHp < lowestHp) - lowestHp = currentHp; - - if (currentHp <= lowestHpEver) - { - fail = true; - testDrop *= 0.96; - OnIterationFail?.Invoke($"FAILED drop {testDrop}: hp too low ({currentHp} < {lowestHpEver})"); - break; - } - - double hpReduction = testDrop * (h.GetEndTime() - h.StartTime); - double hpOverkill = Math.Max(0, hpReduction - currentHp); - reduceHp(hpReduction); - - switch (h) - { - case Slider slider: - { - foreach (var nested in slider.NestedHitObjects) - increaseHp(nested); - break; - } - - case Spinner spinner: - { - foreach (var nested in spinner.NestedHitObjects.Where(t => t is not SpinnerBonusTick)) - increaseHp(nested); - break; - } - } - - // Note: Because HP is capped during the above increases, long sliders (with many ticks) or spinners - // will appear to overkill at lower drain levels than they should. However, it is also not correct to simply use the uncapped version. - if (hpOverkill > 0 && currentHp - hpOverkill <= lowestHpEver) - { - fail = true; - testDrop *= 0.96; - OnIterationFail?.Invoke($"FAILED drop {testDrop}: overkill ({currentHp} - {hpOverkill} <= {lowestHpEver})"); - break; - } - - increaseHp(h); - } - - if (!fail && currentHp < lowestHpEnd) - { - fail = true; - testDrop *= 0.94; - hpMultiplierNormal *= 1.01; - OnIterationFail?.Invoke($"FAILED drop {testDrop}: end hp too low ({currentHp} < {lowestHpEnd})"); - } - - double recovery = (currentHpUncapped - 1) / Beatmap.HitObjects.Count; - - if (!fail && recovery < hpRecoveryAvailable) - { - fail = true; - testDrop *= 0.96; - hpMultiplierNormal *= 1.01; - OnIterationFail?.Invoke($"FAILED drop {testDrop}: recovery too low ({recovery} < {hpRecoveryAvailable})"); - } - - if (!fail) - { - OnIterationSuccess?.Invoke($"PASSED drop {testDrop}"); - return testDrop; - } - } - - void reduceHp(double amount) - { - currentHpUncapped = Math.Max(0, currentHpUncapped - amount); - currentHp = Math.Max(0, currentHp - amount); - } - - void increaseHp(HitObject hitObject) - { - double amount = healthIncreaseFor(hitObject, hitObject.CreateJudgement().MaxResult); - currentHpUncapped += amount; - currentHp = Math.Max(0, Math.Min(1, currentHp + amount)); + break; } } - protected override double GetHealthIncreaseFor(JudgementResult result) => healthIncreaseFor(result.HitObject, result.Type); - - private double healthIncreaseFor(HitObject hitObject, HitResult result) + protected override double GetHealthIncreaseFor(HitObject hitObject, HitResult result) { double increase = 0; @@ -206,7 +83,7 @@ private double healthIncreaseFor(HitObject hitObject, HitResult result) break; } - return hpMultiplierNormal * increase; + return HpMultiplierNormal * increase; } } } diff --git a/osu.Game/Rulesets/Scoring/LegacyDrainingHealthProcessor.cs b/osu.Game/Rulesets/Scoring/LegacyDrainingHealthProcessor.cs new file mode 100644 index 0000000000..ce2f7d5624 --- /dev/null +++ b/osu.Game/Rulesets/Scoring/LegacyDrainingHealthProcessor.cs @@ -0,0 +1,158 @@ +// 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 osu.Game.Beatmaps; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Rulesets.Scoring +{ + /// + /// A that matches legacy drain rate calculations as best as possible. + /// + public abstract partial class LegacyDrainingHealthProcessor : DrainingHealthProcessor + { + public Action? OnIterationFail; + public Action? OnIterationSuccess; + + protected double HpMultiplierNormal { get; private set; } + + private double lowestHpEver; + private double lowestHpEnd; + private double hpRecoveryAvailable; + + protected LegacyDrainingHealthProcessor(double drainStartTime) + : base(drainStartTime) + { + } + + public override void ApplyBeatmap(IBeatmap beatmap) + { + lowestHpEver = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 0.975, 0.8, 0.3); + lowestHpEnd = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 0.99, 0.9, 0.4); + hpRecoveryAvailable = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 0.04, 0.02, 0); + + base.ApplyBeatmap(beatmap); + } + + protected override void Reset(bool storeResults) + { + HpMultiplierNormal = 1; + base.Reset(storeResults); + } + + protected override double ComputeDrainRate() + { + double testDrop = 0.00025; + double currentHp; + double currentHpUncapped; + + while (true) + { + currentHp = 1; + currentHpUncapped = 1; + + double lowestHp = currentHp; + double lastTime = DrainStartTime; + int currentBreak = 0; + bool fail = false; + int topLevelObjectCount = 0; + + foreach (var h in EnumerateTopLevelHitObjects()) + { + topLevelObjectCount++; + + while (currentBreak < Beatmap.Breaks.Count && Beatmap.Breaks[currentBreak].EndTime <= h.StartTime) + { + // If two hitobjects are separated by a break period, there is no drain for the full duration between the hitobjects. + // This differs from legacy (version < 8) beatmaps which continue draining until the break section is entered, + // but this shouldn't have a noticeable impact in practice. + lastTime = h.StartTime; + currentBreak++; + } + + reduceHp(testDrop * (h.StartTime - lastTime)); + + lastTime = h.GetEndTime(); + + if (currentHp < lowestHp) + lowestHp = currentHp; + + if (currentHp <= lowestHpEver) + { + fail = true; + testDrop *= 0.96; + OnIterationFail?.Invoke($"FAILED drop {testDrop}: hp too low ({currentHp} < {lowestHpEver})"); + break; + } + + double hpReduction = testDrop * (h.GetEndTime() - h.StartTime); + double hpOverkill = Math.Max(0, hpReduction - currentHp); + reduceHp(hpReduction); + + foreach (var nested in EnumerateNestedHitObjects(h)) + increaseHp(nested); + + // Note: Because HP is capped during the above increases, long sliders (with many ticks) or spinners + // will appear to overkill at lower drain levels than they should. However, it is also not correct to simply use the uncapped version. + if (hpOverkill > 0 && currentHp - hpOverkill <= lowestHpEver) + { + fail = true; + testDrop *= 0.96; + OnIterationFail?.Invoke($"FAILED drop {testDrop}: overkill ({currentHp} - {hpOverkill} <= {lowestHpEver})"); + break; + } + + increaseHp(h); + } + + if (!fail && currentHp < lowestHpEnd) + { + fail = true; + testDrop *= 0.94; + HpMultiplierNormal *= 1.01; + OnIterationFail?.Invoke($"FAILED drop {testDrop}: end hp too low ({currentHp} < {lowestHpEnd})"); + } + + double recovery = (currentHpUncapped - 1) / Math.Max(1, topLevelObjectCount); + + if (!fail && recovery < hpRecoveryAvailable) + { + fail = true; + testDrop *= 0.96; + HpMultiplierNormal *= 1.01; + OnIterationFail?.Invoke($"FAILED drop {testDrop}: recovery too low ({recovery} < {hpRecoveryAvailable})"); + } + + if (!fail) + { + OnIterationSuccess?.Invoke($"PASSED drop {testDrop}"); + return testDrop; + } + } + + void reduceHp(double amount) + { + currentHpUncapped = Math.Max(0, currentHpUncapped - amount); + currentHp = Math.Max(0, currentHp - amount); + } + + void increaseHp(HitObject hitObject) + { + double amount = GetHealthIncreaseFor(hitObject, hitObject.CreateJudgement().MaxResult); + currentHpUncapped += amount; + currentHp = Math.Max(0, Math.Min(1, currentHp + amount)); + } + } + + protected sealed override double GetHealthIncreaseFor(JudgementResult result) => GetHealthIncreaseFor(result.HitObject, result.Type); + + protected abstract IEnumerable EnumerateTopLevelHitObjects(); + + protected abstract IEnumerable EnumerateNestedHitObjects(HitObject hitObject); + + protected abstract double GetHealthIncreaseFor(HitObject hitObject, HitResult result); + } +}