1
0
mirror of https://github.com/ppy/osu.git synced 2024-12-14 20:22:55 +08:00

Merge pull request #12538 from smoogipoo/multiplayer-spectator-screen

Implement the multiplayer spectator screen
This commit is contained in:
Dean Herbert 2021-05-12 17:24:35 +09:00 committed by GitHub
commit c6f0a6aed3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 1694 additions and 261 deletions

View File

@ -53,9 +53,9 @@ namespace osu.Game.Tests.NonVisual.Multiplayer
Client.RoomSetupAction = room => Client.RoomSetupAction = room =>
{ {
room.State = MultiplayerRoomState.Playing; room.State = MultiplayerRoomState.Playing;
room.Users.Add(new MultiplayerRoomUser(55) room.Users.Add(new MultiplayerRoomUser(PLAYER_1_ID)
{ {
User = new User { Id = 55 }, User = new User { Id = PLAYER_1_ID },
State = MultiplayerUserState.Playing State = MultiplayerUserState.Playing
}); });
}; };

View File

@ -0,0 +1,223 @@
// 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 NUnit.Framework;
using osu.Framework.Bindables;
using osu.Framework.Testing;
using osu.Framework.Timing;
using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate;
using osu.Game.Tests.Visual;
namespace osu.Game.Tests.OnlinePlay
{
[HeadlessTest]
public class TestSceneCatchUpSyncManager : OsuTestScene
{
private TestManualClock master;
private CatchUpSyncManager syncManager;
private TestSpectatorPlayerClock player1;
private TestSpectatorPlayerClock player2;
[SetUp]
public void Setup()
{
syncManager = new CatchUpSyncManager(master = new TestManualClock());
syncManager.AddPlayerClock(player1 = new TestSpectatorPlayerClock(1));
syncManager.AddPlayerClock(player2 = new TestSpectatorPlayerClock(2));
Schedule(() => Child = syncManager);
}
[Test]
public void TestMasterClockStartsWhenAllPlayerClocksHaveFrames()
{
setWaiting(() => player1, false);
assertMasterState(false);
assertPlayerClockState(() => player1, false);
assertPlayerClockState(() => player2, false);
setWaiting(() => player2, false);
assertMasterState(true);
assertPlayerClockState(() => player1, true);
assertPlayerClockState(() => player2, true);
}
[Test]
public void TestMasterClockDoesNotStartWhenNoneReadyForMaximumDelayTime()
{
AddWaitStep($"wait {CatchUpSyncManager.MAXIMUM_START_DELAY} milliseconds", (int)Math.Ceiling(CatchUpSyncManager.MAXIMUM_START_DELAY / TimePerAction));
assertMasterState(false);
}
[Test]
public void TestMasterClockStartsWhenAnyReadyForMaximumDelayTime()
{
setWaiting(() => player1, false);
AddWaitStep($"wait {CatchUpSyncManager.MAXIMUM_START_DELAY} milliseconds", (int)Math.Ceiling(CatchUpSyncManager.MAXIMUM_START_DELAY / TimePerAction));
assertMasterState(true);
}
[Test]
public void TestPlayerClockDoesNotCatchUpWhenSlightlyOutOfSync()
{
setAllWaiting(false);
setMasterTime(CatchUpSyncManager.SYNC_TARGET + 1);
assertCatchingUp(() => player1, false);
}
[Test]
public void TestPlayerClockStartsCatchingUpWhenTooFarBehind()
{
setAllWaiting(false);
setMasterTime(CatchUpSyncManager.MAX_SYNC_OFFSET + 1);
assertCatchingUp(() => player1, true);
assertCatchingUp(() => player2, true);
}
[Test]
public void TestPlayerClockKeepsCatchingUpWhenSlightlyOutOfSync()
{
setAllWaiting(false);
setMasterTime(CatchUpSyncManager.MAX_SYNC_OFFSET + 1);
setPlayerClockTime(() => player1, CatchUpSyncManager.SYNC_TARGET + 1);
assertCatchingUp(() => player1, true);
}
[Test]
public void TestPlayerClockStopsCatchingUpWhenInSync()
{
setAllWaiting(false);
setMasterTime(CatchUpSyncManager.MAX_SYNC_OFFSET + 2);
setPlayerClockTime(() => player1, CatchUpSyncManager.SYNC_TARGET);
assertCatchingUp(() => player1, false);
assertCatchingUp(() => player2, true);
}
[Test]
public void TestPlayerClockDoesNotStopWhenSlightlyAhead()
{
setAllWaiting(false);
setPlayerClockTime(() => player1, -CatchUpSyncManager.SYNC_TARGET);
assertCatchingUp(() => player1, false);
assertPlayerClockState(() => player1, true);
}
[Test]
public void TestPlayerClockStopsWhenTooFarAheadAndStartsWhenBackInSync()
{
setAllWaiting(false);
setPlayerClockTime(() => player1, -CatchUpSyncManager.SYNC_TARGET - 1);
// This is a silent catchup, where IsCatchingUp = false but IsRunning = false also.
assertCatchingUp(() => player1, false);
assertPlayerClockState(() => player1, false);
setMasterTime(1);
assertCatchingUp(() => player1, false);
assertPlayerClockState(() => player1, true);
}
[Test]
public void TestInSyncPlayerClockDoesNotStartIfWaitingOnFrames()
{
setAllWaiting(false);
assertPlayerClockState(() => player1, true);
setWaiting(() => player1, true);
assertPlayerClockState(() => player1, false);
}
private void setWaiting(Func<TestSpectatorPlayerClock> playerClock, bool waiting)
=> AddStep($"set player clock {playerClock().Id} waiting = {waiting}", () => playerClock().WaitingOnFrames.Value = waiting);
private void setAllWaiting(bool waiting) => AddStep($"set all player clocks waiting = {waiting}", () =>
{
player1.WaitingOnFrames.Value = waiting;
player2.WaitingOnFrames.Value = waiting;
});
private void setMasterTime(double time)
=> AddStep($"set master = {time}", () => master.Seek(time));
/// <summary>
/// clock.Time = master.Time - offsetFromMaster
/// </summary>
private void setPlayerClockTime(Func<TestSpectatorPlayerClock> playerClock, double offsetFromMaster)
=> AddStep($"set player clock {playerClock().Id} = master - {offsetFromMaster}", () => playerClock().Seek(master.CurrentTime - offsetFromMaster));
private void assertMasterState(bool running)
=> AddAssert($"master clock {(running ? "is" : "is not")} running", () => master.IsRunning == running);
private void assertCatchingUp(Func<TestSpectatorPlayerClock> playerClock, bool catchingUp) =>
AddAssert($"player clock {playerClock().Id} {(catchingUp ? "is" : "is not")} catching up", () => playerClock().IsCatchingUp == catchingUp);
private void assertPlayerClockState(Func<TestSpectatorPlayerClock> playerClock, bool running)
=> AddAssert($"player clock {playerClock().Id} {(running ? "is" : "is not")} running", () => playerClock().IsRunning == running);
private class TestSpectatorPlayerClock : TestManualClock, ISpectatorPlayerClock
{
public Bindable<bool> WaitingOnFrames { get; } = new Bindable<bool>(true);
public bool IsCatchingUp { get; set; }
public IFrameBasedClock Source
{
set => throw new NotImplementedException();
}
public readonly int Id;
public TestSpectatorPlayerClock(int id)
{
Id = id;
WaitingOnFrames.BindValueChanged(waiting =>
{
if (waiting.NewValue)
Stop();
else
Start();
});
}
public void ProcessFrame()
{
}
public double ElapsedFrameTime => 0;
public double FramesPerSecond => 0;
public FrameTimeInfo TimeInfo => default;
}
private class TestManualClock : ManualClock, IAdjustableClock
{
public void Start() => IsRunning = true;
public void Stop() => IsRunning = false;
public bool Seek(double position)
{
CurrentTime = position;
return true;
}
public void Reset()
{
}
public void ResetSpeedAdjustments()
{
}
}
}
}

View File

