diff --git a/osu.Game.Rulesets.Catch/CatchRuleset.cs b/osu.Game.Rulesets.Catch/CatchRuleset.cs index f0e50c5ba5..dc8df28e6a 100644 --- a/osu.Game.Rulesets.Catch/CatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/CatchRuleset.cs @@ -28,9 +28,7 @@ namespace osu.Game.Rulesets.Catch { public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => new DrawableCatchRuleset(this, beatmap, mods); - public override ScoreProcessor CreateScoreProcessor(IBeatmap beatmap) => new CatchScoreProcessor(beatmap); - - public override HealthProcessor CreateHealthProcessor(IBeatmap beatmap) => new CatchHealthProcessor(beatmap); + public override ScoreProcessor CreateScoreProcessor() => new CatchScoreProcessor(); public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new CatchBeatmapConverter(beatmap, this); diff --git a/osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs b/osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs deleted file mode 100644 index 49ba0f6122..0000000000 --- a/osu.Game.Rulesets.Catch/Scoring/CatchHealthProcessor.cs +++ /dev/null @@ -1,38 +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 osu.Game.Beatmaps; -using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Scoring; - -namespace osu.Game.Rulesets.Catch.Scoring -{ - public class CatchHealthProcessor : HealthProcessor - { - public CatchHealthProcessor(IBeatmap beatmap) - : base(beatmap) - { - } - - private float hpDrainRate; - - protected override void ApplyBeatmap(IBeatmap beatmap) - { - base.ApplyBeatmap(beatmap); - - hpDrainRate = beatmap.BeatmapInfo.BaseDifficulty.DrainRate; - } - - protected override double HealthAdjustmentFactorFor(JudgementResult result) - { - switch (result.Type) - { - case HitResult.Miss: - return hpDrainRate; - - default: - return 10.2 - hpDrainRate; // Award less HP as drain rate is increased - } - } - } -} diff --git a/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs b/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs index ad7520d57d..4c7bc4ab73 100644 --- a/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs +++ b/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs @@ -1,18 +1,12 @@ // 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.Beatmaps; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Catch.Scoring { public class CatchScoreProcessor : ScoreProcessor { - public CatchScoreProcessor(IBeatmap beatmap) - : base(beatmap) - { - } - public override HitWindows CreateHitWindows() => new CatchHitWindows(); } } diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index 3c2fbd1548..c50f4314a3 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -37,9 +37,7 @@ namespace osu.Game.Rulesets.Mania { public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => new DrawableManiaRuleset(this, beatmap, mods); - public override ScoreProcessor CreateScoreProcessor(IBeatmap beatmap) => new ManiaScoreProcessor(beatmap); - - public override HealthProcessor CreateHealthProcessor(IBeatmap beatmap) => new ManiaHealthProcessor(beatmap); + public override ScoreProcessor CreateScoreProcessor() => new ManiaScoreProcessor(); public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new ManiaBeatmapConverter(beatmap, this); diff --git a/osu.Game.Rulesets.Mania/Scoring/ManiaHealthProcessor.cs b/osu.Game.Rulesets.Mania/Scoring/ManiaHealthProcessor.cs deleted file mode 100644 index c362c906a4..0000000000 --- a/osu.Game.Rulesets.Mania/Scoring/ManiaHealthProcessor.cs +++ /dev/null @@ -1,69 +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 osu.Game.Beatmaps; -using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Scoring; - -namespace osu.Game.Rulesets.Mania.Scoring -{ - public class ManiaHealthProcessor : HealthProcessor - { - /// - /// The hit HP multiplier at OD = 0. - /// - private const double hp_multiplier_min = 0.75; - - /// - /// The hit HP multiplier at OD = 0. - /// - private const double hp_multiplier_mid = 0.85; - - /// - /// The hit HP multiplier at OD = 0. - /// - private const double hp_multiplier_max = 1; - - /// - /// The MISS HP multiplier at OD = 0. - /// - private const double hp_multiplier_miss_min = 0.5; - - /// - /// The MISS HP multiplier at OD = 5. - /// - private const double hp_multiplier_miss_mid = 0.75; - - /// - /// The MISS HP multiplier at OD = 10. - /// - private const double hp_multiplier_miss_max = 1; - - /// - /// The MISS HP multiplier. This is multiplied to the miss hp increase. - /// - private double hpMissMultiplier = 1; - - /// - /// The HIT HP multiplier. This is multiplied to hit hp increases. - /// - private double hpMultiplier = 1; - - public ManiaHealthProcessor(IBeatmap beatmap) - : base(beatmap) - { - } - - protected override void ApplyBeatmap(IBeatmap beatmap) - { - base.ApplyBeatmap(beatmap); - - BeatmapDifficulty difficulty = beatmap.BeatmapInfo.BaseDifficulty; - hpMultiplier = BeatmapDifficulty.DifficultyRange(difficulty.DrainRate, hp_multiplier_min, hp_multiplier_mid, hp_multiplier_max); - hpMissMultiplier = BeatmapDifficulty.DifficultyRange(difficulty.DrainRate, hp_multiplier_miss_min, hp_multiplier_miss_mid, hp_multiplier_miss_max); - } - - protected override double HealthAdjustmentFactorFor(JudgementResult result) - => result.Type == HitResult.Miss ? hpMissMultiplier : hpMultiplier; - } -} diff --git a/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs b/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs index 97f1ea721c..9b54b48de3 100644 --- a/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs +++ b/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs @@ -1,18 +1,12 @@ // 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.Beatmaps; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mania.Scoring { internal class ManiaScoreProcessor : ScoreProcessor { - public ManiaScoreProcessor(IBeatmap beatmap) - : base(beatmap) - { - } - public override HitWindows CreateHitWindows() => new ManiaHitWindows(); } } diff --git a/osu.Game.Rulesets.Osu/Judgements/OsuJudgement.cs b/osu.Game.Rulesets.Osu/Judgements/OsuJudgement.cs index 7a5b98864c..bf30fbc351 100644 --- a/osu.Game.Rulesets.Osu/Judgements/OsuJudgement.cs +++ b/osu.Game.Rulesets.Osu/Judgements/OsuJudgement.cs @@ -27,22 +27,5 @@ namespace osu.Game.Rulesets.Osu.Judgements return 300; } } - - protected override double HealthIncreaseFor(HitResult result) - { - switch (result) - { - case HitResult.Miss: - return -0.02; - - case HitResult.Meh: - case HitResult.Good: - case HitResult.Great: - return 0.01; - - default: - return 0; - } - } } } diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index c9ea5e6cf1..36346eb78a 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -36,9 +36,7 @@ namespace osu.Game.Rulesets.Osu { public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => new DrawableOsuRuleset(this, beatmap, mods); - public override ScoreProcessor CreateScoreProcessor(IBeatmap beatmap) => new OsuScoreProcessor(beatmap); - - public override HealthProcessor CreateHealthProcessor(IBeatmap beatmap) => new OsuHealthProcessor(beatmap); + public override ScoreProcessor CreateScoreProcessor() => new OsuScoreProcessor(); public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new OsuBeatmapConverter(beatmap, this); diff --git a/osu.Game.Rulesets.Osu/Scoring/OsuHealthProcessor.cs b/osu.Game.Rulesets.Osu/Scoring/OsuHealthProcessor.cs deleted file mode 100644 index 36ccc80af6..0000000000 --- a/osu.Game.Rulesets.Osu/Scoring/OsuHealthProcessor.cs +++ /dev/null @@ -1,54 +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 osu.Game.Beatmaps; -using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Osu.Judgements; -using osu.Game.Rulesets.Scoring; - -namespace osu.Game.Rulesets.Osu.Scoring -{ - public class OsuHealthProcessor : HealthProcessor - { - public OsuHealthProcessor(IBeatmap beatmap) - : base(beatmap) - { - } - - private float hpDrainRate; - - protected override void ApplyBeatmap(IBeatmap beatmap) - { - base.ApplyBeatmap(beatmap); - - hpDrainRate = beatmap.BeatmapInfo.BaseDifficulty.DrainRate; - } - - protected override double HealthAdjustmentFactorFor(JudgementResult result) - { - switch (result.Type) - { - case HitResult.Great: - return 10.2 - hpDrainRate; - - case HitResult.Good: - return 8 - hpDrainRate; - - case HitResult.Meh: - return 4 - hpDrainRate; - - // case HitResult.SliderTick: - // return Math.Max(7 - hpDrainRate, 0) * 0.01; - - case HitResult.Miss: - return hpDrainRate; - - default: - return 0; - } - } - - protected override JudgementResult CreateResult(HitObject hitObject, Judgement judgement) => new OsuJudgementResult(hitObject, judgement); - } -} diff --git a/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs b/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs index 4593364e42..1de7d488f3 100644 --- a/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs +++ b/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs @@ -1,7 +1,6 @@ // 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.Beatmaps; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Judgements; @@ -11,11 +10,6 @@ namespace osu.Game.Rulesets.Osu.Scoring { internal class OsuScoreProcessor : ScoreProcessor { - public OsuScoreProcessor(IBeatmap beatmap) - : base(beatmap) - { - } - protected override JudgementResult CreateResult(HitObject hitObject, Judgement judgement) => new OsuJudgementResult(hitObject, judgement); public override HitWindows CreateHitWindows() => new OsuHitWindows(); diff --git a/osu.Game.Rulesets.Taiko/Scoring/TaikoHealthProcessor.cs b/osu.Game.Rulesets.Taiko/Scoring/TaikoHealthProcessor.cs index c8aa32a678..edb089dbac 100644 --- a/osu.Game.Rulesets.Taiko/Scoring/TaikoHealthProcessor.cs +++ b/osu.Game.Rulesets.Taiko/Scoring/TaikoHealthProcessor.cs @@ -9,18 +9,17 @@ using osu.Game.Rulesets.Taiko.Objects; namespace osu.Game.Rulesets.Taiko.Scoring { - public class TaikoHealthProcessor : HealthProcessor + /// + /// A for the taiko ruleset. + /// Taiko fails if the player has not half-filled their health by the end of the map. + /// + public class TaikoHealthProcessor : AccumulatingHealthProcessor { /// /// A value used for calculating . /// private const double object_count_factor = 3; - /// - /// Taiko fails at the end of the map if the player has not half-filled their HP bar. - /// - protected override bool DefaultFailCondition => JudgedHits == MaxHits && Health.Value <= 0.5; - /// /// HP multiplier for a successful . /// @@ -31,28 +30,20 @@ namespace osu.Game.Rulesets.Taiko.Scoring /// private double hpMissMultiplier; - public TaikoHealthProcessor(IBeatmap beatmap) - : base(beatmap) + public TaikoHealthProcessor() + : base(0.5) { } - protected override void ApplyBeatmap(IBeatmap beatmap) + public override void ApplyBeatmap(IBeatmap beatmap) { base.ApplyBeatmap(beatmap); hpMultiplier = 1 / (object_count_factor * beatmap.HitObjects.OfType().Count() * BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.DrainRate, 0.5, 0.75, 0.98)); - hpMissMultiplier = BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.DrainRate, 0.0018, 0.0075, 0.0120); } - protected override double HealthAdjustmentFactorFor(JudgementResult result) - => result.Type == HitResult.Miss ? hpMissMultiplier : hpMultiplier; - - protected override void Reset(bool storeResults) - { - base.Reset(storeResults); - - Health.Value = 0; - } + protected override double GetHealthIncreaseFor(JudgementResult result) + => base.GetHealthIncreaseFor(result) * (result.Type == HitResult.Miss ? hpMissMultiplier : hpMultiplier); } } diff --git a/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs b/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs index 10011d2669..003d40af56 100644 --- a/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs +++ b/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs @@ -1,18 +1,12 @@ // 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.Beatmaps; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Taiko.Scoring { internal class TaikoScoreProcessor : ScoreProcessor { - public TaikoScoreProcessor(IBeatmap beatmap) - : base(beatmap) - { - } - public override HitWindows CreateHitWindows() => new TaikoHitWindows(); } } diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index d713a4145d..777b68a993 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -28,9 +28,9 @@ namespace osu.Game.Rulesets.Taiko { public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => new DrawableTaikoRuleset(this, beatmap, mods); - public override ScoreProcessor CreateScoreProcessor(IBeatmap beatmap) => new TaikoScoreProcessor(beatmap); + public override ScoreProcessor CreateScoreProcessor() => new TaikoScoreProcessor(); - public override HealthProcessor CreateHealthProcessor(IBeatmap beatmap) => new TaikoHealthProcessor(beatmap); + public override HealthProcessor CreateHealthProcessor(double drainStartTime) => new TaikoHealthProcessor(); public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new TaikoBeatmapConverter(beatmap, this); diff --git a/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs b/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs new file mode 100644 index 0000000000..eec52669ff --- /dev/null +++ b/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs @@ -0,0 +1,159 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.MathUtils; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Scoring; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Gameplay +{ + [HeadlessTest] + public class TestSceneDrainingHealthProcessor : OsuTestScene + { + private Bindable breakTime; + private HealthProcessor processor; + private ManualClock clock; + + [Test] + public void TestInitialHealthStartsAtOne() + { + createProcessor(createBeatmap(1000, 2000)); + + assertHealthEqualTo(1); + } + + [Test] + public void TestHealthNotDrainedBeforeGameplayStart() + { + createProcessor(createBeatmap(1000, 2000)); + + setTime(100); + assertHealthEqualTo(1); + setTime(900); + assertHealthEqualTo(1); + } + + [Test] + public void TestHealthNotDrainedAfterGameplayEnd() + { + createProcessor(createBeatmap(1000, 2000)); + setTime(2001); // After the hitobjects + setHealth(1); // Reset the current health for assertions to take place + + setTime(2100); + assertHealthEqualTo(1); + setTime(3000); + assertHealthEqualTo(1); + } + + [Test] + public void TestHealthNotDrainedDuringBreak() + { + createProcessor(createBeatmap(0, 2000)); + setBreak(true); + + setTime(700); + assertHealthEqualTo(1); + setTime(900); + assertHealthEqualTo(1); + } + + [Test] + public void TestHealthDrainedDuringGameplay() + { + createProcessor(createBeatmap(0, 1000)); + + setTime(500); + assertHealthNotEqualTo(1); + } + + [Test] + public void TestHealthGainedAfterRewind() + { + createProcessor(createBeatmap(0, 1000)); + setTime(500); + + setTime(0); + assertHealthEqualTo(1); + } + + [Test] + public void TestHealthGainedOnHit() + { + Beatmap beatmap = createBeatmap(0, 1000); + + createProcessor(beatmap); + setTime(10); // Decrease health slightly + assertHealthNotEqualTo(1); + + AddStep("apply hit result", () => processor.ApplyResult(new JudgementResult(beatmap.HitObjects[0], new Judgement()) { Type = HitResult.Perfect })); + assertHealthEqualTo(1); + } + + [Test] + public void TestHealthRemovedOnRevert() + { + var beatmap = createBeatmap(0, 1000); + JudgementResult result = null; + + createProcessor(beatmap); + setTime(10); // Decrease health slightly + AddStep("apply hit result", () => processor.ApplyResult(result = new JudgementResult(beatmap.HitObjects[0], new Judgement()) { Type = HitResult.Perfect })); + + AddStep("revert hit result", () => processor.RevertResult(result)); + assertHealthNotEqualTo(1); + } + + private Beatmap createBeatmap(double startTime, double endTime) + { + var beatmap = new Beatmap + { + BeatmapInfo = { BaseDifficulty = { DrainRate = 5 } }, + }; + + for (double time = startTime; time <= endTime; time += 100) + beatmap.HitObjects.Add(new JudgeableHitObject { StartTime = time }); + + return beatmap; + } + + private void createProcessor(Beatmap beatmap) => AddStep("create processor", () => + { + breakTime = new Bindable(); + + Child = processor = new DrainingHealthProcessor(beatmap.HitObjects[0].StartTime).With(d => + { + d.RelativeSizeAxes = Axes.Both; + d.Clock = new FramedClock(clock = new ManualClock()); + }); + + processor.IsBreakTime.BindTo(breakTime); + processor.ApplyBeatmap(beatmap); + }); + + private void setTime(double time) => AddStep($"set time = {time}", () => clock.CurrentTime = time); + + private void setHealth(double health) => AddStep($"set health = {health}", () => processor.Health.Value = health); + + private void setBreak(bool enabled) => AddStep($"{(enabled ? "enable" : "disable")} break", () => breakTime.Value = enabled); + + private void assertHealthEqualTo(double value) + => AddAssert($"health = {value}", () => Precision.AlmostEquals(value, processor.Health.Value, 0.0001f)); + + private void assertHealthNotEqualTo(double value) + => AddAssert($"health != {value}", () => !Precision.AlmostEquals(value, processor.Health.Value, 0.0001f)); + + private class JudgeableHitObject : HitObject + { + public override Judgement CreateJudgement() => new Judgement(); + } + } +} diff --git a/osu.Game/Rulesets/Judgements/Judgement.cs b/osu.Game/Rulesets/Judgements/Judgement.cs index f07f76a2b8..599135ba54 100644 --- a/osu.Game/Rulesets/Judgements/Judgement.cs +++ b/osu.Game/Rulesets/Judgements/Judgement.cs @@ -11,6 +11,12 @@ namespace osu.Game.Rulesets.Judgements /// public class Judgement { + /// + /// The default health increase for a maximum judgement, as a proportion of total health. + /// By default, each maximum judgement restores 5% of total health. + /// + protected const double DEFAULT_MAX_HEALTH_INCREASE = 0.05; + /// /// The maximum that can be achieved. /// @@ -55,7 +61,32 @@ namespace osu.Game.Rulesets.Judgements /// /// The to find the numeric health increase for. /// The numeric health increase of . - protected virtual double HealthIncreaseFor(HitResult result) => 0; + protected virtual double HealthIncreaseFor(HitResult result) + { + switch (result) + { + case HitResult.Miss: + return -DEFAULT_MAX_HEALTH_INCREASE; + + case HitResult.Meh: + return -DEFAULT_MAX_HEALTH_INCREASE * 0.05; + + case HitResult.Ok: + return -DEFAULT_MAX_HEALTH_INCREASE * 0.01; + + case HitResult.Good: + return DEFAULT_MAX_HEALTH_INCREASE * 0.3; + + case HitResult.Great: + return DEFAULT_MAX_HEALTH_INCREASE; + + case HitResult.Perfect: + return DEFAULT_MAX_HEALTH_INCREASE * 1.05; + + default: + return 0; + } + } /// /// Retrieves the numeric health increase of a . diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs index bfd6a16729..67ec6d15ea 100644 --- a/osu.Game/Rulesets/Ruleset.cs +++ b/osu.Game/Rulesets/Ruleset.cs @@ -71,16 +71,16 @@ namespace osu.Game.Rulesets public abstract DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null); /// - /// Creates a for a beatmap converted to this ruleset. + /// Creates a for this . /// /// The score processor. - public virtual ScoreProcessor CreateScoreProcessor(IBeatmap beatmap) => new ScoreProcessor(beatmap); + public virtual ScoreProcessor CreateScoreProcessor() => new ScoreProcessor(); /// - /// Creates a for a beatmap converted to this ruleset. + /// Creates a for this . /// /// The health processor. - public virtual HealthProcessor CreateHealthProcessor(IBeatmap beatmap) => new HealthProcessor(beatmap); + public virtual HealthProcessor CreateHealthProcessor(double drainStartTime) => new DrainingHealthProcessor(drainStartTime); /// /// Creates a to convert a to one that is applicable for this . diff --git a/osu.Game/Rulesets/Scoring/AccumulatingHealthProcessor.cs b/osu.Game/Rulesets/Scoring/AccumulatingHealthProcessor.cs new file mode 100644 index 0000000000..5dfb5167f4 --- /dev/null +++ b/osu.Game/Rulesets/Scoring/AccumulatingHealthProcessor.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Rulesets.Scoring +{ + /// + /// A that accumulates health and causes a fail if the final health + /// is less than a value required to pass the beatmap. + /// + public class AccumulatingHealthProcessor : HealthProcessor + { + protected override bool DefaultFailCondition => JudgedHits == MaxHits && Health.Value < requiredHealth; + + private readonly double requiredHealth; + + /// + /// Creates a new . + /// + /// The minimum amount of health required to beatmap. + public AccumulatingHealthProcessor(double requiredHealth) + { + this.requiredHealth = requiredHealth; + } + + protected override void Reset(bool storeResults) + { + base.Reset(storeResults); + + Health.Value = 0; + } + } +} diff --git a/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs b/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs new file mode 100644 index 0000000000..fffcbb3c9f --- /dev/null +++ b/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs @@ -0,0 +1,157 @@ +// 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 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 class DrainingHealthProcessor : HealthProcessor + { + /// + /// A reasonable allowable error for the minimum health offset from . A 1% error is unnoticeable. + /// + private const double minimum_health_error = 0.01; + + /// + /// The minimum health target at an HP drain rate of 0. + /// + private const double min_health_target = 0.95; + + /// + /// The minimum health target at an HP drain rate of 5. + /// + private const double mid_health_target = 0.70; + + /// + /// The minimum health target at an HP drain rate of 10. + /// + private const double max_health_target = 0.30; + + private IBeatmap beatmap; + + private double gameplayEndTime; + + private readonly double drainStartTime; + + private readonly List<(double time, double health)> healthIncreases = new List<(double, double)>(); + private double targetMinimumHealth; + private double drainRate = 1; + + /// + /// Creates a new . + /// + /// The time after which draining should begin. + public DrainingHealthProcessor(double drainStartTime) + { + this.drainStartTime = drainStartTime; + } + + protected override void Update() + { + base.Update(); + + if (!IsBreakTime.Value) + { + // 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) + { + this.beatmap = beatmap; + + if (beatmap.HitObjects.Count > 0) + gameplayEndTime = beatmap.HitObjects[^1].GetEndTime(); + + targetMinimumHealth = BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.DrainRate, min_health_target, mid_health_target, max_health_target); + + base.ApplyBeatmap(beatmap); + } + + protected override void ApplyResultInternal(JudgementResult result) + { + base.ApplyResultInternal(result); + healthIncreases.Add((result.HitObject.GetEndTime() + result.TimeOffset, GetHealthIncreaseFor(result))); + } + + protected override void Reset(bool storeResults) + { + base.Reset(storeResults); + + drainRate = 1; + + if (storeResults) + drainRate = computeDrainRate(); + + healthIncreases.Clear(); + } + + private double computeDrainRate() + { + if (healthIncreases.Count == 0) + return 0; + + int adjustment = 1; + double result = 1; + + // Although we expect the following loop to converge within 30 iterations (health within 1/2^31 accuracy of the target), + // we'll still keep a safety measure to avoid infinite loops by detecting overflows. + while (adjustment > 0) + { + double currentHealth = 1; + double lowestHealth = 1; + int currentBreak = -1; + + for (int i = 0; i < healthIncreases.Count; i++) + { + double currentTime = healthIncreases[i].time; + double lastTime = i > 0 ? healthIncreases[i - 1].time : drainStartTime; + + // Subtract any break time from the duration since the last object + if (beatmap.Breaks.Count > 0) + { + // Advance the last break occuring before the current time + while (currentBreak + 1 < beatmap.Breaks.Count && beatmap.Breaks[currentBreak + 1].EndTime < currentTime) + currentBreak++; + + if (currentBreak >= 0) + lastTime = Math.Max(lastTime, beatmap.Breaks[currentBreak].EndTime); + } + + // Apply health adjustments + currentHealth -= (healthIncreases[i].time - lastTime) * result; + lowestHealth = Math.Min(lowestHealth, currentHealth); + currentHealth = Math.Min(1, currentHealth + healthIncreases[i].health); + + // Common scenario for when the drain rate is definitely too harsh + if (lowestHealth < 0) + break; + } + + // Stop if the resulting health is within a reasonable offset from the target + if (Math.Abs(lowestHealth - targetMinimumHealth) <= minimum_health_error) + break; + + // This effectively works like a binary search - each iteration the search space moves closer to the target, but may exceed it. + adjustment *= 2; + result += 1.0 / adjustment * Math.Sign(lowestHealth - targetMinimumHealth); + } + + return result; + } + } +} diff --git a/osu.Game/Rulesets/Scoring/HealthProcessor.cs b/osu.Game/Rulesets/Scoring/HealthProcessor.cs index d05e2d7b6b..0c6b3f67b4 100644 --- a/osu.Game/Rulesets/Scoring/HealthProcessor.cs +++ b/osu.Game/Rulesets/Scoring/HealthProcessor.cs @@ -4,12 +4,11 @@ using System; using osu.Framework.Bindables; using osu.Framework.MathUtils; -using osu.Game.Beatmaps; using osu.Game.Rulesets.Judgements; namespace osu.Game.Rulesets.Scoring { - public class HealthProcessor : JudgementProcessor + public abstract class HealthProcessor : JudgementProcessor { /// /// Invoked when the is in a failed state. @@ -27,16 +26,16 @@ namespace osu.Game.Rulesets.Scoring /// public readonly BindableDouble Health = new BindableDouble(1) { MinValue = 0, MaxValue = 1 }; + /// + /// Whether gameplay is currently in a break. + /// + public readonly IBindable IsBreakTime = new Bindable(); + /// /// Whether this ScoreProcessor has already triggered the failed state. /// public bool HasFailed { get; private set; } - public HealthProcessor(IBeatmap beatmap) - : base(beatmap) - { - } - protected override void ApplyResultInternal(JudgementResult result) { result.HealthAtJudgement = Health.Value; @@ -45,7 +44,7 @@ namespace osu.Game.Rulesets.Scoring if (HasFailed) return; - Health.Value += HealthAdjustmentFactorFor(result) * result.Judgement.HealthIncreaseFor(result); + Health.Value += GetHealthIncreaseFor(result); if (!DefaultFailCondition && FailConditions?.Invoke(this, result) != true) return; @@ -62,11 +61,11 @@ namespace osu.Game.Rulesets.Scoring } /// - /// An adjustment factor which is multiplied into the health increase provided by a . + /// Retrieves the health increase for a . /// - /// The for which the adjustment should apply. - /// The adjustment factor. - protected virtual double HealthAdjustmentFactorFor(JudgementResult result) => 1; + /// The . + /// The health increase. + protected virtual double GetHealthIncreaseFor(JudgementResult result) => result.Judgement.HealthIncreaseFor(result); /// /// The default conditions for failing. diff --git a/osu.Game/Rulesets/Scoring/JudgementProcessor.cs b/osu.Game/Rulesets/Scoring/JudgementProcessor.cs index c7ac466eb0..3016007f98 100644 --- a/osu.Game/Rulesets/Scoring/JudgementProcessor.cs +++ b/osu.Game/Rulesets/Scoring/JudgementProcessor.cs @@ -3,13 +3,14 @@ using System; using osu.Framework.Extensions.TypeExtensions; +using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; namespace osu.Game.Rulesets.Scoring { - public abstract class JudgementProcessor + public abstract class JudgementProcessor : Component { /// /// Invoked when all s have been judged by this . @@ -36,23 +37,17 @@ namespace osu.Game.Rulesets.Scoring /// public bool HasCompleted => JudgedHits == MaxHits; - protected JudgementProcessor(IBeatmap beatmap) + /// + /// Applies a to this . + /// + /// The to read properties from. + public virtual void ApplyBeatmap(IBeatmap beatmap) { - ApplyBeatmap(beatmap); - Reset(false); SimulateAutoplay(beatmap); Reset(true); } - /// - /// Applies any properties of the which affect scoring to this . - /// - /// The to read properties from. - protected virtual void ApplyBeatmap(IBeatmap beatmap) - { - } - /// /// Applies the score change of a to this . /// @@ -138,7 +133,6 @@ namespace osu.Game.Rulesets.Scoring throw new InvalidOperationException($"{GetType().ReadableName()} must provide a {nameof(JudgementResult)} through {nameof(CreateResult)}."); result.Type = judgement.MaxResult; - ApplyResult(result); } } diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index acd394d955..8ccc2af93b 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs @@ -7,7 +7,6 @@ using System.Diagnostics; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Extensions; -using osu.Game.Beatmaps; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; @@ -64,15 +63,9 @@ namespace osu.Game.Rulesets.Scoring private double scoreMultiplier = 1; - public ScoreProcessor(IBeatmap beatmap) - : base(beatmap) + public ScoreProcessor() { Debug.Assert(base_portion + combo_portion == 1.0); - } - - protected override void ApplyBeatmap(IBeatmap beatmap) - { - base.ApplyBeatmap(beatmap); Combo.ValueChanged += combo => HighestCombo.Value = Math.Max(HighestCombo.Value, combo.NewValue); Accuracy.ValueChanged += accuracy => @@ -82,12 +75,6 @@ namespace osu.Game.Rulesets.Scoring Rank.Value = mod.AdjustRank(Rank.Value, accuracy.NewValue); }; - if (maxBaseScore == 0 || maxHighestCombo == 0) - { - Mode.Value = ScoringMode.Classic; - Mode.Disabled = true; - } - Mode.ValueChanged += _ => updateScore(); Mods.ValueChanged += mods => { @@ -225,6 +212,12 @@ namespace osu.Game.Rulesets.Scoring { maxHighestCombo = HighestCombo.Value; maxBaseScore = baseScore; + + if (maxBaseScore == 0 || maxHighestCombo == 0) + { + Mode.Value = ScoringMode.Classic; + Mode.Disabled = true; + } } baseScore = 0; diff --git a/osu.Game/Rulesets/UI/DrawableRuleset.cs b/osu.Game/Rulesets/UI/DrawableRuleset.cs index f318539bb7..e624fb80fa 100644 --- a/osu.Game/Rulesets/UI/DrawableRuleset.cs +++ b/osu.Game/Rulesets/UI/DrawableRuleset.cs @@ -72,10 +72,9 @@ namespace osu.Game.Rulesets.UI /// public override Playfield Playfield => playfield.Value; - /// - /// Place to put drawables above hit objects but below UI. - /// - public Container Overlays { get; private set; } + private Container overlays; + + public override Container Overlays => overlays; public override GameplayClock FrameStableClock => frameStabilityContainer.GameplayClock; @@ -185,12 +184,15 @@ namespace osu.Game.Rulesets.UI frameStabilityContainer = new FrameStabilityContainer(GameplayStartTime) { FrameStablePlayback = FrameStablePlayback, - Child = KeyBindingInputManager - .WithChild(CreatePlayfieldAdjustmentContainer() - .WithChild(Playfield) - ) + Children = new Drawable[] + { + KeyBindingInputManager + .WithChild(CreatePlayfieldAdjustmentContainer() + .WithChild(Playfield) + ), + overlays = new Container { RelativeSizeAxes = Axes.Both } + } }, - Overlays = new Container { RelativeSizeAxes = Axes.Both } }; if ((ResumeOverlay = CreateResumeOverlay()) != null) @@ -385,6 +387,11 @@ namespace osu.Game.Rulesets.UI ///
public abstract Playfield Playfield { get; } + /// + /// Place to put drawables above hit objects but below UI. + /// + public abstract Container Overlays { get; } + /// /// The frame-stable clock which is being used for playfield display. /// diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index f0960371e3..7228e22382 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -131,10 +131,12 @@ namespace osu.Game.Screens.Play DrawableRuleset = ruleset.CreateDrawableRulesetWith(playableBeatmap, Mods.Value); - ScoreProcessor = ruleset.CreateScoreProcessor(playableBeatmap); + ScoreProcessor = ruleset.CreateScoreProcessor(); + ScoreProcessor.ApplyBeatmap(playableBeatmap); ScoreProcessor.Mods.BindTo(Mods); - HealthProcessor = ruleset.CreateHealthProcessor(playableBeatmap); + HealthProcessor = ruleset.CreateHealthProcessor(playableBeatmap.HitObjects[0].StartTime); + HealthProcessor.ApplyBeatmap(playableBeatmap); if (!ScoreProcessor.Mode.Disabled) config.BindWith(OsuSetting.ScoreDisplayMode, ScoreProcessor.Mode); @@ -206,12 +208,6 @@ namespace osu.Game.Screens.Play { target.AddRange(new[] { - BreakOverlay = new BreakOverlay(working.Beatmap.BeatmapInfo.LetterboxInBreaks, DrawableRuleset.GameplayStartTime, ScoreProcessor) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Breaks = working.Beatmap.Breaks - }, // display the cursor above some HUD elements. DrawableRuleset.Cursor?.CreateProxy() ?? new Container(), DrawableRuleset.ResumeOverlay?.CreateProxy() ?? new Container(), @@ -266,6 +262,18 @@ namespace osu.Game.Screens.Play }, failAnimation = new FailAnimation(DrawableRuleset) { OnComplete = onFailComplete, } }); + + DrawableRuleset.Overlays.Add(BreakOverlay = new BreakOverlay(working.Beatmap.BeatmapInfo.LetterboxInBreaks, DrawableRuleset.GameplayStartTime, ScoreProcessor) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Breaks = working.Beatmap.Breaks + }); + + DrawableRuleset.Overlays.Add(ScoreProcessor); + DrawableRuleset.Overlays.Add(HealthProcessor); + + HealthProcessor.IsBreakTime.BindTo(BreakOverlay.IsBreakTime); } private void updatePauseOnFocusLostState() =>