From 599fdb0128d88cbc44b8147cd5058b83528f2287 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 14 Dec 2023 11:51:50 +0900 Subject: [PATCH 01/12] Add lenience for late-hit of slider heads --- .../TestSceneSliderLateHitJudgement.cs | 419 ++++++++++++++++++ .../Objects/Drawables/DrawableSlider.cs | 2 + .../Objects/Drawables/DrawableSliderBall.cs | 57 +-- .../Objects/Drawables/DrawableSliderHead.cs | 48 ++ .../Objects/Drawables/DrawableSliderRepeat.cs | 26 +- .../Objects/Drawables/DrawableSliderTail.cs | 26 +- .../Objects/Drawables/DrawableSliderTick.cs | 28 +- 7 files changed, 565 insertions(+), 41 deletions(-) create mode 100644 osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs new file mode 100644 index 000000000000..4f32a6fe9f5f --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs @@ -0,0 +1,419 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Screens; +using osu.Game.Beatmaps; +using osu.Game.Replays; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.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; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public partial class TestSceneSliderLateHitJudgement : RateAdjustedBeatmapTestScene + { + // Note: In the following tests, the terminology "in range of the follow circle" is used as meaning + // the equivalent of "in range of the follow circle as if it were in its expanded state". + + private const double time_slider_start = 1000; + private const double time_slider_end = 1500; + + private static readonly Vector2 slider_start_position = new Vector2(256 - slider_path_length / 2, 192); + private static readonly Vector2 slider_end_position = new Vector2(256 + slider_path_length / 2, 192); + + private ScoreAccessibleReplayPlayer currentPlayer = null!; + + private const float slider_path_length = 200; + + private readonly List judgementResults = new List(); + + /// + /// If the head circle is hit and the mouse is in range of the follow circle, + /// then tracking should be enabled. + /// + [Test] + public void TestHitLateInRangeTracks() + { + performTest(new List + { + new OsuReplayFrame(time_slider_start + 100, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 100, slider_end_position, OsuAction.LeftButton), + }); + + assertHeadJudgement(HitResult.Ok); + assertTailJudgement(HitResult.LargeTickHit); + assertSliderJudgement(HitResult.IgnoreHit); + } + + /// + /// If the head circle is hit and the mouse is NOT in range of the follow circle, + /// then tracking should NOT be enabled. + /// + [Test] + public void TestHitLateOutOfRangeDoesNotTrack() + { + performTest(new List + { + new OsuReplayFrame(time_slider_start + 100, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 100, slider_end_position, OsuAction.LeftButton), + }, s => + { + s.SliderVelocityMultiplier = 2; + }); + + assertHeadJudgement(HitResult.Ok); + assertTailJudgement(HitResult.IgnoreMiss); + assertSliderJudgement(HitResult.IgnoreHit); + } + + /// + /// If the head circle is hit late and the mouse is in range of the follow circle, + /// then all ticks that the follow circle has passed through should be hit. + /// + [Test] + public void TestHitLateInRangeHitsTicks() + { + performTest(new List + { + new OsuReplayFrame(time_slider_start + 150, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 150, slider_end_position, OsuAction.LeftButton), + }, s => + { + s.TickDistanceMultiplier = 0.2f; + }); + + assertHeadJudgement(HitResult.Meh); + assertTickJudgement(0, HitResult.LargeTickHit); + assertTickJudgement(1, HitResult.LargeTickHit); + assertTickJudgement(2, HitResult.LargeTickHit); + assertTickJudgement(3, HitResult.LargeTickHit); + assertTailJudgement(HitResult.LargeTickHit); + assertSliderJudgement(HitResult.IgnoreHit); + } + + /// + /// If the head circle is hit late and the mouse is NOT in range of the follow circle, + /// then all ticks that the follow circle has passed through should NOT be hit. + /// + [Test] + public void TestHitLateOutOfRangeDoesNotHitTicks() + { + performTest(new List + { + new OsuReplayFrame(time_slider_start + 150, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 150, slider_end_position, OsuAction.LeftButton), + }, s => + { + s.SliderVelocityMultiplier = 2; + s.TickDistanceMultiplier = 0.2f; + }); + + assertHeadJudgement(HitResult.Meh); + assertTickJudgement(0, HitResult.LargeTickMiss); + assertTickJudgement(1, HitResult.LargeTickMiss); + assertTailJudgement(HitResult.IgnoreMiss); + assertSliderJudgement(HitResult.IgnoreHit); + } + + /// + /// If the head circle is pressed after it's missed and the mouse is in range of the follow circle, + /// then tracking should NOT be enabled. + /// + [Test] + public void TestMissHeadInRangeDoesNotTrack() + { + performTest(new List + { + new OsuReplayFrame(time_slider_start + 151, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 151, slider_end_position, OsuAction.LeftButton), + }, s => + { + s.TickDistanceMultiplier = 0.2f; + }); + + assertHeadJudgement(HitResult.Miss); + assertTickJudgement(0, HitResult.LargeTickMiss); + assertTickJudgement(1, HitResult.LargeTickMiss); + assertTickJudgement(2, HitResult.LargeTickMiss); + assertTickJudgement(3, HitResult.LargeTickMiss); + assertTailJudgement(HitResult.IgnoreMiss); + assertSliderJudgement(HitResult.IgnoreMiss); + } + + /// + /// If the head circle is hit late but after the completion of the slider and the mouse is in range of the follow circle, + /// then all nested objects (ticks/repeats/tail) should be hit. + /// + [Test] + public void TestHitLateShortSliderHitsAll() + { + performTest(new List + { + new OsuReplayFrame(time_slider_start + 150, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 150, slider_start_position, OsuAction.LeftButton), + }, s => + { + s.Path = new SliderPath(PathType.LINEAR, new[] + { + Vector2.Zero, + new Vector2(20, 0), + }, 20); + + s.TickDistanceMultiplier = 0.01f; + s.RepeatCount = 1; + }); + + assertHeadJudgement(HitResult.Meh); + assertAllTickJudgements(HitResult.LargeTickHit); + assertRepeatJudgement(HitResult.LargeTickHit); + assertTailJudgement(HitResult.LargeTickHit); + assertSliderJudgement(HitResult.IgnoreHit); + } + + /// + /// If the head circle is hit late and the mouse is in range of the follow circle, + /// then all the repeats that the mouse has passed through should be hit. + /// + [Test] + public void TestHitLateInRangeHitsRepeat() + { + performTest(new List + { + new OsuReplayFrame(time_slider_start + 150, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 150, slider_start_position, OsuAction.LeftButton), + }, s => + { + s.Path = new SliderPath(PathType.LINEAR, new[] + { + Vector2.Zero, + new Vector2(50, 0), + }, 50); + + s.RepeatCount = 1; + }); + + assertHeadJudgement(HitResult.Meh); + assertRepeatJudgement(HitResult.LargeTickHit); + assertTailJudgement(HitResult.LargeTickHit); + assertSliderJudgement(HitResult.IgnoreHit); + } + + /// + /// If the head circle is hit and the mouse is in range of the follow circle, + /// then only the ticks that were in range of the follow circle at the head should be hit. + /// If any hitobject was outside the follow range, ALL hitobjects after that point should be missed. + /// + [Test] + public void TestHitLateInRangeDoesNotHitAfterAnyOutOfRange() + { + performTest(new List + { + new OsuReplayFrame(time_slider_start + 150, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 150, slider_start_position, OsuAction.LeftButton), + }, s => + { + s.Path = new SliderPath(PathType.PERFECT_CURVE, new[] + { + Vector2.Zero, + new Vector2(70, 70), + new Vector2(20, 0), + }); + + s.TickDistanceMultiplier = 0.03f; + s.SliderVelocityMultiplier = 6f; + }); + + assertHeadJudgement(HitResult.Meh); + + // The first few ticks that are in the follow range of the head should be hit. + assertTickJudgement(0, HitResult.LargeTickHit); // This tick is hidden under the slider head :( + assertTickJudgement(1, HitResult.LargeTickHit); + assertTickJudgement(2, HitResult.LargeTickHit); + + // Every other tick should be missed + assertTickJudgement(3, HitResult.LargeTickMiss); + assertTickJudgement(4, HitResult.LargeTickMiss); + assertTickJudgement(5, HitResult.LargeTickMiss); + assertTickJudgement(6, HitResult.LargeTickMiss); + assertTickJudgement(7, HitResult.LargeTickMiss); + assertTickJudgement(8, HitResult.LargeTickMiss); + assertTickJudgement(9, HitResult.LargeTickMiss); + assertTickJudgement(10, HitResult.LargeTickMiss); + + // In particular, these three are in the follow range of the head, but should not be hit + // because the slider was at some point outside the follow range of the head. + assertTickJudgement(11, HitResult.LargeTickMiss); + assertTickJudgement(12, HitResult.LargeTickMiss); + + // And the tail should be hit because of its leniency. + assertTailJudgement(HitResult.LargeTickHit); + + assertSliderJudgement(HitResult.IgnoreHit); + } + + /// + /// If the head circle is hit and the mouse is in range of the follow circle, + /// then a tick outside the range of the follow circle from the head should not be hit. + /// + [Test] + public void TestHitLateInRangeDoesNotHitOutOfRange() + { + performTest(new List + { + new OsuReplayFrame(time_slider_start + 150, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 150, slider_start_position, OsuAction.LeftButton), + }, s => + { + s.Path = new SliderPath(PathType.PERFECT_CURVE, new[] + { + Vector2.Zero, + new Vector2(50, 50), + new Vector2(20, 0), + }); + + s.TickDistanceMultiplier = 0.3f; + s.SliderVelocityMultiplier = 3; + }); + + assertHeadJudgement(HitResult.Meh); + assertTickJudgement(0, HitResult.LargeTickMiss); + assertTailJudgement(HitResult.LargeTickHit); + assertSliderJudgement(HitResult.IgnoreHit); + } + + private void assertHeadJudgement(HitResult result) + { + AddAssert( + "check head result", + () => judgementResults.SingleOrDefault(r => r.HitObject is SliderHeadCircle)?.Type, + () => Is.EqualTo(result)); + } + + private void assertTickJudgement(int index, HitResult result) + { + AddAssert( + $"check tick({index}) result", + () => judgementResults.Where(r => r.HitObject is SliderTick).ElementAtOrDefault(index)?.Type, + () => Is.EqualTo(result)); + } + + private void assertAllTickJudgements(HitResult result) + { + AddAssert( + "check all tick results", + () => judgementResults.Where(r => r.HitObject is SliderTick).Select(t => t.Type), + () => Has.All.EqualTo(result)); + } + + private void assertRepeatJudgement(HitResult result) + { + AddAssert( + "check repeat result", + () => judgementResults.SingleOrDefault(r => r.HitObject is SliderRepeat)?.Type, + () => Is.EqualTo(result)); + } + + private void assertTailJudgement(HitResult result) + { + AddAssert( + "check tail result", + () => judgementResults.SingleOrDefault(r => r.HitObject is SliderTailCircle)?.Type, + () => Is.EqualTo(result)); + } + + private void assertSliderJudgement(HitResult result) + { + AddAssert( + "check slider result", + () => judgementResults.SingleOrDefault(r => r.HitObject is Slider)?.Type, + () => Is.EqualTo(result)); + } + + private Vector2 computePositionFromTime(double time) + { + Vector2 dist = slider_end_position - slider_start_position; + double t = (time - time_slider_start) / (time_slider_end - time_slider_start); + return slider_start_position + dist * (float)t; + } + + private void performTest(List frames, Action? adjustSliderFunc = null, bool classic = false) + { + Slider slider = new Slider + { + StartTime = time_slider_start, + Position = new Vector2(256 - slider_path_length / 2, 192), + TickDistanceMultiplier = 3, + ClassicSliderBehaviour = classic, + Path = new SliderPath(PathType.LINEAR, new[] + { + Vector2.Zero, + new Vector2(slider_path_length, 0), + }, slider_path_length), + }; + + adjustSliderFunc?.Invoke(slider); + + AddStep("load player", () => + { + Beatmap.Value = CreateWorkingBeatmap(new Beatmap + { + HitObjects = { slider }, + BeatmapInfo = + { + Difficulty = new BeatmapDifficulty + { + SliderMultiplier = 4, + SliderTickRate = 3 + }, + Ruleset = new OsuRuleset().RulesetInfo, + } + }); + + var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } }); + + p.OnLoadComplete += _ => + { + p.ScoreProcessor.NewJudgement += result => + { + if (currentPlayer == p) judgementResults.Add(result); + }; + }; + + LoadScreen(currentPlayer = p); + judgementResults.Clear(); + }); + + AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0); + AddUntilStep("Wait until player is loaded", () => currentPlayer.IsCurrentScreen()); + AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor.HasCompleted.Value); + } + + private partial class ScoreAccessibleReplayPlayer : ReplayPlayer + { + public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; + + protected override bool PauseOnFocusLost => false; + + public ScoreAccessibleReplayPlayer(Score score) + : base(score, new PlayerConfiguration + { + AllowPause = false, + ShowResults = false, + }) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index a053c99a5322..1dd6f108f517 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -28,6 +28,8 @@ public partial class DrawableSlider : DrawableOsuHitObject public DrawableSliderHead HeadCircle => headContainer.Child; public DrawableSliderTail TailCircle => tailContainer.Child; + public IEnumerable Ticks => tickContainer.Children; + public IEnumerable Repeats => repeatContainer.Children; [Cached] public DrawableSliderBall Ball { get; private set; } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs index a1724d6fdc44..764dd43c3040 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs @@ -27,7 +27,6 @@ public partial class DrawableSliderBall : CircularContainer, ISliderProgress, IR public Func GetInitialHitAction; - private Drawable followCircleReceptor; private DrawableSlider drawableSlider; private Drawable ball; @@ -48,13 +47,6 @@ private void load(DrawableHitObject drawableSlider) Anchor = Anchor.Centre, RelativeSizeAxes = Axes.Both, }, - followCircleReceptor = new CircularContainer - { - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Masking = true - }, ball = new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.SliderBall), _ => new DefaultSliderBall()) { Anchor = Anchor.Centre, @@ -86,21 +78,7 @@ public override void ApplyTransformsAt(double time, bool propagateChildren = fal base.ApplyTransformsAt(time, false); } - private bool tracking; - - public bool Tracking - { - get => tracking; - private set - { - if (value == tracking) - return; - - tracking = value; - - followCircleReceptor.Scale = new Vector2(tracking ? FOLLOW_AREA : 1f); - } - } + public bool Tracking { get; private set; } /// /// If the cursor moves out of the ball's radius we still need to be able to receive positional updates to stop tracking. @@ -129,6 +107,30 @@ private set /// private readonly List lastPressedActions = new List(); + public bool IsMouseInFollowCircleWithState(bool expanded) + { + if (lastScreenSpaceMousePosition is not Vector2 mousePos) + return false; + + float radius = GetFollowCircleRadius(expanded); + + double followProgress = Math.Clamp((Time.Current - drawableSlider.HitObject.StartTime) / drawableSlider.HitObject.Duration, 0, 1); + Vector2 followCirclePosition = drawableSlider.HitObject.CurvePositionAt(followProgress); + Vector2 mousePositionInSlider = drawableSlider.ToLocalSpace(mousePos) - drawableSlider.OriginPosition; + + return (mousePositionInSlider - followCirclePosition).LengthSquared <= radius * radius; + } + + public float GetFollowCircleRadius(bool expanded) + { + float radius = (float)drawableSlider.HitObject.Radius; + + if (expanded) + radius *= FOLLOW_AREA; + + return radius; + } + protected override void Update() { base.Update(); @@ -152,14 +154,19 @@ protected override void Update() timeToAcceptAnyKeyAfter = Time.Current; } + bool validInFollowArea = IsMouseInFollowCircleWithState(Tracking); + bool validInHeadCircle = drawableSlider.HeadCircle.IsHit + && IsMouseInFollowCircleWithState(true) + && drawableSlider.HeadCircle.Result.TimeAbsolute == Time.Current; + Tracking = // even in an edge case where current time has exceeded the slider's time, we may not have finished judging. // we don't want to potentially update from Tracking=true to Tracking=false at this point. (!drawableSlider.AllJudged || Time.Current <= drawableSlider.HitObject.GetEndTime()) // in valid position range - && lastScreenSpaceMousePosition.HasValue && followCircleReceptor.ReceivePositionalInputAt(lastScreenSpaceMousePosition.Value) && + && (validInFollowArea || validInHeadCircle) // valid action - (actions?.Any(isValidTrackingAction) ?? false); + && (actions?.Any(isValidTrackingAction) ?? false); lastPressedActions.Clear(); if (actions != null) diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs index ff690417a86c..0a104c123bc0 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs @@ -3,10 +3,14 @@ #nullable disable +using System; using System.Diagnostics; +using System.Linq; using osu.Framework.Bindables; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Scoring; +using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables { @@ -61,6 +65,50 @@ protected override void OnApply() CheckHittable = (d, t, r) => DrawableSlider.CheckHittable?.Invoke(d, t, r) ?? ClickAction.Hit; } + protected override void CheckForResult(bool userTriggered, double timeOffset) + { + base.CheckForResult(userTriggered, timeOffset); + + if (!Judged || !Result.IsHit) + return; + + // If the head is hit and in radius of the would-be-expanded follow circle, + // then hit every object that the follow circle has passed through up until the current time. + if (DrawableSlider.Ball.IsMouseInFollowCircleWithState(true)) + { + foreach (var nested in DrawableSlider.NestedHitObjects.OfType()) + { + if (nested.Judged) + continue; + + if (!check(nested.HitObject)) + break; + + if (nested is DrawableSliderTick tick) + tick.HitForcefully(); + + if (nested is DrawableSliderRepeat repeat) + repeat.HitForcefully(); + + if (nested is DrawableSliderTail tail) + tail.HitForcefully(); + } + } + + bool check(OsuHitObject h) + { + if (h.StartTime > Time.Current) + return false; + + float radius = DrawableSlider.Ball.GetFollowCircleRadius(true); + + double objectProgress = Math.Clamp((h.StartTime - DrawableSlider.HitObject.StartTime) / DrawableSlider.HitObject.Duration, 0, 1); + Vector2 objectPosition = DrawableSlider.HitObject.CurvePositionAt(objectProgress); + + return objectPosition.LengthSquared <= radius * radius; + } + } + protected override HitResult ResultFor(double timeOffset) { Debug.Assert(HitObject != null); diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs index cdfd96514ed1..a7979bde2728 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs @@ -85,17 +85,33 @@ protected override void OnApply() Position = HitObject.Position - DrawableSlider.Position; } + public void HitForcefully() + { + if (Judged) + return; + + ApplyResult(r => r.Type = r.Judgement.MaxResult); + } + protected override void CheckForResult(bool userTriggered, double timeOffset) { // shared implementation with DrawableSliderTick. if (timeOffset >= 0) { - // Attempt to preserve correct ordering of judgements as best we can by forcing - // an un-judged head to be missed when the user has clearly skipped it. - // // This check is applied to all nested slider objects apart from the head (ticks, repeats, tail). - if (Tracking && !DrawableSlider.HeadCircle.Judged) - DrawableSlider.HeadCircle.MissForcefully(); + if (!DrawableSlider.HeadCircle.Judged) + { + if (Tracking) + { + // Attempt to preserve correct ordering of judgements as best we can by forcing an un-judged head to be missed when the user has clearly skipped it. + DrawableSlider.HeadCircle.MissForcefully(); + } + else + { + // Don't judge this object as a miss before the head has been judged, to allow the head to be hit late. + return; + } + } ApplyResult(r => r.Type = Tracking ? r.Judgement.MaxResult : r.Judgement.MinResult); } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs index e3ed12a648c6..1ffbaf11c53f 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs @@ -125,6 +125,14 @@ protected override void UpdateHitStateTransforms(ArmedState state) } } + public void HitForcefully() + { + if (Judged) + return; + + ApplyResult(r => r.Type = r.Judgement.MaxResult); + } + protected override void CheckForResult(bool userTriggered, double timeOffset) { if (userTriggered) @@ -141,12 +149,20 @@ protected override void CheckForResult(bool userTriggered, double timeOffset) if (timeOffset < SliderEventGenerator.TAIL_LENIENCY) return; - // Attempt to preserve correct ordering of judgements as best we can by forcing - // an un-judged head to be missed when the user has clearly skipped it. - // // This check is applied to all nested slider objects apart from the head (ticks, repeats, tail). - if (Tracking && !DrawableSlider.HeadCircle.Judged) - DrawableSlider.HeadCircle.MissForcefully(); + if (!DrawableSlider.HeadCircle.Judged) + { + if (Tracking) + { + // Attempt to preserve correct ordering of judgements as best we can by forcing an un-judged head to be missed when the user has clearly skipped it. + DrawableSlider.HeadCircle.MissForcefully(); + } + else + { + // Don't judge this object as a miss before the head has been judged, to allow the head to be hit late. + return; + } + } // The player needs to have engaged in tracking at any point after the tail leniency cutoff. // An actual tick miss should only occur if reaching the tick itself. diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs index 172dca356e60..b2ac8ecbdaf5 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs @@ -73,17 +73,33 @@ protected override void OnApply() Position = HitObject.Position - DrawableSlider.HitObject.Position; } + public void HitForcefully() + { + if (Judged) + return; + + ApplyResult(r => r.Type = r.Judgement.MaxResult); + } + protected override void CheckForResult(bool userTriggered, double timeOffset) { // shared implementation with DrawableSliderRepeat. if (timeOffset >= 0) { - // Attempt to preserve correct ordering of judgements as best we can by forcing - // an un-judged head to be missed when the user has clearly skipped it. - // // This check is applied to all nested slider objects apart from the head (ticks, repeats, tail). - if (Tracking && !DrawableSlider.HeadCircle.Judged) - DrawableSlider.HeadCircle.MissForcefully(); + if (!DrawableSlider.HeadCircle.Judged) + { + if (Tracking) + { + // Attempt to preserve correct ordering of judgements as best we can by forcing an un-judged head to be missed when the user has clearly skipped it. + DrawableSlider.HeadCircle.MissForcefully(); + } + else + { + // Don't judge this object as a miss before the head has been judged, to allow the head to be hit late. + return; + } + } ApplyResult(r => r.Type = Tracking ? r.Judgement.MaxResult : r.Judgement.MinResult); } @@ -107,7 +123,7 @@ protected override void UpdateHitStateTransforms(ArmedState state) case ArmedState.Miss: this.FadeOut(ANIM_DURATION); - this.FadeColour(Color4.Red, ANIM_DURATION / 2); + this.TransformBindableTo(AccentColour, Color4.Red, 0); break; case ArmedState.Hit: From 6bd190c55d809ee6b69db19d5ce944bd6c887bfd Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 15 Dec 2023 16:13:32 +0900 Subject: [PATCH 02/12] Refactor all slider input into SliderInputManager --- .../Objects/Drawables/DrawableHitCircle.cs | 2 +- .../Objects/Drawables/DrawableOsuHitObject.cs | 5 + .../Objects/Drawables/DrawableSlider.cs | 14 +- .../Objects/Drawables/DrawableSliderBall.cs | 126 +-------- .../Objects/Drawables/DrawableSliderHead.cs | 44 +--- .../Objects/Drawables/DrawableSliderRepeat.cs | 36 +-- .../Objects/Drawables/DrawableSliderTail.cs | 53 +--- .../Objects/Drawables/DrawableSliderTick.cs | 36 +-- .../Objects/Drawables/IRequireTracking.cs | 13 - .../Objects/Drawables/SliderInputManager.cs | 248 ++++++++++++++++++ 10 files changed, 270 insertions(+), 307 deletions(-) delete mode 100644 osu.Game.Rulesets.Osu/Objects/Drawables/IRequireTracking.cs create mode 100644 osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs index e87a075a112a..0d665cad0c4e 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs @@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { public partial class DrawableHitCircle : DrawableOsuHitObject, IHasApproachCircle { - public OsuAction? HitAction => HitArea.HitAction; + public OsuAction? HitAction => HitArea?.HitAction; protected virtual OsuSkinComponents CirclePieceComponent => OsuSkinComponents.HitCircle; public SkinnableDrawable ApproachCircle { get; private set; } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs index bdd818cf18ad..5b379a0d905a 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs @@ -97,6 +97,11 @@ protected override void UpdateInitialTransforms() /// public virtual void Shake() { } + /// + /// Causes this to get hit, disregarding all conditions in implementations of . + /// + public void HitForcefully() => ApplyResult(r => r.Type = r.Judgement.MaxResult); + /// /// Causes this to get missed, disregarding all conditions in implementations of . /// diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index 1dd6f108f517..1f9a02804598 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -28,8 +28,6 @@ public partial class DrawableSlider : DrawableOsuHitObject public DrawableSliderHead HeadCircle => headContainer.Child; public DrawableSliderTail TailCircle => tailContainer.Child; - public IEnumerable Ticks => tickContainer.Children; - public IEnumerable Repeats => repeatContainer.Children; [Cached] public DrawableSliderBall Ball { get; private set; } @@ -60,6 +58,8 @@ public partial class DrawableSlider : DrawableOsuHitObject public IBindable PathVersion => pathVersion; private readonly Bindable pathVersion = new Bindable(); + public readonly SliderInputManager SliderInputManager; + private Container headContainer; private Container tailContainer; private Container tickContainer; @@ -74,9 +74,10 @@ public DrawableSlider() public DrawableSlider([CanBeNull] Slider s = null) : base(s) { + SliderInputManager = new SliderInputManager(this); + Ball = new DrawableSliderBall { - GetInitialHitAction = () => HeadCircle.HitAction, BypassAutoSizeAxes = Axes.Both, AlwaysPresent = true, Alpha = 0 @@ -90,6 +91,7 @@ private void load() AddRangeInternal(new Drawable[] { + SliderInputManager, shakeContainer = new ShakeContainer { ShakeDuration = 30, @@ -234,7 +236,7 @@ protected override void Update() { base.Update(); - Tracking.Value = Ball.Tracking; + Tracking.Value = SliderInputManager.Tracking; if (Tracking.Value && slidingSample != null) // keep the sliding sample playing at the current tracking position @@ -247,8 +249,8 @@ protected override void Update() foreach (DrawableHitObject hitObject in NestedHitObjects) { - if (hitObject is ITrackSnaking s) s.UpdateSnakingPosition(HitObject.Path.PositionAt(SliderBody?.SnakedStart ?? 0), HitObject.Path.PositionAt(SliderBody?.SnakedEnd ?? 0)); - if (hitObject is IRequireTracking t) t.Tracking = Ball.Tracking; + if (hitObject is ITrackSnaking s) + s.UpdateSnakingPosition(HitObject.Path.PositionAt(SliderBody?.SnakedStart ?? 0), HitObject.Path.PositionAt(SliderBody?.SnakedEnd ?? 0)); } Size = SliderBody?.Size ?? Vector2.Zero; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs index 764dd43c3040..46f023198124 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs @@ -4,14 +4,9 @@ #nullable disable using System; -using System.Collections.Generic; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Input; -using osu.Framework.Input.Events; -using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Skinning.Default; @@ -21,12 +16,10 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public partial class DrawableSliderBall : CircularContainer, ISliderProgress, IRequireHighFrequencyMousePosition + public partial class DrawableSliderBall : CircularContainer, ISliderProgress { public const float FOLLOW_AREA = 2.4f; - public Func GetInitialHitAction; - private DrawableSlider drawableSlider; private Drawable ball; @@ -55,14 +48,6 @@ private void load(DrawableHitObject drawableSlider) }; } - private Vector2? lastScreenSpaceMousePosition; - - protected override bool OnMouseMove(MouseMoveEvent e) - { - lastScreenSpaceMousePosition = e.ScreenSpaceMousePosition; - return base.OnMouseMove(e); - } - public override void ClearTransformsAfter(double time, bool propagateChildren = false, string targetMember = null) { // Consider the case of rewinding - children's transforms are handled internally, so propagating down @@ -78,115 +63,6 @@ public override void ApplyTransformsAt(double time, bool propagateChildren = fal base.ApplyTransformsAt(time, false); } - public bool Tracking { get; private set; } - - /// - /// If the cursor moves out of the ball's radius we still need to be able to receive positional updates to stop tracking. - /// - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; - - /// - /// The point in time after which we can accept any key for tracking. Before this time, we may need to restrict tracking to the key used to hit the head circle. - /// - /// This is a requirement to stop the case where a player holds down one key (from before the slider) and taps the second key while maintaining full scoring (tracking) of sliders. - /// Visually, this special case can be seen below (time increasing from left to right): - /// - /// Z Z+X Z - /// o========o - /// - /// Without this logic, tracking would continue through the entire slider even though no key hold action is directly attributing to it. - /// - /// In all other cases, no special handling is required (either key being pressed is allowable as valid tracking). - /// - /// The reason for storing this as a time value (rather than a bool) is to correctly handle rewind scenarios. - /// - private double? timeToAcceptAnyKeyAfter; - - /// - /// The actions that were pressed in the previous frame. - /// - private readonly List lastPressedActions = new List(); - - public bool IsMouseInFollowCircleWithState(bool expanded) - { - if (lastScreenSpaceMousePosition is not Vector2 mousePos) - return false; - - float radius = GetFollowCircleRadius(expanded); - - double followProgress = Math.Clamp((Time.Current - drawableSlider.HitObject.StartTime) / drawableSlider.HitObject.Duration, 0, 1); - Vector2 followCirclePosition = drawableSlider.HitObject.CurvePositionAt(followProgress); - Vector2 mousePositionInSlider = drawableSlider.ToLocalSpace(mousePos) - drawableSlider.OriginPosition; - - return (mousePositionInSlider - followCirclePosition).LengthSquared <= radius * radius; - } - - public float GetFollowCircleRadius(bool expanded) - { - float radius = (float)drawableSlider.HitObject.Radius; - - if (expanded) - radius *= FOLLOW_AREA; - - return radius; - } - - protected override void Update() - { - base.Update(); - - // from the point at which the head circle is hit, this will be non-null. - // it may be null if the head circle was missed. - var headCircleHitAction = GetInitialHitAction(); - - if (headCircleHitAction == null) - timeToAcceptAnyKeyAfter = null; - - var actions = drawableSlider.OsuActionInputManager?.PressedActions; - - // if the head circle was hit with a specific key, tracking should only occur while that key is pressed. - if (headCircleHitAction != null && timeToAcceptAnyKeyAfter == null) - { - var otherKey = headCircleHitAction == OsuAction.RightButton ? OsuAction.LeftButton : OsuAction.RightButton; - - // we can start accepting any key once all other keys have been released in the previous frame. - if (!lastPressedActions.Contains(otherKey)) - timeToAcceptAnyKeyAfter = Time.Current; - } - - bool validInFollowArea = IsMouseInFollowCircleWithState(Tracking); - bool validInHeadCircle = drawableSlider.HeadCircle.IsHit - && IsMouseInFollowCircleWithState(true) - && drawableSlider.HeadCircle.Result.TimeAbsolute == Time.Current; - - Tracking = - // even in an edge case where current time has exceeded the slider's time, we may not have finished judging. - // we don't want to potentially update from Tracking=true to Tracking=false at this point. - (!drawableSlider.AllJudged || Time.Current <= drawableSlider.HitObject.GetEndTime()) - // in valid position range - && (validInFollowArea || validInHeadCircle) - // valid action - && (actions?.Any(isValidTrackingAction) ?? false); - - lastPressedActions.Clear(); - if (actions != null) - lastPressedActions.AddRange(actions); - } - - /// - /// Check whether a given user input is a valid tracking action. - /// - private bool isValidTrackingAction(OsuAction action) - { - bool headCircleHit = GetInitialHitAction().HasValue; - - // if the head circle was hit, we may not yet be allowed to accept any key, so we must use the initial hit action. - if (headCircleHit && (!timeToAcceptAnyKeyAfter.HasValue || Time.Current <= timeToAcceptAnyKeyAfter.Value)) - return action == GetInitialHitAction(); - - return action == OsuAction.LeftButton || action == OsuAction.RightButton; - } - private Vector2? lastPosition; public void UpdateProgress(double completionProgress) diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs index 0a104c123bc0..76b9fdc3ceea 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs @@ -3,14 +3,10 @@ #nullable disable -using System; using System.Diagnostics; -using System.Linq; using osu.Framework.Bindables; -using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Scoring; -using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables { @@ -68,45 +64,7 @@ protected override void OnApply() protected override void CheckForResult(bool userTriggered, double timeOffset) { base.CheckForResult(userTriggered, timeOffset); - - if (!Judged || !Result.IsHit) - return; - - // If the head is hit and in radius of the would-be-expanded follow circle, - // then hit every object that the follow circle has passed through up until the current time. - if (DrawableSlider.Ball.IsMouseInFollowCircleWithState(true)) - { - foreach (var nested in DrawableSlider.NestedHitObjects.OfType()) - { - if (nested.Judged) - continue; - - if (!check(nested.HitObject)) - break; - - if (nested is DrawableSliderTick tick) - tick.HitForcefully(); - - if (nested is DrawableSliderRepeat repeat) - repeat.HitForcefully(); - - if (nested is DrawableSliderTail tail) - tail.HitForcefully(); - } - } - - bool check(OsuHitObject h) - { - if (h.StartTime > Time.Current) - return false; - - float radius = DrawableSlider.Ball.GetFollowCircleRadius(true); - - double objectProgress = Math.Clamp((h.StartTime - DrawableSlider.HitObject.StartTime) / DrawableSlider.HitObject.Duration, 0, 1); - Vector2 objectPosition = DrawableSlider.HitObject.CurvePositionAt(objectProgress); - - return objectPosition.LengthSquared <= radius * radius; - } + DrawableSlider.SliderInputManager.PostProcessHeadJudgement(this); } protected override HitResult ResultFor(double timeOffset) diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs index a7979bde2728..0c8e5b765fa3 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs @@ -17,7 +17,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public partial class DrawableSliderRepeat : DrawableOsuHitObject, ITrackSnaking, IRequireTracking + public partial class DrawableSliderRepeat : DrawableOsuHitObject, ITrackSnaking { public new SliderRepeat HitObject => (SliderRepeat)base.HitObject; @@ -36,8 +36,6 @@ public partial class DrawableSliderRepeat : DrawableOsuHitObject, ITrackSnaking, public override bool DisplayResult => false; - public bool Tracking { get; set; } - public DrawableSliderRepeat() : base(null) { @@ -85,37 +83,7 @@ protected override void OnApply() Position = HitObject.Position - DrawableSlider.Position; } - public void HitForcefully() - { - if (Judged) - return; - - ApplyResult(r => r.Type = r.Judgement.MaxResult); - } - - protected override void CheckForResult(bool userTriggered, double timeOffset) - { - // shared implementation with DrawableSliderTick. - if (timeOffset >= 0) - { - // This check is applied to all nested slider objects apart from the head (ticks, repeats, tail). - if (!DrawableSlider.HeadCircle.Judged) - { - if (Tracking) - { - // Attempt to preserve correct ordering of judgements as best we can by forcing an un-judged head to be missed when the user has clearly skipped it. - DrawableSlider.HeadCircle.MissForcefully(); - } - else - { - // Don't judge this object as a miss before the head has been judged, to allow the head to be hit late. - return; - } - } - - ApplyResult(r => r.Type = Tracking ? r.Judgement.MaxResult : r.Judgement.MinResult); - } - } + protected override void CheckForResult(bool userTriggered, double timeOffset) => DrawableSlider.SliderInputManager.TryJudgeNestedObject(this, timeOffset); protected override void UpdateInitialTransforms() { diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs index 1ffbaf11c53f..60bad5d4a7aa 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs @@ -4,12 +4,10 @@ #nullable disable using System.Diagnostics; -using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; using osu.Game.Skinning; @@ -17,7 +15,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public partial class DrawableSliderTail : DrawableOsuHitObject, IRequireTracking + public partial class DrawableSliderTail : DrawableOsuHitObject { public new SliderTailCircle HitObject => (SliderTailCircle)base.HitObject; @@ -37,8 +35,6 @@ public partial class DrawableSliderTail : DrawableOsuHitObject, IRequireTracking /// public bool SamplePlaysOnlyOnHit { get; set; } = true; - public bool Tracking { get; set; } - public SkinnableDrawable CirclePiece { get; private set; } private Container scaleContainer; @@ -125,52 +121,7 @@ protected override void UpdateHitStateTransforms(ArmedState state) } } - public void HitForcefully() - { - if (Judged) - return; - - ApplyResult(r => r.Type = r.Judgement.MaxResult); - } - - protected override void CheckForResult(bool userTriggered, double timeOffset) - { - if (userTriggered) - return; - - // Ensure the tail can only activate after all previous ticks/repeats already have. - // - // This covers the edge case where the lenience may allow the tail to activate before - // the last tick, changing ordering of score/combo awarding. - var lastTick = DrawableSlider.NestedHitObjects.LastOrDefault(o => o.HitObject is SliderTick || o.HitObject is SliderRepeat); - if (lastTick?.Judged == false) - return; - - if (timeOffset < SliderEventGenerator.TAIL_LENIENCY) - return; - - // This check is applied to all nested slider objects apart from the head (ticks, repeats, tail). - if (!DrawableSlider.HeadCircle.Judged) - { - if (Tracking) - { - // Attempt to preserve correct ordering of judgements as best we can by forcing an un-judged head to be missed when the user has clearly skipped it. - DrawableSlider.HeadCircle.MissForcefully(); - } - else - { - // Don't judge this object as a miss before the head has been judged, to allow the head to be hit late. - return; - } - } - - // The player needs to have engaged in tracking at any point after the tail leniency cutoff. - // An actual tick miss should only occur if reaching the tick itself. - if (Tracking) - ApplyResult(r => r.Type = r.Judgement.MaxResult); - else if (timeOffset > 0) - ApplyResult(r => r.Type = r.Judgement.MinResult); - } + protected override void CheckForResult(bool userTriggered, double timeOffset) => DrawableSlider.SliderInputManager.TryJudgeNestedObject(this, timeOffset); protected override void OnApply() { diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs index b2ac8ecbdaf5..cb323f4ac79b 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs @@ -14,14 +14,12 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public partial class DrawableSliderTick : DrawableOsuHitObject, IRequireTracking + public partial class DrawableSliderTick : DrawableOsuHitObject { public const double ANIM_DURATION = 150; private const float default_tick_size = 16; - public bool Tracking { get; set; } - public override bool DisplayResult => false; protected DrawableSlider DrawableSlider => (DrawableSlider)ParentHitObject; @@ -73,37 +71,7 @@ protected override void OnApply() Position = HitObject.Position - DrawableSlider.HitObject.Position; } - public void HitForcefully() - { - if (Judged) - return; - - ApplyResult(r => r.Type = r.Judgement.MaxResult); - } - - protected override void CheckForResult(bool userTriggered, double timeOffset) - { - // shared implementation with DrawableSliderRepeat. - if (timeOffset >= 0) - { - // This check is applied to all nested slider objects apart from the head (ticks, repeats, tail). - if (!DrawableSlider.HeadCircle.Judged) - { - if (Tracking) - { - // Attempt to preserve correct ordering of judgements as best we can by forcing an un-judged head to be missed when the user has clearly skipped it. - DrawableSlider.HeadCircle.MissForcefully(); - } - else - { - // Don't judge this object as a miss before the head has been judged, to allow the head to be hit late. - return; - } - } - - ApplyResult(r => r.Type = Tracking ? r.Judgement.MaxResult : r.Judgement.MinResult); - } - } + protected override void CheckForResult(bool userTriggered, double timeOffset) => DrawableSlider.SliderInputManager.TryJudgeNestedObject(this, timeOffset); protected override void UpdateInitialTransforms() { diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/IRequireTracking.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/IRequireTracking.cs deleted file mode 100644 index b1815b23c909..000000000000 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/IRequireTracking.cs +++ /dev/null @@ -1,13 +0,0 @@ -// 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.Osu.Objects.Drawables -{ - public interface IRequireTracking - { - /// - /// Whether the is currently being tracked by the user. - /// - bool Tracking { get; set; } - } -} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs new file mode 100644 index 000000000000..a5d7bdc7dbf0 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs @@ -0,0 +1,248 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Input; +using osu.Framework.Input.Events; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Objects.Drawables +{ + public partial class SliderInputManager : Component, IRequireHighFrequencyMousePosition + { + /// + /// Whether the slider is currently being tracked. + /// + public bool Tracking { get; private set; } + + /// + /// The point in time after which we can accept any key for tracking. Before this time, we may need to restrict tracking to the key used to hit the head circle. + /// + /// This is a requirement to stop the case where a player holds down one key (from before the slider) and taps the second key while maintaining full scoring (tracking) of sliders. + /// Visually, this special case can be seen below (time increasing from left to right): + /// + /// Z Z+X Z + /// o========o + /// + /// Without this logic, tracking would continue through the entire slider even though no key hold action is directly attributing to it. + /// + /// In all other cases, no special handling is required (either key being pressed is allowable as valid tracking). + /// + /// The reason for storing this as a time value (rather than a bool) is to correctly handle rewind scenarios. + /// + private double? timeToAcceptAnyKeyAfter; + + /// + /// The actions that were pressed in the previous frame. + /// + private readonly List lastPressedActions = new List(); + + private Vector2? screenSpaceMousePosition; + private readonly DrawableSlider slider; + + public SliderInputManager(DrawableSlider slider) + { + this.slider = slider; + } + + /// + /// This component handles all input of the slider, so it should receive input no matter the position. + /// + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; + + protected override bool OnMouseMove(MouseMoveEvent e) + { + screenSpaceMousePosition = e.ScreenSpaceMousePosition; + return base.OnMouseMove(e); + } + + protected override void Update() + { + base.Update(); + updateTracking(isMouseInFollowArea(Tracking)); + } + + public void PostProcessHeadJudgement(DrawableSliderHead head) + { + if (!head.Judged || !head.Result.IsHit) + return; + + if (!isMouseInFollowArea(true)) + return; + + // When the head is hit and the mouse is in the expanded follow area, force a hit on every nested hitobject + // from the start of the slider that is within follow-radius units from the head. + + bool forceMiss = false; + + foreach (var nested in slider.NestedHitObjects.OfType()) + { + // Skip nested objects that are already judged. + if (nested.Judged) + continue; + + // Stop the process when a nested object is reached that can't be hit before the current time. + if (nested.HitObject.StartTime > Time.Current) + break; + + float radius = getFollowRadius(true); + double objectProgress = Math.Clamp((nested.HitObject.StartTime - slider.HitObject.StartTime) / slider.HitObject.Duration, 0, 1); + Vector2 objectPosition = slider.HitObject.CurvePositionAt(objectProgress); + + // When the first nested object that is further than follow-radius units away from the start of the slider is reached, + // forcefully miss all other nested objects that would otherwise be valid to be hit by this process. + if (forceMiss || objectPosition.LengthSquared > radius * radius) + { + nested.MissForcefully(); + forceMiss = true; + } + else + nested.HitForcefully(); + } + + // Enable tracking, since the mouse is within the follow area (if it were expanded). + updateTracking(true); + } + + public void TryJudgeNestedObject(DrawableOsuHitObject nestedObject, double timeOffset) + { + switch (nestedObject) + { + case DrawableSliderRepeat: + case DrawableSliderTick: + if (timeOffset < 0) + return; + + break; + + case DrawableSliderTail: + if (timeOffset < SliderEventGenerator.TAIL_LENIENCY) + return; + + // Ensure the tail can only activate after all previous ticks/repeats already have. + // + // This covers the edge case where the lenience may allow the tail to activate before + // the last tick, changing ordering of score/combo awarding. + var lastTick = slider.NestedHitObjects.LastOrDefault(o => o.HitObject is SliderTick || o.HitObject is SliderRepeat); + if (lastTick?.Judged == false) + return; + + break; + + default: + return; + } + + if (!slider.HeadCircle.Judged) + { + if (slider.Tracking.Value) + { + // Attempt to preserve correct ordering of judgements as best we can by forcing an un-judged head to be missed when the user has clearly skipped it. + slider.HeadCircle.MissForcefully(); + } + else + { + // Don't judge this object as a miss before the head has been judged, to allow the head to be hit late. + return; + } + } + + if (slider.Tracking.Value) + nestedObject.HitForcefully(); + else + nestedObject.MissForcefully(); + } + + /// + /// Whether the mouse is currently in the follow area. + /// + /// Whether to test against the maximum area of the follow circle. + private bool isMouseInFollowArea(bool expanded) + { + if (screenSpaceMousePosition is not Vector2 pos) + return false; + + float radius = getFollowRadius(expanded); + + double followProgress = Math.Clamp((Time.Current - slider.HitObject.StartTime) / slider.HitObject.Duration, 0, 1); + Vector2 followCirclePosition = slider.HitObject.CurvePositionAt(followProgress); + Vector2 mousePositionInSlider = slider.ToLocalSpace(pos) - slider.OriginPosition; + + return (mousePositionInSlider - followCirclePosition).LengthSquared <= radius * radius; + } + + /// + /// Retrieves the radius of the follow area. + /// + /// Whether to return the maximum area of the follow circle. + private float getFollowRadius(bool expanded) + { + float radius = (float)slider.HitObject.Radius; + + if (expanded) + radius *= DrawableSliderBall.FOLLOW_AREA; + + return radius; + } + + /// + /// Updates the tracking state. + /// + /// Whether the current mouse position is valid to begin tracking. + private void updateTracking(bool isValidTrackingPosition) + { + // from the point at which the head circle is hit, this will be non-null. + // it may be null if the head circle was missed. + OsuAction? headCircleHitAction = getInitialHitAction(); + + if (headCircleHitAction == null) + timeToAcceptAnyKeyAfter = null; + + var actions = slider.OsuActionInputManager?.PressedActions; + + // if the head circle was hit with a specific key, tracking should only occur while that key is pressed. + if (headCircleHitAction != null && timeToAcceptAnyKeyAfter == null) + { + var otherKey = headCircleHitAction == OsuAction.RightButton ? OsuAction.LeftButton : OsuAction.RightButton; + + // we can start accepting any key once all other keys have been released in the previous frame. + if (!lastPressedActions.Contains(otherKey)) + timeToAcceptAnyKeyAfter = Time.Current; + } + + Tracking = + // even in an edge case where current time has exceeded the slider's time, we may not have finished judging. + // we don't want to potentially update from Tracking=true to Tracking=false at this point. + (!slider.AllJudged || Time.Current <= slider.HitObject.GetEndTime()) + // in valid position range + && isValidTrackingPosition + // valid action + && (actions?.Any(isValidTrackingAction) ?? false); + + lastPressedActions.Clear(); + if (actions != null) + lastPressedActions.AddRange(actions); + } + + private OsuAction? getInitialHitAction() => slider.HeadCircle?.HitAction; + + /// + /// Check whether a given user input is a valid tracking action. + /// + private bool isValidTrackingAction(OsuAction action) + { + OsuAction? hitAction = getInitialHitAction(); + + // if the head circle was hit, we may not yet be allowed to accept any key, so we must use the initial hit action. + if (hitAction.HasValue && (!timeToAcceptAnyKeyAfter.HasValue || Time.Current <= timeToAcceptAnyKeyAfter.Value)) + return action == hitAction; + + return action == OsuAction.LeftButton || action == OsuAction.RightButton; + } + } +} From 3b8a73bf2c5ac25224ae2fc88967b58f98cb07a7 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 15 Dec 2023 16:13:34 +0900 Subject: [PATCH 03/12] Refactor test --- .../TestSceneSliderLateHitJudgement.cs | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs index 4f32a6fe9f5f..04bdd0094df4 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs @@ -296,7 +296,7 @@ public void TestHitLateInRangeDoesNotHitOutOfRange() private void assertHeadJudgement(HitResult result) { AddAssert( - "check head result", + $"head = {result}", () => judgementResults.SingleOrDefault(r => r.HitObject is SliderHeadCircle)?.Type, () => Is.EqualTo(result)); } @@ -304,7 +304,7 @@ private void assertHeadJudgement(HitResult result) private void assertTickJudgement(int index, HitResult result) { AddAssert( - $"check tick({index}) result", + $"tick({index}) = {result}", () => judgementResults.Where(r => r.HitObject is SliderTick).ElementAtOrDefault(index)?.Type, () => Is.EqualTo(result)); } @@ -312,7 +312,7 @@ private void assertTickJudgement(int index, HitResult result) private void assertAllTickJudgements(HitResult result) { AddAssert( - "check all tick results", + $"all ticks = {result}", () => judgementResults.Where(r => r.HitObject is SliderTick).Select(t => t.Type), () => Has.All.EqualTo(result)); } @@ -320,7 +320,7 @@ private void assertAllTickJudgements(HitResult result) private void assertRepeatJudgement(HitResult result) { AddAssert( - "check repeat result", + $"repeat = {result}", () => judgementResults.SingleOrDefault(r => r.HitObject is SliderRepeat)?.Type, () => Is.EqualTo(result)); } @@ -328,7 +328,7 @@ private void assertRepeatJudgement(HitResult result) private void assertTailJudgement(HitResult result) { AddAssert( - "check tail result", + $"tail = {result}", () => judgementResults.SingleOrDefault(r => r.HitObject is SliderTailCircle)?.Type, () => Is.EqualTo(result)); } @@ -336,18 +336,11 @@ private void assertTailJudgement(HitResult result) private void assertSliderJudgement(HitResult result) { AddAssert( - "check slider result", + $"slider = {result}", () => judgementResults.SingleOrDefault(r => r.HitObject is Slider)?.Type, () => Is.EqualTo(result)); } - private Vector2 computePositionFromTime(double time) - { - Vector2 dist = slider_end_position - slider_start_position; - double t = (time - time_slider_start) / (time_slider_end - time_slider_start); - return slider_start_position + dist * (float)t; - } - private void performTest(List frames, Action? adjustSliderFunc = null, bool classic = false) { Slider slider = new Slider From 12210017e44341e5378edc2af86553addeae4eab Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 15 Dec 2023 17:05:14 +0900 Subject: [PATCH 04/12] Use the cursor position to test nested object validity --- .../TestSceneSliderLateHitJudgement.cs | 42 ++++++++++++++++--- .../Objects/Drawables/SliderInputManager.cs | 14 +++++-- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs index 04bdd0094df4..80e4b041785a 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs @@ -184,7 +184,7 @@ public void TestHitLateShortSliderHitsAll() /// /// If the head circle is hit late and the mouse is in range of the follow circle, - /// then all the repeats that the mouse has passed through should be hit. + /// then all the repeats that the follow circle has passed through should be hit. /// [Test] public void TestHitLateInRangeHitsRepeat() @@ -212,8 +212,8 @@ public void TestHitLateInRangeHitsRepeat() /// /// If the head circle is hit and the mouse is in range of the follow circle, - /// then only the ticks that were in range of the follow circle at the head should be hit. - /// If any hitobject was outside the follow range, ALL hitobjects after that point should be missed. + /// then only the ticks that are in range of the cursor position should be hit. + /// If any hitobject does not meet this criteria, ALL hitobjects after that one should be missed. /// [Test] public void TestHitLateInRangeDoesNotHitAfterAnyOutOfRange() @@ -257,7 +257,7 @@ public void TestHitLateInRangeDoesNotHitAfterAnyOutOfRange() assertTickJudgement(11, HitResult.LargeTickMiss); assertTickJudgement(12, HitResult.LargeTickMiss); - // And the tail should be hit because of its leniency. + // This particular test actually starts tracking the slider just before the end, so the tail should be hit because of its leniency. assertTailJudgement(HitResult.LargeTickHit); assertSliderJudgement(HitResult.IgnoreHit); @@ -265,10 +265,10 @@ public void TestHitLateInRangeDoesNotHitAfterAnyOutOfRange() /// /// If the head circle is hit and the mouse is in range of the follow circle, - /// then a tick outside the range of the follow circle from the head should not be hit. + /// then a tick not within the follow radius from the cursor position should not be hit. /// [Test] - public void TestHitLateInRangeDoesNotHitOutOfRange() + public void TestHitLateInRangeDoesNotHitOutOfRangeTick() { performTest(new List { @@ -293,6 +293,36 @@ public void TestHitLateInRangeDoesNotHitOutOfRange() assertSliderJudgement(HitResult.IgnoreHit); } + /// + /// If the head circle is hit and the mouse is in range of the follow circle, + /// then a tick not within the follow radius from the cursor position should not be hit. + /// + [Test] + public void TestHitLateWithEdgeHit() + { + performTest(new List + { + new OsuReplayFrame(time_slider_start + 150, slider_start_position - new Vector2(20), OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 150, slider_start_position - new Vector2(20), OsuAction.LeftButton), + }, s => + { + s.Path = new SliderPath(PathType.PERFECT_CURVE, new[] + { + Vector2.Zero, + new Vector2(50, 50), + new Vector2(20, 0), + }); + + s.TickDistanceMultiplier = 0.35f; + s.SliderVelocityMultiplier = 4; + }); + + assertHeadJudgement(HitResult.Meh); + assertTickJudgement(0, HitResult.LargeTickMiss); + assertTailJudgement(HitResult.IgnoreMiss); + assertSliderJudgement(HitResult.IgnoreHit); + } + private void assertHeadJudgement(HitResult result) { AddAssert( diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs index a5d7bdc7dbf0..9feeb6ef14e9 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using osu.Framework.Graphics; using osu.Framework.Input; @@ -75,8 +76,12 @@ public void PostProcessHeadJudgement(DrawableSliderHead head) if (!isMouseInFollowArea(true)) return; + Debug.Assert(screenSpaceMousePosition != null); + + Vector2 mousePositionInSlider = slider.ToLocalSpace(screenSpaceMousePosition.Value) - slider.OriginPosition; + // When the head is hit and the mouse is in the expanded follow area, force a hit on every nested hitobject - // from the start of the slider that is within follow-radius units from the head. + // from the start of the slider that is within the follow area. bool forceMiss = false; @@ -94,9 +99,10 @@ public void PostProcessHeadJudgement(DrawableSliderHead head) double objectProgress = Math.Clamp((nested.HitObject.StartTime - slider.HitObject.StartTime) / slider.HitObject.Duration, 0, 1); Vector2 objectPosition = slider.HitObject.CurvePositionAt(objectProgress); - // When the first nested object that is further than follow-radius units away from the start of the slider is reached, - // forcefully miss all other nested objects that would otherwise be valid to be hit by this process. - if (forceMiss || objectPosition.LengthSquared > radius * radius) + // When the first nested object that is further outside the follow area is reached, + // forcefully miss all other nested objects that would otherwise be valid to be hit. + // This covers a case of a slider overlapping itself that requires tracking to a tick on an outer edge. + if (forceMiss || (objectPosition - mousePositionInSlider).LengthSquared > radius * radius) { nested.MissForcefully(); forceMiss = true; From 9ae3be817fa77dd142b5292e3d361953319efe07 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 15 Dec 2023 17:40:36 +0900 Subject: [PATCH 05/12] Add some text to the test scene showing hits/misses --- .../TestSceneSliderLateHitJudgement.cs | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs index 80e4b041785a..2a3655eccdac 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs @@ -5,12 +5,17 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using osu.Framework.Graphics; using osu.Framework.Screens; +using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Graphics.Sprites; using osu.Game.Replays; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Replays; using osu.Game.Rulesets.Replays; @@ -410,7 +415,21 @@ private void performTest(List frames, Action? adjustSliderF { p.ScoreProcessor.NewJudgement += result => { - if (currentPlayer == p) judgementResults.Add(result); + if (currentPlayer == p) + judgementResults.Add(result); + + DrawableHitObject drawableObj = this.ChildrenOfType().Single(h => h.HitObject == result.HitObject); + + var text = new OsuSpriteText + { + Origin = Anchor.Centre, + Position = Content.ToLocalSpace(drawableObj.ToScreenSpace(drawableObj.OriginPosition)) - new Vector2(0, 20), + Text = result.IsHit ? "hit" : "miss" + }; + + Add(text); + + text.FadeOutFromOne(1000).Expire(); }; }; From acb7016156c7dd3411ed1d70154eafaab970e57e Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 15 Dec 2023 23:53:26 +0900 Subject: [PATCH 06/12] Remove unused using --- osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs index 2a3655eccdac..d12020db59c6 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs @@ -15,7 +15,6 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; -using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Replays; using osu.Game.Rulesets.Replays; From f77884b62f0a8c2b8b058c9e1180c21bd25cbdba Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Sun, 17 Dec 2023 19:57:48 +0900 Subject: [PATCH 07/12] Only hit passed-through ticks if none were missed --- .../TestSceneSliderLateHitJudgement.cs | 23 ++------------- .../Objects/Drawables/SliderInputManager.cs | 29 ++++++++++++++----- 2 files changed, 25 insertions(+), 27 deletions(-) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs index d12020db59c6..54140e4d4922 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs @@ -220,7 +220,7 @@ public void TestHitLateInRangeHitsRepeat() /// If any hitobject does not meet this criteria, ALL hitobjects after that one should be missed. /// [Test] - public void TestHitLateInRangeDoesNotHitAfterAnyOutOfRange() + public void TestHitLateDoesNotHitTicksIfAnyOutOfRange() { performTest(new List { @@ -241,25 +241,8 @@ public void TestHitLateInRangeDoesNotHitAfterAnyOutOfRange() assertHeadJudgement(HitResult.Meh); - // The first few ticks that are in the follow range of the head should be hit. - assertTickJudgement(0, HitResult.LargeTickHit); // This tick is hidden under the slider head :( - assertTickJudgement(1, HitResult.LargeTickHit); - assertTickJudgement(2, HitResult.LargeTickHit); - - // Every other tick should be missed - assertTickJudgement(3, HitResult.LargeTickMiss); - assertTickJudgement(4, HitResult.LargeTickMiss); - assertTickJudgement(5, HitResult.LargeTickMiss); - assertTickJudgement(6, HitResult.LargeTickMiss); - assertTickJudgement(7, HitResult.LargeTickMiss); - assertTickJudgement(8, HitResult.LargeTickMiss); - assertTickJudgement(9, HitResult.LargeTickMiss); - assertTickJudgement(10, HitResult.LargeTickMiss); - - // In particular, these three are in the follow range of the head, but should not be hit - // because the slider was at some point outside the follow range of the head. - assertTickJudgement(11, HitResult.LargeTickMiss); - assertTickJudgement(12, HitResult.LargeTickMiss); + // At least one tick was out of range, so they all should be missed. + assertAllTickJudgements(HitResult.LargeTickMiss); // This particular test actually starts tracking the slider just before the end, so the tail should be hit because of its leniency. assertTailJudgement(HitResult.LargeTickHit); diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs index 9feeb6ef14e9..e71f4fcd481e 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs @@ -80,10 +80,11 @@ public void PostProcessHeadJudgement(DrawableSliderHead head) Vector2 mousePositionInSlider = slider.ToLocalSpace(screenSpaceMousePosition.Value) - slider.OriginPosition; - // When the head is hit and the mouse is in the expanded follow area, force a hit on every nested hitobject - // from the start of the slider that is within the follow area. + // When the head is hit late: + // - If the cursor has at all times been within range of the expanded follow area, hit all nested objects that have been passed through. + // - If the cursor has at some point left the expanded follow area, miss those nested objects instead. - bool forceMiss = false; + bool allTicksInRange = true; foreach (var nested in slider.NestedHitObjects.OfType()) { @@ -102,13 +103,27 @@ public void PostProcessHeadJudgement(DrawableSliderHead head) // When the first nested object that is further outside the follow area is reached, // forcefully miss all other nested objects that would otherwise be valid to be hit. // This covers a case of a slider overlapping itself that requires tracking to a tick on an outer edge. - if (forceMiss || (objectPosition - mousePositionInSlider).LengthSquared > radius * radius) + if ((objectPosition - mousePositionInSlider).LengthSquared > radius * radius) { - nested.MissForcefully(); - forceMiss = true; + allTicksInRange = false; + break; } - else + } + + foreach (var nested in slider.NestedHitObjects.OfType()) + { + // Skip nested objects that are already judged. + if (nested.Judged) + continue; + + // Stop the process when a nested object is reached that can't be hit before the current time. + if (nested.HitObject.StartTime > Time.Current) + break; + + if (allTicksInRange) nested.HitForcefully(); + else + nested.MissForcefully(); } // Enable tracking, since the mouse is within the follow area (if it were expanded). From fbe48d7be84874d902d5559f723379bc5c9c395a Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Sun, 17 Dec 2023 20:11:15 +0900 Subject: [PATCH 08/12] Fix tail being missed too early --- osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs index e71f4fcd481e..996a47715375 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs @@ -175,7 +175,7 @@ public void TryJudgeNestedObject(DrawableOsuHitObject nestedObject, double timeO if (slider.Tracking.Value) nestedObject.HitForcefully(); - else + else if (timeOffset >= 0) nestedObject.MissForcefully(); } From 9b02bd712b19b34bd8d3813ce9a2c700063267c7 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Sun, 17 Dec 2023 20:12:02 +0900 Subject: [PATCH 09/12] Only track if in slider ball after any ticks missed --- .../TestSceneSliderLateHitJudgement.cs | 31 +++++++++++++++++++ .../Objects/Drawables/SliderInputManager.cs | 6 ++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs index 54140e4d4922..78633369c768 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs @@ -280,6 +280,37 @@ public void TestHitLateInRangeDoesNotHitOutOfRangeTick() assertSliderJudgement(HitResult.IgnoreHit); } + /// + /// Same as except the tracking is limited to the ball + /// because the tick was missed. + /// + [Test] + public void TestHitLateInRangeDoesNotHitOutOfRangeTickAndTrackingLimitedToBall() + { + performTest(new List + { + new OsuReplayFrame(time_slider_start + 150, slider_start_position, OsuAction.LeftButton), + new OsuReplayFrame(time_slider_end + 150, slider_start_position, OsuAction.LeftButton), + }, s => + { + s.Path = new SliderPath(PathType.PERFECT_CURVE, new[] + { + Vector2.Zero, + new Vector2(50, 50), + new Vector2(20, 0), + }); + + s.TickDistanceMultiplier = 0.25f; + s.SliderVelocityMultiplier = 3; + }); + + assertHeadJudgement(HitResult.Meh); + assertTickJudgement(0, HitResult.LargeTickMiss); + assertTickJudgement(1, HitResult.LargeTickMiss); + assertTailJudgement(HitResult.LargeTickHit); + assertSliderJudgement(HitResult.IgnoreHit); + } + /// /// If the head circle is hit and the mouse is in range of the follow circle, /// then a tick not within the follow radius from the cursor position should not be hit. diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs index 996a47715375..a497c0489419 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs @@ -126,8 +126,10 @@ public void PostProcessHeadJudgement(DrawableSliderHead head) nested.MissForcefully(); } - // Enable tracking, since the mouse is within the follow area (if it were expanded). - updateTracking(true); + // If all ticks were hit so far, enable tracking the full extent. + // If any ticks were missed, assume tracking would've broken at some point, and should only activate if the cursor is within the slider ball. + // For the second case, this may be the last chance we have to enable tracking before other objects get judged, otherwise the same would normally happen via Update(). + updateTracking(allTicksInRange || isMouseInFollowArea(false)); } public void TryJudgeNestedObject(DrawableOsuHitObject nestedObject, double timeOffset) From fddfa33e4982a87b2b27302c0dcd057526827412 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Sun, 17 Dec 2023 20:19:17 +0900 Subject: [PATCH 10/12] Fix 1-frame issues due to referencing external value --- osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs index a497c0489419..d698ba56c4e4 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs @@ -163,7 +163,7 @@ public void TryJudgeNestedObject(DrawableOsuHitObject nestedObject, double timeO if (!slider.HeadCircle.Judged) { - if (slider.Tracking.Value) + if (Tracking) { // Attempt to preserve correct ordering of judgements as best we can by forcing an un-judged head to be missed when the user has clearly skipped it. slider.HeadCircle.MissForcefully(); @@ -175,7 +175,7 @@ public void TryJudgeNestedObject(DrawableOsuHitObject nestedObject, double timeO } } - if (slider.Tracking.Value) + if (Tracking) nestedObject.HitForcefully(); else if (timeOffset >= 0) nestedObject.MissForcefully(); From 2b33aec12418e17f462730a4542f81e0d68cfe57 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Sun, 17 Dec 2023 21:26:48 +0900 Subject: [PATCH 11/12] Require slider head to be judged before ticks --- .../TestSceneSliderLateHitJudgement.cs | 78 ++++++++++++++++--- .../Objects/Drawables/SliderInputManager.cs | 13 +--- 2 files changed, 67 insertions(+), 24 deletions(-) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs index 78633369c768..e1797e877e73 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs @@ -341,6 +341,45 @@ public void TestHitLateWithEdgeHit() assertSliderJudgement(HitResult.IgnoreHit); } + /// + /// Late hit and release on each slider head of a slider stream. + /// + [Test] + public void TestLateHitSliderStream() + { + var beatmap = new Beatmap(); + + for (int i = 0; i < 20; i++) + { + beatmap.HitObjects.Add(new Slider + { + StartTime = time_slider_start + 75 * i, // 200BPM @ 1/4 + Position = new Vector2(256 - slider_path_length / 2, 192), + TickDistanceMultiplier = 3, + Path = new SliderPath(PathType.LINEAR, new[] + { + Vector2.Zero, + new Vector2(20, 0), + }), + }); + } + + var replay = new List(); + + for (int i = 0; i < 20; i++) + { + replay.Add(new OsuReplayFrame(time_slider_start + 75 * i + 75, slider_start_position, i % 2 == 0 ? OsuAction.LeftButton : OsuAction.RightButton)); + replay.Add(new OsuReplayFrame(time_slider_start + 75 * i + 140, slider_start_position)); + } + + performTest(replay, beatmap); + + assertHeadJudgement(HitResult.Meh); + assertTickJudgement(0, HitResult.LargeTickMiss); + assertTailJudgement(HitResult.IgnoreMiss); + assertSliderJudgement(HitResult.IgnoreHit); + } + private void assertHeadJudgement(HitResult result) { AddAssert( @@ -406,21 +445,36 @@ private void performTest(List frames, Action? adjustSliderF adjustSliderFunc?.Invoke(slider); - AddStep("load player", () => + var beatmap = new Beatmap { - Beatmap.Value = CreateWorkingBeatmap(new Beatmap + HitObjects = { slider }, + BeatmapInfo = { - HitObjects = { slider }, - BeatmapInfo = + Difficulty = new BeatmapDifficulty { - Difficulty = new BeatmapDifficulty - { - SliderMultiplier = 4, - SliderTickRate = 3 - }, - Ruleset = new OsuRuleset().RulesetInfo, - } - }); + SliderMultiplier = 4, + SliderTickRate = 3 + }, + Ruleset = new OsuRuleset().RulesetInfo, + } + }; + + performTest(frames, beatmap); + } + + private void performTest(List frames, Beatmap beatmap) + { + beatmap.BeatmapInfo.Ruleset = new OsuRuleset().RulesetInfo; + beatmap.BeatmapInfo.StackLeniency = 0; + beatmap.BeatmapInfo.Difficulty = new BeatmapDifficulty + { + SliderMultiplier = 4, + SliderTickRate = 3, + }; + + AddStep("load player", () => + { + Beatmap.Value = CreateWorkingBeatmap(beatmap); var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } }); diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs index d698ba56c4e4..8aa982783e87 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs @@ -162,18 +162,7 @@ public void TryJudgeNestedObject(DrawableOsuHitObject nestedObject, double timeO } if (!slider.HeadCircle.Judged) - { - if (Tracking) - { - // Attempt to preserve correct ordering of judgements as best we can by forcing an un-judged head to be missed when the user has clearly skipped it. - slider.HeadCircle.MissForcefully(); - } - else - { - // Don't judge this object as a miss before the head has been judged, to allow the head to be hit late. - return; - } - } + return; if (Tracking) nestedObject.HitForcefully(); From 04d542105f1518119f18f7d3b160802d7d468e67 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Sun, 17 Dec 2023 21:52:21 +0900 Subject: [PATCH 12/12] Fix assertions --- .../TestSceneSliderLateHitJudgement.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs index e1797e877e73..6ba9c723da46 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs @@ -374,10 +374,10 @@ public void TestLateHitSliderStream() performTest(replay, beatmap); - assertHeadJudgement(HitResult.Meh); - assertTickJudgement(0, HitResult.LargeTickMiss); - assertTailJudgement(HitResult.IgnoreMiss); - assertSliderJudgement(HitResult.IgnoreHit); + AddAssert( + $"all heads = {HitResult.Ok}", + () => judgementResults.Where(r => r.HitObject is SliderHeadCircle).Select(r => r.Type), + () => Has.All.EqualTo(HitResult.Ok)); } private void assertHeadJudgement(HitResult result)