@ -1,34 +1,32 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Screens; using osu.Framework.Screens;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Database; using osu.Game.Database;
using osu.Game.Online;
using osu.Game.Online.Spectator; using osu.Game.Online.Spectator;
using osu.Game.Replays.Legacy;
using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Replays; using osu.Game.Rulesets.Osu.Replays;
using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Screens.Play; using osu.Game.Screens.Play;
using osu.Game.Tests.Beatmaps.IO; using osu.Game.Tests.Beatmaps.IO;
using osu.Game.Tests.Visual.Multiplayer;
using osu.Game.Tests.Visual.Spectator;
using osu.Game.Users; using osu.Game.Users;
namespace osu.Game.Tests.Visual.Gameplay namespace osu.Game.Tests.Visual.Gameplay
{ {
public class TestSceneSpectator : ScreenTestScene public class TestSceneSpectator : ScreenTestScene
{ {
private readonly User streamingUser = new User { Id = MultiplayerTestScene.PLAYER_1_ID, Username = "Test user" };
[Cached(typeof(SpectatorStreamingClient))] [Cached(typeof(SpectatorStreamingClient))]
private TestSpectatorStreamingClient testSpectatorStreamingClient = new TestSpectatorStreamingClient(); private TestSpectatorStreamingClient testSpectatorStreamingClient = new TestSpectatorStreamingClient();
@ -214,9 +212,9 @@ namespace osu.Game.Tests.Visual.Gameplay
private void waitForPlayer() => AddUntilStep("wait for player", () => Stack.CurrentScreen is Player); private void waitForPlayer() => AddUntilStep("wait for player", () => Stack.CurrentScreen is Player);
private void start(int? beatmapId = null) => AddStep("start play", () => testSpectatorStreamingClient.StartPlay(beatmapId ?? importedBeatmapId)); private void start(int? beatmapId = null) => AddStep("start play", () => testSpectatorStreamingClient.StartPlay(streamingUser.Id, beatmapId ?? importedBeatmapId));
private void finish(int? beatmapId = null) => AddStep("end play", () => testSpectatorStreamingClient.EndPlay(beatmapId ?? importedBeatmapId)); private void finish(int? beatmapId = null) => AddStep("end play", () => testSpectatorStreamingClient.EndPlay(streamingUser.Id, beatmapId ?? importedBeatmapId));
private void checkPaused(bool state) => private void checkPaused(bool state) =>
AddUntilStep($"game is {(state ? "paused" : "playing")}", () => player.ChildrenOfType<DrawableRuleset>().First().IsPaused.Value == state); AddUntilStep($"game is {(state ? "paused" : "playing")}", () => player.ChildrenOfType<DrawableRuleset>().First().IsPaused.Value == state);
@ -225,89 +223,17 @@ namespace osu.Game.Tests.Visual.Gameplay
{ {
AddStep("send frames", () => AddStep("send frames", () =>
{ {
testSpectatorStreamingClient.SendFrames(nextFrame, count); testSpectatorStreamingClient.SendFrames(streamingUser.Id, nextFrame, count);
nextFrame += count; nextFrame += count;
}); });
} }
private void loadSpectatingScreen() private void loadSpectatingScreen()
{ {
AddStep("load screen", () => LoadScreen(spectatorScreen = new SoloSpectator(testSpectatorStreamingClient.StreamingUser))); AddStep("load screen", () => LoadScreen(spectatorScreen = new SoloSpectator(streamingUser)));
AddUntilStep("wait for screen load", () => spectatorScreen.LoadState == LoadState.Loaded); AddUntilStep("wait for screen load", () => spectatorScreen.LoadState == LoadState.Loaded);
} }
public class TestSpectatorStreamingClient : SpectatorStreamingClient
{
public readonly User StreamingUser = new User { Id = 55, Username = "Test user" };
public new BindableList<int> PlayingUsers => (BindableList<int>)base.PlayingUsers;
private int beatmapId;
public TestSpectatorStreamingClient()
: base(new DevelopmentEndpointConfiguration())
{
}
public void StartPlay(int beatmapId)
{
this.beatmapId = beatmapId;
sendState(beatmapId);
}
public void EndPlay(int beatmapId)
{
((ISpectatorClient)this).UserFinishedPlaying(StreamingUser.Id, new SpectatorState
{
BeatmapID = beatmapId,
RulesetID = 0,
});
sentState = false;
}
private bool sentState;
public void SendFrames(int index, int count)
{
var frames = new List<LegacyReplayFrame>();
for (int i = index; i < index + count; i++)
{
var buttonState = i == index + count - 1 ? ReplayButtonState.None : ReplayButtonState.Left1;
frames.Add(new LegacyReplayFrame(i * 100, RNG.Next(0, 512), RNG.Next(0, 512), buttonState));
}
var bundle = new FrameDataBundle(new ScoreInfo(), frames);
((ISpectatorClient)this).UserSentFrames(StreamingUser.Id, bundle);
if (!sentState)
sendState(beatmapId);
}
public override void WatchUser(int userId)
{
if (!PlayingUsers.Contains(userId) && sentState)
{
// usually the server would do this.
sendState(beatmapId);
}
base.WatchUser(userId);
}
private void sendState(int beatmapId)
{
sentState = true;
((ISpectatorClient)this).UserBeganPlaying(StreamingUser.Id, new SpectatorState
{
BeatmapID = beatmapId,
RulesetID = 0,
});
}
}
internal class TestUserLookupCache : UserLookupCache internal class TestUserLookupCache : UserLookupCache
{ {
protected override Task<User> ComputeValueAsync(int lookup, CancellationToken token = default) => Task.FromResult(new User protected override Task<User> ComputeValueAsync(int lookup, CancellationToken token = default) => Task.FromResult(new User

View File

@ -11,20 +11,17 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Framework.Timing; using osu.Framework.Timing;
using osu.Framework.Utils;
using osu.Game.Database; using osu.Game.Database;
using osu.Game.Online;
using osu.Game.Online.Spectator; using osu.Game.Online.Spectator;
using osu.Game.Replays.Legacy;
using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Osu.Scoring;
using osu.Game.Scoring;
using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate;
using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD;
using osu.Game.Tests.Visual.Spectator;
using osu.Game.Users; using osu.Game.Users;
namespace osu.Game.Tests.Visual.Multiplayer namespace osu.Game.Tests.Visual.Multiplayer
{ {
public class TestSceneMultiplayerSpectatorLeaderboard : MultiplayerTestScene public class TestSceneMultiSpectatorLeaderboard : MultiplayerTestScene
{ {
[Cached(typeof(SpectatorStreamingClient))] [Cached(typeof(SpectatorStreamingClient))]
private TestSpectatorStreamingClient streamingClient = new TestSpectatorStreamingClient(); private TestSpectatorStreamingClient streamingClient = new TestSpectatorStreamingClient();
@ -37,11 +34,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
private readonly Dictionary<int, ManualClock> clocks = new Dictionary<int, ManualClock> private readonly Dictionary<int, ManualClock> clocks = new Dictionary<int, ManualClock>
{ {
{ 55, new ManualClock() }, { PLAYER_1_ID, new ManualClock() },
{ 56, new ManualClock() } { PLAYER_2_ID, new ManualClock() }
}; };
public TestSceneMultiplayerSpectatorLeaderboard() public TestSceneMultiSpectatorLeaderboard()
{ {
base.Content.AddRange(new Drawable[] base.Content.AddRange(new Drawable[]
{ {
@ -54,7 +51,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
[SetUpSteps] [SetUpSteps]
public new void SetUpSteps() public new void SetUpSteps()
{ {
MultiplayerSpectatorLeaderboard leaderboard = null; MultiSpectatorLeaderboard leaderboard = null;
AddStep("reset", () => AddStep("reset", () =>
{ {
@ -78,7 +75,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
var scoreProcessor = new OsuScoreProcessor(); var scoreProcessor = new OsuScoreProcessor();
scoreProcessor.ApplyBeatmap(playable); scoreProcessor.ApplyBeatmap(playable);
LoadComponentAsync(leaderboard = new MultiplayerSpectatorLeaderboard(scoreProcessor, clocks.Keys.ToArray()) { Expanded = { Value = true } }, Add); LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(scoreProcessor, clocks.Keys.ToArray()) { Expanded = { Value = true } }, Add);
}); });
AddUntilStep("wait for load", () => leaderboard.IsLoaded); AddUntilStep("wait for load", () => leaderboard.IsLoaded);
@ -95,46 +92,46 @@ namespace osu.Game.Tests.Visual.Multiplayer
{ {
AddStep("send frames", () => AddStep("send frames", () =>
{ {
// For user 55, send frames in sets of 1. // For player 1, send frames in sets of 1.
// For user 56, send frames in sets of 10. // For player 2, send frames in sets of 10.
for (int i = 0; i < 100; i++) for (int i = 0; i < 100; i++)
{ {
streamingClient.SendFrames(55, i, 1); streamingClient.SendFrames(PLAYER_1_ID, i, 1);
if (i % 10 == 0) if (i % 10 == 0)
streamingClient.SendFrames(56, i, 10); streamingClient.SendFrames(PLAYER_2_ID, i, 10);
} }
}); });
assertCombo(55, 1); assertCombo(PLAYER_1_ID, 1);
assertCombo(56, 10); assertCombo(PLAYER_2_ID, 10);
// Advance to a point where only user 55's frame changes. // Advance to a point where only user player 1's frame changes.
setTime(500); setTime(500);
assertCombo(55, 5); assertCombo(PLAYER_1_ID, 5);
assertCombo(56, 10); assertCombo(PLAYER_2_ID, 10);
// Advance to a point where both user's frame changes. // Advance to a point where both user's frame changes.
setTime(1100); setTime(1100);
assertCombo(55, 11); assertCombo(PLAYER_1_ID, 11);
assertCombo(56, 20); assertCombo(PLAYER_2_ID, 20);
// Advance user 56 only to a point where its frame changes. // Advance user player 2 only to a point where its frame changes.
setTime(56, 2100); setTime(PLAYER_2_ID, 2100);
assertCombo(55, 11); assertCombo(PLAYER_1_ID, 11);
assertCombo(56, 30); assertCombo(PLAYER_2_ID, 30);
// Advance both users beyond their last frame // Advance both users beyond their last frame
setTime(101 * 100); setTime(101 * 100);
assertCombo(55, 100); assertCombo(PLAYER_1_ID, 100);
assertCombo(56, 100); assertCombo(PLAYER_2_ID, 100);
} }
[Test] [Test]
public void TestNoFrames() public void TestNoFrames()
{ {
assertCombo(55, 0); assertCombo(PLAYER_1_ID, 0);
assertCombo(56, 0); assertCombo(PLAYER_2_ID, 0);
} }
private void setTime(double time) => AddStep($"set time {time}", () => private void setTime(double time) => AddStep($"set time {time}", () =>
@ -149,71 +146,6 @@ namespace osu.Game.Tests.Visual.Multiplayer
private void assertCombo(int userId, int expectedCombo) private void assertCombo(int userId, int expectedCombo)
=> AddUntilStep($"player {userId} has {expectedCombo} combo", () => this.ChildrenOfType<GameplayLeaderboardScore>().Single(s => s.User?.Id == userId).Combo.Value == expectedCombo); => AddUntilStep($"player {userId} has {expectedCombo} combo", () => this.ChildrenOfType<GameplayLeaderboardScore>().Single(s => s.User?.Id == userId).Combo.Value == expectedCombo);
private class TestSpectatorStreamingClient : SpectatorStreamingClient
{
private readonly Dictionary<int, int> userBeatmapDictionary = new Dictionary<int, int>();
private readonly Dictionary<int, bool> userSentStateDictionary = new Dictionary<int, bool>();
public TestSpectatorStreamingClient()
: base(new DevelopmentEndpointConfiguration())
{
}
public void StartPlay(int userId, int beatmapId)
{
userBeatmapDictionary[userId] = beatmapId;
userSentStateDictionary[userId] = false;
sendState(userId, beatmapId);
}
public void EndPlay(int userId, int beatmapId)
{
((ISpectatorClient)this).UserFinishedPlaying(userId, new SpectatorState
{
BeatmapID = beatmapId,
RulesetID = 0,
});
userSentStateDictionary[userId] = false;
}
public void SendFrames(int userId, int index, int count)
{
var frames = new List<LegacyReplayFrame>();
for (int i = index; i < index + count; i++)
{
var buttonState = i == index + count - 1 ? ReplayButtonState.None : ReplayButtonState.Left1;
frames.Add(new LegacyReplayFrame(i * 100, RNG.Next(0, 512), RNG.Next(0, 512), buttonState));
}
var bundle = new FrameDataBundle(new ScoreInfo { Combo = index + count }, frames);
((ISpectatorClient)this).UserSentFrames(userId, bundle);
if (!userSentStateDictionary[userId])
sendState(userId, userBeatmapDictionary[userId]);
}
public override void WatchUser(int userId)
{
if (userSentStateDictionary[userId])
{
// usually the server would do this.
sendState(userId, userBeatmapDictionary[userId]);
}
base.WatchUser(userId);
}
private void sendState(int userId, int beatmapId)
{
((ISpectatorClient)this).UserBeganPlaying(userId, new SpectatorState
{
BeatmapID = beatmapId,
RulesetID = 0,
});
userSentStateDictionary[userId] = true;
}
}
private class TestUserLookupCache : UserLookupCache private class TestUserLookupCache : UserLookupCache
{ {
protected override Task<User> ComputeValueAsync(int lookup, CancellationToken token = default) protected override Task<User> ComputeValueAsync(int lookup, CancellationToken token = default)

View File

@ -0,0 +1,313 @@
// 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.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Online.Spectator;
using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate;
using osu.Game.Screens.Play;
using osu.Game.Tests.Beatmaps.IO;
using osu.Game.Tests.Visual.Spectator;
using osu.Game.Users;
namespace osu.Game.Tests.Visual.Multiplayer
{
public class TestSceneMultiSpectatorScreen : MultiplayerTestScene
{
[Cached(typeof(SpectatorStreamingClient))]
private TestSpectatorStreamingClient streamingClient = new TestSpectatorStreamingClient();
[Cached(typeof(UserLookupCache))]
private UserLookupCache lookupCache = new TestUserLookupCache();
[Resolved]
private OsuGameBase game { get; set; }
[Resolved]
private BeatmapManager beatmapManager { get; set; }
private MultiSpectatorScreen spectatorScreen;
private readonly List<int> playingUserIds = new List<int>();
private readonly Dictionary<int, int> nextFrame = new Dictionary<int, int>();
private BeatmapSetInfo importedSet;
private BeatmapInfo importedBeatmap;
private int importedBeatmapId;
[BackgroundDependencyLoader]
private void load()
{
importedSet = ImportBeatmapTest.LoadOszIntoOsu(game, virtualTrack: true).Result;
importedBeatmap = importedSet.Beatmaps.First(b => b.RulesetID == 0);
importedBeatmapId = importedBeatmap.OnlineBeatmapID ?? -1;
}
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("reset sent frames", () => nextFrame.Clear());
AddStep("add streaming client", () =>
{
Remove(streamingClient);
Add(streamingClient);
});
AddStep("finish previous gameplay", () =>
{
foreach (var id in playingUserIds)
streamingClient.EndPlay(id, importedBeatmapId);
playingUserIds.Clear();
});
}
[Test]
public void TestDelayedStart()
{
AddStep("start players silently", () =>
{
Client.CurrentMatchPlayingUserIds.Add(PLAYER_1_ID);
Client.CurrentMatchPlayingUserIds.Add(PLAYER_2_ID);
playingUserIds.Add(PLAYER_1_ID);
playingUserIds.Add(PLAYER_2_ID);
nextFrame[PLAYER_1_ID] = 0;
nextFrame[PLAYER_2_ID] = 0;
});
loadSpectateScreen(false);
AddWaitStep("wait a bit", 10);
AddStep("load player first_player_id", () => streamingClient.StartPlay(PLAYER_1_ID, importedBeatmapId));
AddUntilStep("one player added", () => spectatorScreen.ChildrenOfType<Player>().Count() == 1);
AddWaitStep("wait a bit", 10);
AddStep("load player second_player_id", () => streamingClient.StartPlay(PLAYER_2_ID, importedBeatmapId));
AddUntilStep("two players added", () => spectatorScreen.ChildrenOfType<Player>().Count() == 2);
}
[Test]
public void TestGeneral()
{
int[] userIds = Enumerable.Range(0, 4).Select(i => PLAYER_1_ID + i).ToArray();
start(userIds);
loadSpectateScreen();
sendFrames(userIds, 1000);
AddWaitStep("wait a bit", 20);
}
[Test]
public void TestPlayersMustStartSimultaneously()
{
start(new[] { PLAYER_1_ID, PLAYER_2_ID });
loadSpectateScreen();
// Send frames for one player only, both should remain paused.
sendFrames(PLAYER_1_ID, 20);
checkPausedInstant(PLAYER_1_ID, true);
checkPausedInstant(PLAYER_2_ID, true);
// Send frames for the other player, both should now start playing.
sendFrames(PLAYER_2_ID, 20);
checkPausedInstant(PLAYER_1_ID, false);
checkPausedInstant(PLAYER_2_ID, false);
}
[Test]
public void TestPlayersDoNotStartSimultaneouslyIfBufferingForMaximumStartDelay()
{
start(new[] { PLAYER_1_ID, PLAYER_2_ID });
loadSpectateScreen();
// Send frames for one player only, both should remain paused.
sendFrames(PLAYER_1_ID, 1000);
checkPausedInstant(PLAYER_1_ID, true);
checkPausedInstant(PLAYER_2_ID, true);
// Wait for the start delay seconds...
AddWaitStep("wait maximum start delay seconds", (int)(CatchUpSyncManager.MAXIMUM_START_DELAY / TimePerAction));
// Player 1 should start playing by itself, player 2 should remain paused.
checkPausedInstant(PLAYER_1_ID, false);
checkPausedInstant(PLAYER_2_ID, true);
}
[Test]
public void TestPlayersContinueWhileOthersBuffer()
{
start(new[] { PLAYER_1_ID, PLAYER_2_ID });
loadSpectateScreen();
// Send initial frames for both players. A few more for player 1.
sendFrames(PLAYER_1_ID, 20);
sendFrames(PLAYER_2_ID, 10);
checkPausedInstant(PLAYER_1_ID, false);
checkPausedInstant(PLAYER_2_ID, false);
// Eventually player 2 will pause, player 1 must remain running.
checkPaused(PLAYER_2_ID, true);
checkPausedInstant(PLAYER_1_ID, false);
// Eventually both players will run out of frames and should pause.
checkPaused(PLAYER_1_ID, true);
checkPausedInstant(PLAYER_2_ID, true);
// Send more frames for the first player only. Player 1 should start playing with player 2 remaining paused.
sendFrames(PLAYER_1_ID, 20);
checkPausedInstant(PLAYER_2_ID, true);
checkPausedInstant(PLAYER_1_ID, false);
// Send more frames for the second player. Both should be playing
sendFrames(PLAYER_2_ID, 20);
checkPausedInstant(PLAYER_2_ID, false);
checkPausedInstant(PLAYER_1_ID, false);
}
[Test]
public void TestPlayersCatchUpAfterFallingBehind()
{
start(new[] { PLAYER_1_ID, PLAYER_2_ID });
loadSpectateScreen();
// Send initial frames for both players. A few more for player 1.
sendFrames(PLAYER_1_ID, 1000);
sendFrames(PLAYER_2_ID, 10);
checkPausedInstant(PLAYER_1_ID, false);
checkPausedInstant(PLAYER_2_ID, false);
// Eventually player 2 will run out of frames and should pause.
checkPaused(PLAYER_2_ID, true);
AddWaitStep("wait a few more frames", 10);
// Send more frames for player 2. It should unpause.
sendFrames(PLAYER_2_ID, 1000);
checkPausedInstant(PLAYER_2_ID, false);
// Player 2 should catch up to player 1 after unpausing.
waitForCatchup(PLAYER_2_ID);
AddWaitStep("wait a bit", 10);
}
[Test]
public void TestMostInSyncUserIsAudioSource()
{
start(new[] { PLAYER_1_ID, PLAYER_2_ID });
loadSpectateScreen();
assertMuted(PLAYER_1_ID, true);
assertMuted(PLAYER_2_ID, true);
sendFrames(PLAYER_1_ID, 10);
sendFrames(PLAYER_2_ID, 20);
assertMuted(PLAYER_1_ID, false);
assertMuted(PLAYER_2_ID, true);
checkPaused(PLAYER_1_ID, true);
assertMuted(PLAYER_1_ID, true);
assertMuted(PLAYER_2_ID, false);
sendFrames(PLAYER_1_ID, 100);
waitForCatchup(PLAYER_1_ID);
checkPaused(PLAYER_2_ID, true);
assertMuted(PLAYER_1_ID, false);
assertMuted(PLAYER_2_ID, true);
sendFrames(PLAYER_2_ID, 100);
waitForCatchup(PLAYER_2_ID);
assertMuted(PLAYER_1_ID, false);
assertMuted(PLAYER_2_ID, true);
}
private void loadSpectateScreen(bool waitForPlayerLoad = true)
{
AddStep("load screen", () =>
{
Beatmap.Value = beatmapManager.GetWorkingBeatmap(importedBeatmap);
Ruleset.Value = importedBeatmap.Ruleset;
LoadScreen(spectatorScreen = new MultiSpectatorScreen(playingUserIds.ToArray()));
});
AddUntilStep("wait for screen load", () => spectatorScreen.LoadState == LoadState.Loaded && (!waitForPlayerLoad || spectatorScreen.AllPlayersLoaded));
}
private void start(int userId, int? beatmapId = null) => start(new[] { userId }, beatmapId);
private void start(int[] userIds, int? beatmapId = null)
{
AddStep("start play", () =>
{
foreach (int id in userIds)
{
Client.CurrentMatchPlayingUserIds.Add(id);
streamingClient.StartPlay(id, beatmapId ?? importedBeatmapId);
playingUserIds.Add(id);
nextFrame[id] = 0;
}
});
}
private void finish(int userId, int? beatmapId = null)
{
AddStep("end play", () =>
{
streamingClient.EndPlay(userId, beatmapId ?? importedBeatmapId);
playingUserIds.Remove(userId);
nextFrame.Remove(userId);
});
}
private void sendFrames(int userId, int count = 10) => sendFrames(new[] { userId }, count);
private void sendFrames(int[] userIds, int count = 10)
{
AddStep("send frames", () =>
{
foreach (int id in userIds)
{
streamingClient.SendFrames(id, nextFrame[id], count);
nextFrame[id] += count;
}
});
}
private void checkPaused(int userId, bool state)
=> AddUntilStep($"{userId} is {(state ? "paused" : "playing")}", () => getPlayer(userId).ChildrenOfType<GameplayClockContainer>().First().GameplayClock.IsRunning != state);
private void checkPausedInstant(int userId, bool state)
=> AddAssert($"{userId} is {(state ? "paused" : "playing")}", () => getPlayer(userId).ChildrenOfType<GameplayClockContainer>().First().GameplayClock.IsRunning != state);
private void assertMuted(int userId, bool muted)
=> AddAssert($"{userId} {(muted ? "is" : "is not")} muted", () => getInstance(userId).Mute == muted);
private void waitForCatchup(int userId)
=> AddUntilStep($"{userId} not catching up", () => !getInstance(userId).GameplayClock.IsCatchingUp);
private Player getPlayer(int userId) => getInstance(userId).ChildrenOfType<Player>().Single();
private PlayerArea getInstance(int userId) => spectatorScreen.ChildrenOfType<PlayerArea>().Single(p => p.UserId == userId);
internal class TestUserLookupCache : UserLookupCache
{
protected override Task<User> ComputeValueAsync(int lookup, CancellationToken token = default)
{
return Task.FromResult(new User
{
Id = lookup,
Username = $"User {lookup}"
});
}
}
}
}

View File

@ -1,9 +1,25 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Platform;
using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Osu;
using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Components;
using osu.Game.Screens.OnlinePlay.Multiplayer;
using osu.Game.Screens.OnlinePlay.Multiplayer.Match;
using osu.Game.Tests.Resources;
using osu.Game.Users;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Multiplayer namespace osu.Game.Tests.Visual.Multiplayer
{ {
@ -11,7 +27,158 @@ namespace osu.Game.Tests.Visual.Multiplayer
{ {
private TestMultiplayer multiplayerScreen; private TestMultiplayer multiplayerScreen;
private BeatmapManager beatmaps;
private RulesetStore rulesets;
private BeatmapSetInfo importedSet;
private TestMultiplayerClient client => multiplayerScreen.Client;
private Room room => client.APIRoom;
public TestSceneMultiplayer() public TestSceneMultiplayer()
{
loadMultiplayer();
}
[BackgroundDependencyLoader]
private void load(GameHost host, AudioManager audio)
{
Dependencies.Cache(rulesets = new RulesetStore(ContextFactory));
Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, host, Beatmap.Default));
}
[SetUp]
public void Setup() => Schedule(() =>
{
beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).Wait();
importedSet = beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.All).First();
});
[Test]
public void TestUserSetToIdleWhenBeatmapDeleted()
{
loadMultiplayer();
createRoom(() => new Room
{
Name = { Value = "Test Room" },
Playlist =
{
new PlaylistItem
{
Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo },
Ruleset = { Value = new OsuRuleset().RulesetInfo },
}
}
});
AddStep("set user ready", () => client.ChangeState(MultiplayerUserState.Ready));
AddStep("delete beatmap", () => beatmaps.Delete(importedSet));
AddAssert("user state is idle", () => client.LocalUser?.State == MultiplayerUserState.Idle);
}
[Test]
public void TestLocalPlayDoesNotStartWhileSpectatingWithNoBeatmap()
{
loadMultiplayer();
createRoom(() => new Room
{
Name = { Value = "Test Room" },
Playlist =
{
new PlaylistItem
{
Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo },
Ruleset = { Value = new OsuRuleset().RulesetInfo },
}
}
});
AddStep("join other user (ready, host)", () =>
{
client.AddUser(new User { Id = MultiplayerTestScene.PLAYER_1_ID, Username = "Other" });
client.TransferHost(MultiplayerTestScene.PLAYER_1_ID);
client.ChangeUserState(MultiplayerTestScene.PLAYER_1_ID, MultiplayerUserState.Ready);
});
AddStep("delete beatmap", () => beatmaps.Delete(importedSet));
AddStep("click spectate button", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<MultiplayerSpectateButton>().Single());
InputManager.Click(MouseButton.Left);
});
AddStep("start match externally", () => client.StartMatch());
AddAssert("play not started", () => multiplayerScreen.IsCurrentScreen());
}
[Test]
public void TestLocalPlayStartsWhileSpectatingWhenBeatmapBecomesAvailable()
{
loadMultiplayer();
createRoom(() => new Room
{
Name = { Value = "Test Room" },
Playlist =
{
new PlaylistItem
{
Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo },
Ruleset = { Value = new OsuRuleset().RulesetInfo },
}
}
});
AddStep("delete beatmap", () => beatmaps.Delete(importedSet));
AddStep("join other user (ready, host)", () =>
{
client.AddUser(new User { Id = MultiplayerTestScene.PLAYER_1_ID, Username = "Other" });
client.TransferHost(MultiplayerTestScene.PLAYER_1_ID);
client.ChangeUserState(MultiplayerTestScene.PLAYER_1_ID, MultiplayerUserState.Ready);
});
AddStep("click spectate button", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<MultiplayerSpectateButton>().Single());
InputManager.Click(MouseButton.Left);
});
AddStep("start match externally", () => client.StartMatch());
AddStep("restore beatmap", () =>
{
beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).Wait();
importedSet = beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.All).First();
});
AddUntilStep("play started", () => !multiplayerScreen.IsCurrentScreen());
}
private void createRoom(Func<Room> room)
{
AddStep("open room", () =>
{
multiplayerScreen.OpenNewRoom(room());
});
AddUntilStep("wait for room open", () => this.ChildrenOfType<MultiplayerMatchSubScreen>().FirstOrDefault()?.IsLoaded == true);
AddWaitStep("wait for transition", 2);
AddStep("create room", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<MultiplayerMatchSettingsOverlay.CreateOrUpdateButton>().Single());
InputManager.Click(MouseButton.Left);
});
AddUntilStep("wait for join", () => client.Room != null);
}
private void loadMultiplayer()
{ {
AddStep("show", () => AddStep("show", () =>
{ {

View File

@ -6,14 +6,12 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Framework.Utils; using osu.Framework.Utils;
using osu.Game.Configuration; using osu.Game.Configuration;
using osu.Game.Database; using osu.Game.Database;
using osu.Game.Online;
using osu.Game.Online.API; using osu.Game.Online.API;
using osu.Game.Online.Spectator; using osu.Game.Online.Spectator;
using osu.Game.Replays.Legacy; using osu.Game.Replays.Legacy;
@ -22,6 +20,7 @@ using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring; using osu.Game.Scoring;
using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD;
using osu.Game.Tests.Visual.Online; using osu.Game.Tests.Visual.Online;
using osu.Game.Tests.Visual.Spectator;
namespace osu.Game.Tests.Visual.Multiplayer namespace osu.Game.Tests.Visual.Multiplayer
{ {
@ -30,7 +29,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
private const int users = 16; private const int users = 16;
[Cached(typeof(SpectatorStreamingClient))] [Cached(typeof(SpectatorStreamingClient))]
private TestMultiplayerStreaming streamingClient = new TestMultiplayerStreaming(users); private TestMultiplayerStreaming streamingClient = new TestMultiplayerStreaming();
[Cached(typeof(UserLookupCache))] [Cached(typeof(UserLookupCache))]
private UserLookupCache lookupCache = new TestSceneCurrentlyPlayingDisplay.TestUserLookupCache(); private UserLookupCache lookupCache = new TestSceneCurrentlyPlayingDisplay.TestUserLookupCache();
@ -71,7 +70,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
var playable = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value); var playable = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value);
streamingClient.Start(Beatmap.Value.BeatmapInfo.OnlineBeatmapID ?? 0); for (int i = 0; i < users; i++)
streamingClient.StartPlay(i, Beatmap.Value.BeatmapInfo.OnlineBeatmapID ?? 0);
Client.CurrentMatchPlayingUserIds.Clear(); Client.CurrentMatchPlayingUserIds.Clear();
Client.CurrentMatchPlayingUserIds.AddRange(streamingClient.PlayingUsers); Client.CurrentMatchPlayingUserIds.AddRange(streamingClient.PlayingUsers);
@ -114,30 +114,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("change to standardised", () => config.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Standardised)); AddStep("change to standardised", () => config.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Standardised));
} }
public class TestMultiplayerStreaming : SpectatorStreamingClient public class TestMultiplayerStreaming : TestSpectatorStreamingClient
{ {
public new BindableList<int> PlayingUsers => (BindableList<int>)base.PlayingUsers;
private readonly int totalUsers;
public TestMultiplayerStreaming(int totalUsers)
: base(new DevelopmentEndpointConfiguration())
{
this.totalUsers = totalUsers;
}
public void Start(int beatmapId)
{
for (int i = 0; i < totalUsers; i++)
{
((ISpectatorClient)this).UserBeganPlaying(i, new SpectatorState
{
BeatmapID = beatmapId,
RulesetID = 0,
});
}
}
private readonly Dictionary<int, FrameHeader> lastHeaders = new Dictionary<int, FrameHeader>(); private readonly Dictionary<int, FrameHeader> lastHeaders = new Dictionary<int, FrameHeader>();
public void RandomlyUpdateState() public void RandomlyUpdateState()

View File

@ -119,8 +119,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("join other user (ready)", () => AddStep("join other user (ready)", () =>
{ {
Client.AddUser(new User { Id = 55 }); Client.AddUser(new User { Id = PLAYER_1_ID });
Client.ChangeUserState(55, MultiplayerUserState.Ready); Client.ChangeUserState(PLAYER_1_ID, MultiplayerUserState.Ready);
}); });
AddStep("click spectate button", () => AddStep("click spectate button", () =>

View File

@ -120,9 +120,12 @@ namespace osu.Game.Tests.Visual.Multiplayer
}; };
}); });
[Test] [TestCase(MultiplayerRoomState.Open)]
public void TestEnabledWhenRoomOpen() [TestCase(MultiplayerRoomState.WaitingForLoad)]
[TestCase(MultiplayerRoomState.Playing)]
public void TestEnabledWhenRoomOpenOrInGameplay(MultiplayerRoomState roomState)
{ {
AddStep($"change room to {roomState}", () => Client.ChangeRoomState(roomState));
assertSpectateButtonEnablement(true); assertSpectateButtonEnablement(true);
} }
@ -137,12 +140,10 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddAssert("user is idle", () => Client.Room?.Users[0].State == MultiplayerUserState.Idle); AddAssert("user is idle", () => Client.Room?.Users[0].State == MultiplayerUserState.Idle);
} }
[TestCase(MultiplayerRoomState.WaitingForLoad)]
[TestCase(MultiplayerRoomState.Playing)]
[TestCase(MultiplayerRoomState.Closed)] [TestCase(MultiplayerRoomState.Closed)]
public void TestDisabledDuringGameplayOrClosed(MultiplayerRoomState roomState) public void TestDisabledWhenClosed(MultiplayerRoomState roomState)
{ {
AddStep($"change user to {roomState}", () => Client.ChangeRoomState(roomState)); AddStep($"change room to {roomState}", () => Client.ChangeRoomState(roomState));
assertSpectateButtonEnablement(false); assertSpectateButtonEnablement(false);
} }
@ -156,8 +157,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test] [Test]
public void TestReadyButtonEnabledWhenHostAndUsersReady() public void TestReadyButtonEnabledWhenHostAndUsersReady()
{ {
AddStep("add user", () => Client.AddUser(new User { Id = 55 })); AddStep("add user", () => Client.AddUser(new User { Id = PLAYER_1_ID }));
AddStep("set user ready", () => Client.ChangeUserState(55, MultiplayerUserState.Ready)); AddStep("set user ready", () => Client.ChangeUserState(PLAYER_1_ID, MultiplayerUserState.Ready));
addClickSpectateButtonStep(); addClickSpectateButtonStep();
assertReadyButtonEnablement(true); assertReadyButtonEnablement(true);
@ -168,11 +169,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
{ {
AddStep("add user and transfer host", () => AddStep("add user and transfer host", () =>
{ {
Client.AddUser(new User { Id = 55 }); Client.AddUser(new User { Id = PLAYER_1_ID });
Client.TransferHost(55); Client.TransferHost(PLAYER_1_ID);
}); });
AddStep("set user ready", () => Client.ChangeUserState(55, MultiplayerUserState.Ready)); AddStep("set user ready", () => Client.ChangeUserState(PLAYER_1_ID, MultiplayerUserState.Ready));
addClickSpectateButtonStep(); addClickSpectateButtonStep();
assertReadyButtonEnablement(false); assertReadyButtonEnablement(false);

View File

@ -12,7 +12,7 @@ using osu.Framework.Testing;
using osu.Game.Database; using osu.Game.Database;
using osu.Game.Online.Spectator; using osu.Game.Online.Spectator;
using osu.Game.Overlays.Dashboard; using osu.Game.Overlays.Dashboard;
using osu.Game.Tests.Visual.Gameplay; using osu.Game.Tests.Visual.Spectator;
using osu.Game.Users; using osu.Game.Users;
namespace osu.Game.Tests.Visual.Online namespace osu.Game.Tests.Visual.Online
@ -20,7 +20,7 @@ namespace osu.Game.Tests.Visual.Online
public class TestSceneCurrentlyPlayingDisplay : OsuTestScene public class TestSceneCurrentlyPlayingDisplay : OsuTestScene
{ {
[Cached(typeof(SpectatorStreamingClient))] [Cached(typeof(SpectatorStreamingClient))]
private TestSceneSpectator.TestSpectatorStreamingClient testSpectatorStreamingClient = new TestSceneSpectator.TestSpectatorStreamingClient(); private TestSpectatorStreamingClient testSpectatorStreamingClient = new TestSpectatorStreamingClient();
private CurrentlyPlayingDisplay currentlyPlaying; private CurrentlyPlayingDisplay currentlyPlaying;

View File

@ -96,9 +96,6 @@ namespace osu.Game.Online.Multiplayer
if (!IsConnected.Value) if (!IsConnected.Value)
return Task.CompletedTask; return Task.CompletedTask;
if (newState == MultiplayerUserState.Spectating)
return Task.CompletedTask; // Not supported yet.
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeState), newState); return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeState), newState);
} }

View File

@ -142,7 +142,11 @@ namespace osu.Game.Online.Spectator
if (!playingUsers.Contains(userId)) if (!playingUsers.Contains(userId))
playingUsers.Add(userId); playingUsers.Add(userId);
playingUserStates[userId] = state; // UserBeganPlaying() is called by the server regardless of whether the local user is watching the remote user, and is called a further time when the remote user is watched.
// This may be a temporary thing (see: https://github.com/ppy/osu-server-spectator/blob/2273778e02cfdb4a9c6a934f2a46a8459cb5d29c/osu.Server.Spectator/Hubs/SpectatorHub.cs#L28-L29).
// We don't want the user states to update unless the player is being watched, otherwise calling BindUserBeganPlaying() can lead to double invocations.
if (watchingUsers.Contains(userId))
playingUserStates[userId] = state;
} }
OnUserBeganPlaying?.Invoke(userId, state); OnUserBeganPlaying?.Invoke(userId, state);
@ -230,7 +234,7 @@ namespace osu.Game.Online.Spectator
connection.SendAsync(nameof(ISpectatorServer.StartWatchingUser), userId); connection.SendAsync(nameof(ISpectatorServer.StartWatchingUser), userId);
} }
public void StopWatchingUser(int userId) public virtual void StopWatchingUser(int userId)
{ {
lock (userLock) lock (userLock)
{ {

View File

@ -17,7 +17,6 @@ using osu.Game.Online.Rooms;
using osu.Game.Overlays; using osu.Game.Overlays;
using osu.Game.Overlays.Mods; using osu.Game.Overlays.Mods;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Screens.Play;
namespace osu.Game.Screens.OnlinePlay.Match namespace osu.Game.Screens.OnlinePlay.Match
{ {
@ -148,12 +147,18 @@ namespace osu.Game.Screens.OnlinePlay.Match
return base.OnExiting(next); return base.OnExiting(next);
} }
protected void StartPlay(Func<Player> player) protected void StartPlay()
{ {
sampleStart?.Play(); sampleStart?.Play();
ParentScreen?.Push(new PlayerLoader(player)); ParentScreen?.Push(CreateGameplayScreen());
} }
/// <summary>
/// Creates the gameplay screen to be entered.
/// </summary>
/// <returns>The screen to enter.</returns>
protected abstract Screen CreateGameplayScreen();
private void selectedItemChanged() private void selectedItemChanged()
{ {
updateWorkingBeatmap(); updateWorkingBeatmap();

View File

@ -81,7 +81,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
break; break;
} }
button.Enabled.Value = Client.Room?.State == MultiplayerRoomState.Open && !operationInProgress.Value; button.Enabled.Value = Client.Room?.State != MultiplayerRoomState.Closed && !operationInProgress.Value;
} }
private class ButtonWithTrianglesExposed : TriangleButton private class ButtonWithTrianglesExposed : TriangleButton

View File

@ -21,7 +21,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
{ {
base.OnResuming(last); base.OnResuming(last);
if (client.Room != null) if (client.Room != null && client.LocalUser?.State != MultiplayerUserState.Spectating)
client.ChangeState(MultiplayerUserState.Idle); client.ChangeState(MultiplayerUserState.Idle);
} }

View File

@ -14,6 +14,7 @@ using osu.Framework.Screens;
using osu.Framework.Threading; using osu.Framework.Threading;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Configuration; using osu.Game.Configuration;
using osu.Game.Online;
using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms; using osu.Game.Online.Rooms;
using osu.Game.Overlays; using osu.Game.Overlays;
@ -25,6 +26,7 @@ using osu.Game.Screens.OnlinePlay.Match;
using osu.Game.Screens.OnlinePlay.Match.Components; using osu.Game.Screens.OnlinePlay.Match.Components;
using osu.Game.Screens.OnlinePlay.Multiplayer.Match; using osu.Game.Screens.OnlinePlay.Multiplayer.Match;
using osu.Game.Screens.OnlinePlay.Multiplayer.Participants; using osu.Game.Screens.OnlinePlay.Multiplayer.Participants;
using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate;
using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD;
using osu.Game.Users; using osu.Game.Users;
using osuTK; using osuTK;
@ -353,10 +355,17 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
client.ChangeBeatmapAvailability(availability.NewValue); client.ChangeBeatmapAvailability(availability.NewValue);
// while this flow is handled server-side, this covers the edge case of the local user being in a ready state and then deleting the current beatmap. if (availability.NewValue.State != DownloadState.LocallyAvailable)
if (availability.NewValue != Online.Rooms.BeatmapAvailability.LocallyAvailable() {
&& client.LocalUser?.State == MultiplayerUserState.Ready) // while this flow is handled server-side, this covers the edge case of the local user being in a ready state and then deleting the current beatmap.
client.ChangeState(MultiplayerUserState.Idle); if (client.LocalUser?.State == MultiplayerUserState.Ready)
client.ChangeState(MultiplayerUserState.Idle);
}
else
{
if (client.LocalUser?.State == MultiplayerUserState.Spectating && (client.Room?.State == MultiplayerRoomState.WaitingForLoad || client.Room?.State == MultiplayerRoomState.Playing))
onLoadRequested();
}
} }
private void onReadyClick() private void onReadyClick()
@ -407,22 +416,46 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
private void onRoomUpdated() private void onRoomUpdated()
{ {
// user mods may have changed.
Scheduler.AddOnce(UpdateMods); Scheduler.AddOnce(UpdateMods);
} }
private void onLoadRequested() private void onLoadRequested()
{ {
Debug.Assert(client.Room != null); if (BeatmapAvailability.Value.State != DownloadState.LocallyAvailable)
return;
int[] userIds = client.CurrentMatchPlayingUserIds.ToArray(); // 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();
StartPlay(() => new MultiplayerPlayer(SelectedItem.Value, userIds)); Schedule(onLoadRequested);
return;
}
StartPlay();
readyClickOperation?.Dispose(); readyClickOperation?.Dispose();
readyClickOperation = null; readyClickOperation = null;
} }
protected override Screen CreateGameplayScreen()
{
Debug.Assert(client.LocalUser != null);
int[] userIds = client.CurrentMatchPlayingUserIds.ToArray();
switch (client.LocalUser.State)
{
case MultiplayerUserState.Spectating:
return new MultiSpectatorScreen(userIds);
default:
return new MultiplayerPlayer(SelectedItem.Value, userIds);
}
}
protected override void Dispose(bool isDisposing) protected override void Dispose(bool isDisposing)
{ {
base.Dispose(isDisposing); base.Dispose(isDisposing);

View File

@ -0,0 +1,84 @@
// 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.
#nullable enable
using System;
using osu.Framework.Bindables;
using osu.Framework.Timing;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
/// <summary>
/// A <see cref="ISpectatorPlayerClock"/> which catches up using rate adjustment.
/// </summary>
public class CatchUpSpectatorPlayerClock : ISpectatorPlayerClock
{
/// <summary>
/// The catch up rate.
/// </summary>
public const double CATCHUP_RATE = 2;
/// <summary>
/// The source clock.
/// </summary>
public IFrameBasedClock? Source { get; set; }
public double CurrentTime { get; private set; }
public bool IsRunning { get; private set; }
public void Reset() => CurrentTime = 0;
public void Start() => IsRunning = true;
public void Stop() => IsRunning = false;
public bool Seek(double position) => true;
public void ResetSpeedAdjustments()
{
}
public double Rate => IsCatchingUp ? CATCHUP_RATE : 1;
double IAdjustableClock.Rate
{
get => Rate;
set => throw new NotSupportedException();
}
double IClock.Rate => Rate;
public void ProcessFrame()
{
ElapsedFrameTime = 0;
FramesPerSecond = 0;
if (Source == null)
return;
Source.ProcessFrame();
if (IsRunning)
{
double elapsedSource = Source.ElapsedFrameTime;
double elapsed = elapsedSource * Rate;
CurrentTime += elapsed;
ElapsedFrameTime = elapsed;
FramesPerSecond = Source.FramesPerSecond;
}
}
public double ElapsedFrameTime { get; private set; }
public double FramesPerSecond { get; private set; }
public FrameTimeInfo TimeInfo => new FrameTimeInfo { Elapsed = ElapsedFrameTime, Current = CurrentTime };
public Bindable<bool> WaitingOnFrames { get; } = new Bindable<bool>(true);
public bool IsCatchingUp { get; set; }
}
}

View File

@ -0,0 +1,153 @@
// 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.Collections.Generic;
using System.Linq;
using osu.Framework.Graphics;
using osu.Framework.Timing;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
/// <summary>
/// A <see cref="ISyncManager"/> which synchronises de-synced player clocks through catchup.
/// </summary>
public class CatchUpSyncManager : Component, ISyncManager
{
/// <summary>
/// The offset from the master clock to which player clocks should remain within to be considered in-sync.
/// </summary>
public const double SYNC_TARGET = 16;
/// <summary>
/// The offset from the master clock at which player clocks begin resynchronising.
/// </summary>
public const double MAX_SYNC_OFFSET = 50;
/// <summary>
/// The maximum delay to start gameplay, if any (but not all) player clocks are ready.
/// </summary>
public const double MAXIMUM_START_DELAY = 15000;
/// <summary>
/// The master clock which is used to control the timing of all player clocks clocks.
/// </summary>
public IAdjustableClock MasterClock { get; }
/// <summary>
/// The player clocks.
/// </summary>
private readonly List<ISpectatorPlayerClock> playerClocks = new List<ISpectatorPlayerClock>();
private bool hasStarted;
private double? firstStartAttemptTime;
public CatchUpSyncManager(IAdjustableClock master)
{
MasterClock = master;
}
public void AddPlayerClock(ISpectatorPlayerClock clock) => playerClocks.Add(clock);
public void RemovePlayerClock(ISpectatorPlayerClock clock) => playerClocks.Remove(clock);
protected override void Update()
{
base.Update();
if (!attemptStart())
{
// Ensure all player clocks are stopped until the start succeeds.
foreach (var clock in playerClocks)
clock.Stop();
return;
}
updateCatchup();
updateMasterClock();
}
/// <summary>
/// Attempts to start playback. Waits for all player clocks to have available frames for up to <see cref="MAXIMUM_START_DELAY"/> milliseconds.
/// </summary>
/// <returns>Whether playback was started and syncing should occur.</returns>
private bool attemptStart()
{
if (hasStarted)
return true;
if (playerClocks.Count == 0)
return false;
int readyCount = playerClocks.Count(s => !s.WaitingOnFrames.Value);
if (readyCount == playerClocks.Count)
return hasStarted = true;
if (readyCount > 0)
{
firstStartAttemptTime ??= Time.Current;
if (Time.Current - firstStartAttemptTime > MAXIMUM_START_DELAY)
return hasStarted = true;
}
return false;
}
/// <summary>
/// Updates the catchup states of all player clocks clocks.
/// </summary>
private void updateCatchup()
{
for (int i = 0; i < playerClocks.Count; i++)
{
var clock = playerClocks[i];
// How far this player's clock is out of sync, compared to the master clock.
// A negative value means the player is running fast (ahead); a positive value means the player is running behind (catching up).
double timeDelta = MasterClock.CurrentTime - clock.CurrentTime;
// Check that the player clock isn't too far ahead.
// This is a quiet case in which the catchup is done by the master clock, so IsCatchingUp is not set on the player clock.
if (timeDelta < -SYNC_TARGET)
{
clock.Stop();
continue;
}
// Make sure the player clock is running if it can.
if (!clock.WaitingOnFrames.Value)
clock.Start();
if (clock.IsCatchingUp)
{
// Stop the player clock from catching up if it's within the sync target.
if (timeDelta <= SYNC_TARGET)
clock.IsCatchingUp = false;
}
else
{
// Make the player clock start catching up if it's exceeded the maximum allowable sync offset.
if (timeDelta > MAX_SYNC_OFFSET)
clock.IsCatchingUp = true;
}
}
}
/// <summary>
/// Updates the master clock's running state.
/// </summary>
private void updateMasterClock()
{
bool anyInSync = playerClocks.Any(s => !s.IsCatchingUp);
if (MasterClock.IsRunning != anyInSync)
{
if (anyInSync)
MasterClock.Start();
else
MasterClock.Stop();
}
}
}
}

View File

@ -0,0 +1,29 @@
// 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 osu.Framework.Bindables;
using osu.Framework.Timing;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
/// <summary>
/// A clock which is used by <see cref="MultiSpectatorPlayer"/>s and managed by an <see cref="ISyncManager"/>.
/// </summary>
public interface ISpectatorPlayerClock : IFrameBasedClock, IAdjustableClock
{
/// <summary>
/// Whether this clock is waiting on frames to continue playback.
/// </summary>
Bindable<bool> WaitingOnFrames { get; }
/// <summary>
/// Whether this clock is resynchronising to the master clock.
/// </summary>
bool IsCatchingUp { get; set; }
/// <summary>
/// The source clock
/// </summary>
IFrameBasedClock Source { set; }
}
}

View File

@ -0,0 +1,30 @@
// 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 osu.Framework.Timing;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
/// <summary>
/// Manages the synchronisation between one or more <see cref="ISpectatorPlayerClock"/>s in relation to a master clock.
/// </summary>
public interface ISyncManager
{
/// <summary>
/// The master clock which player clocks should synchronise to.
/// </summary>
IAdjustableClock MasterClock { get; }
/// <summary>
/// Adds an <see cref="ISpectatorPlayerClock"/> to manage.
/// </summary>
/// <param name="clock">The <see cref="ISpectatorPlayerClock"/> to add.</param>
void AddPlayerClock(ISpectatorPlayerClock clock);
/// <summary>
/// Removes an <see cref="ISpectatorPlayerClock"/>, stopping it from being managed by this <see cref="ISyncManager"/>.
/// </summary>
/// <param name="clock">The <see cref="ISpectatorPlayerClock"/> to remove.</param>
void RemovePlayerClock(ISpectatorPlayerClock clock);
}
}

View File

@ -9,9 +9,9 @@ using osu.Game.Screens.Play.HUD;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{ {
public class MultiplayerSpectatorLeaderboard : MultiplayerGameplayLeaderboard public class MultiSpectatorLeaderboard : MultiplayerGameplayLeaderboard
{ {
public MultiplayerSpectatorLeaderboard(ScoreProcessor scoreProcessor, int[] userIds) public MultiSpectatorLeaderboard([NotNull] ScoreProcessor scoreProcessor, int[] userIds)
: base(scoreProcessor, userIds) : base(scoreProcessor, userIds)
{ {
} }

View File

@ -0,0 +1,73 @@
// 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 JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Timing;
using osu.Game.Beatmaps;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
/// <summary>
/// A single spectated player within a <see cref="MultiSpectatorScreen"/>.
/// </summary>
public class MultiSpectatorPlayer : SpectatorPlayer
{
private readonly Bindable<bool> waitingOnFrames = new Bindable<bool>(true);
private readonly Score score;
private readonly ISpectatorPlayerClock spectatorPlayerClock;
/// <summary>
/// Creates a new <see cref="MultiSpectatorPlayer"/>.
/// </summary>
/// <param name="score">The score containing the player's replay.</param>
/// <param name="spectatorPlayerClock">The clock controlling the gameplay running state.</param>
public MultiSpectatorPlayer([NotNull] Score score, [NotNull] ISpectatorPlayerClock spectatorPlayerClock)
: base(score)
{
this.score = score;
this.spectatorPlayerClock = spectatorPlayerClock;
}
[BackgroundDependencyLoader]
private void load()
{
spectatorPlayerClock.WaitingOnFrames.BindTo(waitingOnFrames);
}
protected override void UpdateAfterChildren()
{
base.UpdateAfterChildren();
// This is required because the frame stable clock is set to WaitingOnFrames = false for one frame.
waitingOnFrames.Value = DrawableRuleset.FrameStableClock.WaitingOnFrames.Value || score.Replay.Frames.Count == 0;
}
protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart)
=> new SpectatorGameplayClockContainer(spectatorPlayerClock);
private class SpectatorGameplayClockContainer : GameplayClockContainer
{
public SpectatorGameplayClockContainer([NotNull] IClock sourceClock)
: base(sourceClock)
{
}
protected override void Update()
{
// The player clock's running state is controlled externally, but the local pausing state needs to be updated to stop gameplay.
if (SourceClock.IsRunning)
Start();
else
Stop();
base.Update();
}
protected override GameplayClock CreateGameplayClock(IFrameBasedClock source) => new GameplayClock(source);
}
}
}

View File

@ -0,0 +1,30 @@
// 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 JetBrains.Annotations;
using osu.Game.Scoring;
using osu.Game.Screens.Menu;
using osu.Game.Screens.Play;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
/// <summary>
/// Used to load a single <see cref="MultiSpectatorPlayer"/> in a <see cref="MultiSpectatorScreen"/>.
/// </summary>
public class MultiSpectatorPlayerLoader : SpectatorPlayerLoader
{
public MultiSpectatorPlayerLoader([NotNull] Score score, [NotNull] Func<MultiSpectatorPlayer> createPlayer)
: base(score, createPlayer)
{
}
protected override void LogoArriving(OsuLogo logo, bool resuming)
{
}
protected override void LogoExiting(OsuLogo logo)
{
}
}
}

View File

@ -0,0 +1,156 @@
// 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.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Spectator;
using osu.Game.Screens.Play;
using osu.Game.Screens.Spectate;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
/// <summary>
/// A <see cref="SpectatorScreen"/> that spectates multiple users in a match.
/// </summary>
public class MultiSpectatorScreen : SpectatorScreen
{
// Isolates beatmap/ruleset to this screen.
public override bool DisallowExternalBeatmapRulesetChanges => true;
/// <summary>
/// Whether all spectating players have finished loading.
/// </summary>
public bool AllPlayersLoaded => instances.All(p => p?.PlayerLoaded == true);
[Resolved]
private SpectatorStreamingClient spectatorClient { get; set; }
[Resolved]
private StatefulMultiplayerClient multiplayerClient { get; set; }
private readonly PlayerArea[] instances;
private MasterGameplayClockContainer masterClockContainer;
private ISyncManager syncManager;
private PlayerGrid grid;
private MultiSpectatorLeaderboard leaderboard;
private PlayerArea currentAudioSource;
/// <summary>
/// Creates a new <see cref="MultiSpectatorScreen"/>.
/// </summary>
/// <param name="userIds">The players to spectate.</param>
public MultiSpectatorScreen(int[] userIds)
: base(userIds.Take(PlayerGrid.MAX_PLAYERS).ToArray())
{
instances = new PlayerArea[UserIds.Count];
}
[BackgroundDependencyLoader]
private void load()
{
Container leaderboardContainer;
masterClockContainer = new MasterGameplayClockContainer(Beatmap.Value, 0);
InternalChildren = new[]
{
(Drawable)(syncManager = new CatchUpSyncManager(masterClockContainer)),
masterClockContainer.WithChild(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 < UserIds.Count; i++)
{
grid.Add(instances[i] = new PlayerArea(UserIds[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, UserIds.ToArray())
{
Expanded = { Value = true },
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
}, leaderboardContainer.Add);
}
protected override void LoadComplete()
{
base.LoadComplete();
masterClockContainer.Stop();
masterClockContainer.Reset();
}
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;
protected override void OnUserStateChanged(int userId, SpectatorState spectatorState)
{
}
protected override void StartGameplay(int userId, GameplayState gameplayState)
{
var instance = instances.Single(i => i.UserId == userId);
instance.LoadScore(gameplayState.Score);
syncManager.AddPlayerClock(instance.GameplayClock);
leaderboard.AddClock(instance.UserId, instance.GameplayClock);
}
protected override void EndGameplay(int userId)
{
RemoveUser(userId);
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();
}
}
}

View File

@ -0,0 +1,144 @@
// 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 JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Timing;
using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
/// <summary>
/// Provides an area for and manages the hierarchy of a spectated player within a <see cref="MultiSpectatorScreen"/>.
/// </summary>
public class PlayerArea : CompositeDrawable
{
/// <summary>
/// Whether a <see cref="Player"/> is loaded in the area.
/// </summary>
public bool PlayerLoaded => stack?.CurrentScreen is Player;
/// <summary>
/// The user id this <see cref="PlayerArea"/> corresponds to.
/// </summary>
public readonly int UserId;
/// <summary>
/// The <see cref="ISpectatorPlayerClock"/> used to control the gameplay running state of a loaded <see cref="Player"/>.
/// </summary>
[NotNull]
public readonly ISpectatorPlayerClock GameplayClock = new CatchUpSpectatorPlayerClock();
/// <summary>
/// The currently-loaded score.
/// </summary>
[CanBeNull]
public Score Score { get; private set; }
[Resolved]
private BeatmapManager beatmapManager { get; set; }
private readonly BindableDouble volumeAdjustment = new BindableDouble();
private readonly Container gameplayContent;
private readonly LoadingLayer loadingLayer;
private OsuScreenStack stack;
public PlayerArea(int userId, IFrameBasedClock masterClock)
{
UserId = userId;
RelativeSizeAxes = Axes.Both;
Masking = true;
AudioContainer audioContainer;
InternalChildren = new Drawable[]
{
audioContainer = new AudioContainer
{
RelativeSizeAxes = Axes.Both,
Child = gameplayContent = new DrawSizePreservingFillContainer { RelativeSizeAxes = Axes.Both },
},
loadingLayer = new LoadingLayer(true) { State = { Value = Visibility.Visible } }
};
audioContainer.AddAdjustment(AdjustableProperty.Volume, volumeAdjustment);
GameplayClock.Source = masterClock;
}
public void LoadScore([NotNull] Score score)
{
if (Score != null)
throw new InvalidOperationException($"Cannot load a new score on a {nameof(PlayerArea)} that has an existing score.");
Score = score;
gameplayContent.Child = new PlayerIsolationContainer(beatmapManager.GetWorkingBeatmap(Score.ScoreInfo.Beatmap), Score.ScoreInfo.Ruleset, Score.ScoreInfo.Mods)
{
RelativeSizeAxes = Axes.Both,
Child = stack = new OsuScreenStack()
};
stack.Push(new MultiSpectatorPlayerLoader(Score, () => new MultiSpectatorPlayer(Score, GameplayClock)));
loadingLayer.Hide();
}
private bool mute = true;
public bool Mute
{
get => mute;
set
{
mute = value;
volumeAdjustment.Value = value ? 0 : 1;
}
}
// Player interferes with global input, so disable input for now.
public override bool PropagatePositionalInputSubTree => false;
public override bool PropagateNonPositionalInputSubTree => false;
/// <summary>
/// Isolates each player instance from the game-wide ruleset/beatmap/mods (to allow for different players having different settings).
/// </summary>
private class PlayerIsolationContainer : Container
{
[Cached]
private readonly Bindable<RulesetInfo> ruleset = new Bindable<RulesetInfo>();
[Cached]
private readonly Bindable<WorkingBeatmap> beatmap = new Bindable<WorkingBeatmap>();
[Cached]
private readonly Bindable<IReadOnlyList<Mod>> mods = new Bindable<IReadOnlyList<Mod>>();
public PlayerIsolationContainer(WorkingBeatmap beatmap, RulesetInfo ruleset, IReadOnlyList<Mod> mods)
{
this.beatmap.Value = beatmap;
this.ruleset.Value = ruleset;
this.mods.Value = mods;
}
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
{
var dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
dependencies.CacheAs(ruleset.BeginLease(false));
dependencies.CacheAs(beatmap.BeginLease(false));
dependencies.CacheAs(mods.BeginLease(false));
return dependencies;
}
}
}
}

View File

@ -15,6 +15,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
/// </summary> /// </summary>
public partial class PlayerGrid : CompositeDrawable public partial class PlayerGrid : CompositeDrawable
{ {
/// <summary>
/// A temporary limitation on the number of players, because only layouts up to 16 players are supported for a single screen.
/// Todo: Can be removed in the future with scrolling support + performance improvements.
/// </summary>
public const int MAX_PLAYERS = 16;
private const float player_spacing = 5; private const float player_spacing = 5;
/// <summary> /// <summary>
@ -58,11 +64,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
/// Adds a new cell with content to this grid. /// Adds a new cell with content to this grid.
/// </summary> /// </summary>
/// <param name="content">The content the cell should contain.</param> /// <param name="content">The content the cell should contain.</param>
/// <exception cref="InvalidOperationException">If more than 16 cells are added.</exception> /// <exception cref="InvalidOperationException">If more than <see cref="MAX_PLAYERS"/> cells are added.</exception>
public void Add(Drawable content) public void Add(Drawable content)
{ {
if (cellContainer.Count == 16) if (cellContainer.Count == MAX_PLAYERS)
throw new InvalidOperationException("Only 16 cells are supported."); throw new InvalidOperationException($"Only {MAX_PLAYERS} cells are supported.");
int index = cellContainer.Count; int index = cellContainer.Count;

View File

@ -218,10 +218,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
}, },
new Drawable[] new Drawable[]
{ {
new Footer new Footer { OnStart = StartPlay }
{
OnStart = onStart,
}
} }
}, },
RowDimensions = new[] RowDimensions = new[]
@ -274,9 +271,9 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
}, true); }, true);
} }
private void onStart() => StartPlay(() => new PlaylistsPlayer(SelectedItem.Value) protected override Screen CreateGameplayScreen() => new PlaylistsPlayer(SelectedItem.Value)
{ {
Exited = () => leaderboard.RefreshScores() Exited = () => leaderboard.RefreshScores()
}); };
} }
} }

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
@ -12,7 +13,7 @@ namespace osu.Game.Screens.Play
/// <summary> /// <summary>
/// Encapsulates gameplay timing logic and provides a <see cref="GameplayClock"/> via DI for gameplay components to use. /// Encapsulates gameplay timing logic and provides a <see cref="GameplayClock"/> via DI for gameplay components to use.
/// </summary> /// </summary>
public abstract class GameplayClockContainer : Container public abstract class GameplayClockContainer : Container, IAdjustableClock
{ {
/// <summary> /// <summary>
/// The final clock which is exposed to gameplay components. /// The final clock which is exposed to gameplay components.
@ -157,5 +158,33 @@ namespace osu.Game.Screens.Play
/// <param name="source">The <see cref="IFrameBasedClock"/> providing the source time.</param> /// <param name="source">The <see cref="IFrameBasedClock"/> providing the source time.</param>
/// <returns>The final <see cref="GameplayClock"/>.</returns> /// <returns>The final <see cref="GameplayClock"/>.</returns>
protected abstract GameplayClock CreateGameplayClock(IFrameBasedClock source); protected abstract GameplayClock CreateGameplayClock(IFrameBasedClock source);
#region IAdjustableClock
bool IAdjustableClock.Seek(double position)
{
Seek(position);
return true;
}
void IAdjustableClock.Reset() => Reset();
public void ResetSpeedAdjustments()
{
}
double IAdjustableClock.Rate
{
get => GameplayClock.Rate;
set => throw new NotSupportedException();
}
double IClock.Rate => GameplayClock.Rate;
public double CurrentTime => GameplayClock.CurrentTime;
public bool IsRunning => GameplayClock.IsRunning;
#endregion
} }
} }

