Path: blob/master/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs
4636 views
// Copyright (c) ppy Pty Ltd <[email protected]>. 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.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.Cursor; 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; 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.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; using osu.Game.Screens.OnlinePlay.Multiplayer.Match; 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.Screens.OnlinePlay.Playlists; using osu.Game.Users; using osu.Game.Utils; using osuTK; using ParticipantsList = osu.Game.Screens.OnlinePlay.Multiplayer.Participants.ParticipantsList; namespace osu.Game.Screens.OnlinePlay.Multiplayer { [Cached] public partial class MultiplayerMatchSubScreen : OnlinePlaySubScreen, IPreviewTrackOwner, IHandlePresentBeatmap { /// <summary> /// Footer height. /// </summary> private const float footer_height = 50; /// <summary> /// Padding between content and footer. /// </summary> private const float footer_padding = 30; /// <summary> /// Internal padding of the content. /// </summary> private const float content_padding = 20; /// <summary> /// Padding between columns of the content. /// </summary> private const float column_padding = 10; /// <summary> /// Padding between rows of the content. /// </summary> 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; /// <summary> /// Whether the user has confirmed they want to exit this screen in the presence of unsaved changes. /// </summary> protected bool ExitConfirmed { get; private set; } /// <summary> /// Used for testing - whether the local user style can be edited. /// False if the beatmap hasn't been downloaded yet, or if freestyle isn't enabled. /// </summary> internal bool UserStyleEditingEnabled { get { if (!userStyleDisplayContainer.IsPresent) return false; return userStyleDisplayContainer.SingleOrDefault()?.AllowEditing == true; } } [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] private OsuGame? game { get; set; } [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<DrawableRoomPlaylistItem> userStyleDisplayContainer = null!; private Sample? sampleStart; private IDisposable? userModsSelectOverlayRegistration; private long lastPlaylistItemId; private bool isRoomJoined; public MultiplayerMatchSubScreen(Room 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, Child = new PopoverContainer { 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 = 30, Text = "Add item", Action = () => ShowSongSelect() }, }, null, new Drawable[] { new MultiplayerPlaylist { RelativeSizeAxes = Axes.Both, RequestEdit = ShowSongSelect, RequestResults = showResults } }, 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<DrawableRoomPlaylistItem> { 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(); userModsSelectOverlayRegistration = overlayManager?.RegisterBlockingOverlay(userModsSelectOverlay); client.RoomUpdated += onRoomUpdated; client.SettingsChanged += onSettingsChanged; client.ItemChanged += onItemChanged; client.UserStyleChanged += onUserStyleChanged; client.UserModsChanged += onUserModsChanged; client.LoadRequested += onLoadRequested; beatmapAvailabilityTracker.Availability.BindValueChanged(onBeatmapAvailabilityChanged, true); onRoomUpdated(); updateGameplayState(); updateUserActivity(); } /// <summary> /// 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. /// </summary> private void onRoomUpdated() => Scheduler.AddOnce(() => { bool wasRoomJoined = isRoomJoined; isRoomJoined = client.Room != null; // Creating a room. if (!wasRoomJoined && !isRoomJoined) { roomContent.Hide(); settingsOverlay.Show(); } // Joining a room. if (!wasRoomJoined && isRoomJoined) { roomContent.Show(); settingsOverlay.Hide(); } // Leaving a room. if (wasRoomJoined && !isRoomJoined) { Logger.Log($"{this} exiting due to loss of room or connection"); if (this.IsCurrentScreen()) this.Exit(); else ValidForResume = false; } }); /// <summary> /// Responds to changes in the room's settings to update the gameplay state and local user's activity. /// </summary> private void onSettingsChanged(MultiplayerRoomSettings settings) { if (settings.PlaylistItemId != lastPlaylistItemId) { onActivePlaylistItemChanged(); lastPlaylistItemId = settings.PlaylistItemId; } updateUserActivity(); } /// <summary> /// Responds to changes in the active playlist item to update the gameplay state. /// </summary> private void onItemChanged(MultiplayerPlaylistItem item) { if (item.ID == client.Room?.Settings.PlaylistItemId) onActivePlaylistItemChanged(); } /// <summary> /// Responds to changes in the active playlist item resulting from the playlist item being edited or the room settings changing. /// </summary> private void onActivePlaylistItemChanged() { if (client.Room == null) return; // Check if we need to make this the current screen as a result of the beatmap set changing while the user's selecting a style. if (this.GetChildScreen() is MultiplayerMatchFreestyleSelect) { MultiplayerPlaylistItem item = client.Room.CurrentPlaylistItem; var newBeatmap = beatmapManager.QueryOnlineBeatmapId(item.BeatmapID); if (!Beatmap.Value.BeatmapSetInfo.Equals(newBeatmap?.BeatmapSet)) this.MakeCurrent(); } Scheduler.AddOnce(updateGameplayState); } /// <summary> /// Responds to changes in the local user's style to update the gameplay state. /// </summary> private void onUserStyleChanged(MultiplayerRoomUser user) { if (user.Equals(client.LocalUser)) Scheduler.AddOnce(updateGameplayState); } /// <summary> /// Responds to changes in the local user's mods style to update the gameplay state. /// </summary> private void onUserModsChanged(MultiplayerRoomUser user) { if (user.Equals(client.LocalUser)) Scheduler.AddOnce(updateGameplayState); } /// <summary> /// Responds to notifications from the server that a gameplay session is ready to attempt to start the gameplay session. /// </summary> 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; } // 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(); 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; } } /// <summary> /// Responds to changes in the local user's beatmap availability to notify the server and prepare the gameplay session. /// </summary> private void onBeatmapAvailabilityChanged(ValueChangedEvent<BeatmapAvailability> 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).FireAndForget(); break; } } /// <summary> /// Updates the local user's activity to publish their presence in the room. /// </summary> 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); } /// <summary> /// Updates the global beatmap/ruleset/mods in preparation for a new gameplay session. /// </summary> 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.QueryOnlineBeatmapId(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<IBeatmapInfo>(new APIBeatmap { OnlineID = gameplayBeatmapId }), ruleset: gameplayRulesetId); DrawableRoomPlaylistItem? currentDisplay = userStyleDisplayContainer.SingleOrDefault(); if (!apiItem.Equals(currentDisplay?.Item)) { userStyleDisplayContainer.Child = currentDisplay = new DrawableRoomPlaylistItem(apiItem, true) { AllowReordering = false, RequestEdit = _ => ShowUserStyleSelect() }; } currentDisplay.AllowEditing = localBeatmap != null; } else userStyleSection.Hide(); } /// <summary> /// Shows the song selection screen to add or edit an item. /// </summary> /// <param name="itemToEdit">An optional playlist item to edit. If null, a new item will be added instead.</param> public void ShowSongSelect(PlaylistItem? itemToEdit = null) { if (!this.IsCurrentScreen()) return; this.Push(new MultiplayerMatchSongSelect(room, itemToEdit)); } /// <summary> /// Shows the user mod selection. /// </summary> private void showUserModSelect() { if (!this.IsCurrentScreen()) return; userModsSelectOverlay.Show(); } /// <summary> /// Shows the user style selection. /// </summary> public void ShowUserStyleSelect() { if (!this.IsCurrentScreen() || client.Room == null || client.LocalUser == null) return; MultiplayerPlaylistItem item = client.Room.CurrentPlaylistItem; this.Push(new MultiplayerMatchFreestyleSelect(room, new PlaylistItem(item))); } /// <summary> /// Shows the results screen for a playlist item. /// </summary> private void showResults(PlaylistItem item) { if (!this.IsCurrentScreen() || client.Room == null || client.LocalUser == null) return; // fallback is to allow this class to operate when there is no parent OnlineScreen (testing purposes). var targetScreen = (Screen?)parentScreen ?? this; targetScreen.Push(new PlaylistItemUserBestResultsScreen(client.Room.RoomID, item, client.LocalUser.UserID)); } public override void OnEntering(ScreenTransitionEvent e) { base.OnEntering(e); beginHandlingTrack(); } public override void OnSuspending(ScreenTransitionEvent e) { onLeaving(); base.OnSuspending(e); } public override void OnResuming(ScreenTransitionEvent e) { base.OnResuming(e); beginHandlingTrack(); // Required to update beatmap/ruleset when resuming from style selection. updateGameplayState(); } public override bool OnExiting(ScreenExitEvent e) { if (!ensureExitConfirmed()) return true; 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(); } /// <summary> /// Handles changes in the track to keep it looping while active. /// </summary> private void beginHandlingTrack() { Beatmap.BindValueChanged(applyLoopingToTrack, true); } /// <summary> /// Stops looping the current track and stops handling further changes to the track. /// </summary> private void endHandlingTrack() { Beatmap.ValueChanged -= applyLoopingToTrack; Beatmap.Value.Track.Looping = false; previewTrackManager.StopAnyPlaying(this); } /// <summary> /// Invoked on changes to the beatmap to loop the track. See: <see cref="beginHandlingTrack"/>. /// </summary> /// <param name="beatmap">The beatmap change event.</param> private void applyLoopingToTrack(ValueChangedEvent<WorkingBeatmap> beatmap) { if (!this.IsCurrentScreen()) return; beatmap.NewValue.PrepareTrackForPreview(true); music.EnsurePlayingSomething(); } /// <summary> /// Prompts the user to discard unsaved changes to the room before exiting. /// </summary> /// <returns><c>true</c> if the user has confirmed they want to exit.</returns> 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(); else { dialogOverlay.Push(new ConfirmDialog("Are you sure you want to leave this multiplayer match?", () => { ExitConfirmed = true; this.Exit(); })); } return false; } return true; } public void PresentBeatmap(WorkingBeatmap beatmap, RulesetInfo ruleset) { if (!this.IsCurrentScreen()) return; 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; 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; } } } }