From 62a04a93c837db0aef81557585120877f513660c Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 23 Nov 2023 16:22:34 +0900 Subject: [PATCH 1/5] Implement legacy catch health processor --- .../Scoring/CatchHealthProcessor.cs | 15 ++ .../Scoring/LegacyCatchHealthProcessor.cs | 237 ++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs create mode 100644 osu.Game.Rulesets.Catch/Scoring/LegacyCatchHealthProcessor.cs diff --git a/osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs b/osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs new file mode 100644 index 000000000000..7e8162bdfa6f --- /dev/null +++ b/osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Catch.Scoring +{ + public partial class CatchHealthProcessor : DrainingHealthProcessor + { + public CatchHealthProcessor(double drainStartTime) + : base(drainStartTime) + { + } + } +} diff --git a/osu.Game.Rulesets.Catch/Scoring/LegacyCatchHealthProcessor.cs b/osu.Game.Rulesets.Catch/Scoring/LegacyCatchHealthProcessor.cs new file mode 100644 index 000000000000..ef13ba222c32 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Scoring/LegacyCatchHealthProcessor.cs @@ -0,0 +1,237 @@ +// 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.Beatmaps.Timing; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Catch.Scoring +{ + /// + /// Reference implementation for osu!stable's HP drain. + /// Cannot be used for gameplay. + /// + public partial class LegacyCatchHealthProcessor : DrainingHealthProcessor + { + private const double hp_bar_maximum = 200; + private const double hp_combo_geki = 14; + private const double hp_hit_300 = 6; + private const double hp_slider_tick = 3; + + public Action? OnIterationFail; + public Action? OnIterationSuccess; + public bool ApplyComboEndBonus { get; set; } = true; + + private double lowestHpEver; + private double lowestHpEnd; + private double lowestHpComboEnd; + private double hpRecoveryAvailable; + private double hpMultiplierNormal; + private double hpMultiplierComboEnd; + + public LegacyCatchHealthProcessor(double drainStartTime) + : base(drainStartTime) + { + } + + public override void ApplyBeatmap(IBeatmap beatmap) + { + lowestHpEver = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 195, 160, 60); + lowestHpComboEnd = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 198, 170, 80); + lowestHpEnd = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 198, 180, 80); + hpRecoveryAvailable = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 8, 4, 0); + + base.ApplyBeatmap(beatmap); + } + + protected override void ApplyResultInternal(JudgementResult result) + { + if (!IsSimulating) + throw new NotSupportedException("The legacy catch health processor is not supported for gameplay."); + } + + protected override void RevertResultInternal(JudgementResult result) + { + if (!IsSimulating) + throw new NotSupportedException("The legacy catch health processor is not supported for gameplay."); + } + + protected override void Reset(bool storeResults) + { + hpMultiplierNormal = 1; + hpMultiplierComboEnd = 1; + + base.Reset(storeResults); + } + + protected override double ComputeDrainRate() + { + double testDrop = 0.05; + double currentHp; + double currentHpUncapped; + + List<(HitObject hitObject, bool newCombo)> allObjects = enumerateHitObjects(Beatmap).Where(h => h.hitObject is Fruit || h.hitObject is Droplet || h.hitObject is Banana).ToList(); + + do + { + currentHp = hp_bar_maximum; + currentHpUncapped = hp_bar_maximum; + + double lowestHp = currentHp; + double lastTime = DrainStartTime; + int currentBreak = 0; + bool fail = false; + int comboTooLowCount = 0; + string failReason = string.Empty; + + for (int i = 0; i < allObjects.Count; i++) + { + HitObject h = allObjects[i].hitObject; + + // Find active break (between current and lastTime) + double localLastTime = lastTime; + double breakTime = 0; + + // Subtract any break time from the duration since the last object + if (Beatmap.Breaks.Count > 0 && currentBreak < Beatmap.Breaks.Count) + { + BreakPeriod e = Beatmap.Breaks[currentBreak]; + + if (e.StartTime >= localLastTime && e.EndTime <= h.StartTime) + { + // consider break start equal to object end time for version 8+ since drain stops during this time + breakTime = (Beatmap.BeatmapInfo.BeatmapVersion < 8) ? (e.EndTime - e.StartTime) : e.EndTime - localLastTime; + currentBreak++; + } + } + + reduceHp(testDrop * (h.StartTime - lastTime - breakTime)); + + lastTime = h.GetEndTime(); + + if (currentHp < lowestHp) + lowestHp = currentHp; + + if (currentHp <= lowestHpEver) + { + fail = true; + testDrop *= 0.96; + failReason = $"hp too low ({currentHp / hp_bar_maximum} < {lowestHpEver / hp_bar_maximum})"; + break; + } + + switch (h) + { + case Fruit: + if (ApplyComboEndBonus && (i == allObjects.Count - 1 || allObjects[i + 1].newCombo)) + { + increaseHp(hpMultiplierComboEnd * hp_combo_geki + hpMultiplierNormal * hp_hit_300); + + if (currentHp < lowestHpComboEnd) + { + if (++comboTooLowCount > 2) + { + hpMultiplierComboEnd *= 1.07; + hpMultiplierNormal *= 1.03; + fail = true; + failReason = $"combo end hp too low ({currentHp / hp_bar_maximum} < {lowestHpComboEnd / hp_bar_maximum})"; + } + } + } + else + increaseHp(hpMultiplierNormal * hp_hit_300); + + break; + + case Banana: + increaseHp(hpMultiplierNormal / 2); + break; + + case TinyDroplet: + increaseHp(hpMultiplierNormal * hp_slider_tick * 0.1); + break; + + case Droplet: + increaseHp(hpMultiplierNormal * hp_slider_tick); + break; + } + + if (fail) + break; + } + + if (!fail && currentHp < lowestHpEnd) + { + fail = true; + testDrop *= 0.94; + hpMultiplierComboEnd *= 1.01; + hpMultiplierNormal *= 1.01; + failReason = $"end hp too low ({currentHp / hp_bar_maximum} < {lowestHpEnd / hp_bar_maximum})"; + } + + double recovery = (currentHpUncapped - hp_bar_maximum) / allObjects.Count; + + if (!fail && recovery < hpRecoveryAvailable) + { + fail = true; + testDrop *= 0.96; + hpMultiplierComboEnd *= 1.02; + hpMultiplierNormal *= 1.01; + failReason = $"recovery too low ({recovery / hp_bar_maximum} < {hpRecoveryAvailable / hp_bar_maximum})"; + } + + if (fail) + { + OnIterationFail?.Invoke($"FAILED drop {testDrop / hp_bar_maximum}: {failReason}"); + continue; + } + + OnIterationSuccess?.Invoke($"PASSED drop {testDrop / hp_bar_maximum}"); + return testDrop / hp_bar_maximum; + } while (true); + + void reduceHp(double amount) + { + currentHpUncapped = Math.Max(0, currentHpUncapped - amount); + currentHp = Math.Max(0, currentHp - amount); + } + + void increaseHp(double amount) + { + currentHpUncapped += amount; + currentHp = Math.Max(0, Math.Min(hp_bar_maximum, currentHp + amount)); + } + } + + private IEnumerable<(HitObject hitObject, bool newCombo)> enumerateHitObjects(IBeatmap beatmap) + { + return enumerateRecursively(beatmap.HitObjects); + + static IEnumerable<(HitObject hitObject, bool newCombo)> enumerateRecursively(IEnumerable hitObjects) + { + foreach (var hitObject in hitObjects) + { + // The combo end will either be attached to the hitobject itself if it has no children, or the very first child if it has children. + bool newCombo = (hitObject as IHasComboInformation)?.NewCombo ?? false; + + foreach ((HitObject nested, bool _) in enumerateRecursively(hitObject.NestedHitObjects)) + { + yield return (nested, newCombo); + + // Since the combo was attached to the first child, don't attach it to any other child or the parenting hitobject itself. + newCombo = false; + } + + yield return (hitObject, newCombo); + } + } + } + } +} From acf3de5e25f343f492bbf33135bb2160126a94e6 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 24 Nov 2023 13:22:46 +0900 Subject: [PATCH 2/5] Add CatchHealthProcessor, following legacy calculations --- .../Scoring/CatchHealthProcessor.cs | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs b/osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs index 7e8162bdfa6f..1f6a696f98c8 100644 --- a/osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs +++ b/osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs @@ -1,15 +1,175 @@ // 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.Beatmaps.Timing; +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 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); + + 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; + + 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]; + + double localLastTime = lastTime; + double breakTime = 0; + + if (Beatmap.Breaks.Count > 0 && currentBreak < Beatmap.Breaks.Count) + { + BreakPeriod e = Beatmap.Breaks[currentBreak]; + + if (e.StartTime >= localLastTime && e.EndTime <= h.StartTime) + { + // consider break start equal to object end time for version 8+ since drain stops during this time + breakTime = (Beatmap.BeatmapInfo.BeatmapVersion < 8) ? (e.EndTime - e.StartTime) : e.EndTime - localLastTime; + currentBreak++; + } + } + + reduceHp(testDrop * (h.StartTime - lastTime - breakTime)); + + 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) + { + double increase = 0; + + switch (result) + { + case HitResult.SmallTickMiss: + return 0; + + case HitResult.LargeTickMiss: + case HitResult.Miss: + return IBeatmapDifficultyInfo.DifficultyRange(Beatmap.Difficulty.DrainRate, -0.03, -0.125, -0.2); + + case HitResult.SmallTickHit: + increase = 0.0015; + break; + + case HitResult.LargeTickHit: + increase = 0.015; + break; + + case HitResult.Great: + increase = 0.03; + break; + + case HitResult.LargeBonus: + increase = 0.0025; + break; + } + + return hpMultiplierNormal * increase; + } } } From 4ba6450c7703bbd44e97a5b3180a5afc4a5115ab Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 24 Nov 2023 13:32:14 +0900 Subject: [PATCH 3/5] Use better break calculation --- .../Scoring/CatchHealthProcessor.cs | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs b/osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs index 1f6a696f98c8..6d831ad223e2 100644 --- a/osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs +++ b/osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; -using osu.Game.Beatmaps.Timing; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; @@ -65,22 +64,16 @@ protected override double ComputeDrainRate() { HitObject h = allObjects[i]; - double localLastTime = lastTime; - double breakTime = 0; - - if (Beatmap.Breaks.Count > 0 && currentBreak < Beatmap.Breaks.Count) + while (currentBreak < Beatmap.Breaks.Count && Beatmap.Breaks[currentBreak].EndTime <= h.StartTime) { - BreakPeriod e = Beatmap.Breaks[currentBreak]; - - if (e.StartTime >= localLastTime && e.EndTime <= h.StartTime) - { - // consider break start equal to object end time for version 8+ since drain stops during this time - breakTime = (Beatmap.BeatmapInfo.BeatmapVersion < 8) ? (e.EndTime - e.StartTime) : e.EndTime - localLastTime; - currentBreak++; - } + // 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 - breakTime)); + reduceHp(testDrop * (h.StartTime - lastTime)); lastTime = h.GetEndTime(); From bb662676347c79343da3ba480693ff5c272b6878 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 24 Nov 2023 13:46:41 +0900 Subject: [PATCH 4/5] Actually use CatchHealthProcessor for the ruleset --- osu.Game.Rulesets.Catch/CatchRuleset.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/osu.Game.Rulesets.Catch/CatchRuleset.cs b/osu.Game.Rulesets.Catch/CatchRuleset.cs index 9ceb78893ede..013a70966380 100644 --- a/osu.Game.Rulesets.Catch/CatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/CatchRuleset.cs @@ -39,6 +39,8 @@ public class CatchRuleset : Ruleset, ILegacyRuleset public override ScoreProcessor CreateScoreProcessor() => new CatchScoreProcessor(); + public override HealthProcessor CreateHealthProcessor(double drainStartTime) => new CatchHealthProcessor(drainStartTime); + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new CatchBeatmapConverter(beatmap, this); public override IBeatmapProcessor CreateBeatmapProcessor(IBeatmap beatmap) => new CatchBeatmapProcessor(beatmap); From c126c46e2d25bcf2b1853ef80b869a230a9bb8ff Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 24 Nov 2023 15:43:57 +0900 Subject: [PATCH 5/5] Remove legacy implementations (moved to osu-tools) --- .../Scoring/LegacyCatchHealthProcessor.cs | 237 ------------------ .../Scoring/LegacyOsuHealthProcessor.cs | 215 ---------------- 2 files changed, 452 deletions(-) delete mode 100644 osu.Game.Rulesets.Catch/Scoring/LegacyCatchHealthProcessor.cs delete mode 100644 osu.Game.Rulesets.Osu/Scoring/LegacyOsuHealthProcessor.cs diff --git a/osu.Game.Rulesets.Catch/Scoring/LegacyCatchHealthProcessor.cs b/osu.Game.Rulesets.Catch/Scoring/LegacyCatchHealthProcessor.cs deleted file mode 100644 index ef13ba222c32..000000000000 --- a/osu.Game.Rulesets.Catch/Scoring/LegacyCatchHealthProcessor.cs +++ /dev/null @@ -1,237 +0,0 @@ -// 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.Beatmaps.Timing; -using osu.Game.Rulesets.Catch.Objects; -using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Objects.Types; -using osu.Game.Rulesets.Scoring; - -namespace osu.Game.Rulesets.Catch.Scoring -{ - /// - /// Reference implementation for osu!stable's HP drain. - /// Cannot be used for gameplay. - /// - public partial class LegacyCatchHealthProcessor : DrainingHealthProcessor - { - private const double hp_bar_maximum = 200; - private const double hp_combo_geki = 14; - private const double hp_hit_300 = 6; - private const double hp_slider_tick = 3; - - public Action? OnIterationFail; - public Action? OnIterationSuccess; - public bool ApplyComboEndBonus { get; set; } = true; - - private double lowestHpEver; - private double lowestHpEnd; - private double lowestHpComboEnd; - private double hpRecoveryAvailable; - private double hpMultiplierNormal; - private double hpMultiplierComboEnd; - - public LegacyCatchHealthProcessor(double drainStartTime) - : base(drainStartTime) - { - } - - public override void ApplyBeatmap(IBeatmap beatmap) - { - lowestHpEver = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 195, 160, 60); - lowestHpComboEnd = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 198, 170, 80); - lowestHpEnd = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 198, 180, 80); - hpRecoveryAvailable = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 8, 4, 0); - - base.ApplyBeatmap(beatmap); - } - - protected override void ApplyResultInternal(JudgementResult result) - { - if (!IsSimulating) - throw new NotSupportedException("The legacy catch health processor is not supported for gameplay."); - } - - protected override void RevertResultInternal(JudgementResult result) - { - if (!IsSimulating) - throw new NotSupportedException("The legacy catch health processor is not supported for gameplay."); - } - - protected override void Reset(bool storeResults) - { - hpMultiplierNormal = 1; - hpMultiplierComboEnd = 1; - - base.Reset(storeResults); - } - - protected override double ComputeDrainRate() - { - double testDrop = 0.05; - double currentHp; - double currentHpUncapped; - - List<(HitObject hitObject, bool newCombo)> allObjects = enumerateHitObjects(Beatmap).Where(h => h.hitObject is Fruit || h.hitObject is Droplet || h.hitObject is Banana).ToList(); - - do - { - currentHp = hp_bar_maximum; - currentHpUncapped = hp_bar_maximum; - - double lowestHp = currentHp; - double lastTime = DrainStartTime; - int currentBreak = 0; - bool fail = false; - int comboTooLowCount = 0; - string failReason = string.Empty; - - for (int i = 0; i < allObjects.Count; i++) - { - HitObject h = allObjects[i].hitObject; - - // Find active break (between current and lastTime) - double localLastTime = lastTime; - double breakTime = 0; - - // Subtract any break time from the duration since the last object - if (Beatmap.Breaks.Count > 0 && currentBreak < Beatmap.Breaks.Count) - { - BreakPeriod e = Beatmap.Breaks[currentBreak]; - - if (e.StartTime >= localLastTime && e.EndTime <= h.StartTime) - { - // consider break start equal to object end time for version 8+ since drain stops during this time - breakTime = (Beatmap.BeatmapInfo.BeatmapVersion < 8) ? (e.EndTime - e.StartTime) : e.EndTime - localLastTime; - currentBreak++; - } - } - - reduceHp(testDrop * (h.StartTime - lastTime - breakTime)); - - lastTime = h.GetEndTime(); - - if (currentHp < lowestHp) - lowestHp = currentHp; - - if (currentHp <= lowestHpEver) - { - fail = true; - testDrop *= 0.96; - failReason = $"hp too low ({currentHp / hp_bar_maximum} < {lowestHpEver / hp_bar_maximum})"; - break; - } - - switch (h) - { - case Fruit: - if (ApplyComboEndBonus && (i == allObjects.Count - 1 || allObjects[i + 1].newCombo)) - { - increaseHp(hpMultiplierComboEnd * hp_combo_geki + hpMultiplierNormal * hp_hit_300); - - if (currentHp < lowestHpComboEnd) - { - if (++comboTooLowCount > 2) - { - hpMultiplierComboEnd *= 1.07; - hpMultiplierNormal *= 1.03; - fail = true; - failReason = $"combo end hp too low ({currentHp / hp_bar_maximum} < {lowestHpComboEnd / hp_bar_maximum})"; - } - } - } - else - increaseHp(hpMultiplierNormal * hp_hit_300); - - break; - - case Banana: - increaseHp(hpMultiplierNormal / 2); - break; - - case TinyDroplet: - increaseHp(hpMultiplierNormal * hp_slider_tick * 0.1); - break; - - case Droplet: - increaseHp(hpMultiplierNormal * hp_slider_tick); - break; - } - - if (fail) - break; - } - - if (!fail && currentHp < lowestHpEnd) - { - fail = true; - testDrop *= 0.94; - hpMultiplierComboEnd *= 1.01; - hpMultiplierNormal *= 1.01; - failReason = $"end hp too low ({currentHp / hp_bar_maximum} < {lowestHpEnd / hp_bar_maximum})"; - } - - double recovery = (currentHpUncapped - hp_bar_maximum) / allObjects.Count; - - if (!fail && recovery < hpRecoveryAvailable) - { - fail = true; - testDrop *= 0.96; - hpMultiplierComboEnd *= 1.02; - hpMultiplierNormal *= 1.01; - failReason = $"recovery too low ({recovery / hp_bar_maximum} < {hpRecoveryAvailable / hp_bar_maximum})"; - } - - if (fail) - { - OnIterationFail?.Invoke($"FAILED drop {testDrop / hp_bar_maximum}: {failReason}"); - continue; - } - - OnIterationSuccess?.Invoke($"PASSED drop {testDrop / hp_bar_maximum}"); - return testDrop / hp_bar_maximum; - } while (true); - - void reduceHp(double amount) - { - currentHpUncapped = Math.Max(0, currentHpUncapped - amount); - currentHp = Math.Max(0, currentHp - amount); - } - - void increaseHp(double amount) - { - currentHpUncapped += amount; - currentHp = Math.Max(0, Math.Min(hp_bar_maximum, currentHp + amount)); - } - } - - private IEnumerable<(HitObject hitObject, bool newCombo)> enumerateHitObjects(IBeatmap beatmap) - { - return enumerateRecursively(beatmap.HitObjects); - - static IEnumerable<(HitObject hitObject, bool newCombo)> enumerateRecursively(IEnumerable hitObjects) - { - foreach (var hitObject in hitObjects) - { - // The combo end will either be attached to the hitobject itself if it has no children, or the very first child if it has children. - bool newCombo = (hitObject as IHasComboInformation)?.NewCombo ?? false; - - foreach ((HitObject nested, bool _) in enumerateRecursively(hitObject.NestedHitObjects)) - { - yield return (nested, newCombo); - - // Since the combo was attached to the first child, don't attach it to any other child or the parenting hitobject itself. - newCombo = false; - } - - yield return (hitObject, newCombo); - } - } - } - } -} diff --git a/osu.Game.Rulesets.Osu/Scoring/LegacyOsuHealthProcessor.cs b/osu.Game.Rulesets.Osu/Scoring/LegacyOsuHealthProcessor.cs deleted file mode 100644 index e92c3c9b97d0..000000000000 --- a/osu.Game.Rulesets.Osu/Scoring/LegacyOsuHealthProcessor.cs +++ /dev/null @@ -1,215 +0,0 @@ -// 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.Linq; -using osu.Game.Beatmaps; -using osu.Game.Beatmaps.Timing; -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 -{ - /// - /// Reference implementation for osu!stable's HP drain. - /// Cannot be used for gameplay. - /// - public partial class LegacyOsuHealthProcessor : DrainingHealthProcessor - { - private const double hp_bar_maximum = 200; - private const double hp_combo_geki = 14; - private const double hp_hit_300 = 6; - private const double hp_slider_repeat = 4; - private const double hp_slider_tick = 3; - - public Action? OnIterationFail; - public Action? OnIterationSuccess; - public bool ApplyComboEndBonus { get; set; } = true; - - private double lowestHpEver; - private double lowestHpEnd; - private double lowestHpComboEnd; - private double hpRecoveryAvailable; - private double hpMultiplierNormal; - private double hpMultiplierComboEnd; - - public LegacyOsuHealthProcessor(double drainStartTime) - : base(drainStartTime) - { - } - - public override void ApplyBeatmap(IBeatmap beatmap) - { - lowestHpEver = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 195, 160, 60); - lowestHpComboEnd = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 198, 170, 80); - lowestHpEnd = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 198, 180, 80); - hpRecoveryAvailable = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 8, 4, 0); - - base.ApplyBeatmap(beatmap); - } - - protected override void ApplyResultInternal(JudgementResult result) - { - if (!IsSimulating) - throw new NotSupportedException("The legacy osu! health processor is not supported for gameplay."); - } - - protected override void RevertResultInternal(JudgementResult result) - { - if (!IsSimulating) - throw new NotSupportedException("The legacy osu! health processor is not supported for gameplay."); - } - - protected override void Reset(bool storeResults) - { - hpMultiplierNormal = 1; - hpMultiplierComboEnd = 1; - - base.Reset(storeResults); - } - - protected override double ComputeDrainRate() - { - double testDrop = 0.05; - double currentHp; - double currentHpUncapped; - - do - { - currentHp = hp_bar_maximum; - currentHpUncapped = hp_bar_maximum; - - double lowestHp = currentHp; - double lastTime = DrainStartTime; - int currentBreak = 0; - bool fail = false; - int comboTooLowCount = 0; - string failReason = string.Empty; - - for (int i = 0; i < Beatmap.HitObjects.Count; i++) - { - HitObject h = Beatmap.HitObjects[i]; - - // Find active break (between current and lastTime) - double localLastTime = lastTime; - double breakTime = 0; - - // Subtract any break time from the duration since the last object - if (Beatmap.Breaks.Count > 0 && currentBreak < Beatmap.Breaks.Count) - { - BreakPeriod e = Beatmap.Breaks[currentBreak]; - - if (e.StartTime >= localLastTime && e.EndTime <= h.StartTime) - { - // consider break start equal to object end time for version 8+ since drain stops during this time - breakTime = (Beatmap.BeatmapInfo.BeatmapVersion < 8) ? (e.EndTime - e.StartTime) : e.EndTime - localLastTime; - currentBreak++; - } - } - - reduceHp(testDrop * (h.StartTime - lastTime - breakTime)); - - lastTime = h.GetEndTime(); - - if (currentHp < lowestHp) - lowestHp = currentHp; - - if (currentHp <= lowestHpEver) - { - fail = true; - testDrop *= 0.96; - failReason = $"hp too low ({currentHp / hp_bar_maximum} < {lowestHpEver / hp_bar_maximum})"; - break; - } - - double hpReduction = testDrop * (h.GetEndTime() - h.StartTime); - double hpOverkill = Math.Max(0, hpReduction - currentHp); - reduceHp(hpReduction); - - if (h is Slider slider) - { - for (int j = 0; j < slider.RepeatCount + 2; j++) - increaseHp(hpMultiplierNormal * hp_slider_repeat); - foreach (var _ in slider.NestedHitObjects.OfType()) - increaseHp(hpMultiplierNormal * hp_slider_tick); - } - else if (h is Spinner spinner) - { - foreach (var _ in spinner.NestedHitObjects.Where(t => t is not SpinnerBonusTick)) - increaseHp(hpMultiplierNormal * 1.7); - } - - if (hpOverkill > 0 && currentHp - hpOverkill <= lowestHpEver) - { - fail = true; - testDrop *= 0.96; - failReason = $"overkill ({currentHp / hp_bar_maximum} - {hpOverkill / hp_bar_maximum} <= {lowestHpEver / hp_bar_maximum})"; - break; - } - - if (ApplyComboEndBonus && (i == Beatmap.HitObjects.Count - 1 || ((OsuHitObject)Beatmap.HitObjects[i + 1]).NewCombo)) - { - increaseHp(hpMultiplierComboEnd * hp_combo_geki + hpMultiplierNormal * hp_hit_300); - - if (currentHp < lowestHpComboEnd) - { - if (++comboTooLowCount > 2) - { - hpMultiplierComboEnd *= 1.07; - hpMultiplierNormal *= 1.03; - fail = true; - failReason = $"combo end hp too low ({currentHp / hp_bar_maximum} < {lowestHpComboEnd / hp_bar_maximum})"; - break; - } - } - } - else - increaseHp(hpMultiplierNormal * hp_hit_300); - } - - if (!fail && currentHp < lowestHpEnd) - { - fail = true; - testDrop *= 0.94; - hpMultiplierComboEnd *= 1.01; - hpMultiplierNormal *= 1.01; - failReason = $"end hp too low ({currentHp / hp_bar_maximum} < {lowestHpEnd / hp_bar_maximum})"; - } - - double recovery = (currentHpUncapped - hp_bar_maximum) / Beatmap.HitObjects.Count; - - if (!fail && recovery < hpRecoveryAvailable) - { - fail = true; - testDrop *= 0.96; - hpMultiplierComboEnd *= 1.02; - hpMultiplierNormal *= 1.01; - failReason = $"recovery too low ({recovery / hp_bar_maximum} < {hpRecoveryAvailable / hp_bar_maximum})"; - } - - if (fail) - { - OnIterationFail?.Invoke($"FAILED drop {testDrop / hp_bar_maximum}: {failReason}"); - continue; - } - - OnIterationSuccess?.Invoke($"PASSED drop {testDrop / hp_bar_maximum}"); - return testDrop / hp_bar_maximum; - } while (true); - - void reduceHp(double amount) - { - currentHpUncapped = Math.Max(0, currentHpUncapped - amount); - currentHp = Math.Max(0, currentHp - amount); - } - - void increaseHp(double amount) - { - currentHpUncapped += amount; - currentHp = Math.Max(0, Math.Min(hp_bar_maximum, currentHp + amount)); - } - } - } -}