1
0
mirror of https://github.com/ppy/osu.git synced 2025-01-12 18:23:04 +08:00

Merge pull request #17402 from smoogipoo/multiplayer-auto-countdown

Add multiplayer auto-start countdown timer
This commit is contained in:
Dean Herbert 2022-03-25 20:31:14 +09:00 committed by GitHub
commit f92a31cd39
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 185 additions and 103 deletions

View File

@ -143,26 +143,6 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddAssert("countdown button enabled", () => this.ChildrenOfType<MultiplayerCountdownButton>().Single().Enabled.Value);
}
[Test]
public void TestSpectatingDuringCountdownWithNoReadyUsersCancelsCountdown()
{
ClickButtonWhenEnabled<MultiplayerReadyButton>();
AddUntilStep("countdown button shown", () => this.ChildrenOfType<MultiplayerCountdownButton>().SingleOrDefault()?.IsPresent == true);
ClickButtonWhenEnabled<MultiplayerCountdownButton>();
AddStep("click the first countdown button", () =>
{
var popoverButton = this.ChildrenOfType<Popover>().Single().ChildrenOfType<OsuButton>().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<MultiplayerReadyButton>();
AddUntilStep("local user became ready", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Ready);
AddUntilStep("countdown button visible", () => this.ChildrenOfType<MultiplayerCountdownButton>().Single().IsPresent);
AddStep("enable auto start", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { AutoStartDuration = TimeSpan.FromMinutes(1) }).WaitSafely());
ClickButtonWhenEnabled<MultiplayerReadyButton>();
AddUntilStep("local user became ready", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Ready);
AddUntilStep("countdown button not visible", () => !this.ChildrenOfType<MultiplayerCountdownButton>().Single().IsPresent);
}
[Test]
public void TestClickingReadyButtonUnReadiesDuringAutoStart()
{
AddStep("enable auto start", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { AutoStartDuration = TimeSpan.FromMinutes(1) }).WaitSafely());
ClickButtonWhenEnabled<MultiplayerReadyButton>();
AddUntilStep("local user became ready", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Ready);
ClickButtonWhenEnabled<MultiplayerReadyButton>();
AddUntilStep("local user became idle", () => MultiplayerClient.LocalUser?.State == MultiplayerUserState.Idle);
}
[Test]
public void TestDeletedBeatmapDisableReady()
{

View File

@ -241,7 +241,9 @@ namespace osu.Game.Online.Multiplayer
/// <param name="password">The new password, if any.</param>
/// <param name="matchType">The type of the match, if any.</param>
/// <param name="queueMode">The new queue mode, if any.</param>
public Task ChangeSettings(Optional<string> name = default, Optional<string> password = default, Optional<MatchType> matchType = default, Optional<QueueMode> queueMode = default)
/// <param name="autoStartDuration">The new auto-start countdown duration, if any.</param>
public Task ChangeSettings(Optional<string> name = default, Optional<string> password = default, Optional<MatchType> matchType = default, Optional<QueueMode> queueMode = default,
Optional<TimeSpan> 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();
}

View File

@ -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}";
}
}

View File

@ -92,6 +92,16 @@ namespace osu.Game.Online.Rooms
set => QueueMode.Value = value;
}
[Cached]
public readonly Bindable<TimeSpan> AutoStartDuration = new Bindable<TimeSpan>();
[JsonProperty("auto_start_duration")]
private ushort autoStartDuration
{
get => (ushort)AutoStartDuration.Value.TotalSeconds;
set => AutoStartDuration.Value = TimeSpan.FromSeconds(value);
}
[Cached]
public readonly Bindable<int?> MaxParticipants = new Bindable<int?>();
@ -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;

View File

@ -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;

View File

@ -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<QueueMode> QueueModeDropdown;
public OsuTextBox PasswordTextBox;
@ -64,6 +65,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
public OsuSpriteText ErrorText;
private OsuEnumDropdown<StartMode> 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<StartMode>
{
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
}
}
}

View File

@ -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;

View File

@ -81,6 +81,9 @@ namespace osu.Game.Screens.OnlinePlay
[Resolved(typeof(Room))]
protected Bindable<QueueMode> QueueMode { get; private set; }
[Resolved(typeof(Room))]
protected Bindable<TimeSpan> AutoStartDuration { get; private set; }
[Resolved(CanBeNull = true)]
private IBindable<PlaylistItem> subScreenSelectedItem { get; set; }

View File

@ -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<Task> 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);