diff --git a/osu.Android.props b/osu.Android.props index 90d131b117..57550cfb93 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -52,6 +52,6 @@ - + diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs index 44c3056910..c36768baba 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs @@ -243,7 +243,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components int totalCount = Pieces.Count(p => p.IsSelected.Value); int countOfState = Pieces.Where(p => p.IsSelected.Value).Count(p => p.ControlPoint.Type.Value == type); - var item = new PathTypeMenuItem(type, () => + var item = new TernaryStateRadioMenuItem(type == null ? "Inherit" : type.ToString().Humanize(), MenuItemType.Standard, _ => { foreach (var p in Pieces.Where(p => p.IsSelected.Value)) updatePathType(p, type); @@ -258,15 +258,5 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components return item; } - - private class PathTypeMenuItem : TernaryStateMenuItem - { - public PathTypeMenuItem(PathType? type, Action action) - : base(type == null ? "Inherit" : type.ToString().Humanize(), changeState, MenuItemType.Standard, _ => action?.Invoke()) - { - } - - private static TernaryState changeState(TernaryState state) => TernaryState.True; - } } } diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs index 48ee0d4cf4..a24130d6ac 100644 --- a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs @@ -76,10 +76,10 @@ namespace osu.Game.Rulesets.Taiko.Edit protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable> selection) { if (selection.All(s => s.Item is Hit)) - yield return new TernaryStateMenuItem("Rim") { State = { BindTarget = selectionRimState } }; + yield return new TernaryStateToggleMenuItem("Rim") { State = { BindTarget = selectionRimState } }; if (selection.All(s => s.Item is TaikoHitObject)) - yield return new TernaryStateMenuItem("Strong") { State = { BindTarget = selectionStrongState } }; + yield return new TernaryStateToggleMenuItem("Strong") { State = { BindTarget = selectionStrongState } }; foreach (var item in base.GetContextMenuItemsForSelection(selection)) yield return item; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs index a7ed217b4d..e9894ff469 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs @@ -27,8 +27,8 @@ namespace osu.Game.Tests.Visual.Gameplay { private readonly User streamingUser = new User { Id = MultiplayerTestScene.PLAYER_1_ID, Username = "Test user" }; - [Cached(typeof(SpectatorStreamingClient))] - private TestSpectatorStreamingClient testSpectatorStreamingClient = new TestSpectatorStreamingClient(); + [Cached(typeof(SpectatorClient))] + private TestSpectatorClient testSpectatorClient = new TestSpectatorClient(); [Cached(typeof(UserLookupCache))] private UserLookupCache lookupCache = new TestUserLookupCache(); @@ -61,8 +61,8 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("add streaming client", () => { - Remove(testSpectatorStreamingClient); - Add(testSpectatorStreamingClient); + Remove(testSpectatorClient); + Add(testSpectatorClient); }); finish(); @@ -212,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(streamingUser.Id, beatmapId ?? importedBeatmapId)); + private void start(int? beatmapId = null) => AddStep("start play", () => testSpectatorClient.StartPlay(streamingUser.Id, beatmapId ?? importedBeatmapId)); - private void finish(int? beatmapId = null) => AddStep("end play", () => testSpectatorStreamingClient.EndPlay(streamingUser.Id, beatmapId ?? importedBeatmapId)); + private void finish() => AddStep("end play", () => testSpectatorClient.EndPlay(streamingUser.Id)); private void checkPaused(bool state) => AddUntilStep($"game is {(state ? "paused" : "playing")}", () => player.ChildrenOfType().First().IsPaused.Value == state); @@ -223,7 +223,7 @@ namespace osu.Game.Tests.Visual.Gameplay { AddStep("send frames", () => { - testSpectatorStreamingClient.SendFrames(streamingUser.Id, nextFrame, count); + testSpectatorClient.SendFrames(streamingUser.Id, nextFrame, count); nextFrame += count; }); } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs index 9c763814f3..469f594fdc 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs @@ -59,7 +59,7 @@ namespace osu.Game.Tests.Visual.Gameplay private IAPIProvider api { get; set; } [Resolved] - private SpectatorStreamingClient streamingClient { get; set; } + private SpectatorClient spectatorClient { get; set; } [Cached] private GameplayBeatmap gameplayBeatmap = new GameplayBeatmap(new Beatmap()); @@ -69,7 +69,7 @@ namespace osu.Game.Tests.Visual.Gameplay { replay = new Replay(); - users.BindTo(streamingClient.PlayingUsers); + users.BindTo(spectatorClient.PlayingUsers); users.BindCollectionChanged((obj, args) => { switch (args.Action) @@ -80,7 +80,7 @@ namespace osu.Game.Tests.Visual.Gameplay foreach (int user in args.NewItems) { if (user == api.LocalUser.Value.Id) - streamingClient.WatchUser(user); + spectatorClient.WatchUser(user); } break; @@ -91,14 +91,14 @@ namespace osu.Game.Tests.Visual.Gameplay foreach (int user in args.OldItems) { if (user == api.LocalUser.Value.Id) - streamingClient.StopWatchingUser(user); + spectatorClient.StopWatchingUser(user); } break; } }, true); - streamingClient.OnNewFrames += onNewFrames; + spectatorClient.OnNewFrames += onNewFrames; Add(new GridContainer { @@ -189,7 +189,7 @@ namespace osu.Game.Tests.Visual.Gameplay { } - private double latency = SpectatorStreamingClient.TIME_BETWEEN_SENDS; + private double latency = SpectatorClient.TIME_BETWEEN_SENDS; protected override void Update() { @@ -233,7 +233,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("stop recorder", () => { recorder.Expire(); - streamingClient.OnNewFrames -= onNewFrames; + spectatorClient.OnNewFrames -= onNewFrames; }); } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs index 263adc07e1..5ad35be0ec 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs @@ -23,8 +23,8 @@ namespace osu.Game.Tests.Visual.Multiplayer { public class TestSceneMultiSpectatorLeaderboard : MultiplayerTestScene { - [Cached(typeof(SpectatorStreamingClient))] - private TestSpectatorStreamingClient streamingClient = new TestSpectatorStreamingClient(); + [Cached(typeof(SpectatorClient))] + private TestSpectatorClient spectatorClient = new TestSpectatorClient(); [Cached(typeof(UserLookupCache))] private UserLookupCache lookupCache = new TestUserLookupCache(); @@ -42,7 +42,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { base.Content.AddRange(new Drawable[] { - streamingClient, + spectatorClient, lookupCache, content = new Container { RelativeSizeAxes = Axes.Both } }); @@ -59,7 +59,7 @@ namespace osu.Game.Tests.Visual.Multiplayer foreach (var (userId, clock) in clocks) { - streamingClient.EndPlay(userId, 0); + spectatorClient.EndPlay(userId); clock.CurrentTime = 0; } }); @@ -67,7 +67,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("create leaderboard", () => { foreach (var (userId, _) in clocks) - streamingClient.StartPlay(userId, 0); + spectatorClient.StartPlay(userId, 0); Beatmap.Value = CreateWorkingBeatmap(Ruleset.Value); @@ -96,10 +96,10 @@ namespace osu.Game.Tests.Visual.Multiplayer // For player 2, send frames in sets of 10. for (int i = 0; i < 100; i++) { - streamingClient.SendFrames(PLAYER_1_ID, i, 1); + spectatorClient.SendFrames(PLAYER_1_ID, i, 1); if (i % 10 == 0) - streamingClient.SendFrames(PLAYER_2_ID, i, 10); + spectatorClient.SendFrames(PLAYER_2_ID, i, 10); } }); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs index 689c249d05..b91391c409 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs @@ -22,8 +22,8 @@ namespace osu.Game.Tests.Visual.Multiplayer { public class TestSceneMultiSpectatorScreen : MultiplayerTestScene { - [Cached(typeof(SpectatorStreamingClient))] - private TestSpectatorStreamingClient streamingClient = new TestSpectatorStreamingClient(); + [Cached(typeof(SpectatorClient))] + private TestSpectatorClient spectatorClient = new TestSpectatorClient(); [Cached(typeof(UserLookupCache))] private UserLookupCache lookupCache = new TestUserLookupCache(); @@ -59,14 +59,14 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("add streaming client", () => { - Remove(streamingClient); - Add(streamingClient); + Remove(spectatorClient); + Add(spectatorClient); }); AddStep("finish previous gameplay", () => { foreach (var id in playingUserIds) - streamingClient.EndPlay(id, importedBeatmapId); + spectatorClient.EndPlay(id); playingUserIds.Clear(); }); } @@ -87,11 +87,11 @@ namespace osu.Game.Tests.Visual.Multiplayer loadSpectateScreen(false); AddWaitStep("wait a bit", 10); - AddStep("load player first_player_id", () => streamingClient.StartPlay(PLAYER_1_ID, importedBeatmapId)); + AddStep("load player first_player_id", () => spectatorClient.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)); + AddStep("load player second_player_id", () => spectatorClient.StartPlay(PLAYER_2_ID, importedBeatmapId)); AddUntilStep("two players added", () => spectatorScreen.ChildrenOfType().Count() == 2); } @@ -251,18 +251,18 @@ namespace osu.Game.Tests.Visual.Multiplayer foreach (int id in userIds) { Client.CurrentMatchPlayingUserIds.Add(id); - streamingClient.StartPlay(id, beatmapId ?? importedBeatmapId); + spectatorClient.StartPlay(id, beatmapId ?? importedBeatmapId); playingUserIds.Add(id); nextFrame[id] = 0; } }); } - private void finish(int userId, int? beatmapId = null) + private void finish(int userId) { AddStep("end play", () => { - streamingClient.EndPlay(userId, beatmapId ?? importedBeatmapId); + spectatorClient.EndPlay(userId); playingUserIds.Remove(userId); nextFrame.Remove(userId); }); @@ -276,7 +276,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { foreach (int id in userIds) { - streamingClient.SendFrames(id, nextFrame[id], count); + spectatorClient.SendFrames(id, nextFrame[id], count); nextFrame[id] += count; } }); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs index bba7e2b391..424efb255b 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs @@ -195,7 +195,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private class TestMultiplayer : Screens.OnlinePlay.Multiplayer.Multiplayer { - [Cached(typeof(StatefulMultiplayerClient))] + [Cached(typeof(MultiplayerClient))] public readonly TestMultiplayerClient Client; public TestMultiplayer() diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs index 6813a6e7dd..80b9aa8228 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs @@ -28,8 +28,8 @@ namespace osu.Game.Tests.Visual.Multiplayer { private const int users = 16; - [Cached(typeof(SpectatorStreamingClient))] - private TestMultiplayerStreaming streamingClient = new TestMultiplayerStreaming(); + [Cached(typeof(SpectatorClient))] + private TestMultiplayerSpectatorClient spectatorClient = new TestMultiplayerSpectatorClient(); [Cached(typeof(UserLookupCache))] private UserLookupCache lookupCache = new TestSceneCurrentlyPlayingDisplay.TestUserLookupCache(); @@ -44,7 +44,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { base.Content.Children = new Drawable[] { - streamingClient, + spectatorClient, lookupCache, Content }; @@ -71,10 +71,10 @@ namespace osu.Game.Tests.Visual.Multiplayer var playable = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value); for (int i = 0; i < users; i++) - streamingClient.StartPlay(i, Beatmap.Value.BeatmapInfo.OnlineBeatmapID ?? 0); + spectatorClient.StartPlay(i, Beatmap.Value.BeatmapInfo.OnlineBeatmapID ?? 0); Client.CurrentMatchPlayingUserIds.Clear(); - Client.CurrentMatchPlayingUserIds.AddRange(streamingClient.PlayingUsers); + Client.CurrentMatchPlayingUserIds.AddRange(spectatorClient.PlayingUsers); Children = new Drawable[] { @@ -83,7 +83,7 @@ namespace osu.Game.Tests.Visual.Multiplayer scoreProcessor.ApplyBeatmap(playable); - LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(scoreProcessor, streamingClient.PlayingUsers.ToArray()) + LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(scoreProcessor, spectatorClient.PlayingUsers.ToArray()) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -96,7 +96,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestScoreUpdates() { - AddRepeatStep("update state", () => streamingClient.RandomlyUpdateState(), 100); + AddRepeatStep("update state", () => spectatorClient.RandomlyUpdateState(), 100); AddToggleStep("switch compact mode", expanded => leaderboard.Expanded.Value = expanded); } @@ -109,12 +109,12 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestChangeScoringMode() { - AddRepeatStep("update state", () => streamingClient.RandomlyUpdateState(), 5); + AddRepeatStep("update state", () => spectatorClient.RandomlyUpdateState(), 5); AddStep("change to classic", () => config.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Classic)); AddStep("change to standardised", () => config.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Standardised)); } - public class TestMultiplayerStreaming : TestSpectatorStreamingClient + public class TestMultiplayerSpectatorClient : TestSpectatorClient { private readonly Dictionary lastHeaders = new Dictionary(); diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs b/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs index 2791952b66..3cedaf9d45 100644 --- a/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs +++ b/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs @@ -11,8 +11,8 @@ using osu.Game.Overlays; using osu.Game.Screens; using osu.Game.Screens.Menu; using osu.Game.Screens.Play; -using osu.Game.Screens.Select; using osuTK.Input; +using static osu.Game.Tests.Visual.Navigation.TestSceneScreenNavigation; namespace osu.Game.Tests.Visual.Navigation { @@ -37,17 +37,17 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestPerformAtSongSelect() { - PushAndConfirm(() => new PlaySongSelect()); + PushAndConfirm(() => new TestPlaySongSelect()); - AddStep("perform immediately", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(PlaySongSelect) })); + AddStep("perform immediately", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(TestPlaySongSelect) })); AddAssert("did perform", () => actionPerformed); - AddAssert("screen didn't change", () => Game.ScreenStack.CurrentScreen is PlaySongSelect); + AddAssert("screen didn't change", () => Game.ScreenStack.CurrentScreen is TestPlaySongSelect); } [Test] public void TestPerformAtMenuFromSongSelect() { - PushAndConfirm(() => new PlaySongSelect()); + PushAndConfirm(() => new TestPlaySongSelect()); AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true)); AddUntilStep("returned to menu", () => Game.ScreenStack.CurrentScreen is MainMenu); @@ -57,18 +57,18 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestPerformAtSongSelectFromPlayerLoader() { - PushAndConfirm(() => new PlaySongSelect()); + PushAndConfirm(() => new TestPlaySongSelect()); PushAndConfirm(() => new PlayerLoader(() => new SoloPlayer())); - AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(PlaySongSelect) })); - AddUntilStep("returned to song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect); + AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(TestPlaySongSelect) })); + AddUntilStep("returned to song select", () => Game.ScreenStack.CurrentScreen is TestPlaySongSelect); AddAssert("did perform", () => actionPerformed); } [Test] public void TestPerformAtMenuFromPlayerLoader() { - PushAndConfirm(() => new PlaySongSelect()); + PushAndConfirm(() => new TestPlaySongSelect()); PushAndConfirm(() => new PlayerLoader(() => new SoloPlayer())); AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true)); diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 859cefe3a9..253e448bb4 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -34,9 +34,9 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestExitSongSelectWithEscape() { - TestSongSelect songSelect = null; + TestPlaySongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestSongSelect()); + PushAndConfirm(() => songSelect = new TestPlaySongSelect()); AddStep("Show mods overlay", () => songSelect.ModSelectOverlay.Show()); AddAssert("Overlay was shown", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible); pushEscape(); @@ -51,9 +51,9 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestOpenModSelectOverlayUsingAction() { - TestSongSelect songSelect = null; + TestPlaySongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestSongSelect()); + PushAndConfirm(() => songSelect = new TestPlaySongSelect()); AddStep("Show mods overlay", () => InputManager.Key(Key.F1)); AddAssert("Overlay was shown", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible); } @@ -63,7 +63,7 @@ namespace osu.Game.Tests.Visual.Navigation { Player player = null; - PushAndConfirm(() => new TestSongSelect()); + PushAndConfirm(() => new TestPlaySongSelect()); AddStep("import beatmap", () => ImportBeatmapTest.LoadQuickOszIntoOsu(Game).Wait()); @@ -89,7 +89,7 @@ namespace osu.Game.Tests.Visual.Navigation WorkingBeatmap beatmap() => Game.Beatmap.Value; - PushAndConfirm(() => new TestSongSelect()); + PushAndConfirm(() => new TestPlaySongSelect()); AddStep("import beatmap", () => ImportBeatmapTest.LoadQuickOszIntoOsu(Game).Wait()); @@ -113,7 +113,7 @@ namespace osu.Game.Tests.Visual.Navigation WorkingBeatmap beatmap() => Game.Beatmap.Value; - PushAndConfirm(() => new TestSongSelect()); + PushAndConfirm(() => new TestPlaySongSelect()); AddStep("import beatmap", () => ImportBeatmapTest.LoadOszIntoOsu(Game, virtualTrack: true).Wait()); @@ -139,9 +139,9 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestMenuMakesMusic() { - TestSongSelect songSelect = null; + TestPlaySongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestSongSelect()); + PushAndConfirm(() => songSelect = new TestPlaySongSelect()); AddUntilStep("wait for no track", () => Game.MusicController.CurrentTrack.IsDummyDevice); @@ -153,9 +153,9 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestExitSongSelectWithClick() { - TestSongSelect songSelect = null; + TestPlaySongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestSongSelect()); + PushAndConfirm(() => songSelect = new TestPlaySongSelect()); AddStep("Show mods overlay", () => songSelect.ModSelectOverlay.Show()); AddAssert("Overlay was shown", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible); AddStep("Move mouse to backButton", () => InputManager.MoveMouseTo(backButtonPosition)); @@ -213,9 +213,9 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestModSelectInput() { - TestSongSelect songSelect = null; + TestPlaySongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestSongSelect()); + PushAndConfirm(() => songSelect = new TestPlaySongSelect()); AddStep("Show mods overlay", () => songSelect.ModSelectOverlay.Show()); @@ -234,9 +234,9 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestBeatmapOptionsInput() { - TestSongSelect songSelect = null; + TestPlaySongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestSongSelect()); + PushAndConfirm(() => songSelect = new TestPlaySongSelect()); AddStep("Show options overlay", () => songSelect.BeatmapOptionsOverlay.Show()); @@ -312,11 +312,13 @@ namespace osu.Game.Tests.Visual.Navigation ConfirmAtMainMenu(); } - private class TestSongSelect : PlaySongSelect + public class TestPlaySongSelect : PlaySongSelect { public ModSelectOverlay ModSelectOverlay => ModSelect; public BeatmapOptionsOverlay BeatmapOptionsOverlay => BeatmapOptions; + + protected override bool DisplayStableImportPrompt => false; } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs index 8ae6398003..30785fd163 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs @@ -19,8 +19,10 @@ namespace osu.Game.Tests.Visual.Online { public class TestSceneCurrentlyPlayingDisplay : OsuTestScene { - [Cached(typeof(SpectatorStreamingClient))] - private TestSpectatorStreamingClient testSpectatorStreamingClient = new TestSpectatorStreamingClient(); + private readonly User streamingUser = new User { Id = 2, Username = "Test user" }; + + [Cached(typeof(SpectatorClient))] + private TestSpectatorClient testSpectatorClient = new TestSpectatorClient(); private CurrentlyPlayingDisplay currentlyPlaying; @@ -34,7 +36,7 @@ namespace osu.Game.Tests.Visual.Online { AddStep("add streaming client", () => { - nestedContainer?.Remove(testSpectatorStreamingClient); + nestedContainer?.Remove(testSpectatorClient); Remove(lookupCache); Children = new Drawable[] @@ -45,7 +47,7 @@ namespace osu.Game.Tests.Visual.Online RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - testSpectatorStreamingClient, + testSpectatorClient, currentlyPlaying = new CurrentlyPlayingDisplay { RelativeSizeAxes = Axes.Both, @@ -55,15 +57,15 @@ namespace osu.Game.Tests.Visual.Online }; }); - AddStep("Reset players", () => testSpectatorStreamingClient.PlayingUsers.Clear()); + AddStep("Reset players", () => testSpectatorClient.EndPlay(streamingUser.Id)); } [Test] public void TestBasicDisplay() { - AddStep("Add playing user", () => testSpectatorStreamingClient.PlayingUsers.Add(2)); + AddStep("Add playing user", () => testSpectatorClient.StartPlay(streamingUser.Id, 0)); AddUntilStep("Panel loaded", () => currentlyPlaying.ChildrenOfType()?.FirstOrDefault()?.User.Id == 2); - AddStep("Remove playing user", () => testSpectatorStreamingClient.PlayingUsers.Remove(2)); + AddStep("Remove playing user", () => testSpectatorClient.EndPlay(streamingUser.Id)); AddUntilStep("Panel no longer present", () => !currentlyPlaying.ChildrenOfType().Any()); } diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs b/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs index 2af15923a0..f305b7255e 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs @@ -22,82 +22,17 @@ namespace osu.Game.Tests.Visual.Ranking { public class TestSceneAccuracyCircle : OsuTestScene { - [Test] - public void TestLowDRank() + [TestCase(0.2, ScoreRank.D)] + [TestCase(0.5, ScoreRank.D)] + [TestCase(0.75, ScoreRank.C)] + [TestCase(0.85, ScoreRank.B)] + [TestCase(0.925, ScoreRank.A)] + [TestCase(0.975, ScoreRank.S)] + [TestCase(0.9999, ScoreRank.S)] + [TestCase(1, ScoreRank.X)] + public void TestRank(double accuracy, ScoreRank rank) { - var score = createScore(); - score.Accuracy = 0.2; - score.Rank = ScoreRank.D; - - addCircleStep(score); - } - - [Test] - public void TestDRank() - { - var score = createScore(); - score.Accuracy = 0.5; - score.Rank = ScoreRank.D; - - addCircleStep(score); - } - - [Test] - public void TestCRank() - { - var score = createScore(); - score.Accuracy = 0.75; - score.Rank = ScoreRank.C; - - addCircleStep(score); - } - - [Test] - public void TestBRank() - { - var score = createScore(); - score.Accuracy = 0.85; - score.Rank = ScoreRank.B; - - addCircleStep(score); - } - - [Test] - public void TestARank() - { - var score = createScore(); - score.Accuracy = 0.925; - score.Rank = ScoreRank.A; - - addCircleStep(score); - } - - [Test] - public void TestSRank() - { - var score = createScore(); - score.Accuracy = 0.975; - score.Rank = ScoreRank.S; - - addCircleStep(score); - } - - [Test] - public void TestAlmostSSRank() - { - var score = createScore(); - score.Accuracy = 0.9999; - score.Rank = ScoreRank.S; - - addCircleStep(score); - } - - [Test] - public void TestSSRank() - { - var score = createScore(); - score.Accuracy = 1; - score.Rank = ScoreRank.X; + var score = createScore(accuracy, rank); addCircleStep(score); } @@ -120,7 +55,7 @@ namespace osu.Game.Tests.Visual.Ranking } } }, - new AccuracyCircle(score, true) + new AccuracyCircle(score) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -129,7 +64,7 @@ namespace osu.Game.Tests.Visual.Ranking }; }); - private ScoreInfo createScore() => new ScoreInfo + private ScoreInfo createScore(double accuracy, ScoreRank rank) => new ScoreInfo { User = new User { @@ -139,9 +74,9 @@ namespace osu.Game.Tests.Visual.Ranking Beatmap = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo, Mods = new Mod[] { new OsuModHardRock(), new OsuModDoubleTime() }, TotalScore = 2845370, - Accuracy = 0.95, + Accuracy = accuracy, MaxCombo = 999, - Rank = ScoreRank.S, + Rank = rank, Date = DateTimeOffset.Now, Statistics = { diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs index b2be7cdf88..ba6b6bd529 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs @@ -29,13 +29,8 @@ namespace osu.Game.Tests.Visual.Ranking [TestFixture] public class TestSceneResultsScreen : OsuManualInputManagerTestScene { - private BeatmapManager beatmaps; - - [BackgroundDependencyLoader] - private void load(BeatmapManager beatmaps) - { - this.beatmaps = beatmaps; - } + [Resolved] + private BeatmapManager beatmaps { get; set; } protected override void LoadComplete() { @@ -46,10 +41,6 @@ namespace osu.Game.Tests.Visual.Ranking Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmapInfo); } - private TestResultsScreen createResultsScreen() => new TestResultsScreen(new TestScoreInfo(new OsuRuleset().RulesetInfo)); - - private UnrankedSoloResultsScreen createUnrankedSoloResultsScreen() => new UnrankedSoloResultsScreen(new TestScoreInfo(new OsuRuleset().RulesetInfo)); - [Test] public void TestResultsWithoutPlayer() { @@ -69,12 +60,25 @@ namespace osu.Game.Tests.Visual.Ranking AddAssert("retry overlay not present", () => screen.RetryOverlay == null); } - [Test] - public void TestResultsWithPlayer() + [TestCase(0.2, ScoreRank.D)] + [TestCase(0.5, ScoreRank.D)] + [TestCase(0.75, ScoreRank.C)] + [TestCase(0.85, ScoreRank.B)] + [TestCase(0.925, ScoreRank.A)] + [TestCase(0.975, ScoreRank.S)] + [TestCase(0.9999, ScoreRank.S)] + [TestCase(1, ScoreRank.X)] + public void TestResultsWithPlayer(double accuracy, ScoreRank rank) { TestResultsScreen screen = null; - AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen())); + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) + { + Accuracy = accuracy, + Rank = rank + }; + + AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen(score))); AddUntilStep("wait for loaded", () => screen.IsLoaded); AddAssert("retry overlay present", () => screen.RetryOverlay != null); } @@ -232,6 +236,10 @@ namespace osu.Game.Tests.Visual.Ranking AddAssert("download button is enabled", () => screen.ChildrenOfType().Last().Enabled.Value); } + private TestResultsScreen createResultsScreen(ScoreInfo score = null) => new TestResultsScreen(score ?? new TestScoreInfo(new OsuRuleset().RulesetInfo)); + + private UnrankedSoloResultsScreen createUnrankedSoloResultsScreen() => new UnrankedSoloResultsScreen(new TestScoreInfo(new OsuRuleset().RulesetInfo)); + private class TestResultsContainer : Container { [Cached(typeof(Player))] diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBreadcrumbControlHeader.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBreadcrumbControlHeader.cs new file mode 100644 index 0000000000..90c3e142df --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBreadcrumbControlHeader.cs @@ -0,0 +1,86 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Overlays; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneBreadcrumbControlHeader : OsuTestScene + { + private static readonly string[] items = { "first", "second", "third", "fourth", "fifth" }; + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Red); + + private TestHeader header; + + [SetUp] + public void SetUp() => Schedule(() => + { + Child = header = new TestHeader + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + }); + + [Test] + public void TestAddAndRemoveItem() + { + foreach (var item in items.Skip(1)) + AddStep($"Add {item} item", () => header.AddItem(item)); + + foreach (var item in items.Reverse().SkipLast(3)) + AddStep($"Remove {item} item", () => header.RemoveItem(item)); + + AddStep("Clear items", () => header.ClearItems()); + + foreach (var item in items) + AddStep($"Add {item} item", () => header.AddItem(item)); + + foreach (var item in items) + AddStep($"Remove {item} item", () => header.RemoveItem(item)); + } + + private class TestHeader : BreadcrumbControlOverlayHeader + { + public TestHeader() + { + TabControl.AddItem(items[0]); + Current.Value = items[0]; + } + + public void AddItem(string value) + { + TabControl.AddItem(value); + Current.Value = TabControl.Items.LastOrDefault(); + } + + public void RemoveItem(string value) + { + TabControl.RemoveItem(value); + Current.Value = TabControl.Items.LastOrDefault(); + } + + public void ClearItems() + { + TabControl.Clear(); + Current.Value = null; + } + + protected override OverlayTitle CreateTitle() => new TestTitle(); + } + + private class TestTitle : OverlayTitle + { + public TestTitle() + { + Title = "Test Title"; + } + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneStatefulMenuItem.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneStatefulMenuItem.cs index 29aeb6a4b2..18ec631f37 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneStatefulMenuItem.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneStatefulMenuItem.cs @@ -14,7 +14,7 @@ namespace osu.Game.Tests.Visual.UserInterface public class TestSceneStatefulMenuItem : OsuManualInputManagerTestScene { [Test] - public void TestTernaryMenuItem() + public void TestTernaryRadioMenuItem() { OsuMenu menu = null; @@ -30,9 +30,57 @@ namespace osu.Game.Tests.Visual.UserInterface Origin = Anchor.Centre, Items = new[] { - new TernaryStateMenuItem("First"), - new TernaryStateMenuItem("Second") { State = { BindTarget = state } }, - new TernaryStateMenuItem("Third") { State = { Value = TernaryState.True } }, + new TernaryStateRadioMenuItem("First"), + new TernaryStateRadioMenuItem("Second") { State = { BindTarget = state } }, + new TernaryStateRadioMenuItem("Third") { State = { Value = TernaryState.True } }, + } + }; + }); + + checkState(TernaryState.Indeterminate); + + click(); + checkState(TernaryState.True); + + click(); + checkState(TernaryState.True); + + click(); + checkState(TernaryState.True); + + AddStep("change state via bindable", () => state.Value = TernaryState.True); + + void click() => + AddStep("click", () => + { + InputManager.MoveMouseTo(menu.ScreenSpaceDrawQuad.Centre); + InputManager.Click(MouseButton.Left); + }); + + void checkState(TernaryState expected) + => AddAssert($"state is {expected}", () => state.Value == expected); + } + + [Test] + public void TestTernaryToggleMenuItem() + { + OsuMenu menu = null; + + Bindable state = new Bindable(TernaryState.Indeterminate); + + AddStep("create menu", () => + { + state.Value = TernaryState.Indeterminate; + + Child = menu = new OsuMenu(Direction.Vertical, true) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Items = new[] + { + new TernaryStateToggleMenuItem("First"), + new TernaryStateToggleMenuItem("Second") { State = { BindTarget = state } }, + new TernaryStateToggleMenuItem("Third") { State = { Value = TernaryState.True } }, } }; }); diff --git a/osu.Game/Collections/CollectionManager.cs b/osu.Game/Collections/CollectionManager.cs index 9723409c79..3a63587b30 100644 --- a/osu.Game/Collections/CollectionManager.cs +++ b/osu.Game/Collections/CollectionManager.cs @@ -8,13 +8,13 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; -using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Beatmaps; +using osu.Game.IO; using osu.Game.IO.Legacy; using osu.Game.Overlays.Notifications; @@ -38,8 +38,6 @@ namespace osu.Game.Collections public readonly BindableList Collections = new BindableList(); - public bool SupportsImportFromStable => RuntimeInfo.IsDesktop; - [Resolved] private GameHost host { get; set; } @@ -96,25 +94,12 @@ namespace osu.Game.Collections /// public Action PostNotification { protected get; set; } - /// - /// Set a storage with access to an osu-stable install for import purposes. - /// - public Func GetStableStorage { private get; set; } - /// /// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future. /// - public Task ImportFromStableAsync() + public Task ImportFromStableAsync(StableStorage stableStorage) { - var stable = GetStableStorage?.Invoke(); - - if (stable == null) - { - Logger.Log("No osu!stable installation available!", LoggingTarget.Information, LogLevel.Error); - return Task.CompletedTask; - } - - if (!stable.Exists(database_name)) + if (!stableStorage.Exists(database_name)) { // This handles situations like when the user does not have a collections.db file Logger.Log($"No {database_name} available in osu!stable installation", LoggingTarget.Information, LogLevel.Error); @@ -123,7 +108,7 @@ namespace osu.Game.Collections return Task.Run(async () => { - using (var stream = stable.GetStream(database_name)) + using (var stream = stableStorage.GetStream(database_name)) await Import(stream).ConfigureAwait(false); }); } diff --git a/osu.Game/Database/ArchiveModelManager.cs b/osu.Game/Database/ArchiveModelManager.cs index e0f80d2743..8efd451857 100644 --- a/osu.Game/Database/ArchiveModelManager.cs +++ b/osu.Game/Database/ArchiveModelManager.cs @@ -10,7 +10,6 @@ using System.Threading.Tasks; using Humanizer; using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; -using osu.Framework; using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Extensions.IEnumerableExtensions; @@ -81,8 +80,6 @@ namespace osu.Game.Database public virtual IEnumerable HandledExtensions => new[] { ".zip" }; - public virtual bool SupportsImportFromStable => RuntimeInfo.IsDesktop; - protected readonly FileStore Files; protected readonly IDatabaseContextFactory ContextFactory; @@ -669,16 +666,6 @@ namespace osu.Game.Database #region osu-stable import - /// - /// Set a storage with access to an osu-stable install for import purposes. - /// - public Func GetStableStorage { private get; set; } - - /// - /// Denotes whether an osu-stable installation is present to perform automated imports from. - /// - public bool StableInstallationAvailable => GetStableStorage?.Invoke() != null; - /// /// The relative path from osu-stable's data directory to import items from. /// @@ -700,22 +687,16 @@ namespace osu.Game.Database /// /// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future. /// - public Task ImportFromStableAsync() + public Task ImportFromStableAsync(StableStorage stableStorage) { - var stableStorage = GetStableStorage?.Invoke(); - - if (stableStorage == null) - { - Logger.Log("No osu!stable installation available!", LoggingTarget.Information, LogLevel.Error); - return Task.CompletedTask; - } - var storage = PrepareStableStorage(stableStorage); + // Handle situations like when the user does not have a Skins folder. if (!storage.ExistsDirectory(ImportFromStablePath)) { - // This handles situations like when the user does not have a Skins folder - Logger.Log($"No {ImportFromStablePath} folder available in osu!stable installation", LoggingTarget.Information, LogLevel.Error); + string fullPath = storage.GetFullPath(ImportFromStablePath); + + Logger.Log($"Folder \"{fullPath}\" not available in the target osu!stable installation to import {HumanisedModelName}s.", LoggingTarget.Information, LogLevel.Error); return Task.CompletedTask; } diff --git a/osu.Game/Database/StableImportManager.cs b/osu.Game/Database/StableImportManager.cs new file mode 100644 index 0000000000..63a6db35c0 --- /dev/null +++ b/osu.Game/Database/StableImportManager.cs @@ -0,0 +1,96 @@ +// 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 System.Threading.Tasks; +using osu.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions.EnumExtensions; +using osu.Framework.Graphics; +using osu.Framework.Platform; +using osu.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.IO; +using osu.Game.Overlays; +using osu.Game.Overlays.Settings.Sections.Maintenance; +using osu.Game.Scoring; +using osu.Game.Skinning; + +namespace osu.Game.Database +{ + public class StableImportManager : Component + { + [Resolved] + private SkinManager skins { get; set; } + + [Resolved] + private BeatmapManager beatmaps { get; set; } + + [Resolved] + private ScoreManager scores { get; set; } + + [Resolved] + private CollectionManager collections { get; set; } + + [Resolved] + private OsuGame game { get; set; } + + [Resolved] + private DialogOverlay dialogOverlay { get; set; } + + [Resolved(CanBeNull = true)] + private DesktopGameHost desktopGameHost { get; set; } + + private StableStorage cachedStorage; + + public bool SupportsImportFromStable => RuntimeInfo.IsDesktop; + + public async Task ImportFromStableAsync(StableContent content) + { + var stableStorage = await getStableStorage().ConfigureAwait(false); + var importTasks = new List(); + + Task beatmapImportTask = Task.CompletedTask; + if (content.HasFlagFast(StableContent.Beatmaps)) + importTasks.Add(beatmapImportTask = beatmaps.ImportFromStableAsync(stableStorage)); + + if (content.HasFlagFast(StableContent.Skins)) + importTasks.Add(skins.ImportFromStableAsync(stableStorage)); + + if (content.HasFlagFast(StableContent.Collections)) + importTasks.Add(beatmapImportTask.ContinueWith(_ => collections.ImportFromStableAsync(stableStorage), TaskContinuationOptions.OnlyOnRanToCompletion)); + + if (content.HasFlagFast(StableContent.Scores)) + importTasks.Add(beatmapImportTask.ContinueWith(_ => scores.ImportFromStableAsync(stableStorage), TaskContinuationOptions.OnlyOnRanToCompletion)); + + await Task.WhenAll(importTasks.ToArray()).ConfigureAwait(false); + } + + private async Task getStableStorage() + { + if (cachedStorage != null) + return cachedStorage; + + var stableStorage = game.GetStorageForStableInstall(); + if (stableStorage != null) + return cachedStorage = stableStorage; + + var taskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Schedule(() => dialogOverlay.Push(new StableDirectoryLocationDialog(taskCompletionSource))); + var stablePath = await taskCompletionSource.Task.ConfigureAwait(false); + + return cachedStorage = new StableStorage(stablePath, desktopGameHost); + } + } + + [Flags] + public enum StableContent + { + Beatmaps = 1 << 0, + Scores = 1 << 1, + Skins = 1 << 2, + Collections = 1 << 3, + All = Beatmaps | Scores | Skins | Collections + } +} diff --git a/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs b/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs index acf4065f49..5c623150b7 100644 --- a/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs @@ -9,28 +9,17 @@ namespace osu.Game.Graphics.UserInterface /// /// An with three possible states. /// - public class TernaryStateMenuItem : StatefulMenuItem + public abstract class TernaryStateMenuItem : StatefulMenuItem { /// /// Creates a new . /// /// The text to display. + /// A function to inform what the next state should be when this item is clicked. /// The type of action which this performs. /// A delegate to be invoked when this is pressed. - public TernaryStateMenuItem(string text, MenuItemType type = MenuItemType.Standard, Action action = null) - : this(text, getNextState, type, action) - { - } - - /// - /// Creates a new . - /// - /// The text to display. - /// A function that mutates a state to another state after this is pressed. - /// The type of action which this performs. - /// A delegate to be invoked when this is pressed. - protected TernaryStateMenuItem(string text, Func changeStateFunc, MenuItemType type, Action action) - : base(text, changeStateFunc, type, action) + protected TernaryStateMenuItem(string text, Func nextStateFunction, MenuItemType type = MenuItemType.Standard, Action action = null) + : base(text, nextStateFunction, type, action) { } @@ -47,23 +36,5 @@ namespace osu.Game.Graphics.UserInterface return null; } - - private static TernaryState getNextState(TernaryState state) - { - switch (state) - { - case TernaryState.False: - return TernaryState.True; - - case TernaryState.Indeterminate: - return TernaryState.True; - - case TernaryState.True: - return TernaryState.False; - - default: - throw new ArgumentOutOfRangeException(nameof(state), state, null); - } - } } } diff --git a/osu.Game/Graphics/UserInterface/TernaryStateRadioMenuItem.cs b/osu.Game/Graphics/UserInterface/TernaryStateRadioMenuItem.cs new file mode 100644 index 0000000000..46eda06294 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/TernaryStateRadioMenuItem.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; + +namespace osu.Game.Graphics.UserInterface +{ + /// + /// A ternary state menu item which will always set the item to true on click, even if already true. + /// + public class TernaryStateRadioMenuItem : TernaryStateMenuItem + { + /// + /// Creates a new . + /// + /// The text to display. + /// The type of action which this performs. + /// A delegate to be invoked when this is pressed. + public TernaryStateRadioMenuItem(string text, MenuItemType type = MenuItemType.Standard, Action action = null) + : base(text, getNextState, type, action) + { + } + + private static TernaryState getNextState(TernaryState state) => TernaryState.True; + } +} diff --git a/osu.Game/Graphics/UserInterface/TernaryStateToggleMenuItem.cs b/osu.Game/Graphics/UserInterface/TernaryStateToggleMenuItem.cs new file mode 100644 index 0000000000..ce951984fd --- /dev/null +++ b/osu.Game/Graphics/UserInterface/TernaryStateToggleMenuItem.cs @@ -0,0 +1,42 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; + +namespace osu.Game.Graphics.UserInterface +{ + /// + /// A ternary state menu item which toggles the state of this item false if clicked when true. + /// + public class TernaryStateToggleMenuItem : TernaryStateMenuItem + { + /// + /// Creates a new . + /// + /// The text to display. + /// The type of action which this performs. + /// A delegate to be invoked when this is pressed. + public TernaryStateToggleMenuItem(string text, MenuItemType type = MenuItemType.Standard, Action action = null) + : base(text, getNextState, type, action) + { + } + + private static TernaryState getNextState(TernaryState state) + { + switch (state) + { + case TernaryState.False: + return TernaryState.True; + + case TernaryState.Indeterminate: + return TernaryState.True; + + case TernaryState.True: + return TernaryState.False; + + default: + throw new ArgumentOutOfRangeException(nameof(state), state, null); + } + } + } +} diff --git a/osu.Game/Online/API/Requests/GetNewsRequest.cs b/osu.Game/Online/API/Requests/GetNewsRequest.cs index 36d9dc0652..992ccc6d59 100644 --- a/osu.Game/Online/API/Requests/GetNewsRequest.cs +++ b/osu.Game/Online/API/Requests/GetNewsRequest.cs @@ -8,10 +8,12 @@ namespace osu.Game.Online.API.Requests { public class GetNewsRequest : APIRequest { + private readonly int? year; private readonly Cursor cursor; - public GetNewsRequest(Cursor cursor = null) + public GetNewsRequest(int? year = null, Cursor cursor = null) { + this.year = year; this.cursor = cursor; } @@ -19,6 +21,10 @@ namespace osu.Game.Online.API.Requests { var req = base.CreateWebRequest(); req.AddCursor(cursor); + + if (year.HasValue) + req.AddParameter("year", year.Value.ToString()); + return req; } diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index 4529dfd0a7..2e65f7cf1c 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -3,132 +3,621 @@ #nullable enable +using System; using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; using System.Threading; using System.Threading.Tasks; -using Microsoft.AspNetCore.SignalR.Client; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Logging; using osu.Game.Beatmaps; +using osu.Game.Database; using osu.Game.Online.API; -using osu.Game.Online.API.Requests; using osu.Game.Online.Rooms; +using osu.Game.Online.Rooms.RoomStatuses; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Users; +using osu.Game.Utils; namespace osu.Game.Online.Multiplayer { - public class MultiplayerClient : StatefulMultiplayerClient + public abstract class MultiplayerClient : Component, IMultiplayerClient, IMultiplayerRoomServer { - private readonly string endpoint; + /// + /// Invoked when any change occurs to the multiplayer room. + /// + public event Action? RoomUpdated; - private IHubClientConnector? connector; + /// + /// Invoked when the multiplayer server requests the current beatmap to be loaded into play. + /// + public event Action? LoadRequested; - public override IBindable IsConnected { get; } = new BindableBool(); + /// + /// Invoked when the multiplayer server requests gameplay to be started. + /// + public event Action? MatchStarted; - private HubConnection? connection => connector?.CurrentConnection; + /// + /// Invoked when the multiplayer server has finished collating results. + /// + public event Action? ResultsReady; - public MultiplayerClient(EndpointConfiguration endpoints) + /// + /// Whether the is currently connected. + /// This is NOT thread safe and usage should be scheduled. + /// + public abstract IBindable IsConnected { get; } + + /// + /// The joined . + /// + public MultiplayerRoom? Room { get; private set; } + + /// + /// The users in the joined which are participating in the current gameplay loop. + /// + public readonly BindableList CurrentMatchPlayingUserIds = new BindableList(); + + public readonly Bindable CurrentMatchPlayingItem = new Bindable(); + + /// + /// The corresponding to the local player, if available. + /// + public MultiplayerRoomUser? LocalUser => Room?.Users.SingleOrDefault(u => u.User?.Id == API.LocalUser.Value.Id); + + /// + /// Whether the is the host in . + /// + public bool IsHost { - endpoint = endpoints.MultiplayerEndpointUrl; - } - - [BackgroundDependencyLoader] - private void load(IAPIProvider api) - { - connector = api.GetHubConnector(nameof(MultiplayerClient), endpoint); - - if (connector != null) + get { - connector.ConfigureConnection = connection => - { - // this is kind of SILLY - // https://github.com/dotnet/aspnetcore/issues/15198 - connection.On(nameof(IMultiplayerClient.RoomStateChanged), ((IMultiplayerClient)this).RoomStateChanged); - connection.On(nameof(IMultiplayerClient.UserJoined), ((IMultiplayerClient)this).UserJoined); - connection.On(nameof(IMultiplayerClient.UserLeft), ((IMultiplayerClient)this).UserLeft); - connection.On(nameof(IMultiplayerClient.HostChanged), ((IMultiplayerClient)this).HostChanged); - connection.On(nameof(IMultiplayerClient.SettingsChanged), ((IMultiplayerClient)this).SettingsChanged); - connection.On(nameof(IMultiplayerClient.UserStateChanged), ((IMultiplayerClient)this).UserStateChanged); - connection.On(nameof(IMultiplayerClient.LoadRequested), ((IMultiplayerClient)this).LoadRequested); - connection.On(nameof(IMultiplayerClient.MatchStarted), ((IMultiplayerClient)this).MatchStarted); - connection.On(nameof(IMultiplayerClient.ResultsReady), ((IMultiplayerClient)this).ResultsReady); - connection.On>(nameof(IMultiplayerClient.UserModsChanged), ((IMultiplayerClient)this).UserModsChanged); - connection.On(nameof(IMultiplayerClient.UserBeatmapAvailabilityChanged), ((IMultiplayerClient)this).UserBeatmapAvailabilityChanged); - }; - - IsConnected.BindTo(connector.IsConnected); + var localUser = LocalUser; + return localUser != null && Room?.Host != null && localUser.Equals(Room.Host); } } - protected override Task JoinRoom(long roomId) - { - if (!IsConnected.Value) - return Task.FromCanceled(new CancellationToken(true)); + [Resolved] + protected IAPIProvider API { get; private set; } = null!; - return connection.InvokeAsync(nameof(IMultiplayerServer.JoinRoom), roomId); + [Resolved] + protected RulesetStore Rulesets { get; private set; } = null!; + + [Resolved] + private UserLookupCache userLookupCache { get; set; } = null!; + + private Room? apiRoom; + + [BackgroundDependencyLoader] + private void load() + { + IsConnected.BindValueChanged(connected => + { + // clean up local room state on server disconnect. + if (!connected.NewValue && Room != null) + { + Logger.Log("Connection to multiplayer server was lost.", LoggingTarget.Runtime, LogLevel.Important); + LeaveRoom(); + } + }); } - protected override Task LeaveRoomInternal() - { - if (!IsConnected.Value) - return Task.FromCanceled(new CancellationToken(true)); + private readonly TaskChain joinOrLeaveTaskChain = new TaskChain(); + private CancellationTokenSource? joinCancellationSource; - return connection.InvokeAsync(nameof(IMultiplayerServer.LeaveRoom)); + /// + /// Joins the for a given API . + /// + /// The API . + public async Task JoinRoom(Room room) + { + var cancellationSource = joinCancellationSource = new CancellationTokenSource(); + + await joinOrLeaveTaskChain.Add(async () => + { + if (Room != null) + throw new InvalidOperationException("Cannot join a multiplayer room while already in one."); + + Debug.Assert(room.RoomID.Value != null); + + // Join the server-side room. + var joinedRoom = await JoinRoom(room.RoomID.Value.Value).ConfigureAwait(false); + Debug.Assert(joinedRoom != null); + + // Populate users. + Debug.Assert(joinedRoom.Users != null); + await Task.WhenAll(joinedRoom.Users.Select(PopulateUser)).ConfigureAwait(false); + + // Update the stored room (must be done on update thread for thread-safety). + await scheduleAsync(() => + { + Room = joinedRoom; + apiRoom = room; + foreach (var user in joinedRoom.Users) + updateUserPlayingState(user.UserID, user.State); + }, cancellationSource.Token).ConfigureAwait(false); + + // Update room settings. + await updateLocalRoomSettings(joinedRoom.Settings, cancellationSource.Token).ConfigureAwait(false); + }, cancellationSource.Token).ConfigureAwait(false); } - public override Task TransferHost(int userId) + /// + /// Joins the with a given ID. + /// + /// The room ID. + /// The joined . + protected abstract Task JoinRoom(long roomId); + + public Task LeaveRoom() { - if (!IsConnected.Value) + // The join may have not completed yet, so certain tasks that either update the room or reference the room should be cancelled. + // This includes the setting of Room itself along with the initial update of the room settings on join. + joinCancellationSource?.Cancel(); + + // Leaving rooms is expected to occur instantaneously whilst the operation is finalised in the background. + // However a few members need to be reset immediately to prevent other components from entering invalid states whilst the operation hasn't yet completed. + // For example, if a room was left and the user immediately pressed the "create room" button, then the user could be taken into the lobby if the value of Room is not reset in time. + var scheduledReset = scheduleAsync(() => + { + apiRoom = null; + Room = null; + CurrentMatchPlayingUserIds.Clear(); + + RoomUpdated?.Invoke(); + }); + + return joinOrLeaveTaskChain.Add(async () => + { + await scheduledReset.ConfigureAwait(false); + await LeaveRoomInternal().ConfigureAwait(false); + }); + } + + protected abstract Task LeaveRoomInternal(); + + /// + /// Change the current settings. + /// + /// + /// A room must be joined for this to have any effect. + /// + /// The new room name, if any. + /// The new room playlist item, if any. + public Task ChangeSettings(Optional name = default, Optional item = default) + { + if (Room == null) + throw new InvalidOperationException("Must be joined to a match to change settings."); + + // A dummy playlist item filled with the current room settings (except mods). + var existingPlaylistItem = new PlaylistItem + { + Beatmap = + { + Value = new BeatmapInfo + { + OnlineBeatmapID = Room.Settings.BeatmapID, + MD5Hash = Room.Settings.BeatmapChecksum + } + }, + RulesetID = Room.Settings.RulesetID + }; + + return ChangeSettings(new MultiplayerRoomSettings + { + Name = name.GetOr(Room.Settings.Name), + BeatmapID = item.GetOr(existingPlaylistItem).BeatmapID, + BeatmapChecksum = item.GetOr(existingPlaylistItem).Beatmap.Value.MD5Hash, + RulesetID = item.GetOr(existingPlaylistItem).RulesetID, + RequiredMods = item.HasValue ? item.Value.AsNonNull().RequiredMods.Select(m => new APIMod(m)).ToList() : Room.Settings.RequiredMods, + AllowedMods = item.HasValue ? item.Value.AsNonNull().AllowedMods.Select(m => new APIMod(m)).ToList() : Room.Settings.AllowedMods, + }); + } + + /// + /// Toggles the 's ready state. + /// + /// If a toggle of ready state is not valid at this time. + public async Task ToggleReady() + { + var localUser = LocalUser; + + if (localUser == null) + return; + + switch (localUser.State) + { + case MultiplayerUserState.Idle: + await ChangeState(MultiplayerUserState.Ready).ConfigureAwait(false); + return; + + case MultiplayerUserState.Ready: + await ChangeState(MultiplayerUserState.Idle).ConfigureAwait(false); + return; + + default: + throw new InvalidOperationException($"Cannot toggle ready when in {localUser.State}"); + } + } + + /// + /// Toggles the 's spectating state. + /// + /// If a toggle of the spectating state is not valid at this time. + public async Task ToggleSpectate() + { + var localUser = LocalUser; + + if (localUser == null) + return; + + switch (localUser.State) + { + case MultiplayerUserState.Idle: + case MultiplayerUserState.Ready: + await ChangeState(MultiplayerUserState.Spectating).ConfigureAwait(false); + return; + + case MultiplayerUserState.Spectating: + await ChangeState(MultiplayerUserState.Idle).ConfigureAwait(false); + return; + + default: + throw new InvalidOperationException($"Cannot toggle spectate when in {localUser.State}"); + } + } + + public abstract Task TransferHost(int userId); + + public abstract Task ChangeSettings(MultiplayerRoomSettings settings); + + public abstract Task ChangeState(MultiplayerUserState newState); + + public abstract Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability); + + /// + /// Change the local user's mods in the currently joined room. + /// + /// The proposed new mods, excluding any required by the room itself. + public Task ChangeUserMods(IEnumerable newMods) => ChangeUserMods(newMods.Select(m => new APIMod(m)).ToList()); + + public abstract Task ChangeUserMods(IEnumerable newMods); + + public abstract Task StartMatch(); + + Task IMultiplayerClient.RoomStateChanged(MultiplayerRoomState state) + { + if (Room == null) return Task.CompletedTask; - return connection.InvokeAsync(nameof(IMultiplayerServer.TransferHost), userId); + Scheduler.Add(() => + { + if (Room == null) + return; + + Debug.Assert(apiRoom != null); + + Room.State = state; + + switch (state) + { + case MultiplayerRoomState.Open: + apiRoom.Status.Value = new RoomStatusOpen(); + break; + + case MultiplayerRoomState.Playing: + apiRoom.Status.Value = new RoomStatusPlaying(); + break; + + case MultiplayerRoomState.Closed: + apiRoom.Status.Value = new RoomStatusEnded(); + break; + } + + RoomUpdated?.Invoke(); + }, false); + + return Task.CompletedTask; } - public override Task ChangeSettings(MultiplayerRoomSettings settings) + async Task IMultiplayerClient.UserJoined(MultiplayerRoomUser user) { - if (!IsConnected.Value) + if (Room == null) + return; + + await PopulateUser(user).ConfigureAwait(false); + + Scheduler.Add(() => + { + if (Room == null) + return; + + // for sanity, ensure that there can be no duplicate users in the room user list. + if (Room.Users.Any(existing => existing.UserID == user.UserID)) + return; + + Room.Users.Add(user); + + RoomUpdated?.Invoke(); + }, false); + } + + Task IMultiplayerClient.UserLeft(MultiplayerRoomUser user) + { + if (Room == null) return Task.CompletedTask; - return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeSettings), settings); + Scheduler.Add(() => + { + if (Room == null) + return; + + Room.Users.Remove(user); + CurrentMatchPlayingUserIds.Remove(user.UserID); + + RoomUpdated?.Invoke(); + }, false); + + return Task.CompletedTask; } - public override Task ChangeState(MultiplayerUserState newState) + Task IMultiplayerClient.HostChanged(int userId) { - if (!IsConnected.Value) + if (Room == null) return Task.CompletedTask; - return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeState), newState); + Scheduler.Add(() => + { + if (Room == null) + return; + + Debug.Assert(apiRoom != null); + + var user = Room.Users.FirstOrDefault(u => u.UserID == userId); + + Room.Host = user; + apiRoom.Host.Value = user?.User; + + RoomUpdated?.Invoke(); + }, false); + + return Task.CompletedTask; } - public override Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability) + Task IMultiplayerClient.SettingsChanged(MultiplayerRoomSettings newSettings) { - if (!IsConnected.Value) + updateLocalRoomSettings(newSettings); + return Task.CompletedTask; + } + + Task IMultiplayerClient.UserStateChanged(int userId, MultiplayerUserState state) + { + if (Room == null) return Task.CompletedTask; - return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeBeatmapAvailability), newBeatmapAvailability); + Scheduler.Add(() => + { + if (Room == null) + return; + + Room.Users.Single(u => u.UserID == userId).State = state; + + updateUserPlayingState(userId, state); + + RoomUpdated?.Invoke(); + }, false); + + return Task.CompletedTask; } - public override Task ChangeUserMods(IEnumerable newMods) + Task IMultiplayerClient.UserBeatmapAvailabilityChanged(int userId, BeatmapAvailability beatmapAvailability) { - if (!IsConnected.Value) + if (Room == null) return Task.CompletedTask; - return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeUserMods), newMods); + Scheduler.Add(() => + { + var user = Room?.Users.SingleOrDefault(u => u.UserID == userId); + + // errors here are not critical - beatmap availability state is mostly for display. + if (user == null) + return; + + user.BeatmapAvailability = beatmapAvailability; + + RoomUpdated?.Invoke(); + }, false); + + return Task.CompletedTask; } - public override Task StartMatch() + public Task UserModsChanged(int userId, IEnumerable mods) { - if (!IsConnected.Value) + if (Room == null) return Task.CompletedTask; - return connection.InvokeAsync(nameof(IMultiplayerServer.StartMatch)); + Scheduler.Add(() => + { + var user = Room?.Users.SingleOrDefault(u => u.UserID == userId); + + // errors here are not critical - user mods are mostly for display. + if (user == null) + return; + + user.Mods = mods; + + RoomUpdated?.Invoke(); + }, false); + + return Task.CompletedTask; } - protected override Task GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default) + Task IMultiplayerClient.LoadRequested() { - var tcs = new TaskCompletionSource(); - var req = new GetBeatmapSetRequest(beatmapId, BeatmapSetLookupType.BeatmapId); + if (Room == null) + return Task.CompletedTask; - req.Success += res => + Scheduler.Add(() => + { + if (Room == null) + return; + + LoadRequested?.Invoke(); + }, false); + + return Task.CompletedTask; + } + + Task IMultiplayerClient.MatchStarted() + { + if (Room == null) + return Task.CompletedTask; + + Scheduler.Add(() => + { + if (Room == null) + return; + + MatchStarted?.Invoke(); + }, false); + + return Task.CompletedTask; + } + + Task IMultiplayerClient.ResultsReady() + { + if (Room == null) + return Task.CompletedTask; + + Scheduler.Add(() => + { + if (Room == null) + return; + + ResultsReady?.Invoke(); + }, false); + + return Task.CompletedTask; + } + + /// + /// Populates the for a given . + /// + /// The to populate. + protected async Task PopulateUser(MultiplayerRoomUser multiplayerUser) => multiplayerUser.User ??= await userLookupCache.GetUserAsync(multiplayerUser.UserID).ConfigureAwait(false); + + /// + /// Updates the local room settings with the given . + /// + /// + /// This updates both the joined and the respective API . + /// + /// The new to update from. + /// The to cancel the update. + private Task updateLocalRoomSettings(MultiplayerRoomSettings settings, CancellationToken cancellationToken = default) => scheduleAsync(() => + { + if (Room == null) + return; + + Debug.Assert(apiRoom != null); + + // Update a few properties of the room instantaneously. + Room.Settings = settings; + apiRoom.Name.Value = Room.Settings.Name; + + // The current item update is delayed until an online beatmap lookup (below) succeeds. + // In-order for the client to not display an outdated beatmap, the current item is forcefully cleared here. + CurrentMatchPlayingItem.Value = null; + + RoomUpdated?.Invoke(); + + GetOnlineBeatmapSet(settings.BeatmapID, cancellationToken).ContinueWith(set => Schedule(() => + { + if (cancellationToken.IsCancellationRequested) + return; + + updatePlaylist(settings, set.Result); + }), TaskContinuationOptions.OnlyOnRanToCompletion); + }, cancellationToken); + + private void updatePlaylist(MultiplayerRoomSettings settings, BeatmapSetInfo beatmapSet) + { + if (Room == null || !Room.Settings.Equals(settings)) + return; + + Debug.Assert(apiRoom != null); + + var beatmap = beatmapSet.Beatmaps.Single(b => b.OnlineBeatmapID == settings.BeatmapID); + beatmap.MD5Hash = settings.BeatmapChecksum; + + var ruleset = Rulesets.GetRuleset(settings.RulesetID).CreateInstance(); + var mods = settings.RequiredMods.Select(m => m.ToMod(ruleset)); + var allowedMods = settings.AllowedMods.Select(m => m.ToMod(ruleset)); + + // Try to retrieve the existing playlist item from the API room. + var playlistItem = apiRoom.Playlist.FirstOrDefault(i => i.ID == settings.PlaylistItemId); + + if (playlistItem != null) + updateItem(playlistItem); + else + { + // An existing playlist item does not exist, so append a new one. + updateItem(playlistItem = new PlaylistItem()); + apiRoom.Playlist.Add(playlistItem); + } + + CurrentMatchPlayingItem.Value = playlistItem; + + void updateItem(PlaylistItem item) + { + item.ID = settings.PlaylistItemId; + item.Beatmap.Value = beatmap; + item.Ruleset.Value = ruleset.RulesetInfo; + item.RequiredMods.Clear(); + item.RequiredMods.AddRange(mods); + item.AllowedMods.Clear(); + item.AllowedMods.AddRange(allowedMods); + } + } + + /// + /// Retrieves a from an online source. + /// + /// The beatmap set ID. + /// A token to cancel the request. + /// The retrieval task. + protected abstract Task GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default); + + /// + /// For the provided user ID, update whether the user is included in . + /// + /// The user's ID. + /// The new state of the user. + private void updateUserPlayingState(int userId, MultiplayerUserState state) + { + bool wasPlaying = CurrentMatchPlayingUserIds.Contains(userId); + bool isPlaying = state >= MultiplayerUserState.WaitingForLoad && state <= MultiplayerUserState.FinishedPlay; + + if (isPlaying == wasPlaying) + return; + + if (isPlaying) + CurrentMatchPlayingUserIds.Add(userId); + else + CurrentMatchPlayingUserIds.Remove(userId); + } + + private Task scheduleAsync(Action action, CancellationToken cancellationToken = default) + { + var tcs = new TaskCompletionSource(); + + Scheduler.Add(() => { if (cancellationToken.IsCancellationRequested) { @@ -136,20 +625,18 @@ namespace osu.Game.Online.Multiplayer return; } - tcs.SetResult(res.ToBeatmapSet(Rulesets)); - }; - - req.Failure += e => tcs.SetException(e); - - API.Queue(req); + try + { + action(); + tcs.SetResult(true); + } + catch (Exception ex) + { + tcs.SetException(ex); + } + }); return tcs.Task; } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - connector?.Dispose(); - } } } diff --git a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs new file mode 100644 index 0000000000..cf1e18e059 --- /dev/null +++ b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs @@ -0,0 +1,158 @@ +// 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.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Client; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Beatmaps; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.Rooms; + +namespace osu.Game.Online.Multiplayer +{ + /// + /// A with online connectivity. + /// + public class OnlineMultiplayerClient : MultiplayerClient + { + private readonly string endpoint; + + private IHubClientConnector? connector; + + public override IBindable IsConnected { get; } = new BindableBool(); + + private HubConnection? connection => connector?.CurrentConnection; + + public OnlineMultiplayerClient(EndpointConfiguration endpoints) + { + endpoint = endpoints.MultiplayerEndpointUrl; + } + + [BackgroundDependencyLoader] + private void load(IAPIProvider api) + { + connector = api.GetHubConnector(nameof(OnlineMultiplayerClient), endpoint); + + if (connector != null) + { + connector.ConfigureConnection = connection => + { + // this is kind of SILLY + // https://github.com/dotnet/aspnetcore/issues/15198 + connection.On(nameof(IMultiplayerClient.RoomStateChanged), ((IMultiplayerClient)this).RoomStateChanged); + connection.On(nameof(IMultiplayerClient.UserJoined), ((IMultiplayerClient)this).UserJoined); + connection.On(nameof(IMultiplayerClient.UserLeft), ((IMultiplayerClient)this).UserLeft); + connection.On(nameof(IMultiplayerClient.HostChanged), ((IMultiplayerClient)this).HostChanged); + connection.On(nameof(IMultiplayerClient.SettingsChanged), ((IMultiplayerClient)this).SettingsChanged); + connection.On(nameof(IMultiplayerClient.UserStateChanged), ((IMultiplayerClient)this).UserStateChanged); + connection.On(nameof(IMultiplayerClient.LoadRequested), ((IMultiplayerClient)this).LoadRequested); + connection.On(nameof(IMultiplayerClient.MatchStarted), ((IMultiplayerClient)this).MatchStarted); + connection.On(nameof(IMultiplayerClient.ResultsReady), ((IMultiplayerClient)this).ResultsReady); + connection.On>(nameof(IMultiplayerClient.UserModsChanged), ((IMultiplayerClient)this).UserModsChanged); + connection.On(nameof(IMultiplayerClient.UserBeatmapAvailabilityChanged), ((IMultiplayerClient)this).UserBeatmapAvailabilityChanged); + }; + + IsConnected.BindTo(connector.IsConnected); + } + } + + protected override Task JoinRoom(long roomId) + { + if (!IsConnected.Value) + return Task.FromCanceled(new CancellationToken(true)); + + return connection.InvokeAsync(nameof(IMultiplayerServer.JoinRoom), roomId); + } + + protected override Task LeaveRoomInternal() + { + if (!IsConnected.Value) + return Task.FromCanceled(new CancellationToken(true)); + + return connection.InvokeAsync(nameof(IMultiplayerServer.LeaveRoom)); + } + + public override Task TransferHost(int userId) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + return connection.InvokeAsync(nameof(IMultiplayerServer.TransferHost), userId); + } + + public override Task ChangeSettings(MultiplayerRoomSettings settings) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeSettings), settings); + } + + public override Task ChangeState(MultiplayerUserState newState) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeState), newState); + } + + public override Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeBeatmapAvailability), newBeatmapAvailability); + } + + public override Task ChangeUserMods(IEnumerable newMods) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeUserMods), newMods); + } + + public override Task StartMatch() + { + if (!IsConnected.Value) + return Task.CompletedTask; + + return connection.InvokeAsync(nameof(IMultiplayerServer.StartMatch)); + } + + protected override Task GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default) + { + var tcs = new TaskCompletionSource(); + var req = new GetBeatmapSetRequest(beatmapId, BeatmapSetLookupType.BeatmapId); + + req.Success += res => + { + if (cancellationToken.IsCancellationRequested) + { + tcs.SetCanceled(); + return; + } + + tcs.SetResult(res.ToBeatmapSet(Rulesets)); + }; + + req.Failure += e => tcs.SetException(e); + + API.Queue(req); + + return tcs.Task; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + connector?.Dispose(); + } + } +} diff --git a/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs b/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs deleted file mode 100644 index 7fe48d54b1..0000000000 --- a/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs +++ /dev/null @@ -1,642 +0,0 @@ -// 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 System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Extensions.ObjectExtensions; -using osu.Framework.Graphics; -using osu.Framework.Logging; -using osu.Game.Beatmaps; -using osu.Game.Database; -using osu.Game.Online.API; -using osu.Game.Online.Rooms; -using osu.Game.Online.Rooms.RoomStatuses; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; -using osu.Game.Users; -using osu.Game.Utils; - -namespace osu.Game.Online.Multiplayer -{ - public abstract class StatefulMultiplayerClient : Component, IMultiplayerClient, IMultiplayerRoomServer - { - /// - /// Invoked when any change occurs to the multiplayer room. - /// - public event Action? RoomUpdated; - - /// - /// Invoked when the multiplayer server requests the current beatmap to be loaded into play. - /// - public event Action? LoadRequested; - - /// - /// Invoked when the multiplayer server requests gameplay to be started. - /// - public event Action? MatchStarted; - - /// - /// Invoked when the multiplayer server has finished collating results. - /// - public event Action? ResultsReady; - - /// - /// Whether the is currently connected. - /// This is NOT thread safe and usage should be scheduled. - /// - public abstract IBindable IsConnected { get; } - - /// - /// The joined . - /// - public MultiplayerRoom? Room { get; private set; } - - /// - /// The users in the joined which are participating in the current gameplay loop. - /// - public readonly BindableList CurrentMatchPlayingUserIds = new BindableList(); - - public readonly Bindable CurrentMatchPlayingItem = new Bindable(); - - /// - /// The corresponding to the local player, if available. - /// - public MultiplayerRoomUser? LocalUser => Room?.Users.SingleOrDefault(u => u.User?.Id == API.LocalUser.Value.Id); - - /// - /// Whether the is the host in . - /// - public bool IsHost - { - get - { - var localUser = LocalUser; - return localUser != null && Room?.Host != null && localUser.Equals(Room.Host); - } - } - - [Resolved] - protected IAPIProvider API { get; private set; } = null!; - - [Resolved] - protected RulesetStore Rulesets { get; private set; } = null!; - - [Resolved] - private UserLookupCache userLookupCache { get; set; } = null!; - - private Room? apiRoom; - - [BackgroundDependencyLoader] - private void load() - { - IsConnected.BindValueChanged(connected => - { - // clean up local room state on server disconnect. - if (!connected.NewValue && Room != null) - { - Logger.Log("Connection to multiplayer server was lost.", LoggingTarget.Runtime, LogLevel.Important); - LeaveRoom(); - } - }); - } - - private readonly TaskChain joinOrLeaveTaskChain = new TaskChain(); - private CancellationTokenSource? joinCancellationSource; - - /// - /// Joins the for a given API . - /// - /// The API . - public async Task JoinRoom(Room room) - { - var cancellationSource = joinCancellationSource = new CancellationTokenSource(); - - await joinOrLeaveTaskChain.Add(async () => - { - if (Room != null) - throw new InvalidOperationException("Cannot join a multiplayer room while already in one."); - - Debug.Assert(room.RoomID.Value != null); - - // Join the server-side room. - var joinedRoom = await JoinRoom(room.RoomID.Value.Value).ConfigureAwait(false); - Debug.Assert(joinedRoom != null); - - // Populate users. - Debug.Assert(joinedRoom.Users != null); - await Task.WhenAll(joinedRoom.Users.Select(PopulateUser)).ConfigureAwait(false); - - // Update the stored room (must be done on update thread for thread-safety). - await scheduleAsync(() => - { - Room = joinedRoom; - apiRoom = room; - foreach (var user in joinedRoom.Users) - updateUserPlayingState(user.UserID, user.State); - }, cancellationSource.Token).ConfigureAwait(false); - - // Update room settings. - await updateLocalRoomSettings(joinedRoom.Settings, cancellationSource.Token).ConfigureAwait(false); - }, cancellationSource.Token).ConfigureAwait(false); - } - - /// - /// Joins the with a given ID. - /// - /// The room ID. - /// The joined . - protected abstract Task JoinRoom(long roomId); - - public Task LeaveRoom() - { - // The join may have not completed yet, so certain tasks that either update the room or reference the room should be cancelled. - // This includes the setting of Room itself along with the initial update of the room settings on join. - joinCancellationSource?.Cancel(); - - // Leaving rooms is expected to occur instantaneously whilst the operation is finalised in the background. - // However a few members need to be reset immediately to prevent other components from entering invalid states whilst the operation hasn't yet completed. - // For example, if a room was left and the user immediately pressed the "create room" button, then the user could be taken into the lobby if the value of Room is not reset in time. - var scheduledReset = scheduleAsync(() => - { - apiRoom = null; - Room = null; - CurrentMatchPlayingUserIds.Clear(); - - RoomUpdated?.Invoke(); - }); - - return joinOrLeaveTaskChain.Add(async () => - { - await scheduledReset.ConfigureAwait(false); - await LeaveRoomInternal().ConfigureAwait(false); - }); - } - - protected abstract Task LeaveRoomInternal(); - - /// - /// Change the current settings. - /// - /// - /// A room must be joined for this to have any effect. - /// - /// The new room name, if any. - /// The new room playlist item, if any. - public Task ChangeSettings(Optional name = default, Optional item = default) - { - if (Room == null) - throw new InvalidOperationException("Must be joined to a match to change settings."); - - // A dummy playlist item filled with the current room settings (except mods). - var existingPlaylistItem = new PlaylistItem - { - Beatmap = - { - Value = new BeatmapInfo - { - OnlineBeatmapID = Room.Settings.BeatmapID, - MD5Hash = Room.Settings.BeatmapChecksum - } - }, - RulesetID = Room.Settings.RulesetID - }; - - return ChangeSettings(new MultiplayerRoomSettings - { - Name = name.GetOr(Room.Settings.Name), - BeatmapID = item.GetOr(existingPlaylistItem).BeatmapID, - BeatmapChecksum = item.GetOr(existingPlaylistItem).Beatmap.Value.MD5Hash, - RulesetID = item.GetOr(existingPlaylistItem).RulesetID, - RequiredMods = item.HasValue ? item.Value.AsNonNull().RequiredMods.Select(m => new APIMod(m)).ToList() : Room.Settings.RequiredMods, - AllowedMods = item.HasValue ? item.Value.AsNonNull().AllowedMods.Select(m => new APIMod(m)).ToList() : Room.Settings.AllowedMods, - }); - } - - /// - /// Toggles the 's ready state. - /// - /// If a toggle of ready state is not valid at this time. - public async Task ToggleReady() - { - var localUser = LocalUser; - - if (localUser == null) - return; - - switch (localUser.State) - { - case MultiplayerUserState.Idle: - await ChangeState(MultiplayerUserState.Ready).ConfigureAwait(false); - return; - - case MultiplayerUserState.Ready: - await ChangeState(MultiplayerUserState.Idle).ConfigureAwait(false); - return; - - default: - throw new InvalidOperationException($"Cannot toggle ready when in {localUser.State}"); - } - } - - /// - /// Toggles the 's spectating state. - /// - /// If a toggle of the spectating state is not valid at this time. - public async Task ToggleSpectate() - { - var localUser = LocalUser; - - if (localUser == null) - return; - - switch (localUser.State) - { - case MultiplayerUserState.Idle: - case MultiplayerUserState.Ready: - await ChangeState(MultiplayerUserState.Spectating).ConfigureAwait(false); - return; - - case MultiplayerUserState.Spectating: - await ChangeState(MultiplayerUserState.Idle).ConfigureAwait(false); - return; - - default: - throw new InvalidOperationException($"Cannot toggle spectate when in {localUser.State}"); - } - } - - public abstract Task TransferHost(int userId); - - public abstract Task ChangeSettings(MultiplayerRoomSettings settings); - - public abstract Task ChangeState(MultiplayerUserState newState); - - public abstract Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability); - - /// - /// Change the local user's mods in the currently joined room. - /// - /// The proposed new mods, excluding any required by the room itself. - public Task ChangeUserMods(IEnumerable newMods) => ChangeUserMods(newMods.Select(m => new APIMod(m)).ToList()); - - public abstract Task ChangeUserMods(IEnumerable newMods); - - public abstract Task StartMatch(); - - Task IMultiplayerClient.RoomStateChanged(MultiplayerRoomState state) - { - if (Room == null) - return Task.CompletedTask; - - Scheduler.Add(() => - { - if (Room == null) - return; - - Debug.Assert(apiRoom != null); - - Room.State = state; - - switch (state) - { - case MultiplayerRoomState.Open: - apiRoom.Status.Value = new RoomStatusOpen(); - break; - - case MultiplayerRoomState.Playing: - apiRoom.Status.Value = new RoomStatusPlaying(); - break; - - case MultiplayerRoomState.Closed: - apiRoom.Status.Value = new RoomStatusEnded(); - break; - } - - RoomUpdated?.Invoke(); - }, false); - - return Task.CompletedTask; - } - - async Task IMultiplayerClient.UserJoined(MultiplayerRoomUser user) - { - if (Room == null) - return; - - await PopulateUser(user).ConfigureAwait(false); - - Scheduler.Add(() => - { - if (Room == null) - return; - - // for sanity, ensure that there can be no duplicate users in the room user list. - if (Room.Users.Any(existing => existing.UserID == user.UserID)) - return; - - Room.Users.Add(user); - - RoomUpdated?.Invoke(); - }, false); - } - - Task IMultiplayerClient.UserLeft(MultiplayerRoomUser user) - { - if (Room == null) - return Task.CompletedTask; - - Scheduler.Add(() => - { - if (Room == null) - return; - - Room.Users.Remove(user); - CurrentMatchPlayingUserIds.Remove(user.UserID); - - RoomUpdated?.Invoke(); - }, false); - - return Task.CompletedTask; - } - - Task IMultiplayerClient.HostChanged(int userId) - { - if (Room == null) - return Task.CompletedTask; - - Scheduler.Add(() => - { - if (Room == null) - return; - - Debug.Assert(apiRoom != null); - - var user = Room.Users.FirstOrDefault(u => u.UserID == userId); - - Room.Host = user; - apiRoom.Host.Value = user?.User; - - RoomUpdated?.Invoke(); - }, false); - - return Task.CompletedTask; - } - - Task IMultiplayerClient.SettingsChanged(MultiplayerRoomSettings newSettings) - { - updateLocalRoomSettings(newSettings); - return Task.CompletedTask; - } - - Task IMultiplayerClient.UserStateChanged(int userId, MultiplayerUserState state) - { - if (Room == null) - return Task.CompletedTask; - - Scheduler.Add(() => - { - if (Room == null) - return; - - Room.Users.Single(u => u.UserID == userId).State = state; - - updateUserPlayingState(userId, state); - - RoomUpdated?.Invoke(); - }, false); - - return Task.CompletedTask; - } - - Task IMultiplayerClient.UserBeatmapAvailabilityChanged(int userId, BeatmapAvailability beatmapAvailability) - { - if (Room == null) - return Task.CompletedTask; - - Scheduler.Add(() => - { - var user = Room?.Users.SingleOrDefault(u => u.UserID == userId); - - // errors here are not critical - beatmap availability state is mostly for display. - if (user == null) - return; - - user.BeatmapAvailability = beatmapAvailability; - - RoomUpdated?.Invoke(); - }, false); - - return Task.CompletedTask; - } - - public Task UserModsChanged(int userId, IEnumerable mods) - { - if (Room == null) - return Task.CompletedTask; - - Scheduler.Add(() => - { - var user = Room?.Users.SingleOrDefault(u => u.UserID == userId); - - // errors here are not critical - user mods are mostly for display. - if (user == null) - return; - - user.Mods = mods; - - RoomUpdated?.Invoke(); - }, false); - - return Task.CompletedTask; - } - - Task IMultiplayerClient.LoadRequested() - { - if (Room == null) - return Task.CompletedTask; - - Scheduler.Add(() => - { - if (Room == null) - return; - - LoadRequested?.Invoke(); - }, false); - - return Task.CompletedTask; - } - - Task IMultiplayerClient.MatchStarted() - { - if (Room == null) - return Task.CompletedTask; - - Scheduler.Add(() => - { - if (Room == null) - return; - - MatchStarted?.Invoke(); - }, false); - - return Task.CompletedTask; - } - - Task IMultiplayerClient.ResultsReady() - { - if (Room == null) - return Task.CompletedTask; - - Scheduler.Add(() => - { - if (Room == null) - return; - - ResultsReady?.Invoke(); - }, false); - - return Task.CompletedTask; - } - - /// - /// Populates the for a given . - /// - /// The to populate. - protected async Task PopulateUser(MultiplayerRoomUser multiplayerUser) => multiplayerUser.User ??= await userLookupCache.GetUserAsync(multiplayerUser.UserID).ConfigureAwait(false); - - /// - /// Updates the local room settings with the given . - /// - /// - /// This updates both the joined and the respective API . - /// - /// The new to update from. - /// The to cancel the update. - private Task updateLocalRoomSettings(MultiplayerRoomSettings settings, CancellationToken cancellationToken = default) => scheduleAsync(() => - { - if (Room == null) - return; - - Debug.Assert(apiRoom != null); - - // Update a few properties of the room instantaneously. - Room.Settings = settings; - apiRoom.Name.Value = Room.Settings.Name; - - // The current item update is delayed until an online beatmap lookup (below) succeeds. - // In-order for the client to not display an outdated beatmap, the current item is forcefully cleared here. - CurrentMatchPlayingItem.Value = null; - - RoomUpdated?.Invoke(); - - GetOnlineBeatmapSet(settings.BeatmapID, cancellationToken).ContinueWith(set => Schedule(() => - { - if (cancellationToken.IsCancellationRequested) - return; - - updatePlaylist(settings, set.Result); - }), TaskContinuationOptions.OnlyOnRanToCompletion); - }, cancellationToken); - - private void updatePlaylist(MultiplayerRoomSettings settings, BeatmapSetInfo beatmapSet) - { - if (Room == null || !Room.Settings.Equals(settings)) - return; - - Debug.Assert(apiRoom != null); - - var beatmap = beatmapSet.Beatmaps.Single(b => b.OnlineBeatmapID == settings.BeatmapID); - beatmap.MD5Hash = settings.BeatmapChecksum; - - var ruleset = Rulesets.GetRuleset(settings.RulesetID).CreateInstance(); - var mods = settings.RequiredMods.Select(m => m.ToMod(ruleset)); - var allowedMods = settings.AllowedMods.Select(m => m.ToMod(ruleset)); - - // Try to retrieve the existing playlist item from the API room. - var playlistItem = apiRoom.Playlist.FirstOrDefault(i => i.ID == settings.PlaylistItemId); - - if (playlistItem != null) - updateItem(playlistItem); - else - { - // An existing playlist item does not exist, so append a new one. - updateItem(playlistItem = new PlaylistItem()); - apiRoom.Playlist.Add(playlistItem); - } - - CurrentMatchPlayingItem.Value = playlistItem; - - void updateItem(PlaylistItem item) - { - item.ID = settings.PlaylistItemId; - item.Beatmap.Value = beatmap; - item.Ruleset.Value = ruleset.RulesetInfo; - item.RequiredMods.Clear(); - item.RequiredMods.AddRange(mods); - item.AllowedMods.Clear(); - item.AllowedMods.AddRange(allowedMods); - } - } - - /// - /// Retrieves a from an online source. - /// - /// The beatmap set ID. - /// A token to cancel the request. - /// The retrieval task. - protected abstract Task GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default); - - /// - /// For the provided user ID, update whether the user is included in . - /// - /// The user's ID. - /// The new state of the user. - private void updateUserPlayingState(int userId, MultiplayerUserState state) - { - bool wasPlaying = CurrentMatchPlayingUserIds.Contains(userId); - bool isPlaying = state >= MultiplayerUserState.WaitingForLoad && state <= MultiplayerUserState.FinishedPlay; - - if (isPlaying == wasPlaying) - return; - - if (isPlaying) - CurrentMatchPlayingUserIds.Add(userId); - else - CurrentMatchPlayingUserIds.Remove(userId); - } - - private Task scheduleAsync(Action action, CancellationToken cancellationToken = default) - { - var tcs = new TaskCompletionSource(); - - Scheduler.Add(() => - { - if (cancellationToken.IsCancellationRequested) - { - tcs.SetCanceled(); - return; - } - - try - { - action(); - tcs.SetResult(true); - } - catch (Exception ex) - { - tcs.SetException(ex); - } - }); - - return tcs.Task; - } - } -} diff --git a/osu.Game/Online/Spectator/OnlineSpectatorClient.cs b/osu.Game/Online/Spectator/OnlineSpectatorClient.cs new file mode 100644 index 0000000000..753796158e --- /dev/null +++ b/osu.Game/Online/Spectator/OnlineSpectatorClient.cs @@ -0,0 +1,89 @@ +// 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.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Client; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Online.API; + +namespace osu.Game.Online.Spectator +{ + public class OnlineSpectatorClient : SpectatorClient + { + private readonly string endpoint; + + private IHubClientConnector? connector; + + public override IBindable IsConnected { get; } = new BindableBool(); + + private HubConnection? connection => connector?.CurrentConnection; + + public OnlineSpectatorClient(EndpointConfiguration endpoints) + { + endpoint = endpoints.SpectatorEndpointUrl; + } + + [BackgroundDependencyLoader] + private void load(IAPIProvider api) + { + connector = api.GetHubConnector(nameof(SpectatorClient), endpoint); + + if (connector != null) + { + connector.ConfigureConnection = connection => + { + // until strong typed client support is added, each method must be manually bound + // (see https://github.com/dotnet/aspnetcore/issues/15198) + connection.On(nameof(ISpectatorClient.UserBeganPlaying), ((ISpectatorClient)this).UserBeganPlaying); + connection.On(nameof(ISpectatorClient.UserSentFrames), ((ISpectatorClient)this).UserSentFrames); + connection.On(nameof(ISpectatorClient.UserFinishedPlaying), ((ISpectatorClient)this).UserFinishedPlaying); + }; + + IsConnected.BindTo(connector.IsConnected); + } + } + + protected override Task BeginPlayingInternal(SpectatorState state) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + return connection.SendAsync(nameof(ISpectatorServer.BeginPlaySession), state); + } + + protected override Task SendFramesInternal(FrameDataBundle data) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + return connection.SendAsync(nameof(ISpectatorServer.SendFrameData), data); + } + + protected override Task EndPlayingInternal(SpectatorState state) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + return connection.SendAsync(nameof(ISpectatorServer.EndPlaySession), state); + } + + protected override Task WatchUserInternal(int userId) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + return connection.SendAsync(nameof(ISpectatorServer.StartWatchingUser), userId); + } + + protected override Task StopWatchingUserInternal(int userId) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + return connection.SendAsync(nameof(ISpectatorServer.EndWatchingUser), userId); + } + } +} diff --git a/osu.Game/Online/Spectator/SpectatorClient.cs b/osu.Game/Online/Spectator/SpectatorClient.cs new file mode 100644 index 0000000000..a4fc963328 --- /dev/null +++ b/osu.Game/Online/Spectator/SpectatorClient.cs @@ -0,0 +1,261 @@ +// 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 System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Development; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Online.API; +using osu.Game.Replays.Legacy; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Replays.Types; +using osu.Game.Scoring; +using osu.Game.Screens.Play; + +namespace osu.Game.Online.Spectator +{ + public abstract class SpectatorClient : Component, ISpectatorClient + { + /// + /// The maximum milliseconds between frame bundle sends. + /// + public const double TIME_BETWEEN_SENDS = 200; + + /// + /// Whether the is currently connected. + /// This is NOT thread safe and usage should be scheduled. + /// + public abstract IBindable IsConnected { get; } + + private readonly List watchingUsers = new List(); + + public IBindableList PlayingUsers => playingUsers; + private readonly BindableList playingUsers = new BindableList(); + + public IBindableDictionary PlayingUserStates => playingUserStates; + private readonly BindableDictionary playingUserStates = new BindableDictionary(); + + private IBeatmap? currentBeatmap; + + private Score? currentScore; + + [Resolved] + private IBindable currentRuleset { get; set; } = null!; + + [Resolved] + private IBindable> currentMods { get; set; } = null!; + + private readonly SpectatorState currentState = new SpectatorState(); + + /// + /// Whether the local user is playing. + /// + protected bool IsPlaying { get; private set; } + + /// + /// Called whenever new frames arrive from the server. + /// + public event Action? OnNewFrames; + + /// + /// Called whenever a user starts a play session, or immediately if the user is being watched and currently in a play session. + /// + public event Action? OnUserBeganPlaying; + + /// + /// Called whenever a user finishes a play session. + /// + public event Action? OnUserFinishedPlaying; + + [BackgroundDependencyLoader] + private void load() + { + IsConnected.BindValueChanged(connected => Schedule(() => + { + if (connected.NewValue) + { + // get all the users that were previously being watched + int[] users = watchingUsers.ToArray(); + watchingUsers.Clear(); + + // resubscribe to watched users. + foreach (var userId in users) + WatchUser(userId); + + // re-send state in case it wasn't received + if (IsPlaying) + BeginPlayingInternal(currentState); + } + else + { + playingUsers.Clear(); + playingUserStates.Clear(); + } + }), true); + } + + Task ISpectatorClient.UserBeganPlaying(int userId, SpectatorState state) + { + Schedule(() => + { + if (!playingUsers.Contains(userId)) + playingUsers.Add(userId); + + // 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); + }); + + return Task.CompletedTask; + } + + Task ISpectatorClient.UserFinishedPlaying(int userId, SpectatorState state) + { + Schedule(() => + { + playingUsers.Remove(userId); + playingUserStates.Remove(userId); + + OnUserFinishedPlaying?.Invoke(userId, state); + }); + + return Task.CompletedTask; + } + + Task ISpectatorClient.UserSentFrames(int userId, FrameDataBundle data) + { + Schedule(() => OnNewFrames?.Invoke(userId, data)); + + return Task.CompletedTask; + } + + public void BeginPlaying(GameplayBeatmap beatmap, Score score) + { + Debug.Assert(ThreadSafety.IsUpdateThread); + + if (IsPlaying) + throw new InvalidOperationException($"Cannot invoke {nameof(BeginPlaying)} when already playing"); + + IsPlaying = true; + + // transfer state at point of beginning play + currentState.BeatmapID = beatmap.BeatmapInfo.OnlineBeatmapID; + currentState.RulesetID = currentRuleset.Value.ID; + currentState.Mods = currentMods.Value.Select(m => new APIMod(m)); + + currentBeatmap = beatmap.PlayableBeatmap; + currentScore = score; + + BeginPlayingInternal(currentState); + } + + public void SendFrames(FrameDataBundle data) => lastSend = SendFramesInternal(data); + + public void EndPlaying() + { + // This method is most commonly called via Dispose(), which is asynchronous. + // Todo: This should not be a thing, but requires framework changes. + Schedule(() => + { + if (!IsPlaying) + return; + + IsPlaying = false; + currentBeatmap = null; + + EndPlayingInternal(currentState); + }); + } + + public void WatchUser(int userId) + { + Debug.Assert(ThreadSafety.IsUpdateThread); + + if (watchingUsers.Contains(userId)) + return; + + watchingUsers.Add(userId); + + WatchUserInternal(userId); + } + + public void StopWatchingUser(int userId) + { + // This method is most commonly called via Dispose(), which is asynchronous. + // Todo: This should not be a thing, but requires framework changes. + Schedule(() => + { + watchingUsers.Remove(userId); + playingUserStates.Remove(userId); + StopWatchingUserInternal(userId); + }); + } + + protected abstract Task BeginPlayingInternal(SpectatorState state); + + protected abstract Task SendFramesInternal(FrameDataBundle data); + + protected abstract Task EndPlayingInternal(SpectatorState state); + + protected abstract Task WatchUserInternal(int userId); + + protected abstract Task StopWatchingUserInternal(int userId); + + private readonly Queue pendingFrames = new Queue(); + + private double lastSendTime; + + private Task? lastSend; + + private const int max_pending_frames = 30; + + protected override void Update() + { + base.Update(); + + if (pendingFrames.Count > 0 && Time.Current - lastSendTime > TIME_BETWEEN_SENDS) + purgePendingFrames(); + } + + public void HandleFrame(ReplayFrame frame) + { + Debug.Assert(ThreadSafety.IsUpdateThread); + + if (frame is IConvertibleReplayFrame convertible) + pendingFrames.Enqueue(convertible.ToLegacy(currentBeatmap)); + + if (pendingFrames.Count > max_pending_frames) + purgePendingFrames(); + } + + private void purgePendingFrames() + { + if (lastSend?.IsCompleted == false) + return; + + var frames = pendingFrames.ToArray(); + + pendingFrames.Clear(); + + Debug.Assert(currentScore != null); + + SendFrames(new FrameDataBundle(currentScore.ScoreInfo, frames)); + + lastSendTime = Time.Current; + } + } +} diff --git a/osu.Game/Online/Spectator/SpectatorStreamingClient.cs b/osu.Game/Online/Spectator/SpectatorStreamingClient.cs deleted file mode 100644 index ec6d1bf9d8..0000000000 --- a/osu.Game/Online/Spectator/SpectatorStreamingClient.cs +++ /dev/null @@ -1,323 +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 System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Threading.Tasks; -using JetBrains.Annotations; -using Microsoft.AspNetCore.SignalR.Client; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Game.Beatmaps; -using osu.Game.Online.API; -using osu.Game.Replays.Legacy; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Replays; -using osu.Game.Rulesets.Replays.Types; -using osu.Game.Scoring; -using osu.Game.Screens.Play; - -namespace osu.Game.Online.Spectator -{ - public class SpectatorStreamingClient : Component, ISpectatorClient - { - /// - /// The maximum milliseconds between frame bundle sends. - /// - public const double TIME_BETWEEN_SENDS = 200; - - private readonly string endpoint; - - [CanBeNull] - private IHubClientConnector connector; - - private readonly IBindable isConnected = new BindableBool(); - - private HubConnection connection => connector?.CurrentConnection; - - private readonly List watchingUsers = new List(); - - private readonly object userLock = new object(); - - public IBindableList PlayingUsers => playingUsers; - - private readonly BindableList playingUsers = new BindableList(); - - private readonly Dictionary playingUserStates = new Dictionary(); - - [CanBeNull] - private IBeatmap currentBeatmap; - - [CanBeNull] - private Score currentScore; - - [Resolved] - private IBindable currentRuleset { get; set; } - - [Resolved] - private IBindable> currentMods { get; set; } - - private readonly SpectatorState currentState = new SpectatorState(); - - private bool isPlaying; - - /// - /// Called whenever new frames arrive from the server. - /// - public event Action OnNewFrames; - - /// - /// Called whenever a user starts a play session, or immediately if the user is being watched and currently in a play session. - /// - public event Action OnUserBeganPlaying; - - /// - /// Called whenever a user finishes a play session. - /// - public event Action OnUserFinishedPlaying; - - public SpectatorStreamingClient(EndpointConfiguration endpoints) - { - endpoint = endpoints.SpectatorEndpointUrl; - } - - [BackgroundDependencyLoader] - private void load(IAPIProvider api) - { - connector = api.GetHubConnector(nameof(SpectatorStreamingClient), endpoint); - - if (connector != null) - { - connector.ConfigureConnection = connection => - { - // until strong typed client support is added, each method must be manually bound - // (see https://github.com/dotnet/aspnetcore/issues/15198) - connection.On(nameof(ISpectatorClient.UserBeganPlaying), ((ISpectatorClient)this).UserBeganPlaying); - connection.On(nameof(ISpectatorClient.UserSentFrames), ((ISpectatorClient)this).UserSentFrames); - connection.On(nameof(ISpectatorClient.UserFinishedPlaying), ((ISpectatorClient)this).UserFinishedPlaying); - }; - - isConnected.BindTo(connector.IsConnected); - isConnected.BindValueChanged(connected => - { - if (connected.NewValue) - { - // get all the users that were previously being watched - int[] users; - - lock (userLock) - { - users = watchingUsers.ToArray(); - watchingUsers.Clear(); - } - - // resubscribe to watched users. - foreach (var userId in users) - WatchUser(userId); - - // re-send state in case it wasn't received - if (isPlaying) - beginPlaying(); - } - else - { - lock (userLock) - { - playingUsers.Clear(); - playingUserStates.Clear(); - } - } - }, true); - } - } - - Task ISpectatorClient.UserBeganPlaying(int userId, SpectatorState state) - { - lock (userLock) - { - if (!playingUsers.Contains(userId)) - playingUsers.Add(userId); - - // 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); - - return Task.CompletedTask; - } - - Task ISpectatorClient.UserFinishedPlaying(int userId, SpectatorState state) - { - lock (userLock) - { - playingUsers.Remove(userId); - playingUserStates.Remove(userId); - } - - OnUserFinishedPlaying?.Invoke(userId, state); - - return Task.CompletedTask; - } - - Task ISpectatorClient.UserSentFrames(int userId, FrameDataBundle data) - { - OnNewFrames?.Invoke(userId, data); - - return Task.CompletedTask; - } - - public void BeginPlaying(GameplayBeatmap beatmap, Score score) - { - if (isPlaying) - throw new InvalidOperationException($"Cannot invoke {nameof(BeginPlaying)} when already playing"); - - isPlaying = true; - - // transfer state at point of beginning play - currentState.BeatmapID = beatmap.BeatmapInfo.OnlineBeatmapID; - currentState.RulesetID = currentRuleset.Value.ID; - currentState.Mods = currentMods.Value.Select(m => new APIMod(m)); - - currentBeatmap = beatmap.PlayableBeatmap; - currentScore = score; - - beginPlaying(); - } - - private void beginPlaying() - { - Debug.Assert(isPlaying); - - if (!isConnected.Value) return; - - connection.SendAsync(nameof(ISpectatorServer.BeginPlaySession), currentState); - } - - public void SendFrames(FrameDataBundle data) - { - if (!isConnected.Value) return; - - lastSend = connection.SendAsync(nameof(ISpectatorServer.SendFrameData), data); - } - - public void EndPlaying() - { - isPlaying = false; - currentBeatmap = null; - - if (!isConnected.Value) return; - - connection.SendAsync(nameof(ISpectatorServer.EndPlaySession), currentState); - } - - public virtual void WatchUser(int userId) - { - lock (userLock) - { - if (watchingUsers.Contains(userId)) - return; - - watchingUsers.Add(userId); - - if (!isConnected.Value) - return; - } - - connection.SendAsync(nameof(ISpectatorServer.StartWatchingUser), userId); - } - - public virtual void StopWatchingUser(int userId) - { - lock (userLock) - { - watchingUsers.Remove(userId); - - if (!isConnected.Value) - return; - } - - connection.SendAsync(nameof(ISpectatorServer.EndWatchingUser), userId); - } - - private readonly Queue pendingFrames = new Queue(); - - private double lastSendTime; - - private Task lastSend; - - private const int max_pending_frames = 30; - - protected override void Update() - { - base.Update(); - - if (pendingFrames.Count > 0 && Time.Current - lastSendTime > TIME_BETWEEN_SENDS) - purgePendingFrames(); - } - - public void HandleFrame(ReplayFrame frame) - { - if (frame is IConvertibleReplayFrame convertible) - pendingFrames.Enqueue(convertible.ToLegacy(currentBeatmap)); - - if (pendingFrames.Count > max_pending_frames) - purgePendingFrames(); - } - - private void purgePendingFrames() - { - if (lastSend?.IsCompleted == false) - return; - - var frames = pendingFrames.ToArray(); - - pendingFrames.Clear(); - - Debug.Assert(currentScore != null); - - SendFrames(new FrameDataBundle(currentScore.ScoreInfo, frames)); - - lastSendTime = Time.Current; - } - - /// - /// Attempts to retrieve the for a currently-playing user. - /// - /// The user. - /// The current for the user, if they're playing. null if the user is not playing. - /// true if successful (the user is playing), false otherwise. - public bool TryGetPlayingUserState(int userId, out SpectatorState state) - { - lock (userLock) - return playingUserStates.TryGetValue(userId, out state); - } - - /// - /// Bind an action to with the option of running the bound action once immediately. - /// - /// The action to perform when a user begins playing. - /// Whether the action provided in should be run once immediately for all users currently playing. - public void BindUserBeganPlaying(Action callback, bool runOnceImmediately = false) - { - // The lock is taken before the event is subscribed to to prevent doubling of events. - lock (userLock) - { - OnUserBeganPlaying += callback; - - if (!runOnceImmediately) - return; - - foreach (var (userId, state) in playingUserStates) - callback(userId, state); - } - } - } -} diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index f860cd8dd2..06e0b6e9bf 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -100,6 +100,9 @@ namespace osu.Game [Cached] private readonly DifficultyRecommender difficultyRecommender = new DifficultyRecommender(); + [Cached] + private readonly StableImportManager stableImportManager = new StableImportManager(); + [Cached] private readonly ScreenshotManager screenshotManager = new ScreenshotManager(); @@ -566,14 +569,11 @@ namespace osu.Game // todo: all archive managers should be able to be looped here. SkinManager.PostNotification = n => notifications.Post(n); - SkinManager.GetStableStorage = GetStorageForStableInstall; BeatmapManager.PostNotification = n => notifications.Post(n); - BeatmapManager.GetStableStorage = GetStorageForStableInstall; BeatmapManager.PresentImport = items => PresentBeatmap(items.First()); ScoreManager.PostNotification = n => notifications.Post(n); - ScoreManager.GetStableStorage = GetStorageForStableInstall; ScoreManager.PresentImport = items => PresentScore(items.First()); // make config aware of how to lookup skins for on-screen display purposes. @@ -690,10 +690,10 @@ namespace osu.Game loadComponentSingleFile(new CollectionManager(Storage) { PostNotification = n => notifications.Post(n), - GetStableStorage = GetStorageForStableInstall }, Add, true); loadComponentSingleFile(difficultyRecommender, Add); + loadComponentSingleFile(stableImportManager, Add); loadComponentSingleFile(screenshotManager, Add); diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index fbe4022cc1..3c143c1db9 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -85,8 +85,8 @@ namespace osu.Game protected IAPIProvider API; - private SpectatorStreamingClient spectatorStreaming; - private StatefulMultiplayerClient multiplayerClient; + private SpectatorClient spectatorClient; + private MultiplayerClient multiplayerClient; protected MenuCursorContainer MenuCursorContainer; @@ -240,8 +240,8 @@ namespace osu.Game dependencies.CacheAs(API ??= new APIAccess(LocalConfig, endpoints, VersionHash)); - dependencies.CacheAs(spectatorStreaming = new SpectatorStreamingClient(endpoints)); - dependencies.CacheAs(multiplayerClient = new MultiplayerClient(endpoints)); + dependencies.CacheAs(spectatorClient = new OnlineSpectatorClient(endpoints)); + dependencies.CacheAs(multiplayerClient = new OnlineMultiplayerClient(endpoints)); var defaultBeatmap = new DummyWorkingBeatmap(Audio, Textures); @@ -313,7 +313,7 @@ namespace osu.Game // add api components to hierarchy. if (API is APIAccess apiAccess) AddInternal(apiAccess); - AddInternal(spectatorStreaming); + AddInternal(spectatorClient); AddInternal(multiplayerClient); AddInternal(RulesetConfigCache); diff --git a/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs b/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs index 81315f9638..443b3dcf01 100644 --- a/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs +++ b/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs @@ -26,7 +26,10 @@ namespace osu.Game.Overlays AccentColour = colourProvider.Light2; } - protected override TabItem CreateTabItem(string value) => new ControlTabItem(value); + protected override TabItem CreateTabItem(string value) => new ControlTabItem(value) + { + AccentColour = AccentColour, + }; private class ControlTabItem : BreadcrumbTabItem { diff --git a/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs b/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs index 336430fd9b..3051ca7dbe 100644 --- a/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs +++ b/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs @@ -25,7 +25,7 @@ namespace osu.Game.Overlays.Dashboard private FillFlowContainer userFlow; [Resolved] - private SpectatorStreamingClient spectatorStreaming { get; set; } + private SpectatorClient spectatorClient { get; set; } [BackgroundDependencyLoader] private void load() @@ -52,7 +52,7 @@ namespace osu.Game.Overlays.Dashboard { base.LoadComplete(); - playingUsers.BindTo(spectatorStreaming.PlayingUsers); + playingUsers.BindTo(spectatorClient.PlayingUsers); playingUsers.BindCollectionChanged(onUsersChanged, true); } diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index 754b260bf0..e31e307d4d 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -96,7 +96,8 @@ namespace osu.Game.Overlays.Mods Waves.ThirdWaveColour = Color4Extensions.FromHex(@"005774"); Waves.FourthWaveColour = Color4Extensions.FromHex(@"003a4e"); - RelativeSizeAxes = Axes.Both; + RelativeSizeAxes = Axes.X; + Height = HEIGHT; Padding = new MarginPadding { Horizontal = -OsuScreen.HORIZONTAL_OVERFLOW_PADDING }; diff --git a/osu.Game/Overlays/News/Displays/FrontPageDisplay.cs b/osu.Game/Overlays/News/Displays/ArticleListing.cs similarity index 71% rename from osu.Game/Overlays/News/Displays/FrontPageDisplay.cs rename to osu.Game/Overlays/News/Displays/ArticleListing.cs index a1bc6c650b..b49326a1f1 100644 --- a/osu.Game/Overlays/News/Displays/FrontPageDisplay.cs +++ b/osu.Game/Overlays/News/Displays/ArticleListing.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 System.Linq; using System.Threading; using osu.Framework.Allocation; @@ -9,12 +10,18 @@ using osu.Framework.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; using osuTK; namespace osu.Game.Overlays.News.Displays { - public class FrontPageDisplay : CompositeDrawable + /// + /// Lists articles in a vertical flow for a specified year. + /// + public class ArticleListing : CompositeDrawable { + public Action SidebarMetadataUpdated; + [Resolved] private IAPIProvider api { get; set; } @@ -24,6 +31,17 @@ namespace osu.Game.Overlays.News.Displays private GetNewsRequest request; private Cursor lastCursor; + private readonly int? year; + + /// + /// Instantiate a listing for the specified year. + /// + /// The year to load articles from. If null, will show the most recent articles. + public ArticleListing(int? year = null) + { + this.year = year; + } + [BackgroundDependencyLoader] private void load() { @@ -74,7 +92,7 @@ namespace osu.Game.Overlays.News.Displays { request?.Cancel(); - request = new GetNewsRequest(lastCursor); + request = new GetNewsRequest(year, lastCursor); request.Success += response => Schedule(() => onSuccess(response)); api.PerformAsync(request); } @@ -85,22 +103,19 @@ namespace osu.Game.Overlays.News.Displays { cancellationToken?.Cancel(); + // only needs to be updated on the initial load, as the content won't change during pagination. + if (lastCursor == null) + SidebarMetadataUpdated?.Invoke(response.SidebarMetadata); + + // store cursor for next pagination request. lastCursor = response.Cursor; - var flow = new FillFlowContainer + LoadComponentsAsync(response.NewsPosts.Select(p => new NewsCard(p)).ToList(), loaded => { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 10), - Children = response.NewsPosts.Select(p => new NewsCard(p)).ToList() - }; + content.AddRange(loaded); - LoadComponentAsync(flow, loaded => - { - content.Add(loaded); showMore.IsLoading = false; - showMore.Alpha = lastCursor == null ? 0 : 1; + showMore.Alpha = response.Cursor != null ? 1 : 0; }, (cancellationToken = new CancellationTokenSource()).Token); } diff --git a/osu.Game/Overlays/News/NewsHeader.cs b/osu.Game/Overlays/News/NewsHeader.cs index 63174128e7..94bfd62c32 100644 --- a/osu.Game/Overlays/News/NewsHeader.cs +++ b/osu.Game/Overlays/News/NewsHeader.cs @@ -19,13 +19,18 @@ namespace osu.Game.Overlays.News { TabControl.AddItem(front_page_string); + article.BindValueChanged(onArticleChanged, true); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Current.BindValueChanged(e => { if (e.NewValue == front_page_string) ShowFrontPage?.Invoke(); }); - - article.BindValueChanged(onArticleChanged, true); } public void SetFrontPage() => article.Value = null; diff --git a/osu.Game/Overlays/News/Sidebar/NewsSidebar.cs b/osu.Game/Overlays/News/Sidebar/NewsSidebar.cs index d14ad90ef4..9e397e78c8 100644 --- a/osu.Game/Overlays/News/Sidebar/NewsSidebar.cs +++ b/osu.Game/Overlays/News/Sidebar/NewsSidebar.cs @@ -9,6 +9,7 @@ using osu.Game.Online.API.Requests.Responses; using osu.Framework.Graphics.Shapes; using osuTK; using System.Linq; +using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.News.Sidebar { @@ -31,30 +32,55 @@ namespace osu.Game.Overlays.News.Sidebar RelativeSizeAxes = Axes.Both, Colour = colourProvider.Background4 }, + new Box + { + RelativeSizeAxes = Axes.Y, + Width = OsuScrollContainer.SCROLL_BAR_HEIGHT, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Colour = colourProvider.Background3, + Alpha = 0.5f + }, new Container { RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding + Padding = new MarginPadding { Right = -3 }, // Compensate for scrollbar margin + Child = new OsuScrollContainer { - Vertical = 20, - Left = 50, - Right = 30 - }, - Child = new FillFlowContainer - { - Direction = FillDirection.Vertical, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(0, 20), - Children = new Drawable[] + RelativeSizeAxes = Axes.Both, + Child = new Container { - new YearsPanel(), - monthsFlow = new FillFlowContainer + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Right = 3 }, // Addeded 3px back + Child = new Container { - AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 10) + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding + { + Vertical = 20, + Left = 50, + Right = 30 + }, + Child = new FillFlowContainer + { + Direction = FillDirection.Vertical, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0, 20), + Children = new Drawable[] + { + new YearsPanel(), + monthsFlow = new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10) + } + } + } } } } diff --git a/osu.Game/Overlays/News/Sidebar/YearsPanel.cs b/osu.Game/Overlays/News/Sidebar/YearsPanel.cs index b6bbdbb6d4..b07c9924b9 100644 --- a/osu.Game/Overlays/News/Sidebar/YearsPanel.cs +++ b/osu.Game/Overlays/News/Sidebar/YearsPanel.cs @@ -81,6 +81,9 @@ namespace osu.Game.Overlays.News.Sidebar { public int Year { get; } + [Resolved(canBeNull: true)] + private NewsOverlay overlay { get; set; } + private readonly bool isCurrent; public YearButton(int year, bool isCurrent) @@ -106,7 +109,11 @@ namespace osu.Game.Overlays.News.Sidebar { IdleColour = isCurrent ? Color4.White : colourProvider.Light2; HoverColour = isCurrent ? Color4.White : colourProvider.Light1; - Action = () => { }; // Avoid button being disabled since there's no proper action assigned. + Action = () => + { + if (!isCurrent) + overlay?.ShowYear(Year); + }; } } } diff --git a/osu.Game/Overlays/NewsOverlay.cs b/osu.Game/Overlays/NewsOverlay.cs index 5beb285216..dd6de40ecb 100644 --- a/osu.Game/Overlays/NewsOverlay.cs +++ b/osu.Game/Overlays/NewsOverlay.cs @@ -1,11 +1,14 @@ // 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.Threading; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Overlays.News; using osu.Game.Overlays.News.Displays; +using osu.Game.Overlays.News.Sidebar; namespace osu.Game.Overlays { @@ -13,9 +16,48 @@ namespace osu.Game.Overlays { private readonly Bindable article = new Bindable(null); + private readonly Container sidebarContainer; + private readonly NewsSidebar sidebar; + + private readonly Container content; + + private CancellationTokenSource cancellationToken; + + private bool displayUpdateRequired = true; + public NewsOverlay() : base(OverlayColourScheme.Purple, false) { + Child = new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension() + }, + Content = new[] + { + new Drawable[] + { + sidebarContainer = new Container + { + AutoSizeAxes = Axes.X, + Child = sidebar = new NewsSidebar() + }, + content = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + } + } + } + }; } protected override void LoadComplete() @@ -26,12 +68,7 @@ namespace osu.Game.Overlays article.BindValueChanged(onArticleChanged); } - protected override NewsHeader CreateHeader() => new NewsHeader - { - ShowFrontPage = ShowFrontPage - }; - - private bool displayUpdateRequired = true; + protected override NewsHeader CreateHeader() => new NewsHeader { ShowFrontPage = ShowFrontPage }; protected override void PopIn() { @@ -56,38 +93,69 @@ namespace osu.Game.Overlays Show(); } + public void ShowYear(int year) + { + loadFrontPage(year); + Show(); + } + public void ShowArticle(string slug) { article.Value = slug; Show(); } - private CancellationTokenSource cancellationToken; - - private void onArticleChanged(ValueChangedEvent e) - { - cancellationToken?.Cancel(); - Loading.Show(); - - if (e.NewValue == null) - { - Header.SetFrontPage(); - LoadDisplay(new FrontPageDisplay()); - return; - } - - Header.SetArticle(e.NewValue); - LoadDisplay(Empty()); - } - protected void LoadDisplay(Drawable display) { ScrollFlow.ScrollToStart(); - LoadComponentAsync(display, loaded => + LoadComponentAsync(display, loaded => content.Child = loaded, (cancellationToken = new CancellationTokenSource()).Token); + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + sidebarContainer.Height = DrawHeight; + sidebarContainer.Y = Math.Clamp(ScrollFlow.Current - Header.DrawHeight, 0, Math.Max(ScrollFlow.ScrollContent.DrawHeight - DrawHeight - Header.DrawHeight, 0)); + } + + private void onArticleChanged(ValueChangedEvent article) + { + if (article.NewValue == null) + loadFrontPage(); + else + loadArticle(article.NewValue); + } + + private void loadFrontPage(int? year = null) + { + beginLoading(); + + Header.SetFrontPage(); + + var page = new ArticleListing(year); + page.SidebarMetadataUpdated += metadata => Schedule(() => { - Child = loaded; + sidebar.Metadata.Value = metadata; Loading.Hide(); - }, (cancellationToken = new CancellationTokenSource()).Token); + }); + LoadDisplay(page); + } + + private void loadArticle(string article) + { + beginLoading(); + + Header.SetArticle(article); + + // Temporary, should be handled by ArticleDisplay later + LoadDisplay(Empty()); + Loading.Hide(); + } + + private void beginLoading() + { + cancellationToken?.Cancel(); + Loading.Show(); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/DirectorySelectScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/DirectorySelectScreen.cs index e7c69e89fe..349a112477 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/DirectorySelectScreen.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/DirectorySelectScreen.cs @@ -11,9 +11,9 @@ using osuTK; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Localisation; -using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Framework.Screens; +using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.Settings.Sections.Maintenance { @@ -69,20 +69,24 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance RelativeSizeAxes = Axes.Both, RowDimensions = new[] { + new Dimension(GridSizeMode.AutoSize), new Dimension(), - new Dimension(GridSizeMode.Relative, 0.8f), - new Dimension(), + new Dimension(GridSizeMode.AutoSize), }, Content = new[] { new Drawable[] { - new OsuSpriteText + new OsuTextFlowContainer(cp => { - Text = HeaderText, - Font = OsuFont.Default.With(size: 40), - Origin = Anchor.Centre, - Anchor = Anchor.Centre, + cp.Font = OsuFont.Default.With(size: 24); + }) + { + Text = HeaderText.ToString(), + TextAnchor = Anchor.TopCentre, + Margin = new MarginPadding(10), + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, } }, new Drawable[] @@ -99,6 +103,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance Anchor = Anchor.Centre, Origin = Anchor.Centre, Width = 300, + Margin = new MarginPadding(10), Text = "Select directory", Action = () => OnSelection(directorySelector.CurrentPath.Value) }, diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs index 848ce381a9..a38ca81e23 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs @@ -8,6 +8,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Collections; +using osu.Game.Database; using osu.Game.Graphics.UserInterface; using osu.Game.Scoring; using osu.Game.Skinning; @@ -29,9 +30,9 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance private TriangleButton undeleteButton; [BackgroundDependencyLoader(permitNulls: true)] - private void load(BeatmapManager beatmaps, ScoreManager scores, SkinManager skins, [CanBeNull] CollectionManager collectionManager, DialogOverlay dialogOverlay) + private void load(BeatmapManager beatmaps, ScoreManager scores, SkinManager skins, [CanBeNull] CollectionManager collectionManager, [CanBeNull] StableImportManager stableImportManager, DialogOverlay dialogOverlay) { - if (beatmaps.SupportsImportFromStable) + if (stableImportManager?.SupportsImportFromStable == true) { Add(importBeatmapsButton = new SettingsButton { @@ -39,7 +40,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance Action = () => { importBeatmapsButton.Enabled.Value = false; - beatmaps.ImportFromStableAsync().ContinueWith(t => Schedule(() => importBeatmapsButton.Enabled.Value = true)); + stableImportManager.ImportFromStableAsync(StableContent.Beatmaps).ContinueWith(t => Schedule(() => importBeatmapsButton.Enabled.Value = true)); } }); } @@ -57,7 +58,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance } }); - if (scores.SupportsImportFromStable) + if (stableImportManager?.SupportsImportFromStable == true) { Add(importScoresButton = new SettingsButton { @@ -65,7 +66,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance Action = () => { importScoresButton.Enabled.Value = false; - scores.ImportFromStableAsync().ContinueWith(t => Schedule(() => importScoresButton.Enabled.Value = true)); + stableImportManager.ImportFromStableAsync(StableContent.Scores).ContinueWith(t => Schedule(() => importScoresButton.Enabled.Value = true)); } }); } @@ -83,7 +84,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance } }); - if (skins.SupportsImportFromStable) + if (stableImportManager?.SupportsImportFromStable == true) { Add(importSkinsButton = new SettingsButton { @@ -91,7 +92,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance Action = () => { importSkinsButton.Enabled.Value = false; - skins.ImportFromStableAsync().ContinueWith(t => Schedule(() => importSkinsButton.Enabled.Value = true)); + stableImportManager.ImportFromStableAsync(StableContent.Skins).ContinueWith(t => Schedule(() => importSkinsButton.Enabled.Value = true)); } }); } @@ -111,7 +112,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance if (collectionManager != null) { - if (collectionManager.SupportsImportFromStable) + if (stableImportManager?.SupportsImportFromStable == true) { Add(importCollectionsButton = new SettingsButton { @@ -119,7 +120,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance Action = () => { importCollectionsButton.Enabled.Value = false; - collectionManager.ImportFromStableAsync().ContinueWith(t => Schedule(() => importCollectionsButton.Enabled.Value = true)); + stableImportManager.ImportFromStableAsync(StableContent.Collections).ContinueWith(t => Schedule(() => importCollectionsButton.Enabled.Value = true)); } }); } diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectoryLocationDialog.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectoryLocationDialog.cs new file mode 100644 index 0000000000..904c9deaae --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectoryLocationDialog.cs @@ -0,0 +1,38 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Screens; +using osu.Game.Overlays.Dialog; + +namespace osu.Game.Overlays.Settings.Sections.Maintenance +{ + public class StableDirectoryLocationDialog : PopupDialog + { + [Resolved] + private OsuGame game { get; set; } + + public StableDirectoryLocationDialog(TaskCompletionSource taskCompletionSource) + { + HeaderText = "Failed to automatically locate an osu!stable installation."; + BodyText = "An existing install could not be located. If you know where it is, you can help locate it."; + Icon = FontAwesome.Solid.QuestionCircle; + + Buttons = new PopupDialogButton[] + { + new PopupDialogOkButton + { + Text = "Sure! I know where it is located!", + Action = () => Schedule(() => game.PerformFromScreen(screen => screen.Push(new StableDirectorySelectScreen(taskCompletionSource)))) + }, + new PopupDialogCancelButton + { + Text = "Actually I don't have osu!stable installed.", + Action = () => taskCompletionSource.TrySetCanceled() + } + }; + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectorySelectScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectorySelectScreen.cs new file mode 100644 index 0000000000..4aea05fb14 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectorySelectScreen.cs @@ -0,0 +1,39 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Localisation; +using osu.Framework.Screens; + +namespace osu.Game.Overlays.Settings.Sections.Maintenance +{ + public class StableDirectorySelectScreen : DirectorySelectScreen + { + private readonly TaskCompletionSource taskCompletionSource; + + protected override OverlayActivation InitialOverlayActivationMode => OverlayActivation.Disabled; + + protected override bool IsValidDirectory(DirectoryInfo info) => info?.GetFiles("osu!.*.cfg").Any() ?? false; + + public override LocalisableString HeaderText => "Please select your osu!stable install location"; + + public StableDirectorySelectScreen(TaskCompletionSource taskCompletionSource) + { + this.taskCompletionSource = taskCompletionSource; + } + + protected override void OnSelection(DirectoryInfo directory) + { + taskCompletionSource.TrySetResult(directory.FullName); + this.Exit(); + } + + public override bool OnExiting(IScreen next) + { + taskCompletionSource.TrySetCanceled(); + return base.OnExiting(next); + } + } +} diff --git a/osu.Game/Rulesets/UI/ReplayRecorder.cs b/osu.Game/Rulesets/UI/ReplayRecorder.cs index 643ded4cad..d18e0f9541 100644 --- a/osu.Game/Rulesets/UI/ReplayRecorder.cs +++ b/osu.Game/Rulesets/UI/ReplayRecorder.cs @@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.UI public int RecordFrameRate = 60; [Resolved(canBeNull: true)] - private SpectatorStreamingClient spectatorStreaming { get; set; } + private SpectatorClient spectatorClient { get; set; } [Resolved] private GameplayBeatmap gameplayBeatmap { get; set; } @@ -49,13 +49,13 @@ namespace osu.Game.Rulesets.UI inputManager = GetContainingInputManager(); - spectatorStreaming?.BeginPlaying(gameplayBeatmap, target); + spectatorClient?.BeginPlaying(gameplayBeatmap, target); } protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - spectatorStreaming?.EndPlaying(); + spectatorClient?.EndPlaying(); } protected override void Update() @@ -98,7 +98,7 @@ namespace osu.Game.Rulesets.UI { target.Replay.Frames.Add(frame); - spectatorStreaming?.HandleFrame(frame); + spectatorClient?.HandleFrame(frame); } } diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs index 6ab4ca8267..2141c490df 100644 --- a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs @@ -168,13 +168,13 @@ namespace osu.Game.Screens.Edit.Compose.Components { if (SelectedBlueprints.All(b => b.Item is IHasComboInformation)) { - yield return new TernaryStateMenuItem("New combo") { State = { BindTarget = SelectionNewComboState } }; + yield return new TernaryStateToggleMenuItem("New combo") { State = { BindTarget = SelectionNewComboState } }; } yield return new OsuMenuItem("Sound") { Items = SelectionSampleStates.Select(kvp => - new TernaryStateMenuItem(kvp.Value.Description) { State = { BindTarget = kvp.Value } }).ToArray() + new TernaryStateToggleMenuItem(kvp.Value.Description) { State = { BindTarget = kvp.Value } }).ToArray() }; } diff --git a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs index 375aac729d..a53e253581 100644 --- a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs @@ -72,8 +72,8 @@ namespace osu.Game.Screens.OnlinePlay.Match Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, Depth = float.MinValue, - RelativeSizeAxes = Axes.Both, - Height = 0.5f, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, Padding = new MarginPadding { Horizontal = HORIZONTAL_OVERFLOW_PADDING }, Child = userModsSelectOverlay = new UserModSelectOverlay { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/CreateMultiplayerMatchButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/CreateMultiplayerMatchButton.cs index a13d2cf540..cc51b5b691 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/CreateMultiplayerMatchButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/CreateMultiplayerMatchButton.cs @@ -14,7 +14,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private IBindable operationInProgress; [Resolved] - private StatefulMultiplayerClient multiplayerClient { get; set; } + private MultiplayerClient multiplayerClient { get; set; } [Resolved] private OngoingOperationTracker ongoingOperationTracker { get; set; } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs index 3199232f6f..fe9979b161 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs @@ -59,7 +59,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private IRoomManager manager { get; set; } [Resolved] - private StatefulMultiplayerClient client { get; set; } + private MultiplayerClient client { get; set; } [Resolved] private Bindable currentRoom { get; set; } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs index 085c824bdc..a065d04f64 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs @@ -15,7 +15,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer public class Multiplayer : OnlinePlayScreen { [Resolved] - private StatefulMultiplayerClient client { get; set; } + private MultiplayerClient client { get; set; } public override void OnResuming(IScreen last) { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs index 0a9a3f680f..4d20652465 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs @@ -18,7 +18,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer protected override RoomSubScreen CreateRoomSubScreen(Room room) => new MultiplayerMatchSubScreen(room); [Resolved] - private StatefulMultiplayerClient client { get; set; } + private MultiplayerClient client { get; set; } public override void Open(Room room) { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs index c9f0f6de90..3733b85a5e 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs @@ -17,7 +17,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer public class MultiplayerMatchSongSelect : OnlinePlaySongSelect { [Resolved] - private StatefulMultiplayerClient client { get; set; } + private MultiplayerClient client { get; set; } private LoadingLayer loadingLayer; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index 783b8b4bf2..62ef70ed68 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -43,7 +43,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer public override string ShortTitle => "room"; [Resolved] - private StatefulMultiplayerClient client { get; set; } + private MultiplayerClient client { get; set; } [Resolved] private OngoingOperationTracker ongoingOperationTracker { get; set; } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs index ae2042fbe8..1bbe49a705 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs @@ -26,7 +26,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer protected override bool CheckModsAllowFailure() => false; [Resolved] - private StatefulMultiplayerClient client { get; set; } + private MultiplayerClient client { get; set; } private IBindable isConnected; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs index 8030107ad8..d334c618f5 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs @@ -13,7 +13,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer protected MultiplayerRoom Room => Client.Room; [Resolved] - protected StatefulMultiplayerClient Client { get; private set; } + protected MultiplayerClient Client { get; private set; } protected override void LoadComplete() { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs index 1e57847f04..8526196902 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs @@ -19,7 +19,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer public class MultiplayerRoomManager : RoomManager { [Resolved] - private StatefulMultiplayerClient multiplayerClient { get; set; } + private MultiplayerClient multiplayerClient { get; set; } public readonly Bindable TimeBetweenListingPolls = new Bindable(); public readonly Bindable TimeBetweenSelectionPolls = new Bindable(); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsListHeader.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsListHeader.cs index 6c1a55a0eb..7e442c6568 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsListHeader.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsListHeader.cs @@ -10,7 +10,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants public class ParticipantsListHeader : OverlinedHeader { [Resolved] - private StatefulMultiplayerClient client { get; set; } + private MultiplayerClient client { get; set; } public ParticipantsListHeader() : base("Participants") diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs index 8c7b7bab01..277aa5d772 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs @@ -28,10 +28,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate public bool AllPlayersLoaded => instances.All(p => p?.PlayerLoaded == true); [Resolved] - private SpectatorStreamingClient spectatorClient { get; set; } + private SpectatorClient spectatorClient { get; set; } [Resolved] - private StatefulMultiplayerClient multiplayerClient { get; set; } + private MultiplayerClient multiplayerClient { get; set; } private readonly PlayerArea[] instances; private MasterGameplayClockContainer masterClockContainer; diff --git a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs index 70de067784..c3bfe19b29 100644 --- a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs @@ -22,10 +22,10 @@ namespace osu.Game.Screens.Play.HUD protected readonly Dictionary UserScores = new Dictionary(); [Resolved] - private SpectatorStreamingClient streamingClient { get; set; } + private SpectatorClient spectatorClient { get; set; } [Resolved] - private StatefulMultiplayerClient multiplayerClient { get; set; } + private MultiplayerClient multiplayerClient { get; set; } [Resolved] private UserLookupCache userLookupCache { get; set; } @@ -55,8 +55,6 @@ namespace osu.Game.Screens.Play.HUD foreach (var userId in playingUsers) { - streamingClient.WatchUser(userId); - // probably won't be required in the final implementation. var resolvedUser = userLookupCache.GetUserAsync(userId).Result; @@ -80,6 +78,8 @@ namespace osu.Game.Screens.Play.HUD // BindableList handles binding in a really bad way (Clear then AddRange) so we need to do this manually.. foreach (int userId in playingUsers) { + spectatorClient.WatchUser(userId); + if (!multiplayerClient.CurrentMatchPlayingUserIds.Contains(userId)) usersChanged(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, new[] { userId })); } @@ -88,7 +88,7 @@ namespace osu.Game.Screens.Play.HUD playingUsers.BindCollectionChanged(usersChanged); // this leaderboard should be guaranteed to be completely loaded before the gameplay starts (is a prerequisite in MultiplayerPlayer). - streamingClient.OnNewFrames += handleIncomingFrames; + spectatorClient.OnNewFrames += handleIncomingFrames; } private void usersChanged(object sender, NotifyCollectionChangedEventArgs e) @@ -98,7 +98,7 @@ namespace osu.Game.Screens.Play.HUD case NotifyCollectionChangedAction.Remove: foreach (var userId in e.OldItems.OfType()) { - streamingClient.StopWatchingUser(userId); + spectatorClient.StopWatchingUser(userId); if (UserScores.TryGetValue(userId, out var trackedData)) trackedData.MarkUserQuit(); @@ -123,14 +123,14 @@ namespace osu.Game.Screens.Play.HUD { base.Dispose(isDisposing); - if (streamingClient != null) + if (spectatorClient != null) { foreach (var user in playingUsers) { - streamingClient.StopWatchingUser(user); + spectatorClient.StopWatchingUser(user); } - streamingClient.OnNewFrames -= handleIncomingFrames; + spectatorClient.OnNewFrames -= handleIncomingFrames; } } diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 6317a41bec..39f9e2d388 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -22,6 +22,7 @@ using osu.Game.Configuration; using osu.Game.Graphics.Containers; using osu.Game.IO.Archives; using osu.Game.Online.API; +using osu.Game.Online.Spectator; using osu.Game.Overlays; using osu.Game.Replays; using osu.Game.Rulesets; @@ -93,6 +94,9 @@ namespace osu.Game.Screens.Play [Resolved] private MusicController musicController { get; set; } + [Resolved] + private SpectatorClient spectatorClient { get; set; } + private Sample sampleRestart; public BreakOverlay BreakOverlay; @@ -882,6 +886,11 @@ namespace osu.Game.Screens.Play return true; } + // EndPlaying() is typically called from ReplayRecorder.Dispose(). Disposal is currently asynchronous. + // To resolve test failures, forcefully end playing synchronously when this screen exits. + // Todo: Replace this with a more permanent solution once osu-framework has a synchronous cleanup method. + spectatorClient.EndPlaying(); + // GameplayClockContainer performs seeks / start / stop operations on the beatmap's track. // as we are no longer the current screen, we cannot guarantee the track is still usable. (GameplayClockContainer as MasterGameplayClockContainer)?.StopUsingBeatmapClock(); diff --git a/osu.Game/Screens/Play/SpectatorPlayer.cs b/osu.Game/Screens/Play/SpectatorPlayer.cs index 9822f62dd8..a8125dfded 100644 --- a/osu.Game/Screens/Play/SpectatorPlayer.cs +++ b/osu.Game/Screens/Play/SpectatorPlayer.cs @@ -31,12 +31,12 @@ namespace osu.Game.Screens.Play } [Resolved] - private SpectatorStreamingClient spectatorStreaming { get; set; } + private SpectatorClient spectatorClient { get; set; } [BackgroundDependencyLoader] private void load() { - spectatorStreaming.OnUserBeganPlaying += userBeganPlaying; + spectatorClient.OnUserBeganPlaying += userBeganPlaying; AddInternal(new OsuSpriteText { @@ -66,7 +66,7 @@ namespace osu.Game.Screens.Play public override bool OnExiting(IScreen next) { - spectatorStreaming.OnUserBeganPlaying -= userBeganPlaying; + spectatorClient.OnUserBeganPlaying -= userBeganPlaying; return base.OnExiting(next); } @@ -84,8 +84,8 @@ namespace osu.Game.Screens.Play { base.Dispose(isDisposing); - if (spectatorStreaming != null) - spectatorStreaming.OnUserBeganPlaying -= userBeganPlaying; + if (spectatorClient != null) + spectatorClient.OnUserBeganPlaying -= userBeganPlaying; } } } diff --git a/osu.Game/Screens/Play/SpectatorResultsScreen.cs b/osu.Game/Screens/Play/SpectatorResultsScreen.cs index dabdf0a139..fd7af3af85 100644 --- a/osu.Game/Screens/Play/SpectatorResultsScreen.cs +++ b/osu.Game/Screens/Play/SpectatorResultsScreen.cs @@ -17,12 +17,12 @@ namespace osu.Game.Screens.Play } [Resolved] - private SpectatorStreamingClient spectatorStreaming { get; set; } + private SpectatorClient spectatorClient { get; set; } [BackgroundDependencyLoader] private void load() { - spectatorStreaming.OnUserBeganPlaying += userBeganPlaying; + spectatorClient.OnUserBeganPlaying += userBeganPlaying; } private void userBeganPlaying(int userId, SpectatorState state) @@ -40,8 +40,8 @@ namespace osu.Game.Screens.Play { base.Dispose(isDisposing); - if (spectatorStreaming != null) - spectatorStreaming.OnUserBeganPlaying -= userBeganPlaying; + if (spectatorClient != null) + spectatorClient.OnUserBeganPlaying -= userBeganPlaying; } } } diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs index bca3a07fa6..c70b4dd35b 100644 --- a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs +++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs @@ -10,11 +10,9 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Utils; -using osu.Game.Audio; using osu.Game.Graphics; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; -using osu.Game.Skinning; using osuTK; namespace osu.Game.Screens.Ranking.Expanded.Accuracy @@ -76,19 +74,14 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy private readonly ScoreInfo score; - private readonly bool withFlair; - private SmoothCircularProgress accuracyCircle; private SmoothCircularProgress innerMask; private Container badges; private RankText rankText; - private SkinnableSound applauseSound; - - public AccuracyCircle(ScoreInfo score, bool withFlair) + public AccuracyCircle(ScoreInfo score) { this.score = score; - this.withFlair = withFlair; } [BackgroundDependencyLoader] @@ -211,13 +204,6 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy }, rankText = new RankText(score.Rank) }; - - if (withFlair) - { - AddInternal(applauseSound = score.Rank >= ScoreRank.A - ? new SkinnableSound(new SampleInfo("Results/rankpass", "applause")) - : new SkinnableSound(new SampleInfo("Results/rankfail"))); - } } private ScoreRank getRank(ScoreRank rank) @@ -256,7 +242,6 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy using (BeginDelayedSequence(TEXT_APPEAR_DELAY, true)) { - this.Delay(-1440).Schedule(() => applauseSound?.Play()); rankText.Appear(); } } diff --git a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs index 6a6b39b61c..4895240314 100644 --- a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs +++ b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs @@ -122,7 +122,7 @@ namespace osu.Game.Screens.Ranking.Expanded Margin = new MarginPadding { Top = 40 }, RelativeSizeAxes = Axes.X, Height = 230, - Child = new AccuracyCircle(score, withFlair) + Child = new AccuracyCircle(score) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game/Screens/Ranking/ResultsScreen.cs b/osu.Game/Screens/Ranking/ResultsScreen.cs index c1f5d92d17..a0ea27b640 100644 --- a/osu.Game/Screens/Ranking/ResultsScreen.cs +++ b/osu.Game/Screens/Ranking/ResultsScreen.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Bindings; using osu.Framework.Screens; +using osu.Game.Audio; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; @@ -19,13 +20,20 @@ using osu.Game.Online.API; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Screens.Play; +using osu.Game.Screens.Ranking.Expanded.Accuracy; using osu.Game.Screens.Ranking.Statistics; +using osu.Game.Skinning; using osuTK; namespace osu.Game.Screens.Ranking { public abstract class ResultsScreen : ScreenWithBeatmapBackground, IKeyBindingHandler { + /// + /// Delay before the default applause sound should be played, in order to match the grade display timing in . + /// + public const double APPLAUSE_DELAY = AccuracyCircle.ACCURACY_TRANSFORM_DELAY + AccuracyCircle.TEXT_APPEAR_DELAY + ScorePanel.RESIZE_DURATION + ScorePanel.TOP_LAYER_EXPAND_DELAY - 1440; + protected const float BACKGROUND_BLUR = 20; private static readonly float screen_height = 768 - TwoLayerButton.SIZE_EXTENDED.Y; @@ -56,6 +64,8 @@ namespace osu.Game.Screens.Ranking private readonly bool allowRetry; private readonly bool allowWatchingReplay; + private SkinnableSound applauseSound; + protected ResultsScreen(ScoreInfo score, bool allowRetry, bool allowWatchingReplay = true) { Score = score; @@ -146,6 +156,13 @@ namespace osu.Game.Screens.Ranking bool shouldFlair = player != null && !Score.Mods.Any(m => m is ModAutoplay); ScorePanelList.AddScore(Score, shouldFlair); + + if (shouldFlair) + { + AddInternal(applauseSound = Score.Rank >= ScoreRank.A + ? new SkinnableSound(new SampleInfo("Results/rankpass", "applause")) + : new SkinnableSound(new SampleInfo("Results/rankfail"))); + } } if (allowWatchingReplay) @@ -183,6 +200,9 @@ namespace osu.Game.Screens.Ranking api.Queue(req); statisticsPanel.State.BindValueChanged(onStatisticsStateChanged, true); + + using (BeginDelayedSequence(APPLAUSE_DELAY)) + Schedule(() => applauseSound?.Play()); } protected override void Update() diff --git a/osu.Game/Screens/Ranking/ScorePanel.cs b/osu.Game/Screens/Ranking/ScorePanel.cs index df710e4eb8..f66a998db6 100644 --- a/osu.Game/Screens/Ranking/ScorePanel.cs +++ b/osu.Game/Screens/Ranking/ScorePanel.cs @@ -54,12 +54,12 @@ namespace osu.Game.Screens.Ranking /// /// Duration for the panel to resize into its expanded/contracted size. /// - private const double resize_duration = 200; + public const double RESIZE_DURATION = 200; /// - /// Delay after before the top layer is expanded. + /// Delay after before the top layer is expanded. /// - private const double top_layer_expand_delay = 100; + public const double TOP_LAYER_EXPAND_DELAY = 100; /// /// Duration for the top layer expansion. @@ -208,8 +208,8 @@ namespace osu.Game.Screens.Ranking case PanelState.Expanded: Size = new Vector2(EXPANDED_WIDTH, expanded_height); - topLayerBackground.FadeColour(expanded_top_layer_colour, resize_duration, Easing.OutQuint); - middleLayerBackground.FadeColour(expanded_middle_layer_colour, resize_duration, Easing.OutQuint); + topLayerBackground.FadeColour(expanded_top_layer_colour, RESIZE_DURATION, Easing.OutQuint); + middleLayerBackground.FadeColour(expanded_middle_layer_colour, RESIZE_DURATION, Easing.OutQuint); topLayerContentContainer.Add(topLayerContent = new ExpandedPanelTopContent(Score.User).With(d => d.Alpha = 0)); middleLayerContentContainer.Add(middleLayerContent = new ExpandedPanelMiddleContent(Score, displayWithFlair).With(d => d.Alpha = 0)); @@ -221,20 +221,20 @@ namespace osu.Game.Screens.Ranking case PanelState.Contracted: Size = new Vector2(CONTRACTED_WIDTH, contracted_height); - topLayerBackground.FadeColour(contracted_top_layer_colour, resize_duration, Easing.OutQuint); - middleLayerBackground.FadeColour(contracted_middle_layer_colour, resize_duration, Easing.OutQuint); + topLayerBackground.FadeColour(contracted_top_layer_colour, RESIZE_DURATION, Easing.OutQuint); + middleLayerBackground.FadeColour(contracted_middle_layer_colour, RESIZE_DURATION, Easing.OutQuint); topLayerContentContainer.Add(topLayerContent = new ContractedPanelTopContent(Score).With(d => d.Alpha = 0)); middleLayerContentContainer.Add(middleLayerContent = new ContractedPanelMiddleContent(Score).With(d => d.Alpha = 0)); break; } - content.ResizeTo(Size, resize_duration, Easing.OutQuint); + content.ResizeTo(Size, RESIZE_DURATION, Easing.OutQuint); bool topLayerExpanded = topLayerContainer.Y < 0; // If the top layer was already expanded, then we don't need to wait for the resize and can instead transform immediately. This looks better when changing the panel state. - using (BeginDelayedSequence(topLayerExpanded ? 0 : resize_duration + top_layer_expand_delay, true)) + using (BeginDelayedSequence(topLayerExpanded ? 0 : RESIZE_DURATION + TOP_LAYER_EXPAND_DELAY, true)) { topLayerContainer.FadeIn(); diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs index d7e901b71e..a3fca3d4e1 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs @@ -250,7 +250,7 @@ namespace osu.Game.Screens.Select.Carousel else state = TernaryState.False; - return new TernaryStateMenuItem(collection.Name.Value, MenuItemType.Standard, s => + return new TernaryStateToggleMenuItem(collection.Name.Value, MenuItemType.Standard, s => { foreach (var b in beatmapSet.Beatmaps) { diff --git a/osu.Game/Screens/Select/ImportFromStablePopup.cs b/osu.Game/Screens/Select/ImportFromStablePopup.cs index 8dab83b24c..d8137432bd 100644 --- a/osu.Game/Screens/Select/ImportFromStablePopup.cs +++ b/osu.Game/Screens/Select/ImportFromStablePopup.cs @@ -12,7 +12,7 @@ namespace osu.Game.Screens.Select public ImportFromStablePopup(Action importFromStable) { HeaderText = @"You have no beatmaps!"; - BodyText = "An existing copy of osu! was found, though.\nWould you like to import your beatmaps, skins, collections and scores?\nThis will create a second copy of all files on disk."; + BodyText = "Would you like to import your beatmaps, skins, collections and scores from an existing osu!stable installation?\nThis will create a second copy of all files on disk."; Icon = FontAwesome.Solid.Plane; diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index 215700d87c..74e10037ab 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -22,7 +22,6 @@ using osu.Game.Rulesets.Mods; using osu.Game.Screens.Edit; using osu.Game.Screens.Menu; using osu.Game.Screens.Select.Options; -using osu.Game.Skinning; using osuTK; using osuTK.Graphics; using osuTK.Input; @@ -35,9 +34,9 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; using osu.Game.Collections; using osu.Game.Graphics.UserInterface; -using osu.Game.Scoring; using System.Diagnostics; using osu.Game.Screens.Play; +using osu.Game.Database; namespace osu.Game.Screens.Select { @@ -52,6 +51,8 @@ namespace osu.Game.Screens.Select protected virtual bool ShowFooter => true; + protected virtual bool DisplayStableImportPrompt => stableImportManager?.SupportsImportFromStable == true; + /// /// Can be null if is false. /// @@ -84,6 +85,9 @@ namespace osu.Game.Screens.Select [Resolved] private BeatmapManager beatmaps { get; set; } + [Resolved(CanBeNull = true)] + private StableImportManager stableImportManager { get; set; } + protected ModSelectOverlay ModSelect { get; private set; } protected Sample SampleConfirm { get; private set; } @@ -101,7 +105,7 @@ namespace osu.Game.Screens.Select private MusicController music { get; set; } [BackgroundDependencyLoader(true)] - private void load(AudioManager audio, DialogOverlay dialog, OsuColour colours, SkinManager skins, ScoreManager scores, CollectionManager collections, ManageCollectionsDialog manageCollectionsDialog, DifficultyRecommender recommender) + private void load(AudioManager audio, DialogOverlay dialog, OsuColour colours, ManageCollectionsDialog manageCollectionsDialog, DifficultyRecommender recommender) { // initial value transfer is required for FilterControl (it uses our re-cached bindables in its async load for the initial filter). transferRulesetValue(); @@ -282,18 +286,12 @@ namespace osu.Game.Screens.Select { Schedule(() => { - // if we have no beatmaps but osu-stable is found, let's prompt the user to import. - if (!beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.Minimal).Any() && beatmaps.StableInstallationAvailable) + // if we have no beatmaps, let's prompt the user to import from over a stable install if he has one. + if (!beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.Minimal).Any() && DisplayStableImportPrompt) { dialogOverlay.Push(new ImportFromStablePopup(() => { - Task.Run(beatmaps.ImportFromStableAsync) - .ContinueWith(_ => - { - Task.Run(scores.ImportFromStableAsync); - Task.Run(collections.ImportFromStableAsync); - }, TaskContinuationOptions.OnlyOnRanToCompletion); - Task.Run(skins.ImportFromStableAsync); + Task.Run(() => stableImportManager.ImportFromStableAsync(StableContent.All)); })); } }); diff --git a/osu.Game/Screens/Spectate/SpectatorScreen.cs b/osu.Game/Screens/Spectate/SpectatorScreen.cs index bcebd51954..9a20bb58b8 100644 --- a/osu.Game/Screens/Spectate/SpectatorScreen.cs +++ b/osu.Game/Screens/Spectate/SpectatorScreen.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Online.Spectator; @@ -37,13 +38,12 @@ namespace osu.Game.Screens.Spectate private RulesetStore rulesets { get; set; } [Resolved] - private SpectatorStreamingClient spectatorClient { get; set; } + private SpectatorClient spectatorClient { get; set; } [Resolved] private UserLookupCache userLookupCache { get; set; } - // A lock is used to synchronise access to spectator/gameplay states, since this class is a screen which may become non-current and stop receiving updates at any point. - private readonly object stateLock = new object(); + private readonly IBindableDictionary playingUserStates = new BindableDictionary(); private readonly Dictionary userMap = new Dictionary(); private readonly Dictionary gameplayStates = new Dictionary(); @@ -63,36 +63,36 @@ namespace osu.Game.Screens.Spectate { base.LoadComplete(); - populateAllUsers().ContinueWith(_ => Schedule(() => + getAllUsers().ContinueWith(users => Schedule(() => { - spectatorClient.BindUserBeganPlaying(userBeganPlaying, true); - spectatorClient.OnUserFinishedPlaying += userFinishedPlaying; + foreach (var u in users.Result) + userMap[u.Id] = u; + + playingUserStates.BindTo(spectatorClient.PlayingUserStates); + playingUserStates.BindCollectionChanged(onPlayingUserStatesChanged, true); + spectatorClient.OnNewFrames += userSentFrames; managerUpdated = beatmaps.ItemUpdated.GetBoundCopy(); managerUpdated.BindValueChanged(beatmapUpdated); - lock (stateLock) - { - foreach (var (id, _) in userMap) - spectatorClient.WatchUser(id); - } + foreach (var (id, _) in userMap) + spectatorClient.WatchUser(id); })); } - private Task populateAllUsers() + private Task getAllUsers() { - var userLookupTasks = new List(); + var userLookupTasks = new List>(); foreach (var u in userIds) { userLookupTasks.Add(userLookupCache.GetUserAsync(u).ContinueWith(task => { if (!task.IsCompletedSuccessfully) - return; + return null; - lock (stateLock) - userMap[u] = task.Result; + return task.Result; })); } @@ -104,118 +104,119 @@ namespace osu.Game.Screens.Spectate if (!e.NewValue.TryGetTarget(out var beatmapSet)) return; - lock (stateLock) + foreach (var (userId, _) in userMap) { - foreach (var (userId, _) in userMap) - { - if (!spectatorClient.TryGetPlayingUserState(userId, out var userState)) - continue; + if (!playingUserStates.TryGetValue(userId, out var userState)) + continue; - if (beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID == userState.BeatmapID)) - updateGameplayState(userId); - } + if (beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID == userState.BeatmapID)) + updateGameplayState(userId); } } - private void userBeganPlaying(int userId, SpectatorState state) + private void onPlayingUserStatesChanged(object sender, NotifyDictionaryChangedEventArgs e) + { + switch (e.Action) + { + case NotifyDictionaryChangedAction.Add: + foreach (var (userId, state) in e.NewItems.AsNonNull()) + onUserStateAdded(userId, state); + break; + + case NotifyDictionaryChangedAction.Remove: + foreach (var (userId, _) in e.OldItems.AsNonNull()) + onUserStateRemoved(userId); + break; + + case NotifyDictionaryChangedAction.Replace: + foreach (var (userId, _) in e.OldItems.AsNonNull()) + onUserStateRemoved(userId); + + foreach (var (userId, state) in e.NewItems.AsNonNull()) + onUserStateAdded(userId, state); + break; + } + } + + private void onUserStateAdded(int userId, SpectatorState state) { if (state.RulesetID == null || state.BeatmapID == null) return; - lock (stateLock) - { - if (!userMap.ContainsKey(userId)) - return; + if (!userMap.ContainsKey(userId)) + return; - // The user may have stopped playing. - if (!spectatorClient.TryGetPlayingUserState(userId, out _)) - return; + Schedule(() => OnUserStateChanged(userId, state)); + updateGameplayState(userId); + } - Schedule(() => OnUserStateChanged(userId, state)); + private void onUserStateRemoved(int userId) + { + if (!userMap.ContainsKey(userId)) + return; - updateGameplayState(userId); - } + if (!gameplayStates.TryGetValue(userId, out var gameplayState)) + return; + + gameplayState.Score.Replay.HasReceivedAllFrames = true; + + gameplayStates.Remove(userId); + Schedule(() => EndGameplay(userId)); } private void updateGameplayState(int userId) { - lock (stateLock) + Debug.Assert(userMap.ContainsKey(userId)); + + var user = userMap[userId]; + var spectatorState = playingUserStates[userId]; + + var resolvedRuleset = rulesets.AvailableRulesets.FirstOrDefault(r => r.ID == spectatorState.RulesetID)?.CreateInstance(); + if (resolvedRuleset == null) + return; + + var resolvedBeatmap = beatmaps.QueryBeatmap(b => b.OnlineBeatmapID == spectatorState.BeatmapID); + if (resolvedBeatmap == null) + return; + + var score = new Score { - Debug.Assert(userMap.ContainsKey(userId)); - - // The user may have stopped playing. - if (!spectatorClient.TryGetPlayingUserState(userId, out var spectatorState)) - return; - - var user = userMap[userId]; - - var resolvedRuleset = rulesets.AvailableRulesets.FirstOrDefault(r => r.ID == spectatorState.RulesetID)?.CreateInstance(); - if (resolvedRuleset == null) - return; - - var resolvedBeatmap = beatmaps.QueryBeatmap(b => b.OnlineBeatmapID == spectatorState.BeatmapID); - if (resolvedBeatmap == null) - return; - - var score = new Score + ScoreInfo = new ScoreInfo { - ScoreInfo = new ScoreInfo - { - Beatmap = resolvedBeatmap, - User = user, - Mods = spectatorState.Mods.Select(m => m.ToMod(resolvedRuleset)).ToArray(), - Ruleset = resolvedRuleset.RulesetInfo, - }, - Replay = new Replay { HasReceivedAllFrames = false }, - }; + Beatmap = resolvedBeatmap, + User = user, + Mods = spectatorState.Mods.Select(m => m.ToMod(resolvedRuleset)).ToArray(), + Ruleset = resolvedRuleset.RulesetInfo, + }, + Replay = new Replay { HasReceivedAllFrames = false }, + }; - var gameplayState = new GameplayState(score, resolvedRuleset, beatmaps.GetWorkingBeatmap(resolvedBeatmap)); + var gameplayState = new GameplayState(score, resolvedRuleset, beatmaps.GetWorkingBeatmap(resolvedBeatmap)); - gameplayStates[userId] = gameplayState; - Schedule(() => StartGameplay(userId, gameplayState)); - } + gameplayStates[userId] = gameplayState; + Schedule(() => StartGameplay(userId, gameplayState)); } private void userSentFrames(int userId, FrameDataBundle bundle) { - lock (stateLock) + if (!userMap.ContainsKey(userId)) + return; + + if (!gameplayStates.TryGetValue(userId, out var gameplayState)) + return; + + // The ruleset instance should be guaranteed to be in sync with the score via ScoreLock. + Debug.Assert(gameplayState.Ruleset != null && gameplayState.Ruleset.RulesetInfo.Equals(gameplayState.Score.ScoreInfo.Ruleset)); + + foreach (var frame in bundle.Frames) { - if (!userMap.ContainsKey(userId)) - return; + IConvertibleReplayFrame convertibleFrame = gameplayState.Ruleset.CreateConvertibleReplayFrame(); + convertibleFrame.FromLegacy(frame, gameplayState.Beatmap.Beatmap); - if (!gameplayStates.TryGetValue(userId, out var gameplayState)) - return; + var convertedFrame = (ReplayFrame)convertibleFrame; + convertedFrame.Time = frame.Time; - // The ruleset instance should be guaranteed to be in sync with the score via ScoreLock. - Debug.Assert(gameplayState.Ruleset != null && gameplayState.Ruleset.RulesetInfo.Equals(gameplayState.Score.ScoreInfo.Ruleset)); - - foreach (var frame in bundle.Frames) - { - IConvertibleReplayFrame convertibleFrame = gameplayState.Ruleset.CreateConvertibleReplayFrame(); - convertibleFrame.FromLegacy(frame, gameplayState.Beatmap.Beatmap); - - var convertedFrame = (ReplayFrame)convertibleFrame; - convertedFrame.Time = frame.Time; - - gameplayState.Score.Replay.Frames.Add(convertedFrame); - } - } - } - - private void userFinishedPlaying(int userId, SpectatorState state) - { - lock (stateLock) - { - if (!userMap.ContainsKey(userId)) - return; - - if (!gameplayStates.TryGetValue(userId, out var gameplayState)) - return; - - gameplayState.Score.Replay.HasReceivedAllFrames = true; - - gameplayStates.Remove(userId); - Schedule(() => EndGameplay(userId)); + gameplayState.Score.Replay.Frames.Add(convertedFrame); } } @@ -245,15 +246,12 @@ namespace osu.Game.Screens.Spectate /// The user to stop spectating. protected void RemoveUser(int userId) { - lock (stateLock) - { - userFinishedPlaying(userId, null); + onUserStateRemoved(userId); - userIds.Remove(userId); - userMap.Remove(userId); + userIds.Remove(userId); + userMap.Remove(userId); - spectatorClient.StopWatchingUser(userId); - } + spectatorClient.StopWatchingUser(userId); } protected override void Dispose(bool isDisposing) @@ -262,15 +260,10 @@ namespace osu.Game.Screens.Spectate if (spectatorClient != null) { - spectatorClient.OnUserBeganPlaying -= userBeganPlaying; - spectatorClient.OnUserFinishedPlaying -= userFinishedPlaying; spectatorClient.OnNewFrames -= userSentFrames; - lock (stateLock) - { - foreach (var (userId, _) in userMap) - spectatorClient.StopWatchingUser(userId); - } + foreach (var (userId, _) in userMap) + spectatorClient.StopWatchingUser(userId); } managerUpdated?.UnbindAll(); diff --git a/osu.Game/Skinning/Editor/SkinEditor.cs b/osu.Game/Skinning/Editor/SkinEditor.cs index f24b0c71c0..07a94cac7a 100644 --- a/osu.Game/Skinning/Editor/SkinEditor.cs +++ b/osu.Game/Skinning/Editor/SkinEditor.cs @@ -19,7 +19,7 @@ using osuTK; namespace osu.Game.Skinning.Editor { [Cached(typeof(SkinEditor))] - public class SkinEditor : FocusedOverlayContainer + public class SkinEditor : VisibilityContainer { public const double TRANSITION_DURATION = 500; diff --git a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs index 2eb4ea107d..2cfb9d0f96 100644 --- a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs +++ b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs @@ -100,7 +100,7 @@ namespace osu.Game.Skinning.Editor foreach (var item in base.GetContextMenuItemsForSelection(selection)) yield return item; - IEnumerable createAnchorItems(Func checkFunction, Action applyFunction) + IEnumerable createAnchorItems(Func checkFunction, Action applyFunction) { var displayableAnchors = new[] { @@ -117,7 +117,7 @@ namespace osu.Game.Skinning.Editor return displayableAnchors.Select(a => { - return new AnchorMenuItem(a, selection, _ => applyFunction(a)) + return new TernaryStateRadioMenuItem(a.ToString(), MenuItemType.Standard, _ => applyFunction(a)) { State = { Value = GetStateFromSelection(selection, c => checkFunction((Drawable)c.Item) == a) } }; @@ -166,15 +166,5 @@ namespace osu.Game.Skinning.Editor scale.Y = scale.X; } } - - public class AnchorMenuItem : TernaryStateMenuItem - { - public AnchorMenuItem(Anchor anchor, IEnumerable> selection, Action action) - : base(anchor.ToString(), getNextState, MenuItemType.Standard, action) - { - } - - private static TernaryState getNextState(TernaryState state) => TernaryState.True; - } } } diff --git a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs index db344b28dd..c76d1053b2 100644 --- a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs +++ b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs @@ -19,7 +19,7 @@ namespace osu.Game.Tests.Visual.Multiplayer public const int PLAYER_1_ID = 55; public const int PLAYER_2_ID = 56; - [Cached(typeof(StatefulMultiplayerClient))] + [Cached(typeof(MultiplayerClient))] public TestMultiplayerClient Client { get; } [Cached(typeof(IRoomManager))] diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index 167cf705a7..b12bd8091d 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -20,7 +20,7 @@ using osu.Game.Users; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestMultiplayerClient : StatefulMultiplayerClient + public class TestMultiplayerClient : MultiplayerClient { public override IBindable IsConnected => isConnected; private readonly Bindable isConnected = new Bindable(true); diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomContainer.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomContainer.cs index e57411d04d..1abf4d8f5d 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomContainer.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomContainer.cs @@ -16,7 +16,7 @@ namespace osu.Game.Tests.Visual.Multiplayer protected override Container Content => content; private readonly Container content; - [Cached(typeof(StatefulMultiplayerClient))] + [Cached(typeof(MultiplayerClient))] public readonly TestMultiplayerClient Client; [Cached(typeof(IRoomManager))] diff --git a/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs new file mode 100644 index 0000000000..3a5ffa8770 --- /dev/null +++ b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs @@ -0,0 +1,110 @@ +// 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.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Utils; +using osu.Game.Online.API; +using osu.Game.Online.Spectator; +using osu.Game.Replays.Legacy; +using osu.Game.Scoring; + +namespace osu.Game.Tests.Visual.Spectator +{ + public class TestSpectatorClient : SpectatorClient + { + public override IBindable IsConnected { get; } = new Bindable(true); + + private readonly Dictionary userBeatmapDictionary = new Dictionary(); + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + /// + /// Starts play for an arbitrary user. + /// + /// The user to start play for. + /// The playing beatmap id. + public void StartPlay(int userId, int beatmapId) + { + userBeatmapDictionary[userId] = beatmapId; + sendPlayingState(userId); + } + + /// + /// Ends play for an arbitrary user. + /// + /// The user to end play for. + public void EndPlay(int userId) + { + if (!PlayingUsers.Contains(userId)) + return; + + ((ISpectatorClient)this).UserFinishedPlaying(userId, new SpectatorState + { + BeatmapID = userBeatmapDictionary[userId], + RulesetID = 0, + }); + } + + /// + /// Sends frames for an arbitrary user. + /// + /// The user to send frames for. + /// The frame index. + /// The number of frames to send. + 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); + } + + protected override Task BeginPlayingInternal(SpectatorState state) + { + // Track the local user's playing beatmap ID. + Debug.Assert(state.BeatmapID != null); + userBeatmapDictionary[api.LocalUser.Value.Id] = state.BeatmapID.Value; + + return ((ISpectatorClient)this).UserBeganPlaying(api.LocalUser.Value.Id, state); + } + + protected override Task SendFramesInternal(FrameDataBundle data) => ((ISpectatorClient)this).UserSentFrames(api.LocalUser.Value.Id, data); + + protected override Task EndPlayingInternal(SpectatorState state) => ((ISpectatorClient)this).UserFinishedPlaying(api.LocalUser.Value.Id, state); + + protected override Task WatchUserInternal(int userId) + { + // When newly watching a user, the server sends the playing state immediately. + if (PlayingUsers.Contains(userId)) + sendPlayingState(userId); + + return Task.CompletedTask; + } + + protected override Task StopWatchingUserInternal(int userId) => Task.CompletedTask; + + private void sendPlayingState(int userId) + { + ((ISpectatorClient)this).UserBeganPlaying(userId, new SpectatorState + { + BeatmapID = userBeatmapDictionary[userId], + RulesetID = 0, + }); + } + } +} diff --git a/osu.Game/Tests/Visual/Spectator/TestSpectatorStreamingClient.cs b/osu.Game/Tests/Visual/Spectator/TestSpectatorStreamingClient.cs deleted file mode 100644 index cc8437479d..0000000000 --- a/osu.Game/Tests/Visual/Spectator/TestSpectatorStreamingClient.cs +++ /dev/null @@ -1,90 +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 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; - } - } -} diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 587bdaf622..1e3b77cd70 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -29,7 +29,7 @@ - + diff --git a/osu.iOS.props b/osu.iOS.props index 7ba7a554d6..a2a9ac35fc 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -70,7 +70,7 @@ - + @@ -93,7 +93,7 @@ - +