View File

@ -12,7 +12,12 @@ namespace osu.Game.Screens.Play
public readonly ScoreInfo Score; public readonly ScoreInfo Score;
public SpectatorPlayerLoader(Score score) public SpectatorPlayerLoader(Score score)
: base(() => new SpectatorPlayer(score)) : this(score, () => new SpectatorPlayer(score))
{
}
public SpectatorPlayerLoader(Score score, Func<Player> createPlayer)
: base(createPlayer)
{ {
if (score.Replay == null) if (score.Replay == null)
throw new ArgumentException($"{nameof(score)} must have a non-null {nameof(score.Replay)}.", nameof(score)); throw new ArgumentException($"{nameof(score)} must have a non-null {nameof(score.Replay)}.", nameof(score));

View File

@ -26,7 +26,9 @@ namespace osu.Game.Screens.Spectate
/// </summary> /// </summary>
public abstract class SpectatorScreen : OsuScreen public abstract class SpectatorScreen : OsuScreen
{ {
private readonly int[] userIds; protected IReadOnlyList<int> UserIds => userIds;
private readonly List<int> userIds = new List<int>();
[Resolved] [Resolved]
private BeatmapManager beatmaps { get; set; } private BeatmapManager beatmaps { get; set; }
@ -54,7 +56,7 @@ namespace osu.Game.Screens.Spectate
/// <param name="userIds">The users to spectate.</param> /// <param name="userIds">The users to spectate.</param>
protected SpectatorScreen(params int[] userIds) protected SpectatorScreen(params int[] userIds)
{ {
this.userIds = userIds; this.userIds.AddRange(userIds);
} }
protected override void LoadComplete() protected override void LoadComplete()
@ -80,20 +82,18 @@ namespace osu.Game.Screens.Spectate
private Task populateAllUsers() private Task populateAllUsers()
{ {
var userLookupTasks = new Task[userIds.Length]; var userLookupTasks = new List<Task>();
for (int i = 0; i < userIds.Length; i++) foreach (var u in userIds)
{ {
var userId = userIds[i]; userLookupTasks.Add(userLookupCache.GetUserAsync(u).ContinueWith(task =>
userLookupTasks[i] = userLookupCache.GetUserAsync(userId).ContinueWith(task =>
{ {
if (!task.IsCompletedSuccessfully) if (!task.IsCompletedSuccessfully)
return; return;
lock (stateLock) lock (stateLock)
userMap[userId] = task.Result; userMap[u] = task.Result;
}); }));
} }
return Task.WhenAll(userLookupTasks); return Task.WhenAll(userLookupTasks);
@ -239,6 +239,23 @@ namespace osu.Game.Screens.Spectate
/// <param name="userId">The user to end gameplay for.</param> /// <param name="userId">The user to end gameplay for.</param>
protected abstract void EndGameplay(int userId); protected abstract void EndGameplay(int userId);
/// <summary>
/// Stops spectating a user.
/// </summary>
/// <param name="userId">The user to stop spectating.</param>
protected void RemoveUser(int userId)
{
lock (stateLock)
{
userFinishedPlaying(userId, null);
userIds.Remove(userId);
userMap.Remove(userId);
spectatorClient.StopWatchingUser(userId);
}
}
protected override void Dispose(bool isDisposing) protected override void Dispose(bool isDisposing)
{ {
base.Dispose(isDisposing); base.Dispose(isDisposing);

View File

@ -16,6 +16,9 @@ namespace osu.Game.Tests.Visual.Multiplayer
{ {
public abstract class MultiplayerTestScene : RoomTestScene public abstract class MultiplayerTestScene : RoomTestScene
{ {
public const int PLAYER_1_ID = 55;
public const int PLAYER_2_ID = 56;
[Cached(typeof(StatefulMultiplayerClient))] [Cached(typeof(StatefulMultiplayerClient))]
public TestMultiplayerClient Client { get; } public TestMultiplayerClient Client { get; }

View File

@ -25,6 +25,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
public override IBindable<bool> IsConnected => isConnected; public override IBindable<bool> IsConnected => isConnected;
private readonly Bindable<bool> isConnected = new Bindable<bool>(true); private readonly Bindable<bool> isConnected = new Bindable<bool>(true);
public Room? APIRoom { get; private set; }
public Action<MultiplayerRoom>? RoomSetupAction; public Action<MultiplayerRoom>? RoomSetupAction;
[Resolved] [Resolved]
@ -138,10 +140,16 @@ namespace osu.Game.Tests.Visual.Multiplayer
RoomSetupAction?.Invoke(room); RoomSetupAction?.Invoke(room);
RoomSetupAction = null; RoomSetupAction = null;
APIRoom = apiRoom;
return Task.FromResult(room); return Task.FromResult(room);
} }
protected override Task LeaveRoomInternal() => Task.CompletedTask; protected override Task LeaveRoomInternal()
{
APIRoom = null;
return Task.CompletedTask;
}
public override Task TransferHost(int userId) => ((IMultiplayerClient)this).HostChanged(userId); public override Task TransferHost(int userId) => ((IMultiplayerClient)this).HostChanged(userId);

View File

@ -0,0 +1,90 @@
// 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.Collections.Concurrent;
using System.Collections.Generic;
using osu.Framework.Bindables;
using osu.Framework.Utils;
using osu.Game.Online;
using osu.Game.Online.Spectator;
using osu.Game.Replays.Legacy;
using osu.Game.Scoring;
namespace osu.Game.Tests.Visual.Spectator
{
public class TestSpectatorStreamingClient : SpectatorStreamingClient
{
public new BindableList<int> PlayingUsers => (BindableList<int>)base.PlayingUsers;
private readonly ConcurrentDictionary<int, byte> watchingUsers = new ConcurrentDictionary<int, byte>();
private readonly Dictionary<int, int> userBeatmapDictionary = new Dictionary<int, int>();
private readonly Dictionary<int, bool> userSentStateDictionary = new Dictionary<int, bool>();
public TestSpectatorStreamingClient()
: base(new DevelopmentEndpointConfiguration())
{
}
public void StartPlay(int userId, int beatmapId)
{
userBeatmapDictionary[userId] = beatmapId;
sendState(userId, beatmapId);
}
public void EndPlay(int userId, int beatmapId)
{
((ISpectatorClient)this).UserFinishedPlaying(userId, new SpectatorState
{
BeatmapID = beatmapId,
RulesetID = 0,
});
userBeatmapDictionary.Remove(userId);
userSentStateDictionary.Remove(userId);
}
public void SendFrames(int userId, int index, int count)
{
var frames = new List<LegacyReplayFrame>();
for (int i = index; i < index + count; i++)
{
var buttonState = i == index + count - 1 ? ReplayButtonState.None : ReplayButtonState.Left1;
frames.Add(new LegacyReplayFrame(i * 100, RNG.Next(0, 512), RNG.Next(0, 512), buttonState));
}
var bundle = new FrameDataBundle(new ScoreInfo { Combo = index + count }, frames);
((ISpectatorClient)this).UserSentFrames(userId, bundle);
if (!userSentStateDictionary[userId])
sendState(userId, userBeatmapDictionary[userId]);
}
public override void WatchUser(int userId)
{
base.WatchUser(userId);
// When newly watching a user, the server sends the playing state immediately.
if (watchingUsers.TryAdd(userId, 0) && PlayingUsers.Contains(userId))
sendState(userId, userBeatmapDictionary[userId]);
}
public override void StopWatchingUser(int userId)
{
base.StopWatchingUser(userId);
watchingUsers.TryRemove(userId, out _);
}
private void sendState(int userId, int beatmapId)
{
((ISpectatorClient)this).UserBeganPlaying(userId, new SpectatorState
{
BeatmapID = beatmapId,
RulesetID = 0,
});
userSentStateDictionary[userId] = true;
}
}
}