// 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 { public partial class OsuHealthProcessor : DrainingHealthProcessor { 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) { 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; 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; // TODO: This doesn't handle overlapping/sequential breaks correctly (/b/614). // 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; 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); if (h is Slider slider) { foreach (var nested in slider.NestedHitObjects) increaseHp(nested); } else if (h is Spinner spinner) { foreach (var nested in spinner.NestedHitObjects.Where(t => t is not SpinnerBonusTick)) 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) / 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)); } } protected override double GetHealthIncreaseFor(JudgementResult result) => healthIncreaseFor(result.HitObject, result.Type); private double healthIncreaseFor(HitObject hitObject, HitResult result) { double increase; switch (result) { case HitResult.SmallTickMiss: return IBeatmapDifficultyInfo.DifficultyRange(Beatmap.Difficulty.DrainRate, -0.02, -0.075, -0.14); case HitResult.LargeTickMiss: return IBeatmapDifficultyInfo.DifficultyRange(Beatmap.Difficulty.DrainRate, -0.02, -0.075, -0.14); case HitResult.Miss: return IBeatmapDifficultyInfo.DifficultyRange(Beatmap.Difficulty.DrainRate, -0.03, -0.125, -0.2); case HitResult.SmallTickHit: // This result always comes from the slider tail, which is judged the same as a repeat. increase = 0.02; break; case HitResult.LargeTickHit: // This result comes from either a slider tick or repeat. increase = hitObject is SliderTick ? 0.015 : 0.02; break; case HitResult.Meh: increase = 0.002; break; case HitResult.Ok: increase = 0.011; break; case HitResult.Great: increase = 0.03; break; case HitResult.SmallBonus: increase = 0.0085; break; case HitResult.LargeBonus: increase = 0.01; break; default: increase = 0; break; } return hpMultiplierNormal * increase; } } }