diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs index 8c311cce91..a2ef72fe57 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs @@ -506,7 +506,6 @@ namespace osu.Game.Rulesets.Osu.Tests } [Test] - [Ignore("Currently broken, first attempt at fixing broke even harder. See https://github.com/ppy/osu/issues/24743.")] public void TestInputDoesNotFallThroughOverlappingSliders() { const double time_first_slider = 1000; @@ -550,12 +549,103 @@ namespace osu.Game.Rulesets.Osu.Tests 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.blockInputToObjectsUnderSliderHead()`), + // 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); } [Test] - public void TestOverlappingObjectsDontBlockEachOtherWhenFullyFadedOut() + public void TestOverlappingSlidersDontBlockEachOtherWhenFullyJudged() + { + const double time_first_slider = 1000; + const double time_second_slider = 1600; + 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 }, + // this frame doesn't do anything on lazer, but is REQUIRED for correct playback on stable, + // because stable during replay playback only updates game state _when it encounters a replay frame_ + new OsuReplayFrame { Time = 1250, Position = midpoint }, + new OsuReplayFrame { Time = time_second_slider + 50, Position = midpoint, Actions = { OsuAction.LeftButton } }, + new OsuReplayFrame { Time = time_second_slider + 75, Position = midpoint }, + }); + + addJudgementAssert(hitObjects[0], HitResult.Ok); + addJudgementOffsetAssert("first slider head", () => ((Slider)hitObjects[0]).HeadCircle, 0); + addJudgementAssert(hitObjects[1], HitResult.Ok); + addJudgementOffsetAssert("second slider head", () => ((Slider)hitObjects[1]).HeadCircle, 50); + addClickActionAssert(0, ClickAction.Hit); + addClickActionAssert(1, ClickAction.Hit); + } + + [Test] + public void TestOverlappingHitCirclesDontBlockEachOtherWhenBothVisible() + { + const double time_first_circle = 1000; + const double time_second_circle = 1200; + Vector2 positionFirstCircle = new Vector2(100); + Vector2 positionSecondCircle = new Vector2(120); + var midpoint = (positionFirstCircle + positionSecondCircle) / 2; + + var hitObjects = new List + { + new HitCircle + { + StartTime = time_first_circle, + Position = positionFirstCircle, + }, + new HitCircle + { + StartTime = time_second_circle, + Position = positionSecondCircle, + }, + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_first_circle, Position = midpoint, Actions = { OsuAction.LeftButton } }, + new OsuReplayFrame { Time = time_first_circle + 25, Position = midpoint }, + new OsuReplayFrame { Time = time_first_circle + 50, Position = midpoint, Actions = { OsuAction.RightButton } }, + }); + + addJudgementAssert(hitObjects[0], HitResult.Great); + addJudgementOffsetAssert(hitObjects[0], 0); + + addJudgementAssert(hitObjects[1], HitResult.Meh); + addJudgementOffsetAssert(hitObjects[1], -150); + } + + [Test] + public void TestOverlappingHitCirclesDontBlockEachOtherWhenFullyFadedOut() { const double time_first_circle = 1000; const double time_second_circle = 1200; @@ -586,8 +676,10 @@ namespace osu.Game.Rulesets.Osu.Tests { new OsuReplayFrame { Time = time_first_circle, Position = positionFirstCircle, Actions = { OsuAction.LeftButton } }, new OsuReplayFrame { Time = time_first_circle + 50, Position = positionFirstCircle }, + new OsuReplayFrame { Time = time_second_circle - 50, Position = positionSecondCircle }, new OsuReplayFrame { Time = time_second_circle, Position = positionSecondCircle, Actions = { OsuAction.LeftButton } }, new OsuReplayFrame { Time = time_second_circle + 50, Position = positionSecondCircle }, + new OsuReplayFrame { Time = time_third_circle - 50, Position = positionFirstCircle }, new OsuReplayFrame { Time = time_third_circle, Position = positionFirstCircle, Actions = { OsuAction.LeftButton } }, new OsuReplayFrame { Time = time_third_circle + 50, Position = positionFirstCircle }, }); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs b/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs index 5dbf23f7ea..0148ec1987 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) + blockInputToObjectsUnderSliderHead(head); + break; case DrawableSliderTail tail: @@ -83,10 +87,27 @@ namespace osu.Game.Rulesets.Osu.Mods case DrawableHitCircle circle: if (FadeHitCircleEarly.Value && !usingHiddenFading) applyEarlyFading(circle); + break; } } + /// + /// On stable, slider heads that have already been hit block input from reaching objects that may be underneath them + /// until the sliders they're part of have been fully judged. + /// 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 blockInputToObjectsUnderSliderHead(DrawableSliderHead slider) + { + var oldHitAction = slider.HitArea.Hit; + slider.HitArea.Hit = () => + { + oldHitAction?.Invoke(); + return !slider.DrawableSlider.AllJudged; + }; + } + 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; } diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs index 640e895b6c..4f28baa849 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs @@ -110,5 +110,31 @@ namespace osu.Game.Tests.Visual.Online } }, new OsuRuleset().RulesetInfo)); } + + [Test] + public void TestPreviousUsernames() + { + AddStep("Show user w/ previous usernames", () => header.User.Value = new UserProfileData(new APIUser + { + Id = 727, + Username = "SomeoneIndecisive", + CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c1.jpg", + Groups = new[] + { + new APIUserGroup { Colour = "#EB47D0", ShortName = "DEV", Name = "Developers" }, + }, + Statistics = new UserStatistics + { + IsRanked = false, + // web will sometimes return non-empty rank history even for unranked users. + RankHistory = new APIRankHistory + { + Mode = @"osu", + Data = Enumerable.Range(2345, 85).ToArray() + }, + }, + PreviousUsernames = new[] { "tsrk.", "quoicoubeh", "apagnan", "epita" } + }, new OsuRuleset().RulesetInfo)); + } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfilePreviousUsernames.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfilePreviousUsernamesDisplay.cs similarity index 76% rename from osu.Game.Tests/Visual/Online/TestSceneUserProfilePreviousUsernames.cs rename to osu.Game.Tests/Visual/Online/TestSceneUserProfilePreviousUsernamesDisplay.cs index 921738d331..c1140f60af 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfilePreviousUsernames.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfilePreviousUsernamesDisplay.cs @@ -5,20 +5,29 @@ using System; using NUnit.Framework; using osu.Framework.Graphics; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; using osu.Game.Overlays.Profile.Header.Components; namespace osu.Game.Tests.Visual.Online { [TestFixture] - public partial class TestSceneUserProfilePreviousUsernames : OsuTestScene + public partial class TestSceneUserProfilePreviousUsernamesDisplay : OsuTestScene { - private PreviousUsernames container = null!; + private PreviousUsernamesDisplay container = null!; + private OverlayColourProvider colourProvider = null!; [SetUp] public void SetUp() => Schedule(() => { - Child = container = new PreviousUsernames + colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); + Child = new DependencyProvidingContainer { + Child = container = new PreviousUsernamesDisplay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + CachedDependencies = new (Type, object)[] { (typeof(OverlayColourProvider), colourProvider) }, Anchor = Anchor.Centre, Origin = Anchor.Centre, }; diff --git a/osu.Game/Overlays/Profile/Header/Components/PreviousUsernames.cs b/osu.Game/Overlays/Profile/Header/Components/PreviousUsernamesDisplay.cs similarity index 85% rename from osu.Game/Overlays/Profile/Header/Components/PreviousUsernames.cs rename to osu.Game/Overlays/Profile/Header/Components/PreviousUsernamesDisplay.cs index b722fe92e0..dce5c84d12 100644 --- a/osu.Game/Overlays/Profile/Header/Components/PreviousUsernames.cs +++ b/osu.Game/Overlays/Profile/Header/Components/PreviousUsernamesDisplay.cs @@ -18,12 +18,13 @@ using osuTK; namespace osu.Game.Overlays.Profile.Header.Components { - public partial class PreviousUsernames : CompositeDrawable + public partial class PreviousUsernamesDisplay : CompositeDrawable { private const int duration = 200; private const int margin = 10; - private const int width = 310; + private const int width = 300; private const int move_offset = 15; + private const int base_y_offset = -3; // eye balled to make it look good public readonly Bindable User = new Bindable(); @@ -31,14 +32,15 @@ namespace osu.Game.Overlays.Profile.Header.Components private readonly Box background; private readonly SpriteText header; - public PreviousUsernames() + public PreviousUsernamesDisplay() { HoverIconContainer hoverIcon; AutoSizeAxes = Axes.Y; Width = width; Masking = true; - CornerRadius = 5; + CornerRadius = 6; + Y = base_y_offset; AddRangeInternal(new Drawable[] { @@ -84,6 +86,9 @@ namespace osu.Game.Overlays.Profile.Header.Components RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Full, + // Prevents the tooltip of having a sudden size reduction and flickering when the text is being faded out. + // Also prevents a potential OnHover/HoverLost feedback loop. + AlwaysPresent = true, Margin = new MarginPadding { Bottom = margin, Top = margin / 2f } } } @@ -96,9 +101,9 @@ namespace osu.Game.Overlays.Profile.Header.Components } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colours) { - background.Colour = colours.GreySeaFoamDarker; + background.Colour = colours.Background6; } protected override void LoadComplete() @@ -134,7 +139,7 @@ namespace osu.Game.Overlays.Profile.Header.Components text.FadeIn(duration, Easing.OutQuint); header.FadeIn(duration, Easing.OutQuint); background.FadeIn(duration, Easing.OutQuint); - this.MoveToY(-move_offset, duration, Easing.OutQuint); + this.MoveToY(base_y_offset - move_offset, duration, Easing.OutQuint); } private void hideContent() @@ -142,7 +147,7 @@ namespace osu.Game.Overlays.Profile.Header.Components text.FadeOut(duration, Easing.OutQuint); header.FadeOut(duration, Easing.OutQuint); background.FadeOut(duration, Easing.OutQuint); - this.MoveToY(0, duration, Easing.OutQuint); + this.MoveToY(base_y_offset, duration, Easing.OutQuint); } private partial class HoverIconContainer : Container @@ -156,7 +161,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { Margin = new MarginPadding { Top = 6, Left = margin, Right = margin * 2 }, Size = new Vector2(15), - Icon = FontAwesome.Solid.IdCard, + Icon = FontAwesome.Solid.AddressCard, }; } diff --git a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs index dc47ce6e30..36bd8a5af5 100644 --- a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs @@ -46,6 +46,7 @@ namespace osu.Game.Overlays.Profile.Header private OsuSpriteText userCountryText = null!; private GroupBadgeFlow groupBadgeFlow = null!; private ToggleCoverButton coverToggle = null!; + private PreviousUsernamesDisplay previousUsernamesDisplay = null!; private Bindable coverExpanded = null!; @@ -143,6 +144,11 @@ namespace osu.Game.Overlays.Profile.Header Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, }, + new Container + { + // Intentionally use a zero-size container, else the fill flow will adjust to (and cancel) the upwards animation. + Child = previousUsernamesDisplay = new PreviousUsernamesDisplay(), + } } }, titleText = new OsuSpriteText @@ -216,6 +222,7 @@ namespace osu.Game.Overlays.Profile.Header titleText.Text = user?.Title ?? string.Empty; titleText.Colour = Color4Extensions.FromHex(user?.Colour ?? "fff"); groupBadgeFlow.User.Value = user; + previousUsernamesDisplay.User.Value = user; } private void updateCoverState()