diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs index f46123f35bb7..0945b994cfbe 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs @@ -1205,6 +1205,27 @@ public void TestUserStyleSelectionExitedWhenBeatmapSetChanged() AddUntilStep("style selection screen closed", () => this.ChildrenOfType().SingleOrDefault()?.IsCurrentScreen() != true); } + [Test] + public void TestMaxParticipantsAndSlots() + { + createRoom(() => new Room + { + Name = "Test Room", + Password = "password", + Playlist = + [ + new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID + } + ], + MaxParticipants = 10 + }); + + AddStep("turn max participants off", () => multiplayerClient.ChangeSettings(maxParticipants: null)); + AddStep("turn max participants back on", () => multiplayerClient.ChangeSettings(maxParticipants: 8)); + } + private void enterGameplay() { pressReadyButton(); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs index cc3d1a6a5c66..e0d0efda3262 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Numerics; using NUnit.Framework; +using osu.Framework.Extensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; @@ -55,6 +56,68 @@ public void TestAddUser() AddAssert("two unique panels", () => this.ChildrenOfType().Select(p => p.Current.Value).Distinct().Count() == 2); } + [Test] + public void TestSlots() + { + setUpList(); + AddAssert("one unique panel", () => this.ChildrenOfType().Select(p => p.Current.Value).Distinct().Count() == 1); + + AddStep("add user", () => MultiplayerClient.AddUser(new APIUser + { + Id = 3, + Username = "Second", + CoverUrl = TestResources.COVER_IMAGE_3, + })); + + AddAssert("two unique panels", () => this.ChildrenOfType().Select(p => p.Current.Value).Distinct().Count() == 2); + + AddStep("introduce slots", () => MultiplayerClient.ChangeMatchRoomState(new StandardMatchRoomState + { + Slots = [null, 3, null, null, 1001, null, null] + }).WaitSafely()); + + AddStep("click first slot", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("slots changed", () => ((StandardMatchRoomState)MultiplayerClient.ClientRoom!.MatchState!).Slots, + () => Is.EquivalentTo(new int?[] { 1001, 3, null, null, null, null, null })); + + AddStep("click second slot", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().ElementAt(1)); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("slots not changed", () => ((StandardMatchRoomState)MultiplayerClient.ClientRoom!.MatchState!).Slots, + () => Is.EquivalentTo(new int?[] { 1001, 3, null, null, null, null, null })); + + AddStep("click last slot", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Last()); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("slots changed", () => ((StandardMatchRoomState)MultiplayerClient.ClientRoom!.MatchState!).Slots, + () => Is.EquivalentTo(new int?[] { null, 3, null, null, null, null, 1001 })); + + AddStep("shuffle slots", () => MultiplayerClient.ChangeMatchRoomState(new StandardMatchRoomState + { + Slots = [null, null, 1001, null, null, null, 3] + }).WaitSafely()); + AddStep("remove slots", () => MultiplayerClient.ChangeMatchRoomState(new StandardMatchRoomState + { + Slots = [null, 3, null, 1001] + }).WaitSafely()); + AddStep("add slots", () => MultiplayerClient.ChangeMatchRoomState(new StandardMatchRoomState + { + Slots = [null, null, 3, null, 1001, null] + }).WaitSafely()); + AddStep("turn off slots", () => MultiplayerClient.ChangeMatchRoomState(new StandardMatchRoomState + { + Slots = null + }).WaitSafely()); + } + [Test] public void TestAddReferee() { @@ -86,7 +149,7 @@ public void TestAddUnresolvedUser() AddUntilStep("two unique panels", () => this.ChildrenOfType().Select(p => p.Current.Value).Distinct().Count() == 2); - AddStep("kick null user", () => this.ChildrenOfType().Single(p => p.Current.Value.User == null) + AddStep("kick null user", () => this.ChildrenOfType().Single(p => p.Current.Value.User?.User == null) .ChildrenOfType().Single().TriggerClick()); AddUntilStep("null user kicked", () => MultiplayerClient.ClientRoom.AsNonNull().Users.Count == 1); @@ -111,7 +174,7 @@ public void TestRemoveUser() AddStep("remove host", () => MultiplayerClient.RemoveUser(API.LocalUser.Value)); - AddAssert("single panel is for second user", () => this.ChildrenOfType().Single().Current.Value.UserID == secondUser?.Id); + AddAssert("single panel is for second user", () => this.ChildrenOfType().Single().Current.Value.User?.UserID == secondUser?.Id); } [Test] @@ -150,7 +213,7 @@ public void TestBeatmapDownloadingStates() AddRepeatStep("increment progress", () => { - float progress = this.ChildrenOfType().Single().Current.Value.BeatmapAvailability.DownloadProgress ?? 0; + float progress = this.ChildrenOfType().Single().Current.Value.User?.BeatmapAvailability.DownloadProgress ?? 0; MultiplayerClient.ChangeBeatmapAvailability(BeatmapAvailability.Downloading(progress + RNG.NextSingle(0.1f))); }, 25); @@ -195,16 +258,16 @@ public void TestCrownChangesStateWhenHostTransferred() })); AddUntilStep("first user crown visible", - () => this.ChildrenOfType().Single(p => p.Current.Value.UserID == 1001).ChildrenOfType().First().Alpha == 1); + () => this.ChildrenOfType().Single(p => p.Current.Value.User?.UserID == 1001).ChildrenOfType().First().Alpha == 1); AddUntilStep("second user crown hidden", - () => this.ChildrenOfType().Single(p => p.Current.Value.UserID == 3).ChildrenOfType().First().Alpha == 0); + () => this.ChildrenOfType().Single(p => p.Current.Value.User?.UserID == 3).ChildrenOfType().First().Alpha == 0); AddStep("make second user host", () => MultiplayerClient.TransferHost(3)); AddUntilStep("first user crown visible", - () => this.ChildrenOfType().Single(p => p.Current.Value.UserID == 1001).ChildrenOfType().First().Alpha == 0); + () => this.ChildrenOfType().Single(p => p.Current.Value.User?.UserID == 1001).ChildrenOfType().First().Alpha == 0); AddUntilStep("second user crown hidden", - () => this.ChildrenOfType().Single(p => p.Current.Value.UserID == 3).ChildrenOfType().First().Alpha == 1); + () => this.ChildrenOfType().Single(p => p.Current.Value.User?.UserID == 3).ChildrenOfType().First().Alpha == 1); } [Test] @@ -221,8 +284,8 @@ public void TestHostGetsPinnedToTop() AddStep("make second user host", () => MultiplayerClient.TransferHost(3)); AddAssert("second user above first", () => { - var first = this.ChildrenOfType().Single(u => u.Current.Value.UserID == 1001); - var second = this.ChildrenOfType().Single(u => u.Current.Value.UserID == 3); + var first = this.ChildrenOfType().Single(u => u.Current.Value.User?.UserID == 1001); + var second = this.ChildrenOfType().Single(u => u.Current.Value.User?.UserID == 3); return second.ScreenSpaceDrawQuad.TopLeft.Y < first.ScreenSpaceDrawQuad.TopLeft.Y; }); } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomPanel.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomPanel.cs index 6665c80a67f9..7857de95e87b 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomPanel.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomPanel.cs @@ -178,6 +178,23 @@ public void TestEnableAndDisablePassword() AddAssert("password icon hidden", () => Precision.AlmostEquals(0, panel.ChildrenOfType().First().Alpha)); } + [Test] + public void TestSetAndUnsetMaxParticipants() + { + RoomPanel panel = null!; + Room room = null!; + + AddStep("create room", () => Child = panel = createLoungeRoom(room = new Room + { + Name = "A room", + Type = MatchType.HeadToHead, + })); + + AddUntilStep("wait for panel load", () => panel.ChildrenOfType().Any()); + AddStep("set max participants", () => room.MaxParticipants = 5); + AddStep("unset max participants", () => room.MaxParticipants = null); + } + [Test] public void TestMultiplayerRooms() { diff --git a/osu.Game/Online/Multiplayer/ChangeSlotRequest.cs b/osu.Game/Online/Multiplayer/ChangeSlotRequest.cs new file mode 100644 index 000000000000..04884081968f --- /dev/null +++ b/osu.Game/Online/Multiplayer/ChangeSlotRequest.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using MessagePack; + +namespace osu.Game.Online.Multiplayer +{ + /// + /// User requests to change their slot in the room. + /// + [MessagePackObject] + public class ChangeSlotRequest : MatchUserRequest + { + /// + /// The zero-based ID of the desired slot. + /// + [Key(0)] + public byte SlotID { get; set; } + } +} diff --git a/osu.Game/Online/Multiplayer/MatchRoomState.cs b/osu.Game/Online/Multiplayer/MatchRoomState.cs index 531395980674..a2b700da7e87 100644 --- a/osu.Game/Online/Multiplayer/MatchRoomState.cs +++ b/osu.Game/Online/Multiplayer/MatchRoomState.cs @@ -18,6 +18,7 @@ namespace osu.Game.Online.Multiplayer [Union(0, typeof(TeamVersusRoomState))] // IMPORTANT: Add rules to SignalRUnionWorkaroundResolver for new derived types. [Union(1, typeof(MatchmakingRoomState))] [Union(2, typeof(RankedPlayRoomState))] + [Union(3, typeof(StandardMatchRoomState))] public abstract class MatchRoomState { } diff --git a/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusRoomState.cs b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusRoomState.cs index d5e30bb2e08b..9d9512622109 100644 --- a/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusRoomState.cs +++ b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusRoomState.cs @@ -7,22 +7,20 @@ namespace osu.Game.Online.Multiplayer.MatchTypes.TeamVersus { [MessagePackObject] - public class TeamVersusRoomState : MatchRoomState + public class TeamVersusRoomState : StandardMatchRoomState { [Key(0)] public List Teams { get; set; } = new List(); - [Key(1)] - public bool Locked { get; set; } - - public static TeamVersusRoomState CreateDefault() => + public static TeamVersusRoomState CreateDefault(byte? maxParticipants = null) => new TeamVersusRoomState { Teams = { new MultiplayerTeam { ID = 0, Name = "Team Red" }, new MultiplayerTeam { ID = 1, Name = "Team Blue" }, - } + }, + Slots = maxParticipants == null ? null : new int?[maxParticipants.Value] }; } } diff --git a/osu.Game/Online/Multiplayer/MatchUserRequest.cs b/osu.Game/Online/Multiplayer/MatchUserRequest.cs index bacc1a7632b4..270c437519a8 100644 --- a/osu.Game/Online/Multiplayer/MatchUserRequest.cs +++ b/osu.Game/Online/Multiplayer/MatchUserRequest.cs @@ -23,6 +23,7 @@ namespace osu.Game.Online.Multiplayer [Union(4, typeof(RankedPlayCardHandReplayRequest))] [Union(5, typeof(SetLockStateRequest))] [Union(6, typeof(RollRequest))] + [Union(7, typeof(ChangeSlotRequest))] public abstract class MatchUserRequest { } diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index b946074f6b47..5f49955544d0 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -403,8 +403,9 @@ await runOnUpdateThreadAsync(() => /// The new queue mode, if any. /// The new auto-start countdown duration, if any. /// The new auto-skip setting. + /// The new participant count limit, if any. public Task ChangeSettings(Optional name = default, Optional password = default, Optional matchType = default, Optional queueMode = default, - Optional autoStartDuration = default, Optional autoSkip = default) + Optional autoStartDuration = default, Optional autoSkip = default, Optional maxParticipants = default) { if (Room == null) throw new InvalidOperationException("Must be joined to a match to change settings."); @@ -416,7 +417,8 @@ public Task ChangeSettings(Optional name = default, Optional pas MatchType = matchType.GetOr(Room.Settings.MatchType), QueueMode = queueMode.GetOr(Room.Settings.QueueMode), AutoStartDuration = autoStartDuration.GetOr(Room.Settings.AutoStartDuration), - AutoSkip = autoSkip.GetOr(Room.Settings.AutoSkip) + AutoSkip = autoSkip.GetOr(Room.Settings.AutoSkip), + MaxParticipants = maxParticipants.GetOr(Room.Settings.MaxParticipants), }); } diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs b/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs index c264ec1eefb6..d78302e080a5 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs @@ -32,6 +32,9 @@ public class MultiplayerRoomSettings : IEquatable [Key(6)] public bool AutoSkip { get; set; } + [Key(7)] + public byte? MaxParticipants { get; set; } + [IgnoreMember] public bool AutoStartEnabled => AutoStartDuration != TimeSpan.Zero; @@ -47,6 +50,7 @@ public MultiplayerRoomSettings(Room room) QueueMode = room.QueueMode; AutoStartDuration = room.AutoStartDuration; AutoSkip = room.AutoSkip; + MaxParticipants = room.MaxParticipants; } public bool Equals(MultiplayerRoomSettings? other) @@ -60,7 +64,8 @@ public bool Equals(MultiplayerRoomSettings? other) && MatchType == other.MatchType && QueueMode == other.QueueMode && AutoStartDuration == other.AutoStartDuration - && AutoSkip == other.AutoSkip; + && AutoSkip == other.AutoSkip + && MaxParticipants == other.MaxParticipants; } public override string ToString() => $"Name:{Name}" @@ -69,6 +74,7 @@ public override string ToString() => $"Name:{Name}" + $" Item:{PlaylistItemId}" + $" Queue:{QueueMode}" + $" Start:{AutoStartDuration}" - + $" AutoSkip:{AutoSkip}"; + + $" AutoSkip:{AutoSkip}" + + $" MaxParticipants:{MaxParticipants?.ToString() ?? "no limit"}"; } } diff --git a/osu.Game/Online/Multiplayer/SetLockStateRequest.cs b/osu.Game/Online/Multiplayer/SetLockStateRequest.cs index 8f1451fdab7a..fd501de2e933 100644 --- a/osu.Game/Online/Multiplayer/SetLockStateRequest.cs +++ b/osu.Game/Online/Multiplayer/SetLockStateRequest.cs @@ -10,14 +10,13 @@ public class SetLockStateRequest : MatchUserRequest { /// /// - /// If , s will not be able to change teams by themselves in the room, + /// If , s will not be able to change teams and slots by themselves in the room, /// only s will be able to change teams for the s. /// /// - /// If , any user can change their team in the room. + /// If , any user can change their team and slot in the room. /// /// - // TODO: mention slots as well when slots are reimplemented [Key(0)] public bool Locked { get; set; } } diff --git a/osu.Game/Online/Multiplayer/StandardMatchRoomState.cs b/osu.Game/Online/Multiplayer/StandardMatchRoomState.cs new file mode 100644 index 000000000000..478c8c120af4 --- /dev/null +++ b/osu.Game/Online/Multiplayer/StandardMatchRoomState.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 MessagePack; + +namespace osu.Game.Online.Multiplayer +{ + [MessagePackObject] + public class StandardMatchRoomState : MatchRoomState + { + /// + /// Whether the room is currently locked. + /// When locked, changes to slots (and teams, in team versus) cannot be performed by anyone but room referees. + /// + [Key(1)] + public bool Locked { get; set; } + + /// + /// The state of slots in the room. + /// Linked to . + /// + /// When is , this property is also . + /// + /// When is not , this property is an array of that length. + /// The items of that array represent either an empty slot (represented by ), + /// or an user occupying that slot (represented by the ID of the relevant user). + /// + /// + /// + [Key(2)] + public int?[]? Slots { get; set; } + + public static StandardMatchRoomState Create(byte? maxParticipants = null) => + new StandardMatchRoomState + { + Slots = maxParticipants == null ? null : new int?[maxParticipants.Value] + }; + } +} diff --git a/osu.Game/Online/Rooms/Room.cs b/osu.Game/Online/Rooms/Room.cs index 948542604270..4c97d56b26f7 100644 --- a/osu.Game/Online/Rooms/Room.cs +++ b/osu.Game/Online/Rooms/Room.cs @@ -127,7 +127,7 @@ public DateTimeOffset? EndDate /// /// The maximum number of users allowed in the room. /// - public int? MaxParticipants + public byte? MaxParticipants { get; set => SetField(ref field, value); @@ -318,6 +318,7 @@ public Room(MultiplayerRoom room) QueueMode = room.Settings.QueueMode; AutoStartDuration = room.Settings.AutoStartDuration; AutoSkip = room.Settings.AutoSkip; + MaxParticipants = room.Settings.MaxParticipants; Host = room.Host != null ? new APIUser { Id = room.Host.UserID } : null; Playlist = room.Playlist.Select(p => new PlaylistItem(p)).ToArray(); } diff --git a/osu.Game/Online/SignalRWorkaroundTypes.cs b/osu.Game/Online/SignalRWorkaroundTypes.cs index 06e8451205b2..02ca9d098dde 100644 --- a/osu.Game/Online/SignalRWorkaroundTypes.cs +++ b/osu.Game/Online/SignalRWorkaroundTypes.cs @@ -24,6 +24,7 @@ internal static class SignalRWorkaroundTypes internal static readonly IReadOnlyList<(Type derivedType, Type baseType)> BASE_TYPE_MAPPING = new[] { // multiplayer + (typeof(ChangeSlotRequest), typeof(MatchUserRequest)), (typeof(ChangeTeamRequest), typeof(MatchUserRequest)), (typeof(StartMatchCountdownRequest), typeof(MatchUserRequest)), (typeof(StopCountdownRequest), typeof(MatchUserRequest)), @@ -32,6 +33,7 @@ internal static class SignalRWorkaroundTypes (typeof(CountdownStartedEvent), typeof(MatchServerEvent)), (typeof(CountdownStoppedEvent), typeof(MatchServerEvent)), (typeof(RollEvent), typeof(MatchServerEvent)), + (typeof(StandardMatchRoomState), typeof(MatchRoomState)), (typeof(TeamVersusRoomState), typeof(MatchRoomState)), (typeof(TeamVersusUserState), typeof(MatchUserState)), (typeof(MatchStartCountdown), typeof(MultiplayerCountdown)), diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs index 61c77debf7c6..3765a2d3e93e 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs @@ -260,6 +260,7 @@ private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) break; case nameof(Room.ParticipantCount): + case nameof(Room.MaxParticipants): updateRoomParticipantCount(); break; @@ -284,7 +285,7 @@ private void updateRoomHost() private void updateRoomParticipantCount() { updateHiddenUsers(); - totalCount.Text = room.ParticipantCount.ToString(); + totalCount.Text = room.MaxParticipants == null ? room.ParticipantCount.ToString() : $@"{room.ParticipantCount} / {room.MaxParticipants}"; } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs index 9af8c9566bdf..86de3e8d9499 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs @@ -52,14 +52,13 @@ public MultiplayerMatchSettingsOverlay(Room room) protected partial class MatchSettings : CompositeDrawable { - private const float disabled_alpha = 0.2f; - public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; public Action? SettingsApplied; public OsuTextBox NameField = null!; - public OsuTextBox MaxParticipantsField = null!; + private FormSliderBar maximumParticipantsSliderBar = null!; + private FormCheckBox maximumParticipantsCheckbox = null!; public MatchTypePicker TypePicker = null!; public OsuEnumDropdown QueueModeDropdown = null!; public OsuTextBox PasswordTextBox = null!; @@ -221,14 +220,26 @@ private void load(OverlayColourProvider colourProvider, OsuColour colours) Padding = new MarginPadding { Left = FIELD_PADDING / 2 }, Children = new[] { - new Section("Max participants") + new Section("Player count") { - Alpha = disabled_alpha, - Child = MaxParticipantsField = new OsuNumberBox + Children = new Drawable[] { - RelativeSizeAxes = Axes.X, - TabbableContentContainer = this, - ReadOnly = true, + maximumParticipantsCheckbox = new FormCheckBox + { + Caption = "Limited slots", + HintText = "When enabled, total players allowed in a room will be limited. Unlimited when disabled." + }, + maximumParticipantsSliderBar = new FormSliderBar + { + Caption = "Slot count", + RelativeSizeAxes = Axes.X, + Margin = new MarginPadding { Top = 5 }, + Current = new BindableNumber(16) + { + MinValue = 2, + MaxValue = 16, + } + }, }, }, new Section("Password (optional)") @@ -365,6 +376,11 @@ protected override void LoadComplete() updateRoomMaxParticipants(); updateRoomAutoStartDuration(); updateRoomPlaylist(); + + maximumParticipantsCheckbox.Current.BindValueChanged(enabled => + { + maximumParticipantsSliderBar.Alpha = enabled.NewValue ? 1 : 0; + }, true); } private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) @@ -421,7 +437,15 @@ private void updateRoomAutoSkip() => AutoSkipCheckbox.Current.Value = room.AutoSkip; private void updateRoomMaxParticipants() - => MaxParticipantsField.Text = room.MaxParticipants?.ToString(); + { + if (room.MaxParticipants.HasValue) + { + maximumParticipantsCheckbox.Current.Value = true; + maximumParticipantsSliderBar.Current.Value = room.MaxParticipants.Value; + } + else + maximumParticipantsCheckbox.Current.Value = false; + } private void updateRoomAutoStartDuration() => startModeDropdown.Current.Value = (StartMode)room.AutoStartDuration.TotalSeconds; @@ -442,6 +466,8 @@ private void apply() if (!ApplyButton.Enabled.Value) return; + byte? maxParticipants = maximumParticipantsCheckbox.Current.Value ? maximumParticipantsSliderBar.Current.Value : null; + ErrorText.FadeOut(50); Debug.Assert(applyingSettingsOperation == null); @@ -457,7 +483,8 @@ private void apply() matchType: TypePicker.Current.Value, queueMode: QueueModeDropdown.Current.Value, autoStartDuration: TimeSpan.FromSeconds((int)startModeDropdown.Current.Value), - autoSkip: AutoSkipCheckbox.Current.Value) + autoSkip: AutoSkipCheckbox.Current.Value, + maxParticipants: maxParticipants) .ContinueWith(t => Schedule(() => { if (t.IsCompletedSuccessfully) @@ -475,6 +502,7 @@ private void apply() room.AutoStartDuration = TimeSpan.FromSeconds((int)startModeDropdown.Current.Value); room.AutoSkip = AutoSkipCheckbox.Current.Value; room.Playlist = drawablePlaylist.Items.ToArray(); + room.MaxParticipants = maxParticipants; client.CreateRoom(room).ContinueWith(t => Schedule(() => { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs index 65516f9ad1cd..6b4d3cf918ee 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs @@ -21,6 +21,7 @@ using osu.Game.Beatmaps.Drawables; using osu.Game.Database; using osu.Game.Graphics; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Online; @@ -35,17 +36,17 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants { - public partial class ParticipantPanel : PoolableDrawable, IHasContextMenu, IHasCurrentValue + public partial class ParticipantPanel : PoolableDrawable, IHasContextMenu, IHasCurrentValue { public const int HEIGHT = 40; - public Bindable Current + public Bindable Current { get => current.Current; set => current.Current = value; } - private readonly BindableWithCurrent current = new BindableWithCurrent(new MultiplayerRoomUser(-1)); + private readonly BindableWithCurrent current = new BindableWithCurrent(Slot.FromUser(new MultiplayerRoomUser(-1))); [Resolved] private IAPIProvider api { get; set; } = null!; @@ -59,6 +60,7 @@ public Bindable Current private SpriteIcon crown = null!; private UserCoverBackground userCover = null!; + private FillFlowContainer userContent = null!; private UpdateableAvatar userAvatar = null!; private UpdateableFlag userFlag = null!; private OsuSpriteText username = null!; @@ -67,6 +69,7 @@ public Bindable Current private StyleDisplayIcon userStyleDisplay = null!; private ModDisplay userModsDisplay = null!; private StateDisplay userStateDisplay = null!; + private ClickableContainer emptySlotMarker = null!; private IconButton kickButton = null!; @@ -125,7 +128,7 @@ private void load() Width = 0.75f, Colour = ColourInfo.GradientHorizontal(Colour4.White.Opacity(0), Colour4.White.Opacity(0.25f)) }, - new FillFlowContainer + userContent = new FillFlowContainer { RelativeSizeAxes = Axes.Both, Spacing = new Vector2(10), @@ -193,6 +196,18 @@ private void load() Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, Margin = new MarginPadding { Right = 10 }, + }, + emptySlotMarker = new OsuClickableContainer + { + RelativeSizeAxes = Axes.Both, + Child = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Style.Caption1, + Text = "(empty slot)" + }, + Action = moveToSlot, } } }, @@ -202,7 +217,11 @@ private void load() Origin = Anchor.Centre, Alpha = 0, Margin = new MarginPadding(4), - Action = () => client.KickUser(current.Value.UserID).FireAndForget(), + Action = () => + { + if (!current.Value.IsEmpty) + client.KickUser(current.Value.User!.UserID).FireAndForget(); + }, }, }, } @@ -214,7 +233,7 @@ protected override void PrepareForUse() base.PrepareForUse(); client.RoomUpdated += onRoomUpdated; - updateUser(); + Current.BindValueChanged(_ => updateUser(), true); FinishTransforms(true); } @@ -234,18 +253,28 @@ protected override void FreeAfterUse() current.SetDefault(); } + private const double fade_time = 50; + private void updateUser() { - var user = current.Value.User; + userCover.FadeTo(current.Value.IsEmpty ? 0 : 1, fade_time); + userContent.FadeTo(current.Value.IsEmpty ? 0 : 1, fade_time); + emptySlotMarker.Enabled.Value = current.Value.IsEmpty; + emptySlotMarker.FadeTo(current.Value.IsEmpty ? 1 : 0, fade_time); - userCover.User = user; - userAvatar.User = user; - userFlag.CountryCode = user?.CountryCode ?? default; - teamFlagContainer.Child = new UpdateableTeamFlag(user?.Team) + if (!current.Value.IsEmpty) { - Size = new Vector2(40, 20), - }; - username.Text = user?.Username ?? string.Empty; + var user = current.Value.User.User; + + userCover.User = user; + userAvatar.User = user; + userFlag.CountryCode = user?.CountryCode ?? default; + teamFlagContainer.Child = new UpdateableTeamFlag(user?.Team) + { + Size = new Vector2(40, 20), + }; + username.Text = user?.Username ?? string.Empty; + } updateState(); } @@ -257,17 +286,15 @@ private void updateState() if (client.Room == null || client.LocalUser == null) return; - const double fade_time = 50; - - var user = current.Value; + var slot = current.Value; - if (client.Room.GetCurrentItem() is MultiplayerPlaylistItem currentItem) + if (!slot.IsEmpty && client.Room.GetCurrentItem() is MultiplayerPlaylistItem currentItem) { - int userBeatmapId = user.BeatmapId ?? currentItem.BeatmapID; - int userRulesetId = user.RulesetId ?? currentItem.RulesetID; + int userBeatmapId = slot.User.BeatmapId ?? currentItem.BeatmapID; + int userRulesetId = slot.User.RulesetId ?? currentItem.RulesetID; Ruleset? userRuleset = rulesets.GetRuleset(userRulesetId)?.CreateInstance(); - int? currentModeRank = userRuleset == null ? null : user.User?.RulesetsStatistics?.GetValueOrDefault(userRuleset.ShortName)?.GlobalRank; + int? currentModeRank = userRuleset == null ? null : slot.User.User?.RulesetsStatistics?.GetValueOrDefault(userRuleset.ShortName)?.GlobalRank; userRankText.Text = currentModeRank != null ? $"#{currentModeRank.Value:N0}" : string.Empty; if (userBeatmapId == currentItem.BeatmapID && userRulesetId == currentItem.RulesetID) @@ -277,12 +304,12 @@ private void updateState() // If the mods are updated at the end of the frame, the flow container will skip a reflow cycle: https://github.com/ppy/osu-framework/issues/4187 // This looks particularly jarring here, so re-schedule the update to that start of our frame as a fix. - Schedule(() => userModsDisplay.Current.Value = userRuleset == null ? [] : user.Mods.Select(m => m.ToMod(userRuleset)).ToList()); + Schedule(() => userModsDisplay.Current.Value = userRuleset == null ? [] : slot.User.Mods.Select(m => m.ToMod(userRuleset)).ToList()); } - userStateDisplay.UpdateStatus(user); + userStateDisplay.UpdateStatus(current.Value); - if (user.BeatmapAvailability.State == DownloadState.LocallyAvailable && user.State != MultiplayerUserState.Spectating) + if (!slot.IsEmpty && slot.User.BeatmapAvailability.State == DownloadState.LocallyAvailable && slot.User.State != MultiplayerUserState.Spectating) { userModsDisplay.FadeIn(fade_time); userStyleDisplay.FadeIn(fade_time); @@ -293,8 +320,8 @@ private void updateState() userStyleDisplay.FadeOut(fade_time); } - kickButton.Alpha = (client.IsHost || client.IsReferee) && !user.Equals(client.LocalUser) ? 1 : 0; - crown.Alpha = client.Room.Host?.Equals(user) == true ? 1 : 0; + kickButton.Alpha = (client.IsHost || client.IsReferee) && !slot.IsEmpty && !slot.User.Equals(client.LocalUser) ? 1 : 0; + crown.Alpha = !slot.IsEmpty && client.Room.Host?.Equals(slot.User) == true ? 1 : 0; } public MenuItem[]? ContextMenuItems @@ -304,7 +331,15 @@ public MenuItem[]? ContextMenuItems if (client.Room == null) return null; - var user = current.Value; + if (current.Value.IsEmpty) + { + return new MenuItem[] + { + new OsuMenuItem("Move to slot", MenuItemType.Highlighted, moveToSlot) + }; + } + + var user = current.Value.User; // If the local user is targetted. if (user.UserID == api.LocalUser.Value.Id) @@ -337,6 +372,14 @@ public MenuItem[]? ContextMenuItems } } + private void moveToSlot() + { + if (!current.Value.IsEmpty) + return; + + client.SendMatchRequest(new ChangeSlotRequest { SlotID = current.Value.SlotId.Value }).FireAndForget(); + } + public partial class KickButton : IconButton { public KickButton() diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs index 7429fc817ca3..cbd56ab5c23b 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -12,11 +14,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants { - public partial class ParticipantsList : VirtualisedListContainer + public partial class ParticipantsList : VirtualisedListContainer { - private BindableList participants => RowData; + private BindableList slots => RowData; - private MultiplayerRoomUser? currentHost; + private Slot? currentHost; [Resolved] private MultiplayerClient client { get; set; } = null!; @@ -44,38 +46,66 @@ protected override void LoadComplete() private void updateState() { if (client.Room == null) - participants.Clear(); - else { - // Remove panels for users no longer in the room. - for (int i = participants.Count - 1; i >= 0; i--) + slots.Clear(); + return; + } + + // pathway for handling rooms with participant count limit and slots + if (client.Room.MatchState is StandardMatchRoomState standardMatchRoomState && standardMatchRoomState.Slots is int?[] slotUserIds) + { + // reset host tracking - in slots mode the host's position is decided solely by their slot + // the reset has the side benefit of getting the host pinned to top of list again if slots are turned off (see logic lower down). + currentHost = null; + + if (slots.Count > slotUserIds.Length) + slots.RemoveRange(slotUserIds.Length, slots.Count - slotUserIds.Length); + + for (byte i = 0; i < slotUserIds.Length; ++i) { - var participant = participants[i]; + var participant = slotUserIds[i] == null ? Slot.Empty(i) : Slot.FromUser(client.Room.Users.Single(u => u.UserID == slotUserIds[i])); - // Note that we *must* use reference equality here, as this call is scheduled and a user may have left and joined since it was last run. - if (client.Room.Users.All(u => !ReferenceEquals(participant, u))) - participants.RemoveAt(i); + if (i >= slots.Count) + slots.Add(participant); + if (!participant.Equals(slots[i])) + slots[i] = participant; } - // Add panels for all users new to the room. - foreach (var user in client.Room.Users.Except(participants)) - participants.Add(user); + return; + } + + // Remove panels for empty slots & users no longer in the room. + for (int i = slots.Count - 1; i >= 0; i--) + { + var slot = slots[i]; + + // Note that we *must* use reference equality here, as this call is scheduled and a user may have left and joined since it was last run. + if (slot.IsEmpty || client.Room.Users.All(u => !ReferenceEquals(slot.User, u))) + slots.RemoveAt(i); + } + + // This assertion guarantees that all subsequent accesses to `User` of any `Slot` is safe. + // Unfortunately static analysis is not smart enough to pick this up, so there'll be a lot of `.AsNonNull()` lower down. + Debug.Assert(slots.All(p => !p.IsEmpty)); + + // Add panels for all users new to the room. + foreach (var user in client.Room.Users.Except(slots.Select(u => u.User.AsNonNull()))) + slots.Add(Slot.FromUser(user)); - if (currentHost == null || !currentHost.Equals(client.Room.Host)) + if (currentHost == null || !currentHost.User.AsNonNull().Equals(client.Room.Host)) + { + currentHost = null; + + // Change position of new host to display above all participants. + if (client.Room.Host != null) { - currentHost = null; + currentHost = slots.SingleOrDefault(u => u.User.AsNonNull().Equals(client.Room.Host)); + int currentHostIndex = currentHost == null ? -1 : slots.IndexOf(currentHost); - // Change position of new host to display above all participants. - if (client.Room.Host != null) + if (currentHostIndex > 0) { - currentHost = participants.SingleOrDefault(u => u.Equals(client.Room.Host)); - int currentHostIndex = participants.IndexOf(client.Room.Host); - - if (currentHostIndex > 0) - { - participants.Move(currentHostIndex, 0); - currentHost = participants[0]; - } + slots.Move(currentHostIndex, 0); + currentHost = slots[0]; } } } @@ -89,4 +119,26 @@ protected override void Dispose(bool isDisposing) client.RoomUpdated -= onRoomUpdated; } } + + public record Slot + { + [MemberNotNullWhen(false, nameof(User))] + [MemberNotNullWhen(true, nameof(SlotId))] + public bool IsEmpty { get; } + + public MultiplayerRoomUser? User { get; } + + public byte? SlotId { get; } + + private Slot(bool isEmpty, MultiplayerRoomUser? user, byte? slotId) + { + IsEmpty = isEmpty; + User = user; + SlotId = slotId; + } + + public static Slot FromUser(MultiplayerRoomUser user) => new Slot(false, user, null); + + public static Slot Empty(byte slotId) => new Slot(true, null, slotId); + } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsListHeader.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsListHeader.cs index cd695a0143f8..1c4692652f47 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsListHeader.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsListHeader.cs @@ -26,7 +26,7 @@ protected override void Update() if (room == null) return; - DetailsText.Value = $"{room.Users.Count}"; + DetailsText.Value = room.Settings.MaxParticipants == null ? $@"{room.Users.Count}" : $@"{room.Users.Count} / {room.Settings.MaxParticipants}"; } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs index 90a63837a720..72250f4dae22 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs @@ -84,12 +84,20 @@ private void load(OsuColour colours) private OsuColour colours = null!; - public void UpdateStatus(MultiplayerRoomUser user) + public void UpdateStatus(Slot slot) { // the only case where the progress bar is used does its own local fade in. // starting by fading out is a sane default. progressBar.FadeOut(fade_time); + + if (slot.IsEmpty) + { + this.FadeOut(fade_time); + return; + } + this.FadeIn(fade_time); + var user = slot.User!; if (user.Role == MultiplayerRoomUserRole.Referee) { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs index 2f77594a7516..1e21daa0b014 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs @@ -21,15 +21,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants { - internal partial class TeamDisplay : CompositeDrawable, IHasCurrentValue + internal partial class TeamDisplay : CompositeDrawable, IHasCurrentValue { - public Bindable Current + public Bindable Current { get => current.Current; set => current.Current = value; } - private readonly BindableWithCurrent current = new BindableWithCurrent(new MultiplayerRoomUser(-1)); + private readonly BindableWithCurrent current = new BindableWithCurrent(Slot.FromUser(new MultiplayerRoomUser(-1))); [Resolved] private OsuColour colours { get; set; } = null!; @@ -115,12 +115,12 @@ private void updateState(bool playSamples) { // we don't have a way of knowing when an individual user's state has updated, so just handle on RoomUpdated for now. - var user = current.Value; - var userRoomState = client.Room?.Users.FirstOrDefault(u => u.Equals(user))?.MatchState; + var slot = current.Value; + var userRoomState = slot.IsEmpty ? null : client.Room?.Users.FirstOrDefault(u => u.Equals(slot.User))?.MatchState; bool roomLocked = client.Room?.MatchState is TeamVersusRoomState { Locked: true }; - if (client.LocalUser?.Equals(user) == true && !roomLocked) + if (!slot.IsEmpty && client.LocalUser?.Equals(slot.User) == true && !roomLocked) { clickableContent.Action = changeTeam; clickableContent.TooltipText = "Change team"; diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs index 46c37aee7f1d..06a00abd57f0 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs @@ -440,7 +440,7 @@ private void apply() room.Name = NameField.Text; room.Availability = AvailabilityPicker.Current.Value; - room.MaxParticipants = int.TryParse(MaxParticipantsField.Text, out int maxParticipants) ? maxParticipants : null; + room.MaxParticipants = !string.IsNullOrWhiteSpace(MaxParticipantsField.Text) && byte.TryParse(MaxParticipantsField.Text, out byte maxParticipants) ? maxParticipants : null; room.MaxAttempts = int.TryParse(MaxAttemptsField.Text, out int maxAttempts) ? maxAttempts : null; room.Duration = DurationField.Current.Value; diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index 17a00b04dde6..4a71f52be25b 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -258,7 +258,8 @@ protected override async Task JoinRoomInternal(long roomId, str MatchType = ServerAPIRoom.Type, Password = password ?? string.Empty, QueueMode = ServerAPIRoom.QueueMode, - AutoStartDuration = ServerAPIRoom.AutoStartDuration + AutoStartDuration = ServerAPIRoom.AutoStartDuration, + MaxParticipants = ServerAPIRoom.MaxParticipants, }, Playlist = ServerAPIRoom.Playlist.Select(item => new MultiplayerPlaylistItem(item)).ToList(), Users = { localUser }, @@ -429,6 +430,21 @@ public async Task SendUserMatchRequest(int userId, MatchUserRequest request) break; + case ChangeSlotRequest changeSlot: + if (ServerRoom.MatchState is not StandardMatchRoomState standardMatchRoomState || standardMatchRoomState.Slots is not int?[] slots) + break; + + byte slotId = changeSlot.SlotID; + if (slotId >= slots.Length || slots[slotId] != null) + break; + + int previousSlotId = Array.IndexOf(slots, LocalUser.UserID); + if (previousSlotId >= 0) + slots[previousSlotId] = null; + slots[slotId] = LocalUser.UserID; + await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(standardMatchRoomState)).ConfigureAwait(false); + break; + case StartMatchCountdownRequest startCountdown: await StartCountdown(new MatchStartCountdown { TimeRemaining = startCountdown.Duration }).ConfigureAwait(false); break; @@ -618,31 +634,38 @@ protected override Task CreateRoomInternal(MultiplayerRoom room private async Task changeMatchType(MatchType type) { Debug.Assert(ServerRoom != null); + int i = 0; switch (type) { case MatchType.HeadToHead: - ServerRoom.MatchState = null; - await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom.MatchState)).ConfigureAwait(false); + var headToHeadRoomState = StandardMatchRoomState.Create(ServerRoom.Settings.MaxParticipants); foreach (var user in ServerRoom.Users) { + headToHeadRoomState.Slots?[i++] = user.UserID; + user.MatchState = null; await ((IMultiplayerClient)this).MatchUserStateChanged(clone(user.UserID), clone(user.MatchState)).ConfigureAwait(false); } + ServerRoom.MatchState = headToHeadRoomState; + await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom.MatchState)).ConfigureAwait(false); break; case MatchType.TeamVersus: - ServerRoom.MatchState = TeamVersusRoomState.CreateDefault(); - await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom.MatchState)).ConfigureAwait(false); + var teamVersusRoomState = TeamVersusRoomState.CreateDefault(ServerRoom.Settings.MaxParticipants); foreach (var user in ServerRoom.Users) { + teamVersusRoomState.Slots?[i++] = user.UserID; + user.MatchState = new TeamVersusUserState(); await ((IMultiplayerClient)this).MatchUserStateChanged(clone(user.UserID), clone(user.MatchState)).ConfigureAwait(false); } + ServerRoom.MatchState = teamVersusRoomState; + await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom.MatchState)).ConfigureAwait(false); break; case MatchType.Matchmaking: