diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs index 12b3b90fc4..b4cbc40403 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.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 NUnit.Framework; using osu.Framework.Allocation; @@ -16,11 +17,13 @@ using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.Countdown; using osu.Game.Online.Rooms; using osu.Game.Rulesets; using osu.Game.Screens.OnlinePlay.Multiplayer.Match; using osu.Game.Tests.Resources; using osuTK; +using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { @@ -68,6 +71,139 @@ namespace osu.Game.Tests.Visual.Multiplayer }; }); + [Test] + public void TestStartWithCountdown() + { + ClickButtonWhenEnabled(); + AddUntilStep("countdown button shown", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent == true); + ClickButtonWhenEnabled(); + AddStep("click the first countdown button", () => + { + var popoverButton = this.ChildrenOfType().First(); + InputManager.MoveMouseTo(popoverButton); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("countdown button not visible", () => !this.ChildrenOfType().Single().IsPresent); + AddStep("finish countdown", () => MultiplayerClient.FinishCountDown()); + AddUntilStep("match started", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.WaitingForLoad); + } + + [Test] + public void TestCancelCountdown() + { + ClickButtonWhenEnabled(); + AddUntilStep("countdown button shown", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent == true); + ClickButtonWhenEnabled(); + AddStep("click the first countdown button", () => + { + var popoverButton = this.ChildrenOfType().First(); + InputManager.MoveMouseTo(popoverButton); + InputManager.Click(MouseButton.Left); + }); + + ClickButtonWhenEnabled(); + + AddStep("finish countdown", () => MultiplayerClient.FinishCountDown()); + AddUntilStep("match not started", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Ready); + } + + [Test] + public void TestReadyAndUnReadyDuringCountdown() + { + AddStep("add second user as host", () => + { + MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" }); + MultiplayerClient.TransferHost(2); + }); + + AddStep("start with countdown", () => MultiplayerClient.SendMatchRequest(new MatchStartCountdownRequest { Delay = TimeSpan.FromMinutes(2) })); + + ClickButtonWhenEnabled(); + AddUntilStep("user is ready", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Ready); + + ClickButtonWhenEnabled(); + AddUntilStep("user is idle", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Idle); + } + + [Test] + public void TestCountdownButtonEnablementAndVisibilityWhileSpectating() + { + AddStep("set spectating", () => MultiplayerClient.ChangeUserState(API.LocalUser.Value.OnlineID, MultiplayerUserState.Spectating)); + AddUntilStep("local user is spectating", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Spectating); + + AddAssert("countdown button is visible", () => this.ChildrenOfType().Single().IsPresent); + AddAssert("countdown button disabled", () => !this.ChildrenOfType().Single().Enabled.Value); + + AddStep("add second user", () => MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" })); + AddAssert("countdown button disabled", () => !this.ChildrenOfType().Single().Enabled.Value); + + AddStep("set second user ready", () => MultiplayerClient.ChangeUserState(2, MultiplayerUserState.Ready)); + AddAssert("countdown button enabled", () => this.ChildrenOfType().Single().Enabled.Value); + } + + [Test] + public void TestSpectatingDuringCountdownWithNoReadyUsersCancelsCountdown() + { + ClickButtonWhenEnabled(); + AddUntilStep("countdown button shown", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent == true); + ClickButtonWhenEnabled(); + AddStep("click the first countdown button", () => + { + var popoverButton = this.ChildrenOfType().First(); + InputManager.MoveMouseTo(popoverButton); + InputManager.Click(MouseButton.Left); + }); + + AddStep("set spectating", () => MultiplayerClient.ChangeUserState(API.LocalUser.Value.OnlineID, MultiplayerUserState.Spectating)); + AddUntilStep("local user is spectating", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Spectating); + + AddStep("finish countdown", () => MultiplayerClient.FinishCountDown()); + AddUntilStep("match not started", () => MultiplayerClient.Room?.State == MultiplayerRoomState.Open); + } + + [Test] + public void TestReadyButtonEnabledWhileSpectatingDuringCountdown() + { + AddStep("add second user", () => MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" })); + AddStep("set second user ready", () => MultiplayerClient.ChangeUserState(2, MultiplayerUserState.Ready)); + + ClickButtonWhenEnabled(); + AddUntilStep("countdown button shown", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent == true); + ClickButtonWhenEnabled(); + AddStep("click the first countdown button", () => + { + var popoverButton = this.ChildrenOfType().First(); + InputManager.MoveMouseTo(popoverButton); + InputManager.Click(MouseButton.Left); + }); + + AddStep("set spectating", () => MultiplayerClient.ChangeUserState(API.LocalUser.Value.OnlineID, MultiplayerUserState.Spectating)); + AddUntilStep("local user is spectating", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Spectating); + + AddAssert("ready button enabled", () => this.ChildrenOfType().Single().Enabled.Value); + } + + [Test] + public void TestBecomeHostDuringCountdownAndReady() + { + AddStep("add second user as host", () => + { + MultiplayerClient.AddUser(new APIUser { Id = 2, Username = "Another user" }); + MultiplayerClient.TransferHost(2); + }); + + AddStep("start countdown", () => MultiplayerClient.SendMatchRequest(new MatchStartCountdownRequest { Delay = TimeSpan.FromMinutes(1) })); + AddUntilStep("countdown started", () => MultiplayerClient.Room?.Countdown != null); + + AddStep("transfer host to local user", () => MultiplayerClient.TransferHost(API.LocalUser.Value.OnlineID)); + AddUntilStep("local user is host", () => MultiplayerClient.Room?.Host?.Equals(MultiplayerClient.LocalUser) == true); + + ClickButtonWhenEnabled(); + AddUntilStep("local user became ready", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Ready); + AddAssert("countdown still active", () => MultiplayerClient.Room?.Countdown != null); + } + [Test] public void TestDeletedBeatmapDisableReady() { diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index a56cc7f8d6..2d5496c5c1 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -16,6 +16,7 @@ using osu.Framework.Logging; using osu.Game.Database; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer.Countdown; using osu.Game.Online.Rooms; using osu.Game.Online.Rooms.RoomStatuses; using osu.Game.Rulesets; @@ -534,7 +535,24 @@ namespace osu.Game.Online.Multiplayer public Task MatchEvent(MatchServerEvent e) { - // not used by any match types just yet. + if (Room == null) + return Task.CompletedTask; + + Scheduler.Add(() => + { + if (Room == null) + return; + + switch (e) + { + case CountdownChangedEvent countdownChangedEvent: + Room.Countdown = countdownChangedEvent.Countdown; + break; + } + + RoomUpdated?.Invoke(); + }, false); + return Task.CompletedTask; } diff --git a/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs b/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs index 9822ceaaf6..79cf5c7236 100644 --- a/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs @@ -14,20 +14,18 @@ namespace osu.Game.Screens.OnlinePlay.Components public abstract class ReadyButton : TriangleButton, IHasTooltip { public new readonly BindableBool Enabled = new BindableBool(); - - private IBindable availability; + protected readonly IBindable Availability = new Bindable(); [BackgroundDependencyLoader] private void load(OnlinePlayBeatmapAvailabilityTracker beatmapTracker) { - availability = beatmapTracker.Availability.GetBoundCopy(); - - availability.BindValueChanged(_ => updateState()); + Availability.BindTo(beatmapTracker.Availability); + Availability.BindValueChanged(_ => updateState()); Enabled.BindValueChanged(_ => updateState(), true); } private void updateState() => - base.Enabled.Value = availability.Value.State == DownloadState.LocallyAvailable && Enabled.Value; + base.Enabled.Value = Availability.Value.State == DownloadState.LocallyAvailable && Enabled.Value; public virtual LocalisableString TooltipText { @@ -36,7 +34,7 @@ namespace osu.Game.Screens.OnlinePlay.Components if (Enabled.Value) return string.Empty; - if (availability.Value.State != DownloadState.LocallyAvailable) + if (Availability.Value.State != DownloadState.LocallyAvailable) return "Beatmap not downloaded"; return string.Empty; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs index 4e53b40075..f9f070f17a 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs @@ -17,12 +17,14 @@ using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; using osu.Framework.Threading; using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.Countdown; using osuTK; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match @@ -124,6 +126,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match return; } + // Local user is the room host and is in a ready state. + // The only action they can take is to stop a countdown if one's currently running. + if (Room.Countdown != null) + { + stopCountdown(); + return; + } + // And if a countdown isn't running, start the match. startMatch(); @@ -131,6 +141,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match void toggleReady() => Client.ToggleReady().ContinueWith(_ => endOperation()); + void stopCountdown() => Client.SendMatchRequest(new StopCountdownRequest()).ContinueWith(_ => endOperation()); + void startMatch() => Client.StartMatch().ContinueWith(t => { // accessing Exception here silences any potential errors from the antecedent task @@ -146,6 +158,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private void startCountdown(TimeSpan duration) { + Debug.Assert(clickOperation == null); + clickOperation = ongoingOperationTracker.BeginOperation(); + + Client.SendMatchRequest(new MatchStartCountdownRequest { Delay = duration }).ContinueWith(_ => endOperation()); } private void endOperation() @@ -167,16 +183,21 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match int newCountReady = Room.Users.Count(u => u.State == MultiplayerUserState.Ready); int newCountTotal = Room.Users.Count(u => u.State != MultiplayerUserState.Spectating); - switch (localUser?.State) + if (Room.Countdown != null) + countdownButton.Alpha = 0; + else { - default: - countdownButton.Alpha = 0; - break; + switch (localUser?.State) + { + default: + countdownButton.Alpha = 0; + break; - case MultiplayerUserState.Spectating: - case MultiplayerUserState.Ready: - countdownButton.Alpha = Room.Host?.Equals(localUser) == true ? 1 : 0; - break; + case MultiplayerUserState.Spectating: + case MultiplayerUserState.Ready: + countdownButton.Alpha = Room.Host?.Equals(localUser) == true ? 1 : 0; + break; + } } enabled.Value = @@ -232,6 +253,17 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match onRoomUpdated(); } + protected override void Update() + { + base.Update(); + + if (room?.Countdown != null) + { + // Update the countdown timer. + onRoomUpdated(); + } + } + private void onRoomUpdated() { updateButtonText(); @@ -251,21 +283,39 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match int countReady = room.Users.Count(u => u.State == MultiplayerUserState.Ready); int countTotal = room.Users.Count(u => u.State != MultiplayerUserState.Spectating); + string countdownText = room.Countdown == null ? string.Empty : $"Starting in {room.Countdown.EndTime - DateTimeOffset.Now:mm\\:ss}"; string countText = $"({countReady} / {countTotal} ready)"; - switch (localUser?.State) + if (room.Countdown != null) { - default: - Text = "Ready"; - break; + switch (localUser?.State) + { + default: + Text = $"Ready ({countdownText.ToLowerInvariant()})"; + break; - case MultiplayerUserState.Spectating: - case MultiplayerUserState.Ready: - Text = room.Host?.Equals(localUser) == true - ? $"Start match {countText}" - : $"Waiting for host... {countText}"; + case MultiplayerUserState.Spectating: + case MultiplayerUserState.Ready: + Text = $"{countdownText} {countText}"; + break; + } + } + else + { + switch (localUser?.State) + { + default: + Text = "Ready"; + break; - break; + case MultiplayerUserState.Spectating: + case MultiplayerUserState.Ready: + Text = room.Host?.Equals(localUser) == true + ? $"Start match {countText}" + : $"Waiting for host... {countText}"; + + break; + } } } @@ -279,20 +329,37 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match var localUser = multiplayerClient.LocalUser; - switch (localUser?.State) + if (room.Countdown != null) { - default: - setGreen(); - break; - - case MultiplayerUserState.Spectating: - case MultiplayerUserState.Ready: - if (room?.Host?.Equals(localUser) == true) + switch (localUser?.State) + { + default: setGreen(); - else - setYellow(); + break; - break; + case MultiplayerUserState.Spectating: + case MultiplayerUserState.Ready: + setYellow(); + break; + } + } + else + { + switch (localUser?.State) + { + default: + setGreen(); + break; + + case MultiplayerUserState.Spectating: + case MultiplayerUserState.Ready: + if (room?.Host?.Equals(localUser) == true) + setGreen(); + else + setYellow(); + + break; + } } void setYellow() @@ -317,6 +384,17 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match if (multiplayerClient != null) multiplayerClient.RoomUpdated -= onRoomUpdated; } + + public override LocalisableString TooltipText + { + get + { + if (room?.Countdown != null && multiplayerClient.IsHost && multiplayerClient.LocalUser?.State == MultiplayerUserState.Ready) + return "Cancel countdown"; + + return base.TooltipText; + } + } } public class CountdownButton : IconButton, IHasPopover diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index 6dc5159b6f..a1ae1aa171 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -7,12 +7,14 @@ 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; using osu.Game.Online.API; using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.Countdown; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; using osu.Game.Online.Rooms; using osu.Game.Rulesets.Mods; @@ -114,12 +116,24 @@ namespace osu.Game.Tests.Visual.Multiplayer public void ChangeUserState(int userId, MultiplayerUserState newState) { Debug.Assert(Room != null); + ((IMultiplayerClient)this).UserStateChanged(userId, newState); Schedule(() => { switch (Room.State) { + case MultiplayerRoomState.Open: + // If there are no remaining ready users or the host is not ready, stop any existing countdown. + // Todo: When we have an "automatic start" mode, this should also start a new countdown if any users _are_ ready. + // Todo: This doesn't yet support non-match-start countdowns. + bool shouldStopCountdown = Room.Users.All(u => u.State != MultiplayerUserState.Ready); + shouldStopCountdown |= Room.Host?.State != MultiplayerUserState.Ready && Room.Host?.State != MultiplayerUserState.Spectating; + + if (shouldStopCountdown) + countdownStopSource?.Cancel(); + break; + case MultiplayerRoomState.WaitingForLoad: if (Room.Users.All(u => u.State != MultiplayerUserState.WaitingForLoad)) { @@ -282,6 +296,12 @@ namespace osu.Game.Tests.Visual.Multiplayer return Task.CompletedTask; } + private CancellationTokenSource? countdownFinishSource; + private CancellationTokenSource? countdownStopSource; + private Task countdownTask = Task.CompletedTask; + + public void FinishCountDown() => countdownFinishSource?.Cancel(); + public override async Task SendMatchRequest(MatchUserRequest request) { Debug.Assert(Room != null); @@ -289,6 +309,71 @@ namespace osu.Game.Tests.Visual.Multiplayer switch (request) { + case MatchStartCountdownRequest matchCountdownRequest: + countdownStopSource?.Cancel(); + + var stopSource = countdownStopSource = new CancellationTokenSource(); + var finishSource = countdownFinishSource = new CancellationTokenSource(); + var cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(stopSource.Token, finishSource.Token); + var countdown = new MatchStartCountdown { EndTime = DateTimeOffset.Now + matchCountdownRequest.Delay }; + + Task lastCountdownTask = countdownTask; + countdownTask = start(); + + async Task start() + { + try + { + await lastCountdownTask; + } + catch (OperationCanceledException) + { + } + + Schedule(() => + { + if (stopSource.IsCancellationRequested) + return; + + Room.Countdown = countdown; + MatchEvent(new CountdownChangedEvent { Countdown = countdown }); + }); + + try + { + await Task.Delay(matchCountdownRequest.Delay, cancellationSource.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + + Schedule(() => + { + if (Room.Countdown != countdown) + return; + + Room.Countdown = null; + MatchEvent(new CountdownChangedEvent { Countdown = null }); + + using (cancellationSource) + { + if (stopSource.Token.IsCancellationRequested) + return; + } + + StartMatch().WaitSafely(); + }); + } + + break; + + case StopCountdownRequest _: + countdownStopSource?.Cancel(); + + Room.Countdown = null; + await MatchEvent(new CountdownChangedEvent { Countdown = Room.Countdown }); + break; + case ChangeTeamRequest changeTeam: TeamVersusRoomState roomState = (TeamVersusRoomState)Room.MatchState!; @@ -307,7 +392,7 @@ namespace osu.Game.Tests.Visual.Multiplayer } } - public override Task StartMatch() + public override async Task StartMatch() { Debug.Assert(Room != null); @@ -315,7 +400,7 @@ namespace osu.Game.Tests.Visual.Multiplayer foreach (var user in Room.Users.Where(u => u.State == MultiplayerUserState.Ready)) ChangeUserState(user.UserID, MultiplayerUserState.WaitingForLoad); - return ((IMultiplayerClient)this).LoadRequested(); + await ((IMultiplayerClient)this).LoadRequested(); } public override Task AbortGameplay()