-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathMultiSpectatorScreen.cs
More file actions
324 lines (267 loc) · 13.1 KB
/
MultiSpectatorScreen.cs
File metadata and controls
324 lines (267 loc) · 13.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
// 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.Linq;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Logging;
using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Online.Spectator;
using osu.Game.Screens.Play;
using osu.Game.Screens.Play.HUD;
using osu.Game.Screens.Play.Leaderboards;
using osu.Game.Screens.Spectate;
using osu.Game.Users;
using osuTK;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
/// <summary>
/// A <see cref="SpectatorScreen"/> that spectates multiple users in a match.
/// </summary>
public partial 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? ApplyModTrackAdjustments => false;
public override bool HideOverlaysOnEnter => true;
/// <summary>
/// Whether all spectating players have finished loading.
/// </summary>
public bool AllPlayersLoaded => instances.All(p => p.PlayerLoaded);
internal DrawableGameplayLeaderboard Leaderboard { get; private set; } = null!;
protected override UserActivity InitialActivity => new UserActivity.SpectatingMultiplayerGame(Beatmap.Value.BeatmapInfo, Ruleset.Value);
[Resolved]
private OsuColour colours { get; set; } = null!;
[Resolved]
private MultiplayerClient multiplayerClient { get; set; } = null!;
[Cached(typeof(IGameplayLeaderboardProvider))]
private MultiSpectatorLeaderboardProvider leaderboardProvider { get; set; }
private IAggregateAudioAdjustment? boundAdjustments;
private readonly PlayerArea[] instances;
private MasterGameplayClockContainer masterClockContainer = null!;
private SpectatorSyncManager syncManager = null!;
private PlayerGrid grid = null!;
private PlayerArea? currentAudioSource;
private readonly Room room;
private ReplaySettingsOverlay replaySettingsOverlay = null!;
private Bindable<bool> configSettingsOverlay = null!;
/// <summary>
/// Creates a new <see cref="MultiSpectatorScreen"/>.
/// </summary>
/// <param name="room">The room.</param>
/// <param name="users">The players to spectate.</param>
public MultiSpectatorScreen(Room room, MultiplayerRoomUser[] users)
: base(users.Select(u => u.UserID).ToArray())
{
this.room = room;
instances = new PlayerArea[Users.Count];
leaderboardProvider = new MultiSpectatorLeaderboardProvider(users);
}
[BackgroundDependencyLoader]
private void load(OsuConfigManager config)
{
configSettingsOverlay = config.GetBindable<bool>(OsuSetting.ReplaySettingsOverlay);
FillFlowContainer leaderboardFlow;
Container scoreDisplayContainer;
InternalChildren = new Drawable[]
{
masterClockContainer = new MasterGameplayClockContainer(Beatmap.Value, 0)
{
Child = 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[]
{
leaderboardFlow = new FillFlowContainer
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Spacing = new Vector2(5)
},
grid = new PlayerGrid { RelativeSizeAxes = Axes.Both }
}
}
}
}
}
}
},
syncManager = new SpectatorSyncManager(masterClockContainer)
{
ReadyToStart = performInitialSeek,
},
replaySettingsOverlay = new ReplaySettingsOverlay
{
Alpha = 0,
}
};
for (int i = 0; i < Users.Count; i++)
grid.Add(instances[i] = new PlayerArea(Users[i], syncManager.CreateManagedClock()));
LoadComponentAsync(leaderboardProvider, _ =>
{
AddInternal(leaderboardProvider);
foreach (var instance in instances)
leaderboardProvider.AddClock(instance.UserId, instance.SpectatorPlayerClock);
if (leaderboardProvider.TeamScores.Count == 2)
{
LoadComponentAsync(new MatchScoreDisplay
{
Team1Score = { BindTarget = leaderboardProvider.TeamScores.First().Value },
Team2Score = { BindTarget = leaderboardProvider.TeamScores.Last().Value },
}, scoreDisplayContainer.Add);
}
});
leaderboardFlow.Insert(0, Leaderboard = new DrawableGameplayLeaderboard
{
CollapseDuringGameplay = { Value = false },
AlwaysShown = true,
});
LoadComponentAsync(new GameplayChatDisplay(room)
{
Expanded = { Value = true },
}, chat => leaderboardFlow.Insert(1, chat));
}
protected override void LoadComplete()
{
base.LoadComplete();
masterClockContainer.Reset();
// Start with adjustments from the first player to keep a sane state.
bindAudioAdjustments(instances.First());
configSettingsOverlay.BindValueChanged(_ => updateVisibility(), true);
}
private void updateVisibility()
{
if (configSettingsOverlay.Value)
replaySettingsOverlay.Show();
else
replaySettingsOverlay.Hide();
}
protected override void Update()
{
base.Update();
checkAudioSource();
}
private void checkAudioSource()
{
// always use the maximised player instance as the current audio source if there is one
if (grid.MaximisedCell?.Content is PlayerArea maximisedPlayer && maximisedPlayer == currentAudioSource)
return;
// if there is no maximised player instance and the previous audio source is still good to use, keep using it
if (grid.MaximisedCell == null && isCandidateAudioSource(currentAudioSource?.SpectatorPlayerClock))
return;
// at this point we're in one of the following scenarios:
// - the maximised player instance is not the current audio source => we want to switch to the maximised player instance
// - there is no maximised player instance, and the previous audio source is stopped => find another running audio source
currentAudioSource = grid.MaximisedCell?.Content as PlayerArea
?? instances.Where(i => isCandidateAudioSource(i.SpectatorPlayerClock)).MinBy(i => Math.Abs(i.SpectatorPlayerClock.CurrentTime - syncManager.CurrentMasterTime));
// Only bind adjustments if there's actually a valid source, else just use the previous ones to ensure no sudden changes to audio.
if (currentAudioSource != null)
bindAudioAdjustments(currentAudioSource);
foreach (var instance in instances)
instance.Mute = instance != currentAudioSource;
}
private void bindAudioAdjustments(PlayerArea first)
{
if (boundAdjustments != null)
masterClockContainer.AdjustmentsFromMods.UnbindAdjustments(boundAdjustments);
boundAdjustments = first.ClockAdjustmentsFromMods;
masterClockContainer.AdjustmentsFromMods.BindAdjustments(boundAdjustments);
}
private bool isCandidateAudioSource(SpectatorPlayerClock? clock)
=> clock?.IsRunning == true && !clock.IsCatchingUp && !clock.WaitingOnFrames;
private void performInitialSeek()
{
// We want to start showing gameplay as soon as possible.
// Each client may be in a different place in the beatmap, so we need to do our best to find a common
// starting point.
//
// Preferring a lower value ensures that we don't have some clients stuttering to keep up.
List<double> minFrameTimes = new List<double>();
foreach (var instance in instances)
{
if (instance.Score == null)
continue;
minFrameTimes.Add(instance.Score.Replay.Frames.MinBy(f => f.Time)?.Time ?? 0);
}
// Remove any outliers (only need to worry about removing those lower than the mean since we will take a Min() after).
double mean = minFrameTimes.Average();
minFrameTimes.RemoveAll(t => mean - t > 1000);
double startTime = minFrameTimes.Min();
masterClockContainer.Reset(startTime, true);
Logger.Log($"Multiplayer spectator seeking to initial time of {startTime}");
}
protected override void OnNewPlayingUserState(int userId, SpectatorState spectatorState)
{
}
protected override void StartGameplay(int userId, SpectatorGameplayState spectatorGameplayState) => Schedule(() =>
{
var playerArea = instances.Single(i => i.UserId == userId);
// The multiplayer spectator flow requires the client to return to a higher level screen
// (ie. StartGameplay should only be called once per player).
//
// Meanwhile, the solo spectator flow supports multiple `StartGameplay` calls.
// To ensure we don't crash out in an edge case where this is called more than once in multiplayer,
// guard against re-entry for the same player.
if (playerArea.Score != null)
return;
playerArea.LoadScore(spectatorGameplayState.Score);
});
protected override void FailGameplay(int userId) => Schedule(() =>
{
// We probably want to visualise this in the future.
var instance = instances.Single(i => i.UserId == userId);
syncManager.RemoveManagedClock(instance.SpectatorPlayerClock);
});
protected override void PassGameplay(int userId) => Schedule(() =>
{
var instance = instances.Single(i => i.UserId == userId);
syncManager.RemoveManagedClock(instance.SpectatorPlayerClock);
});
protected override void QuitGameplay(int userId) => Schedule(() =>
{
RemoveUser(userId);
var instance = instances.Single(i => i.UserId == userId);
instance.FadeColour(colours.Gray4, 400, Easing.OutQuint);
syncManager.RemoveManagedClock(instance.SpectatorPlayerClock);
});
public override bool OnBackButton()
{
if (multiplayerClient.Room == null)
return base.OnBackButton();
// On a manual exit, set the player back to idle unless gameplay has finished.
// Of note, this doesn't cover exiting using alt-f4 or menu home option.
if (multiplayerClient.Room.State != MultiplayerRoomState.Open)
multiplayerClient.ChangeState(MultiplayerUserState.Idle).FireAndForget();
return base.OnBackButton();
}
}
}