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/TestSceneSkinEditor.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs index 73da76df78..a0b27755b7 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs @@ -19,6 +19,8 @@ namespace osu.Game.Tests.Visual.Gameplay [Resolved] private SkinManager skinManager { get; set; } + protected override bool Autoplay => true; + [SetUpSteps] public override void SetUpSteps() { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs index c7c93b8892..245e190b1f 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs @@ -1,13 +1,11 @@ // 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.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play; @@ -32,12 +30,13 @@ namespace osu.Game.Tests.Visual.Gameplay SetContents(() => { var ruleset = new OsuRuleset(); + var mods = new[] { ruleset.GetAutoplayMod() }; var working = CreateWorkingBeatmap(ruleset.RulesetInfo); - var beatmap = working.GetPlayableBeatmap(ruleset.RulesetInfo); + var beatmap = working.GetPlayableBeatmap(ruleset.RulesetInfo, mods); - var drawableRuleset = ruleset.CreateDrawableRulesetWith(beatmap); + var drawableRuleset = ruleset.CreateDrawableRulesetWith(beatmap, mods); - var hudOverlay = new HUDOverlay(drawableRuleset, Array.Empty()) + var hudOverlay = new HUDOverlay(drawableRuleset, mods) { Anchor = Anchor.Centre, Origin = Anchor.Centre, 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/DrawableExtensions.cs b/osu.Game/Extensions/DrawableExtensions.cs index 0ec96a876e..2ac6e6ff22 100644 --- a/osu.Game/Extensions/DrawableExtensions.cs +++ b/osu.Game/Extensions/DrawableExtensions.cs @@ -46,9 +46,9 @@ namespace osu.Game.Extensions public static Vector2 ScreenSpaceDeltaToParentSpace(this Drawable drawable, Vector2 delta) => drawable.Parent.ToLocalSpace(drawable.Parent.ToScreenSpace(Vector2.Zero) + delta); - public static SkinnableInfo CreateSerialisedInformation(this Drawable component) => new SkinnableInfo(component); + public static SkinnableInfo CreateSkinnableInfo(this Drawable component) => new SkinnableInfo(component); - public static void ApplySerialisedInformation(this Drawable component, SkinnableInfo info) + public static void ApplySkinnableInfo(this Drawable component, SkinnableInfo info) { // todo: can probably make this better via deserialisation directly using a common interface. component.Position = info.Position; 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/HUD/DefaultAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs index d8dff89b29..45ba05e036 100644 --- a/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs @@ -7,7 +7,7 @@ using osu.Game.Skinning; namespace osu.Game.Screens.Play.HUD { - public class DefaultAccuracyCounter : GameplayAccuracyCounter, ISkinnableComponent + public class DefaultAccuracyCounter : GameplayAccuracyCounter, ISkinnableDrawable { [Resolved(canBeNull: true)] private HUDOverlay hud { get; set; } diff --git a/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs index 13bd045fc0..c4575c5ad0 100644 --- a/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs @@ -12,7 +12,7 @@ using osu.Game.Skinning; namespace osu.Game.Screens.Play.HUD { - public class DefaultComboCounter : RollingCounter, ISkinnableComponent + public class DefaultComboCounter : RollingCounter, ISkinnableDrawable { [Resolved(canBeNull: true)] private HUDOverlay hud { get; set; } diff --git a/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs b/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs index 241777244b..ed297f0ffc 100644 --- a/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs @@ -17,7 +17,7 @@ using osu.Game.Skinning; namespace osu.Game.Screens.Play.HUD { - public class DefaultHealthDisplay : HealthDisplay, IHasAccentColour, ISkinnableComponent + public class DefaultHealthDisplay : HealthDisplay, IHasAccentColour, ISkinnableDrawable { /// /// The base opacity of the glow. diff --git a/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs index bd18050dfb..16e3642181 100644 --- a/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs @@ -8,7 +8,7 @@ using osu.Game.Skinning; namespace osu.Game.Screens.Play.HUD { - public class DefaultScoreCounter : GameplayScoreCounter, ISkinnableComponent + public class DefaultScoreCounter : GameplayScoreCounter, ISkinnableDrawable { public DefaultScoreCounter() : base(6) diff --git a/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs b/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs index 2565faf423..d64513d41e 100644 --- a/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs @@ -15,7 +15,7 @@ namespace osu.Game.Screens.Play.HUD /// /// Uses the 'x' symbol and has a pop-out effect while rolling over. /// - public class LegacyComboCounter : CompositeDrawable, ISkinnableComponent + public class LegacyComboCounter : CompositeDrawable, ISkinnableDrawable { public Bindable Current { get; } = new BindableInt { MinValue = 0, }; diff --git a/osu.Game/Screens/Play/HUD/SkinnableInfo.cs b/osu.Game/Screens/Play/HUD/SkinnableInfo.cs index 2de3f1b729..e08044b14c 100644 --- a/osu.Game/Screens/Play/HUD/SkinnableInfo.cs +++ b/osu.Game/Screens/Play/HUD/SkinnableInfo.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Newtonsoft.Json; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Extensions; @@ -14,7 +15,7 @@ using osuTK; namespace osu.Game.Screens.Play.HUD { /// - /// Serialised information governing custom changes to an . + /// Serialised information governing custom changes to an . /// [Serializable] public class SkinnableInfo : IJsonSerializable @@ -33,10 +34,15 @@ namespace osu.Game.Screens.Play.HUD public List Children { get; } = new List(); + [JsonConstructor] public SkinnableInfo() { } + /// + /// Construct a new instance populating all attributes from the provided drawable. + /// + /// The drawable which attributes should be sourced from. public SkinnableInfo(Drawable component) { Type = component.GetType(); @@ -47,17 +53,21 @@ namespace osu.Game.Screens.Play.HUD Anchor = component.Anchor; Origin = component.Origin; - if (component is Container container) + if (component is Container container) { - foreach (var child in container.Children.OfType().OfType()) - Children.Add(child.CreateSerialisedInformation()); + foreach (var child in container.OfType().OfType()) + Children.Add(child.CreateSkinnableInfo()); } } + /// + /// Construct an instance of the drawable with all attributes applied. + /// + /// The new instance. public Drawable CreateInstance() { Drawable d = (Drawable)Activator.CreateInstance(Type); - d.ApplySerialisedInformation(this); + d.ApplySkinnableInfo(this); return d; } } diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs index 887346b5df..a10e91dae8 100644 --- a/osu.Game/Screens/Play/HUDOverlay.cs +++ b/osu.Game/Screens/Play/HUDOverlay.cs @@ -68,7 +68,7 @@ namespace osu.Game.Screens.Play private bool holdingForHUD; - private readonly SkinnableElementTargetContainer mainComponents; + private readonly SkinnableTargetContainer mainComponents; private IEnumerable hideTargets => new Drawable[] { visibilityContainer, KeyCounter, topRightElements }; @@ -97,7 +97,7 @@ namespace osu.Game.Screens.Play RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - mainComponents = new SkinnableElementTargetContainer(SkinnableTarget.MainHUDComponents) + mainComponents = new SkinnableTargetContainer(SkinnableTarget.MainHUDComponents) { RelativeSizeAxes = Axes.Both, }, 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..65e8fd1b82 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) { @@ -45,7 +47,7 @@ namespace osu.Game.Skinning switch (target.Target) { case SkinnableTarget.MainHUDComponents: - var skinnableTargetWrapper = new SkinnableTargetWrapper(container => + var skinnableTargetWrapper = new SkinnableTargetComponentsContainer(container => { var score = container.OfType().FirstOrDefault(); var accuracy = container.OfType().FirstOrDefault(); @@ -90,10 +92,10 @@ namespace osu.Game.Skinning return skinnableTargetWrapper; } - return null; + break; } - return base.GetDrawableComponent(component); + return null; } public override IBindable GetConfig(TLookup lookup) diff --git a/osu.Game/Skinning/Editor/SkinBlueprint.cs b/osu.Game/Skinning/Editor/SkinBlueprint.cs index b8dfdbad0a..4be9299699 100644 --- a/osu.Game/Skinning/Editor/SkinBlueprint.cs +++ b/osu.Game/Skinning/Editor/SkinBlueprint.cs @@ -13,7 +13,7 @@ using osuTK; namespace osu.Game.Skinning.Editor { - public class SkinBlueprint : SelectionBlueprint + public class SkinBlueprint : SelectionBlueprint { private Container box; @@ -26,7 +26,7 @@ namespace osu.Game.Skinning.Editor protected override bool ShouldBeAlive => (drawable.IsAlive && Item.IsPresent) || (AlwaysShowWhenSelected && State == SelectionState.Selected); - public SkinBlueprint(ISkinnableComponent component) + public SkinBlueprint(ISkinnableDrawable component) : base(component) { } diff --git a/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs b/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs index ef189ed165..c0cc2ab40e 100644 --- a/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs +++ b/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs @@ -12,14 +12,15 @@ using osu.Framework.Testing; using osu.Game.Rulesets.Edit; using osu.Game.Screens; using osu.Game.Screens.Edit.Compose.Components; -using osu.Game.Screens.Play.HUD; namespace osu.Game.Skinning.Editor { - public class SkinBlueprintContainer : BlueprintContainer + public class SkinBlueprintContainer : BlueprintContainer { private readonly Drawable target; + private readonly List> targetComponents = new List>(); + public SkinBlueprintContainer(Drawable target) { this.target = target; @@ -31,14 +32,12 @@ namespace osu.Game.Skinning.Editor SelectedItems.BindTo(editor.SelectedComponents); } - private readonly List> targetComponents = new List>(); - protected override void LoadComplete() { base.LoadComplete(); // track each target container on the current screen. - var targetContainers = target.ChildrenOfType().ToArray(); + var targetContainers = target.ChildrenOfType().ToArray(); if (targetContainers.Length == 0) { @@ -50,7 +49,7 @@ namespace osu.Game.Skinning.Editor foreach (var targetContainer in targetContainers) { - var bindableList = new BindableList { BindTarget = targetContainer.Components }; + var bindableList = new BindableList { BindTarget = targetContainer.Components }; bindableList.BindCollectionChanged(componentsChanged, true); targetComponents.Add(bindableList); @@ -62,29 +61,37 @@ namespace osu.Game.Skinning.Editor switch (e.Action) { case NotifyCollectionChangedAction.Add: - foreach (var item in e.NewItems.Cast()) + foreach (var item in e.NewItems.Cast()) AddBlueprintFor(item); break; case NotifyCollectionChangedAction.Remove: case NotifyCollectionChangedAction.Reset: - foreach (var item in e.OldItems.Cast()) + foreach (var item in e.OldItems.Cast()) RemoveBlueprintFor(item); break; case NotifyCollectionChangedAction.Replace: - foreach (var item in e.OldItems.Cast()) + foreach (var item in e.OldItems.Cast()) RemoveBlueprintFor(item); - foreach (var item in e.NewItems.Cast()) + foreach (var item in e.NewItems.Cast()) AddBlueprintFor(item); break; } } - protected override SelectionHandler CreateSelectionHandler() => new SkinSelectionHandler(); + protected override void AddBlueprintFor(ISkinnableDrawable item) + { + if (!item.IsEditable) + return; - protected override SelectionBlueprint CreateBlueprintFor(ISkinnableComponent component) + base.AddBlueprintFor(item); + } + + protected override SelectionHandler CreateSelectionHandler() => new SkinSelectionHandler(); + + protected override SelectionBlueprint CreateBlueprintFor(ISkinnableDrawable component) => new SkinBlueprint(component); } } diff --git a/osu.Game/Skinning/Editor/SkinComponentToolbox.cs b/osu.Game/Skinning/Editor/SkinComponentToolbox.cs index a000204062..8536cba139 100644 --- a/osu.Game/Skinning/Editor/SkinComponentToolbox.cs +++ b/osu.Game/Skinning/Editor/SkinComponentToolbox.cs @@ -56,7 +56,7 @@ namespace osu.Game.Skinning.Editor Spacing = new Vector2(20) }; - var skinnableTypes = typeof(OsuGame).Assembly.GetTypes().Where(t => typeof(ISkinnableComponent).IsAssignableFrom(t)).ToArray(); + var skinnableTypes = typeof(OsuGame).Assembly.GetTypes().Where(t => typeof(ISkinnableDrawable).IsAssignableFrom(t)).ToArray(); foreach (var type in skinnableTypes) { @@ -78,6 +78,9 @@ namespace osu.Game.Skinning.Editor Debug.Assert(instance != null); + if (!((ISkinnableDrawable)instance).IsEditable) + return null; + return new ToolboxComponentButton(instance); } catch diff --git a/osu.Game/Skinning/Editor/SkinEditor.cs b/osu.Game/Skinning/Editor/SkinEditor.cs index 4e38caa806..00aa6ae940 100644 --- a/osu.Game/Skinning/Editor/SkinEditor.cs +++ b/osu.Game/Skinning/Editor/SkinEditor.cs @@ -13,7 +13,6 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; -using osu.Game.Screens.Play.HUD; using osuTK; namespace osu.Game.Skinning.Editor @@ -23,19 +22,19 @@ namespace osu.Game.Skinning.Editor { public const double TRANSITION_DURATION = 500; + public readonly BindableList SelectedComponents = new BindableList(); + + protected override bool StartHidden => true; + private readonly Drawable targetScreen; private OsuTextFlowContainer headerText; - protected override bool StartHidden => true; - - public readonly BindableList SelectedComponents = new BindableList(); + private Bindable currentSkin; [Resolved] private SkinManager skins { get; set; } - private Bindable currentSkin; - [Resolved] private OsuColour colours { get; set; } @@ -171,8 +170,8 @@ namespace osu.Game.Skinning.Editor if (targetContainer == null) return; - if (!(Activator.CreateInstance(type) is ISkinnableComponent component)) - throw new InvalidOperationException("Attempted to instantiate a component for placement which was not an {typeof(ISkinnableComponent)}."); + if (!(Activator.CreateInstance(type) is ISkinnableDrawable component)) + throw new InvalidOperationException($"Attempted to instantiate a component for placement which was not an {typeof(ISkinnableDrawable)}."); var drawableComponent = (Drawable)component; @@ -194,7 +193,7 @@ namespace osu.Game.Skinning.Editor private void revert() { - SkinnableElementTargetContainer[] targetContainers = targetScreen.ChildrenOfType().ToArray(); + SkinnableTargetContainer[] targetContainers = targetScreen.ChildrenOfType().ToArray(); foreach (var t in targetContainers) { @@ -207,7 +206,7 @@ namespace osu.Game.Skinning.Editor private void save() { - SkinnableElementTargetContainer[] targetContainers = targetScreen.ChildrenOfType().ToArray(); + SkinnableTargetContainer[] targetContainers = targetScreen.ChildrenOfType().ToArray(); foreach (var t in targetContainers) currentSkin.Value.UpdateDrawableTarget(t); diff --git a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs index cf5ece03e9..8ca98c794f 100644 --- a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs +++ b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs @@ -14,7 +14,7 @@ using osuTK; namespace osu.Game.Skinning.Editor { - public class SkinSelectionHandler : SelectionHandler + public class SkinSelectionHandler : SelectionHandler { public override bool HandleRotation(float angle) { @@ -36,7 +36,7 @@ namespace osu.Game.Skinning.Editor return true; } - public override bool HandleMovement(MoveSelectionEvent moveEvent) + public override bool HandleMovement(MoveSelectionEvent moveEvent) { foreach (var c in SelectedBlueprints) { @@ -57,7 +57,7 @@ namespace osu.Game.Skinning.Editor SelectionBox.CanReverse = false; } - protected override void DeleteItems(IEnumerable items) + protected override void DeleteItems(IEnumerable items) { foreach (var i in items) { @@ -66,7 +66,7 @@ namespace osu.Game.Skinning.Editor } } - protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable> selection) + protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable> selection) { yield return new OsuMenuItem("Anchor") { @@ -131,7 +131,7 @@ namespace osu.Game.Skinning.Editor public class AnchorMenuItem : TernaryStateMenuItem { - public AnchorMenuItem(Anchor anchor, IEnumerable> selection, Action action) + public AnchorMenuItem(Anchor anchor, IEnumerable> selection, Action action) : base(anchor.ToString(), getNextState, MenuItemType.Standard, action) { } diff --git a/osu.Game/Skinning/ISkinSerialisable.cs b/osu.Game/Skinning/ISkinSerialisable.cs deleted file mode 100644 index d1777512af..0000000000 --- a/osu.Game/Skinning/ISkinSerialisable.cs +++ /dev/null @@ -1,15 +0,0 @@ -// 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.Graphics; - -namespace osu.Game.Skinning -{ - /// - /// Denotes a drawable component which should be serialised as part of a skin. - /// Use for components which should be mutable by the user / editor. - /// - public interface ISkinSerialisable : IDrawable - { - } -} diff --git a/osu.Game/Skinning/ISkinnableComponent.cs b/osu.Game/Skinning/ISkinnableDrawable.cs similarity index 58% rename from osu.Game/Skinning/ISkinnableComponent.cs rename to osu.Game/Skinning/ISkinnableDrawable.cs index e44c7be13d..d42b6f71b0 100644 --- a/osu.Game/Skinning/ISkinnableComponent.cs +++ b/osu.Game/Skinning/ISkinnableDrawable.cs @@ -1,12 +1,18 @@ // 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.Graphics; + namespace osu.Game.Skinning { /// /// Denotes a drawable which, as a drawable, can be adjusted via skinning specifications. /// - public interface ISkinnableComponent : ISkinSerialisable + public interface ISkinnableDrawable : IDrawable { + /// + /// Whether this component should be editable by an end user. + /// + bool IsEditable => true; } } diff --git a/osu.Game/Skinning/ISkinnableTarget.cs b/osu.Game/Skinning/ISkinnableTarget.cs index 4f6e3e66c3..65b8ba7b17 100644 --- a/osu.Game/Skinning/ISkinnableTarget.cs +++ b/osu.Game/Skinning/ISkinnableTarget.cs @@ -1,25 +1,44 @@ // 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.Bindables; using osu.Framework.Graphics; +using osu.Game.Extensions; +using osu.Game.Screens.Play.HUD; namespace osu.Game.Skinning { /// - /// Denotes a container which can house s. + /// Denotes a container which can house s. /// public interface ISkinnableTarget : IDrawable { - public SkinnableTarget Target { get; } + /// + /// The definition of this target. + /// + SkinnableTarget Target { get; } + + /// + /// A bindable list of components which are being tracked by this skinnable target. + /// + IBindableList Components { get; } + + /// + /// Serialise all children as . + /// + /// The serialised content. + IEnumerable CreateSkinnableInfo() => Components.Select(d => ((Drawable)d).CreateSkinnableInfo()); /// /// Reload this target from the current skin. /// - public void Reload(); + void Reload(); /// /// Add the provided item to this target. /// - public void Add(ISkinnableComponent drawable); + void Add(ISkinnableDrawable drawable); } } diff --git a/osu.Game/Skinning/LegacyAccuracyCounter.cs b/osu.Game/Skinning/LegacyAccuracyCounter.cs index 3eea5d22d5..16562d9571 100644 --- a/osu.Game/Skinning/LegacyAccuracyCounter.cs +++ b/osu.Game/Skinning/LegacyAccuracyCounter.cs @@ -10,11 +10,8 @@ using osuTK; namespace osu.Game.Skinning { - public class LegacyAccuracyCounter : GameplayAccuracyCounter, ISkinnableComponent + public class LegacyAccuracyCounter : GameplayAccuracyCounter, ISkinnableDrawable { - [Resolved] - private ISkinSource skin { get; set; } - public LegacyAccuracyCounter() { Anchor = Anchor.TopRight; diff --git a/osu.Game/Skinning/LegacyHealthDisplay.cs b/osu.Game/Skinning/LegacyHealthDisplay.cs index dfb6ca1a64..c601adc3a0 100644 --- a/osu.Game/Skinning/LegacyHealthDisplay.cs +++ b/osu.Game/Skinning/LegacyHealthDisplay.cs @@ -16,7 +16,7 @@ using osuTK.Graphics; namespace osu.Game.Skinning { - public class LegacyHealthDisplay : HealthDisplay, ISkinnableComponent + public class LegacyHealthDisplay : HealthDisplay, ISkinnableDrawable { private const double epic_cutoff = 0.5; diff --git a/osu.Game/Skinning/LegacyScoreCounter.cs b/osu.Game/Skinning/LegacyScoreCounter.cs index 8aa48da453..64ea03d59c 100644 --- a/osu.Game/Skinning/LegacyScoreCounter.cs +++ b/osu.Game/Skinning/LegacyScoreCounter.cs @@ -8,7 +8,7 @@ using osuTK; namespace osu.Game.Skinning { - public class LegacyScoreCounter : GameplayScoreCounter, ISkinnableComponent + public class LegacyScoreCounter : GameplayScoreCounter, ISkinnableDrawable { protected override double RollingDuration => 1000; protected override Easing RollingEasing => Easing.Out; diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index 85cfab8297..a6f8f45c0f 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") { @@ -331,7 +332,7 @@ namespace osu.Game.Skinning { case SkinnableTarget.MainHUDComponents: - var skinnableTargetWrapper = new SkinnableTargetWrapper(container => + var skinnableTargetWrapper = new SkinnableTargetComponentsContainer(container => { var score = container.OfType().FirstOrDefault(); var accuracy = container.OfType().FirstOrDefault(); diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs index 10481e4efd..2944c7a8ec 100644 --- a/osu.Game/Skinning/Skin.cs +++ b/osu.Game/Skinning/Skin.cs @@ -39,8 +39,6 @@ namespace osu.Game.Skinning { SkinInfo = skin; - // may be null for default skin. - // we may want to move this to some kind of async operation in the future. foreach (SkinnableTarget skinnableTarget in Enum.GetValues(typeof(SkinnableTarget))) { @@ -63,14 +61,22 @@ namespace osu.Game.Skinning } } - public void ResetDrawableTarget(SkinnableElementTargetContainer targetContainer) + /// + /// Remove all stored customisations for the provided target. + /// + /// The target container to reset. + public void ResetDrawableTarget(ISkinnableTarget targetContainer) { DrawableComponentInfo.Remove(targetContainer.Target); } - public void UpdateDrawableTarget(SkinnableElementTargetContainer targetContainer) + /// + /// Update serialised information for the provided target. + /// + /// The target container to serialise to this skin. + public void UpdateDrawableTarget(ISkinnableTarget targetContainer) { - DrawableComponentInfo[targetContainer.Target] = targetContainer.CreateSerialisedChildren().ToArray(); + DrawableComponentInfo[targetContainer.Target] = targetContainer.CreateSkinnableInfo().ToArray(); } public virtual Drawable GetDrawableComponent(ISkinComponent component) @@ -78,13 +84,10 @@ namespace osu.Game.Skinning switch (component) { case SkinnableTargetComponent target: - - var skinnableTarget = target.Target; - - if (!DrawableComponentInfo.TryGetValue(skinnableTarget, out var skinnableInfo)) + if (!DrawableComponentInfo.TryGetValue(target.Target, out var skinnableInfo)) return null; - return new SkinnableTargetWrapper + return new SkinnableTargetComponentsContainer { ChildrenEnumerable = skinnableInfo.Select(i => i.CreateInstance()) }; 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..5793edda30 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -19,12 +19,12 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; -using osu.Framework.Logging; using osu.Framework.Platform; 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 +127,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); @@ -180,7 +180,6 @@ namespace osu.Game.Skinning foreach (var drawableInfo in skin.DrawableComponentInfo) { - // todo: the OfType() call can be removed with better IDrawable support. string json = JsonConvert.SerializeObject(drawableInfo.Value, new JsonSerializerSettings { Formatting = Formatting.Indented }); using (var streamContent = new MemoryStream(Encoding.UTF8.GetBytes(json))) @@ -193,9 +192,6 @@ namespace osu.Game.Skinning ReplaceFile(skin.SkinInfo, oldFile, streamContent, oldFile.Filename); else AddFile(skin.SkinInfo, streamContent, filename); - - Logger.Log($"Saving out {filename} with {json.Length} bytes"); - Logger.Log(json); } } } diff --git a/osu.Game/Skinning/SkinnableTargetWrapper.cs b/osu.Game/Skinning/SkinnableTargetComponentsContainer.cs similarity index 56% rename from osu.Game/Skinning/SkinnableTargetWrapper.cs rename to osu.Game/Skinning/SkinnableTargetComponentsContainer.cs index 0d16775f51..2107ca7a8b 100644 --- a/osu.Game/Skinning/SkinnableTargetWrapper.cs +++ b/osu.Game/Skinning/SkinnableTargetComponentsContainer.cs @@ -2,30 +2,35 @@ // See the LICENCE file in the repository root for full licence text. using System; +using Newtonsoft.Json; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; namespace osu.Game.Skinning { /// - /// A container which is serialised and can encapsulate multiple skinnable elements into a single return type (for consumption via . - /// Will also optionally apply default cross-element layout dependencies when initialised from a non-deserialised source. + /// A container which groups the components of a into a single object. + /// Optionally also applies a default layout to the components. /// - public class SkinnableTargetWrapper : Container, ISkinSerialisable + [Serializable] + public class SkinnableTargetComponentsContainer : Container, ISkinnableDrawable { + public bool IsEditable => false; + private readonly Action applyDefaults; /// /// Construct a wrapper with defaults that should be applied once. /// - /// A function with default to apply after the initial layout (ie. consuming autosize) - public SkinnableTargetWrapper(Action applyDefaults) + /// A function to apply the default layout. + public SkinnableTargetComponentsContainer(Action applyDefaults) : this() { this.applyDefaults = applyDefaults; } - public SkinnableTargetWrapper() + [JsonConstructor] + public SkinnableTargetComponentsContainer() { RelativeSizeAxes = Axes.Both; } @@ -35,7 +40,7 @@ namespace osu.Game.Skinning base.LoadComplete(); // schedule is required to allow children to run their LoadComplete and take on their correct sizes. - Schedule(() => applyDefaults?.Invoke(this)); + ScheduleAfterChildren(() => applyDefaults?.Invoke(this)); } } } diff --git a/osu.Game/Screens/Play/HUD/SkinnableElementTargetContainer.cs b/osu.Game/Skinning/SkinnableTargetContainer.cs similarity index 50% rename from osu.Game/Screens/Play/HUD/SkinnableElementTargetContainer.cs rename to osu.Game/Skinning/SkinnableTargetContainer.cs index b2f6d32927..a4d7f621eb 100644 --- a/osu.Game/Screens/Play/HUD/SkinnableElementTargetContainer.cs +++ b/osu.Game/Skinning/SkinnableTargetContainer.cs @@ -2,62 +2,65 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Game.Extensions; -using osu.Game.Skinning; -namespace osu.Game.Screens.Play.HUD +namespace osu.Game.Skinning { - public class SkinnableElementTargetContainer : SkinReloadableDrawable, ISkinnableTarget + public class SkinnableTargetContainer : SkinReloadableDrawable, ISkinnableTarget { - private SkinnableTargetWrapper content; + private SkinnableTargetComponentsContainer content; public SkinnableTarget Target { get; } - public IBindableList Components => components; + public IBindableList Components => components; - private readonly BindableList components = new BindableList(); + private readonly BindableList components = new BindableList(); - public SkinnableElementTargetContainer(SkinnableTarget target) + public SkinnableTargetContainer(SkinnableTarget target) { Target = target; } + /// + /// Reload all components in this container from the current skin. + /// public void Reload() { ClearInternal(); components.Clear(); - content = CurrentSkin.GetDrawableComponent(new SkinnableTargetComponent(Target)) as SkinnableTargetWrapper; + content = CurrentSkin.GetDrawableComponent(new SkinnableTargetComponent(Target)) as SkinnableTargetComponentsContainer; if (content != null) { LoadComponentAsync(content, wrapper => { AddInternal(wrapper); - components.AddRange(wrapper.Children.OfType()); + components.AddRange(wrapper.Children.OfType()); }); } } - public void Add(ISkinnableComponent component) + /// + /// Add a new skinnable component to this target. + /// + /// The component to add. + /// Thrown when attempting to add an element to a target which is not supported by the current skin. + /// Thrown if the provided instance is not a . + public void Add(ISkinnableDrawable component) { if (content == null) throw new NotSupportedException("Attempting to add a new component to a target container which is not supported by the current skin."); if (!(component is Drawable drawable)) - throw new ArgumentException("Provided argument must be of type {nameof(ISkinnableComponent)}.", nameof(drawable)); + throw new ArgumentException($"Provided argument must be of type {nameof(Drawable)}.", nameof(drawable)); content.Add(drawable); components.Add(component); } - public IEnumerable CreateSerialisedChildren() => - components.Select(d => ((Drawable)d).CreateSerialisedInformation()); - protected override void SkinChanged(ISkinSource skin, bool allowFallback) { base.SkinChanged(skin, allowFallback); 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; + } + } +}