1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-13 20:33:35 +08:00
Files
osu-lazer/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs
T
2025-03-27 19:46:07 +09:00

861 lines
41 KiB
C#

// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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.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,
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 = item =>
{
Debug.Assert(room.RoomID != null);
parentScreen?.Push(new PlaylistItemUserBestResultsScreen(room.RoomID.Value, item,
api.LocalUser.Value.Id));
}
}
},
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 PlaylistsSongSelect(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 },
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();
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;
}
}
/// <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);
// Select an initial item for the user to help them get into a playable state quicker.
SelectedItem.Value = room.Playlist.FirstOrDefault();
});
}
}
/// <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>
/// Lists the <see cref="Mod"/>s that are valid to be selected for the user mod style.
/// </summary>
private Mod[] listAllowedMods()
{
if (SelectedItem.Value == null)
return [];
PlaylistItem item = SelectedItem.Value;
RulesetInfo gameplayRuleset = UserRuleset.Value ?? rulesets.GetRuleset(item.RulesetID)!;
Ruleset rulesetInstance = gameplayRuleset.CreateInstance();
if (item.Freestyle)
return rulesetInstance.AllMods.OfType<Mod>().Where(m => ModUtils.IsValidFreeModForMatchType(m, room.Type)).ToArray();
return item.AllowedMods.Select(m => m.ToMod(rulesetInstance)).ToArray();
}
/// <summary>
/// Validates the user mod style against the selected item and ruleset style.
/// </summary>
private void validateUserMods()
{
Mod[] allowedMods = listAllowedMods();
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 = listAllowedMods();
// 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
int beatmapId = gameplayBeatmap.OnlineID;
var localBeatmap = beatmapManager.QueryBeatmap(b => b.OnlineID == beatmapId);
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 = 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));
PlaylistItem? currentItem = userStyleDisplayContainer.SingleOrDefault()?.Item;
if (!gameplayItem.Equals(currentItem))
{
userStyleDisplayContainer.Child = new DrawableRoomPlaylistItem(gameplayItem, true)
{
AllowReordering = false,
AllowEditing = true,
RequestEdit = _ => showUserStyleSelect()
};
}
}
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>
/// 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;
}
}
}