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); diff --git a/osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs b/osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs new file mode 100644 index 000000000000..6d831ad223e2 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs @@ -0,0 +1,168 @@ +// 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 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]; + + 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) + { + 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; + } + } +} 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)); - } - } - } -}