// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Online.Multiplayer; using osu.Game.Online.Spectator; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Spectate; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate { /// /// A that spectates multiple users in a match. /// public class MultiSpectatorScreen : SpectatorScreen { // Isolates beatmap/ruleset to this screen. public override bool DisallowExternalBeatmapRulesetChanges => true; // We are managing our own adjustments. For now, this happens inside the Player instances themselves. public override bool? AllowTrackAdjustments => false; /// /// Whether all spectating players have finished loading. /// public bool AllPlayersLoaded => instances.All(p => p?.PlayerLoaded == true); [Resolved] private OsuColour colours { get; set; } [Resolved] private SpectatorClient spectatorClient { get; set; } [Resolved] private MultiplayerClient multiplayerClient { get; set; } private readonly PlayerArea[] instances; private MasterGameplayClockContainer masterClockContainer; private ISyncManager syncManager; private PlayerGrid grid; private MultiSpectatorLeaderboard leaderboard; private PlayerArea currentAudioSource; private bool canStartMasterClock; private readonly MultiplayerRoomUser[] users; /// /// Creates a new . /// /// The players to spectate. public MultiSpectatorScreen(MultiplayerRoomUser[] users) : base(users.Select(u => u.UserID).ToArray()) { this.users = users; instances = new PlayerArea[Users.Count]; } [BackgroundDependencyLoader] private void load() { Container leaderboardContainer; Container scoreDisplayContainer; masterClockContainer = new MasterGameplayClockContainer(Beatmap.Value, 0); InternalChildren = new[] { (Drawable)(syncManager = new CatchUpSyncManager(masterClockContainer)), masterClockContainer.WithChild(new GridContainer { RelativeSizeAxes = Axes.Both, RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, Content = new[] { new Drawable[] { scoreDisplayContainer = new Container { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y }, }, new Drawable[] { new GridContainer { RelativeSizeAxes = Axes.Both, ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, Content = new[] { new Drawable[] { leaderboardContainer = new Container { RelativeSizeAxes = Axes.Y, AutoSizeAxes = Axes.X }, grid = new PlayerGrid { RelativeSizeAxes = Axes.Both } } } } } } }) }; for (int i = 0; i < Users.Count; i++) { grid.Add(instances[i] = new PlayerArea(Users[i], masterClockContainer.GameplayClock)); syncManager.AddPlayerClock(instances[i].GameplayClock); } // Todo: This is not quite correct - it should be per-user to adjust for other mod combinations. var playableBeatmap = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value); var scoreProcessor = Ruleset.Value.CreateInstance().CreateScoreProcessor(); scoreProcessor.ApplyBeatmap(playableBeatmap); LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(scoreProcessor, users) { Expanded = { Value = true }, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, }, l => { foreach (var instance in instances) leaderboard.AddClock(instance.UserId, instance.GameplayClock); leaderboardContainer.Add(leaderboard); if (leaderboard.TeamScores.Count == 2) { LoadComponentAsync(new MatchScoreDisplay { Team1Score = { BindTarget = leaderboard.TeamScores.First().Value }, Team2Score = { BindTarget = leaderboard.TeamScores.Last().Value }, }, scoreDisplayContainer.Add); } }); } protected override void LoadComplete() { base.LoadComplete(); masterClockContainer.Reset(); masterClockContainer.Stop(); syncManager.ReadyToStart += onReadyToStart; syncManager.MasterState.BindValueChanged(onMasterStateChanged, true); } protected override void Update() { base.Update(); if (!isCandidateAudioSource(currentAudioSource?.GameplayClock)) { currentAudioSource = instances.Where(i => isCandidateAudioSource(i.GameplayClock)) .OrderBy(i => Math.Abs(i.GameplayClock.CurrentTime - syncManager.MasterClock.CurrentTime)) .FirstOrDefault(); foreach (var instance in instances) instance.Mute = instance != currentAudioSource; } } private bool isCandidateAudioSource([CanBeNull] ISpectatorPlayerClock clock) => clock?.IsRunning == true && !clock.IsCatchingUp && !clock.WaitingOnFrames.Value; private void onReadyToStart() { // Seek the master clock to the gameplay time. // This is chosen as the first available frame in the players' replays, which matches the seek by each individual SpectatorPlayer. double startTime = instances.Where(i => i.Score != null) .SelectMany(i => i.Score.Replay.Frames) .Select(f => f.Time) .DefaultIfEmpty(0) .Min(); masterClockContainer.Seek(startTime); masterClockContainer.Start(); // Although the clock has been started, this flag is set to allow for later synchronisation state changes to also be able to start it. canStartMasterClock = true; } private void onMasterStateChanged(ValueChangedEvent state) { switch (state.NewValue) { case MasterClockState.Synchronised: if (canStartMasterClock) masterClockContainer.Start(); break; case MasterClockState.TooFarAhead: masterClockContainer.Stop(); break; } } protected override void OnUserStateChanged(int userId, SpectatorState spectatorState) { } protected override void StartGameplay(int userId, SpectatorGameplayState spectatorGameplayState) => instances.Single(i => i.UserId == userId).LoadScore(spectatorGameplayState.Score); protected override void EndGameplay(int userId) { RemoveUser(userId); var instance = instances.Single(i => i.UserId == userId); instance.FadeColour(colours.Gray4, 400, Easing.OutQuint); syncManager.RemovePlayerClock(instance.GameplayClock); leaderboard.RemoveClock(userId); } public override bool OnBackButton() { // On a manual exit, set the player state back to idle. multiplayerClient.ChangeState(MultiplayerUserState.Idle); return base.OnBackButton(); } } }