From 286b3d9f5b46e203165dbe31ea6ed31e6bc1c810 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 4 Apr 2025 10:31:06 +0900 Subject: [PATCH 1/7] Rewrite match subscreen to use full online state --- .../Multiplayer/TestSceneHostOnlyQueueMode.cs | 2 +- .../Multiplayer/TestSceneMultiplayer.cs | 15 +- .../TestSceneMultiplayerMatchSubScreen.cs | 10 +- .../Online/Multiplayer/MultiplayerClient.cs | 22 +- .../Screens/OnlinePlay/Match/RoomSubScreen.cs | 544 --------- .../Match/MultiplayerMatchSettingsOverlay.cs | 8 +- .../Multiplayer/MultiplayerMatchSubScreen.cs | 1065 ++++++++++++----- osu.Game/Users/UserActivity.cs | 7 + 8 files changed, 806 insertions(+), 867 deletions(-) delete mode 100644 osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneHostOnlyQueueMode.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneHostOnlyQueueMode.cs index 55c9e8142f..7d3d30b9f9 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneHostOnlyQueueMode.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneHostOnlyQueueMode.cs @@ -105,7 +105,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("select other beatmap", () => ((Screens.Select.SongSelect)CurrentSubScreen).FinaliseSelection(otherBeatmap = beatmap())); AddUntilStep("wait for return to match", () => CurrentSubScreen is MultiplayerMatchSubScreen); - AddUntilStep("selected item is new beatmap", () => (CurrentSubScreen as MultiplayerMatchSubScreen)?.SelectedItem.Value?.Beatmap.OnlineID == otherBeatmap.OnlineID); + AddUntilStep("selected item is new beatmap", () => Beatmap.Value.BeatmapInfo.OnlineID == otherBeatmap.OnlineID); } private void addItem(Func beatmap) diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs index 8fc0250d04..8066ea1b94 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs @@ -443,7 +443,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("Enter song select", () => { var currentSubScreen = ((Screens.OnlinePlay.Multiplayer.Multiplayer)multiplayerComponents.CurrentScreen).CurrentSubScreen; - ((MultiplayerMatchSubScreen)currentSubScreen).OpenSongSelection(item); + ((MultiplayerMatchSubScreen)currentSubScreen).ShowSongSelect(item); }); AddUntilStep("wait for song select", () => this.ChildrenOfType().FirstOrDefault()?.BeatmapSetsLoaded == true); @@ -484,7 +484,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("Enter song select", () => { var currentSubScreen = ((Screens.OnlinePlay.Multiplayer.Multiplayer)multiplayerComponents.CurrentScreen).CurrentSubScreen; - ((MultiplayerMatchSubScreen)currentSubScreen).OpenSongSelection(item); + ((MultiplayerMatchSubScreen)currentSubScreen).ShowSongSelect(item); }); AddUntilStep("wait for song select", () => this.ChildrenOfType().FirstOrDefault()?.BeatmapSetsLoaded == true); @@ -525,7 +525,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("Enter song select", () => { var currentSubScreen = ((Screens.OnlinePlay.Multiplayer.Multiplayer)multiplayerComponents.CurrentScreen).CurrentSubScreen; - ((MultiplayerMatchSubScreen)currentSubScreen).OpenSongSelection(item); + ((MultiplayerMatchSubScreen)currentSubScreen).ShowSongSelect(item); }); AddUntilStep("wait for song select", () => this.ChildrenOfType().FirstOrDefault()?.BeatmapSetsLoaded == true); @@ -657,7 +657,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("invoke on back button", () => multiplayerComponents.OnBackButton()); - AddAssert("mod overlay is hidden", () => this.ChildrenOfType().Single().UserModsSelectOverlay.State.Value == Visibility.Hidden); + AddAssert("mod overlay is hidden", () => this.ChildrenOfType().Single().State.Value == Visibility.Hidden); AddAssert("dialog overlay is hidden", () => DialogOverlay.State.Value == Visibility.Hidden); @@ -828,11 +828,8 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for room open", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); AddUntilStep("wait for join", () => multiplayerClient.RoomJoined); - AddAssert("local room has correct settings", () => - { - var localRoom = this.ChildrenOfType().Single().Room; - return localRoom.Name == multiplayerClient.ServerSideRooms[0].Name && localRoom.Playlist.Single().ID == 2; - }); + AddAssert("local room has correct name", () => this.ChildrenOfType().Single().Room.Name, () => Is.EqualTo(multiplayerClient.ServerSideRooms[0].Name)); + AddAssert("local room has correct playlist", () => this.ChildrenOfType().Single().Items.Single().ID, () => Is.EqualTo(2)); } [Test] diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs index 14e6a67d3a..660f84b4d6 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs @@ -186,7 +186,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("mod select contents loaded", () => this.ChildrenOfType().Any() && this.ChildrenOfType().All(col => col.IsLoaded && col.ItemsLoaded)); AddUntilStep("mod select contains only double time mod", - () => this.ChildrenOfType().Single().UserModsSelectOverlay + () => this.ChildrenOfType().Single() .ChildrenOfType() .SingleOrDefault(panel => panel.Visible)?.Mod is OsuModDoubleTime); } @@ -212,7 +212,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("press toggle mod select key", () => InputManager.Key(Key.F1)); - AddUntilStep("mod select shown", () => this.ChildrenOfType().Single().UserModsSelectOverlay.State.Value == Visibility.Visible); + AddUntilStep("mod select shown", () => this.ChildrenOfType().Single().State.Value == Visibility.Visible); } [Test] @@ -235,7 +235,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("press toggle mod select key", () => InputManager.Key(Key.F1)); AddWaitStep("wait some", 3); - AddAssert("mod select not shown", () => this.ChildrenOfType().Single().UserModsSelectOverlay.State.Value == Visibility.Hidden); + AddAssert("mod select not shown", () => this.ChildrenOfType().Single().State.Value == Visibility.Hidden); } [Test] @@ -307,10 +307,10 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("mod select shows unranked", () => this.ChildrenOfType().Single().Ranked.Value == false); AddAssert("score multiplier = 1.20", () => this.ChildrenOfType().Single().ModMultiplier.Value, () => Is.EqualTo(1.2).Within(0.01)); - AddStep("select flashlight", () => screen.UserModsSelectOverlay.ChildrenOfType().Single(m => m.Mod is ModFlashlight).TriggerClick()); + AddStep("select flashlight", () => this.ChildrenOfType().Single().ChildrenOfType().Single(m => m.Mod is ModFlashlight).TriggerClick()); AddAssert("score multiplier = 1.35", () => this.ChildrenOfType().Single().ModMultiplier.Value, () => Is.EqualTo(1.35).Within(0.01)); - AddStep("change flashlight setting", () => ((OsuModFlashlight)screen.UserModsSelectOverlay.SelectedMods.Value.Single()).FollowDelay.Value = 1200); + AddStep("change flashlight setting", () => ((OsuModFlashlight)this.ChildrenOfType().Single().SelectedMods.Value.Single()).FollowDelay.Value = 1200); AddAssert("score multiplier = 1.20", () => this.ChildrenOfType().Single().ModMultiplier.Value, () => Is.EqualTo(1.2).Within(0.01)); } diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index 57aaf68853..92fc8a3dcf 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -36,6 +36,21 @@ namespace osu.Game.Online.Multiplayer /// public virtual event Action? RoomUpdated; + /// + /// Invoked when a user's local style is changed. + /// + public event Action? UserStyleChanged; + + /// + /// Invoked when a user's local mods are changed. + /// + public event Action? UserModsChanged; + + /// + /// Invoked when the room's settings are changed. + /// + public event Action? SettingsChanged; + /// /// Invoked when a new user joins the room. /// @@ -710,7 +725,7 @@ namespace osu.Game.Online.Multiplayer return Task.CompletedTask; } - public Task UserStyleChanged(int userId, int? beatmapId, int? rulesetId) + Task IMultiplayerClient.UserStyleChanged(int userId, int? beatmapId, int? rulesetId) { Scheduler.Add(() => { @@ -723,13 +738,14 @@ namespace osu.Game.Online.Multiplayer user.BeatmapId = beatmapId; user.RulesetId = rulesetId; + UserStyleChanged?.Invoke(user); RoomUpdated?.Invoke(); }, false); return Task.CompletedTask; } - public Task UserModsChanged(int userId, IEnumerable mods) + Task IMultiplayerClient.UserModsChanged(int userId, IEnumerable mods) { Scheduler.Add(() => { @@ -741,6 +757,7 @@ namespace osu.Game.Online.Multiplayer user.Mods = mods; + UserModsChanged?.Invoke(user); RoomUpdated?.Invoke(); }, false); @@ -907,6 +924,7 @@ namespace osu.Game.Online.Multiplayer APIRoom.CurrentPlaylistItem = APIRoom.Playlist.Single(item => item.ID == settings.PlaylistItemId); APIRoom.AutoSkip = Room.Settings.AutoSkip; + SettingsChanged?.Invoke(settings); RoomUpdated?.Invoke(); } diff --git a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs deleted file mode 100644 index c73a36617d..0000000000 --- a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs +++ /dev/null @@ -1,544 +0,0 @@ -// 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.ComponentModel; -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Audio; -using osu.Framework.Audio.Sample; -using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Screens; -using osu.Game.Audio; -using osu.Game.Beatmaps; -using osu.Game.Graphics.Cursor; -using osu.Game.Online.API; -using osu.Game.Online.Rooms; -using osu.Game.Overlays; -using osu.Game.Overlays.Mods; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; -using osu.Game.Screens.Menu; -using osu.Game.Screens.OnlinePlay.Match.Components; -using osu.Game.Screens.OnlinePlay.Multiplayer; -using osu.Game.Screens.OnlinePlay.Multiplayer.Match; -using osu.Game.Utils; -using Container = osu.Framework.Graphics.Containers.Container; - -namespace osu.Game.Screens.OnlinePlay.Match -{ - [Cached(typeof(IPreviewTrackOwner))] - public abstract partial class RoomSubScreen : OnlinePlaySubScreen, IPreviewTrackOwner - { - public readonly Bindable SelectedItem = new Bindable(); - - public override bool? ApplyModTrackAdjustments => true; - - protected override BackgroundScreen CreateBackground() => new MultiplayerRoomBackgroundScreen(); - - public override bool DisallowExternalBeatmapRulesetChanges => true; - - /// - /// A container that provides controls for selection of user mods. - /// This will be shown/hidden automatically when applicable. - /// - protected Drawable UserModsSection = null!; - - /// - /// A container that provides controls for selection of the user style. - /// This will be shown/hidden automatically when applicable. - /// - protected Drawable UserStyleSection = null!; - - /// - /// A container that will display the user's style. - /// - protected Container UserStyleDisplayContainer = null!; - - private Sample? sampleStart; - - [Resolved(CanBeNull = true)] - private IOverlayManager? overlayManager { get; set; } - - [Resolved] - private MusicController music { get; set; } = null!; - - [Resolved] - private BeatmapManager beatmapManager { get; set; } = null!; - - [Resolved] - protected RulesetStore Rulesets { get; private set; } = null!; - - [Resolved] - protected IAPIProvider API { get; private set; } = null!; - - [Resolved(canBeNull: true)] - protected OnlinePlayScreen? ParentScreen { get; private set; } - - [Resolved] - private PreviewTrackManager previewTrackManager { get; set; } = null!; - - [Resolved(canBeNull: true)] - protected IDialogOverlay? DialogOverlay { get; private set; } - - [Cached(typeof(OnlinePlayBeatmapAvailabilityTracker))] - private readonly MultiplayerBeatmapAvailabilityTracker beatmapAvailabilityTracker = new MultiplayerBeatmapAvailabilityTracker(); - - protected IBindable BeatmapAvailability => beatmapAvailabilityTracker.Availability; - - public readonly Room Room; - - internal ModSelectOverlay UserModsSelectOverlay { get; private set; } = null!; - - private IDisposable? userModsSelectOverlayRegistration; - private RoomSettingsOverlay settingsOverlay = null!; - private Drawable mainContent = null!; - - /// - /// Creates a new . - /// - /// The . - protected RoomSubScreen(Room room) - { - Room = room; - Padding = new MarginPadding { Top = Header.HEIGHT }; - } - - [BackgroundDependencyLoader] - private void load(AudioManager audio) - { - sampleStart = audio.Samples.Get(@"SongSelect/confirm-selection"); - - InternalChild = new PopoverContainer - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - beatmapAvailabilityTracker, - new MultiplayerRoomSounds(), - new GridContainer - { - RelativeSizeAxes = Axes.Both, - RowDimensions = new[] - { - new Dimension(), - new Dimension(GridSizeMode.Absolute, 50) - }, - Content = new[] - { - // Padded main content (drawable room + main content) - new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding - { - Horizontal = WaveOverlayContainer.WIDTH_PADDING, - Bottom = 30 - }, - Children = new[] - { - mainContent = new GridContainer - { - RelativeSizeAxes = Axes.Both, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.Absolute, 10) - }, - Content = new[] - { - new Drawable[] - { - new OsuContextMenuContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Child = new MultiplayerRoomPanel(Room) - { - OnEdit = () => settingsOverlay.Show(), - } - } - }, - null, - new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.Both, - Children = new[] - { - new Container - { - RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = 10, - Child = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4Extensions.FromHex(@"3e3a44") // Temporary. - }, - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding(20), - Child = CreateMainContent(), - }, - new Container - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - }, - } - } - } - } - }, - new Container - { - RelativeSizeAxes = Axes.Both, - // Resolves 1px masking errors between the settings overlay and the room panel. - Padding = new MarginPadding(-1), - Child = settingsOverlay = CreateRoomSettingsOverlay(Room) - } - }, - }, - }, - // Footer - new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4Extensions.FromHex(@"28242d") // Temporary. - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding(5), - Child = CreateFooter() - }, - } - } - } - } - } - } - }; - - LoadComponent(UserModsSelectOverlay = new MultiplayerUserModSelectOverlay()); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - SelectedItem.BindValueChanged(_ => updateSpecifics()); - - beatmapAvailabilityTracker.Availability.BindValueChanged(_ => updateSpecifics()); - - userModsSelectOverlayRegistration = overlayManager?.RegisterBlockingOverlay(UserModsSelectOverlay); - - Room.PropertyChanged += onRoomPropertyChanged; - updateSetupState(); - } - - private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) - { - if (e.PropertyName == nameof(Room.RoomID)) - updateSetupState(); - } - - private void updateSetupState() - { - if (Room.RoomID == null) - { - // A new room is being created. - // The main content should be hidden until the settings overlay is hidden, signaling the room is ready to be displayed. - mainContent.Hide(); - settingsOverlay.Show(); - } - else - { - mainContent.Show(); - settingsOverlay.Hide(); - } - } - - protected virtual bool IsConnected => API.State.Value == APIState.Online; - - public override bool OnBackButton() - { - if (Room.RoomID == null) - { - if (!ensureExitConfirmed()) - return true; - - settingsOverlay.Hide(); - return base.OnBackButton(); - } - - if (UserModsSelectOverlay.State.Value == Visibility.Visible) - { - UserModsSelectOverlay.Hide(); - return true; - } - - if (settingsOverlay.State.Value == Visibility.Visible) - { - settingsOverlay.Hide(); - return true; - } - - return base.OnBackButton(); - } - - protected void ShowUserModSelect() => UserModsSelectOverlay.Show(); - - public override void OnEntering(ScreenTransitionEvent e) - { - base.OnEntering(e); - beginHandlingTrack(); - } - - public override void OnSuspending(ScreenTransitionEvent e) - { - // Should be a noop in most cases, but let's ensure beyond doubt that the beatmap is in a correct state. - updateSpecifics(); - - onLeaving(); - base.OnSuspending(e); - } - - public override void OnResuming(ScreenTransitionEvent e) - { - base.OnResuming(e); - - updateSpecifics(); - - beginHandlingTrack(); - } - - protected bool ExitConfirmed { get; private set; } - - public override bool OnExiting(ScreenExitEvent e) - { - if (!ensureExitConfirmed()) - return true; - - if (Room.RoomID != null) - PartRoom(); - - Mods.Value = Array.Empty(); - - onLeaving(); - - return base.OnExiting(e); - } - - /// - /// Parts from the current room. - /// - protected abstract void PartRoom(); - - private bool ensureExitConfirmed() - { - if (ExitConfirmed) - return true; - - if (!IsConnected) - return true; - - bool hasUnsavedChanges = Room.RoomID == null && Room.Playlist.Count > 0; - - if (DialogOverlay == null || !hasUnsavedChanges) - return true; - - // if the dialog is already displayed, block exiting until the user explicitly makes a decision. - if (DialogOverlay.CurrentDialog is ConfirmDiscardChangesDialog discardChangesDialog) - { - discardChangesDialog.Flash(); - return false; - } - - DialogOverlay.Push(new ConfirmDiscardChangesDialog(() => - { - ExitConfirmed = true; - settingsOverlay.Hide(); - this.Exit(); - })); - - return false; - } - - protected void StartPlay() - { - if (SelectedItem.Value is not PlaylistItem item) - return; - - item = item.With( - ruleset: GetGameplayRuleset().OnlineID, - beatmap: new Optional(GetGameplayBeatmap())); - - // User may be at song select or otherwise when the host starts gameplay. - // Ensure that they first return to this screen, else global bindables (beatmap etc.) may be in a bad state. - if (!this.IsCurrentScreen()) - { - this.MakeCurrent(); - - Schedule(StartPlay); - return; - } - - sampleStart?.Play(); - - // fallback is to allow this class to operate when there is no parent OnlineScreen (testing purposes). - var targetScreen = (Screen?)ParentScreen ?? this; - - targetScreen.Push(CreateGameplayScreen(item)); - } - - /// - /// Creates the gameplay screen to be entered. - /// - /// The playlist item about to be played. - /// The screen to enter. - protected abstract Screen CreateGameplayScreen(PlaylistItem selectedItem); - - private void updateSpecifics() - { - if (!this.IsCurrentScreen() || SelectedItem.Value is not PlaylistItem item) - return; - - var rulesetInstance = GetGameplayRuleset().CreateInstance(); - - Mod[] allowedMods = item.Freestyle - ? rulesetInstance.AllMods.OfType().Where(m => ModUtils.IsValidFreeModForMatchType(m, Room.Type)).ToArray() - : item.AllowedMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); - - // Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info - int beatmapId = GetGameplayBeatmap().OnlineID; - var localBeatmap = beatmapManager.QueryBeatmap($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}", beatmapId); - Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap); - UserModsSelectOverlay.Beatmap.Value = Beatmap.Value; - - Mods.Value = GetGameplayMods().Select(m => m.ToMod(rulesetInstance)).ToArray(); - Ruleset.Value = GetGameplayRuleset(); - - if (allowedMods.Length > 0) - UserModsSection.Show(); - else - { - UserModsSection.Hide(); - UserModsSelectOverlay.Hide(); - } - - if (item.Freestyle) - { - UserStyleSection.Show(); - - PlaylistItem gameplayItem = SelectedItem.Value.With(ruleset: GetGameplayRuleset().OnlineID, beatmap: new Optional(GetGameplayBeatmap())); - PlaylistItem? currentItem = UserStyleDisplayContainer.SingleOrDefault()?.Item; - - if (gameplayItem.Equals(currentItem)) - return; - - UserStyleDisplayContainer.Child = new DrawableRoomPlaylistItem(gameplayItem, true) - { - AllowReordering = false, - AllowEditing = true, - RequestEdit = _ => OpenStyleSelection() - }; - } - else - UserStyleSection.Hide(); - } - - protected virtual APIMod[] GetGameplayMods() => SelectedItem.Value!.RequiredMods; - - protected virtual RulesetInfo GetGameplayRuleset() => Rulesets.GetRuleset(SelectedItem.Value!.RulesetID)!; - - protected virtual IBeatmapInfo GetGameplayBeatmap() => SelectedItem.Value!.Beatmap; - - protected abstract void OpenStyleSelection(); - - private void beginHandlingTrack() - { - Beatmap.BindValueChanged(applyLoopingToTrack, true); - } - - private void onLeaving() - { - UserModsSelectOverlay.Hide(); - endHandlingTrack(); - - previewTrackManager.StopAnyPlaying(this); - } - - private void endHandlingTrack() - { - Beatmap.ValueChanged -= applyLoopingToTrack; - cancelTrackLooping(); - } - - private void applyLoopingToTrack(ValueChangedEvent? _ = null) - { - if (!this.IsCurrentScreen()) - return; - - var track = Beatmap.Value?.Track; - - if (track != null) - { - Beatmap.Value!.PrepareTrackForPreview(true); - music.EnsurePlayingSomething(); - } - } - - private void cancelTrackLooping() - { - var track = Beatmap.Value?.Track; - - if (track != null) - track.Looping = false; - } - - /// - /// Creates the main centred content. - /// - protected abstract Drawable CreateMainContent(); - - /// - /// Creates the footer content. - /// - protected abstract Drawable CreateFooter(); - - /// - /// Creates the room settings overlay. - /// - /// The room to change the settings of. - protected abstract RoomSettingsOverlay CreateRoomSettingsOverlay(Room room); - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - userModsSelectOverlayRegistration?.Dispose(); - Room.PropertyChanged -= onRoomPropertyChanged; - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs index 42d240c60e..018d36069e 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs @@ -12,7 +12,6 @@ using osu.Framework.Extensions.ExceptionExtensions; 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.Containers; using osu.Game.Graphics.Sprites; @@ -35,7 +34,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match [Resolved] private OngoingOperationTracker ongoingOperationTracker { get; set; } = null!; - private readonly BindableWithCurrent selectedItem = new BindableWithCurrent(); private MatchSettings settings = null!; public MultiplayerMatchSettingsOverlay(Room room) @@ -274,11 +272,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match RelativeSizeAxes = Axes.X, Height = 40, Text = "Select beatmap", - Action = () => - { - if (matchSubScreen.IsCurrentScreen()) - matchSubScreen.Push(new MultiplayerMatchSongSelect(matchSubScreen.Room)); - } + Action = () => matchSubScreen.ShowSongSelect() } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index 0cc033907f..cff823c969 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -1,15 +1,21 @@ // 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.Diagnostics; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Bindables; +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.Logging; using osu.Framework.Screens; +using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Graphics.Cursor; using osu.Game.Online; @@ -20,6 +26,7 @@ using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Overlays.Dialog; using osu.Game.Rulesets; +using osu.Game.Screens.Menu; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Match; using osu.Game.Screens.OnlinePlay.Match.Components; @@ -28,258 +35,777 @@ using osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist; using osu.Game.Screens.OnlinePlay.Multiplayer.Participants; using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; using osu.Game.Users; +using osu.Game.Utils; using osuTK; +using Container = osu.Framework.Graphics.Containers.Container; using ParticipantsList = osu.Game.Screens.OnlinePlay.Multiplayer.Participants.ParticipantsList; namespace osu.Game.Screens.OnlinePlay.Multiplayer { [Cached] - public partial class MultiplayerMatchSubScreen : RoomSubScreen, IHandlePresentBeatmap + public partial class MultiplayerMatchSubScreen : OnlinePlaySubScreen, IPreviewTrackOwner, IHandlePresentBeatmap { + /// + /// Footer height. + /// + private const float footer_height = 50; + + /// + /// Padding between content and footer. + /// + private const float footer_padding = 30; + + /// + /// Internal padding of the content. + /// + private const float content_padding = 20; + + /// + /// Padding between columns of the content. + /// + private const float column_padding = 10; + + /// + /// Padding between rows of the content. + /// + private const float row_padding = 10; + public override string Title { get; } public override string ShortTitle => "room"; + public override bool? ApplyModTrackAdjustments => true; + + public override bool DisallowExternalBeatmapRulesetChanges => true; + + /// + /// Whether the user has confirmed they want to exit this screen in the presence of unsaved changes. + /// + protected bool ExitConfirmed { get; private set; } + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private AudioManager audio { get; set; } = null!; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + [Resolved] + private PreviewTrackManager previewTrackManager { get; set; } = null!; + + [Resolved] + private MusicController music { get; set; } = null!; + + [Resolved] + private OnlinePlayScreen? parentScreen { get; set; } + + [Resolved] + private IOverlayManager? overlayManager { get; set; } + + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } + [Resolved] private MultiplayerClient client { get; set; } = null!; - [Resolved(canBeNull: true)] + [Resolved] private OsuGame? game { get; set; } - private AddItemButton addItemButton = null!; + [Cached(typeof(OnlinePlayBeatmapAvailabilityTracker))] + private readonly OnlinePlayBeatmapAvailabilityTracker beatmapAvailabilityTracker = new MultiplayerBeatmapAvailabilityTracker(); + + private readonly Room room; + + private Drawable roomContent = null!; + private MultiplayerMatchSettingsOverlay settingsOverlay = null!; + + private FillFlowContainer userModsSection = null!; + private MultiplayerUserModSelectOverlay userModsSelectOverlay = null!; + + private FillFlowContainer userStyleSection = null!; + private Container userStyleDisplayContainer = null!; + + private Sample? sampleStart; + private IDisposable? userModsSelectOverlayRegistration; + + private long lastPlaylistItemId; + private bool isRoomJoined; public MultiplayerMatchSubScreen(Room room) - : base(room) { + this.room = room; + Title = room.RoomID == null ? "New room" : room.Name; Activity.Value = new UserActivity.InLobby(room); + + Padding = new MarginPadding { Top = Header.HEIGHT }; + } + + [BackgroundDependencyLoader] + private void load() + { + sampleStart = audio.Samples.Get(@"SongSelect/confirm-selection"); + + InternalChild = new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + beatmapAvailabilityTracker, + new MultiplayerRoomSounds(), + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Horizontal = WaveOverlayContainer.WIDTH_PADDING, + Bottom = footer_height + footer_padding + }, + Children = new[] + { + roomContent = new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, row_padding), + }, + Content = new[] + { + new Drawable[] + { + new MultiplayerRoomPanel(room) + { + OnEdit = () => settingsOverlay.Show() + } + }, + null, + 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 GridContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(content_padding), + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, column_padding), + new Dimension(), + new Dimension(GridSizeMode.Absolute, column_padding), + new Dimension(), + }, + Content = new[] + { + new Drawable?[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new Drawable[] + { + new ParticipantsListHeader() + }, + new Drawable[] + { + new ParticipantsList + { + RelativeSizeAxes = Axes.Both + }, + } + } + }, + null, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, 5), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] + { + new OverlinedHeader("Beatmap queue") + }, + new Drawable[] + { + new AddItemButton + { + RelativeSizeAxes = Axes.X, + Height = 40, + Text = "Add item", + Action = () => ShowSongSelect() + }, + }, + null, + new Drawable[] + { + new MultiplayerPlaylist + { + RelativeSizeAxes = Axes.Both, + RequestEdit = ShowSongSelect + } + }, + new Drawable[] + { + userModsSection = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Top = 10 }, + Alpha = 0, + Children = new Drawable[] + { + new OverlinedHeader("Extra mods"), + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + new UserModSelectButton + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Width = 90, + Height = 30, + Text = "Select", + Action = showUserModSelect, + }, + new MultiplayerUserModDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Scale = new Vector2(0.8f), + }, + } + }, + } + } + }, + new Drawable[] + { + userStyleSection = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Top = 10 }, + Alpha = 0, + Children = new Drawable[] + { + new OverlinedHeader("Difficulty"), + userStyleDisplayContainer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + } + } + }, + }, + }, + }, + null, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new Drawable[] + { + new OverlinedHeader("Chat") + }, + new Drawable[] + { + new MatchChatDisplay(room) + { + RelativeSizeAxes = Axes.Both + } + } + } + } + } + } + } + } + } + } + } + }, + settingsOverlay = new MultiplayerMatchSettingsOverlay(room) + } + }, + new Container + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + Height = footer_height, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex(@"28242d") // Temporary. + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(5), + Child = new MultiplayerMatchFooter() + } + } + } + } + }; + + LoadComponent(userModsSelectOverlay = new MultiplayerUserModSelectOverlay + { + Beatmap = { BindTarget = Beatmap } + }); } protected override void LoadComplete() { base.LoadComplete(); - BeatmapAvailability.BindValueChanged(updateBeatmapAvailability, true); + userModsSelectOverlayRegistration = overlayManager?.RegisterBlockingOverlay(userModsSelectOverlay); - client.LoadRequested += onLoadRequested; client.RoomUpdated += onRoomUpdated; + client.SettingsChanged += onSettingsChanged; + client.ItemChanged += onItemChanged; + client.UserStyleChanged += onUserStyleChanged; + client.UserModsChanged += onUserModsChanged; + client.LoadRequested += onLoadRequested; - if (!client.IsConnected.Value) - handleRoomLost(); + beatmapAvailabilityTracker.Availability.BindValueChanged(onBeatmapAvailabilityChanged, true); + + onRoomUpdated(); + updateGameplayState(); + updateUserActivity(); } - protected override bool IsConnected => base.IsConnected && client.IsConnected.Value; - - protected override Drawable CreateMainContent() => new Container + /// + /// Responds to changes in the active room to adjust the visibility of the settings and main content. + /// Only the settings overlay is visible while the room isn't created, and only the main content is visible after creation. + /// + private void onRoomUpdated() => Scheduler.AddOnce(() => { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Horizontal = 5, Vertical = 10 }, - Child = new OsuContextMenuContainer + bool newIsRoomJoined = client.Room != null; + + if (newIsRoomJoined) { - RelativeSizeAxes = Axes.Both, - Child = new GridContainer - { - RelativeSizeAxes = Axes.Both, - ColumnDimensions = new[] - { - new Dimension(), - new Dimension(GridSizeMode.Absolute, 10), - new Dimension(), - new Dimension(GridSizeMode.Absolute, 10), - new Dimension(), - }, - Content = new[] - { - new Drawable?[] - { - new GridContainer - { - RelativeSizeAxes = Axes.Both, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize) - }, - Content = new[] - { - new Drawable[] { new ParticipantsListHeader() }, - new Drawable[] - { - new ParticipantsList - { - RelativeSizeAxes = Axes.Both - }, - } - } - }, - null, - new GridContainer - { - RelativeSizeAxes = Axes.Both, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.Absolute, 5), - new Dimension(), - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.AutoSize), - }, - Content = new[] - { - new Drawable[] { new OverlinedHeader("Beatmap queue") }, - new Drawable[] - { - addItemButton = new AddItemButton - { - RelativeSizeAxes = Axes.X, - Height = 40, - Text = "Add item", - Action = () => OpenSongSelection() - }, - }, - null, - new Drawable[] - { - new MultiplayerPlaylist - { - RelativeSizeAxes = Axes.Both, - RequestEdit = OpenSongSelection - } - }, - new[] - { - UserModsSection = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Margin = new MarginPadding { Top = 10 }, - Alpha = 0, - Children = new Drawable[] - { - new OverlinedHeader("Extra mods"), - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(10, 0), - Children = new Drawable[] - { - new UserModSelectButton - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Width = 90, - Height = 30, - Text = "Select", - Action = ShowUserModSelect, - }, - new MultiplayerUserModDisplay - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Scale = new Vector2(0.8f), - }, - } - }, - } - } - }, - new[] - { - UserStyleSection = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Margin = new MarginPadding { Top = 10 }, - Alpha = 0, - Children = new Drawable[] - { - new OverlinedHeader("Difficulty"), - UserStyleDisplayContainer = new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y - } - } - }, - }, - }, - }, - null, - new GridContainer - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] { new OverlinedHeader("Chat") }, - new Drawable[] { new MatchChatDisplay(Room) { RelativeSizeAxes = Axes.Both } } - }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - } - }, - } - } - } + roomContent.Show(); + settingsOverlay.Hide(); } - }; + else if (isRoomJoined) + { + Logger.Log($"{this} exiting due to loss of room or connection"); + + if (this.IsCurrentScreen()) + this.Exit(); + else + ValidForResume = false; + } + else + { + Debug.Assert(!isRoomJoined && !newIsRoomJoined); + + // A new room is being created. + // The main content should be hidden until the settings overlay is hidden, signaling the room is ready to be displayed. + roomContent.Hide(); + settingsOverlay.Show(); + } + + isRoomJoined = newIsRoomJoined; + }); /// - /// Opens the song selection screen to add or edit an item. + /// Responds to changes in the room's settings to update the gameplay state and local user's activity. + /// + private void onSettingsChanged(MultiplayerRoomSettings settings) + { + if (settings.PlaylistItemId != lastPlaylistItemId) + { + updateGameplayState(); + lastPlaylistItemId = settings.PlaylistItemId; + } + + updateUserActivity(); + } + + /// + /// Responds to changes in the active playlist item to update the gameplay state. + /// + private void onItemChanged(MultiplayerPlaylistItem item) + { + if (item.ID == client.Room?.Settings.PlaylistItemId) + updateGameplayState(); + } + + /// + /// Responds to changes in the local user's style to update the gameplay state. + /// + private void onUserStyleChanged(MultiplayerRoomUser user) + { + if (user.Equals(client.LocalUser)) + updateGameplayState(); + } + + /// + /// Responds to changes in the local user's mods style to update the gameplay state. + /// + private void onUserModsChanged(MultiplayerRoomUser user) + { + if (user.Equals(client.LocalUser)) + updateGameplayState(); + } + + /// + /// Responds to notifications from the server that a gameplay session is ready to attempt to start the gameplay session. + /// + private void onLoadRequested() + { + if (client.Room == null || client.LocalUser == null) + return; + + // In the case of spectating, IMultiplayerClient.LoadRequested can be fired while the game is still spectating a previous session. + // For now, we want to game to switch to the new game so need to request exiting from the play screen. + if (!parentScreen.IsCurrentScreen()) + { + parentScreen.MakeCurrent(); + Schedule(onLoadRequested); + return; + } + + if (!this.IsCurrentScreen()) + { + this.MakeCurrent(); + Schedule(onLoadRequested); + return; + } + + if (beatmapAvailabilityTracker.Availability.Value.State != DownloadState.LocallyAvailable) + return; + + sampleStart?.Play(); + + int[] userIds = client.CurrentMatchPlayingUserIds.ToArray(); + MultiplayerRoomUser[] users = userIds.Select(id => client.Room.Users.First(u => u.UserID == id)).ToArray(); + + // fallback is to allow this class to operate when there is no parent OnlineScreen (testing purposes). + var targetScreen = (Screen?)parentScreen ?? this; + + switch (client.LocalUser.State) + { + case MultiplayerUserState.Spectating: + targetScreen.Push(new MultiSpectatorScreen(room, users.Take(PlayerGrid.MAX_PLAYERS).ToArray())); + break; + + default: + targetScreen.Push(new MultiplayerPlayerLoader(() => new MultiplayerPlayer(room, new PlaylistItem(client.Room.CurrentPlaylistItem), users))); + break; + } + } + + /// + /// Responds to changes in the local user's beatmap availability to notify the server and prepare the gameplay session. + /// + private void onBeatmapAvailabilityChanged(ValueChangedEvent e) + { + if (client.Room == null || client.LocalUser == null) + return; + + client.ChangeBeatmapAvailability(e.NewValue).FireAndForget(); + + switch (e.NewValue.State) + { + case DownloadState.LocallyAvailable: + updateGameplayState(); + + // Optimistically enter spectator if the match is in progress while spectating. + if (client.LocalUser.State == MultiplayerUserState.Spectating && (client.Room.State == MultiplayerRoomState.WaitingForLoad || client.Room.State == MultiplayerRoomState.Playing)) + onLoadRequested(); + break; + + case DownloadState.NotDownloaded: + updateGameplayState(); + + if (client.LocalUser.State == MultiplayerUserState.Ready) + client.ChangeState(MultiplayerUserState.Idle); + break; + } + } + + /// + /// Updates the local user's activity to publish their presence in the room. + /// + private void updateUserActivity() + { + if (client.Room == null) + return; + + if (Activity.Value is not UserActivity.InLobby existing || existing.RoomName != client.Room.Settings.Name) + Activity.Value = new UserActivity.InLobby(client.Room); + } + + /// + /// Updates the global beatmap/ruleset/mods in preparation for a new gameplay session. + /// + private void updateGameplayState() + { + if (client.Room == null || client.LocalUser == null) + return; + + MultiplayerPlaylistItem item = client.Room.CurrentPlaylistItem; + int gameplayBeatmapId = client.LocalUser.BeatmapId ?? item.BeatmapID; + int gameplayRulesetId = client.LocalUser.RulesetId ?? item.RulesetID; + + RulesetInfo ruleset = rulesets.GetRuleset(gameplayRulesetId)!; + 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(b => b.OnlineID == gameplayBeatmapId); + Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap); + Ruleset.Value = ruleset; + Mods.Value = client.LocalUser.Mods.Concat(item.RequiredMods).Select(m => m.ToMod(rulesetInstance)).ToArray(); + + bool freemods = item.Freestyle || item.AllowedMods.Any(); + bool freestyle = item.Freestyle; + + if (freemods) + userModsSection.Show(); + else + { + userModsSection.Hide(); + userModsSelectOverlay.Hide(); + } + + if (freestyle) + { + userStyleSection.Show(); + + PlaylistItem apiItem = new PlaylistItem(item).With(beatmap: new Optional(new APIBeatmap { OnlineID = gameplayBeatmapId }), ruleset: gameplayRulesetId); + + if (!apiItem.Equals(userStyleDisplayContainer.SingleOrDefault()?.Item)) + { + userStyleDisplayContainer.Child = new DrawableRoomPlaylistItem(apiItem, true) + { + AllowReordering = false, + AllowEditing = true, + RequestEdit = _ => showUserStyleSelect() + }; + } + } + else + userStyleSection.Hide(); + } + + /// + /// Shows the song selection screen to add or edit an item. /// /// An optional playlist item to edit. If null, a new item will be added instead. - internal void OpenSongSelection(PlaylistItem? itemToEdit = null) + public void ShowSongSelect(PlaylistItem? itemToEdit = null) { if (!this.IsCurrentScreen()) return; - this.Push(new MultiplayerMatchSongSelect(Room, itemToEdit)); + this.Push(new MultiplayerMatchSongSelect(room, itemToEdit)); } - protected override void OpenStyleSelection() + /// + /// Shows the user mod selection. + /// + private void showUserModSelect() { - if (!this.IsCurrentScreen() || SelectedItem.Value is not PlaylistItem item) + if (!this.IsCurrentScreen()) return; - this.Push(new MultiplayerMatchFreestyleSelect(Room, item)); + userModsSelectOverlay.Show(); } - protected override Drawable CreateFooter() => new MultiplayerMatchFooter(); - - protected override RoomSettingsOverlay CreateRoomSettingsOverlay(Room room) => new MultiplayerMatchSettingsOverlay(room); - - protected override APIMod[] GetGameplayMods() + /// + /// Shows the user style selection. + /// + private void showUserStyleSelect() { - // Using the room's reported status makes the server authoritative. - return client.LocalUser?.Mods != null ? client.LocalUser.Mods.Concat(SelectedItem.Value!.RequiredMods).ToArray() : base.GetGameplayMods(); + if (!this.IsCurrentScreen() || client.Room == null || client.LocalUser == null) + return; + + MultiplayerPlaylistItem item = client.Room.CurrentPlaylistItem; + this.Push(new MultiplayerMatchFreestyleSelect(room, new PlaylistItem(item))); } - protected override RulesetInfo GetGameplayRuleset() + public override void OnEntering(ScreenTransitionEvent e) { - // Using the room's reported status makes the server authoritative. - return client.LocalUser?.RulesetId != null ? Rulesets.GetRuleset(client.LocalUser.RulesetId.Value)! : base.GetGameplayRuleset(); + base.OnEntering(e); + beginHandlingTrack(); } - protected override IBeatmapInfo GetGameplayBeatmap() + public override void OnSuspending(ScreenTransitionEvent e) { - // Using the room's reported status makes the server authoritative. - return client.LocalUser?.BeatmapId != null ? new APIBeatmap { OnlineID = client.LocalUser.BeatmapId.Value } : base.GetGameplayBeatmap(); + onLeaving(); + base.OnSuspending(e); } - [Resolved(canBeNull: true)] - private IDialogOverlay? dialogOverlay { get; set; } + public override void OnResuming(ScreenTransitionEvent e) + { + base.OnResuming(e); + beginHandlingTrack(); - private bool exitConfirmed; + // Required to update beatmap/ruleset when resuming from style selection. + updateGameplayState(); + } public override bool OnExiting(ScreenExitEvent e) { - // room has not been created yet or we're offline; exit immediately. - if (client.Room == null || !IsConnected) - return base.OnExiting(e); + if (!ensureExitConfirmed()) + return true; - if (!exitConfirmed && dialogOverlay != null) + client.LeaveRoom().FireAndForget(); + + onLeaving(); + return base.OnExiting(e); + } + + public override bool OnBackButton() + { + if (room.RoomID == null) + { + if (!ensureExitConfirmed()) + return true; + + settingsOverlay.Hide(); + return base.OnBackButton(); + } + + if (userModsSelectOverlay.State.Value == Visibility.Visible) + { + userModsSelectOverlay.Hide(); + return true; + } + + if (settingsOverlay.State.Value == Visibility.Visible) + { + settingsOverlay.Hide(); + return true; + } + + return base.OnBackButton(); + } + + private void onLeaving() + { + // Must hide this overlay because it is added to a global container. + userModsSelectOverlay.Hide(); + + endHandlingTrack(); + } + + /// + /// Handles changes in the track to keep it looping while active. + /// + private void beginHandlingTrack() + { + Beatmap.BindValueChanged(applyLoopingToTrack, true); + } + + /// + /// Stops looping the current track and stops handling further changes to the track. + /// + private void endHandlingTrack() + { + Beatmap.ValueChanged -= applyLoopingToTrack; + Beatmap.Value.Track.Looping = false; + + previewTrackManager.StopAnyPlaying(this); + } + + /// + /// Invoked on changes to the beatmap to loop the track. See: . + /// + /// The beatmap change event. + private void applyLoopingToTrack(ValueChangedEvent beatmap) + { + if (!this.IsCurrentScreen()) + return; + + beatmap.NewValue.PrepareTrackForPreview(true); + music.EnsurePlayingSomething(); + } + + /// + /// Prompts the user to discard unsaved changes to the room before exiting. + /// + /// true if the user has confirmed they want to exit. + private bool ensureExitConfirmed() + { + if (ExitConfirmed) + return true; + + if (api.State.Value != APIState.Online || !client.IsConnected.Value) + return true; + + if (dialogOverlay == null) + return true; + + bool hasUnsavedChanges = room.RoomID == null && room.Playlist.Count > 0; + + if (hasUnsavedChanges) + { + // if the dialog is already displayed, block exiting until the user explicitly makes a decision. + if (dialogOverlay.CurrentDialog is ConfirmDiscardChangesDialog discardChangesDialog) + { + discardChangesDialog.Flash(); + return false; + } + + dialogOverlay.Push(new ConfirmDiscardChangesDialog(() => + { + ExitConfirmed = true; + settingsOverlay.Hide(); + this.Exit(); + })); + + return false; + } + + if (client.Room != null) { if (dialogOverlay.CurrentDialog is ConfirmDialog confirmDialog) confirmDialog.PerformOkAction(); @@ -287,119 +813,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { dialogOverlay.Push(new ConfirmDialog("Are you sure you want to leave this multiplayer match?", () => { - exitConfirmed = true; - if (this.IsCurrentScreen()) - this.Exit(); + ExitConfirmed = true; + this.Exit(); })); } - return true; + return false; } - return base.OnExiting(e); - } - - protected override void PartRoom() => client.LeaveRoom(); - - private void updateBeatmapAvailability(ValueChangedEvent availability) - { - if (client.Room == null) - return; - - client.ChangeBeatmapAvailability(availability.NewValue).FireAndForget(); - - switch (availability.NewValue.State) - { - case DownloadState.LocallyAvailable: - if (client.LocalUser?.State == MultiplayerUserState.Spectating - && (client.Room?.State == MultiplayerRoomState.WaitingForLoad || client.Room?.State == MultiplayerRoomState.Playing)) - { - onLoadRequested(); - } - - break; - - case DownloadState.Unknown: - // Don't do anything rash in an unknown state. - break; - - default: - // while this flow is handled server-side, this covers the edge case of the local user being in a ready state and then deleting the current beatmap. - if (client.LocalUser?.State == MultiplayerUserState.Ready) - client.ChangeState(MultiplayerUserState.Idle); - break; - } - } - - private void onRoomUpdated() - { - // may happen if the client is kicked or otherwise removed from the room. - if (client.Room == null) - { - handleRoomLost(); - return; - } - - SelectedItem.Value = Room.Playlist.SingleOrDefault(i => i.ID == client.Room.Settings.PlaylistItemId); - - addItemButton.Alpha = localUserCanAddItem ? 1 : 0; - - Activity.Value = new UserActivity.InLobby(Room); - } - - private bool localUserCanAddItem => client.IsHost || Room.QueueMode != QueueMode.HostOnly; - - private void handleRoomLost() => Schedule(() => - { - Logger.Log($"{this} exiting due to loss of room or connection"); - - if (this.IsCurrentScreen()) - this.Exit(); - else - ValidForResume = false; - }); - - private void onLoadRequested() - { - // In the case of spectating, IMultiplayerClient.LoadRequested can be fired while the game is still spectating a previous session. - // For now, we want to game to switch to the new game so need to request exiting from the play screen. - if (!ParentScreen.IsCurrentScreen()) - { - ParentScreen.MakeCurrent(); - - Schedule(onLoadRequested); - return; - } - - // The beatmap is queried asynchronously when the selected item changes. - // This is an issue with MultiSpectatorScreen which is effectively in an always "ready" state and receives LoadRequested() callbacks - // even when it is not truly ready (i.e. the beatmap hasn't been selected by the client yet). For the time being, a simple fix to this is to ignore the callback. - // Note that spectator will be entered automatically when the client is capable of doing so via beatmap availability callbacks (see: updateBeatmapAvailability()). - if (client.LocalUser?.State == MultiplayerUserState.Spectating && (SelectedItem.Value == null || Beatmap.IsDefault)) - return; - - if (BeatmapAvailability.Value.State != DownloadState.LocallyAvailable) - return; - - StartPlay(); - } - - protected override Screen CreateGameplayScreen(PlaylistItem selectedItem) - { - Debug.Assert(client.LocalUser != null); - Debug.Assert(client.Room != null); - - int[] userIds = client.CurrentMatchPlayingUserIds.ToArray(); - MultiplayerRoomUser[] users = userIds.Select(id => client.Room.Users.First(u => u.UserID == id)).ToArray(); - - switch (client.LocalUser.State) - { - case MultiplayerUserState.Spectating: - return new MultiSpectatorScreen(Room, users.Take(PlayerGrid.MAX_PLAYERS).ToArray()); - - default: - return new MultiplayerPlayerLoader(() => new MultiplayerPlayer(Room, selectedItem, users)); - } + return true; } public void PresentBeatmap(WorkingBeatmap beatmap, RulesetInfo ruleset) @@ -407,31 +829,76 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer if (!this.IsCurrentScreen()) return; - if (!localUserCanAddItem) + if (client.Room == null || client.LocalUser == null) + return; + + if (client.Room.CanAddPlaylistItems(client.LocalUser) != true) return; // If there's only one playlist item and we are the host, assume we want to change it. Else add a new one. - PlaylistItem? itemToEdit = client.IsHost && Room.Playlist.Count == 1 ? Room.Playlist.Single() : null; + PlaylistItem? itemToEdit = client.IsHost && room.Playlist.Count == 1 ? room.Playlist.Single() : null; - OpenSongSelection(itemToEdit); + ShowSongSelect(itemToEdit); // Re-run PresentBeatmap now that we've pushed a song select that can handle it. game?.PresentBeatmap(beatmap.BeatmapSetInfo, b => b.ID == beatmap.BeatmapInfo.ID); } + // Block all input to this screen during gameplay/etc when the parent screen is no longer current. + // Normally this would be handled by ScreenStack, but we are in a child ScreenStack. + public override bool PropagatePositionalInputSubTree => base.PropagatePositionalInputSubTree && (parentScreen?.IsCurrentScreen() ?? this.IsCurrentScreen()); + + // Block all input to this screen during gameplay/etc when the parent screen is no longer current. + // Normally this would be handled by ScreenStack, but we are in a child ScreenStack. + public override bool PropagateNonPositionalInputSubTree => base.PropagateNonPositionalInputSubTree && (parentScreen?.IsCurrentScreen() ?? this.IsCurrentScreen()); + + protected override BackgroundScreen CreateBackground() => new MultiplayerRoomBackgroundScreen(); + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); + userModsSelectOverlayRegistration?.Dispose(); + if (client.IsNotNull()) { client.RoomUpdated -= onRoomUpdated; + client.SettingsChanged -= onSettingsChanged; + client.ItemChanged -= onItemChanged; + client.UserStyleChanged -= onUserStyleChanged; + client.UserModsChanged -= onUserModsChanged; client.LoadRequested -= onLoadRequested; } } public partial class AddItemButton : PurpleRoundedButton { + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + client.RoomUpdated += onRoomUpdated; + onRoomUpdated(); + } + + private void onRoomUpdated() + { + if (client.Room == null || client.LocalUser == null) + return; + + Alpha = client.Room.CanAddPlaylistItems(client.LocalUser) ? 1 : 0; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + client.RoomUpdated -= onRoomUpdated; + } } } } diff --git a/osu.Game/Users/UserActivity.cs b/osu.Game/Users/UserActivity.cs index 16b30546de..b7b6c6f366 100644 --- a/osu.Game/Users/UserActivity.cs +++ b/osu.Game/Users/UserActivity.cs @@ -6,6 +6,7 @@ using MessagePack; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Online; +using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Rulesets; using osu.Game.Scoring; @@ -271,6 +272,12 @@ namespace osu.Game.Users RoomName = room.Name; } + public InLobby(MultiplayerRoom room) + { + RoomID = room.RoomID; + RoomName = room.Settings.Name; + } + [SerializationConstructor] public InLobby() { } From bb1cfdca84ee3d476c7487024345eb7840a637ae Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 4 Apr 2025 10:50:34 +0900 Subject: [PATCH 2/7] Remove unnecessary using --- .../Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index cff823c969..d464362fda 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -37,7 +37,6 @@ using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; using osu.Game.Users; using osu.Game.Utils; using osuTK; -using Container = osu.Framework.Graphics.Containers.Container; using ParticipantsList = osu.Game.Screens.OnlinePlay.Multiplayer.Participants.ParticipantsList; namespace osu.Game.Screens.OnlinePlay.Multiplayer From aa8ebf989b902f25bc8cd0787d1d2774d7dcd705 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Mon, 7 Apr 2025 20:10:27 +0900 Subject: [PATCH 3/7] Add back removed hash check --- .../Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index d464362fda..f1736903df 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -599,7 +599,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer // 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(b => b.OnlineID == gameplayBeatmapId); + var localBeatmap = beatmapManager.QueryBeatmap($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}", gameplayBeatmapId); Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap); Ruleset.Value = ruleset; Mods.Value = client.LocalUser.Mods.Concat(item.RequiredMods).Select(m => m.ToMod(rulesetInstance)).ToArray(); From 71af50f67541fcac80c4e1e97b8028aad761a3c3 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Mon, 7 Apr 2025 20:55:50 +0900 Subject: [PATCH 4/7] Validate state with lesser magic --- .../Multiplayer/MultiplayerMatchSubScreen.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index f1736903df..f1eeae2d61 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -518,9 +518,19 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer return; } - if (beatmapAvailabilityTracker.Availability.Value.State != DownloadState.LocallyAvailable) - return; + // Ensure all the gameplay states are up-to-date, forgoing any misordering/scheduling shenanigans. + updateGameplayState(); + // ... And then check that the set gameplay state is valid. + // When spectating, we'll receive LoadRequested() from the server even though we may not yet have the beatmap. + // In that case, this method will be invoked again after availability changes in onBeatmapAvailabilityChanged(). + if (Beatmap.IsDefault) + { + Logger.Log("Aborting gameplay start - beatmap not downloaded."); + return; + } + + // Start the gameplay session. sampleStart?.Play(); int[] userIds = client.CurrentMatchPlayingUserIds.ToArray(); From 968fe6e618199cbdc6ded0cd4c9f85f95720a158 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Tue, 8 Apr 2025 18:30:35 +0900 Subject: [PATCH 5/7] Add failing countdown start test --- .../TestSceneMultiplayerMatchSubScreen.cs | 34 +++++++++++++++++++ .../Multiplayer/TestMultiplayerClient.cs | 19 +++++++++-- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs index 660f84b4d6..2def7aeb1c 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs @@ -8,6 +8,7 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Extensions; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Platform; using osu.Framework.Screens; using osu.Framework.Testing; @@ -392,6 +393,39 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("flashlight mod panel not activated", () => !this.ChildrenOfType().Single(p => p.Mod is OsuModFlashlight).Active.Value); } + [Test] + public void TestStartCountdown() + { + AddStep("set playlist", () => + { + room.Playlist = + [ + new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()).BeatmapInfo) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID + } + ]; + }); + + ClickButtonWhenEnabled(); + + AddUntilStep("wait for room join", () => RoomJoined); + + AddStep("click countdown button", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddStep("start a countdown", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single().ChildrenOfType