diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs new file mode 100644 index 000000000000..6ba9c723da46 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs @@ -0,0 +1,528 @@ +// 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.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.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 follow circle 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 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 TestHitLateDoesNotHitTicksIfAnyOutOfRange() + { + 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); + + // 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); + + 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 TestHitLateInRangeDoesNotHitOutOfRangeTick() + { + 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); + } + + /// + /// 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. + /// + [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); + } + + /// + /// 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); + + 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) + { + AddAssert( + $"head = {result}", + () => judgementResults.SingleOrDefault(r => r.HitObject is SliderHeadCircle)?.Type, + () => Is.EqualTo(result)); + } + + private void assertTickJudgement(int index, HitResult result) + { + AddAssert( + $"tick({index}) = {result}", + () => judgementResults.Where(r => r.HitObject is SliderTick).ElementAtOrDefault(index)?.Type, + () => Is.EqualTo(result)); + } + + private void assertAllTickJudgements(HitResult result) + { + AddAssert( + $"all ticks = {result}", + () => judgementResults.Where(r => r.HitObject is SliderTick).Select(t => t.Type), + () => Has.All.EqualTo(result)); + } + + private void assertRepeatJudgement(HitResult result) + { + AddAssert( + $"repeat = {result}", + () => judgementResults.SingleOrDefault(r => r.HitObject is SliderRepeat)?.Type, + () => Is.EqualTo(result)); + } + + private void assertTailJudgement(HitResult result) + { + AddAssert( + $"tail = {result}", + () => judgementResults.SingleOrDefault(r => r.HitObject is SliderTailCircle)?.Type, + () => Is.EqualTo(result)); + } + + private void assertSliderJudgement(HitResult result) + { + AddAssert( + $"slider = {result}", + () => judgementResults.SingleOrDefault(r => r.HitObject is Slider)?.Type, + () => Is.EqualTo(result)); + } + + 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); + + var beatmap = new Beatmap + { + HitObjects = { slider }, + BeatmapInfo = + { + Difficulty = new BeatmapDifficulty + { + 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 } }); + + p.OnLoadComplete += _ => + { + p.ScoreProcessor.NewJudgement += 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(); + }; + }; + + 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/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 a053c99a5322..1f9a02804598 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -58,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; @@ -72,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 @@ -88,6 +91,7 @@ private void load() AddRangeInternal(new Drawable[] { + SliderInputManager, shakeContainer = new ShakeContainer { ShakeDuration = 30, @@ -232,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 @@ -245,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 a1724d6fdc44..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,13 +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 Drawable followCircleReceptor; private DrawableSlider drawableSlider; private Drawable ball; @@ -48,13 +40,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, @@ -63,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 @@ -86,100 +63,6 @@ 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); - } - } - - /// - /// 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(); - - 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; - } - - 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) && - // 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 ff690417a86c..76b9fdc3ceea 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs @@ -61,6 +61,12 @@ 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); + DrawableSlider.SliderInputManager.PostProcessHeadJudgement(this); + } + 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..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,21 +83,7 @@ protected override void OnApply() Position = HitObject.Position - DrawableSlider.Position; } - 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(); - - 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 e3ed12a648c6..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,36 +121,7 @@ protected override void UpdateHitStateTransforms(ArmedState state) } } - 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; - - // 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(); - - // 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 172dca356e60..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,21 +71,7 @@ protected override void OnApply() Position = HitObject.Position - DrawableSlider.HitObject.Position; } - 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(); - - 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() { @@ -107,7 +91,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: 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..8aa982783e87 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/SliderInputManager.cs @@ -0,0 +1,260 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +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; + + Debug.Assert(screenSpaceMousePosition != null); + + Vector2 mousePositionInSlider = slider.ToLocalSpace(screenSpaceMousePosition.Value) - slider.OriginPosition; + + // 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 allTicksInRange = true; + + 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 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 ((objectPosition - mousePositionInSlider).LengthSquared > radius * radius) + { + allTicksInRange = false; + break; + } + } + + 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(); + } + + // 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) + { + 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) + return; + + if (Tracking) + nestedObject.HitForcefully(); + else if (timeOffset >= 0) + 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; + } + } +}