diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs new file mode 100644 index 0000000000..4f32a6fe9f --- /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 a053c99a53..1dd6f108f5 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -28,6 +28,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables 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 a1724d6fdc..764dd43c30 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs @@ -27,7 +27,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public Func GetInitialHitAction; - private Drawable followCircleReceptor; private DrawableSlider drawableSlider; private Drawable ball; @@ -48,13 +47,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables 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 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables 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 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables /// 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 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables 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 ff690417a8..0a104c123b 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 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables 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 cdfd96514e..a7979bde27 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs @@ -85,17 +85,33 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables 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 e3ed12a648..1ffbaf11c5 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs @@ -125,6 +125,14 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables } } + 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 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables 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 172dca356e..b2ac8ecbda 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs @@ -73,17 +73,33 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables 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 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables case ArmedState.Miss: this.FadeOut(ANIM_DURATION); - this.FadeColour(Color4.Red, ANIM_DURATION / 2); + this.TransformBindableTo(AccentColour, Color4.Red, 0); break; case ArmedState.Hit: