From fecc6f580bf309357682d1ee725f0a3796d9261a Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Mon, 23 Oct 2023 16:37:26 +0900 Subject: [PATCH] Add legacy reference health processor --- .../Scoring/LegacyOsuHealthProcessor.cs | 197 ++++++++++++++++++ .../Scoring/DrainingHealthProcessor.cs | 1 - .../Scoring/LegacyDrainingHealthProcessor.cs | 90 ++++++++ 3 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 osu.Game.Rulesets.Osu/Scoring/LegacyOsuHealthProcessor.cs create mode 100644 osu.Game/Rulesets/Scoring/LegacyDrainingHealthProcessor.cs diff --git a/osu.Game.Rulesets.Osu/Scoring/LegacyOsuHealthProcessor.cs b/osu.Game.Rulesets.Osu/Scoring/LegacyOsuHealthProcessor.cs new file mode 100644 index 0000000000..103569ffc3 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Scoring/LegacyOsuHealthProcessor.cs @@ -0,0 +1,197 @@ +// 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.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. + public partial class LegacyOsuHealthProcessor : LegacyDrainingHealthProcessor + { + 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; + + 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 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 (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) + { + if (Log) + Console.WriteLine($"FAILED drop {testDrop / hp_bar_maximum}: {failReason}"); + continue; + } + + if (Log) + Console.WriteLine($"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)); + } + } + } +} diff --git a/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs b/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs index 592dcbfeb8..2f81aa735e 100644 --- a/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs +++ b/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs @@ -42,7 +42,6 @@ namespace osu.Game.Rulesets.Scoring private const double max_health_target = 0.4; private IBeatmap beatmap; - private double gameplayEndTime; private readonly double drainStartTime; diff --git a/osu.Game/Rulesets/Scoring/LegacyDrainingHealthProcessor.cs b/osu.Game/Rulesets/Scoring/LegacyDrainingHealthProcessor.cs new file mode 100644 index 0000000000..5d2426e4b7 --- /dev/null +++ b/osu.Game/Rulesets/Scoring/LegacyDrainingHealthProcessor.cs @@ -0,0 +1,90 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable disable + +using System; +using System.Linq; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects; +using osu.Game.Utils; + +namespace osu.Game.Rulesets.Scoring +{ + /// + /// A which continuously drains health.
+ /// At HP=0, the minimum health reached for a perfect play is 95%.
+ /// At HP=5, the minimum health reached for a perfect play is 70%.
+ /// At HP=10, the minimum health reached for a perfect play is 30%. + ///
+ public abstract partial class LegacyDrainingHealthProcessor : HealthProcessor + { + protected double DrainStartTime { get; } + protected double GameplayEndTime { get; private set; } + + protected IBeatmap Beatmap { get; private set; } + protected PeriodTracker NoDrainPeriodTracker { get; private set; } + + public bool Log { get; set; } + + public double DrainRate { get; private set; } + + /// + /// Creates a new . + /// + /// The time after which draining should begin. + protected LegacyDrainingHealthProcessor(double drainStartTime) + { + DrainStartTime = drainStartTime; + } + + protected override void Update() + { + base.Update(); + + if (NoDrainPeriodTracker?.IsInAny(Time.Current) == true) + return; + + // When jumping in and out of gameplay time within a single frame, health should only be drained for the period within the gameplay time + double lastGameplayTime = Math.Clamp(Time.Current - Time.Elapsed, DrainStartTime, GameplayEndTime); + double currentGameplayTime = Math.Clamp(Time.Current, DrainStartTime, GameplayEndTime); + + Health.Value -= DrainRate * (currentGameplayTime - lastGameplayTime); + } + + public override void ApplyBeatmap(IBeatmap beatmap) + { + Beatmap = beatmap; + + if (beatmap.HitObjects.Count > 0) + GameplayEndTime = beatmap.HitObjects[^1].GetEndTime(); + + NoDrainPeriodTracker = new PeriodTracker(beatmap.Breaks.Select(breakPeriod => new Period( + beatmap.HitObjects + .Select(hitObject => hitObject.GetEndTime()) + .Where(endTime => endTime <= breakPeriod.StartTime) + .DefaultIfEmpty(double.MinValue) + .Last(), + beatmap.HitObjects + .Select(hitObject => hitObject.StartTime) + .Where(startTime => startTime >= breakPeriod.EndTime) + .DefaultIfEmpty(double.MaxValue) + .First() + ))); + + base.ApplyBeatmap(beatmap); + } + + protected override void Reset(bool storeResults) + { + base.Reset(storeResults); + + DrainRate = 1; + + if (storeResults) + DrainRate = ComputeDrainRate(); + } + + protected abstract double ComputeDrainRate(); + } +}