Path: blob/master/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs
5084 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.Collections.Generic; using System.ComponentModel; 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.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.Input; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; 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; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; using osu.Game.Users; using osu.Game.Utils; using osuTK; using Container = osu.Framework.Graphics.Containers.Container; namespace osu.Game.Screens.OnlinePlay.Playlists { public partial class PlaylistsRoomSubScreen : OnlinePlaySubScreen, IPreviewTrackOwner { /// <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 => "playlist"; 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; } [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 IdleTracker? idleTracker { get; set; } [Resolved] private OnlinePlayScreen? parentScreen { get; set; } [Resolved] private IOverlayManager? overlayManager { get; set; } [Resolved] private IDialogOverlay? dialogOverlay { get; set; } [Cached(typeof(OnlinePlayBeatmapAvailabilityTracker))] private readonly PlaylistsBeatmapAvailabilityTracker beatmapAvailabilityTracker; protected readonly Bindable<PlaylistItem?> SelectedItem = new Bindable<PlaylistItem?>(); protected readonly Bindable<BeatmapInfo?> UserBeatmap = new Bindable<BeatmapInfo?>(); protected readonly Bindable<RulesetInfo?> UserRuleset = new Bindable<RulesetInfo?>(); protected readonly Bindable<IReadOnlyList<Mod>> UserMods = new Bindable<IReadOnlyList<Mod>>(Array.Empty<Mod>()); private readonly IBindable<bool> isIdle = new BindableBool(); private readonly Room room; private Drawable roomContent = null!; private PlaylistsRoomUpdater roomUpdater = null!; private PlaylistsRoomSettingsOverlay settingsOverlay = null!; private MatchLeaderboard leaderboard = null!; private FillFlowContainer progressSection = null!; private DrawableRoomPlaylist drawablePlaylist = null!; private FillFlowContainer userModsSection = null!; private RoomModSelectOverlay userModsSelectOverlay = null!; private FillFlowContainer userStyleSection = null!; private Container<DrawableRoomPlaylistItem> userStyleDisplayContainer = null!; private Sample? sampleStart; private IDisposable? userModsSelectOverlayRegistration; public PlaylistsRoomSubScreen(Room room) { this.room = room; Title = room.RoomID == null ? "New playlist" : room.Name; Activity.Value = new UserActivity.InLobby(room); Padding = new MarginPadding { Top = Header.HEIGHT }; beatmapAvailabilityTracker = new PlaylistsBeatmapAvailabilityTracker { PlaylistItem = { BindTarget = SelectedItem } }; } [BackgroundDependencyLoader] private void load() { if (idleTracker != null) isIdle.BindTo(idleTracker.IsIdle); sampleStart = audio.Samples.Get(@"SongSelect/confirm-selection"); InternalChild = new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, Child = new PopoverContainer { RelativeSizeAxes = Axes.Both, Children = new Drawable[] { roomUpdater = new PlaylistsRoomUpdater(room), 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 PlaylistsRoomPanel(room) { SelectedItem = SelectedItem } }, 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), new Dimension(), new Dimension(GridSizeMode.AutoSize), }, Content = new[] { new Drawable[] { new OverlinedPlaylistHeader(room), }, new Drawable[] { drawablePlaylist = new DrawableRoomPlaylist { RelativeSizeAxes = Axes.Both, SelectedItem = { BindTarget = SelectedItem }, AllowSelection = true, AllowShowingResults = true, RequestResults = showResults } }, new Drawable[] { new AddPlaylistToCollectionButton(room) { Margin = new MarginPadding { Top = 5 }, RelativeSizeAxes = Axes.X, Size = new Vector2(1, 40) } } } }, null, new GridContainer { RelativeSizeAxes = Axes.Both, RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize), new Dimension(GridSizeMode.AutoSize), new Dimension(GridSizeMode.AutoSize), new Dimension(GridSizeMode.AutoSize), }, Content = new[] { new Drawable[] { userModsSection = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Margin = new MarginPadding { Bottom = row_padding }, 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 ModDisplay { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Current = UserMods, Scale = new Vector2(0.8f), } } } } } }, new Drawable[] { userStyleSection = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Margin = new MarginPadding { Bottom = row_padding }, Alpha = 0, Children = new Drawable[] { new OverlinedHeader("Difficulty"), userStyleDisplayContainer = new Container<DrawableRoomPlaylistItem> { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y } } } }, new Drawable[] { progressSection = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Margin = new MarginPadding { Bottom = row_padding }, Alpha = 0, Direction = FillDirection.Vertical, Children = new Drawable[] { new OverlinedHeader("Progress"), new RoomLocalUserInfo(room), } } }, new Drawable[] { new OverlinedHeader("Leaderboard") }, new Drawable[] { leaderboard = new MatchLeaderboard(room) { RelativeSizeAxes = Axes.Both }, } } }, 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 PlaylistsRoomSettingsOverlay(room) { EditPlaylist = () => { if (this.IsCurrentScreen()) this.Push(new PlaylistsSongSelectV2(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 PlaylistsRoomFooter(room) { OnStart = startPlay, OnClose = closePlaylist } } } } } } }; LoadComponent(userModsSelectOverlay = new RoomModSelectOverlay { SelectedItem = { BindTarget = SelectedItem }, SelectedMods = { BindTarget = UserMods }, Beatmap = { BindTarget = Beatmap }, IsValidMod = _ => false }); } protected override void LoadComplete() { base.LoadComplete(); userModsSelectOverlayRegistration = overlayManager?.RegisterBlockingOverlay(userModsSelectOverlay); room.PropertyChanged += onRoomPropertyChanged; isIdle.BindValueChanged(_ => updatePollingRate(), true); beatmapAvailabilityTracker.Availability.BindValueChanged(_ => updateGameplayState()); SelectedItem.BindValueChanged(onSelectedItemChanged); UserBeatmap.BindValueChanged(_ => updateGameplayState()); UserMods.BindValueChanged(_ => updateGameplayState()); UserRuleset.BindValueChanged(_ => { // The user mod selection overlay is separate from the beatmap/ruleset style selection screen, // and so the validity of mods has to be confirmed separately after the ruleset is changed. validateUserMods(); updateGameplayState(); }); updateSetupState(); updateUserScore(); updateGameplayState(); } /// <summary> /// Responds to changes of the <see cref="Room"/>'s properties. /// </summary> /// <param name="sender">The <see cref="Room"/> that changed.</param> /// <param name="e">Describes the property that changed.</param> private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) { switch (e.PropertyName) { case nameof(Room.RoomID): updateSetupState(); break; case nameof(Room.UserScore): updateUserScore(); break; } } /// <summary> /// Responds to changes in <see cref="Room.RoomID"/> 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 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. roomContent.Hide(); settingsOverlay.Show(); } else { roomContent.Show(); settingsOverlay.Hide(); // Scheduled because room properties are updated in arbitrary order. Schedule(() => { progressSection.Alpha = room.MaxAttempts != null ? 1 : 0; drawablePlaylist.Items.ReplaceRange(0, drawablePlaylist.Items.Count, room.Playlist); updateUserScore(); // Select an initial item for the user to help them get into a playable state quicker. SelectedItem.Value = room.Playlist.FirstOrDefault(); }); } } /// <summary> /// Responds to changes in <see cref="Room.UserScore"/> to mark playlist items as completed. /// </summary> private void updateUserScore() { if (room.UserScore == null) return; if (drawablePlaylist.Items.Count == 0) return; foreach (var item in room.UserScore.PlaylistItemAttempts) { if (item.Passed) drawablePlaylist.Items.Single(i => i.ID == item.PlaylistItemID).MarkCompleted(); } } /// <summary> /// Adjusts the rate at which the <see cref="Room"/> is updated. /// </summary> private void updatePollingRate() { roomUpdater.TimeBetweenPolls.Value = isIdle.Value ? 30000 : 5000; Logger.Log($"Polling adjusted (selection: {roomUpdater.TimeBetweenPolls.Value})"); } /// <summary> /// Responds to changes in <see cref="SelectedItem"/> to validate the user style and update the global gameplay state. /// </summary> private void onSelectedItemChanged(ValueChangedEvent<PlaylistItem?> item) { if (item.NewValue == null) return; // Always resetting the user beatmap style when a new item is selected is most intuitive. UserBeatmap.Value = null; if (item.NewValue.Freestyle) { // If freestyle is active, attempt to preserve the user ruleset style but only if the online item is from the osu! ruleset // (i.e. the beatmap is generally always convertible to the current ruleset, excluding custom rulesets). if (item.NewValue.RulesetID > 0) UserRuleset.Value = null; } else UserRuleset.Value = null; validateUserMods(); updateGameplayState(); } /// <summary> /// Validates the user mod style against the selected item and ruleset style. /// </summary> private void validateUserMods() { if (SelectedItem.Value == null) return; PlaylistItem item = SelectedItem.Value; RulesetInfo gameplayRuleset = UserRuleset.Value ?? rulesets.GetRuleset(item.RulesetID)!; Mod[] allowedMods = ModUtils.EnumerateUserSelectableFreeMods(MatchType.Playlists, item.RequiredMods, item.AllowedMods, item.Freestyle, gameplayRuleset.CreateInstance()); UserMods.Value = UserMods.Value.Where(m => allowedMods.Any(a => m.GetType() == a.GetType())).ToArray(); } /// <summary> /// Updates the global states in preparation for a new gameplay session. /// </summary> private void updateGameplayState() { if (!this.IsCurrentScreen() || SelectedItem.Value == null) return; PlaylistItem item = SelectedItem.Value; IBeatmapInfo gameplayBeatmap = UserBeatmap.Value ?? item.Beatmap; RulesetInfo gameplayRuleset = UserRuleset.Value ?? rulesets.GetRuleset(item.RulesetID)!; Ruleset rulesetInstance = gameplayRuleset.CreateInstance(); Mod[] allowedMods = ModUtils.EnumerateUserSelectableFreeMods(MatchType.Playlists, item.RequiredMods, item.AllowedMods, item.Freestyle, gameplayRuleset.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(gameplayBeatmap.OnlineID); Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap); Ruleset.Value = gameplayRuleset; Mods.Value = UserMods.Value.Concat(item.RequiredMods.Select(m => m.ToMod(rulesetInstance))).ToArray(); // Update UI elements to reflect the new selection. bool freemods = item.Freestyle || allowedMods.Length > 0; bool freestyle = item.Freestyle; if (freemods) { userModsSection.Show(); userModsSelectOverlay.IsValidMod = m => allowedMods.Any(a => a.GetType() == m.GetType()); } else { userModsSection.Hide(); userModsSelectOverlay.Hide(); userModsSelectOverlay.IsValidMod = _ => false; } if (freestyle) { userStyleSection.Show(); PlaylistItem gameplayItem = item.With(ruleset: gameplayRuleset.OnlineID, beatmap: new Optional<IBeatmapInfo>(gameplayBeatmap)); DrawableRoomPlaylistItem? currentDisplay = userStyleDisplayContainer.SingleOrDefault(); if (!gameplayItem.Equals(currentDisplay?.Item)) { userStyleDisplayContainer.Child = currentDisplay = new DrawableRoomPlaylistItem(gameplayItem, true) { AllowReordering = false, RequestEdit = _ => showUserStyleSelect() }; } currentDisplay.AllowEditing = localBeatmap != null; } else userStyleSection.Hide(); } /// <summary> /// Pushes a <see cref="Player"/> to start gameplay with the current selection. /// </summary> private void startPlay() { if (!this.IsCurrentScreen() || SelectedItem.Value == null) return; PlaylistItem item = SelectedItem.Value; // Required for validation inside the player. RulesetInfo gameplayRuleset = UserRuleset.Value ?? rulesets.GetRuleset(item.RulesetID)!; IBeatmapInfo gameplayBeatmap = UserBeatmap.Value ?? item.Beatmap; PlaylistItem gameplayItem = item.With(ruleset: gameplayRuleset.OnlineID, beatmap: new Optional<IBeatmapInfo>(gameplayBeatmap)); 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(new PlayerLoader(() => new PlaylistsPlayer(room, gameplayItem) { Exited = () => leaderboard.RefetchScores() })); } /// <summary> /// Shows the user mod selection. /// </summary> private void showUserModSelect() { if (!this.IsCurrentScreen() || SelectedItem.Value == null) return; userModsSelectOverlay.Show(); } /// <summary> /// Shows the user style selection. /// </summary> private void showUserStyleSelect() { if (!this.IsCurrentScreen() || SelectedItem.Value == null) return; this.Push(new PlaylistsRoomFreestyleSelect(room, SelectedItem.Value) { Beatmap = { BindTarget = UserBeatmap }, Ruleset = { BindTarget = UserRuleset } }); } /// <summary> /// Shows the results screen for a playlist item. /// </summary> private void showResults(PlaylistItem item) { if (!this.IsCurrentScreen()) return; Debug.Assert(room.RoomID != null); // 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(room.RoomID.Value, item, api.LocalUser.Value.OnlineID)); } /// <summary> /// May be invoked by the owner of the room to permanently close the room ahead of its intended end date. /// </summary> private void closePlaylist() { dialogOverlay?.Push(new ClosePlaylistDialog(room, () => { var request = new ClosePlaylistRequest(room.RoomID!.Value); request.Success += () => room.EndDate = DateTimeOffset.UtcNow; api.Queue(request); })); } 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; if (room.RoomID != null) api.Queue(new PartRoomRequest(room)); 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) 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; } // 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 RoomBackgroundScreen(room.Playlist.FirstOrDefault()) { SelectedItem = { BindTarget = SelectedItem } }; protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); userModsSelectOverlayRegistration?.Dispose(); room.PropertyChanged -= onRoomPropertyChanged; } } }