diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs index f8caa121a9..f45e6326d1 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs @@ -50,11 +50,11 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("add many scores in one go", () => { for (int i = 0; i < 32; i++) - createRandomScore(new APIUser { Username = $"Player {i + 1}" }); + leaderboardProvider.CreateRandomScore(new APIUser { Username = $"Player {i + 1}" }); // Add player at end to force an animation down the whole list. playerScore.Value = 0; - createLeaderboardScore(playerScore, new APIUser { Username = "You", Id = 3 }, true); + leaderboardProvider.CreateLeaderboardScore(playerScore, new APIUser { Username = "You", Id = 3 }, true); }); // Gameplay leaderboard has custom scroll logic, which when coupled with LayoutDuration @@ -84,7 +84,7 @@ namespace osu.Game.Tests.Visual.Gameplay addLocalPlayer(); int playerNumber = 1; - AddRepeatStep("add player with random score", () => createRandomScore(new APIUser { Username = $"Player {playerNumber++}" }), 10); + AddRepeatStep("add player with random score", () => leaderboardProvider.CreateRandomScore(new APIUser { Username = $"Player {playerNumber++}" }), 10); } [Test] @@ -93,10 +93,10 @@ namespace osu.Game.Tests.Visual.Gameplay createLeaderboard(); addLocalPlayer(); - AddStep("add peppy", () => createRandomScore(new APIUser { Username = "peppy", Id = 2 })); - AddStep("add smoogipoo", () => createRandomScore(new APIUser { Username = "smoogipoo", Id = 1040328 })); - AddStep("add flyte", () => createRandomScore(new APIUser { Username = "flyte", Id = 3103765 })); - AddStep("add frenzibyte", () => createRandomScore(new APIUser { Username = "frenzibyte", Id = 14210502 })); + AddStep("add peppy", () => leaderboardProvider.CreateRandomScore(new APIUser { Username = "peppy", Id = 2 })); + AddStep("add smoogipoo", () => leaderboardProvider.CreateRandomScore(new APIUser { Username = "smoogipoo", Id = 1040328 })); + AddStep("add flyte", () => leaderboardProvider.CreateRandomScore(new APIUser { Username = "flyte", Id = 3103765 })); + AddStep("add frenzibyte", () => leaderboardProvider.CreateRandomScore(new APIUser { Username = "frenzibyte", Id = 14210502 })); } [Test] @@ -123,12 +123,12 @@ namespace osu.Game.Tests.Visual.Gameplay int playerNumber = 1; - AddRepeatStep("add 3 other players", () => createRandomScore(new APIUser { Username = $"Player {playerNumber++}" }), 3); + AddRepeatStep("add 3 other players", () => leaderboardProvider.CreateRandomScore(new APIUser { Username = $"Player {playerNumber++}" }), 3); AddUntilStep("no pink color scores", () => leaderboard.ChildrenOfType().Select(b => ((Colour4)b.Colour).ToHex()), () => Does.Not.Contain("#FF549A")); - AddRepeatStep("add 3 friend score", () => createRandomScore(friend), 3); + AddRepeatStep("add 3 friend score", () => leaderboardProvider.CreateRandomScore(friend), 3); AddUntilStep("at least one friend score is pink", () => leaderboard.GetAllScoresForUsername("my friend") .SelectMany(score => score.ChildrenOfType()) @@ -141,7 +141,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("add local player", () => { playerScore.Value = 1222333; - createLeaderboardScore(playerScore, new APIUser { Username = "You", Id = 3 }, true); + leaderboardProvider.CreateLeaderboardScore(playerScore, new APIUser { Username = "You", Id = 3 }, true); }); } @@ -159,14 +159,6 @@ namespace osu.Game.Tests.Visual.Gameplay }); } - private void createRandomScore(APIUser user) => createLeaderboardScore(new BindableLong(RNG.Next(0, 5_000_000)), user); - - private void createLeaderboardScore(BindableLong score, APIUser user, bool isTracked = false) - { - var leaderboardScore = new GameplayLeaderboardScore(user, isTracked, score); - leaderboardProvider.Scores.Add(leaderboardScore); - } - private partial class TestDrawableGameplayLeaderboard : DrawableGameplayLeaderboard { public float Spacing => Flow.Spacing.Y; @@ -175,10 +167,20 @@ namespace osu.Game.Tests.Visual.Gameplay => Flow.Where(i => i.User?.Username == username); } - private class TestGameplayLeaderboardProvider : IGameplayLeaderboardProvider + public class TestGameplayLeaderboardProvider : IGameplayLeaderboardProvider { - IBindableList IGameplayLeaderboardProvider.Scores => Scores; public BindableList Scores { get; } = new BindableList(); + + public GameplayLeaderboardScore CreateRandomScore(APIUser user) => CreateLeaderboardScore(new BindableLong(RNG.Next(0, 5_000_000)), user); + + public GameplayLeaderboardScore CreateLeaderboardScore(BindableLong totalScore, APIUser user, bool isTracked = false) + { + var score = new GameplayLeaderboardScore(user, isTracked, totalScore); + Scores.Add(score); + return score; + } + + IBindableList IGameplayLeaderboardProvider.Scores => Scores; } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPositionDisplay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPositionDisplay.cs new file mode 100644 index 0000000000..42f34539de --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPositionDisplay.cs @@ -0,0 +1,90 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Configuration; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.OnlinePlay.Multiplayer; +using osu.Game.Screens.Play; +using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Tests.Gameplay; +using osu.Game.Tests.Visual.Gameplay; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public partial class TestSceneMultiplayerPositionDisplay : OsuTestScene + { + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + private GameplayLeaderboardScore score = null!; + + private readonly Bindable position = new Bindable(8); + + private TestSceneGameplayLeaderboard.TestGameplayLeaderboardProvider leaderboardProvider = null!; + private MultiplayerPositionDisplay display = null!; + private GameplayState gameplayState = null!; + + private const int player_count = 32; + + [Test] + public void TestAppearance() + { + AddStep("create content", () => + { + Children = new Drawable[] + { + new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = + [ + (typeof(IGameplayLeaderboardProvider), leaderboardProvider = new TestSceneGameplayLeaderboard.TestGameplayLeaderboardProvider()), + (typeof(GameplayState), gameplayState = TestGameplayState.Create(new OsuRuleset())) + ], + Child = display = new MultiplayerPositionDisplay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + } + }; + + score = leaderboardProvider.CreateLeaderboardScore(new BindableLong(), API.LocalUser.Value, true); + score.Position.BindTo(position); + + for (int i = 0; i < player_count - 1; i++) + { + var r = leaderboardProvider.CreateRandomScore(new APIUser()); + r.Position.Value = i; + } + }); + + AddSliderStep("set score position", 1, player_count, position.Value!.Value, r => position.Value = r); + AddStep("unset position", () => position.Value = null); + + AddStep("toggle leaderboardProvider on", () => config.SetValue(OsuSetting.GameplayLeaderboard, true)); + AddUntilStep("display visible", () => display.Alpha, () => Is.EqualTo(1)); + + AddStep("toggle leaderboardProvider off", () => config.SetValue(OsuSetting.GameplayLeaderboard, false)); + AddUntilStep("display hidden", () => display.Alpha, () => Is.EqualTo(0)); + + AddStep("enter break", () => ((Bindable)gameplayState.PlayingState).Value = LocalUserPlayingState.Break); + AddUntilStep("display visible", () => display.Alpha, () => Is.EqualTo(1)); + + AddStep("exit break", () => ((Bindable)gameplayState.PlayingState).Value = LocalUserPlayingState.Playing); + AddUntilStep("display hidden", () => display.Alpha, () => Is.EqualTo(0)); + + AddStep("toggle leaderboardProvider on", () => config.SetValue(OsuSetting.GameplayLeaderboard, true)); + AddUntilStep("display visible", () => display.Alpha, () => Is.EqualTo(1)); + + AddStep("change local user", () => ((DummyAPIAccess)API).LocalUser.Value = new GuestUser()); + AddUntilStep("display hidden", () => display.Alpha, () => Is.EqualTo(0)); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs index 386276720e..0e114b752e 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs @@ -88,6 +88,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer } } }, HUDOverlay.TopLeftElements.Add); + LoadComponentAsync(new MultiplayerPositionDisplay + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + }, d => HUDOverlay.BottomRightElements.Insert(-1, d)); LoadComponentAsync(leaderboardProvider, loaded => { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPositionDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPositionDisplay.cs new file mode 100644 index 0000000000..4be6e3b8c4 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPositionDisplay.cs @@ -0,0 +1,193 @@ +// 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.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Screens.Play; +using osu.Game.Screens.Select.Leaderboards; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer +{ + public partial class MultiplayerPositionDisplay : VisibilityContainer + { + private readonly IBindable user = new Bindable(); + private readonly IBindableList scores = new BindableList(); + private readonly BindableBool showLeaderboard = new BindableBool(); + private readonly IBindable localUserPlayingState = new Bindable(); + + private readonly Bindable position = new Bindable(); + + private RollingCounter positionText = null!; + + private Drawable localPlayerMarker = null!; + + private const float marker_size = 5; + private const float width = 90; + + private const float min_alpha = 0.2f; + private const float max_alpha = 0.4f; + + private GameplayLeaderboardScore? userScore; + + protected override bool StartHidden => true; + + [BackgroundDependencyLoader] + private void load(IGameplayLeaderboardProvider leaderboardProvider, IAPIProvider api, OsuConfigManager configManager, GameplayState gameplayState, OsuColour colours) + { + scores.BindTo(leaderboardProvider.Scores); + user.BindTo(api.LocalUser); + configManager.BindWith(OsuSetting.GameplayLeaderboard, showLeaderboard); + localUserPlayingState.BindTo(gameplayState.PlayingState); + + AutoSizeAxes = Axes.Y; + Width = width; + + InternalChildren = new Drawable[] + { + positionText = new PositionCounter + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Alpha = 0, + Padding = new MarginPadding { Right = -5 }, + }, + new Container + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + RelativeSizeAxes = Axes.X, + Height = marker_size - 2, + Children = new[] + { + new Circle + { + Colour = ColourInfo.GradientHorizontal( + Color4.White.Opacity(max_alpha), + Color4.White.Opacity(min_alpha) + ), + RelativeSizeAxes = Axes.Both, + Masking = true, + }, + localPlayerMarker = new Circle + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.Centre, + Colour = colours.Blue1, + Size = new Vector2(marker_size), + Blending = BlendingParameters.Additive, + Alpha = 0.4f, + }, + } + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + user.BindValueChanged(_ => updateScoreBindings()); + scores.BindCollectionChanged((_, __) => updateScoreBindings(), true); + + showLeaderboard.BindValueChanged(_ => updateVisibility()); + localUserPlayingState.BindValueChanged(_ => updateVisibility(), true); + + State.BindValueChanged(_ => updatePosition()); + position.BindValueChanged(_ => updatePosition(), true); + } + + protected override void PopIn() + { + this.FadeIn(500, Easing.OutQuint); + localPlayerMarker.ScaleTo(Vector2.One, 500, Easing.OutQuint); + } + + protected override void PopOut() + { + this.FadeOut(500, Easing.OutQuint); + localPlayerMarker.ScaleTo(new Vector2(0.8f), 500, Easing.Out); + } + + private void updateVisibility() + { + bool shouldDisplay = userScore != null && (showLeaderboard.Value || localUserPlayingState.Value == LocalUserPlayingState.Break); + + State.Value = shouldDisplay ? Visibility.Visible : Visibility.Hidden; + } + + private void updateScoreBindings() + { + position.UnbindBindings(); + + userScore = scores.SingleOrDefault(s => s.User.Equals(user.Value)); + if (userScore != null) + position.BindTo(userScore.Position); + else + position.Value = null; + + updateVisibility(); + updatePosition(); + } + + private void updatePosition() + { + // only update when visible to delay animations. + if (State.Value != Visibility.Visible) return; + + if (position.Value == null) + { + positionText.Alpha = min_alpha; + positionText.Current.Value = -1; + localPlayerMarker.FadeOut(); + return; + } + + float relativePosition = (float)(position.Value.Value - 1) / scores.Count; + + positionText.Current.Value = position.Value.Value; + positionText.FadeTo(min_alpha + (max_alpha - min_alpha) * (1 - relativePosition), 1000, Easing.OutPow10); + + localPlayerMarker.FadeIn(); + localPlayerMarker.MoveToX(marker_size / 2 + Math.Min(relativePosition * (width - marker_size / 2), width - marker_size / 2), 1000, Easing.OutPow10); + } + + private partial class PositionCounter : RollingCounter + { + protected override double RollingDuration => Current.Value > 0 ? 1000 : 0; + protected override Easing RollingEasing => Easing.OutPow10; + + protected override LocalisableString FormatCount(int count) + { + if (count <= 0) + return "-"; + + return "#" + base.FormatCount(count); + } + + protected override OsuSpriteText CreateSpriteText() + { + return new OsuSpriteText + { + Font = OsuFont.Torus.With(size: 60, weight: FontWeight.Light, fixedWidth: true), + Spacing = new Vector2(-8, 0), + }; + } + } + } +} diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs index d108d82a6b..806e593729 100644 --- a/osu.Game/Screens/Play/HUDOverlay.cs +++ b/osu.Game/Screens/Play/HUDOverlay.cs @@ -51,7 +51,7 @@ namespace osu.Game.Screens.Play return base.ShouldBeConsideredForInput(child); // hold to quit button should always be interactive. - return child == bottomRightElements; + return child == BottomRightElements; } public readonly ModDisplay ModDisplay; @@ -92,8 +92,8 @@ namespace osu.Game.Screens.Play // They will make a best-effort attempt to get out of the way of any other skinnable components. public readonly FillFlowContainer TopLeftElements; - internal readonly FillFlowContainer TopRightElements; - private readonly FillFlowContainer bottomRightElements; + public readonly FillFlowContainer TopRightElements; + public readonly FillFlowContainer BottomRightElements; internal readonly IBindable IsPlaying = new Bindable(); @@ -153,7 +153,7 @@ namespace osu.Game.Screens.Play ModDisplay = CreateModsContainer(), } }, - bottomRightElements = new FillFlowContainer + BottomRightElements = new FillFlowContainer { Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, @@ -289,10 +289,10 @@ namespace osu.Game.Screens.Play else TopLeftElements.Y = 0; - if (highestBottomScreenSpace.HasValue && DrawHeight - bottomRightElements.DrawHeight > 0) - bottomRightElements.Y = BottomScoringElementsHeight = -Math.Clamp(DrawHeight - ToLocalSpace(highestBottomScreenSpace.Value).Y, 0, DrawHeight - bottomRightElements.DrawHeight); + if (highestBottomScreenSpace.HasValue && DrawHeight - BottomRightElements.DrawHeight > 0) + BottomRightElements.Y = BottomScoringElementsHeight = -Math.Clamp(DrawHeight - ToLocalSpace(highestBottomScreenSpace.Value).Y, 0, DrawHeight - BottomRightElements.DrawHeight); else - bottomRightElements.Y = 0; + BottomRightElements.Y = 0; void processDrawables(SkinnableContainer components) {