diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs index a374488306..52854db235 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs @@ -143,26 +143,6 @@ namespace osu.Game.Tests.Visual.Multiplayer 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().Single().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.SkipToEndOfCountdown()); - AddUntilStep("match not started", () => MultiplayerClient.Room?.State == MultiplayerRoomState.Open); - } - [Test] public void TestReadyButtonEnabledWhileSpectatingDuringCountdown() { @@ -205,6 +185,31 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("countdown still active", () => MultiplayerClient.Room?.Countdown != null); } + [Test] + public void TestCountdownButtonVisibilityWithAutoStartEnablement() + { + ClickButtonWhenEnabled(); + AddUntilStep("local user became ready", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Ready); + AddUntilStep("countdown button visible", () => this.ChildrenOfType().Single().IsPresent); + + AddStep("enable auto start", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { AutoStartDuration = TimeSpan.FromMinutes(1) }).WaitSafely()); + + ClickButtonWhenEnabled(); + AddUntilStep("local user became ready", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Ready); + AddUntilStep("countdown button not visible", () => !this.ChildrenOfType().Single().IsPresent); + } + + [Test] + public void TestClickingReadyButtonUnReadiesDuringAutoStart() + { + AddStep("enable auto start", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { AutoStartDuration = TimeSpan.FromMinutes(1) }).WaitSafely()); + ClickButtonWhenEnabled(); + AddUntilStep("local user became ready", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Ready); + + ClickButtonWhenEnabled(); + AddUntilStep("local user became idle", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Idle); + } + [Test] public void TestDeletedBeatmapDisableReady() { diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index faa995ed19..d6099e5f72 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -241,7 +241,9 @@ namespace osu.Game.Online.Multiplayer /// The new password, if any. /// The type of the match, if any. /// The new queue mode, if any. - public Task ChangeSettings(Optional name = default, Optional password = default, Optional matchType = default, Optional queueMode = default) + /// The new auto-start countdown duration, if any. + public Task ChangeSettings(Optional name = default, Optional password = default, Optional matchType = default, Optional queueMode = default, + Optional autoStartDuration = default) { if (Room == null) throw new InvalidOperationException("Must be joined to a match to change settings."); @@ -252,6 +254,7 @@ namespace osu.Game.Online.Multiplayer Password = password.GetOr(Room.Settings.Password), MatchType = matchType.GetOr(Room.Settings.MatchType), QueueMode = queueMode.GetOr(Room.Settings.QueueMode), + AutoStartDuration = autoStartDuration.GetOr(Room.Settings.AutoStartDuration), }); } @@ -745,6 +748,7 @@ namespace osu.Game.Online.Multiplayer APIRoom.Password.Value = Room.Settings.Password; APIRoom.Type.Value = Room.Settings.MatchType; APIRoom.QueueMode.Value = Room.Settings.QueueMode; + APIRoom.AutoStartDuration.Value = Room.Settings.AutoStartDuration; RoomUpdated?.Invoke(); } diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs b/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs index c392260a22..5c086066e6 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs @@ -28,6 +28,12 @@ namespace osu.Game.Online.Multiplayer [Key(4)] public QueueMode QueueMode { get; set; } = QueueMode.HostOnly; + [Key(5)] + public TimeSpan AutoStartDuration { get; set; } + + [IgnoreMember] + public bool AutoStartEnabled => AutoStartDuration != TimeSpan.Zero; + public bool Equals(MultiplayerRoomSettings? other) { if (ReferenceEquals(this, other)) return true; @@ -37,13 +43,15 @@ namespace osu.Game.Online.Multiplayer && Name.Equals(other.Name, StringComparison.Ordinal) && PlaylistItemId == other.PlaylistItemId && MatchType == other.MatchType - && QueueMode == other.QueueMode; + && QueueMode == other.QueueMode + && AutoStartDuration == other.AutoStartDuration; } public override string ToString() => $"Name:{Name}" + $" Password:{(string.IsNullOrEmpty(Password) ? "no" : "yes")}" + $" Type:{MatchType}" + $" Item:{PlaylistItemId}" - + $" Queue:{QueueMode}"; + + $" Queue:{QueueMode}" + + $" Start:{AutoStartDuration}"; } } diff --git a/osu.Game/Online/Rooms/Room.cs b/osu.Game/Online/Rooms/Room.cs index 543b176b51..60c0503ddd 100644 --- a/osu.Game/Online/Rooms/Room.cs +++ b/osu.Game/Online/Rooms/Room.cs @@ -92,6 +92,16 @@ namespace osu.Game.Online.Rooms set => QueueMode.Value = value; } + [Cached] + public readonly Bindable AutoStartDuration = new Bindable(); + + [JsonProperty("auto_start_duration")] + private ushort autoStartDuration + { + get => (ushort)AutoStartDuration.Value.TotalSeconds; + set => AutoStartDuration.Value = TimeSpan.FromSeconds(value); + } + [Cached] public readonly Bindable MaxParticipants = new Bindable(); @@ -172,6 +182,7 @@ namespace osu.Game.Online.Rooms EndDate.Value = other.EndDate.Value; UserScore.Value = other.UserScore.Value; QueueMode.Value = other.QueueMode.Value; + AutoStartDuration.Value = other.AutoStartDuration.Value; DifficultyRange.Value = other.DifficultyRange.Value; PlaylistItemStats.Value = other.PlaylistItemStats.Value; CurrentPlaylistItem.Value = other.CurrentPlaylistItem.Value; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs index af7ed9b9e2..6297203684 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs @@ -109,7 +109,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match clickOperation = ongoingOperationTracker.BeginOperation(); // Ensure the current user becomes ready before being able to do anything else (start match, stop countdown, unready). - if (!isReady() || !Client.IsHost) + if (!isReady() || !Client.IsHost || Room.Settings.AutoStartEnabled) { toggleReady(); return; @@ -172,19 +172,19 @@ 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); - if (Room.Countdown != null) - countdownButton.Alpha = 0; + if (!Client.IsHost || Room.Countdown != null || Room.Settings.AutoStartEnabled) + countdownButton.Hide(); else { switch (localUser?.State) { default: - countdownButton.Alpha = 0; + countdownButton.Hide(); break; case MultiplayerUserState.Spectating: case MultiplayerUserState.Ready: - countdownButton.Alpha = Room.Host?.Equals(localUser) == true ? 1 : 0; + countdownButton.Show(); break; } } @@ -197,7 +197,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match // When the local user is the host and spectating the match, the "start match" state should be enabled if any users are ready. if (localUser?.State == MultiplayerUserState.Spectating) - enabled.Value &= Room.Host?.Equals(localUser) == true && newCountReady > 0; + enabled.Value &= Client.IsHost && newCountReady > 0; if (newCountReady == countReady) return; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs index be98a9d4e9..a103d71120 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.ComponentModel; using System.Diagnostics; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -21,6 +22,7 @@ using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Screens.OnlinePlay.Match.Components; using osuTK; +using Container = osu.Framework.Graphics.Containers.Container; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { @@ -56,7 +58,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match public Action SettingsApplied; public OsuTextBox NameField, MaxParticipantsField; - public RoomAvailabilityPicker AvailabilityPicker; public MatchTypePicker TypePicker; public OsuEnumDropdown QueueModeDropdown; public OsuTextBox PasswordTextBox; @@ -64,6 +65,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match public OsuSpriteText ErrorText; + private OsuEnumDropdown startModeDropdown; private OsuSpriteText typeLabel; private LoadingLayer loadingLayer; @@ -163,14 +165,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match LengthLimit = 100, }, }, - new Section("Room visibility") - { - Alpha = disabled_alpha, - Child = AvailabilityPicker = new RoomAvailabilityPicker - { - Enabled = { Value = false } - }, - }, + // new Section("Room visibility") + // { + // Alpha = disabled_alpha, + // Child = AvailabilityPicker = new RoomAvailabilityPicker + // { + // Enabled = { Value = false } + // }, + // }, new Section("Game type") { Child = new FillFlowContainer @@ -204,6 +206,18 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match RelativeSizeAxes = Axes.X } } + }, + new Section("Auto start") + { + Child = new Container + { + RelativeSizeAxes = Axes.X, + Height = 40, + Child = startModeDropdown = new OsuEnumDropdown + { + RelativeSizeAxes = Axes.X + } + } } }, }, @@ -321,12 +335,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match TypePicker.Current.BindValueChanged(type => typeLabel.Text = type.NewValue.GetLocalisableDescription(), true); RoomName.BindValueChanged(name => NameField.Text = name.NewValue, true); - Availability.BindValueChanged(availability => AvailabilityPicker.Current.Value = availability.NewValue, true); Type.BindValueChanged(type => TypePicker.Current.Value = type.NewValue, true); MaxParticipants.BindValueChanged(count => MaxParticipantsField.Text = count.NewValue?.ToString(), true); RoomID.BindValueChanged(roomId => playlistContainer.Alpha = roomId.NewValue == null ? 1 : 0, true); Password.BindValueChanged(password => PasswordTextBox.Text = password.NewValue ?? string.Empty, true); QueueMode.BindValueChanged(mode => QueueModeDropdown.Current.Value = mode.NewValue, true); + AutoStartDuration.BindValueChanged(duration => startModeDropdown.Current.Value = (StartMode)(int)duration.NewValue.TotalSeconds, true); operationInProgress.BindTo(ongoingOperationTracker.InProgress); operationInProgress.BindValueChanged(v => @@ -363,6 +377,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match Debug.Assert(applyingSettingsOperation == null); applyingSettingsOperation = ongoingOperationTracker.BeginOperation(); + TimeSpan autoStartDuration = TimeSpan.FromSeconds((int)startModeDropdown.Current.Value); + // If the client is already in a room, update via the client. // Otherwise, update the room directly in preparation for it to be submitted to the API on match creation. if (client.Room != null) @@ -371,7 +387,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match name: NameField.Text, password: PasswordTextBox.Text, matchType: TypePicker.Current.Value, - queueMode: QueueModeDropdown.Current.Value) + queueMode: QueueModeDropdown.Current.Value, + autoStartDuration: autoStartDuration) .ContinueWith(t => Schedule(() => { if (t.IsCompletedSuccessfully) @@ -383,10 +400,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match else { room.Name.Value = NameField.Text; - room.Availability.Value = AvailabilityPicker.Current.Value; room.Type.Value = TypePicker.Current.Value; room.Password.Value = PasswordTextBox.Current.Value; room.QueueMode.Value = QueueModeDropdown.Current.Value; + room.AutoStartDuration.Value = autoStartDuration; if (int.TryParse(MaxParticipantsField.Text, out int max)) room.MaxParticipants.Value = max; @@ -452,5 +469,23 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match Triangles.ColourDark = colours.YellowDark; } } + + private enum StartMode + { + [Description("Off")] + Off = 0, + + [Description("30 seconds")] + Seconds_30 = 30, + + [Description("1 minute")] + Seconds_60 = 60, + + [Description("3 minutes")] + Seconds_180 = 180, + + [Description("5 minutes")] + Seconds_300 = 300 + } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs index f0049565c9..0ec2c6560a 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs @@ -168,7 +168,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { get { - if (room?.Countdown != null && multiplayerClient.IsHost && multiplayerClient.LocalUser?.State == MultiplayerUserState.Ready) + if (room?.Countdown != null && multiplayerClient.IsHost && multiplayerClient.LocalUser?.State == MultiplayerUserState.Ready && !room.Settings.AutoStartEnabled) return "Cancel countdown"; return base.TooltipText; diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs index 95d9b2af15..88354c8646 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs @@ -81,6 +81,9 @@ namespace osu.Game.Screens.OnlinePlay [Resolved(typeof(Room))] protected Bindable QueueMode { get; private set; } + [Resolved(typeof(Room))] + protected Bindable AutoStartDuration { get; private set; } + [Resolved(CanBeNull = true)] private IBindable subScreenSelectedItem { get; set; } diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index 9be1b18062..b9304f713d 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -119,6 +119,13 @@ namespace osu.Game.Tests.Visual.Multiplayer Debug.Assert(Room != null); ((IMultiplayerClient)this).UserStateChanged(userId, newState); + updateRoomStateIfRequired(); + } + + private void updateRoomStateIfRequired() + { + Debug.Assert(Room != null); + Debug.Assert(APIRoom != null); Schedule(() => { @@ -126,13 +133,15 @@ namespace osu.Game.Tests.Visual.Multiplayer { 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 (Room.Settings.AutoStartEnabled) + { + bool shouldHaveCountdown = !APIRoom.Playlist.GetCurrentItem()!.Expired && Room.Users.Any(u => u.State == MultiplayerUserState.Ready); + + if (shouldHaveCountdown && Room.Countdown == null) + startCountdown(new MatchStartCountdown { TimeRemaining = Room.Settings.AutoStartDuration }, StartMatch); + } - if (shouldStopCountdown) - countdownStopSource?.Cancel(); break; case MultiplayerRoomState.WaitingForLoad: @@ -204,7 +213,8 @@ namespace osu.Game.Tests.Visual.Multiplayer Name = apiRoom.Name.Value, MatchType = apiRoom.Type.Value, Password = password, - QueueMode = apiRoom.QueueMode.Value + QueueMode = apiRoom.QueueMode.Value, + AutoStartDuration = apiRoom.AutoStartDuration.Value }, Playlist = serverSidePlaylist.ToList(), Users = { localUser }, @@ -263,6 +273,7 @@ namespace osu.Game.Tests.Visual.Multiplayer ChangeUserState(user.UserID, MultiplayerUserState.Idle); await changeMatchType(settings.MatchType).ConfigureAwait(false); + updateRoomStateIfRequired(); } public override Task ChangeState(MultiplayerUserState newState) @@ -315,64 +326,11 @@ namespace osu.Game.Tests.Visual.Multiplayer switch (request) { case StartMatchCountdownRequest matchCountdownRequest: - Debug.Assert(ThreadSafety.IsUpdateThread); - - countdownStopSource?.Cancel(); - - // Note that this will leak CTSs, however this is a test method and we haven't noticed foregoing disposal of non-linked CTSs to be detrimental. - // If necessary, this can be moved into the final schedule below, and the class-level fields be nulled out accordingly. - var stopSource = countdownStopSource = new CancellationTokenSource(); - var skipSource = countdownSkipSource = new CancellationTokenSource(); - var countdown = new MatchStartCountdown { TimeRemaining = matchCountdownRequest.Duration }; - - Task lastCountdownTask = countdownTask; - countdownTask = start(); - - async Task start() - { - await lastCountdownTask; - - Schedule(() => - { - if (stopSource.IsCancellationRequested) - return; - - Room.Countdown = countdown; - MatchEvent(new CountdownChangedEvent { Countdown = countdown }); - }); - - try - { - using (var cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(stopSource.Token, skipSource.Token)) - await Task.Delay(matchCountdownRequest.Duration, cancellationSource.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Clients need to be notified of cancellations in the following code. - } - - Schedule(() => - { - if (Room.Countdown != countdown) - return; - - Room.Countdown = null; - MatchEvent(new CountdownChangedEvent { Countdown = null }); - - if (stopSource.IsCancellationRequested) - return; - - StartMatch().WaitSafely(); - }); - } - + startCountdown(new MatchStartCountdown { TimeRemaining = matchCountdownRequest.Duration }, StartMatch); break; case StopCountdownRequest _: - countdownStopSource?.Cancel(); - - Room.Countdown = null; - await MatchEvent(new CountdownChangedEvent { Countdown = Room.Countdown }); + stopCountdown(); break; case ChangeTeamRequest changeTeam: @@ -393,6 +351,62 @@ namespace osu.Game.Tests.Visual.Multiplayer } } + private void startCountdown(MultiplayerCountdown countdown, Func continuation) + { + Debug.Assert(Room != null); + Debug.Assert(ThreadSafety.IsUpdateThread); + + stopCountdown(); + + // Note that this will leak CTSs, however this is a test method and we haven't noticed foregoing disposal of non-linked CTSs to be detrimental. + // If necessary, this can be moved into the final schedule below, and the class-level fields be nulled out accordingly. + var stopSource = countdownStopSource = new CancellationTokenSource(); + var skipSource = countdownSkipSource = new CancellationTokenSource(); + + Task lastCountdownTask = countdownTask; + countdownTask = start(); + + async Task start() + { + await lastCountdownTask; + + Schedule(() => + { + if (stopSource.IsCancellationRequested) + return; + + Room.Countdown = countdown; + MatchEvent(new CountdownChangedEvent { Countdown = countdown }); + }); + + try + { + using (var cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(stopSource.Token, skipSource.Token)) + await Task.Delay(countdown.TimeRemaining, cancellationSource.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Clients need to be notified of cancellations in the following code. + } + + Schedule(() => + { + if (Room.Countdown != countdown) + return; + + Room.Countdown = null; + MatchEvent(new CountdownChangedEvent { Countdown = null }); + + if (stopSource.IsCancellationRequested) + return; + + continuation().WaitSafely(); + }); + } + } + + private void stopCountdown() => countdownStopSource?.Cancel(); + public override Task StartMatch() { Debug.Assert(Room != null); @@ -427,6 +441,7 @@ namespace osu.Game.Tests.Visual.Multiplayer await addItem(item).ConfigureAwait(false); await updateCurrentItem(Room).ConfigureAwait(false); + updateRoomStateIfRequired(); } public override Task AddPlaylistItem(MultiplayerPlaylistItem item) => AddUserPlaylistItem(api.LocalUser.Value.OnlineID, item); @@ -483,6 +498,7 @@ namespace osu.Game.Tests.Visual.Multiplayer await ((IMultiplayerClient)this).PlaylistItemRemoved(playlistItemId).ConfigureAwait(false); await updateCurrentItem(Room).ConfigureAwait(false); + updateRoomStateIfRequired(); } public override Task RemovePlaylistItem(long playlistItemId) => RemoveUserPlaylistItem(api.LocalUser.Value.OnlineID, playlistItemId);