diff --git a/osu.Android.props b/osu.Android.props index a43c834406..0b41d5cda4 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -53,7 +53,7 @@ - - + + 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/Mods/CatchModDifficultyAdjust.cs b/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs index 4c0f5d510e..8377b3786a 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs @@ -34,8 +34,8 @@ namespace osu.Game.Rulesets.Catch.Mods { base.TransferSettings(difficulty); - CircleSize.Value = CircleSize.Default = difficulty.CircleSize; - ApproachRate.Value = ApproachRate.Default = difficulty.ApproachRate; + TransferSetting(CircleSize, difficulty.CircleSize); + TransferSetting(ApproachRate, difficulty.ApproachRate); } protected override void ApplySettings(BeatmapDifficulty difficulty) 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.Tests/TestSceneHoldNoteInput.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs new file mode 100644 index 0000000000..7b0cf40d45 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs @@ -0,0 +1,314 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using NUnit.Framework; +using osu.Framework.Screens; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Replays; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Replays; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Mania.Tests +{ + public class TestSceneHoldNoteInput : RateAdjustedBeatmapTestScene + { + private const double time_before_head = 250; + private const double time_head = 1500; + private const double time_during_hold_1 = 2500; + private const double time_tail = 4000; + private const double time_after_tail = 5250; + + private List judgementResults; + private bool allJudgedFired; + + /// + /// -----[ ]----- + /// o o + /// + [Test] + public void TestNoInput() + { + performTest(new List + { + new ManiaReplayFrame(time_before_head), + new ManiaReplayFrame(time_after_tail), + }); + + assertHeadJudgement(HitResult.Miss); + assertTickJudgement(HitResult.Miss); + assertTailJudgement(HitResult.Miss); + assertNoteJudgement(HitResult.Perfect); + } + + /// + /// -----[ ]----- + /// x o + /// + [Test] + public void TestPressTooEarlyAndReleaseAfterTail() + { + performTest(new List + { + new ManiaReplayFrame(time_before_head, ManiaAction.Key1), + new ManiaReplayFrame(time_after_tail, ManiaAction.Key1), + }); + + assertHeadJudgement(HitResult.Miss); + assertTickJudgement(HitResult.Miss); + assertTailJudgement(HitResult.Miss); + } + + /// + /// -----[ ]----- + /// x o + /// + [Test] + public void TestPressTooEarlyAndReleaseAtTail() + { + performTest(new List + { + new ManiaReplayFrame(time_before_head, ManiaAction.Key1), + new ManiaReplayFrame(time_tail), + }); + + assertHeadJudgement(HitResult.Miss); + assertTickJudgement(HitResult.Miss); + assertTailJudgement(HitResult.Miss); + } + + /// + /// -----[ ]----- + /// xo x o + /// + [Test] + public void TestPressTooEarlyThenPressAtStartAndReleaseAfterTail() + { + performTest(new List + { + new ManiaReplayFrame(time_before_head, ManiaAction.Key1), + new ManiaReplayFrame(time_before_head + 10), + new ManiaReplayFrame(time_head, ManiaAction.Key1), + new ManiaReplayFrame(time_after_tail), + }); + + assertHeadJudgement(HitResult.Perfect); + assertTickJudgement(HitResult.Perfect); + assertTailJudgement(HitResult.Miss); + } + + /// + /// -----[ ]----- + /// xo x o + /// + [Test] + public void TestPressTooEarlyThenPressAtStartAndReleaseAtTail() + { + performTest(new List + { + new ManiaReplayFrame(time_before_head, ManiaAction.Key1), + new ManiaReplayFrame(time_before_head + 10), + new ManiaReplayFrame(time_head, ManiaAction.Key1), + new ManiaReplayFrame(time_tail), + }); + + assertHeadJudgement(HitResult.Perfect); + assertTickJudgement(HitResult.Perfect); + assertTailJudgement(HitResult.Perfect); + } + + /// + /// -----[ ]----- + /// xo o + /// + [Test] + public void TestPressAtStartAndBreak() + { + performTest(new List + { + new ManiaReplayFrame(time_head, ManiaAction.Key1), + new ManiaReplayFrame(time_head + 10), + new ManiaReplayFrame(time_after_tail), + }); + + assertHeadJudgement(HitResult.Perfect); + assertTickJudgement(HitResult.Miss); + assertTailJudgement(HitResult.Miss); + } + + /// + /// -----[ ]----- + /// xo x o + /// + [Test] + public void TestPressAtStartThenBreakThenRepressAndReleaseAfterTail() + { + performTest(new List + { + new ManiaReplayFrame(time_head, ManiaAction.Key1), + new ManiaReplayFrame(time_head + 10), + new ManiaReplayFrame(time_during_hold_1, ManiaAction.Key1), + new ManiaReplayFrame(time_after_tail), + }); + + assertHeadJudgement(HitResult.Perfect); + assertTickJudgement(HitResult.Perfect); + assertTailJudgement(HitResult.Miss); + } + + /// + /// -----[ ]----- + /// xo x o o + /// + [Test] + public void TestPressAtStartThenBreakThenRepressAndReleaseAtTail() + { + performTest(new List + { + new ManiaReplayFrame(time_head, ManiaAction.Key1), + new ManiaReplayFrame(time_head + 10), + new ManiaReplayFrame(time_during_hold_1, ManiaAction.Key1), + new ManiaReplayFrame(time_tail), + }); + + assertHeadJudgement(HitResult.Perfect); + assertTickJudgement(HitResult.Perfect); + assertTailJudgement(HitResult.Meh); + } + + /// + /// -----[ ]----- + /// x o + /// + [Test] + public void TestPressDuringNoteAndReleaseAfterTail() + { + performTest(new List + { + new ManiaReplayFrame(time_during_hold_1, ManiaAction.Key1), + new ManiaReplayFrame(time_after_tail), + }); + + assertHeadJudgement(HitResult.Miss); + assertTickJudgement(HitResult.Perfect); + assertTailJudgement(HitResult.Miss); + } + + /// + /// -----[ ]----- + /// x o o + /// + [Test] + public void TestPressDuringNoteAndReleaseAtTail() + { + performTest(new List + { + new ManiaReplayFrame(time_during_hold_1, ManiaAction.Key1), + new ManiaReplayFrame(time_tail), + }); + + assertHeadJudgement(HitResult.Miss); + assertTickJudgement(HitResult.Perfect); + assertTailJudgement(HitResult.Meh); + } + + /// + /// -----[ ]----- + /// xo o + /// + [Test] + public void TestPressAndReleaseAtTail() + { + performTest(new List + { + new ManiaReplayFrame(time_tail, ManiaAction.Key1), + new ManiaReplayFrame(time_tail + 10), + }); + + assertHeadJudgement(HitResult.Miss); + assertTickJudgement(HitResult.Miss); + assertTailJudgement(HitResult.Meh); + } + + private void assertHeadJudgement(HitResult result) + => AddAssert($"head judged as {result}", () => judgementResults[0].Type == result); + + private void assertTailJudgement(HitResult result) + => AddAssert($"tail judged as {result}", () => judgementResults[^2].Type == result); + + private void assertNoteJudgement(HitResult result) + => AddAssert($"hold note judged as {result}", () => judgementResults[^1].Type == result); + + private void assertTickJudgement(HitResult result) + => AddAssert($"tick judged as {result}", () => judgementResults[6].Type == result); // arbitrary tick + + private ScoreAccessibleReplayPlayer currentPlayer; + + private void performTest(List frames) + { + AddStep("load player", () => + { + Beatmap.Value = CreateWorkingBeatmap(new Beatmap + { + HitObjects = + { + new HoldNote + { + StartTime = time_head, + Duration = time_tail - time_head, + Column = 0, + } + }, + BeatmapInfo = + { + BaseDifficulty = new BeatmapDifficulty { SliderTickRate = 4 }, + Ruleset = new ManiaRuleset().RulesetInfo + }, + }); + + Beatmap.Value.Beatmap.ControlPointInfo.Add(0, new DifficultyControlPoint { SpeedMultiplier = 0.1f }); + + var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } }); + + p.OnLoadComplete += _ => + { + p.ScoreProcessor.NewJudgement += result => + { + if (currentPlayer == p) judgementResults.Add(result); + }; + p.ScoreProcessor.AllJudged += () => + { + if (currentPlayer == p) allJudgedFired = true; + }; + }; + + LoadScreen(currentPlayer = p); + allJudgedFired = false; + judgementResults = new List(); + }); + + AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0); + AddUntilStep("Wait until player is loaded", () => currentPlayer.IsCurrentScreen()); + AddUntilStep("Wait for all judged", () => allJudgedFired); + } + + private class ScoreAccessibleReplayPlayer : ReplayPlayer + { + public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; + + protected override bool PauseOnFocusLost => false; + + public ScoreAccessibleReplayPlayer(Score score) + : base(score, false, false) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/TestScenePlayer.cs b/osu.Game.Rulesets.Mania.Tests/TestScenePlayer.cs new file mode 100644 index 0000000000..cd25d162d0 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/TestScenePlayer.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.Tests.Visual; + +namespace osu.Game.Rulesets.Mania.Tests +{ + public class TestScenePlayer : PlayerTestScene + { + public TestScenePlayer() + : base(new ManiaRuleset()) + { + } + } +} diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteNoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteNoteSelectionBlueprint.cs index acce41db6f..4e73883de0 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteNoteSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteNoteSelectionBlueprint.cs @@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints // Todo: This shouldn't exist, mania should not reference the drawable hitobject directly. if (DrawableObject.IsLoaded) { - DrawableNote note = position == HoldNotePosition.Start ? DrawableObject.Head : DrawableObject.Tail; + DrawableNote note = position == HoldNotePosition.Start ? (DrawableNote)DrawableObject.Head : DrawableObject.Tail; Anchor = note.Anchor; Origin = note.Origin; 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/Objects/Drawables/DrawableHoldNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs index 87b9633c80..155adb958b 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.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 System.Diagnostics; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Rulesets.Mania.Objects.Drawables.Pieces; @@ -21,11 +20,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables { public override bool DisplayResult => false; - public DrawableNote Head => headContainer.Child; - public DrawableNote Tail => tailContainer.Child; + public DrawableHoldNoteHead Head => headContainer.Child; + public DrawableHoldNoteTail Tail => tailContainer.Child; - private readonly Container headContainer; - private readonly Container tailContainer; + private readonly Container headContainer; + private readonly Container tailContainer; private readonly Container tickContainer; private readonly BodyPiece bodyPiece; @@ -33,12 +32,12 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables /// /// Time at which the user started holding this hold note. Null if the user is not holding this hold note. /// - private double? holdStartTime; + public double? HoldStartTime { get; private set; } /// /// Whether the hold note has been released too early and shouldn't give full score for the release. /// - private bool hasBroken; + public bool HasBroken { get; private set; } public DrawableHoldNote(HoldNote hitObject) : base(hitObject) @@ -49,8 +48,8 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables { bodyPiece = new BodyPiece { RelativeSizeAxes = Axes.X }, tickContainer = new Container { RelativeSizeAxes = Axes.Both }, - headContainer = new Container { RelativeSizeAxes = Axes.Both }, - tailContainer = new Container { RelativeSizeAxes = Axes.Both }, + headContainer = new Container { RelativeSizeAxes = Axes.Both }, + tailContainer = new Container { RelativeSizeAxes = Axes.Both }, }); AccentColour.BindValueChanged(colour => @@ -65,11 +64,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables switch (hitObject) { - case DrawableHeadNote head: + case DrawableHoldNoteHead head: headContainer.Child = head; break; - case DrawableTailNote tail: + case DrawableHoldNoteTail tail: tailContainer.Child = tail; break; @@ -92,7 +91,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables switch (hitObject) { case TailNote _: - return new DrawableTailNote(this) + return new DrawableHoldNoteTail(this) { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, @@ -100,7 +99,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables }; case Note _: - return new DrawableHeadNote(this) + return new DrawableHoldNoteHead(this) { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, @@ -110,7 +109,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables case HoldNoteTick tick: return new DrawableHoldNoteTick(tick) { - HoldStartTime = () => holdStartTime, + HoldStartTime = () => HoldStartTime, AccentColour = { BindTarget = AccentColour } }; } @@ -125,12 +124,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables bodyPiece.Anchor = bodyPiece.Origin = e.NewValue == ScrollingDirection.Up ? Anchor.TopLeft : Anchor.BottomLeft; } - protected override void CheckForResult(bool userTriggered, double timeOffset) - { - if (Tail.AllJudged) - ApplyResult(r => r.Type = HitResult.Perfect); - } - protected override void Update() { base.Update(); @@ -146,146 +139,64 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables base.UpdateStateTransforms(state); } - protected void BeginHold() + protected override void CheckForResult(bool userTriggered, double timeOffset) { - holdStartTime = Time.Current; - bodyPiece.Hitting = true; - } + if (Tail.AllJudged) + ApplyResult(r => r.Type = HitResult.Perfect); - protected void EndHold() - { - holdStartTime = null; - bodyPiece.Hitting = false; + if (Tail.Result.Type == HitResult.Miss) + HasBroken = true; } public bool OnPressed(ManiaAction action) { - // Make sure the action happened within the body of the hold note - if (Time.Current < HitObject.StartTime || Time.Current > HitObject.EndTime) + if (AllJudged) return false; if (action != Action.Value) return false; - // The user has pressed during the body of the hold note, after the head note and its hit windows have passed - // and within the limited range of the above if-statement. This state will be managed by the head note if the - // user has pressed during the hit windows of the head note. - BeginHold(); + beginHoldAt(Time.Current - Head.HitObject.StartTime); + Head.UpdateResult(); + return true; } + private void beginHoldAt(double timeOffset) + { + if (timeOffset < -Head.HitObject.HitWindows.WindowFor(HitResult.Miss)) + return; + + HoldStartTime = Time.Current; + bodyPiece.Hitting = true; + } + public bool OnReleased(ManiaAction action) { - // Make sure that the user started holding the key during the hold note - if (!holdStartTime.HasValue) + if (AllJudged) return false; if (action != Action.Value) return false; - EndHold(); + // Make sure a hold was started + if (HoldStartTime == null) + return false; + + Tail.UpdateResult(); + endHold(); // If the key has been released too early, the user should not receive full score for the release if (!Tail.IsHit) - hasBroken = true; + HasBroken = true; return true; } - /// - /// The head note of a hold. - /// - private class DrawableHeadNote : DrawableNote + private void endHold() { - private readonly DrawableHoldNote holdNote; - - public DrawableHeadNote(DrawableHoldNote holdNote) - : base(holdNote.HitObject.Head) - { - this.holdNote = holdNote; - } - - public override bool OnPressed(ManiaAction action) - { - if (!base.OnPressed(action)) - return false; - - // If the key has been released too early, the user should not receive full score for the release - if (Result.Type == HitResult.Miss) - holdNote.hasBroken = true; - - // The head note also handles early hits before the body, but we want accurate early hits to count as the body being held - // The body doesn't handle these early early hits, so we have to explicitly set the holding state here - holdNote.BeginHold(); - - return true; - } - } - - /// - /// The tail note of a hold. - /// - private class DrawableTailNote : DrawableNote - { - /// - /// Lenience of release hit windows. This is to make cases where the hold note release - /// is timed alongside presses of other hit objects less awkward. - /// Todo: This shouldn't exist for non-LegacyBeatmapDecoder beatmaps - /// - private const double release_window_lenience = 1.5; - - private readonly DrawableHoldNote holdNote; - - public DrawableTailNote(DrawableHoldNote holdNote) - : base(holdNote.HitObject.Tail) - { - this.holdNote = holdNote; - } - - protected override void CheckForResult(bool userTriggered, double timeOffset) - { - Debug.Assert(HitObject.HitWindows != null); - - // Factor in the release lenience - timeOffset /= release_window_lenience; - - if (!userTriggered) - { - if (!HitObject.HitWindows.CanBeHit(timeOffset)) - ApplyResult(r => r.Type = HitResult.Miss); - - return; - } - - var result = HitObject.HitWindows.ResultFor(timeOffset); - if (result == HitResult.None) - return; - - ApplyResult(r => - { - if (holdNote.hasBroken && (result == HitResult.Perfect || result == HitResult.Perfect)) - result = HitResult.Good; - - r.Type = result; - }); - } - - public override bool OnPressed(ManiaAction action) => false; // Tail doesn't handle key down - - public override bool OnReleased(ManiaAction action) - { - // Make sure that the user started holding the key during the hold note - if (!holdNote.holdStartTime.HasValue) - return false; - - if (action != Action.Value) - return false; - - UpdateResult(true); - - // Handled by the hold note, which will set holding = false - return false; - } + HoldStartTime = null; + bodyPiece.Hitting = false; } } } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs new file mode 100644 index 0000000000..a5d03bf765 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs @@ -0,0 +1,22 @@ +// 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.Mania.Objects.Drawables +{ + /// + /// The head of a . + /// + public class DrawableHoldNoteHead : DrawableNote + { + public DrawableHoldNoteHead(DrawableHoldNote holdNote) + : base(holdNote.HitObject.Head) + { + } + + public void UpdateResult() => base.UpdateResult(true); + + public override bool OnPressed(ManiaAction action) => false; // Handled by the hold note + + public override bool OnReleased(ManiaAction action) => false; // Handled by the hold note + } +} diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs new file mode 100644 index 0000000000..a660144dd1 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs @@ -0,0 +1,64 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Mania.Objects.Drawables +{ + /// + /// The tail of a . + /// + public class DrawableHoldNoteTail : DrawableNote + { + /// + /// Lenience of release hit windows. This is to make cases where the hold note release + /// is timed alongside presses of other hit objects less awkward. + /// Todo: This shouldn't exist for non-LegacyBeatmapDecoder beatmaps + /// + private const double release_window_lenience = 1.5; + + private readonly DrawableHoldNote holdNote; + + public DrawableHoldNoteTail(DrawableHoldNote holdNote) + : base(holdNote.HitObject.Tail) + { + this.holdNote = holdNote; + } + + public void UpdateResult() => base.UpdateResult(true); + + protected override void CheckForResult(bool userTriggered, double timeOffset) + { + Debug.Assert(HitObject.HitWindows != null); + + // Factor in the release lenience + timeOffset /= release_window_lenience; + + if (!userTriggered) + { + if (!HitObject.HitWindows.CanBeHit(timeOffset)) + ApplyResult(r => r.Type = HitResult.Miss); + + return; + } + + var result = HitObject.HitWindows.ResultFor(timeOffset); + if (result == HitResult.None) + return; + + ApplyResult(r => + { + // If the head wasn't hit or the hold note was broken, cap the max score to Meh. + if (result > HitResult.Meh && (!holdNote.Head.IsHit || holdNote.HasBroken)) + result = HitResult.Meh; + + r.Type = result; + }); + } + + public override bool OnPressed(ManiaAction action) => false; // Handled by the hold note + + public override bool OnReleased(ManiaAction action) => false; // Handled by the hold note + } +} 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.Mania/UI/Components/ColumnHitObjectArea.cs b/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs index 386bcbb724..ee2cec1bbd 100644 --- a/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs +++ b/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs @@ -22,26 +22,15 @@ namespace osu.Game.Rulesets.Mania.UI.Components private readonly IBindable direction = new Bindable(); - private readonly Container hitTargetLine; - private readonly Drawable hitTargetBar; + private readonly Drawable hitTarget; public ColumnHitObjectArea(HitObjectContainer hitObjectContainer) { InternalChildren = new[] { - hitTargetBar = new Box + hitTarget = new DefaultHitTarget { RelativeSizeAxes = Axes.X, - Height = NotePiece.NOTE_HEIGHT, - Alpha = 0.6f, - Colour = Color4.Black - }, - hitTargetLine = new Container - { - RelativeSizeAxes = Axes.X, - Height = hit_target_bar_height, - Masking = true, - Child = new Box { RelativeSizeAxes = Axes.Both } }, hitObjectContainer }; @@ -55,17 +44,10 @@ namespace osu.Game.Rulesets.Mania.UI.Components { Anchor anchor = dir.NewValue == ScrollingDirection.Up ? Anchor.TopLeft : Anchor.BottomLeft; - hitTargetBar.Anchor = hitTargetBar.Origin = anchor; - hitTargetLine.Anchor = hitTargetLine.Origin = anchor; + hitTarget.Anchor = hitTarget.Origin = anchor; }, true); } - protected override void LoadComplete() - { - base.LoadComplete(); - updateColours(); - } - private Color4 accentColour; public Color4 AccentColour @@ -78,21 +60,86 @@ namespace osu.Game.Rulesets.Mania.UI.Components accentColour = value; - updateColours(); + if (hitTarget is IHasAccentColour colouredHitTarget) + colouredHitTarget.AccentColour = accentColour; } } - private void updateColours() + private class DefaultHitTarget : CompositeDrawable, IHasAccentColour { - if (!IsLoaded) - return; + private readonly IBindable direction = new Bindable(); - hitTargetLine.EdgeEffect = new EdgeEffectParameters + private readonly Container hitTargetLine; + private readonly Drawable hitTargetBar; + + public DefaultHitTarget() { - Type = EdgeEffectType.Glow, - Radius = 5, - Colour = accentColour.Opacity(0.5f), - }; + InternalChildren = new[] + { + hitTargetBar = new Box + { + RelativeSizeAxes = Axes.X, + Height = NotePiece.NOTE_HEIGHT, + Alpha = 0.6f, + Colour = Color4.Black + }, + hitTargetLine = new Container + { + RelativeSizeAxes = Axes.X, + Height = hit_target_bar_height, + Masking = true, + Child = new Box { RelativeSizeAxes = Axes.Both } + }, + }; + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(dir => + { + Anchor anchor = dir.NewValue == ScrollingDirection.Up ? Anchor.TopLeft : Anchor.BottomLeft; + + hitTargetBar.Anchor = hitTargetBar.Origin = anchor; + hitTargetLine.Anchor = hitTargetLine.Origin = anchor; + }, true); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateColours(); + } + + private Color4 accentColour; + + public Color4 AccentColour + { + get => accentColour; + set + { + if (accentColour == value) + return; + + accentColour = value; + + updateColours(); + } + } + + private void updateColours() + { + if (!IsLoaded) + return; + + hitTargetLine.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Radius = 5, + Colour = accentColour.Opacity(0.5f), + }; + } } } } 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/Mods/OsuModDifficultyAdjust.cs b/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs index 0514e2ab34..7eee71be81 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs @@ -34,8 +34,8 @@ namespace osu.Game.Rulesets.Osu.Mods { base.TransferSettings(difficulty); - CircleSize.Value = CircleSize.Default = difficulty.CircleSize; - ApproachRate.Value = ApproachRate.Default = difficulty.ApproachRate; + TransferSetting(CircleSize, difficulty.CircleSize); + TransferSetting(ApproachRate, difficulty.ApproachRate); } protected override void ApplySettings(BeatmapDifficulty difficulty) 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.Tests/Visual/Gameplay/TestSceneScrollingHitObjects.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneScrollingHitObjects.cs index aa80819694..8629522dc2 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneScrollingHitObjects.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneScrollingHitObjects.cs @@ -3,12 +3,14 @@ using System; using System.Collections.Generic; +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Threading; using osu.Game.Configuration; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; @@ -28,12 +30,16 @@ namespace osu.Game.Tests.Visual.Gameplay [Cached(typeof(IReadOnlyList))] private IReadOnlyList mods { get; set; } = Array.Empty(); + private const int spawn_interval = 5000; + private readonly ScrollingTestContainer[] scrollContainers = new ScrollingTestContainer[4]; private readonly TestPlayfield[] playfields = new TestPlayfield[4]; + private ScheduledDelegate hitObjectSpawnDelegate; - public TestSceneScrollingHitObjects() + [SetUp] + public void Setup() => Schedule(() => { - Add(new GridContainer + Child = new GridContainer { RelativeSizeAxes = Axes.Both, Content = new[] @@ -43,48 +49,66 @@ namespace osu.Game.Tests.Visual.Gameplay scrollContainers[0] = new ScrollingTestContainer(ScrollingDirection.Up) { RelativeSizeAxes = Axes.Both, - Child = playfields[0] = new TestPlayfield() + Child = playfields[0] = new TestPlayfield(), + TimeRange = spawn_interval }, - scrollContainers[1] = new ScrollingTestContainer(ScrollingDirection.Up) + scrollContainers[1] = new ScrollingTestContainer(ScrollingDirection.Down) { RelativeSizeAxes = Axes.Both, - Child = playfields[1] = new TestPlayfield() + Child = playfields[1] = new TestPlayfield(), + TimeRange = spawn_interval }, }, new Drawable[] { - scrollContainers[2] = new ScrollingTestContainer(ScrollingDirection.Up) + scrollContainers[2] = new ScrollingTestContainer(ScrollingDirection.Left) { RelativeSizeAxes = Axes.Both, - Child = playfields[2] = new TestPlayfield() + Child = playfields[2] = new TestPlayfield(), + TimeRange = spawn_interval }, - scrollContainers[3] = new ScrollingTestContainer(ScrollingDirection.Up) + scrollContainers[3] = new ScrollingTestContainer(ScrollingDirection.Right) { RelativeSizeAxes = Axes.Both, - Child = playfields[3] = new TestPlayfield() + Child = playfields[3] = new TestPlayfield(), + TimeRange = spawn_interval } } } - }); + }; + setUpHitObjects(); + }); + + private void setUpHitObjects() + { + scrollContainers.ForEach(c => c.ControlPoints.Add(new MultiplierControlPoint(0))); + + for (int i = 0; i <= spawn_interval; i += 1000) + addHitObject(Time.Current + i); + + hitObjectSpawnDelegate?.Cancel(); + hitObjectSpawnDelegate = Scheduler.AddDelayed(() => addHitObject(Time.Current + spawn_interval), 1000, true); + } + + [Test] + public void TestScrollAlgorithms() + { AddStep("Constant scroll", () => setScrollAlgorithm(ScrollVisualisationMethod.Constant)); AddStep("Overlapping scroll", () => setScrollAlgorithm(ScrollVisualisationMethod.Overlapping)); AddStep("Sequential scroll", () => setScrollAlgorithm(ScrollVisualisationMethod.Sequential)); - AddSliderStep("Time range", 100, 10000, 5000, v => scrollContainers.ForEach(c => c.TimeRange = v)); - AddStep("Add control point", () => addControlPoint(Time.Current + 5000)); + AddSliderStep("Time range", 100, 10000, spawn_interval, v => scrollContainers.Where(c => c != null).ForEach(c => c.TimeRange = v)); + AddStep("Add control point", () => addControlPoint(Time.Current + spawn_interval)); } - protected override void LoadComplete() + [Test] + public void TestScrollLifetime() { - base.LoadComplete(); - - scrollContainers.ForEach(c => c.ControlPoints.Add(new MultiplierControlPoint(0))); - - for (int i = 0; i <= 5000; i += 1000) - addHitObject(Time.Current + i); - - Scheduler.AddDelayed(() => addHitObject(Time.Current + 5000), 1000, true); + AddStep("Set constant scroll", () => setScrollAlgorithm(ScrollVisualisationMethod.Constant)); + // scroll container time range must be less than the rate of spawning hitobjects + // otherwise the hitobjects will spawn already partly visible on screen and look wrong + AddStep("Set time range", () => scrollContainers.ForEach(c => c.TimeRange = spawn_interval / 2.0)); } private void addHitObject(double time) @@ -207,7 +231,9 @@ namespace osu.Game.Tests.Visual.Gameplay public TestDrawableHitObject(double time) : base(new HitObject { StartTime = time }) { - Origin = Anchor.Centre; + Origin = Anchor.Custom; + OriginPosition = new Vector2(75 / 4.0f); + AutoSizeAxes = Axes.Both; AddInternal(new Box { Size = new Vector2(75) }); diff --git a/osu.Game/Graphics/UserInterface/SearchTextBox.cs b/osu.Game/Graphics/UserInterface/SearchTextBox.cs index ff3618b263..fe8756a4d2 100644 --- a/osu.Game/Graphics/UserInterface/SearchTextBox.cs +++ b/osu.Game/Graphics/UserInterface/SearchTextBox.cs @@ -34,11 +34,21 @@ namespace osu.Game.Graphics.UserInterface public override bool OnPressed(PlatformAction action) { - // Shift+delete is handled via PlatformAction on macOS. this is not so useful in the context of a SearchTextBox - // as we do not allow arrow key navigation in the first place (ie. the caret should always be at the end of text) - // Avoid handling it here to allow other components to potentially consume the shortcut. - if (action.ActionType == PlatformActionType.CharNext && action.ActionMethod == PlatformActionMethod.Delete) - return false; + switch (action.ActionType) + { + case PlatformActionType.LineEnd: + case PlatformActionType.LineStart: + return false; + + // Shift+delete is handled via PlatformAction on macOS. this is not so useful in the context of a SearchTextBox + // as we do not allow arrow key navigation in the first place (ie. the caret should always be at the end of text) + // Avoid handling it here to allow other components to potentially consume the shortcut. + case PlatformActionType.CharNext: + if (action.ActionMethod == PlatformActionMethod.Delete) + return false; + + break; + } return base.OnPressed(action); } diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index c7c746bed3..84aba4af52 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -39,6 +39,7 @@ using osu.Game.Online.Chat; using osu.Game.Skinning; using osuTK.Graphics; using osu.Game.Overlays.Volume; +using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Screens.Select; using osu.Game.Utils; @@ -204,6 +205,7 @@ namespace osu.Game Audio.AddAdjustment(AdjustableProperty.Volume, inactiveVolumeFade); + SelectedMods.BindValueChanged(modsChanged); Beatmap.BindValueChanged(beatmapChanged, true); } @@ -403,9 +405,29 @@ namespace osu.Game oldBeatmap.Track.Completed -= currentTrackCompleted; } + updateModDefaults(); + nextBeatmap?.LoadBeatmapAsync(); } + private void modsChanged(ValueChangedEvent> mods) + { + updateModDefaults(); + } + + private void updateModDefaults() + { + BeatmapDifficulty baseDifficulty = Beatmap.Value.BeatmapInfo.BaseDifficulty; + + if (baseDifficulty != null && SelectedMods.Value.Any(m => m is IApplicableToDifficulty)) + { + var adjustedDifficulty = baseDifficulty.Clone(); + + foreach (var mod in SelectedMods.Value.OfType()) + mod.ReadFromDifficulty(adjustedDifficulty); + } + } + private void currentTrackCompleted() => Schedule(() => { if (!Beatmap.Value.Track.Looping && !Beatmap.Disabled) diff --git a/osu.Game/Overlays/Direct/DirectPanel.cs b/osu.Game/Overlays/Direct/DirectPanel.cs index c1c5113c5e..4ad8e95512 100644 --- a/osu.Game/Overlays/Direct/DirectPanel.cs +++ b/osu.Game/Overlays/Direct/DirectPanel.cs @@ -1,6 +1,7 @@ // 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.Diagnostics; using System.Linq; @@ -9,21 +10,25 @@ using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; using osuTK; using osuTK.Graphics; namespace osu.Game.Overlays.Direct { - public abstract class DirectPanel : Container + public abstract class DirectPanel : OsuClickableContainer, IHasContextMenu { public readonly BeatmapSetInfo SetInfo; @@ -32,8 +37,6 @@ namespace osu.Game.Overlays.Direct private Container content; - private BeatmapSetOverlay beatmapSetOverlay; - public PreviewTrack Preview => PlayButton.Preview; public Bindable PreviewPlaying => PlayButton?.Playing; @@ -44,6 +47,8 @@ namespace osu.Game.Overlays.Direct protected override Container Content => content; + protected Action ViewBeatmap; + protected DirectPanel(BeatmapSetInfo setInfo) { Debug.Assert(setInfo.OnlineBeatmapSetID != null); @@ -70,8 +75,6 @@ namespace osu.Game.Overlays.Direct [BackgroundDependencyLoader(permitNulls: true)] private void load(BeatmapManager beatmaps, OsuColour colours, BeatmapSetOverlay beatmapSetOverlay) { - this.beatmapSetOverlay = beatmapSetOverlay; - AddInternal(content = new Container { RelativeSizeAxes = Axes.Both, @@ -88,6 +91,12 @@ namespace osu.Game.Overlays.Direct }, } }); + + Action = ViewBeatmap = () => + { + Debug.Assert(SetInfo.OnlineBeatmapSetID != null); + beatmapSetOverlay?.FetchAndShowBeatmapSet(SetInfo.OnlineBeatmapSetID.Value); + }; } protected override void Update() @@ -120,13 +129,6 @@ namespace osu.Game.Overlays.Direct base.OnHoverLost(e); } - protected override bool OnClick(ClickEvent e) - { - Debug.Assert(SetInfo.OnlineBeatmapSetID != null); - beatmapSetOverlay?.FetchAndShowBeatmapSet(SetInfo.OnlineBeatmapSetID.Value); - return true; - } - protected override void LoadComplete() { base.LoadComplete(); @@ -203,5 +205,10 @@ namespace osu.Game.Overlays.Direct Value = value; } } + + public MenuItem[] ContextMenuItems => new MenuItem[] + { + new OsuMenuItem("View Beatmap", MenuItemType.Highlighted, ViewBeatmap), + }; } } diff --git a/osu.Game/Overlays/SearchableList/SearchableListOverlay.cs b/osu.Game/Overlays/SearchableList/SearchableListOverlay.cs index 37478d902b..5975e94ffc 100644 --- a/osu.Game/Overlays/SearchableList/SearchableListOverlay.cs +++ b/osu.Game/Overlays/SearchableList/SearchableListOverlay.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Cursor; namespace osu.Game.Overlays.SearchableList { @@ -61,21 +62,20 @@ namespace osu.Game.Overlays.SearchableList scrollContainer = new Container { RelativeSizeAxes = Axes.Both, - Children = new[] + Child = new OsuContextMenuContainer { - new OsuScrollContainer + RelativeSizeAxes = Axes.Both, + Masking = true, + Child = new OsuScrollContainer { RelativeSizeAxes = Axes.Both, ScrollbarVisible = false, - Children = new[] + Child = ScrollFlow = new FillFlowContainer { - ScrollFlow = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Horizontal = WIDTH_PADDING, Bottom = 50 }, - Direction = FillDirection.Vertical, - }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = WIDTH_PADDING, Bottom = 50 }, + Direction = FillDirection.Vertical, }, }, }, 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/Mods/IApplicableToDifficulty.cs b/osu.Game/Rulesets/Mods/IApplicableToDifficulty.cs index 4d4cd75434..34198da722 100644 --- a/osu.Game/Rulesets/Mods/IApplicableToDifficulty.cs +++ b/osu.Game/Rulesets/Mods/IApplicableToDifficulty.cs @@ -10,6 +10,17 @@ namespace osu.Game.Rulesets.Mods /// public interface IApplicableToDifficulty : IApplicableMod { + /// + /// Called when a beatmap is changed. Can be used to read default values. + /// Any changes made will not be preserved. + /// + /// The difficulty to read from. + void ReadFromDifficulty(BeatmapDifficulty difficulty); + + /// + /// Called post beatmap conversion. Can be used to apply changes to difficulty attributes. + /// + /// The difficulty to mutate. void ApplyToDifficulty(BeatmapDifficulty difficulty); } } diff --git a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs index 224fc78508..c5b8a1bc73 100644 --- a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs +++ b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs @@ -5,6 +5,7 @@ using osu.Game.Beatmaps; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; using System; +using System.Collections.Generic; using osu.Game.Configuration; namespace osu.Game.Rulesets.Mods @@ -47,25 +48,48 @@ namespace osu.Game.Rulesets.Mods private BeatmapDifficulty difficulty; - public void ApplyToDifficulty(BeatmapDifficulty difficulty) + public void ReadFromDifficulty(BeatmapDifficulty difficulty) { if (this.difficulty == null || this.difficulty.ID != difficulty.ID) { - this.difficulty = difficulty; TransferSettings(difficulty); + this.difficulty = difficulty; } - else - ApplySettings(difficulty); } + public void ApplyToDifficulty(BeatmapDifficulty difficulty) => ApplySettings(difficulty); + /// /// Transfer initial settings from the beatmap to settings. /// /// The beatmap's initial values. protected virtual void TransferSettings(BeatmapDifficulty difficulty) { - DrainRate.Value = DrainRate.Default = difficulty.DrainRate; - OverallDifficulty.Value = OverallDifficulty.Default = difficulty.OverallDifficulty; + TransferSetting(DrainRate, difficulty.DrainRate); + TransferSetting(OverallDifficulty, difficulty.OverallDifficulty); + } + + private readonly Dictionary userChangedSettings = new Dictionary(); + + /// + /// Transfer a setting from to a configuration bindable. + /// Only performs the transfer if the user it not currently overriding.. + /// + protected void TransferSetting(BindableNumber bindable, T beatmapDefault) + where T : struct, IComparable, IConvertible, IEquatable + { + bindable.UnbindEvents(); + + userChangedSettings.TryAdd(bindable, false); + + bindable.Default = beatmapDefault; + + // users generally choose a difficulty setting and want it to stick across multiple beatmap changes. + // we only want to value transfer if the user hasn't changed the value previously. + if (!userChangedSettings[bindable]) + bindable.Value = beatmapDefault; + + bindable.ValueChanged += _ => userChangedSettings[bindable] = !bindable.IsDefault; } /// diff --git a/osu.Game/Rulesets/Mods/ModEasy.cs b/osu.Game/Rulesets/Mods/ModEasy.cs index f2da70d046..ec0f50c0be 100644 --- a/osu.Game/Rulesets/Mods/ModEasy.cs +++ b/osu.Game/Rulesets/Mods/ModEasy.cs @@ -32,6 +32,8 @@ namespace osu.Game.Rulesets.Mods private BindableNumber health; + public void ReadFromDifficulty(BeatmapDifficulty difficulty) { } + public void ApplyToDifficulty(BeatmapDifficulty difficulty) { const float ratio = 0.5f; diff --git a/osu.Game/Rulesets/Mods/ModHardRock.cs b/osu.Game/Rulesets/Mods/ModHardRock.cs index 2bcac3e4a9..a613d41cf4 100644 --- a/osu.Game/Rulesets/Mods/ModHardRock.cs +++ b/osu.Game/Rulesets/Mods/ModHardRock.cs @@ -17,6 +17,8 @@ namespace osu.Game.Rulesets.Mods public override string Description => "Everything just got a bit harder..."; public override Type[] IncompatibleMods => new[] { typeof(ModEasy), typeof(ModDifficultyAdjust) }; + public void ReadFromDifficulty(BeatmapDifficulty difficulty) { } + public void ApplyToDifficulty(BeatmapDifficulty difficulty) { const float ratio = 1.4f; 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/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs b/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs index 857929ff9e..04b4374fc4 100644 --- a/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs +++ b/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs @@ -100,7 +100,7 @@ namespace osu.Game.Rulesets.UI.Scrolling private void computeLifetimeStartRecursive(DrawableHitObject hitObject) { - hitObject.LifetimeStart = scrollingInfo.Algorithm.GetDisplayStartTime(hitObject.HitObject.StartTime, timeRange.Value); + hitObject.LifetimeStart = computeOriginAdjustedLifetimeStart(hitObject); foreach (var obj in hitObject.NestedHitObjects) computeLifetimeStartRecursive(obj); @@ -108,6 +108,35 @@ namespace osu.Game.Rulesets.UI.Scrolling private readonly Dictionary hitObjectInitialStateCache = new Dictionary(); + private double computeOriginAdjustedLifetimeStart(DrawableHitObject hitObject) + { + float originAdjustment = 0.0f; + + // calculate the dimension of the part of the hitobject that should already be visible + // when the hitobject origin first appears inside the scrolling container + switch (direction.Value) + { + case ScrollingDirection.Up: + originAdjustment = hitObject.OriginPosition.Y; + break; + + case ScrollingDirection.Down: + originAdjustment = hitObject.DrawHeight - hitObject.OriginPosition.Y; + break; + + case ScrollingDirection.Left: + originAdjustment = hitObject.OriginPosition.X; + break; + + case ScrollingDirection.Right: + originAdjustment = hitObject.DrawWidth - hitObject.OriginPosition.X; + break; + } + + var adjustedStartTime = scrollingInfo.Algorithm.TimeAt(-originAdjustment, hitObject.HitObject.StartTime, timeRange.Value, scrollLength); + return scrollingInfo.Algorithm.GetDisplayStartTime(adjustedStartTime, timeRange.Value); + } + // Cant use AddOnce() since the delegate is re-constructed every invocation private void computeInitialStateRecursive(DrawableHitObject hitObject) => hitObject.Schedule(() => { 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() => diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 57021dfc68..64fcc48004 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -118,8 +118,6 @@ namespace osu.Game.Screens.Play }, idleTracker = new IdleTracker(750) }); - - loadNewPlayer(); } protected override void LoadComplete() @@ -127,6 +125,21 @@ namespace osu.Game.Screens.Play base.LoadComplete(); inputManager = GetContainingInputManager(); + } + + public override void OnEntering(IScreen last) + { + base.OnEntering(last); + + loadNewPlayer(); + + content.ScaleTo(0.7f); + Background?.FadeColour(Color4.White, 800, Easing.OutQuint); + + contentIn(); + + info.Delay(750).FadeIn(500); + this.Delay(1800).Schedule(pushWhenLoaded); if (!muteWarningShownOnce.Value) { @@ -179,19 +192,6 @@ namespace osu.Game.Screens.Play content.FadeOut(250); } - public override void OnEntering(IScreen last) - { - base.OnEntering(last); - - content.ScaleTo(0.7f); - Background?.FadeColour(Color4.White, 800, Easing.OutQuint); - - contentIn(); - - info.Delay(750).FadeIn(500); - this.Delay(1800).Schedule(pushWhenLoaded); - } - protected override void LogoArriving(OsuLogo logo, bool resuming) { base.LogoArriving(logo, resuming); diff --git a/osu.Game/Screens/Play/ReplayPlayerLoader.cs b/osu.Game/Screens/Play/ReplayPlayerLoader.cs index c8ca604902..4572570437 100644 --- a/osu.Game/Screens/Play/ReplayPlayerLoader.cs +++ b/osu.Game/Screens/Play/ReplayPlayerLoader.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osu.Framework.Allocation; +using osu.Framework.Screens; using osu.Game.Scoring; namespace osu.Game.Screens.Play @@ -20,15 +20,13 @@ namespace osu.Game.Screens.Play scoreInfo = score.ScoreInfo; } - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + public override void OnEntering(IScreen last) { - var dependencies = base.CreateChildDependencies(parent); - // these will be reverted thanks to PlayerLoader's lease. Mods.Value = scoreInfo.Mods; Ruleset.Value = scoreInfo.Ruleset; - return dependencies; + base.OnEntering(last); } } } diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 3f64ce8dfd..565608b40f 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -22,8 +22,8 @@ - - + + diff --git a/osu.iOS.props b/osu.iOS.props index c2d4e3ad8e..60355b8592 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -73,8 +73,8 @@ - - + + @@ -82,7 +82,7 @@ - +