diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleLateFade.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleLateFade.cs index d74a31ada4..483155e646 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleLateFade.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleLateFade.cs @@ -142,6 +142,7 @@ namespace osu.Game.Rulesets.Osu.Tests drawableHitCircle.Scale = new Vector2(2f); + LoadComponent(drawableHitCircle); foreach (var mod in SelectedMods.Value.OfType()) mod.ApplyToDrawableHitObject(drawableHitCircle); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs index 2cfbe6611f..615044b642 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs @@ -505,6 +505,56 @@ namespace osu.Game.Rulesets.Osu.Tests addClickActionAssert(0, ClickAction.Shake); } + [Test] + public void TestInputDoesNotFallThroughOverlappingSliders() + { + const double time_first_slider = 1000; + const double time_second_slider = 1250; + Vector2 positionFirstSlider = new Vector2(100, 50); + Vector2 positionSecondSlider = new Vector2(100, 80); + var midpoint = (positionFirstSlider + positionSecondSlider) / 2; + + var hitObjects = new List + { + new Slider + { + StartTime = time_first_slider, + Position = positionFirstSlider, + Path = new SliderPath(PathType.Linear, new[] + { + Vector2.Zero, + new Vector2(25, 0), + }) + }, + new Slider + { + StartTime = time_second_slider, + Position = positionSecondSlider, + Path = new SliderPath(PathType.Linear, new[] + { + Vector2.Zero, + new Vector2(25, 0), + }) + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_first_slider, Position = midpoint, Actions = { OsuAction.RightButton } }, + new OsuReplayFrame { Time = time_first_slider + 25, Position = midpoint, Actions = { OsuAction.LeftButton } }, + new OsuReplayFrame { Time = time_first_slider + 50, Position = midpoint }, + }); + + addJudgementAssert(hitObjects[0], HitResult.Ok); + addJudgementOffsetAssert("first slider head", () => ((Slider)hitObjects[0]).HeadCircle, 0); + addJudgementAssert(hitObjects[1], HitResult.Miss); + // the slider head of the first slider prevents the second slider's head from being hit, so the judgement offset should be very late. + // this is not strictly done by the hit policy implementation itself (see `OsuModClassic.blockInputToUnderlyingObjects()`), + // but we're testing this here anyways to just keep everything related to input handling and note lock in one place. + addJudgementOffsetAssert("second slider head", () => ((Slider)hitObjects[1]).HeadCircle, referenceHitWindows.WindowFor(HitResult.Meh)); + addClickActionAssert(0, ClickAction.Hit); + } + private void addJudgementAssert(OsuHitObject hitObject, HitResult result) { AddAssert($"({hitObject.GetType().ReadableName()} @ {hitObject.StartTime}) judgement is {result}", @@ -523,6 +573,12 @@ namespace osu.Game.Rulesets.Osu.Tests () => judgementResults.Single(r => r.HitObject == hitObject).TimeOffset, () => Is.EqualTo(offset).Within(50)); } + private void addJudgementOffsetAssert(string name, Func hitObject, double offset) + { + AddAssert($"{name} @ judged at {offset}", + () => judgementResults.Single(r => r.HitObject == hitObject()).TimeOffset, () => Is.EqualTo(offset).Within(50)); + } + private void addClickActionAssert(int inputIndex, ClickAction action) => AddAssert($"input #{inputIndex} resulted in {action}", () => testPolicy.ClickActions[inputIndex], () => Is.EqualTo(action)); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs index f4257a9ee7..909eb821cf 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs @@ -336,6 +336,52 @@ namespace osu.Game.Rulesets.Osu.Tests addJudgementAssert(hitObjects[1], HitResult.IgnoreHit); } + [Test] + public void TestInputFallsThroughJudgedSliders() + { + const double time_first_slider = 1000; + const double time_second_slider = 1250; + Vector2 positionFirstSlider = new Vector2(100, 50); + Vector2 positionSecondSlider = new Vector2(100, 80); + var midpoint = (positionFirstSlider + positionSecondSlider) / 2; + + var hitObjects = new List + { + new TestSlider + { + StartTime = time_first_slider, + Position = positionFirstSlider, + Path = new SliderPath(PathType.Linear, new[] + { + Vector2.Zero, + new Vector2(25, 0), + }) + }, + new TestSlider + { + StartTime = time_second_slider, + Position = positionSecondSlider, + Path = new SliderPath(PathType.Linear, new[] + { + Vector2.Zero, + new Vector2(25, 0), + }) + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_first_slider, Position = midpoint, Actions = { OsuAction.RightButton } }, + new OsuReplayFrame { Time = time_first_slider + 25, Position = midpoint, Actions = { OsuAction.LeftButton } }, + new OsuReplayFrame { Time = time_first_slider + 50, Position = midpoint }, + }); + + addJudgementAssert("first slider head", () => ((Slider)hitObjects[0]).HeadCircle, HitResult.Great); + addJudgementOffsetAssert("first slider head", () => ((Slider)hitObjects[0]).HeadCircle, 0); + addJudgementAssert("second slider head", () => ((Slider)hitObjects[1]).HeadCircle, HitResult.Great); + addJudgementOffsetAssert("second slider head", () => ((Slider)hitObjects[1]).HeadCircle, -200); + } + private void addJudgementAssert(OsuHitObject hitObject, HitResult result) { AddAssert($"({hitObject.GetType().ReadableName()} @ {hitObject.StartTime}) judgement is {result}", @@ -354,6 +400,12 @@ namespace osu.Game.Rulesets.Osu.Tests () => Precision.AlmostEquals(judgementResults.Single(r => r.HitObject == hitObject).TimeOffset, offset, 100)); } + private void addJudgementOffsetAssert(string name, Func hitObject, double offset) + { + AddAssert($"{name} @ judged at {offset}", + () => judgementResults.Single(r => r.HitObject == hitObject()).TimeOffset, () => Is.EqualTo(offset).Within(50)); + } + private ScoreAccessibleReplayPlayer currentPlayer; private List judgementResults; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs b/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs index 5dbf23f7ea..1d95f833b0 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs @@ -74,6 +74,10 @@ namespace osu.Game.Rulesets.Osu.Mods head.TrackFollowCircle = !NoSliderHeadMovement.Value; if (FadeHitCircleEarly.Value && !usingHiddenFading) applyEarlyFading(head); + + if (ClassicNoteLock.Value) + blockInputToUnderlyingObjects(head); + break; case DrawableSliderTail tail: @@ -83,10 +87,29 @@ namespace osu.Game.Rulesets.Osu.Mods case DrawableHitCircle circle: if (FadeHitCircleEarly.Value && !usingHiddenFading) applyEarlyFading(circle); + + if (ClassicNoteLock.Value) + blockInputToUnderlyingObjects(circle); + break; } } + /// + /// On stable, hitcircles that have already been hit block input from reaching objects that may be underneath them. + /// The purpose of this method is to restore that behaviour. + /// In order to avoid introducing yet another confusing config option, this behaviour is roped into the general notion of "note lock". + /// + private static void blockInputToUnderlyingObjects(DrawableHitCircle circle) + { + var oldHitAction = circle.HitArea.Hit; + circle.HitArea.Hit = () => + { + oldHitAction?.Invoke(); + return true; + }; + } + private void applyEarlyFading(DrawableHitCircle circle) { circle.ApplyCustomUpdateState += (dho, state) => diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs index 932f6d3fff..6beed0294d 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs @@ -261,7 +261,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables case OsuAction.RightButton: if (IsHovered && (Hit?.Invoke() ?? false)) { - HitAction = e.Action; + HitAction ??= e.Action; return true; }