diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs
index b3889bc7d3..fbb9b3c466 100644
--- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs
+++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs
@@ -47,7 +47,7 @@ namespace osu.Game.Rulesets.Mania
public override ScoreProcessor CreateScoreProcessor() => new ManiaScoreProcessor();
- public override HealthProcessor CreateHealthProcessor(double drainStartTime) => new DrainingHealthProcessor(drainStartTime, 0.2);
+ public override HealthProcessor CreateHealthProcessor(double drainStartTime) => new DrainingHealthProcessor(drainStartTime, 0.5);
public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new ManiaBeatmapConverter(beatmap, this);
diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs
index 1550faee50..003646d654 100644
--- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs
+++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs
@@ -6,6 +6,7 @@ using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
+using osu.Game.Audio;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Rulesets.Mania.UI;
@@ -24,6 +25,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
[Resolved(canBeNull: true)]
private ManiaPlayfield playfield { get; set; }
+ ///
+ /// Gets the samples that are played by this object during gameplay.
+ ///
+ public ISampleInfo[] GetGameplaySamples() => Samples.Samples;
+
protected override float SamplePlaybackPosition
{
get
diff --git a/osu.Game.Rulesets.Mania/UI/Column.cs b/osu.Game.Rulesets.Mania/UI/Column.cs
index d2a9b69b60..0f02e2cd4b 100644
--- a/osu.Game.Rulesets.Mania/UI/Column.cs
+++ b/osu.Game.Rulesets.Mania/UI/Column.cs
@@ -27,6 +27,12 @@ namespace osu.Game.Rulesets.Mania.UI
public const float COLUMN_WIDTH = 80;
public const float SPECIAL_COLUMN_WIDTH = 70;
+ ///
+ /// For hitsounds played by this (i.e. not as a result of hitting a hitobject),
+ /// a certain number of samples are allowed to be played concurrently so that it feels better when spam-pressing the key.
+ ///
+ private const int max_concurrent_hitsounds = OsuGameBase.SAMPLE_CONCURRENCY;
+
///
/// The index of this column as part of the whole playfield.
///
@@ -38,6 +44,7 @@ namespace osu.Game.Rulesets.Mania.UI
internal readonly Container TopLevelContainer;
private readonly DrawablePool hitExplosionPool;
private readonly OrderedHitPolicy hitPolicy;
+ private readonly Container hitSounds;
public Container UnderlayElements => HitObjectArea.UnderlayElements;
@@ -64,6 +71,12 @@ namespace osu.Game.Rulesets.Mania.UI
RelativeSizeAxes = Axes.Both
},
background,
+ hitSounds = new Container
+ {
+ Name = "Column samples pool",
+ RelativeSizeAxes = Axes.Both,
+ Children = Enumerable.Range(0, max_concurrent_hitsounds).Select(_ => new SkinnableSound()).ToArray()
+ },
TopLevelContainer = new Container { RelativeSizeAxes = Axes.Both }
};
@@ -120,6 +133,8 @@ namespace osu.Game.Rulesets.Mania.UI
HitObjectArea.Explosions.Add(hitExplosionPool.Get(e => e.Apply(result)));
}
+ private int nextHitSoundIndex;
+
public bool OnPressed(ManiaAction action)
{
if (action != Action.Value)
@@ -131,7 +146,15 @@ namespace osu.Game.Rulesets.Mania.UI
HitObjectContainer.Objects.FirstOrDefault(h => h.HitObject.StartTime > Time.Current) ??
HitObjectContainer.Objects.LastOrDefault();
- nextObject?.PlaySamples();
+ if (nextObject is DrawableManiaHitObject maniaObject)
+ {
+ var hitSound = hitSounds[nextHitSoundIndex];
+
+ hitSound.Samples = maniaObject.GetGameplaySamples();
+ hitSound.Play();
+
+ nextHitSoundIndex = (nextHitSoundIndex + 1) % max_concurrent_hitsounds;
+ }
return true;
}
diff --git a/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs b/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs
index 377a33b527..14589f8e6c 100644
--- a/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs
+++ b/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs
@@ -53,9 +53,9 @@ namespace osu.Game.Tests.NonVisual.Multiplayer
Client.RoomSetupAction = room =>
{
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
});
};
diff --git a/osu.Game.Tests/OnlinePlay/TestSceneCatchUpSyncManager.cs b/osu.Game.Tests/OnlinePlay/TestSceneCatchUpSyncManager.cs
new file mode 100644
index 0000000000..d4e591cf09
--- /dev/null
+++ b/osu.Game.Tests/OnlinePlay/TestSceneCatchUpSyncManager.cs
@@ -0,0 +1,223 @@
+// 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 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 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));
+
+ ///
+ /// clock.Time = master.Time - offsetFromMaster
+ ///
+ private void setPlayerClockTime(Func 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 playerClock, bool catchingUp) =>
+ AddAssert($"player clock {playerClock().Id} {(catchingUp ? "is" : "is not")} catching up", () => playerClock().IsCatchingUp == catchingUp);
+
+ private void assertPlayerClockState(Func playerClock, bool running)
+ => AddAssert($"player clock {playerClock().Id} {(running ? "is" : "is not")} running", () => playerClock().IsRunning == running);
+
+ private class TestSpectatorPlayerClock : TestManualClock, ISpectatorPlayerClock
+ {
+ public Bindable WaitingOnFrames { get; } = new Bindable(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()
+ {
+ }
+ }
+ }
+}
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs
index 74ce66096e..a7ed217b4d 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs
@@ -1,34 +1,32 @@
// Copyright (c) ppy Pty Ltd . 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.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Screens;
using osu.Framework.Testing;
-using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Database;
-using osu.Game.Online;
using osu.Game.Online.Spectator;
-using osu.Game.Replays.Legacy;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Replays;
using osu.Game.Rulesets.UI;
-using osu.Game.Scoring;
using osu.Game.Screens.Play;
using osu.Game.Tests.Beatmaps.IO;
+using osu.Game.Tests.Visual.Multiplayer;
+using osu.Game.Tests.Visual.Spectator;
using osu.Game.Users;
namespace osu.Game.Tests.Visual.Gameplay
{
public class TestSceneSpectator : ScreenTestScene
{
+ private readonly User streamingUser = new User { Id = MultiplayerTestScene.PLAYER_1_ID, Username = "Test user" };
+
[Cached(typeof(SpectatorStreamingClient))]
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 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) =>
AddUntilStep($"game is {(state ? "paused" : "playing")}", () => player.ChildrenOfType().First().IsPaused.Value == state);
@@ -225,89 +223,17 @@ namespace osu.Game.Tests.Visual.Gameplay
{
AddStep("send frames", () =>
{
- testSpectatorStreamingClient.SendFrames(nextFrame, count);
+ testSpectatorStreamingClient.SendFrames(streamingUser.Id, nextFrame, count);
nextFrame += count;
});
}
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);
}
- public class TestSpectatorStreamingClient : SpectatorStreamingClient
- {
- public readonly User StreamingUser = new User { Id = 55, Username = "Test user" };
-
- public new BindableList PlayingUsers => (BindableList)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();
-
- 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
{
protected override Task ComputeValueAsync(int lookup, CancellationToken token = default) => Task.FromResult(new User
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectatorLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs
similarity index 51%
rename from osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectatorLeaderboard.cs
rename to osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs
index 3b2cfb1c7b..263adc07e1 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectatorLeaderboard.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs
@@ -11,20 +11,17 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Framework.Timing;
-using osu.Framework.Utils;
using osu.Game.Database;
-using osu.Game.Online;
using osu.Game.Online.Spectator;
-using osu.Game.Replays.Legacy;
using osu.Game.Rulesets.Osu.Scoring;
-using osu.Game.Scoring;
using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate;
using osu.Game.Screens.Play.HUD;
+using osu.Game.Tests.Visual.Spectator;
using osu.Game.Users;
namespace osu.Game.Tests.Visual.Multiplayer
{
- public class TestSceneMultiplayerSpectatorLeaderboard : MultiplayerTestScene
+ public class TestSceneMultiSpectatorLeaderboard : MultiplayerTestScene
{
[Cached(typeof(SpectatorStreamingClient))]
private TestSpectatorStreamingClient streamingClient = new TestSpectatorStreamingClient();
@@ -37,11 +34,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
private readonly Dictionary clocks = new Dictionary
{
- { 55, new ManualClock() },
- { 56, new ManualClock() }
+ { PLAYER_1_ID, new ManualClock() },
+ { PLAYER_2_ID, new ManualClock() }
};
- public TestSceneMultiplayerSpectatorLeaderboard()
+ public TestSceneMultiSpectatorLeaderboard()
{
base.Content.AddRange(new Drawable[]
{
@@ -54,7 +51,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
[SetUpSteps]
public new void SetUpSteps()
{
- MultiplayerSpectatorLeaderboard leaderboard = null;
+ MultiSpectatorLeaderboard leaderboard = null;
AddStep("reset", () =>
{
@@ -78,7 +75,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
var scoreProcessor = new OsuScoreProcessor();
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);
@@ -95,46 +92,46 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
AddStep("send frames", () =>
{
- // For user 55, send frames in sets of 1.
- // For user 56, send frames in sets of 10.
+ // For player 1, send frames in sets of 1.
+ // For player 2, send frames in sets of 10.
for (int i = 0; i < 100; i++)
{
- streamingClient.SendFrames(55, i, 1);
+ streamingClient.SendFrames(PLAYER_1_ID, i, 1);
if (i % 10 == 0)
- streamingClient.SendFrames(56, i, 10);
+ streamingClient.SendFrames(PLAYER_2_ID, i, 10);
}
});
- assertCombo(55, 1);
- assertCombo(56, 10);
+ assertCombo(PLAYER_1_ID, 1);
+ 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);
- assertCombo(55, 5);
- assertCombo(56, 10);
+ assertCombo(PLAYER_1_ID, 5);
+ assertCombo(PLAYER_2_ID, 10);
// Advance to a point where both user's frame changes.
setTime(1100);
- assertCombo(55, 11);
- assertCombo(56, 20);
+ assertCombo(PLAYER_1_ID, 11);
+ assertCombo(PLAYER_2_ID, 20);
- // Advance user 56 only to a point where its frame changes.
- setTime(56, 2100);
- assertCombo(55, 11);
- assertCombo(56, 30);
+ // Advance user player 2 only to a point where its frame changes.
+ setTime(PLAYER_2_ID, 2100);
+ assertCombo(PLAYER_1_ID, 11);
+ assertCombo(PLAYER_2_ID, 30);
// Advance both users beyond their last frame
setTime(101 * 100);
- assertCombo(55, 100);
- assertCombo(56, 100);
+ assertCombo(PLAYER_1_ID, 100);
+ assertCombo(PLAYER_2_ID, 100);
}
[Test]
public void TestNoFrames()
{
- assertCombo(55, 0);
- assertCombo(56, 0);
+ assertCombo(PLAYER_1_ID, 0);
+ assertCombo(PLAYER_2_ID, 0);
}
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)
=> AddUntilStep($"player {userId} has {expectedCombo} combo", () => this.ChildrenOfType().Single(s => s.User?.Id == userId).Combo.Value == expectedCombo);
- private class TestSpectatorStreamingClient : SpectatorStreamingClient
- {
- private readonly Dictionary userBeatmapDictionary = new Dictionary();
- private readonly Dictionary userSentStateDictionary = new Dictionary();
-
- 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();
-
- 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
{
protected override Task ComputeValueAsync(int lookup, CancellationToken token = default)
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs
new file mode 100644
index 0000000000..689c249d05
--- /dev/null
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs
@@ -0,0 +1,313 @@
+// Copyright (c) ppy Pty Ltd . 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 playingUserIds = new List();
+ private readonly Dictionary nextFrame = new Dictionary();
+
+ 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().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().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().First().GameplayClock.IsRunning != state);
+
+ private void checkPausedInstant(int userId, bool state)
+ => AddAssert($"{userId} is {(state ? "paused" : "playing")}", () => getPlayer(userId).ChildrenOfType().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().Single();
+
+ private PlayerArea getInstance(int userId) => spectatorScreen.ChildrenOfType().Single(p => p.UserId == userId);
+
+ internal class TestUserLookupCache : UserLookupCache
+ {
+ protected override Task ComputeValueAsync(int lookup, CancellationToken token = default)
+ {
+ return Task.FromResult(new User
+ {
+ Id = lookup,
+ Username = $"User {lookup}"
+ });
+ }
+ }
+ }
+}
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs
index 78bc51e47b..bba7e2b391 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs
@@ -1,9 +1,25 @@
// 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 NUnit.Framework;
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.Rooms;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.Osu;
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
{
@@ -11,7 +27,158 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
private TestMultiplayer multiplayerScreen;
+ private BeatmapManager beatmaps;
+ private RulesetStore rulesets;
+ private BeatmapSetInfo importedSet;
+
+ private TestMultiplayerClient client => multiplayerScreen.Client;
+ private Room room => client.APIRoom;
+
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));
+
+ AddUntilStep("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().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().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)
+ {
+ AddStep("open room", () =>
+ {
+ multiplayerScreen.OpenNewRoom(room());
+ });
+
+ AddUntilStep("wait for room open", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true);
+ AddWaitStep("wait for transition", 2);
+
+ AddStep("create room", () =>
+ {
+ InputManager.MoveMouseTo(this.ChildrenOfType().Single());
+ InputManager.Click(MouseButton.Left);
+ });
+
+ AddUntilStep("wait for join", () => client.Room != null);
+ }
+
+ private void loadMultiplayer()
{
AddStep("show", () =>
{
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs
index b6c06bb149..6813a6e7dd 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs
@@ -6,14 +6,12 @@ using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
-using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Configuration;
using osu.Game.Database;
-using osu.Game.Online;
using osu.Game.Online.API;
using osu.Game.Online.Spectator;
using osu.Game.Replays.Legacy;
@@ -22,6 +20,7 @@ using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Screens.Play.HUD;
using osu.Game.Tests.Visual.Online;
+using osu.Game.Tests.Visual.Spectator;
namespace osu.Game.Tests.Visual.Multiplayer
{
@@ -30,7 +29,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
private const int users = 16;
[Cached(typeof(SpectatorStreamingClient))]
- private TestMultiplayerStreaming streamingClient = new TestMultiplayerStreaming(users);
+ private TestMultiplayerStreaming streamingClient = new TestMultiplayerStreaming();
[Cached(typeof(UserLookupCache))]
private UserLookupCache lookupCache = new TestSceneCurrentlyPlayingDisplay.TestUserLookupCache();
@@ -71,7 +70,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
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.AddRange(streamingClient.PlayingUsers);
@@ -114,30 +114,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("change to standardised", () => config.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Standardised));
}
- public class TestMultiplayerStreaming : SpectatorStreamingClient
+ public class TestMultiplayerStreaming : TestSpectatorStreamingClient
{
- public new BindableList PlayingUsers => (BindableList)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 lastHeaders = new Dictionary();
public void RandomlyUpdateState()
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs
index 7c6c158b5a..f611d5fecf 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs
@@ -119,8 +119,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("join other user (ready)", () =>
{
- Client.AddUser(new User { Id = 55 });
- Client.ChangeUserState(55, MultiplayerUserState.Ready);
+ Client.AddUser(new User { Id = PLAYER_1_ID });
+ Client.ChangeUserState(PLAYER_1_ID, MultiplayerUserState.Ready);
});
AddStep("click spectate button", () =>
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs
index e65e4a68a7..e59b342176 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs
@@ -120,9 +120,12 @@ namespace osu.Game.Tests.Visual.Multiplayer
};
});
- [Test]
- public void TestEnabledWhenRoomOpen()
+ [TestCase(MultiplayerRoomState.Open)]
+ [TestCase(MultiplayerRoomState.WaitingForLoad)]
+ [TestCase(MultiplayerRoomState.Playing)]
+ public void TestEnabledWhenRoomOpenOrInGameplay(MultiplayerRoomState roomState)
{
+ AddStep($"change room to {roomState}", () => Client.ChangeRoomState(roomState));
assertSpectateButtonEnablement(true);
}
@@ -137,12 +140,10 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddAssert("user is idle", () => Client.Room?.Users[0].State == MultiplayerUserState.Idle);
}
- [TestCase(MultiplayerRoomState.WaitingForLoad)]
- [TestCase(MultiplayerRoomState.Playing)]
[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);
}
@@ -156,8 +157,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
[Test]
public void TestReadyButtonEnabledWhenHostAndUsersReady()
{
- AddStep("add user", () => Client.AddUser(new User { Id = 55 }));
- AddStep("set user ready", () => Client.ChangeUserState(55, MultiplayerUserState.Ready));
+ AddStep("add user", () => Client.AddUser(new User { Id = PLAYER_1_ID }));
+ AddStep("set user ready", () => Client.ChangeUserState(PLAYER_1_ID, MultiplayerUserState.Ready));
addClickSpectateButtonStep();
assertReadyButtonEnablement(true);
@@ -168,11 +169,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
AddStep("add user and transfer host", () =>
{
- Client.AddUser(new User { Id = 55 });
- Client.TransferHost(55);
+ Client.AddUser(new User { Id = PLAYER_1_ID });
+ 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();
assertReadyButtonEnablement(false);
diff --git a/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs
index 1baa07f208..8ae6398003 100644
--- a/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs
+++ b/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs
@@ -12,7 +12,7 @@ using osu.Framework.Testing;
using osu.Game.Database;
using osu.Game.Online.Spectator;
using osu.Game.Overlays.Dashboard;
-using osu.Game.Tests.Visual.Gameplay;
+using osu.Game.Tests.Visual.Spectator;
using osu.Game.Users;
namespace osu.Game.Tests.Visual.Online
@@ -20,7 +20,7 @@ namespace osu.Game.Tests.Visual.Online
public class TestSceneCurrentlyPlayingDisplay : OsuTestScene
{
[Cached(typeof(SpectatorStreamingClient))]
- private TestSceneSpectator.TestSpectatorStreamingClient testSpectatorStreamingClient = new TestSceneSpectator.TestSpectatorStreamingClient();
+ private TestSpectatorStreamingClient testSpectatorStreamingClient = new TestSpectatorStreamingClient();
private CurrentlyPlayingDisplay currentlyPlaying;
diff --git a/osu.Game/Extensions/TypeExtensions.cs b/osu.Game/Extensions/TypeExtensions.cs
new file mode 100644
index 0000000000..2e93c81758
--- /dev/null
+++ b/osu.Game/Extensions/TypeExtensions.cs
@@ -0,0 +1,31 @@
+// 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;
+
+namespace osu.Game.Extensions
+{
+ internal static class TypeExtensions
+ {
+ ///
+ /// Returns 's
+ /// with the assembly version, culture and public key token values removed.
+ ///
+ ///
+ /// This method is usually used in extensibility scenarios (i.e. for custom rulesets or skins)
+ /// when a version-agnostic identifier associated with a C# class - potentially originating from
+ /// an external assembly - is needed.
+ /// Leaving only the type and assembly names in such a scenario allows to preserve compatibility
+ /// across assembly versions.
+ ///
+ internal static string GetInvariantInstantiationInfo(this Type type)
+ {
+ string assemblyQualifiedName = type.AssemblyQualifiedName;
+ if (assemblyQualifiedName == null)
+ throw new ArgumentException($"{type}'s assembly-qualified name is null. Ensure that it is a concrete type and not a generic type parameter.", nameof(type));
+
+ return string.Join(',', assemblyQualifiedName.Split(',').Take(2));
+ }
+ }
+}
diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs
index 37e11cc576..4529dfd0a7 100644
--- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs
+++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs
@@ -96,9 +96,6 @@ namespace osu.Game.Online.Multiplayer
if (!IsConnected.Value)
return Task.CompletedTask;
- if (newState == MultiplayerUserState.Spectating)
- return Task.CompletedTask; // Not supported yet.
-
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeState), newState);
}
diff --git a/osu.Game/Online/Spectator/SpectatorStreamingClient.cs b/osu.Game/Online/Spectator/SpectatorStreamingClient.cs
index 378096c7fb..ec6d1bf9d8 100644
--- a/osu.Game/Online/Spectator/SpectatorStreamingClient.cs
+++ b/osu.Game/Online/Spectator/SpectatorStreamingClient.cs
@@ -142,7 +142,11 @@ namespace osu.Game.Online.Spectator
if (!playingUsers.Contains(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);
@@ -230,7 +234,7 @@ namespace osu.Game.Online.Spectator
connection.SendAsync(nameof(ISpectatorServer.StartWatchingUser), userId);
}
- public void StopWatchingUser(int userId)
+ public virtual void StopWatchingUser(int userId)
{
lock (userLock)
{
diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs
index 7f0c27adfc..7bdf84ace4 100644
--- a/osu.Game/Rulesets/Ruleset.cs
+++ b/osu.Game/Rulesets/Ruleset.cs
@@ -26,6 +26,7 @@ using JetBrains.Annotations;
using osu.Framework.Extensions;
using osu.Framework.Extensions.EnumExtensions;
using osu.Framework.Testing;
+using osu.Game.Extensions;
using osu.Game.Rulesets.Filter;
using osu.Game.Screens.Ranking.Statistics;
@@ -135,7 +136,7 @@ namespace osu.Game.Rulesets
Name = Description,
ShortName = ShortName,
ID = (this as ILegacyRuleset)?.LegacyID,
- InstantiationInfo = GetType().AssemblyQualifiedName,
+ InstantiationInfo = GetType().GetInvariantInstantiationInfo(),
Available = true,
};
}
diff --git a/osu.Game/Rulesets/RulesetInfo.cs b/osu.Game/Rulesets/RulesetInfo.cs
index d5aca8c650..702bf35fa8 100644
--- a/osu.Game/Rulesets/RulesetInfo.cs
+++ b/osu.Game/Rulesets/RulesetInfo.cs
@@ -3,7 +3,6 @@
using System;
using System.Diagnostics.CodeAnalysis;
-using System.Linq;
using Newtonsoft.Json;
using osu.Framework.Testing;
@@ -18,20 +17,7 @@ namespace osu.Game.Rulesets
public string ShortName { get; set; }
- private string instantiationInfo;
-
- public string InstantiationInfo
- {
- get => instantiationInfo;
- set => instantiationInfo = abbreviateInstantiationInfo(value);
- }
-
- private string abbreviateInstantiationInfo(string value)
- {
- // exclude version onwards, matching only on namespace and type.
- // this is mainly to allow for new versions of already loaded rulesets to "upgrade" from old.
- return string.Join(',', value.Split(',').Take(2));
- }
+ public string InstantiationInfo { get; set; }
[JsonIgnore]
public bool Available { get; set; }
diff --git a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs
index 706da05d15..68bdd9160e 100644
--- a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs
+++ b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs
@@ -17,7 +17,6 @@ using osu.Game.Online.Rooms;
using osu.Game.Overlays;
using osu.Game.Overlays.Mods;
using osu.Game.Rulesets.Mods;
-using osu.Game.Screens.Play;
namespace osu.Game.Screens.OnlinePlay.Match
{
@@ -148,12 +147,18 @@ namespace osu.Game.Screens.OnlinePlay.Match
return base.OnExiting(next);
}
- protected void StartPlay(Func player)
+ protected void StartPlay()
{
sampleStart?.Play();
- ParentScreen?.Push(new PlayerLoader(player));
+ ParentScreen?.Push(CreateGameplayScreen());
}
+ ///
+ /// Creates the gameplay screen to be entered.
+ ///
+ /// The screen to enter.
+ protected abstract Screen CreateGameplayScreen();
+
private void selectedItemChanged()
{
updateWorkingBeatmap();
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs
index 4b3fb5d00f..04150902bc 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs
@@ -81,7 +81,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
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
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs
index ae22e1fcec..085c824bdc 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs
@@ -21,7 +21,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
{
base.OnResuming(last);
- if (client.Room != null)
+ if (client.Room != null && client.LocalUser?.State != MultiplayerUserState.Spectating)
client.ChangeState(MultiplayerUserState.Idle);
}
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs
index 90cef0107c..fa18b792c3 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs
@@ -14,6 +14,7 @@ using osu.Framework.Screens;
using osu.Framework.Threading;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
+using osu.Game.Online;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
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.Multiplayer.Match;
using osu.Game.Screens.OnlinePlay.Multiplayer.Participants;
+using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate;
using osu.Game.Screens.Play.HUD;
using osu.Game.Users;
using osuTK;
@@ -353,10 +355,17 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
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 != Online.Rooms.BeatmapAvailability.LocallyAvailable()
- && client.LocalUser?.State == MultiplayerUserState.Ready)
- client.ChangeState(MultiplayerUserState.Idle);
+ if (availability.NewValue.State != DownloadState.LocallyAvailable)
+ {
+ // 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 (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()
@@ -407,22 +416,46 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
private void onRoomUpdated()
{
- // user mods may have changed.
Scheduler.AddOnce(UpdateMods);
}
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 = 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)
{
base.Dispose(isDisposing);
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSpectatorPlayerClock.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSpectatorPlayerClock.cs
new file mode 100644
index 0000000000..9e1a020eca
--- /dev/null
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSpectatorPlayerClock.cs
@@ -0,0 +1,84 @@
+// Copyright (c) ppy Pty Ltd . 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
+{
+ ///
+ /// A which catches up using rate adjustment.
+ ///
+ public class CatchUpSpectatorPlayerClock : ISpectatorPlayerClock
+ {
+ ///
+ /// The catch up rate.
+ ///
+ public const double CATCHUP_RATE = 2;
+
+ ///
+ /// The source clock.
+ ///
+ 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 WaitingOnFrames { get; } = new Bindable(true);
+
+ public bool IsCatchingUp { get; set; }
+ }
+}
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSyncManager.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSyncManager.cs
new file mode 100644
index 0000000000..efc12eaaa5
--- /dev/null
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSyncManager.cs
@@ -0,0 +1,153 @@
+// Copyright (c) ppy Pty Ltd . 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
+{
+ ///
+ /// A which synchronises de-synced player clocks through catchup.
+ ///
+ public class CatchUpSyncManager : Component, ISyncManager
+ {
+ ///
+ /// The offset from the master clock to which player clocks should remain within to be considered in-sync.
+ ///
+ public const double SYNC_TARGET = 16;
+
+ ///
+ /// The offset from the master clock at which player clocks begin resynchronising.
+ ///
+ public const double MAX_SYNC_OFFSET = 50;
+
+ ///
+ /// The maximum delay to start gameplay, if any (but not all) player clocks are ready.
+ ///
+ public const double MAXIMUM_START_DELAY = 15000;
+
+ ///
+ /// The master clock which is used to control the timing of all player clocks clocks.
+ ///
+ public IAdjustableClock MasterClock { get; }
+
+ ///
+ /// The player clocks.
+ ///
+ private readonly List playerClocks = new List();
+
+ 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();
+ }
+
+ ///
+ /// Attempts to start playback. Waits for all player clocks to have available frames for up to milliseconds.
+ ///
+ /// Whether playback was started and syncing should occur.
+ 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;
+ }
+
+ ///
+ /// Updates the catchup states of all player clocks clocks.
+ ///
+ 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;
+ }
+ }
+ }
+
+ ///
+ /// Updates the master clock's running state.
+ ///
+ private void updateMasterClock()
+ {
+ bool anyInSync = playerClocks.Any(s => !s.IsCatchingUp);
+
+ if (MasterClock.IsRunning != anyInSync)
+ {
+ if (anyInSync)
+ MasterClock.Start();
+ else
+ MasterClock.Stop();
+ }
+ }
+ }
+}
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISpectatorPlayerClock.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISpectatorPlayerClock.cs
new file mode 100644
index 0000000000..1a5231e602
--- /dev/null
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISpectatorPlayerClock.cs
@@ -0,0 +1,29 @@
+// Copyright (c) ppy Pty Ltd . 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
+{
+ ///
+ /// A clock which is used by s and managed by an .
+ ///
+ public interface ISpectatorPlayerClock : IFrameBasedClock, IAdjustableClock
+ {
+ ///
+ /// Whether this clock is waiting on frames to continue playback.
+ ///
+ Bindable WaitingOnFrames { get; }
+
+ ///
+ /// Whether this clock is resynchronising to the master clock.
+ ///
+ bool IsCatchingUp { get; set; }
+
+ ///
+ /// The source clock
+ ///
+ IFrameBasedClock Source { set; }
+ }
+}
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISyncManager.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISyncManager.cs
new file mode 100644
index 0000000000..bd698108f6
--- /dev/null
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISyncManager.cs
@@ -0,0 +1,30 @@
+// Copyright (c) ppy Pty Ltd . 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
+{
+ ///
+ /// Manages the synchronisation between one or more s in relation to a master clock.
+ ///
+ public interface ISyncManager
+ {
+ ///
+ /// The master clock which player clocks should synchronise to.
+ ///
+ IAdjustableClock MasterClock { get; }
+
+ ///
+ /// Adds an to manage.
+ ///
+ /// The to add.
+ void AddPlayerClock(ISpectatorPlayerClock clock);
+
+ ///
+ /// Removes an , stopping it from being managed by this .
+ ///
+ /// The to remove.
+ void RemovePlayerClock(ISpectatorPlayerClock clock);
+ }
+}
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiplayerSpectatorLeaderboard.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs
similarity index 92%
rename from osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiplayerSpectatorLeaderboard.cs
rename to osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs
index 1b9e2bda2d..ab3ead68b5 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiplayerSpectatorLeaderboard.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs
@@ -9,9 +9,9 @@ using osu.Game.Screens.Play.HUD;
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)
{
}
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs
new file mode 100644
index 0000000000..0fe9e01d9d
--- /dev/null
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs
@@ -0,0 +1,73 @@
+// Copyright (c) ppy Pty Ltd . 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
+{
+ ///
+ /// A single spectated player within a .
+ ///
+ public class MultiSpectatorPlayer : SpectatorPlayer
+ {
+ private readonly Bindable waitingOnFrames = new Bindable(true);
+ private readonly Score score;
+ private readonly ISpectatorPlayerClock spectatorPlayerClock;
+
+ ///
+ /// Creates a new .
+ ///
+ /// The score containing the player's replay.
+ /// The clock controlling the gameplay running state.
+ 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);
+ }
+ }
+}
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayerLoader.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayerLoader.cs
new file mode 100644
index 0000000000..5a1d28e9c4
--- /dev/null
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayerLoader.cs
@@ -0,0 +1,30 @@
+// 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 JetBrains.Annotations;
+using osu.Game.Scoring;
+using osu.Game.Screens.Menu;
+using osu.Game.Screens.Play;
+
+namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
+{
+ ///
+ /// Used to load a single in a .
+ ///
+ public class MultiSpectatorPlayerLoader : SpectatorPlayerLoader
+ {
+ public MultiSpectatorPlayerLoader([NotNull] Score score, [NotNull] Func createPlayer)
+ : base(score, createPlayer)
+ {
+ }
+
+ protected override void LogoArriving(OsuLogo logo, bool resuming)
+ {
+ }
+
+ protected override void LogoExiting(OsuLogo logo)
+ {
+ }
+ }
+}
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs
new file mode 100644
index 0000000000..8c7b7bab01
--- /dev/null
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs
@@ -0,0 +1,156 @@
+// 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.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
+{
+ ///
+ /// A that spectates multiple users in a match.
+ ///
+ public class MultiSpectatorScreen : SpectatorScreen
+ {
+ // Isolates beatmap/ruleset to this screen.
+ public override bool DisallowExternalBeatmapRulesetChanges => true;
+
+ ///
+ /// Whether all spectating players have finished loading.
+ ///
+ 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;
+
+ ///
+ /// Creates a new .
+ ///
+ /// The players to spectate.
+ 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();
+ }
+ }
+}
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs
new file mode 100644
index 0000000000..fe79e5db72
--- /dev/null
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs
@@ -0,0 +1,144 @@
+// 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.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
+{
+ ///
+ /// Provides an area for and manages the hierarchy of a spectated player within a .
+ ///
+ public class PlayerArea : CompositeDrawable
+ {
+ ///
+ /// Whether a is loaded in the area.
+ ///
+ public bool PlayerLoaded => stack?.CurrentScreen is Player;
+
+ ///
+ /// The user id this corresponds to.
+ ///
+ public readonly int UserId;
+
+ ///
+ /// The used to control the gameplay running state of a loaded .
+ ///
+ [NotNull]
+ public readonly ISpectatorPlayerClock GameplayClock = new CatchUpSpectatorPlayerClock();
+
+ ///
+ /// The currently-loaded score.
+ ///
+ [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;
+
+ ///
+ /// Isolates each player instance from the game-wide ruleset/beatmap/mods (to allow for different players having different settings).
+ ///
+ private class PlayerIsolationContainer : Container
+ {
+ [Cached]
+ private readonly Bindable ruleset = new Bindable();
+
+ [Cached]
+ private readonly Bindable beatmap = new Bindable();
+
+ [Cached]
+ private readonly Bindable> mods = new Bindable>();
+
+ public PlayerIsolationContainer(WorkingBeatmap beatmap, RulesetInfo ruleset, IReadOnlyList 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;
+ }
+ }
+ }
+}
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid.cs
index 830378f129..6638d47dca 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid.cs
@@ -15,6 +15,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
///
public partial class PlayerGrid : CompositeDrawable
{
+ ///
+ /// 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.
+ ///
+ public const int MAX_PLAYERS = 16;
+
private const float player_spacing = 5;
///
@@ -58,11 +64,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
/// Adds a new cell with content to this grid.
///
/// The content the cell should contain.
- /// If more than 16 cells are added.
+ /// If more than cells are added.
public void Add(Drawable content)
{
- if (cellContainer.Count == 16)
- throw new InvalidOperationException("Only 16 cells are supported.");
+ if (cellContainer.Count == MAX_PLAYERS)
+ throw new InvalidOperationException($"Only {MAX_PLAYERS} cells are supported.");
int index = cellContainer.Count;
diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs
index 6542d01e64..11bc55823f 100644
--- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs
+++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs
@@ -218,10 +218,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
},
new Drawable[]
{
- new Footer
- {
- OnStart = onStart,
- }
+ new Footer { OnStart = StartPlay }
}
},
RowDimensions = new[]
@@ -274,9 +271,9 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
}, true);
}
- private void onStart() => StartPlay(() => new PlaylistsPlayer(SelectedItem.Value)
+ protected override Screen CreateGameplayScreen() => new PlaylistsPlayer(SelectedItem.Value)
{
Exited = () => leaderboard.RefreshScores()
- });
+ };
}
}
diff --git a/osu.Game/Screens/Play/GameplayClockContainer.cs b/osu.Game/Screens/Play/GameplayClockContainer.cs
index 1c8a3e51ac..f791da80c8 100644
--- a/osu.Game/Screens/Play/GameplayClockContainer.cs
+++ b/osu.Game/Screens/Play/GameplayClockContainer.cs
@@ -1,6 +1,7 @@
// 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 osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
@@ -12,7 +13,7 @@ namespace osu.Game.Screens.Play
///
/// Encapsulates gameplay timing logic and provides a via DI for gameplay components to use.
///
- public abstract class GameplayClockContainer : Container
+ public abstract class GameplayClockContainer : Container, IAdjustableClock
{
///
/// The final clock which is exposed to gameplay components.
@@ -157,5 +158,33 @@ namespace osu.Game.Screens.Play
/// The providing the source time.
/// The final .
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
}
}
diff --git a/osu.Game/Screens/Play/SpectatorPlayerLoader.cs b/osu.Game/Screens/Play/SpectatorPlayerLoader.cs
index 580af81166..bdd23962dc 100644
--- a/osu.Game/Screens/Play/SpectatorPlayerLoader.cs
+++ b/osu.Game/Screens/Play/SpectatorPlayerLoader.cs
@@ -12,7 +12,12 @@ namespace osu.Game.Screens.Play
public readonly ScoreInfo Score;
public SpectatorPlayerLoader(Score score)
- : base(() => new SpectatorPlayer(score))
+ : this(score, () => new SpectatorPlayer(score))
+ {
+ }
+
+ public SpectatorPlayerLoader(Score score, Func createPlayer)
+ : base(createPlayer)
{
if (score.Replay == null)
throw new ArgumentException($"{nameof(score)} must have a non-null {nameof(score.Replay)}.", nameof(score));
diff --git a/osu.Game/Screens/Spectate/SpectatorScreen.cs b/osu.Game/Screens/Spectate/SpectatorScreen.cs
index ed01d56801..bcebd51954 100644
--- a/osu.Game/Screens/Spectate/SpectatorScreen.cs
+++ b/osu.Game/Screens/Spectate/SpectatorScreen.cs
@@ -26,7 +26,9 @@ namespace osu.Game.Screens.Spectate
///
public abstract class SpectatorScreen : OsuScreen
{
- private readonly int[] userIds;
+ protected IReadOnlyList UserIds => userIds;
+
+ private readonly List userIds = new List();
[Resolved]
private BeatmapManager beatmaps { get; set; }
@@ -54,7 +56,7 @@ namespace osu.Game.Screens.Spectate
/// The users to spectate.
protected SpectatorScreen(params int[] userIds)
{
- this.userIds = userIds;
+ this.userIds.AddRange(userIds);
}
protected override void LoadComplete()
@@ -80,20 +82,18 @@ namespace osu.Game.Screens.Spectate
private Task populateAllUsers()
{
- var userLookupTasks = new Task[userIds.Length];
+ var userLookupTasks = new List();
- for (int i = 0; i < userIds.Length; i++)
+ foreach (var u in userIds)
{
- var userId = userIds[i];
-
- userLookupTasks[i] = userLookupCache.GetUserAsync(userId).ContinueWith(task =>
+ userLookupTasks.Add(userLookupCache.GetUserAsync(u).ContinueWith(task =>
{
if (!task.IsCompletedSuccessfully)
return;
lock (stateLock)
- userMap[userId] = task.Result;
- });
+ userMap[u] = task.Result;
+ }));
}
return Task.WhenAll(userLookupTasks);
@@ -239,6 +239,23 @@ namespace osu.Game.Screens.Spectate
/// The user to end gameplay for.
protected abstract void EndGameplay(int userId);
+ ///
+ /// Stops spectating a user.
+ ///
+ /// The user to stop spectating.
+ 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)
{
base.Dispose(isDisposing);
diff --git a/osu.Game/Skinning/DefaultLegacySkin.cs b/osu.Game/Skinning/DefaultLegacySkin.cs
index 564be8630e..f30130b1fb 100644
--- a/osu.Game/Skinning/DefaultLegacySkin.cs
+++ b/osu.Game/Skinning/DefaultLegacySkin.cs
@@ -1,7 +1,9 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using JetBrains.Annotations;
using osu.Framework.IO.Stores;
+using osu.Game.Extensions;
using osu.Game.IO;
using osuTK.Graphics;
@@ -14,6 +16,7 @@ namespace osu.Game.Skinning
{
}
+ [UsedImplicitly(ImplicitUseKindFlags.InstantiatedWithFixedConstructorSignature)]
public DefaultLegacySkin(SkinInfo skin, IResourceStore storage, IStorageResourceProvider resources)
: base(skin, storage, resources, string.Empty)
{
@@ -33,7 +36,7 @@ namespace osu.Game.Skinning
ID = SkinInfo.CLASSIC_SKIN, // this is temporary until database storage is decided upon.
Name = "osu!classic",
Creator = "team osu!",
- InstantiationInfo = typeof(DefaultLegacySkin).AssemblyQualifiedName,
+ InstantiationInfo = typeof(DefaultLegacySkin).GetInvariantInstantiationInfo()
};
}
}
diff --git a/osu.Game/Skinning/DefaultSkin.cs b/osu.Game/Skinning/DefaultSkin.cs
index 3de3dc7702..55f34ba1c6 100644
--- a/osu.Game/Skinning/DefaultSkin.cs
+++ b/osu.Game/Skinning/DefaultSkin.cs
@@ -3,6 +3,7 @@
using System.Collections.Generic;
using System.Linq;
+using JetBrains.Annotations;
using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
@@ -24,6 +25,7 @@ namespace osu.Game.Skinning
{
}
+ [UsedImplicitly(ImplicitUseKindFlags.InstantiatedWithFixedConstructorSignature)]
public DefaultSkin(SkinInfo skin, IStorageResourceProvider resources)
: base(skin, resources)
{
diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs
index 85cfab8297..27f6fcdf97 100644
--- a/osu.Game/Skinning/LegacySkin.cs
+++ b/osu.Game/Skinning/LegacySkin.cs
@@ -52,6 +52,7 @@ namespace osu.Game.Skinning
private readonly Dictionary maniaConfigurations = new Dictionary();
+ [UsedImplicitly(ImplicitUseKindFlags.InstantiatedWithFixedConstructorSignature)]
public LegacySkin(SkinInfo skin, IStorageResourceProvider resources)
: this(skin, new LegacySkinResourceStore(skin, resources.Files), resources, "skin.ini")
{
diff --git a/osu.Game/Skinning/SkinInfo.cs b/osu.Game/Skinning/SkinInfo.cs
index 2e29808cb4..bc57a8e71c 100644
--- a/osu.Game/Skinning/SkinInfo.cs
+++ b/osu.Game/Skinning/SkinInfo.cs
@@ -3,10 +3,10 @@
using System;
using System.Collections.Generic;
-using System.Linq;
using osu.Framework.IO.Stores;
using osu.Game.Configuration;
using osu.Game.Database;
+using osu.Game.Extensions;
using osu.Game.IO;
namespace osu.Game.Skinning
@@ -25,20 +25,7 @@ namespace osu.Game.Skinning
public string Creator { get; set; }
- private string instantiationInfo;
-
- public string InstantiationInfo
- {
- get => instantiationInfo;
- set => instantiationInfo = abbreviateInstantiationInfo(value);
- }
-
- private string abbreviateInstantiationInfo(string value)
- {
- // exclude version onwards, matching only on namespace and type.
- // this is mainly to allow for new versions of already loaded rulesets to "upgrade" from old.
- return string.Join(',', value.Split(',').Take(2));
- }
+ public string InstantiationInfo { get; set; }
public virtual Skin CreateInstance(IResourceStore legacyDefaultResources, IStorageResourceProvider resources)
{
@@ -64,7 +51,7 @@ namespace osu.Game.Skinning
ID = DEFAULT_SKIN,
Name = "osu!lazer",
Creator = "team osu!",
- InstantiationInfo = typeof(DefaultSkin).AssemblyQualifiedName,
+ InstantiationInfo = typeof(DefaultSkin).GetInvariantInstantiationInfo()
};
public bool Equals(SkinInfo other) => other != null && ID == other.ID;
diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs
index 63d7e4f86f..91f3d0c7cf 100644
--- a/osu.Game/Skinning/SkinManager.cs
+++ b/osu.Game/Skinning/SkinManager.cs
@@ -25,6 +25,7 @@ using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Audio;
using osu.Game.Database;
+using osu.Game.Extensions;
using osu.Game.IO;
using osu.Game.IO.Archives;
@@ -127,7 +128,7 @@ namespace osu.Game.Skinning
var instance = GetSkin(model);
- model.InstantiationInfo ??= instance.GetType().AssemblyQualifiedName;
+ model.InstantiationInfo ??= instance.GetType().GetInvariantInstantiationInfo();
if (model.Name?.Contains(".osk", StringComparison.OrdinalIgnoreCase) == true)
populateMetadata(model, instance);
diff --git a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs
index 7775c2bd24..db344b28dd 100644
--- a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs
+++ b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs
@@ -16,6 +16,9 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
public abstract class MultiplayerTestScene : RoomTestScene
{
+ public const int PLAYER_1_ID = 55;
+ public const int PLAYER_2_ID = 56;
+
[Cached(typeof(StatefulMultiplayerClient))]
public TestMultiplayerClient Client { get; }
diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs
index de77a15da0..167cf705a7 100644
--- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs
+++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs
@@ -25,6 +25,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
public override IBindable IsConnected => isConnected;
private readonly Bindable isConnected = new Bindable(true);
+ public Room? APIRoom { get; private set; }
+
public Action? RoomSetupAction;
[Resolved]
@@ -138,10 +140,16 @@ namespace osu.Game.Tests.Visual.Multiplayer
RoomSetupAction?.Invoke(room);
RoomSetupAction = null;
+ APIRoom = apiRoom;
+
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);
diff --git a/osu.Game/Tests/Visual/Spectator/TestSpectatorStreamingClient.cs b/osu.Game/Tests/Visual/Spectator/TestSpectatorStreamingClient.cs
new file mode 100644
index 0000000000..cc8437479d
--- /dev/null
+++ b/osu.Game/Tests/Visual/Spectator/TestSpectatorStreamingClient.cs
@@ -0,0 +1,90 @@
+// Copyright (c) ppy Pty Ltd . 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 PlayingUsers => (BindableList)base.PlayingUsers;
+ private readonly ConcurrentDictionary watchingUsers = new ConcurrentDictionary();
+
+ private readonly Dictionary userBeatmapDictionary = new Dictionary();
+ private readonly Dictionary userSentStateDictionary = new Dictionary();
+
+ 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();
+
+ 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;
+ }
+ }
+}