From 111b98ef8eccb8f43b232cdd31cc57e8592c84a5 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Tue, 2 Sep 2025 15:04:22 +0900 Subject: [PATCH] Add matchmaking --- .../Matchmaking/TestSceneBeatmapPanel.cs | 28 ++ .../TestSceneBeatmapSelectionGrid.cs | 184 ++++++++ .../TestSceneBeatmapSelectionOverlay.cs | 68 +++ .../TestSceneBeatmapSelectionPanel.cs | 57 +++ .../Visual/Matchmaking/TestSceneIdleScreen.cs | 89 ++++ .../Matchmaking/TestSceneMatchmakingCloud.cs | 44 ++ .../TestSceneMatchmakingQueueScreen.cs | 55 +++ .../Matchmaking/TestSceneMatchmakingScreen.cs | 244 +++++++++++ .../TestSceneMatchmakingScreenStack.cs | 119 ++++++ .../Visual/Matchmaking/TestScenePickScreen.cs | 106 +++++ .../Matchmaking/TestScenePlayerPanel.cs | 94 +++++ .../Matchmaking/TestSceneResultsScreen.cs | 98 +++++ .../TestSceneRoomStatisticPanel.cs | 32 ++ .../TestSceneRoundResultsScreen.cs | 102 +++++ .../Matchmaking/TestSceneStageBubble.cs | 49 +++ .../Matchmaking/TestSceneStageDisplay.cs | 56 +++ .../Visual/Matchmaking/TestSceneStageText.cs | 43 ++ .../UserInterface/TestSceneMainMenuButton.cs | 25 +- .../Online/Multiplayer/IMultiplayerClient.cs | 3 +- .../Online/Multiplayer/MultiplayerClient.cs | 114 ++++- .../Multiplayer/OnlineMultiplayerClient.cs | 82 ++++ osu.Game/OsuGame.cs | 2 + osu.Game/Screens/Menu/ButtonSystem.cs | 22 +- osu.Game/Screens/Menu/MainMenu.cs | 4 + osu.Game/Screens/Menu/MatchmakingButton.cs | 19 + .../Matchmaking/MatchmakingAvatar.cs | 68 +++ .../Matchmaking/MatchmakingCloud.cs | 117 ++++++ .../Matchmaking/MatchmakingController.cs | 170 ++++++++ .../Matchmaking/MatchmakingPlayer.cs | 31 ++ .../Matchmaking/MatchmakingScreen.cs | 342 +++++++++++++++ .../Matchmaking/Screens/Idle/IdleScreen.cs | 27 ++ .../Matchmaking/Screens/Idle/PlayerPanel.cs | 197 +++++++++ .../Screens/Idle/PlayerPanelList.cs | 80 ++++ .../Screens/MatchmakingIntroScreen.cs | 263 ++++++++++++ .../Screens/MatchmakingQueueScreen.cs | 393 ++++++++++++++++++ .../Screens/MatchmakingScreenStack.cs | 121 ++++++ .../Screens/MatchmakingSubScreen.cs | 43 ++ .../Matchmaking/Screens/Pick/BeatmapPanel.cs | 192 +++++++++ .../Screens/Pick/BeatmapSelectionGrid.cs | 340 +++++++++++++++ .../Screens/Pick/BeatmapSelectionOverlay.cs | 139 +++++++ .../Screens/Pick/BeatmapSelectionPanel.cs | 213 ++++++++++ .../Matchmaking/Screens/Pick/PickScreen.cs | 83 ++++ .../Screens/Results/ResultsScreen.cs | 345 +++++++++++++++ .../Screens/Results/RoomStatisticPanel.cs | 52 +++ .../Screens/Results/UserStatisticPanel.cs | 49 +++ .../RoundResults/RoundResultsScorePanel.cs | 36 ++ .../RoundResults/RoundResultsScreen.cs | 181 ++++++++ .../OnlinePlay/Matchmaking/StageBubble.cs | 157 +++++++ .../OnlinePlay/Matchmaking/StageDisplay.cs | 91 ++++ .../OnlinePlay/Matchmaking/StageText.cs | 84 ++++ .../Multiplayer/TestMultiplayerClient.cs | 102 ++++- 51 files changed, 5637 insertions(+), 18 deletions(-) create mode 100644 osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapPanel.cs create mode 100644 osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectionGrid.cs create mode 100644 osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectionOverlay.cs create mode 100644 osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectionPanel.cs create mode 100644 osu.Game.Tests/Visual/Matchmaking/TestSceneIdleScreen.cs create mode 100644 osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingCloud.cs create mode 100644 osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingQueueScreen.cs create mode 100644 osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingScreen.cs create mode 100644 osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingScreenStack.cs create mode 100644 osu.Game.Tests/Visual/Matchmaking/TestScenePickScreen.cs create mode 100644 osu.Game.Tests/Visual/Matchmaking/TestScenePlayerPanel.cs create mode 100644 osu.Game.Tests/Visual/Matchmaking/TestSceneResultsScreen.cs create mode 100644 osu.Game.Tests/Visual/Matchmaking/TestSceneRoomStatisticPanel.cs create mode 100644 osu.Game.Tests/Visual/Matchmaking/TestSceneRoundResultsScreen.cs create mode 100644 osu.Game.Tests/Visual/Matchmaking/TestSceneStageBubble.cs create mode 100644 osu.Game.Tests/Visual/Matchmaking/TestSceneStageDisplay.cs create mode 100644 osu.Game.Tests/Visual/Matchmaking/TestSceneStageText.cs create mode 100644 osu.Game/Screens/Menu/MatchmakingButton.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/MatchmakingAvatar.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/MatchmakingCloud.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/MatchmakingController.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/MatchmakingPlayer.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/MatchmakingScreen.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Idle/IdleScreen.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Idle/PlayerPanel.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Idle/PlayerPanelList.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/Screens/MatchmakingIntroScreen.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/Screens/MatchmakingQueueScreen.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/Screens/MatchmakingScreenStack.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/Screens/MatchmakingSubScreen.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Pick/BeatmapPanel.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Pick/BeatmapSelectionGrid.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Pick/BeatmapSelectionOverlay.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Pick/BeatmapSelectionPanel.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Pick/PickScreen.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Results/ResultsScreen.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Results/RoomStatisticPanel.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Results/UserStatisticPanel.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/Screens/RoundResults/RoundResultsScorePanel.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/Screens/RoundResults/RoundResultsScreen.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/StageBubble.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/StageDisplay.cs create mode 100644 osu.Game/Screens/OnlinePlay/Matchmaking/StageText.cs diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapPanel.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapPanel.cs new file mode 100644 index 0000000000..c46beba037 --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapPanel.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Pick; +using osu.Game.Tests.Visual.Multiplayer; +using osuTK; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestSceneBeatmapPanel : MultiplayerTestScene + { + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("add beatmap panel", () => + { + Child = new BeatmapPanel(CreateAPIBeatmap()) + { + Size = new Vector2(300, 70), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectionGrid.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectionGrid.cs new file mode 100644 index 0000000000..79ed79e388 --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectionGrid.cs @@ -0,0 +1,184 @@ +// 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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Pick; +using osu.Game.Tests.Visual.OnlinePlay; +using osuTK; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestSceneBeatmapSelectionGrid : OnlinePlayTestScene + { + private MultiplayerPlaylistItem[] items = null!; + + private BeatmapSelectionGrid grid = null!; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + var beatmaps = beatmapManager.GetAllUsableBeatmapSets() + .SelectMany(it => it.Beatmaps) + .Take(50) + .ToArray(); + + if (beatmaps.Length > 0) + { + items = Enumerable.Range(1, 50).Select(i => new MultiplayerPlaylistItem + { + ID = i, + BeatmapID = beatmaps[i % beatmaps.Length].OnlineID, + StarRating = i / 10.0, + }).ToArray(); + } + else + { + items = Enumerable.Range(1, 50).Select(i => new MultiplayerPlaylistItem + { + ID = i, + BeatmapID = i, + StarRating = i / 10.0, + }).ToArray(); + } + } + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("add grid", () => Child = grid = new BeatmapSelectionGrid + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(0.8f), + }); + + AddStep("add items", () => + { + foreach (var item in items) + grid.AddItem(item); + }); + + AddWaitStep("wait for panels", 3); + } + + [Test] + public void TestCompleteRollAnimation() + { + AddStep("play animation", () => + { + var (candidateItems, finalItem) = pickRandomItems(5); + + grid.RollAndDisplayFinalBeatmap(candidateItems, finalItem); + }); + } + + [Test] + public void TestRollAnimation() + { + AddStep("play animation", () => + { + var (candidateItems, finalItem) = pickRandomItems(5); + + grid.TransferCandidatePanelsToRollContainer(candidateItems, duration: 0); + grid.ArrangeItemsForRollAnimation(duration: 0, stagger: 0); + + Scheduler.AddDelayed(() => grid.PlayRollAnimation(finalItem), 500); + }); + } + + [Test] + public void TestPresentRolledBeatmap() + { + AddStep("present beatmap", () => + { + var (candidateItems, finalItem) = pickRandomItems(5); + + grid.TransferCandidatePanelsToRollContainer(candidateItems, duration: 0); + grid.ArrangeItemsForRollAnimation(duration: 0, stagger: 0); + grid.PlayRollAnimation(finalItem, duration: 0); + + Scheduler.AddDelayed(() => grid.PresentRolledBeatmap(finalItem), 500); + }); + } + + [Test] + public void TestPresentUnanimouslyChosenBeatmap() + { + AddStep("present beatmap", () => + { + var (candidateItems, finalItem) = pickRandomItems(5); + + grid.TransferCandidatePanelsToRollContainer(candidateItems, duration: 0); + grid.ArrangeItemsForRollAnimation(duration: 0, stagger: 0); + grid.PlayRollAnimation(finalItem, duration: 0); + + Scheduler.AddDelayed(() => grid.PresentUnanimouslyChosenBeatmap(finalItem), 500); + }); + } + + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + [TestCase(4)] + [TestCase(5)] + [TestCase(6)] + [TestCase(7)] + [TestCase(8)] + public void TestPanelArrangement(int count) + { + AddStep("arrange panels", () => + { + var (candidateItems, _) = pickRandomItems(count); + + grid.TransferCandidatePanelsToRollContainer(candidateItems); + grid.Delay(BeatmapSelectionGrid.ARRANGE_DELAY) + .Schedule(() => grid.ArrangeItemsForRollAnimation()); + }); + + AddWaitStep("wait for movement", 5); + + AddStep("display roll order", () => + { + var panels = grid.ChildrenOfType().ToArray(); + + for (int i = 0; i < panels.Length; i++) + { + var panel = panels[i]; + + panel.Add(new OsuSpriteText + { + Text = (i + 1).ToString(), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Default.With(size: 50, weight: FontWeight.SemiBold), + }); + } + }); + } + + private (long[] candidateItems, long finalItem) pickRandomItems(int count) + { + long[] candidateItems = items.Select(it => it.ID).ToArray(); + Random.Shared.Shuffle(candidateItems); + candidateItems = candidateItems.Take(count).ToArray(); + + long finalItem = candidateItems[Random.Shared.Next(candidateItems.Length)]; + + return (candidateItems, finalItem); + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectionOverlay.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectionOverlay.cs new file mode 100644 index 0000000000..4e596d65cc --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectionOverlay.cs @@ -0,0 +1,68 @@ +// 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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Pick; +using osuTK; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestSceneBeatmapSelectionOverlay : OsuTestScene + { + private BeatmapSelectionOverlay selectionOverlay = null!; + + [SetUpSteps] + public void SetupSteps() + { + AddStep("add drawable", () => Child = new Container + { + Width = 100, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(2), + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.1f, + }, + selectionOverlay = new BeatmapSelectionOverlay + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + } + } + }); + } + + [Test] + public void TestSelectionOverlay() + { + AddStep("add maarvin", () => selectionOverlay.AddUser(new APIUser + { + Id = 6411631, + Username = "Maarvin", + }, isOwnUser: true)); + AddStep("add peppy", () => selectionOverlay.AddUser(new APIUser + { + Id = 2, + Username = "peppy", + }, false)); + AddStep("add smogipoo", () => selectionOverlay.AddUser(new APIUser + { + Id = 1040328, + Username = "smoogipoo", + }, false)); + AddStep("remove smogipoo", () => selectionOverlay.RemoveUser(1040328)); + AddStep("remove peppy", () => selectionOverlay.RemoveUser(2)); + AddStep("remove maarvin", () => selectionOverlay.RemoveUser(6411631)); + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectionPanel.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectionPanel.cs new file mode 100644 index 0000000000..addb0ed3a0 --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectionPanel.cs @@ -0,0 +1,57 @@ +// 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.Graphics; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Pick; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestSceneBeatmapSelectionPanel : MultiplayerTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + [Test] + public void TestBeatmapPanel() + { + BeatmapSelectionPanel? panel = null; + + AddStep("add panel", () => Child = panel = new BeatmapSelectionPanel(new MultiplayerPlaylistItem()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + + AddStep("add maarvin", () => panel!.AddUser(new APIUser + { + Id = 6411631, + Username = "Maarvin", + }, isOwnUser: true)); + AddStep("add peppy", () => panel!.AddUser(new APIUser + { + Id = 2, + Username = "peppy", + })); + AddStep("add smogipoo", () => panel!.AddUser(new APIUser + { + Id = 1040328, + Username = "smoogipoo", + })); + AddStep("remove smogipoo", () => panel!.RemoveUser(new APIUser { Id = 1040328 })); + AddStep("remove peppy", () => panel!.RemoveUser(new APIUser { Id = 2 })); + AddStep("remove maarvin", () => panel!.RemoveUser(new APIUser { Id = 6411631 })); + + AddToggleStep("allow selection", value => + { + if (panel != null) + panel.AllowSelection = value; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneIdleScreen.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneIdleScreen.cs new file mode 100644 index 0000000000..49daedb6a3 --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneIdleScreen.cs @@ -0,0 +1,89 @@ +// 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 NUnit.Framework; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Screens; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Idle; +using osu.Game.Tests.Visual.Multiplayer; +using osuTK; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestSceneIdleScreen : MultiplayerTestScene + { + private const int user_count = 8; + + private (MultiplayerRoomUser user, int score)[] userScores = null!; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom())); + WaitForJoined(); + + AddStep("add list", () => + { + userScores = Enumerable.Range(1, user_count).Select(i => + { + var user = new MultiplayerRoomUser(i) + { + User = new APIUser + { + Username = $"Player {i}" + } + }; + + return (user, 0); + }).ToArray(); + + Child = new ScreenStack(new IdleScreen()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(0.8f) + }; + }); + + AddStep("join users", () => + { + foreach (var (user, _) in userScores) + MultiplayerClient.AddUser(user); + }); + } + + [Test] + public void TestRandomChanges() + { + AddStep("apply random changes", () => + { + int[] deltas = Enumerable.Range(1, userScores.Length).ToArray(); + new Random().Shuffle(deltas); + + for (int i = 0; i < userScores.Length; i++) + userScores[i] = (userScores[i].user, userScores[i].score + deltas[i]); + userScores = userScores.OrderByDescending(u => u.score).ToArray(); + + MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState + { + Users = + { + UserDictionary = userScores.Select((tuple, i) => new MatchmakingUser + { + UserId = tuple.user.UserID, + Points = tuple.score, + Placement = i + 1 + }).ToDictionary(s => s.UserId) + } + }).WaitSafely(); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingCloud.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingCloud.cs new file mode 100644 index 0000000000..c25057c84b --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingCloud.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Utils; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Screens.OnlinePlay.Matchmaking; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestSceneMatchmakingCloud : OsuTestScene + { + private MatchmakingCloud cloud = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + Child = cloud = new MatchmakingCloud + { + RelativeSizeAxes = Axes.Both, + }; + } + + [Test] + public void TestBasic() + { + AddStep("refresh users", () => + { + var testUsers = Enumerable.Range(0, 50).Select(_ => new APIUser + { + Username = "peppy", + Statistics = new UserStatistics { GlobalRank = 1234 }, + Id = RNG.Next(2, 30000000), + }).ToArray(); + + cloud.Users = testUsers; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingQueueScreen.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingQueueScreen.cs new file mode 100644 index 0000000000..ea2a2d15eb --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingQueueScreen.cs @@ -0,0 +1,55 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Screens.OnlinePlay.Matchmaking; +using osu.Game.Screens.OnlinePlay.Matchmaking.Screens; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestSceneMatchmakingQueueScreen : ScreenTestScene + { + [Cached] + private readonly MatchmakingController controller = new MatchmakingController(); + + private MatchmakingQueueScreen? queueScreen => Stack.CurrentScreen as MatchmakingQueueScreen; + + [SetUpSteps] + public override void SetUpSteps() + { + AddStep("load screen", () => LoadScreen(new MatchmakingIntroScreen())); + } + + [Test] + public void TestBasic() + { + AddUntilStep("wait for queue screen", () => queueScreen != null); + + AddStep("set users", () => + { + queueScreen!.Users = Enumerable.Range(0, 10).Select(_ => new APIUser + { + Username = "peppy", + Statistics = new UserStatistics { GlobalRank = 1234 }, + Id = RNG.Next(2, 30000000), + }).ToArray(); + }); + + AddStep("change state to idle", () => queueScreen!.SetState(MatchmakingQueueScreen.MatchmakingScreenState.Idle)); + + AddStep("change state to queueing", () => queueScreen!.SetState(MatchmakingQueueScreen.MatchmakingScreenState.Queueing)); + + AddStep("change state to found match", () => queueScreen!.SetState(MatchmakingQueueScreen.MatchmakingScreenState.PendingAccept)); + + AddStep("change state to waiting for room", () => queueScreen!.SetState(MatchmakingQueueScreen.MatchmakingScreenState.AcceptedWaitingForRoom)); + + AddStep("change state to in room", () => queueScreen!.SetState(MatchmakingQueueScreen.MatchmakingScreenState.InRoom)); + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingScreen.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingScreen.cs new file mode 100644 index 0000000000..416811d345 --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingScreen.cs @@ -0,0 +1,244 @@ +// 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.Extensions; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.OnlinePlay.Matchmaking; +using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Pick; +using osu.Game.Tests.Visual.Multiplayer; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestSceneMatchmakingScreen : MultiplayerTestScene + { + private const int user_count = 8; + private const int beatmap_count = 50; + + private MultiplayerRoomUser[] users = null!; + private MatchmakingScreen screen = null!; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => + { + var room = CreateDefaultRoom(); + room.Playlist = Enumerable.Range(1, 50).Select(i => new PlaylistItem(new MultiplayerPlaylistItem + { + ID = i, + BeatmapID = i, + StarRating = i / 10.0, + })).ToArray(); + + JoinRoom(room); + }); + + WaitForJoined(); + + setupRequestHandler(); + + AddStep("load match", () => + { + users = Enumerable.Range(1, user_count).Select(i => new MultiplayerRoomUser(i) + { + User = new APIUser + { + Username = $"Player {i}" + } + }).ToArray(); + + var beatmaps = Enumerable.Range(1, beatmap_count).Select(i => new MultiplayerPlaylistItem + { + BeatmapID = i, + StarRating = i / 10.0 + }).ToArray(); + + LoadScreen(screen = new MatchmakingScreen(new MultiplayerRoom(0) + { + Users = users, + Playlist = beatmaps + })); + }); + AddUntilStep("wait for load", () => screen.IsCurrentScreen()); + } + + [Test] + public void TestGameplayFlow() + { + // Initial "ready" status of the room". + AddWaitStep("wait", 5); + + AddStep("round start", () => MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState + { + Stage = MatchmakingStage.RoundWarmupTime + }).WaitSafely()); + + // Next round starts with picks. + AddWaitStep("wait", 5); + + AddStep("pick", () => MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState + { + Stage = MatchmakingStage.UserBeatmapSelect + }).WaitSafely()); + + // Make some selections + AddWaitStep("wait", 5); + + for (int i = 0; i < 3; i++) + { + int j = i * 2; + AddStep("click a beatmap", () => + { + Quad panelQuad = this.ChildrenOfType().ElementAt(j).ScreenSpaceDrawQuad; + + InputManager.MoveMouseTo(new Vector2(panelQuad.Centre.X, panelQuad.TopLeft.Y + 5)); + InputManager.Click(MouseButton.Left); + }); + + AddWaitStep("wait", 2); + } + + // Lock in the gameplay beatmap + + AddStep("selection", () => + { + MultiplayerPlaylistItem[] beatmaps = Enumerable.Range(1, 50).Select(i => new MultiplayerPlaylistItem + { + ID = i, + BeatmapID = i, + StarRating = i / 10.0, + }).ToArray(); + + MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState + { + Stage = MatchmakingStage.ServerBeatmapFinalised, + CandidateItems = beatmaps.Select(b => b.ID).ToArray(), + CandidateItem = beatmaps[0].ID + }).WaitSafely(); + }); + + // Prepare gameplay. + AddWaitStep("wait", 25); + + AddStep("prepare gameplay", () => MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState + { + Stage = MatchmakingStage.GameplayWarmupTime + }).WaitSafely()); + + // Start gameplay. + AddWaitStep("wait", 5); + + AddStep("gameplay", () => MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState + { + Stage = MatchmakingStage.Gameplay + }).WaitSafely()); + + AddStep("start gameplay", () => MultiplayerClient.StartMatch().WaitSafely()); + // AddUntilStep("wait for player", () => (Stack.CurrentScreen as Player)?.IsLoaded == true); + + // Finish gameplay. + AddWaitStep("wait", 5); + + AddStep("round end", () => MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState + { + Stage = MatchmakingStage.ResultsDisplaying + }).WaitSafely()); + + AddWaitStep("wait", 10); + + AddStep("room end", () => + { + MatchmakingRoomState state = new MatchmakingRoomState + { + CurrentRound = 1, + Stage = MatchmakingStage.Ended + }; + + int localUserId = API.LocalUser.Value.OnlineID; + + state.Users[localUserId].Placement = 1; + state.Users[localUserId].Rounds[1].Placement = 1; + state.Users[localUserId].Rounds[1].TotalScore = 1; + state.Users[localUserId].Rounds[1].Statistics[HitResult.LargeBonus] = 1; + + MultiplayerClient.ChangeMatchRoomState(state).WaitSafely(); + }); + } + + private void setupRequestHandler() + { + AddStep("setup request handler", () => + { + Func? defaultRequestHandler = null; + + ((DummyAPIAccess)API).HandleRequest = request => + { + switch (request) + { + case GetBeatmapsRequest getBeatmaps: + getBeatmaps.TriggerSuccess(new GetBeatmapsResponse + { + Beatmaps = getBeatmaps.BeatmapIds.Select(id => new APIBeatmap + { + OnlineID = id, + StarRating = id, + DifficultyName = $"Beatmap {id}", + BeatmapSet = new APIBeatmapSet + { + Title = $"Title {id}", + Artist = $"Artist {id}", + AuthorString = $"Author {id}" + } + }).ToList() + }); + return true; + + case IndexPlaylistScoresRequest index: + var result = new IndexedMultiplayerScores(); + + for (int i = 0; i < 8; ++i) + { + result.Scores.Add(new MultiplayerScore + { + ID = i, + Accuracy = 1 - (float)i / 16, + Position = i + 1, + EndedAt = DateTimeOffset.Now, + Passed = true, + Rank = (ScoreRank)RNG.Next((int)ScoreRank.D, (int)ScoreRank.XH), + MaxCombo = 1000 - i, + TotalScore = (long)(1_000_000 * (1 - (float)i / 16)), + User = new APIUser { Username = $"user {i}" }, + Statistics = new Dictionary() + }); + } + + index.TriggerSuccess(result); + return true; + + default: + return defaultRequestHandler?.Invoke(request) ?? false; + } + }; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingScreenStack.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingScreenStack.cs new file mode 100644 index 0000000000..be3d7463d6 --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingScreenStack.cs @@ -0,0 +1,119 @@ +// 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 NUnit.Framework; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.OnlinePlay.Matchmaking.Screens; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestSceneMatchmakingScreenStack : MultiplayerTestScene + { + private const int user_count = 8; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => + { + var room = CreateDefaultRoom(); + room.Playlist = Enumerable.Range(1, 50).Select(i => new PlaylistItem(new MultiplayerPlaylistItem + { + ID = i, + BeatmapID = i, + StarRating = i / 10.0, + })).ToArray(); + + JoinRoom(room); + }); + + WaitForJoined(); + + AddStep("add carousel", () => + { + Child = new MatchmakingScreenStack + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + }; + }); + + AddStep("join users", () => + { + var users = Enumerable.Range(1, user_count).Select(i => new MultiplayerRoomUser(i) + { + User = new APIUser + { + Username = $"Player {i}" + } + }).ToArray(); + + foreach (var user in users) + MultiplayerClient.AddUser(user); + }); + } + + [Test] + public void TestStatus() + { + AddWaitStep("wait for scroll", 5); + AddStep("pick", () => MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState + { + Stage = MatchmakingStage.UserBeatmapSelect + }).WaitSafely()); + + AddWaitStep("wait for scroll", 5); + AddStep("selection", () => + { + MultiplayerPlaylistItem[] beatmaps = Enumerable.Range(1, 50).Select(i => new MultiplayerPlaylistItem + { + ID = i, + BeatmapID = i, + StarRating = i / 10.0, + }).ToArray(); + + beatmaps = Random.Shared.GetItems(beatmaps, 8); + + MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState + { + Stage = MatchmakingStage.ServerBeatmapFinalised, + CandidateItems = beatmaps.Select(b => b.ID).ToArray(), + CandidateItem = beatmaps[0].ID + }).WaitSafely(); + }); + + AddWaitStep("wait for scroll", 35); + AddStep("room end", () => + { + var state = new MatchmakingRoomState + { + CurrentRound = 1, + Stage = MatchmakingStage.Ended + }; + + int localUserId = API.LocalUser.Value.OnlineID; + + state.Users[localUserId].Placement = 1; + state.Users[localUserId].Rounds[1].Placement = 1; + state.Users[localUserId].Rounds[1].TotalScore = 1; + state.Users[localUserId].Rounds[1].Statistics[HitResult.LargeBonus] = 1; + + state.Users[1].Placement = 2; + state.Users[1].Rounds[1].Placement = 2; + + MultiplayerClient.ChangeMatchRoomState(state).WaitSafely(); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestScenePickScreen.cs b/osu.Game.Tests/Visual/Matchmaking/TestScenePickScreen.cs new file mode 100644 index 0000000000..fdb5aed789 --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestScenePickScreen.cs @@ -0,0 +1,106 @@ +// 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.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Pick; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestScenePickScreen : MultiplayerTestScene + { + private readonly IReadOnlyList users = new[] + { + new APIUser + { + Id = 2, + Username = "peppy", + }, + new APIUser + { + Id = 1040328, + Username = "smoogipoo", + }, + new APIUser + { + Id = 6573093, + Username = "OliBomby", + }, + new APIUser + { + Id = 7782553, + Username = "aesth", + }, + new APIUser + { + Id = 6411631, + Username = "Maarvin", + } + }; + + private readonly PlaylistItem[] items = Enumerable.Range(1, 50).Select(i => new PlaylistItem(new MultiplayerPlaylistItem + { + ID = i, + BeatmapID = i, + StarRating = i / 10.0, + })).ToArray(); + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => + { + var room = CreateDefaultRoom(); + room.Playlist = items; + + JoinRoom(room); + }); + + WaitForJoined(); + + AddStep("add users", () => + { + foreach (var user in users) + MultiplayerClient.AddUser(user); + }); + } + + [Test] + public void TestScreen() + { + var selectedItems = new List(); + + PickScreen screen = null!; + + AddStep("add screen", () => LoadScreen(screen = new PickScreen())); + + AddStep("select maps", () => + { + selectedItems.Clear(); + + foreach (var user in users) + { + var item = items[Random.Shared.Next(items.Length)]; + selectedItems.Add(item.ID); + + MultiplayerClient.MatchmakingToggleUserSelection(user.Id, item.ID).FireAndForget(); + } + }); + + AddStep("show final map", () => + { + long[] candidateItems = selectedItems.ToArray(); + long finalItem = candidateItems[Random.Shared.Next(candidateItems.Length)]; + + screen.RollFinalBeatmap(candidateItems, finalItem); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestScenePlayerPanel.cs b/osu.Game.Tests/Visual/Matchmaking/TestScenePlayerPanel.cs new file mode 100644 index 0000000000..dafb2d9f03 --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestScenePlayerPanel.cs @@ -0,0 +1,94 @@ +// 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.Extensions; +using osu.Framework.Graphics; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Idle; +using osu.Game.Tests.Visual.Multiplayer; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestScenePlayerPanel : MultiplayerTestScene + { + private PlayerPanel panel = null!; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom())); + WaitForJoined(); + + AddStep("add panel", () => Child = panel = new PlayerPanel(new MultiplayerRoomUser(1) + { + User = new APIUser + { + Username = @"peppy", + Id = 2, + Colour = "99EB47", + CountryCode = CountryCode.AU, + CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", + Statistics = new UserStatistics { GlobalRank = null, CountryRank = null } + } + }) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }); + } + + [Test] + public void TestIncreasePlacement() + { + int rank = 0; + + AddStep("increase placement", () => MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState + { + Users = + { + UserDictionary = + { + { + 2, new MatchmakingUser + { + UserId = 2, + Placement = ++rank + } + } + } + } + }).WaitSafely()); + + AddToggleStep("toggle horizontal", h => panel.Horizontal = h); + } + + [Test] + public void TestIncreasePoints() + { + int points = 0; + + AddStep("increase points", () => MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState + { + Users = + { + UserDictionary = + { + { + 1, new MatchmakingUser + { + UserId = 1, + Placement = 1, + Points = ++points + } + } + } + } + }).WaitSafely()); + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneResultsScreen.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneResultsScreen.cs new file mode 100644 index 0000000000..5fd5b1c906 --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneResultsScreen.cs @@ -0,0 +1,98 @@ +// 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.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Screens; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Results; +using osu.Game.Tests.Visual.Multiplayer; +using osuTK; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestSceneResultsScreen : MultiplayerTestScene + { + private const int invalid_user_id = 1; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom())); + WaitForJoined(); + + AddStep("add results screen", () => + { + Child = new ScreenStack(new ResultsScreen()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(0.8f) + }; + }); + + AddStep("join another user", () => MultiplayerClient.AddUser(new MultiplayerRoomUser(invalid_user_id) + { + User = new APIUser + { + Id = invalid_user_id, + Username = "Invalid user" + } + })); + } + + [Test] + public void TestResults() + { + AddStep("set results stage", () => + { + var state = new MatchmakingRoomState + { + CurrentRound = 6, + Stage = MatchmakingStage.Ended + }; + + int localUserId = API.LocalUser.Value.OnlineID; + + // Overall state. + state.Users[localUserId].Placement = 1; + state.Users[localUserId].Points = 8; + state.Users[invalid_user_id].Placement = 2; + state.Users[invalid_user_id].Points = 7; + for (int round = 1; round <= state.CurrentRound; round++) + state.Users[localUserId].Rounds[round].Placement = round; + + // Highest score. + state.Users[localUserId].Rounds[1].TotalScore = 1000; + state.Users[invalid_user_id].Rounds[1].TotalScore = 990; + + // Highest accuracy. + state.Users[localUserId].Rounds[2].Accuracy = 0.9995; + state.Users[invalid_user_id].Rounds[2].Accuracy = 0.5; + + // Highest combo. + state.Users[localUserId].Rounds[3].MaxCombo = 100; + state.Users[invalid_user_id].Rounds[3].MaxCombo = 10; + + // Most bonus score. + state.Users[localUserId].Rounds[4].Statistics[HitResult.LargeBonus] = 50; + state.Users[invalid_user_id].Rounds[4].Statistics[HitResult.LargeBonus] = 25; + + // Smallest score difference. + state.Users[localUserId].Rounds[5].TotalScore = 1000; + state.Users[invalid_user_id].Rounds[5].TotalScore = 999; + + // Largest score difference. + state.Users[localUserId].Rounds[6].TotalScore = 1000; + state.Users[invalid_user_id].Rounds[6].TotalScore = 0; + + MultiplayerClient.ChangeMatchRoomState(state).WaitSafely(); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneRoomStatisticPanel.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneRoomStatisticPanel.cs new file mode 100644 index 0000000000..b5d69485cf --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneRoomStatisticPanel.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Results; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestSceneRoomStatisticPanel : MultiplayerTestScene + { + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("add statistic", () => Child = new RoomStatisticPanel("Statistic description", new MultiplayerRoomUser(1) + { + User = new APIUser + { + Id = 1, + Username = "peppy" + } + }) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneRoundResultsScreen.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneRoundResultsScreen.cs new file mode 100644 index 0000000000..e19d228c85 --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneRoundResultsScreen.cs @@ -0,0 +1,102 @@ +// 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 osu.Framework.Graphics; +using osu.Framework.Screens; +using osu.Framework.Utils; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.RoundResults; +using osu.Game.Tests.Visual.Multiplayer; +using osuTK; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestSceneRoundResultsScreen : MultiplayerTestScene + { + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom())); + WaitForJoined(); + + setupRequestHandler(); + + AddStep("load screen", () => + { + Child = new ScreenStack(new RoundResultsScreen()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(0.8f) + }; + }); + } + + private void setupRequestHandler() + { + AddStep("setup request handler", () => + { + Func? defaultRequestHandler = null; + + ((DummyAPIAccess)API).HandleRequest = request => + { + switch (request) + { + case GetBeatmapsRequest getBeatmaps: + getBeatmaps.TriggerSuccess(new GetBeatmapsResponse + { + Beatmaps = getBeatmaps.BeatmapIds.Select(id => new APIBeatmap + { + OnlineID = id, + StarRating = id, + DifficultyName = $"Beatmap {id}", + BeatmapSet = new APIBeatmapSet + { + Title = $"Title {id}", + Artist = $"Artist {id}", + AuthorString = $"Author {id}" + } + }).ToList() + }); + return true; + + case IndexPlaylistScoresRequest index: + var result = new IndexedMultiplayerScores(); + + for (int i = 0; i < 8; ++i) + { + result.Scores.Add(new MultiplayerScore + { + ID = i, + Accuracy = 1 - (float)i / 16, + Position = i + 1, + EndedAt = DateTimeOffset.Now, + Passed = true, + Rank = (ScoreRank)RNG.Next((int)ScoreRank.D, (int)ScoreRank.XH), + MaxCombo = 1000 - i, + TotalScore = (long)(1_000_000 * (1 - (float)i / 16)), + User = new APIUser { Username = $"user {i}" }, + Statistics = new Dictionary() + }); + } + + index.TriggerSuccess(result); + return true; + + default: + return defaultRequestHandler?.Invoke(request) ?? false; + } + }; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneStageBubble.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneStageBubble.cs new file mode 100644 index 0000000000..6349f01f28 --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneStageBubble.cs @@ -0,0 +1,49 @@ +// 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 NUnit.Framework; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Game.Online.Matchmaking; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osu.Game.Screens.OnlinePlay.Matchmaking; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestSceneStageBubble : MultiplayerTestScene + { + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom())); + WaitForJoined(); + + AddStep("add bubble", () => Child = new StageBubble(MatchmakingStage.RoundWarmupTime, "Next Round") + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 100 + }); + } + + [Test] + public void TestStartStopCountdown() + { + MultiplayerCountdown countdown = null!; + + AddStep("start countdown", () => MultiplayerClient.StartCountdown(countdown = new MatchmakingStageCountdown + { + Stage = MatchmakingStage.RoundWarmupTime, + TimeRemaining = TimeSpan.FromSeconds(5) + }).WaitSafely()); + + AddWaitStep("wait a bit", 10); + + AddStep("stop countdown", () => MultiplayerClient.StopCountdown(countdown).WaitSafely()); + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneStageDisplay.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneStageDisplay.cs new file mode 100644 index 0000000000..49680acd64 --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneStageDisplay.cs @@ -0,0 +1,56 @@ +// 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 NUnit.Framework; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Game.Online.Matchmaking; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osu.Game.Screens.OnlinePlay.Matchmaking; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestSceneStageDisplay : MultiplayerTestScene + { + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom())); + WaitForJoined(); + + AddStep("add bubble", () => Child = new StageDisplay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Width = 0.5f, + }); + } + + [Test] + public void TestStartCountdown() + { + foreach (var status in Enum.GetValues()) + { + AddStep($"{status}", () => + { + MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState + { + Stage = status + }).WaitSafely(); + + MultiplayerClient.StartCountdown(new MatchmakingStageCountdown + { + Stage = status, + TimeRemaining = TimeSpan.FromSeconds(5) + }).WaitSafely(); + }); + + AddWaitStep("wait a bit", 10); + } + } + } +} diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneStageText.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneStageText.cs new file mode 100644 index 0000000000..0094c7645a --- /dev/null +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneStageText.cs @@ -0,0 +1,43 @@ +// 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.Extensions; +using osu.Framework.Graphics; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osu.Game.Screens.OnlinePlay.Matchmaking; +using osu.Game.Tests.Visual.Multiplayer; + +namespace osu.Game.Tests.Visual.Matchmaking +{ + public partial class TestSceneStageText : MultiplayerTestScene + { + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("join room", () => JoinRoom(CreateDefaultRoom())); + WaitForJoined(); + + AddStep("create display", () => Child = new StageText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }); + } + + [TestCase(MatchmakingStage.WaitingForClientsJoin)] + [TestCase(MatchmakingStage.RoundWarmupTime)] + [TestCase(MatchmakingStage.UserBeatmapSelect)] + [TestCase(MatchmakingStage.ServerBeatmapFinalised)] + [TestCase(MatchmakingStage.WaitingForClientsBeatmapDownload)] + [TestCase(MatchmakingStage.GameplayWarmupTime)] + [TestCase(MatchmakingStage.Gameplay)] + [TestCase(MatchmakingStage.ResultsDisplaying)] + [TestCase(MatchmakingStage.Ended)] + public void TestStatus(MatchmakingStage status) + { + AddStep("set status", () => MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState { Stage = status }).WaitSafely()); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneMainMenuButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneMainMenuButton.cs index c091c089cf..793bc3cd66 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneMainMenuButton.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneMainMenuButton.cs @@ -13,8 +13,8 @@ using osu.Game.Online.Metadata; using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Screens.Menu; +using osuTK.Graphics; using osuTK.Input; -using Color4 = osuTK.Graphics.Color4; namespace osu.Game.Tests.Visual.UserInterface { @@ -177,5 +177,28 @@ namespace osu.Game.Tests.Visual.UserInterface })); AddAssert("no notification posted", () => notificationOverlay.AllNotifications.Count(), () => Is.Zero); } + + [Test] + public void TestMatchmaking() + { + AddStep("add content", () => + { + Children = new Drawable[] + { + new DependencyProvidingContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Child = new MatchmakingButton(@"button-default-select", new Color4(102, 68, 204, 255), (_, _) => { }, 0, Key.D) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + ButtonSystemState = ButtonSystemState.TopLevel, + }, + }, + }; + }); + } } } diff --git a/osu.Game/Online/Multiplayer/IMultiplayerClient.cs b/osu.Game/Online/Multiplayer/IMultiplayerClient.cs index adb9b92614..aaf9f6e863 100644 --- a/osu.Game/Online/Multiplayer/IMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/IMultiplayerClient.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using osu.Game.Online.API; +using osu.Game.Online.Matchmaking; using osu.Game.Online.Rooms; namespace osu.Game.Online.Multiplayer @@ -13,7 +14,7 @@ namespace osu.Game.Online.Multiplayer /// /// An interface defining a multiplayer client instance. /// - public interface IMultiplayerClient : IStatefulUserHubClient + public interface IMultiplayerClient : IStatefulUserHubClient, IMatchmakingClient { /// /// Signals that the room has changed state. diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index 745e773512..1946863988 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -17,6 +17,7 @@ using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Matchmaking; using osu.Game.Online.Multiplayer.Countdown; using osu.Game.Online.Rooms; using osu.Game.Overlays.Notifications; @@ -26,7 +27,7 @@ using osu.Game.Utils; namespace osu.Game.Online.Multiplayer { - public abstract partial class MultiplayerClient : Component, IMultiplayerClient, IMultiplayerRoomServer + public abstract partial class MultiplayerClient : Component, IMultiplayerClient, IMultiplayerRoomServer, IMatchmakingServer { public Action? PostNotification { protected get; set; } @@ -112,6 +113,22 @@ namespace osu.Game.Online.Multiplayer /// public event Action? Disconnecting; + public event Action? CountdownStarted; + + public event Action? CountdownStopped; + + public event Action? UserStateChanged; + + public event Action? MatchmakingQueueJoined; + public event Action? MatchmakingQueueLeft; + public event Action? MatchmakingRoomInvited; + public event Action? MatchmakingRoomReady; + public event Action? MatchmakingLobbyStatusChanged; + public event Action? MatchmakingQueueStatusChanged; + public event Action? MatchmakingItemSelected; + public event Action? MatchmakingItemDeselected; + public event Action? MatchRoomStateChanged; + /// /// Whether the is currently connected. /// This is NOT thread safe and usage should be scheduled. @@ -179,9 +196,13 @@ namespace osu.Game.Online.Multiplayer { IsConnected.BindValueChanged(connected => Scheduler.Add(() => { - // clean up local room state on server disconnect. - if (!connected.NewValue && Room != null) - LeaveRoom(); + if (!connected.NewValue) + { + if (Room != null) + LeaveRoom(); + + MatchmakingQueueLeft?.Invoke(); + } })); } @@ -254,6 +275,9 @@ namespace osu.Game.Online.Multiplayer Room = joinedRoom; APIRoom = apiRoom; + while (pendingRequests.TryDequeue(out Action? action)) + action(); + APIRoom.RoomID = joinedRoom.RoomID; APIRoom.ChannelId = joinedRoom.ChannelID; APIRoom.Host = joinedRoom.Host?.User; @@ -640,6 +664,7 @@ namespace osu.Game.Online.Multiplayer user.State = state; updateUserPlayingState(userId, state); + UserStateChanged?.Invoke(user, state); RoomUpdated?.Invoke(); }); @@ -672,6 +697,7 @@ namespace osu.Game.Online.Multiplayer Debug.Assert(Room != null); Room.MatchState = state; + MatchRoomStateChanged?.Invoke(state); RoomUpdated?.Invoke(); }); @@ -688,6 +714,7 @@ namespace osu.Game.Online.Multiplayer { case CountdownStartedEvent countdownStartedEvent: Room.ActiveCountdowns.Add(countdownStartedEvent.Countdown); + CountdownStarted?.Invoke(countdownStartedEvent.Countdown); switch (countdownStartedEvent.Countdown) { @@ -700,8 +727,13 @@ namespace osu.Game.Online.Multiplayer case CountdownStoppedEvent countdownStoppedEvent: MultiplayerCountdown? countdown = Room.ActiveCountdowns.FirstOrDefault(countdown => countdown.ID == countdownStoppedEvent.ID); + if (countdown != null) + { Room.ActiveCountdowns.Remove(countdown); + CountdownStopped?.Invoke(countdown); + } + break; } @@ -1001,6 +1033,80 @@ namespace osu.Game.Online.Multiplayer return Task.CompletedTask; } + Task IMatchmakingClient.MatchmakingQueueJoined() + { + Scheduler.Add(() => MatchmakingQueueJoined?.Invoke()); + return Task.CompletedTask; + } + + Task IMatchmakingClient.MatchmakingQueueLeft() + { + Scheduler.Add(() => MatchmakingQueueLeft?.Invoke()); + return Task.CompletedTask; + } + + Task IMatchmakingClient.MatchmakingRoomInvited() + { + Scheduler.Add(() => MatchmakingRoomInvited?.Invoke()); + return Task.CompletedTask; + } + + Task IMatchmakingClient.MatchmakingRoomReady(long roomId) + { + Scheduler.Add(() => MatchmakingRoomReady?.Invoke(roomId)); + return Task.CompletedTask; + } + + Task IMatchmakingClient.MatchmakingLobbyStatusChanged(MatchmakingLobbyStatus status) + { + Scheduler.Add(() => MatchmakingLobbyStatusChanged?.Invoke(status)); + return Task.CompletedTask; + } + + Task IMatchmakingClient.MatchmakingQueueStatusChanged(MatchmakingQueueStatus status) + { + Scheduler.Add(() => MatchmakingQueueStatusChanged?.Invoke(status)); + return Task.CompletedTask; + } + + Task IMatchmakingClient.MatchmakingItemSelected(int userId, long playlistItemId) + { + Scheduler.Add(() => + { + MatchmakingItemSelected?.Invoke(userId, playlistItemId); + RoomUpdated?.Invoke(); + }); + + return Task.CompletedTask; + } + + Task IMatchmakingClient.MatchmakingItemDeselected(int userId, long playlistItemId) + { + Scheduler.Add(() => + { + MatchmakingItemDeselected?.Invoke(userId, playlistItemId); + RoomUpdated?.Invoke(); + }); + + return Task.CompletedTask; + } + + public abstract Task MatchmakingJoinLobby(); + + public abstract Task MatchmakingLeaveLobby(); + + public abstract Task MatchmakingJoinQueue(MatchmakingSettings settings); + + public abstract Task MatchmakingLeaveQueue(); + + public abstract Task MatchmakingAcceptInvitation(); + + public abstract Task MatchmakingDeclineInvitation(); + + public abstract Task MatchmakingToggleSelection(long playlistItemId); + + public abstract Task MatchmakingSkipToNextStage(); + private partial class MultiplayerInvitationNotification : UserAvatarNotification { protected override IconUsage CloseButtonIcon => FontAwesome.Solid.Times; diff --git a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs index 02e9cd4ee8..83ff06d095 100644 --- a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs @@ -14,6 +14,7 @@ using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Overlays.Notifications; using osu.Game.Localisation; +using osu.Game.Online.Matchmaking; namespace osu.Game.Online.Multiplayer { @@ -70,6 +71,15 @@ namespace osu.Game.Online.Multiplayer connection.On(nameof(IMultiplayerClient.PlaylistItemRemoved), ((IMultiplayerClient)this).PlaylistItemRemoved); connection.On(nameof(IMultiplayerClient.PlaylistItemChanged), ((IMultiplayerClient)this).PlaylistItemChanged); connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IMultiplayerClient)this).DisconnectRequested); + + connection.On(nameof(IMultiplayerClient.MatchmakingQueueJoined), ((IMultiplayerClient)this).MatchmakingQueueJoined); + connection.On(nameof(IMultiplayerClient.MatchmakingQueueLeft), ((IMultiplayerClient)this).MatchmakingQueueLeft); + connection.On(nameof(IMultiplayerClient.MatchmakingRoomInvited), ((IMultiplayerClient)this).MatchmakingRoomInvited); + connection.On(nameof(IMultiplayerClient.MatchmakingRoomReady), ((IMultiplayerClient)this).MatchmakingRoomReady); + connection.On(nameof(IMultiplayerClient.MatchmakingLobbyStatusChanged), ((IMultiplayerClient)this).MatchmakingLobbyStatusChanged); + connection.On(nameof(IMultiplayerClient.MatchmakingQueueStatusChanged), ((IMultiplayerClient)this).MatchmakingQueueStatusChanged); + connection.On(nameof(IMultiplayerClient.MatchmakingItemSelected), ((IMultiplayerClient)this).MatchmakingItemSelected); + connection.On(nameof(IMultiplayerClient.MatchmakingItemDeselected), ((IMultiplayerClient)this).MatchmakingItemDeselected); }; IsConnected.BindTo(connector.IsConnected); @@ -310,6 +320,78 @@ namespace osu.Game.Online.Multiplayer return connector.Disconnect(); } + public override Task MatchmakingJoinLobby() + { + if (!IsConnected.Value) + return Task.CompletedTask; + + Debug.Assert(connection != null); + return connection.InvokeAsync(nameof(IMatchmakingServer.MatchmakingJoinLobby)); + } + + public override Task MatchmakingLeaveLobby() + { + if (!IsConnected.Value) + return Task.CompletedTask; + + Debug.Assert(connection != null); + return connection.InvokeAsync(nameof(IMatchmakingServer.MatchmakingLeaveLobby)); + } + + public override Task MatchmakingJoinQueue(MatchmakingSettings settings) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + Debug.Assert(connection != null); + return connection.InvokeAsync(nameof(IMatchmakingServer.MatchmakingJoinQueue), settings); + } + + public override Task MatchmakingLeaveQueue() + { + if (!IsConnected.Value) + return Task.CompletedTask; + + Debug.Assert(connection != null); + return connection.InvokeAsync(nameof(IMatchmakingServer.MatchmakingLeaveQueue)); + } + + public override Task MatchmakingAcceptInvitation() + { + if (!IsConnected.Value) + return Task.CompletedTask; + + Debug.Assert(connection != null); + return connection.InvokeAsync(nameof(IMatchmakingServer.MatchmakingAcceptInvitation)); + } + + public override Task MatchmakingDeclineInvitation() + { + if (!IsConnected.Value) + return Task.CompletedTask; + + Debug.Assert(connection != null); + return connection.InvokeAsync(nameof(IMatchmakingServer.MatchmakingDeclineInvitation)); + } + + public override Task MatchmakingToggleSelection(long playlistItemId) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + Debug.Assert(connection != null); + return connection.InvokeAsync(nameof(IMatchmakingServer.MatchmakingToggleSelection), playlistItemId); + } + + public override Task MatchmakingSkipToNextStage() + { + if (!IsConnected.Value) + return Task.CompletedTask; + + Debug.Assert(connection != null); + return connection.InvokeAsync(nameof(IMatchmakingServer.MatchmakingSkipToNextStage)); + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index bf08023242..d610bd64d5 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -65,6 +65,7 @@ using osu.Game.Screens.Edit; using osu.Game.Screens.Footer; using osu.Game.Screens.Menu; using osu.Game.Screens.OnlinePlay.DailyChallenge; +using osu.Game.Screens.OnlinePlay.Matchmaking; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Screens.Play; @@ -1270,6 +1271,7 @@ namespace osu.Game loadComponentSingleFile(new BackgroundDataStoreProcessor(), Add); loadComponentSingleFile(detachedBeatmapStore = new RealmDetachedBeatmapStore(), Add, true); + loadComponentSingleFile(new MatchmakingController(), Add, true); Add(externalLinkOpener = new ExternalLinkOpener()); Add(new MusicKeyBindingHandler()); diff --git a/osu.Game/Screens/Menu/ButtonSystem.cs b/osu.Game/Screens/Menu/ButtonSystem.cs index 073a0d4021..48d745562c 100644 --- a/osu.Game/Screens/Menu/ButtonSystem.cs +++ b/osu.Game/Screens/Menu/ButtonSystem.cs @@ -46,6 +46,7 @@ namespace osu.Game.Screens.Menu public Action? OnSolo; public Action? OnSettings; public Action? OnMultiplayer; + public Action? OnMatchmaking; public Action? OnPlaylists; public Action? OnDailyChallenge; @@ -138,23 +139,27 @@ namespace osu.Game.Screens.Menu Padding = new MarginPadding { Left = WEDGE_WIDTH }, }); buttonsPlay.Add(new MainMenuButton(ButtonSystemStrings.Multi, @"button-default-select", OsuIcon.Online, new Color4(94, 63, 186, 255), onMultiplayer, Key.M)); + buttonsPlay.Add(new MatchmakingButton(@"button-default-select", new Color4(94, 63, 186, 255), onMatchmaking, Key.N)); buttonsPlay.Add(new MainMenuButton(ButtonSystemStrings.Playlists, @"button-default-select", OsuIcon.Tournament, new Color4(94, 63, 186, 255), onPlaylists, Key.L)); buttonsPlay.Add(new DailyChallengeButton(@"button-daily-select", new Color4(94, 63, 186, 255), onDailyChallenge, Key.D)); buttonsPlay.ForEach(b => b.VisibleState = ButtonSystemState.Play); - buttonsEdit.Add(new MainMenuButton(EditorStrings.BeatmapEditor.ToLower(), @"button-default-select", OsuIcon.Beatmap, new Color4(238, 170, 0, 255), (_, _) => OnEditBeatmap?.Invoke(), Key.B, Key.E) + buttonsEdit.Add(new MainMenuButton(EditorStrings.BeatmapEditor.ToLower(), @"button-default-select", OsuIcon.Beatmap, new Color4(238, 170, 0, 255), (_, _) => OnEditBeatmap?.Invoke(), Key.B, + Key.E) { Padding = new MarginPadding { Left = WEDGE_WIDTH }, }); buttonsEdit.Add(new MainMenuButton(SkinEditorStrings.SkinEditor.ToLower(), @"button-default-select", OsuIcon.SkinB, new Color4(220, 160, 0, 255), (_, _) => OnEditSkin?.Invoke(), Key.S)); buttonsEdit.ForEach(b => b.VisibleState = ButtonSystemState.Edit); - buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Play, @"button-play-select", OsuIcon.Logo, new Color4(102, 68, 204, 255), (_, _) => State = ButtonSystemState.Play, Key.P, Key.M, Key.L) + buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Play, @"button-play-select", OsuIcon.Logo, new Color4(102, 68, 204, 255), (_, _) => State = ButtonSystemState.Play, Key.P, Key.M, + Key.L) { Padding = new MarginPadding { Left = WEDGE_WIDTH }, }); buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Edit, @"button-play-select", OsuIcon.EditCircle, new Color4(238, 170, 0, 255), (_, _) => State = ButtonSystemState.Edit, Key.E)); - buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Browse, @"button-default-select", OsuIcon.Beatmap, new Color4(165, 204, 0, 255), (_, _) => OnBeatmapListing?.Invoke(), Key.B, Key.D)); + buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Browse, @"button-default-select", OsuIcon.Beatmap, new Color4(165, 204, 0, 255), (_, _) => OnBeatmapListing?.Invoke(), Key.B, + Key.D)); if (host.CanExit) buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Exit, string.Empty, OsuIcon.CrossCircle, new Color4(238, 51, 153, 255), (_, e) => OnExit?.Invoke(e), Key.Q)); @@ -191,6 +196,17 @@ namespace osu.Game.Screens.Menu OnMultiplayer?.Invoke(); } + private void onMatchmaking(MainMenuButton mainMenuButton, UIEvent uiEvent) + { + if (api.State.Value != APIState.Online) + { + loginOverlay?.Show(); + return; + } + + OnMatchmaking?.Invoke(); + } + private void onPlaylists(MainMenuButton mainMenuButton, UIEvent uiEvent) { if (api.State.Value != APIState.Online) diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs index bc3bcbd800..c74b60c5d7 100644 --- a/osu.Game/Screens/Menu/MainMenu.cs +++ b/osu.Game/Screens/Menu/MainMenu.cs @@ -37,6 +37,7 @@ using osu.Game.Rulesets; using osu.Game.Screens.Backgrounds; using osu.Game.Screens.Edit; using osu.Game.Screens.OnlinePlay.DailyChallenge; +using osu.Game.Screens.OnlinePlay.Matchmaking.Screens; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Screens.SelectV2; @@ -159,6 +160,7 @@ namespace osu.Game.Screens.Menu }, OnSolo = loadSongSelect, OnMultiplayer = () => this.Push(new Multiplayer()), + OnMatchmaking = joinOrLeaveMatchmakingQueue, OnPlaylists = () => this.Push(new Playlists()), OnDailyChallenge = room => { @@ -481,6 +483,8 @@ namespace osu.Game.Screens.Menu private void loadSongSelect() => this.Push(new SoloSongSelect()); + private void joinOrLeaveMatchmakingQueue() => this.Push(new MatchmakingIntroScreen()); + private partial class MobileDisclaimerDialog : PopupDialog { public MobileDisclaimerDialog(Action confirmed) diff --git a/osu.Game/Screens/Menu/MatchmakingButton.cs b/osu.Game/Screens/Menu/MatchmakingButton.cs new file mode 100644 index 0000000000..b65f08fe03 --- /dev/null +++ b/osu.Game/Screens/Menu/MatchmakingButton.cs @@ -0,0 +1,19 @@ +// 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 osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osuTK.Graphics; +using osuTK.Input; + +namespace osu.Game.Screens.Menu +{ + public partial class MatchmakingButton : MainMenuButton + { + public MatchmakingButton(string sampleName, Color4 colour, Action? clickAction = null, params Key[] triggerKeys) + : base("matchmaking", sampleName, FontAwesome.Solid.Newspaper, colour, clickAction, triggerKeys) + { + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/MatchmakingAvatar.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/MatchmakingAvatar.cs new file mode 100644 index 0000000000..e3d314844f --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/MatchmakingAvatar.cs @@ -0,0 +1,68 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Users.Drawables; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking +{ + public partial class MatchmakingAvatar : CompositeDrawable + { + public static readonly Vector2 SIZE = new Vector2(30); + + private readonly APIUser user; + private readonly bool isOwnUser; + + public MatchmakingAvatar(APIUser user, bool isOwnUser = false) + { + this.user = user; + this.isOwnUser = isOwnUser; + + Size = SIZE; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colour) + { + if (isOwnUser) + { + AddInternal(new Container + { + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue, + Padding = new MarginPadding(-2), + Child = new FastCircle + { + RelativeSizeAxes = Axes.Both, + Colour = colour.Yellow, + } + }); + } + + AddInternal(new CircularContainer + { + RelativeSizeAxes = Axes.Both, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.LightSlateGray, + }, + new ClickableAvatar(user, true) + { + RelativeSizeAxes = Axes.Both, + } + } + }); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/MatchmakingCloud.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/MatchmakingCloud.cs new file mode 100644 index 0000000000..5a738f05d4 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/MatchmakingCloud.cs @@ -0,0 +1,117 @@ +// 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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Utils; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Screens.Ranking; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking +{ + public partial class MatchmakingCloud : CompositeDrawable + { + private APIUser[] users = []; + private Container usersContainer = null!; + + public APIUser[] Users + { + get => users; + set + { + users = value; + + foreach (var u in usersContainer) + u.Delay(RNG.Next(0, 1000)).FadeOut(500).Expire(); + + LoadComponentsAsync(users.Select(u => new MovingAvatar(u)), avatars => + { + if (usersContainer.Count == 0) + { + usersContainer.ScaleTo(0) + .ScaleTo(1, 5000, Easing.OutPow10); + } + + usersContainer.AddRange(avatars); + }); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + InternalChildren = new Drawable[] + { + usersContainer = new AspectContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + }, + }; + } + + public partial class MovingAvatar : MatchmakingAvatar + { + private float angle; + private float angularSpeed; + + private float targetSpeed; + private float targetScale; + private float targetAlpha; + + public MovingAvatar(APIUser apiUser) + : base(apiUser) + { + RelativePositionAxes = Axes.Both; + Scale = new Vector2(2); + + Origin = Anchor.Centre; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + updateParams(); + + angle = RNG.NextSingle(0f, MathF.Tau); + + angularSpeed = targetSpeed; + Scale = new Vector2(targetScale); + + Hide(); + this.Delay(RNG.Next(0, 1000)).FadeTo(targetAlpha, 2000, Easing.OutQuint); + } + + private void updateParams() + { + targetSpeed = RNG.NextSingle(0.05f, 0.5f); + targetScale = RNG.NextSingle(0.2f, 3f); + targetAlpha = RNG.NextSingle(0.5f, 1f); + + Scheduler.AddDelayed(updateParams, RNG.Next(500, 5000)); + } + + protected override void Update() + { + base.Update(); + + float elapsed = (float)Math.Min(20, Time.Elapsed) / 1000; + + Scale = new Vector2((float)Interpolation.Lerp(Scale.X, targetScale, elapsed / 100)); + Alpha = (float)Interpolation.Lerp(Alpha, targetAlpha, elapsed / 100); + angularSpeed = (float)Interpolation.Lerp(angularSpeed, targetSpeed, elapsed / 100); + + angle += angularSpeed * elapsed * 0.5f; + + Position = new Vector2(0.5f) + + new Vector2(MathF.Cos(angle), MathF.Sin(angle)) * angularSpeed; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/MatchmakingController.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/MatchmakingController.cs new file mode 100644 index 0000000000..dde7adfc13 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/MatchmakingController.cs @@ -0,0 +1,170 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Screens; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using osu.Game.Rulesets; +using osu.Game.Screens.OnlinePlay.Matchmaking.Screens; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking +{ + public partial class MatchmakingController : Component + { + public readonly Bindable CurrentState = new Bindable(); + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + [Resolved] + private INotificationOverlay? notifications { get; set; } + + [Resolved] + private IPerformFromScreenRunner? performer { get; set; } + + [Resolved] + private IBindable ruleset { get; set; } = null!; + + private ProgressNotification? backgroundNotification; + private Notification? readyNotification; + private bool isBackgrounded; + + protected override void LoadComplete() + { + base.LoadComplete(); + + client.RoomUpdated += onRoomUpdated; + client.MatchmakingQueueJoined += onMatchmakingQueueJoined; + client.MatchmakingQueueLeft += onMatchmakingQueueLeft; + client.MatchmakingRoomInvited += onMatchmakingRoomInvited; + client.MatchmakingRoomReady += onMatchmakingRoomReady; + + ruleset.BindValueChanged(_ => client.MatchmakingLeaveQueue().FireAndForget()); + } + + public void SearchInBackground() + { + if (isBackgrounded) + return; + + isBackgrounded = true; + postNotification(); + } + + public void SearchInForeground() + { + if (!isBackgrounded) + return; + + isBackgrounded = false; + closeNotifications(); + } + + private void onRoomUpdated() => Scheduler.Add(() => + { + if (client.Room == null) + CurrentState.Value = MatchmakingQueueScreen.MatchmakingScreenState.Idle; + }); + + private void onMatchmakingQueueJoined() => Scheduler.Add(() => + { + CurrentState.Value = MatchmakingQueueScreen.MatchmakingScreenState.Queueing; + + if (isBackgrounded) + { + closeNotifications(); + postNotification(); + } + }); + + private void onMatchmakingQueueLeft() => Scheduler.Add(() => + { + if (CurrentState.Value != MatchmakingQueueScreen.MatchmakingScreenState.InRoom) + CurrentState.Value = MatchmakingQueueScreen.MatchmakingScreenState.Idle; + + closeNotifications(); + }); + + private void onMatchmakingRoomInvited() => Scheduler.Add(() => + { + CurrentState.Value = MatchmakingQueueScreen.MatchmakingScreenState.PendingAccept; + + if (backgroundNotification != null) + { + backgroundNotification.State = ProgressNotificationState.Completed; + backgroundNotification = null; + } + }); + + private void onMatchmakingRoomReady(long roomId) => Scheduler.Add(() => + { + client.JoinRoom(new Room { RoomID = roomId }) + .FireAndForget(() => Scheduler.Add(() => + { + CurrentState.Value = MatchmakingQueueScreen.MatchmakingScreenState.InRoom; + })); + }); + + private void postNotification() + { + if (backgroundNotification != null) + return; + + notifications?.Post(backgroundNotification = new ProgressNotification + { + Text = "Searching for opponents...", + CompletionTarget = n => notifications.Post(readyNotification = n), + CompletionText = "Your match is ready! Click to join.", + CompletionClickAction = () => + { + client.MatchmakingAcceptInvitation().FireAndForget(); + performer?.PerformFromScreen(s => s.Push(new MatchmakingIntroScreen())); + + closeNotifications(); + return true; + }, + CancelRequested = () => + { + client.MatchmakingLeaveQueue().FireAndForget(); + + closeNotifications(); + return true; + } + }); + } + + private void closeNotifications() + { + if (backgroundNotification != null) + { + backgroundNotification.State = ProgressNotificationState.Cancelled; + backgroundNotification.Close(false); + } + + readyNotification?.Close(false); + + backgroundNotification = null; + readyNotification = null; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + { + client.RoomUpdated -= onRoomUpdated; + client.MatchmakingQueueJoined -= onMatchmakingQueueJoined; + client.MatchmakingQueueLeft -= onMatchmakingQueueLeft; + client.MatchmakingRoomInvited -= onMatchmakingRoomInvited; + client.MatchmakingRoomReady -= onMatchmakingRoomReady; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/MatchmakingPlayer.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/MatchmakingPlayer.cs new file mode 100644 index 0000000000..af19aa1252 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/MatchmakingPlayer.cs @@ -0,0 +1,31 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading.Tasks; +using osu.Framework.Screens; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; +using osu.Game.Scoring; +using osu.Game.Screens.OnlinePlay.Multiplayer; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking +{ + public partial class MatchmakingPlayer : MultiplayerPlayer + { + public MatchmakingPlayer(Room room, PlaylistItem playlistItem, MultiplayerRoomUser[] users) + : base(room, playlistItem, users) + { + } + + protected override async Task PrepareScoreForResultsAsync(Score score) + { + await base.PrepareScoreForResultsAsync(score).ConfigureAwait(false); + + Scheduler.Add(() => + { + if (this.IsCurrentScreen()) + this.Exit(); + }); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/MatchmakingScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/MatchmakingScreen.cs new file mode 100644 index 0000000000..af77306113 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/MatchmakingScreen.cs @@ -0,0 +1,342 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Logging; +using osu.Framework.Screens; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Graphics.Cursor; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Online; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Overlays.Dialog; +using osu.Game.Rulesets; +using osu.Game.Screens.Footer; +using osu.Game.Screens.OnlinePlay.Match.Components; +using osu.Game.Screens.OnlinePlay.Matchmaking.Screens; +using osu.Game.Screens.OnlinePlay.Multiplayer; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking +{ + public partial class MatchmakingScreen : OsuScreen + { + /// + /// Padding between rows of the content. + /// + private const float row_padding = 10; + + public override bool? ApplyModTrackAdjustments => true; + + public override bool DisallowExternalBeatmapRulesetChanges => true; + + public override bool ShowFooter => true; + + [Cached(typeof(OnlinePlayBeatmapAvailabilityTracker))] + private readonly OnlinePlayBeatmapAvailabilityTracker beatmapAvailabilityTracker = new MultiplayerBeatmapAvailabilityTracker(); + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + [Resolved] + private BeatmapLookupCache beatmapLookupCache { get; set; } = null!; + + [Resolved] + private BeatmapModelDownloader beatmapDownloader { get; set; } = null!; + + [Resolved] + private IDialogOverlay dialogOverlay { get; set; } = null!; + + private readonly MultiplayerRoom room; + + private CancellationTokenSource? downloadCheckCancellation; + private int? lastDownloadCheckedBeatmapId; + + public MatchmakingScreen(MultiplayerRoom room) + { + this.room = room; + + Activity.Value = new UserActivity.InLobby(room); + Padding = new MarginPadding { Horizontal = -HORIZONTAL_OVERFLOW_PADDING }; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + beatmapAvailabilityTracker, + new MultiplayerRoomSounds(), + new GridContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Horizontal = WaveOverlayContainer.WIDTH_PADDING, + Bottom = ScreenFooter.HEIGHT + 20 + }, + RowDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, row_padding), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new Drawable[]?[] + { + [ + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 10, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex(@"3e3a44") // Temporary. + }, + new MatchmakingScreenStack(), + } + } + ], + null, + [ + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + Height = 100, + Padding = new MarginPadding + { + Horizontal = 200, + }, + Child = new MatchChatDisplay(new Room(room)) + { + RelativeSizeAxes = Axes.Both, + } + }, + new RoundedButton + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Text = "Don't click me", + Size = new Vector2(100, 30), + Action = () => client.MatchmakingSkipToNextStage() + } + } + } + ] + } + } + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + client.RoomUpdated += onRoomUpdated; + client.UserStateChanged += onUserStateChanged; + client.SettingsChanged += onSettingsChanged; + client.LoadRequested += onLoadRequested; + + beatmapAvailabilityTracker.Availability.BindValueChanged(onBeatmapAvailabilityChanged, true); + } + + private void onRoomUpdated() + { + if (this.IsCurrentScreen() && client.Room == null) + { + Logger.Log($"{this} exiting due to loss of room or connection"); + this.Exit(); + } + } + + private void onUserStateChanged(MultiplayerRoomUser user, MultiplayerUserState state) + { + if (user.Equals(client.LocalUser) && state == MultiplayerUserState.Idle) + this.MakeCurrent(); + } + + private void onSettingsChanged(MultiplayerRoomSettings _) => Scheduler.Add(() => + { + checkForAutomaticDownload(); + updateGameplayState(); + }); + + private void onBeatmapAvailabilityChanged(ValueChangedEvent e) => Scheduler.Add(() => + { + if (client.Room == null || client.LocalUser == null) + return; + + client.ChangeBeatmapAvailability(e.NewValue).FireAndForget(); + + switch (e.NewValue.State) + { + case DownloadState.NotDownloaded: + case DownloadState.LocallyAvailable: + updateGameplayState(); + break; + } + }); + + private void updateGameplayState() + { + if (client.Room?.MatchState is not MatchmakingRoomState matchmakingState) + return; + + if (matchmakingState.Stage != MatchmakingStage.WaitingForClientsBeatmapDownload) + return; + + MultiplayerPlaylistItem item = client.Room!.CurrentPlaylistItem; + RulesetInfo ruleset = rulesets.GetRuleset(item.RulesetID)!; + Ruleset rulesetInstance = ruleset.CreateInstance(); + + // Update global gameplay state to correspond to the new selection. + // Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info + var localBeatmap = beatmapManager.QueryBeatmap($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}", item.BeatmapID); + Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap); + Ruleset.Value = ruleset; + Mods.Value = item.RequiredMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); + + if (Beatmap.Value is DummyWorkingBeatmap) + client.ChangeState(MultiplayerUserState.Idle).FireAndForget(); + else + client.ChangeState(MultiplayerUserState.Ready).FireAndForget(); + } + + private void onLoadRequested() => Scheduler.Add(() => + { + updateGameplayState(); + this.Push(new MultiplayerPlayerLoader(() => new MatchmakingPlayer(new Room(room), new PlaylistItem(client.Room!.CurrentPlaylistItem), room.Users.ToArray()))); + }); + + private void checkForAutomaticDownload() + { + if (client.Room == null) + return; + + MultiplayerPlaylistItem item = client.Room.CurrentPlaylistItem; + + // This method is called every time anything changes in the room. + // This could result in download requests firing far too often, when we only expect them to fire once per beatmap. + // + // Without this check, we would see especially egregious behaviour when a user has hit the download rate limit. + if (lastDownloadCheckedBeatmapId == item.BeatmapID) + return; + + lastDownloadCheckedBeatmapId = item.BeatmapID; + + downloadCheckCancellation?.Cancel(); + + // In a perfect world we'd use BeatmapAvailability, but there's no event-driven flow for when a selection changes. + // ie. if selection changes from "not downloaded" to another "not downloaded" we wouldn't get a value changed raised. + beatmapLookupCache + .GetBeatmapAsync(item.BeatmapID, (downloadCheckCancellation = new CancellationTokenSource()).Token) + .ContinueWith(resolved => Schedule(() => + { + var beatmapSet = resolved.GetResultSafely()?.BeatmapSet; + + if (beatmapSet == null) + return; + + if (beatmapManager.IsAvailableLocally(new BeatmapSetInfo { OnlineID = beatmapSet.OnlineID })) + return; + + beatmapDownloader.Download(beatmapSet); + })); + } + + private bool exitConfirmed; + + public override bool OnExiting(ScreenExitEvent e) + { + if (base.OnExiting(e)) + return true; + + if (exitConfirmed) + { + client.LeaveRoom().FireAndForget(); + return false; + } + + if (dialogOverlay.CurrentDialog is ConfirmDialog confirmDialog) + confirmDialog.PerformOkAction(); + else + { + dialogOverlay.Push(new ConfirmDialog("Are you sure you want to leave this multiplayer match?", () => + { + exitConfirmed = true; + if (this.IsCurrentScreen()) + this.Exit(); + })); + } + + return true; + } + + public override void OnResuming(ScreenTransitionEvent e) + { + base.OnResuming(e); + + if (e.Last is not MultiplayerPlayerLoader playerLoader) + return; + + if (!playerLoader.GameplayPassed) + { + client.AbortGameplay().FireAndForget(); + return; + } + + client.ChangeState(MultiplayerUserState.Idle); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + { + client.RoomUpdated -= onRoomUpdated; + client.UserStateChanged -= onUserStateChanged; + client.SettingsChanged -= onSettingsChanged; + client.LoadRequested -= onLoadRequested; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Idle/IdleScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Idle/IdleScreen.cs new file mode 100644 index 0000000000..e67e2a520a --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Idle/IdleScreen.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Screens; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Idle +{ + public partial class IdleScreen : MatchmakingSubScreen + { + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new PlayerPanelList + { + RelativeSizeAxes = Axes.Both + }; + } + + public override void OnEntering(ScreenTransitionEvent e) + { + base.OnEntering(e); + this.MoveToX(0); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Idle/PlayerPanel.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Idle/PlayerPanel.cs new file mode 100644 index 0000000000..eaddb0f2e4 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Idle/PlayerPanel.cs @@ -0,0 +1,197 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Idle +{ + public partial class PlayerPanel : UserPanel + { + public readonly MultiplayerRoomUser RoomUser; + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + private OsuSpriteText rankText = null!; + private OsuSpriteText scoreText = null!; + + private MatchmakingAvatar avatar = null!; + private OsuSpriteText username = null!; + + private Container mainContent = null!; + + public bool Horizontal + { + get => horizontal; + set + { + horizontal = value; + if (IsLoaded) + updateLayout(false); + } + } + + private bool horizontal; + + public PlayerPanel(MultiplayerRoomUser user) + : base(user.User!) + { + RoomUser = user; + } + + [BackgroundDependencyLoader] + private void load() + { + Masking = true; + CornerRadius = 10; + + Add(mainContent = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + avatar = new MatchmakingAvatar(User, isOwnUser: User.Id == api.LocalUser.Value.Id) + { + Anchor = Anchor.TopLeft, + Origin = Anchor.Centre, + Size = new Vector2(80), + }, + rankText = new OsuSpriteText + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomCentre, + Blending = BlendingParameters.Additive, + Margin = new MarginPadding(4), + Font = OsuFont.Style.Title.With(size: 70), + }, + username = new OsuSpriteText + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Text = User.Username, + Font = OsuFont.Style.Heading1, + }, + scoreText = new OsuSpriteText + { + Margin = new MarginPadding(10), + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Font = OsuFont.Style.Heading2, + Text = "0 pts" + } + } + }); + } + + protected override Drawable CreateLayout() => Empty(); + + protected override void LoadComplete() + { + base.LoadComplete(); + + updateLayout(true); + + client.MatchRoomStateChanged += onRoomStateChanged; + onRoomStateChanged(client.Room!.MatchState); + + avatar.ScaleTo(0) + .ScaleTo(1, 500, Easing.OutElasticHalf) + .FadeIn(200); + + rankText.Hide(); + scoreText.Hide(); + username.Hide(); + + using (BeginDelayedSequence(100)) + { + username.FadeInFromZero(600); + + using (BeginDelayedSequence(100)) + { + scoreText.FadeInFromZero(600); + + using (BeginDelayedSequence(100)) + { + rankText.FadeTo(0.6f, 600); + } + } + } + } + + private Vector2 avatarPosition => horizontal ? new Vector2(50) : new Vector2(75, 50); + + private void updateLayout(bool instant) + { + double duration = instant ? 0 : 1000; + + avatar.MoveTo(avatarPosition, duration, Easing.OutPow10); + this.ResizeTo(horizontal ? new Vector2(250, 100) : new Vector2(150, 200), duration, Easing.OutPow10); + + rankText.MoveTo(horizontal ? new Vector2(-40, -10) : new Vector2(-70, 0), duration, Easing.OutPow10); + username.MoveTo(horizontal ? new Vector2(0, -46) : new Vector2(0, -86), duration, Easing.OutPow10); + scoreText.MoveTo(horizontal ? new Vector2(0, -16) : new Vector2(0, -56), duration, Easing.OutPow10); + } + + protected override bool OnHover(HoverEvent e) + { + this.ScaleTo(1.02f, 1000, Easing.OutQuint); + mainContent.ScaleTo(1.03f, 1000, Easing.OutQuint); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + this.ScaleTo(1f, 500, Easing.OutQuint); + mainContent.ScaleTo(1, 500, Easing.OutQuint); + + mainContent.MoveTo(Vector2.Zero, 500, Easing.OutElasticHalf); + avatar.MoveTo(avatarPosition, 1500, Easing.OutElastic); + base.OnHoverLost(e); + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + var offset = (avatar.ToLocalSpace(e.ScreenSpaceMousePosition) - avatar.DrawSize / 2) * 0.02f; + + mainContent.MoveTo(offset * 0.5f, 1000, Easing.OutQuint); + avatar.MoveTo(avatarPosition + offset, 400, Easing.OutQuint); + return base.OnMouseMove(e); + } + + private void onRoomStateChanged(MatchRoomState? state) => Scheduler.Add(() => + { + if (state is not MatchmakingRoomState matchmakingState) + return; + + if (!matchmakingState.Users.UserDictionary.TryGetValue(User.Id, out MatchmakingUser? userScore)) + return; + + rankText.Text = $"#{userScore.Placement}"; + scoreText.Text = $"{userScore.Points} pts"; + }); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + client.MatchRoomStateChanged -= onRoomStateChanged; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Idle/PlayerPanelList.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Idle/PlayerPanelList.cs new file mode 100644 index 0000000000..003c35d8c4 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Idle/PlayerPanelList.cs @@ -0,0 +1,80 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Idle +{ + public partial class PlayerPanelList : CompositeDrawable + { + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + public bool Horizontal { get; init; } + + private FillFlowContainer panels = null!; + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = panels = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Spacing = new Vector2(20, 5), + LayoutEasing = Easing.InOutQuint, + LayoutDuration = 500 + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + client.MatchRoomStateChanged += onRoomStateChanged; + client.UserJoined += onUserJoined; + client.UserLeft += onUserLeft; + + if (client.Room != null) + { + onRoomStateChanged(client.Room.MatchState); + foreach (var user in client.Room.Users) + onUserJoined(user); + } + } + + private void onUserJoined(MultiplayerRoomUser user) => Scheduler.Add(() => + { + panels.Add(new PlayerPanel(user) + { + Horizontal = Horizontal, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + }); + + private void onUserLeft(MultiplayerRoomUser user) => Scheduler.Add(() => + { + panels.Single(p => p.RoomUser.Equals(user)).Expire(); + }); + + private void onRoomStateChanged(MatchRoomState? state) => Scheduler.Add(() => + { + if (state is not MatchmakingRoomState matchmakingState) + return; + + foreach (var panel in panels) + { + if (matchmakingState.Users.UserDictionary.TryGetValue(panel.User.Id, out MatchmakingUser? user)) + panels.SetLayoutPosition(panel, user.Placement); + else + panels.SetLayoutPosition(panel, float.MaxValue); + } + }); + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/MatchmakingIntroScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/MatchmakingIntroScreen.cs new file mode 100644 index 0000000000..9a23c963a9 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/MatchmakingIntroScreen.cs @@ -0,0 +1,263 @@ +// 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 osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Screens; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osu.Game.Screens.OnlinePlay.Match; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens +{ + public partial class MatchmakingIntroScreen : OsuScreen + { + public override bool DisallowExternalBeatmapRulesetChanges => false; + + public override bool? ApplyModTrackAdjustments => true; + + public override bool ShowFooter => true; + + private Container introContent = null!; + + private Container titleContainer = null!; + + private bool animationBegan; + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Plum); + + [Resolved] + private MusicController musicController { get; set; } = null!; + + [Resolved] + private MatchmakingController controller { get; set; } = null!; + + public override bool AllowUserExit => !ValidForResume; + + private Sample? dateWindupSample; + private Sample? dateImpactSample; + private Sample? beatmapWindupSample; + private Sample? beatmapImpactSample; + + private SampleChannel? dateWindupChannel; + private SampleChannel? dateImpactChannel; + private SampleChannel? beatmapWindupChannel; + private SampleChannel? beatmapImpactChannel; + + private IDisposable? duckOperation; + + protected override BackgroundScreen CreateBackground() => new MatchmakingIntroBackgroundScreen(colourProvider); + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + InternalChildren = new Drawable[] + { + introContent = new Container + { + Alpha = 0f, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Shear = OsuGame.SHEAR, + Children = new Drawable[] + { + titleContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + CornerRadius = 10f, + Masking = true, + X = 10, + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background3, + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Matchmaking", + Margin = new MarginPadding { Horizontal = 10f, Vertical = 5f }, + Shear = -OsuGame.SHEAR, + Font = OsuFont.GetFont(size: 32, weight: FontWeight.Light, typeface: Typeface.TorusAlternate), + }, + } + }, + } + }, + } + } + }; + + dateWindupSample = audio.Samples.Get(@"DailyChallenge/date-windup"); + dateImpactSample = audio.Samples.Get(@"DailyChallenge/date-impact"); + beatmapWindupSample = audio.Samples.Get(@"DailyChallenge/beatmap-windup"); + beatmapImpactSample = audio.Samples.Get(@"DailyChallenge/beatmap-impact"); + } + + public override void OnEntering(ScreenTransitionEvent e) + { + base.OnEntering(e); + + this.FadeInFromZero(400, Easing.OutQuint); + + updateAnimationState(); + playDateWindupSample(); + + controller.SearchInForeground(); + } + + public override void OnSuspending(ScreenTransitionEvent e) + { + ValidForResume = false; + + this.FadeOut(800, Easing.OutQuint); + base.OnSuspending(e); + } + + private void updateAnimationState() + { + if (animationBegan) + return; + + beginAnimation(); + animationBegan = true; + } + + private void beginAnimation() + { + using (BeginDelayedSequence(200)) + { + introContent.Show(); + + titleContainer + .ScaleTo(2) + .Then() + .ScaleTo(1, 400, Easing.In); + + using (BeginDelayedSequence(150)) + { + Schedule(() => + { + playDateImpactSample(); + playBeatmapWindupSample(); + + duckOperation?.Dispose(); + duckOperation = musicController.Duck(new DuckParameters + { + RestoreDuration = 1500f, + }); + }); + + using (BeginDelayedSequence(2750)) + { + Schedule(() => + { + duckOperation?.Dispose(); + }); + } + } + + using (BeginDelayedSequence(1000)) + { + using (BeginDelayedSequence(100)) + { + titleContainer + .ScaleTo(0.4f, 400, Easing.In) + .FadeOut(500, Easing.OutQuint); + } + + using (BeginDelayedSequence(240)) + { + Schedule(() => + { + if (this.IsCurrentScreen()) + this.Push(new MatchmakingQueueScreen()); + }); + } + } + } + } + + private void playDateWindupSample() + { + dateWindupChannel = dateWindupSample?.GetChannel(); + dateWindupChannel?.Play(); + } + + private void playDateImpactSample() + { + dateImpactChannel = dateImpactSample?.GetChannel(); + dateImpactChannel?.Play(); + } + + private void playBeatmapWindupSample() + { + beatmapWindupChannel = beatmapWindupSample?.GetChannel(); + beatmapWindupChannel?.Play(); + } + + private void playBeatmapImpactSample() + { + beatmapImpactChannel = beatmapImpactSample?.GetChannel(); + beatmapImpactChannel?.Play(); + } + + protected override void Dispose(bool isDisposing) + { + resetAudio(); + base.Dispose(isDisposing); + } + + private void resetAudio() + { + dateWindupChannel?.Stop(); + dateImpactChannel?.Stop(); + beatmapWindupChannel?.Stop(); + beatmapImpactChannel?.Stop(); + duckOperation?.Dispose(); + } + + private partial class MatchmakingIntroBackgroundScreen : RoomBackgroundScreen + { + private readonly OverlayColourProvider colourProvider; + + public MatchmakingIntroBackgroundScreen(OverlayColourProvider colourProvider) + : base(null) + { + this.colourProvider = colourProvider; + } + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(new Box + { + Depth = float.MinValue, + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5.Opacity(0.6f), + }); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/MatchmakingQueueScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/MatchmakingQueueScreen.cs new file mode 100644 index 0000000000..e434ed240a --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/MatchmakingQueueScreen.cs @@ -0,0 +1,393 @@ +// 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 System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Screens; +using osu.Game.Database; +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.Online.Matchmaking; +using osu.Game.Online.Multiplayer; +using osu.Game.Overlays; +using osu.Game.Overlays.Dialog; +using osu.Game.Rulesets; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens +{ + public partial class MatchmakingQueueScreen : OsuScreen + { + public override bool ShowFooter => true; + + private Container mainContent = null!; + + private MatchmakingScreenState state; + private MatchmakingCloud cloud = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + [Resolved] + private IDialogOverlay dialogOverlay { get; set; } = null!; + + [Resolved] + private MatchmakingController controller { get; set; } = null!; + + [Resolved] + private UserLookupCache userLookupCache { get; set; } = null!; + + [Resolved] + private IBindable ruleset { get; set; } = null!; + + private readonly IBindable currentState = new Bindable(); + private CancellationTokenSource userLookupCancellation = new CancellationTokenSource(); + + protected override void LoadComplete() + { + base.LoadComplete(); + + InternalChildren = new Drawable[] + { + cloud = new MatchmakingCloud + { + Y = -100, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.6f) + }, + new MatchmakingAvatar(api.LocalUser.Value, true) + { + Y = -100, + Scale = new Vector2(3), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new Container + { + RelativePositionAxes = Axes.Y, + Y = 0.25f, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + CornerRadius = 10f, + Masking = true, + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background3, + RelativeSizeAxes = Axes.Both, + }, + mainContent = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0, + AutoSizeAxes = Axes.Both, + AutoSizeDuration = 300, + AutoSizeEasing = Easing.OutQuint, + Padding = new MarginPadding(20), + }, + } + }, + }; + + currentState.BindTo(controller.CurrentState); + currentState.BindValueChanged(s => SetState(s.NewValue)); + + client.MatchmakingLobbyStatusChanged += onMatchmakingLobbyStatusChanged; + } + + private void onMatchmakingLobbyStatusChanged(MatchmakingLobbyStatus status) => Scheduler.Add(() => + { + userLookupCancellation.Cancel(); + var cancellation = userLookupCancellation = new CancellationTokenSource(); + + userLookupCache.GetUsersAsync(status.UsersInQueue, cancellation.Token) + .ContinueWith(result => Schedule(() => + { + APIUser?[] users = result.GetResultSafely(); + if (!cancellation.IsCancellationRequested) + Users = users.OfType().ToArray(); + }), cancellation.Token); + }); + + public override void OnEntering(ScreenTransitionEvent e) + { + base.OnEntering(e); + + client.MatchmakingJoinLobby().FireAndForget(); + + using (BeginDelayedSequence(800)) + Schedule(() => SetState(currentState.Value)); + } + + public override void OnResuming(ScreenTransitionEvent e) + { + base.OnResuming(e); + + client.MatchmakingJoinLobby().FireAndForget(); + } + + public override void OnSuspending(ScreenTransitionEvent e) + { + base.OnSuspending(e); + + client.MatchmakingLeaveLobby().FireAndForget(); + } + + private bool exitConfirmed; + private bool isBackgrounded; + + public override bool OnExiting(ScreenExitEvent e) + { + if (base.OnExiting(e)) + return true; + + client.MatchmakingLeaveLobby().FireAndForget(); + + if (isBackgrounded) + return false; + + if (exitConfirmed) + { + client.MatchmakingLeaveQueue().FireAndForget(); + return false; + } + + if (currentState.Value == MatchmakingScreenState.Idle) + return false; + + if (dialogOverlay.CurrentDialog is ConfirmDialog confirmDialog) + confirmDialog.PerformOkAction(); + else + { + dialogOverlay.Push(new ConfirmDialog("Are you sure you want to leave the matchmaking queue?", () => + { + exitConfirmed = true; + if (this.IsCurrentScreen()) + this.Exit(); + })); + } + + return true; + } + + public APIUser[] Users + { + set => cloud.Users = value; + } + + public void SetState(MatchmakingScreenState newState) + { + state = newState; + + mainContent.FadeInFromZero(500, Easing.OutQuint); + mainContent.Clear(); + + switch (newState) + { + case MatchmakingScreenState.Idle: + mainContent.Child = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(20), + Children = new Drawable[] + { + new ShearedButton(200) + { + DarkerColour = colours.Blue2, + LighterColour = colours.Blue1, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Action = () => client.MatchmakingJoinQueue(new MatchmakingSettings { RulesetId = ruleset.Value.OnlineID }).FireAndForget(), + Text = "Begin queueing", + } + } + }; + break; + + case MatchmakingScreenState.Queueing: + ShearedButton sendToBackgroundButton; + + mainContent.Child = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(20), + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Waiting for a game...", + Font = OsuFont.GetFont(size: 32, weight: FontWeight.Light, typeface: Typeface.TorusAlternate), + }, + new LoadingSpinner + { + State = { Value = Visibility.Visible }, + }, + sendToBackgroundButton = new ShearedButton(200) + { + DarkerColour = colours.Orange3, + LighterColour = colours.Orange4, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Queue in background", + Action = () => + { + controller.SearchInBackground(); + isBackgrounded = true; + this.Exit(); + }, + Enabled = { Value = false }, + TooltipText = "Wait 5 seconds for this option to become available." + } + } + }; + + Scheduler.AddDelayed(() => + { + if (state != newState) + return; + + sendToBackgroundButton.Enabled.Value = true; + sendToBackgroundButton.TooltipText = "You will receive a notification when your game is ready. Make sure to watch out for it!"; + }, 5000); + break; + + case MatchmakingScreenState.PendingAccept: + mainContent.Child = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(20), + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Found a match!", + Font = OsuFont.GetFont(size: 32, weight: FontWeight.Regular, typeface: Typeface.TorusAlternate), + }, + new ShearedButton(200) + { + DarkerColour = colours.YellowDark, + LighterColour = colours.YellowLight, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Action = () => + { + client.MatchmakingAcceptInvitation().FireAndForget(); + SetState(MatchmakingScreenState.AcceptedWaitingForRoom); + }, + Text = "Join match!", + } + } + }; + break; + + case MatchmakingScreenState.AcceptedWaitingForRoom: + mainContent.Child = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(20), + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Waiting for all players...", + Font = OsuFont.GetFont(size: 32, weight: FontWeight.Light, typeface: Typeface.TorusAlternate), + }, + new LoadingSpinner + { + State = { Value = Visibility.Visible }, + }, + } + }; + break; + + case MatchmakingScreenState.InRoom: + // room received, show users and transition to next screen. + mainContent.Child = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(20), + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Good luck!", + Font = OsuFont.GetFont(size: 32, weight: FontWeight.Light, typeface: Typeface.TorusAlternate), + }, + } + }; + + using (BeginDelayedSequence(2000)) + Schedule(() => this.Push(new MatchmakingScreen(client.Room!))); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(newState), newState, null); + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + client.MatchmakingLobbyStatusChanged -= onMatchmakingLobbyStatusChanged; + } + + public enum MatchmakingScreenState + { + Idle, + Queueing, + PendingAccept, + AcceptedWaitingForRoom, + InRoom + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/MatchmakingScreenStack.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/MatchmakingScreenStack.cs new file mode 100644 index 0000000000..cba5c89385 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/MatchmakingScreenStack.cs @@ -0,0 +1,121 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Screens; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Idle; +using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Pick; +using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Results; +using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.RoundResults; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens +{ + public partial class MatchmakingScreenStack : CompositeDrawable + { + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + private ScreenStack screenStack = null!; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + Padding = new MarginPadding(10); + + InternalChild = new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] { new Dimension(), new Dimension(GridSizeMode.AutoSize) }, + Content = new Drawable[][] + { + [ + new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] { new Dimension(), new Dimension(GridSizeMode.Absolute, 20), new Dimension(GridSizeMode.AutoSize) }, + Padding = new MarginPadding { Bottom = 20 }, + Content = new Drawable?[][] + { + [ + screenStack = new ScreenStack(), + null, + new PlayerPanelList + { + Horizontal = true, + RelativeSizeAxes = Axes.Y, + Width = 250, + Scale = new Vector2(0.8f), + } + ] + } + } + ], + [ + new StageDisplay + { + RelativeSizeAxes = Axes.X + } + ] + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + screenStack.Push(new IdleScreen()); + + client.MatchRoomStateChanged += onMatchRoomStateChanged; + onMatchRoomStateChanged(client.Room!.MatchState); + } + + private void onMatchRoomStateChanged(MatchRoomState? state) => Scheduler.Add(() => + { + if (state is not MatchmakingRoomState matchmakingState) + return; + + switch (matchmakingState.Stage) + { + case MatchmakingStage.WaitingForClientsJoin: + case MatchmakingStage.RoundWarmupTime: + while (screenStack.CurrentScreen is not IdleScreen) + screenStack.Exit(); + break; + + case MatchmakingStage.UserBeatmapSelect: + screenStack.Push(new PickScreen()); + break; + + case MatchmakingStage.ServerBeatmapFinalised: + Debug.Assert(screenStack.CurrentScreen is PickScreen); + ((PickScreen)screenStack.CurrentScreen).RollFinalBeatmap(matchmakingState.CandidateItems, matchmakingState.CandidateItem); + break; + + case MatchmakingStage.ResultsDisplaying: + screenStack.Push(new RoundResultsScreen()); + break; + + case MatchmakingStage.Ended: + screenStack.Push(new ResultsScreen()); + break; + } + }); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + client.MatchRoomStateChanged -= onMatchRoomStateChanged; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/MatchmakingSubScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/MatchmakingSubScreen.cs new file mode 100644 index 0000000000..86a46546ca --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/MatchmakingSubScreen.cs @@ -0,0 +1,43 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Screens; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens +{ + public partial class MatchmakingSubScreen : Screen + { + public MatchmakingSubScreen() + { + RelativePositionAxes = Axes.X; + } + + public override void OnEntering(ScreenTransitionEvent e) + { + base.OnEntering(e); + this.MoveToX(1).MoveToX(0, 200); + } + + public override void OnSuspending(ScreenTransitionEvent e) + { + base.OnSuspending(e); + this.MoveToX(-1, 200); + } + + public override void OnResuming(ScreenTransitionEvent e) + { + base.OnResuming(e); + this.MoveToX(0, 200); + } + + public override bool OnExiting(ScreenExitEvent e) + { + if (base.OnExiting(e)) + return true; + + this.MoveToX(1, 200); + return false; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Pick/BeatmapPanel.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Pick/BeatmapPanel.cs new file mode 100644 index 0000000000..d3e5249c73 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Pick/BeatmapPanel.cs @@ -0,0 +1,192 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +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.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Pick +{ + public partial class BeatmapPanel : CompositeDrawable + { + public static readonly Vector2 SIZE = new Vector2(300, 70); + + public readonly Container OverlayLayer = new Container { RelativeSizeAxes = Axes.Both }; + + public APIBeatmap? Beatmap + { + get => beatmap; + set + { + if (beatmap?.OnlineID == value?.OnlineID) + return; + + beatmap = value; + + if (IsLoaded) + updateContent(); + } + } + + private APIBeatmap? beatmap; + + private Container content = null!; + private UpdateableOnlineBeatmapSetCover cover = null!; + + public BeatmapPanel(APIBeatmap? beatmap = null) + { + this.beatmap = beatmap; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Masking = true; + CornerRadius = 6; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background3 + }, + cover = new UpdateableOnlineBeatmapSetCover(BeatmapSetCoverType.Card, timeBeforeLoad: 0) + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = ColourInfo.GradientVertical(Color4.White.Opacity(0.1f), Color4.White.Opacity(0.3f)) + }, + content = new Container + { + RelativeSizeAxes = Axes.Both, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 6, + BorderThickness = 2, + BorderColour = ColourInfo.GradientVertical(colourProvider.Background1, colourProvider.Background1.Opacity(0)), + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + }, + }, + OverlayLayer, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + updateContent(); + FinishTransforms(true); + } + + private void updateContent() + { + foreach (var child in content.Children) + child.FadeOut(300).Expire(); + + cover.OnlineInfo = beatmap?.BeatmapSet; + + if (beatmap != null) + { + var panelContent = new BeatmapPanelContent(beatmap) + { + RelativeSizeAxes = Axes.Both, + }; + + content.Add(panelContent); + + panelContent.FadeInFromZero(300); + } + } + + private partial class BeatmapPanelContent : CompositeDrawable + { + private readonly APIBeatmap beatmap; + + public BeatmapPanelContent(APIBeatmap beatmap) + { + this.beatmap = beatmap; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new FillFlowContainer + { + Direction = FillDirection.Vertical, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Padding = new MarginPadding { Horizontal = 12 }, + Children = new Drawable[] + { + new TruncatingSpriteText + { + Text = new RomanisableString(beatmap.Metadata.TitleUnicode, beatmap.Metadata.TitleUnicode), + Font = OsuFont.Default.With(size: 19, weight: FontWeight.SemiBold), + Shadow = false, + RelativeSizeAxes = Axes.X, + }, + new TextFlowContainer(s => + { + s.Shadow = false; + s.Font = OsuFont.GetFont(size: 16, weight: FontWeight.SemiBold); + }).With(d => + { + d.RelativeSizeAxes = Axes.X; + d.AutoSizeAxes = Axes.Y; + d.AddText("by "); + d.AddText(new RomanisableString(beatmap.Metadata.ArtistUnicode, beatmap.Metadata.Artist)); + }), + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Margin = new MarginPadding { Top = 6 }, + Spacing = new Vector2(4), + Children = new Drawable[] + { + new StarRatingDisplay(new StarDifficulty(beatmap.StarRating, 0), StarRatingDisplaySize.Small) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + new TruncatingSpriteText + { + Text = beatmap.DifficultyName, + Font = OsuFont.Default.With(size: 16, weight: FontWeight.SemiBold), + Shadow = false, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + } + }, + }, + }; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Pick/BeatmapSelectionGrid.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Pick/BeatmapSelectionGrid.cs new file mode 100644 index 0000000000..8e93139e98 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Pick/BeatmapSelectionGrid.cs @@ -0,0 +1,340 @@ +// 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.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using Microsoft.Toolkit.HighPerformance; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Transforms; +using osu.Game.Graphics.Containers; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Rooms; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Pick +{ + public partial class BeatmapSelectionGrid : CompositeDrawable + { + public const double ARRANGE_DELAY = 200; + + private const double hide_duration = 800; + private const double arrange_duration = 1000; + private const double roll_duration = 4000; + private const double present_beatmap_delay = 1200; + private const float panel_spacing = 20; + + public event Action? ItemSelected; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + private readonly Dictionary panelLookup = new Dictionary(); + + private readonly PanelGridContainer panelGridContainer; + private readonly Container rollContainer; + private readonly OsuScrollContainer scroll; + + private bool allowSelection = true; + + public BeatmapSelectionGrid() + { + InternalChildren = new Drawable[] + { + scroll = new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + ScrollbarVisible = false, + Child = panelGridContainer = new PanelGridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(20), + Spacing = new Vector2(panel_spacing) + }, + }, + rollContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + const double enter_duration = 500; + + // the scroll container has a 1 frame delay until it receives the correct height for the scrollable area which leads to the scrollbar resizing awkwardly + // if we wait until the panels have entered we get to avoid having to see that and the scrollbar it will appear synchronized with the rest of the content as a bonus + Scheduler.AddDelayed(() => scroll.ScrollbarVisible = true, enter_duration); + + SchedulerAfterChildren.Add(() => + { + foreach (var panel in panelGridContainer) + { + double delay = panel.Y / 3; + + panel.FadeInAndEnterFromBelow(duration: enter_duration, delay: delay); + } + }); + } + + public void AddItem(MultiplayerPlaylistItem item) + { + var panel = panelLookup[item.ID] = new BeatmapSelectionPanel(item) + { + Size = new Vector2(300, 70), + AllowSelection = allowSelection, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Action = ItemSelected, + }; + + panelGridContainer.Add(panel); + panelGridContainer.SetLayoutPosition(panel, (float)item.StarRating); + } + + public void RemoveItem(long id) + { + if (!panelLookup.Remove(id, out var panel)) + return; + + panel.Expire(); + } + + public void SetUserSelection(APIUser user, long itemId, bool selected) + { + if (!panelLookup.TryGetValue(itemId, out var panel)) + return; + + if (selected) + panel.AddUser(user, user.Equals(api.LocalUser.Value)); + else + panel.RemoveUser(user); + } + + public void RollAndDisplayFinalBeatmap(long[] candidateItemIds, long finalItemId) + { + Debug.Assert(candidateItemIds.Length >= 1); + Debug.Assert(candidateItemIds.Contains(finalItemId)); + Debug.Assert(panelLookup.ContainsKey(finalItemId)); + Debug.Assert(candidateItemIds.All(id => panelLookup.ContainsKey(id))); + + allowSelection = false; + + TransferCandidatePanelsToRollContainer(candidateItemIds); + + if (candidateItemIds.Length == 1) + { + this.Delay(ARRANGE_DELAY) + .Schedule(() => ArrangeItemsForRollAnimation()) + .Delay(arrange_duration + present_beatmap_delay) + .Schedule(() => PresentUnanimouslyChosenBeatmap(finalItemId)); + } + else + { + this.Delay(ARRANGE_DELAY) + .Schedule(() => ArrangeItemsForRollAnimation()) + .Delay(arrange_duration) + .Schedule(() => PlayRollAnimation(finalItemId, roll_duration)) + .Delay(roll_duration + present_beatmap_delay) + .Schedule(() => PresentRolledBeatmap(finalItemId)); + } + } + + internal void TransferCandidatePanelsToRollContainer(long[] candidateItemIds, double duration = hide_duration) + { + scroll.ScrollbarVisible = false; + panelGridContainer.LayoutDisabled = true; + + var rng = new Random(); + + var remainingPanels = new List(); + + foreach (var panel in panelGridContainer.Children.ToArray()) + { + panel.AllowSelection = false; + + if (!candidateItemIds.Contains(panel.Item.ID)) + { + panel.PopOutAndExpire(duration: duration / 2, delay: rng.NextDouble() * duration / 2); + continue; + } + + remainingPanels.Add(panel); + } + + rng.Shuffle(remainingPanels.AsSpan()); + + foreach (var panel in remainingPanels) + { + var position = panel.ScreenSpaceDrawQuad.Centre; + + panelGridContainer.Remove(panel, false); + + panel.Anchor = panel.Origin = Anchor.Centre; + panel.Position = rollContainer.ToLocalSpace(position) - rollContainer.ChildSize / 2; + + rollContainer.Add(panel); + } + } + + internal void ArrangeItemsForRollAnimation(double duration = arrange_duration, double stagger = 30) + { + var positions = calculateLayoutPositionsForRollAnimation(rollContainer.Children.Count); + + Debug.Assert(positions.Length == rollContainer.Children.Count); + + for (int i = 0; i < positions.Length; i++) + { + var panel = rollContainer.Children[i]; + + var position = positions[i] * (BeatmapPanel.SIZE + new Vector2(panel_spacing)); + + panel.MoveTo(position, duration + stagger * i, new SplitEasingFunction(Easing.InCubic, Easing.OutExpo, 0.3f)); + } + } + + private static Vector2[] calculateLayoutPositionsForRollAnimation(int panelCount) + { + if (panelCount == 1) + return new[] { Vector2.Zero }; + + // goal is to get the positions arranged in clockwise order, with the top-left position being the first one + // to keep things simple the positions are first inserted in the order: right row, optional bottom center panel, left row backwards + // then the positions get shifted by 1 to move the top-left position into the first spot + + bool hasCenterPanel = panelCount % 2 == 1; + int rowCount = (panelCount + 1) / 2; + int outerRowCount = hasCenterPanel ? rowCount - 1 : rowCount; + + float yOffset = -(rowCount - 1f) / 2; + + var positions = new Vector2[panelCount]; + + for (int row = 0; row < outerRowCount; row++) + { + positions[row] = new Vector2(0.5f, row + yOffset); + } + + if (hasCenterPanel) + { + int centerIndex = panelCount / 2; + + positions[centerIndex] = new Vector2(0, outerRowCount + yOffset); + } + + for (int row = 0; row < outerRowCount; row++) + { + int index = positions.Length - 1 - row; + + positions[index] = new Vector2(-0.5f, row + yOffset); + } + + return positions.TakeLast(1).Concat(positions.SkipLast(1)).ToArray(); + } + + internal void PlayRollAnimation(long finalItem, double duration = roll_duration) + { + const int minimum_steps = 20; + + int finalItemIndex = rollContainer.Children + .Select(it => it.Item.ID) + .ToImmutableList() + .IndexOf(finalItem); + + Debug.Assert(finalItemIndex >= 0); + + int numSteps = minimum_steps; + while ((numSteps - 1) % rollContainer.Children.Count != finalItemIndex) + numSteps++; + + BeatmapSelectionPanel? lastPanel = null; + + for (int i = 0; i < numSteps; i++) + { + float progress = ((float)i) / (numSteps - 1); + + double delay = Math.Pow(progress, 2.5) * duration; + var panel = rollContainer.Children[i % rollContainer.Children.Count]; + + Scheduler.AddDelayed(() => + { + lastPanel?.HideBorder(); + panel.ShowBorder(); + + lastPanel = panel; + }, delay); + } + } + + internal void PresentRolledBeatmap(long finalItem) + { + Debug.Assert(rollContainer.Children.Any(it => it.Item.ID == finalItem)); + + foreach (var panel in rollContainer.Children) + { + if (panel.Item.ID != finalItem) + { + panel.FadeOut(200); + panel.PopOutAndExpire(easing: Easing.InQuad); + continue; + } + + // if we changed child depth without scheduling we'd change the order of the panels while iterating + Schedule(() => + { + rollContainer.ChangeChildDepth(panel, float.MinValue); + + panel.ShowBorder(); + panel.MoveTo(Vector2.Zero, 1000, Easing.OutExpo) + .ScaleTo(1.5f, 1000, Easing.OutExpo); + }); + } + } + + internal void PresentUnanimouslyChosenBeatmap(long finalItem) + { + // TODO: display special animation in this case + + PresentRolledBeatmap(finalItem); + } + + private partial class PanelGridContainer : FillFlowContainer + { + public bool LayoutDisabled; + + protected override IEnumerable ComputeLayoutPositions() + { + if (LayoutDisabled) + return FlowingChildren.Select(c => c.Position); + + return base.ComputeLayoutPositions(); + } + } + + private readonly struct SplitEasingFunction(DefaultEasingFunction easeIn, DefaultEasingFunction easeOut, float ratio) : IEasingFunction + { + public SplitEasingFunction(Easing easeIn, Easing easeOut, float ratio = 0.5f) + : this(new DefaultEasingFunction(easeIn), new DefaultEasingFunction(easeOut), ratio) + { + } + + public double ApplyEasing(double time) + { + if (time < ratio) + return easeIn.ApplyEasing(time / ratio) * ratio; + + return double.Lerp(ratio, 1, easeOut.ApplyEasing((time - ratio) / (1 - ratio))); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Pick/BeatmapSelectionOverlay.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Pick/BeatmapSelectionOverlay.cs new file mode 100644 index 0000000000..3f3fda32d8 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Pick/BeatmapSelectionOverlay.cs @@ -0,0 +1,139 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.API.Requests.Responses; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Pick +{ + public partial class BeatmapSelectionOverlay : CompositeDrawable + { + private readonly Dictionary avatars = new Dictionary(); + + private readonly Container avatarContainer; + + public new Axes AutoSizeAxes + { + get => base.AutoSizeAxes; + set => base.AutoSizeAxes = value; + } + + public new MarginPadding Padding + { + get => base.Padding; + set => base.Padding = value; + } + + public BeatmapSelectionOverlay() + { + InternalChild = avatarContainer = new Container(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + avatarContainer.AutoSizeAxes = AutoSizeAxes; + avatarContainer.RelativeSizeAxes = RelativeSizeAxes; + } + + public bool AddUser(APIUser user, bool isOwnUser) + { + if (avatars.ContainsKey(user.Id)) + return false; + + var avatar = new SelectionAvatar(user, isOwnUser) + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + }; + + avatarContainer.Add(avatars[user.Id] = avatar); + + updateLayout(); + + avatar.FinishTransforms(); + + return true; + } + + public bool RemoveUser(int id) + { + if (!avatars.Remove(id, out var avatar)) + return false; + + avatar.PopOutAndExpire(); + avatarContainer.ChangeChildDepth(avatar, float.MaxValue); + + updateLayout(); + + return true; + } + + private void updateLayout() + { + const double stagger = 30; + const float spacing = 4; + + double delay = 0; + float x = 0; + + for (int i = avatarContainer.Count - 1; i >= 0; i--) + { + var avatar = avatarContainer[i]; + + if (avatar.Expired) + continue; + + avatar.Delay(delay).MoveToX(x, 500, Easing.OutElasticQuarter); + + x -= avatar.LayoutSize.X + spacing; + + delay += stagger; + } + } + + public partial class SelectionAvatar : CompositeDrawable + { + public bool Expired { get; private set; } + + private readonly Container content; + + public SelectionAvatar(APIUser user, bool isOwnUser) + { + Size = new Vector2(30); + + InternalChildren = new Drawable[] + { + content = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Child = new MatchmakingAvatar(user, isOwnUser) + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + content.ScaleTo(0) + .ScaleTo(1, 500, Easing.OutElasticHalf) + .FadeIn(200); + } + + public void PopOutAndExpire() + { + content.ScaleTo(0, 400, Easing.OutExpo); + + this.FadeOut(100).Expire(); + Expired = true; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Pick/BeatmapSelectionPanel.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Pick/BeatmapSelectionPanel.cs new file mode 100644 index 0000000000..029bf48e30 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Pick/BeatmapSelectionPanel.cs @@ -0,0 +1,213 @@ +// 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 osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Database; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Rooms; +using osuTK.Graphics; +using osuTK.Input; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Pick +{ + public partial class BeatmapSelectionPanel : Container + { + private const float corner_radius = 6; + private const float border_width = 3; + + public readonly MultiplayerPlaylistItem Item; + + private readonly Container scaleContainer; + private readonly BeatmapPanel beatmapPanel; + private readonly BeatmapSelectionOverlay selectionOverlay; + private readonly Container border; + private readonly Box flash; + private readonly Container shadow; + + public bool AllowSelection; + + public Action? Action; + + [Resolved] + private BeatmapLookupCache beatmapLookupCache { get; set; } = null!; + + public override bool PropagatePositionalInputSubTree => AllowSelection; + + public BeatmapSelectionPanel(MultiplayerPlaylistItem item) + { + Item = item; + Size = BeatmapPanel.SIZE; + + InternalChildren = new Drawable[] + { + scaleContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + shadow = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(4), + Y = 8, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 7, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + Alpha = 0.15f, + } + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(-border_width), + Child = border = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = corner_radius + border_width, + Alpha = 0, + Child = new Box { RelativeSizeAxes = Axes.Both }, + } + }, + beatmapPanel = new BeatmapPanel + { + RelativeSizeAxes = Axes.Both, + OverlayLayer = + { + Children = new[] + { + flash = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + }, + } + } + }, + selectionOverlay = new BeatmapSelectionOverlay + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = 10 }, + Origin = Anchor.CentreLeft, + }, + } + }, + new HoverClickSounds(), + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + beatmapLookupCache.GetBeatmapAsync(Item.BeatmapID).ContinueWith(b => Schedule(() => + { + var beatmap = b.GetResultSafely()!; + + beatmap.StarRating = Item.StarRating; + + beatmapPanel.Beatmap = beatmap; + })); + } + + public bool AddUser(APIUser user, bool isOwnUser = false) => selectionOverlay.AddUser(user, isOwnUser); + + public bool RemoveUser(int userId) => selectionOverlay.RemoveUser(userId); + + public bool RemoveUser(APIUser user) => RemoveUser(user.Id); + + protected override bool OnHover(HoverEvent e) + { + flash.FadeTo(0.2f, 50) + .Then() + .FadeTo(0.1f, 300); + + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + + flash.FadeOut(200); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (e.Button == MouseButton.Left) + { + scaleContainer.ScaleTo(0.95f, 400, Easing.OutExpo); + shadow.MoveToY(4, 400, Easing.OutExpo) + .TransformTo(nameof(Padding), new MarginPadding(2), 400, Easing.OutExpo); + return true; + } + + return base.OnMouseDown(e); + } + + protected override void OnMouseUp(MouseUpEvent e) + { + base.OnMouseUp(e); + + if (e.Button == MouseButton.Left) + { + scaleContainer.ScaleTo(1f, 500, Easing.OutElasticHalf); + shadow.MoveToY(8, 500, Easing.OutElasticHalf) + .TransformTo(nameof(Padding), new MarginPadding(4), 400, Easing.OutExpo); + } + } + + protected override bool OnClick(ClickEvent e) + { + Action?.Invoke(Item); + + flash.FadeTo(0.5f, 50) + .Then() + .FadeTo(0.1f, 400); + + return true; + } + + public void ShowBorder() => border.Show(); + + public void HideBorder() => border.Hide(); + + public void FadeInAndEnterFromBelow(double duration = 500, double delay = 0, float distance = 200) + { + scaleContainer + .FadeOut() + .MoveToY(distance) + .Delay(delay) + .FadeIn(duration / 2) + .MoveToY(0, duration, Easing.OutExpo); + } + + public void PopOutAndExpire(double duration = 400, double delay = 0, Easing easing = Easing.InCubic) + { + AllowSelection = false; + + scaleContainer.Delay(delay) + .ScaleTo(0, duration, easing) + .FadeOut(duration); + + this.Delay(delay + duration).FadeOut().Expire(); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Pick/PickScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Pick/PickScreen.cs new file mode 100644 index 0000000000..73e2188273 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Pick/PickScreen.cs @@ -0,0 +1,83 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Pick +{ + public partial class PickScreen : OsuScreen + { + private BeatmapSelectionGrid selectionGrid = null!; + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new Container + { + RelativeSizeAxes = Axes.Both, + Child = selectionGrid = new BeatmapSelectionGrid + { + RelativeSizeAxes = Axes.Both, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + client.ItemAdded += onItemAdded; + + foreach (var item in client.Room!.Playlist) + onItemAdded(item); + + selectionGrid.ItemSelected += item => client.MatchmakingToggleSelection(item.ID); + + client.MatchmakingItemSelected += onItemSelected; + client.MatchmakingItemDeselected += onItemDeselected; + } + + private void onItemAdded(MultiplayerPlaylistItem item) => Scheduler.Add(() => + { + if (item.Expired) + return; + + selectionGrid.AddItem(item); + }); + + private void onItemSelected(int userId, long itemId) + { + var user = client.Room!.Users.First(it => it.UserID == userId).User!; + selectionGrid.SetUserSelection(user, itemId, true); + } + + private void onItemDeselected(int userId, long itemId) + { + var user = client.Room!.Users.First(it => it.UserID == userId).User!; + selectionGrid.SetUserSelection(user, itemId, false); + } + + public void RollFinalBeatmap(long[] candidateItems, long finalItem) => selectionGrid.RollAndDisplayFinalBeatmap(candidateItems, finalItem); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + { + client.ItemAdded -= onItemAdded; + client.MatchmakingItemSelected -= onItemSelected; + client.MatchmakingItemDeselected -= onItemDeselected; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Results/ResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Results/ResultsScreen.cs new file mode 100644 index 0000000000..50b34f7555 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Results/ResultsScreen.cs @@ -0,0 +1,345 @@ +// 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.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Idle; +using osu.Game.Utils; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Results +{ + public partial class ResultsScreen : MatchmakingSubScreen + { + private const float grid_spacing = 5; + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + private OsuSpriteText placementText = null!; + private FillFlowContainer userStatistics = null!; + private FillFlowContainer roomStatistics = null!; + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = + [ + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, grid_spacing), + new Dimension(), + new Dimension(GridSizeMode.Absolute, grid_spacing), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, 75) + ], + Content = new Drawable[]?[] + { + [ + new FillFlowContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(grid_spacing), + Children = new[] + { + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = "Placement", + Font = OsuFont.Default.With(size: 12) + }, + placementText = new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Font = OsuFont.Default.With(size: 72), + UseFullGlyphHeight = false + } + } + } + ], + null, + [ + new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = + [ + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, grid_spacing), + new Dimension() + ], + Content = new Drawable?[][] + { + [ + new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(grid_spacing), + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = "Breakdown", + Font = OsuFont.Default.With(size: 12) + }, + userStatistics = new FillFlowContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(grid_spacing) + } + } + }, + null, + new PlayerPanelList + { + RelativeSizeAxes = Axes.Both + } + ] + } + } + ], + null, + [ + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(grid_spacing), + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = "Statistics", + Font = OsuFont.Default.With(size: 12) + }, + roomStatistics = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(grid_spacing) + } + } + }, + ], + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + client.MatchRoomStateChanged += onRoomStateChanged; + + onRoomStateChanged(client.Room?.MatchState); + } + + private void onRoomStateChanged(MatchRoomState? state) => Scheduler.Add(() => + { + if (state is not MatchmakingRoomState matchmakingState || matchmakingState.Stage != MatchmakingStage.Ended) + return; + + populateUserStatistics(matchmakingState); + populateRoomStatistics(matchmakingState); + }); + + private void populateUserStatistics(MatchmakingRoomState state) + { + userStatistics.Clear(); + + if (state.Users[client.LocalUser!.UserID].Rounds.Count == 0) + { + placementText.Text = "-"; + addStatistic("No rounds played"); + return; + } + + int overallPlacement = state.Users[client.LocalUser!.UserID].Placement; + int overallPoints = state.Users[client.LocalUser!.UserID].Points; + int bestPlacement = state.Users[client.LocalUser!.UserID].Rounds.Min(r => r.Placement); + var accuracyPlacement = state.Users.Select(u => (user: u, avgAcc: u.Rounds.Select(r => r.Accuracy).DefaultIfEmpty(0).Average())) + .OrderByDescending(t => t.avgAcc) + .Select((t, i) => (info: t, index: i)) + .Single(t => t.info.user.UserId == client.LocalUser!.UserID); + + placementText.Text = $"#{state.Users[client.LocalUser!.UserID].Placement}"; + addStatistic($"#{overallPlacement} overall ({overallPoints}pts)"); + addStatistic($"#{bestPlacement} best placement"); + addStatistic($"#{accuracyPlacement.index + 1} accuracy ({accuracyPlacement.info.avgAcc.FormatAccuracy()})"); + + void addStatistic(string text) + { + userStatistics.Add(new UserStatisticPanel(text) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre + }); + } + } + + private void populateRoomStatistics(MatchmakingRoomState state) + { + roomStatistics.Clear(); + + long maxScore = long.MinValue; + int maxScoreUserId = 0; + + double maxAccuracy = double.MinValue; + int maxAccuracyUserId = 0; + + int maxCombo = int.MinValue; + int maxComboUserId = 0; + + long maxBonusScore = 0; + int maxBonusScoreUserId = 0; + + long largestScoreDifference = long.MinValue; + int largestScoreDifferenceUserId = 0; + + long smallestScoreDifference = long.MaxValue; + int smallestScoreDifferenceUserId = 0; + + for (int round = 1; round <= state.CurrentRound; round++) + { + long roundHighestScore = long.MinValue; + int roundHighestScoreUserId = 0; + + long roundLowestScore = long.MaxValue; + + foreach (MatchmakingUser user in state.Users) + { + if (!user.Rounds.RoundsDictionary.TryGetValue(round, out MatchmakingRound? mmRound)) + continue; + + if (mmRound.TotalScore > maxScore) + { + maxScore = mmRound.TotalScore; + maxScoreUserId = user.UserId; + } + + if (mmRound.Accuracy > maxAccuracy) + { + maxAccuracy = mmRound.Accuracy; + maxAccuracyUserId = user.UserId; + } + + if (mmRound.MaxCombo > maxCombo) + { + maxCombo = mmRound.MaxCombo; + maxComboUserId = user.UserId; + } + + if (mmRound.TotalScore > roundHighestScore) + { + roundHighestScore = mmRound.TotalScore; + roundHighestScoreUserId = user.UserId; + } + + if (mmRound.TotalScore < roundLowestScore) + roundLowestScore = mmRound.TotalScore; + } + + long roundScoreDifference = roundHighestScore - roundLowestScore; + + if (roundScoreDifference > 0 && roundScoreDifference > largestScoreDifference) + { + largestScoreDifference = roundScoreDifference; + largestScoreDifferenceUserId = roundHighestScoreUserId; + } + + if (roundScoreDifference > 0 && roundScoreDifference < smallestScoreDifference) + { + smallestScoreDifference = roundScoreDifference; + smallestScoreDifferenceUserId = roundHighestScoreUserId; + } + } + + foreach (MatchmakingUser user in state.Users) + { + int userBonusScore = 0; + + foreach (MatchmakingRound round in user.Rounds) + { + userBonusScore += round.Statistics.TryGetValue(HitResult.LargeBonus, out int bonus) ? bonus * 5 : 0; + userBonusScore += round.Statistics.TryGetValue(HitResult.SmallBonus, out bonus) ? bonus : 0; + } + + if (userBonusScore > maxBonusScore) + { + maxBonusScore = userBonusScore; + maxBonusScoreUserId = user.UserId; + } + } + + // Highest score - highest score across all rounds. + addStatistic(maxScoreUserId, "Highest score"); + + // Most accurate - highest accuracy across all rounds. + addStatistic(maxAccuracyUserId, "Most accurate"); + + // Most combo - highest combo across all rounds. + addStatistic(maxComboUserId, "Most combo"); + + // Most bonus - most bonus score across all rounds. + if (maxBonusScoreUserId > 0) + addStatistic(maxBonusScoreUserId, "Most bonus"); + + // Most clutch - smallest victory in any round. + if (smallestScoreDifferenceUserId > 0) + addStatistic(smallestScoreDifferenceUserId, "Most clutch"); + + // Best finish - largest victory in any round. + if (largestScoreDifferenceUserId > 0) + addStatistic(largestScoreDifferenceUserId, "Best finish"); + + void addStatistic(int userId, string text) + { + MultiplayerRoomUser? user = client.Room?.Users.FirstOrDefault(u => u.UserID == userId); + + if (user == null) + throw new InvalidOperationException($"User not found in room: {userId}"); + + roomStatistics.Add(new RoomStatisticPanel(text, user) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre + }); + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + client.MatchRoomStateChanged -= onRoomStateChanged; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Results/RoomStatisticPanel.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Results/RoomStatisticPanel.cs new file mode 100644 index 0000000000..00c61113ab --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Results/RoomStatisticPanel.cs @@ -0,0 +1,52 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.Multiplayer; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Results +{ + public partial class RoomStatisticPanel : CompositeDrawable + { + private readonly Color4 backgroundColour = Color4.SaddleBrown; + + private readonly string text; + private readonly MultiplayerRoomUser user; + + public RoomStatisticPanel(string text, MultiplayerRoomUser user) + { + this.text = text; + this.user = user; + + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new CircularContainer + { + AutoSizeAxes = Axes.Both, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = backgroundColour + }, + new OsuSpriteText + { + Margin = new MarginPadding(10), + Text = $"{text}: {user.User?.Username}" + } + } + }; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Results/UserStatisticPanel.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Results/UserStatisticPanel.cs new file mode 100644 index 0000000000..3a39fc714d --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/Results/UserStatisticPanel.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.Sprites; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens.Results +{ + public partial class UserStatisticPanel : CompositeDrawable + { + private readonly Color4 backgroundColour = Color4.SaddleBrown; + + private readonly string text; + + public UserStatisticPanel(string text) + { + this.text = text; + + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new CircularContainer + { + AutoSizeAxes = Axes.Both, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = backgroundColour + }, + new OsuSpriteText + { + Margin = new MarginPadding(10), + Text = text + } + } + }; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/RoundResults/RoundResultsScorePanel.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/RoundResults/RoundResultsScorePanel.cs new file mode 100644 index 0000000000..ad30c19c02 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/RoundResults/RoundResultsScorePanel.cs @@ -0,0 +1,36 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens.RoundResults +{ + internal partial class RoundResultsScorePanel : CompositeDrawable + { + public RoundResultsScorePanel(ScoreInfo score) + { + AutoSizeAxes = Axes.Both; + InternalChild = new InstantSizingScorePanel(score); + } + + public override bool PropagateNonPositionalInputSubTree => false; + public override bool PropagatePositionalInputSubTree => false; + + private partial class InstantSizingScorePanel : ScorePanel + { + public InstantSizingScorePanel(ScoreInfo score, bool isNewLocalScore = false) + : base(score, isNewLocalScore) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + FinishTransforms(true); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/RoundResults/RoundResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/RoundResults/RoundResultsScreen.cs new file mode 100644 index 0000000000..d7837e96c6 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Screens/RoundResults/RoundResultsScreen.cs @@ -0,0 +1,181 @@ +// 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 System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Logging; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Models; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking.Screens.RoundResults +{ + public partial class RoundResultsScreen : MatchmakingSubScreen + { + private const int panel_spacing = 5; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + [Resolved] + private BeatmapLookupCache beatmapLookupCache { get; set; } = null!; + + [Resolved] + private ScoreManager scoreManager { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + private AutoScrollContainer scrollContainer = null!; + private LoadingSpinner loadingSpinner = null!; + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + scrollContainer = new AutoScrollContainer + { + RelativeSizeAxes = Axes.Both + }, + loadingSpinner = new LoadingSpinner + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + loadingSpinner.Show(); + + queryScores().FireAndForget(); + } + + private async Task queryScores() + { + try + { + if (client.Room == null) + return; + + Task beatmapTask = beatmapLookupCache.GetBeatmapAsync(client.Room.CurrentPlaylistItem.BeatmapID); + TaskCompletionSource> scoreTask = new TaskCompletionSource>(); + + var request = new IndexPlaylistScoresRequest(client.Room.RoomID, client.Room.Settings.PlaylistItemId); + request.Success += req => scoreTask.SetResult(req.Scores); + request.Failure += e => scoreTask.SetException(e); + api.Queue(request); + + await Task.WhenAll(beatmapTask, scoreTask.Task).ConfigureAwait(false); + + APIBeatmap? apiBeatmap = beatmapTask.GetResultSafely(); + List apiScores = scoreTask.Task.GetResultSafely(); + + if (apiBeatmap == null) + return; + + // Reference: PlaylistItemResultsScreen + setScores(apiScores.Select(s => s.CreateScoreInfo(scoreManager, rulesets, new BeatmapInfo + { + Difficulty = new BeatmapDifficulty(apiBeatmap.Difficulty), + Metadata = + { + Artist = apiBeatmap.Metadata.Artist, + Title = apiBeatmap.Metadata.Title, + Author = new RealmUser + { + Username = apiBeatmap.Metadata.Author.Username, + OnlineID = apiBeatmap.Metadata.Author.OnlineID, + } + }, + DifficultyName = apiBeatmap.DifficultyName, + StarRating = apiBeatmap.StarRating, + Length = apiBeatmap.Length, + BPM = apiBeatmap.BPM + })).ToArray()); + } + catch (Exception e) + { + Logger.Error(e, "Failed to load scores for playlist item."); + throw; + } + finally + { + Scheduler.Add(() => loadingSpinner.Hide()); + } + } + + private void setScores(ScoreInfo[] scores) => Scheduler.Add(() => + { + Container panels; + + scrollContainer.Child = panels = new Container + { + RelativeSizeAxes = Axes.Y, + Width = scores.Length * (ScorePanel.CONTRACTED_WIDTH + panel_spacing), + ChildrenEnumerable = scores.Select(s => new RoundResultsScorePanel(s) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft + }) + }; + + for (int i = 0; i < panels.Count; i++) + { + panels[i].MoveToX(panels.DrawWidth * 2) + .Delay(i * 100) + .MoveToX((ScorePanel.CONTRACTED_WIDTH + panel_spacing) * i, 500, Easing.OutQuint); + } + }); + + private partial class AutoScrollContainer : UserTrackingScrollContainer + { + private const float initial_offset = -0.5f; + private const double scroll_duration = 20000; + + private double? scrollStartTime; + + public AutoScrollContainer() + : base(Direction.Horizontal) + { + } + + protected override void Update() + { + base.Update(); + + if (!UserScrolling && Children.Count > 0) + { + scrollStartTime ??= Time.Current; + + double scrollOffset = (Time.Current - scrollStartTime.Value) / scroll_duration; + + if (scrollOffset < 1) + ScrollTo(DrawWidth * (initial_offset + scrollOffset), false); + } + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/StageBubble.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/StageBubble.cs new file mode 100644 index 0000000000..281374ba71 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/StageBubble.cs @@ -0,0 +1,157 @@ +// 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 osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.Matchmaking; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking +{ + internal partial class StageBubble : CompositeDrawable + { + private readonly Color4 backgroundColour = Color4.Salmon; + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + private readonly MatchmakingStage stage; + private readonly LocalisableString displayText; + private Drawable progressBar = null!; + + private DateTimeOffset countdownStartTime; + private DateTimeOffset countdownEndTime; + + public StageBubble(MatchmakingStage stage, LocalisableString displayText) + { + this.stage = stage; + this.displayText = displayText; + + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new CircularContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Masking = true, + Children = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = backgroundColour.Darken(0.2f) + }, + progressBar = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = backgroundColour + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = displayText, + Padding = new MarginPadding(10) + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + client.MatchRoomStateChanged += onMatchRoomStateChanged; + client.CountdownStarted += onCountdownStarted; + client.CountdownStopped += onCountdownStopped; + + if (client.Room != null) + { + onMatchRoomStateChanged(client.Room.MatchState); + foreach (var countdown in client.Room.ActiveCountdowns) + onCountdownStarted(countdown); + } + } + + protected override void Update() + { + base.Update(); + + TimeSpan duration = countdownEndTime - countdownStartTime; + + if (duration.TotalMilliseconds == 0) + progressBar.Width = 0; + else + { + TimeSpan elapsed = DateTimeOffset.Now - countdownStartTime; + progressBar.Width = (float)(elapsed.TotalMilliseconds / duration.TotalMilliseconds); + } + } + + private void onMatchRoomStateChanged(MatchRoomState? state) => Scheduler.Add(() => + { + if (state is not MatchmakingRoomState matchmakingState) + return; + + if (matchmakingState.Stage == MatchmakingStage.RoundWarmupTime) + { + countdownStartTime = countdownEndTime = DateTimeOffset.Now; + activate(); + } + }); + + private void onCountdownStarted(MultiplayerCountdown countdown) => Scheduler.Add(() => + { + if (countdown is not MatchmakingStageCountdown matchmakingStatusCountdown || matchmakingStatusCountdown.Stage != stage) + return; + + countdownStartTime = DateTimeOffset.Now; + countdownEndTime = countdownStartTime + countdown.TimeRemaining; + activate(); + }); + + private void onCountdownStopped(MultiplayerCountdown countdown) => Scheduler.Add(() => + { + if (countdown is not MatchmakingStageCountdown matchmakingStatusCountdown || matchmakingStatusCountdown.Stage != stage) + return; + + countdownEndTime = DateTimeOffset.Now; + deactivate(); + }); + + private void activate() + { + this.FadeTo(1, 200); + } + + private void deactivate() + { + this.FadeTo(0.5f, 200); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + { + client.MatchRoomStateChanged -= onMatchRoomStateChanged; + client.CountdownStarted -= onCountdownStarted; + client.CountdownStopped -= onCountdownStopped; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/StageDisplay.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/StageDisplay.cs new file mode 100644 index 0000000000..1f426ec8e6 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/StageDisplay.cs @@ -0,0 +1,91 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking +{ + public partial class StageDisplay : CompositeDrawable + { + public static readonly (MatchmakingStage status, LocalisableString text)[] DISPLAYED_STAGES = + [ + (MatchmakingStage.RoundWarmupTime, "Next Round"), + (MatchmakingStage.UserBeatmapSelect, "Beatmap Selection"), + (MatchmakingStage.GameplayWarmupTime, "Get Ready"), + (MatchmakingStage.ResultsDisplaying, "Results"), + (MatchmakingStage.Ended, "Match End") + ]; + + public StageDisplay() + { + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load() + { + List columnDimensions = new List(); + List columnContent = new List(); + + for (int i = 0; i < DISPLAYED_STAGES.Length; i++) + { + if (i > 0) + { + columnDimensions.Add(new Dimension(GridSizeMode.AutoSize)); + columnContent.Add(new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(16), + Icon = FontAwesome.Solid.ArrowRight, + Margin = new MarginPadding { Horizontal = 10 } + }); + } + + columnDimensions.Add(new Dimension()); + columnContent.Add(new StageBubble(DISPLAYED_STAGES[i].status, DISPLAYED_STAGES[i].text) + { + RelativeSizeAxes = Axes.X + }); + } + + InternalChild = new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + RowDimensions = + [ + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize) + ], + Content = new Drawable[][] + { + [ + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + ColumnDimensions = columnDimensions.ToArray(), + RowDimensions = [new Dimension(GridSizeMode.AutoSize)], + Content = new[] { columnContent.ToArray() } + } + ], + [ + new StageText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre + } + ] + } + }; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/StageText.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/StageText.cs new file mode 100644 index 0000000000..ab2627474e --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/StageText.cs @@ -0,0 +1,84 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking; + +namespace osu.Game.Screens.OnlinePlay.Matchmaking +{ + public partial class StageText : CompositeDrawable + { + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + private OsuSpriteText text = null!; + + public StageText() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = text = new OsuSpriteText + { + Height = 16, + Font = OsuFont.Default, + AlwaysPresent = true, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + client.MatchRoomStateChanged += onMatchRoomStateChanged; + onMatchRoomStateChanged(client.Room!.MatchState); + } + + private void onMatchRoomStateChanged(MatchRoomState? state) => Scheduler.Add(() => + { + if (state is not MatchmakingRoomState matchmakingState) + return; + + text.Text = getTextForStatus(matchmakingState.Stage); + }); + + private LocalisableString getTextForStatus(MatchmakingStage status) + { + switch (status) + { + case MatchmakingStage.WaitingForClientsJoin: + return "Players are joining the match..."; + + case MatchmakingStage.WaitingForClientsBeatmapDownload: + return "Players are downloading the beatmap..."; + + case MatchmakingStage.Gameplay: + return "Game is in progress..."; + + case MatchmakingStage.Ended: + return "Thanks for playing! The match will close shortly."; + + default: + return string.Empty; + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + client.MatchRoomStateChanged -= onMatchRoomStateChanged; + } + } +} diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index 806dc63aed..0944626edf 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -14,6 +14,7 @@ using osu.Game.Beatmaps; using osu.Game.Online; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Matchmaking; using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.Countdown; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; @@ -73,6 +74,8 @@ namespace osu.Game.Tests.Visual.Multiplayer private long lastPlaylistItemId; private int lastCountdownId; + private readonly Dictionary matchmakingUserPicks = new Dictionary(); + private readonly TestRoomRequestsHandler apiRequestHandler; public TestMultiplayerClient(TestRoomRequestsHandler? apiRequestHandler = null) @@ -409,22 +412,43 @@ namespace osu.Game.Tests.Visual.Multiplayer break; case StartMatchCountdownRequest startCountdown: - ServerRoom.ActiveCountdowns.Add(new MatchStartCountdown - { - ID = ++lastCountdownId, - TimeRemaining = startCountdown.Duration - }); - - await ((IMultiplayerClient)this).MatchEvent(clone(new CountdownStartedEvent(ServerRoom.ActiveCountdowns[^1]))).ConfigureAwait(false); + await StartCountdown(new MatchStartCountdown { TimeRemaining = startCountdown.Duration }).ConfigureAwait(false); break; case StopCountdownRequest stopCountdown: - ServerRoom.ActiveCountdowns.Remove(ServerRoom.ActiveCountdowns.First(c => c.ID == stopCountdown.ID)); - await ((IMultiplayerClient)this).MatchEvent(clone(new CountdownStoppedEvent(stopCountdown.ID))).ConfigureAwait(false); + await StopCountdown(ServerRoom.ActiveCountdowns.First(c => c.ID == stopCountdown.ID)).ConfigureAwait(false); break; } } + public async Task StartCountdown(MultiplayerCountdown countdown) + { + countdown.ID = ++lastCountdownId; + countdown = clone(countdown); + + Debug.Assert(ServerRoom != null); + Debug.Assert(LocalUser != null); + + if (countdown.IsExclusive) + { + MultiplayerCountdown? existingCountdown = ServerRoom.ActiveCountdowns.FirstOrDefault(c => c.GetType() == countdown.GetType()); + if (existingCountdown != null) + await StopCountdown(existingCountdown).ConfigureAwait(false); + } + + ServerRoom.ActiveCountdowns.Add(countdown); + await ((IMultiplayerClient)this).MatchEvent(clone(new CountdownStartedEvent(ServerRoom.ActiveCountdowns[^1]))).ConfigureAwait(false); + } + + public async Task StopCountdown(MultiplayerCountdown countdown) + { + Debug.Assert(ServerRoom != null); + Debug.Assert(LocalUser != null); + + ServerRoom.ActiveCountdowns.Remove(ServerRoom.ActiveCountdowns.First(c => c.ID == countdown.ID)); + await ((IMultiplayerClient)this).MatchEvent(clone(new CountdownStoppedEvent(countdown.ID))).ConfigureAwait(false); + } + public override Task StartMatch() { Debug.Assert(ServerRoom != null); @@ -718,6 +742,66 @@ namespace osu.Game.Tests.Visual.Multiplayer return Task.CompletedTask; } + public async Task ChangeMatchRoomState(MatchRoomState state) + { + Debug.Assert(ServerRoom != null); + + ServerRoom.MatchState = state; + await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom.MatchState)).ConfigureAwait(false); + } + + public override Task MatchmakingJoinLobby() + { + return Task.CompletedTask; + } + + public override Task MatchmakingLeaveLobby() + { + return Task.CompletedTask; + } + + public override async Task MatchmakingJoinQueue(MatchmakingSettings settings) + { + await ((IMultiplayerClient)this).MatchmakingQueueJoined().ConfigureAwait(false); + await ((IMultiplayerClient)this).MatchmakingQueueStatusChanged(new MatchmakingQueueStatus.Searching()).ConfigureAwait(false); + } + + public override async Task MatchmakingLeaveQueue() + { + await ((IMultiplayerClient)this).MatchmakingQueueLeft().ConfigureAwait(false); + } + + public override Task MatchmakingAcceptInvitation() + { + return Task.CompletedTask; + } + + public override Task MatchmakingDeclineInvitation() + { + return Task.CompletedTask; + } + + public override Task MatchmakingToggleSelection(long playlistItemId) + => MatchmakingToggleUserSelection(api.LocalUser.Value.OnlineID, playlistItemId); + + public override Task MatchmakingSkipToNextStage() + => Task.CompletedTask; + + public async Task MatchmakingToggleUserSelection(int userId, long playlistItemId) + { + if (matchmakingUserPicks.TryGetValue(userId, out long existingId)) + { + if (existingId == playlistItemId) + return; + + await ((IMultiplayerClient)this).MatchmakingItemDeselected(clone(userId), clone(existingId)).ConfigureAwait(false); + } + + matchmakingUserPicks[userId] = playlistItemId; + + await ((IMultiplayerClient)this).MatchmakingItemSelected(clone(userId), clone(playlistItemId)).ConfigureAwait(false); + } + #region API Room Handling public IReadOnlyList ServerSideRooms