1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-20 16:00:46 +08:00

Various tournament-minded improvements for multiplayer (#37000)

https://github.com/user-attachments/assets/042311f1-b8c3-479b-a173-13b93fe2c5cc

- `/roll` command is now supported in multiplayer chat for all players
(don't need to be a referee).
- Referees are shown in the room with a special status.
- Tournament mode rooms can be locked, which prevents users from
changing team (and slot, whenever those get brought back). When the room
is locked, the user team indicator shows a padlock icon on top to
indicate the lock state.

---

- Related: https://github.com/ppy/osu-server-spectator/issues/406

Grab-bag because I really don't think splitting this into 3 PRs is very
helpful.

I was going to add an animation for rolling but I had a go on Friday and
my attempt got more or less the same reception as a wet fart so I'm not
trying again. Someone else can if so inclined. I have completely lost
trust in my design senses.

Contrary to stable the roll results are completely ephemeral and go away
when the room is re-entered. This could be both considered good and bad.
Not for me to say.
This commit is contained in:
Bartłomiej Dach
2026-03-20 10:56:45 +01:00
committed by GitHub
Unverified
parent b98cf42bd3
commit 43c53b5a8d
9 changed files with 215 additions and 37 deletions
@@ -17,6 +17,7 @@ using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Chat;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Overlays;
@@ -57,6 +58,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default));
Dependencies.Cache(Realm);
Dependencies.CacheAs<BeatmapStore>(new RealmDetachedBeatmapStore());
Dependencies.Cache(new ChannelManager(API));
beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely();
@@ -439,6 +441,27 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("countdown started", () => MultiplayerClient.ServerRoom!.ActiveCountdowns.Any());
}
[Test]
public void TestRoll()
{
AddStep("set playlist", () =>
{
room.Playlist =
[
new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()).BeatmapInfo)
{
RulesetID = new OsuRuleset().RulesetInfo.OnlineID
}
];
});
ClickButtonWhenEnabled<MultiplayerMatchSettingsOverlay.CreateOrUpdateButton>();
AddStep("set channel", () => room.ChannelId = 1);
AddUntilStep("wait for room join", () => RoomJoined);
AddStep("roll", () => MultiplayerClient.SendMatchRequest(new RollRequest()));
}
[Test]
public void TestSettingsRemainsOpenOnRoomUpdate()
{
@@ -56,6 +56,25 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddAssert("two unique panels", () => this.ChildrenOfType<ParticipantPanel>().Select(p => p.Current.Value).Distinct().Count() == 2);
}
[Test]
public void TestAddReferee()
{
AddAssert("one unique panel", () => this.ChildrenOfType<ParticipantPanel>().Select(p => p.Current.Value).Distinct().Count() == 1);
AddStep("add user", () => MultiplayerClient.AddUser(new MultiplayerRoomUser(3)
{
User = new APIUser
{
Id = 3,
Username = "Second",
CoverUrl = TestResources.COVER_IMAGE_3,
},
Role = MultiplayerRoomUserRole.Referee
}));
AddAssert("two unique panels", () => this.ChildrenOfType<ParticipantPanel>().Select(p => p.Current.Value).Distinct().Count() == 2);
}
[Test]
public void TestAddUnresolvedUser()
{
@@ -116,6 +116,55 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("user still on team 0", () => (multiplayerClient.ClientRoom?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 0);
}
[Test]
public void TestTeamChangesLocked()
{
createRoom(() => new Room
{
Name = "Test Room",
Type = MatchType.TeamVersus,
Playlist =
[
new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo)
{
RulesetID = new OsuRuleset().RulesetInfo.OnlineID
}
]
});
AddUntilStep("user on team 0", () => (multiplayerClient.ClientRoom?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 0);
AddStep("add another user", () => multiplayerClient.AddUser(new APIUser { Username = "otheruser", Id = 44 }));
AddStep("lock room", () =>
{
var roomState = TeamVersusRoomState.CreateDefault();
roomState.Locked = true;
multiplayerClient.ChangeMatchRoomState(roomState).WaitSafely();
});
AddStep("press own button", () =>
{
InputManager.MoveMouseTo(multiplayerComponents.ChildrenOfType<TeamDisplay>().First());
InputManager.Click(MouseButton.Left);
});
AddUntilStep("user on team 0", () => (multiplayerClient.ClientRoom?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 0);
AddStep("unlock room", () =>
{
var roomState = TeamVersusRoomState.CreateDefault();
roomState.Locked = false;
multiplayerClient.ChangeMatchRoomState(roomState).WaitSafely();
});
AddStep("press own button", () =>
{
InputManager.MoveMouseTo(multiplayerComponents.ChildrenOfType<TeamDisplay>().First());
InputManager.Click(MouseButton.Left);
});
AddUntilStep("user on team 1", () => (multiplayerClient.ClientRoom?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 1);
AddStep("press own button again", () => InputManager.Click(MouseButton.Left));
AddUntilStep("user on team 0", () => (multiplayerClient.ClientRoom?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 0);
}
[Test]
public void TestSettingsUpdatedWhenChangingMatchType()
{
+43 -8
View File
@@ -7,6 +7,8 @@ using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using JetBrains.Annotations;
using Microsoft.AspNetCore.SignalR;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
@@ -18,6 +20,7 @@ using osu.Game.Database;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Overlays.Chat.Listing;
namespace osu.Game.Online.Chat
@@ -70,6 +73,10 @@ namespace osu.Game.Online.Chat
[Resolved]
private UserLookupCache users { get; set; }
[Resolved(CanBeNull = true)]
[CanBeNull]
private MultiplayerClient multiplayerClient { get; set; }
private readonly IBindable<APIUser> localUser = new Bindable<APIUser>();
private readonly IBindable<APIState> apiState = new Bindable<APIState>();
private readonly IBindableList<APIRelation> localUserBlocks = new BindableList<APIRelation>();
@@ -264,11 +271,11 @@ namespace osu.Game.Online.Chat
switch (command.ToLowerInvariant())
{
case "np":
case @"np":
AddInternal(new NowPlayingCommand(target));
break;
case "me":
case @"me":
if (string.IsNullOrWhiteSpace(content))
{
target.AddNewMessages(new ErrorMessage("Usage: /me [action]"));
@@ -278,7 +285,7 @@ namespace osu.Game.Online.Chat
PostMessage(content, true, target);
break;
case "join":
case @"join":
if (string.IsNullOrWhiteSpace(content))
{
target.AddNewMessages(new ErrorMessage("Usage: /join [channel]"));
@@ -296,9 +303,9 @@ namespace osu.Game.Online.Chat
JoinChannel(channel);
break;
case "chat":
case "msg":
case "query":
case @"chat":
case @"msg":
case @"query":
if (string.IsNullOrWhiteSpace(content))
{
target.AddNewMessages(new ErrorMessage($"Usage: /{command} [user]"));
@@ -323,8 +330,36 @@ namespace osu.Game.Online.Chat
api.Queue(request);
break;
case "help":
target.AddNewMessages(new InfoMessage("Supported commands: /help, /me [action], /join [channel], /chat [user], /np"));
case @"roll":
if (target.Type != ChannelType.Multiplayer || multiplayerClient?.Room?.ChannelID != target.Id)
{
target.AddNewMessages(new ErrorMessage("Cannot roll when not in a multiplayer room."));
break;
}
uint max = 100;
if (!string.IsNullOrEmpty(content))
{
if (!uint.TryParse(content, out max) || max < 2 || max > 100)
{
target.AddNewMessages(new ErrorMessage("Usage: /roll [2-100]"));
break;
}
}
var rollRequest = new RollRequest { Max = max };
multiplayerClient.SendMatchRequest(rollRequest).FireAndForget(onError: ex =>
{
string message = ex is HubException
? $"Failed to roll: {ex.Message}"
: "Failed to roll.";
target.AddNewMessages(new ErrorMessage(message));
});
break;
case @"help":
target.AddNewMessages(new InfoMessage("Supported commands: /help, /me [action], /join [channel], /chat [user], /np, /roll [2-100] (multiplayer only)"));
break;
default:
@@ -3,6 +3,7 @@
using System;
using System.Linq;
using Humanizer;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
@@ -21,6 +22,7 @@ using osu.Game.Graphics.Cursor;
using osu.Game.Online;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Chat;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Overlays;
@@ -145,6 +147,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
private FillFlowContainer userStyleSection = null!;
private Container<DrawableRoomPlaylistItem> userStyleDisplayContainer = null!;
private MatchChatDisplay chat = null!;
private Sample? sampleStart;
private IDisposable? userModsSelectOverlayRegistration;
@@ -370,7 +374,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
},
new Drawable[]
{
new MatchChatDisplay(room)
chat = new MatchChatDisplay(room)
{
RelativeSizeAxes = Axes.Both
}
@@ -431,6 +435,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
client.UserStyleChanged += onUserStyleChanged;
client.UserModsChanged += onUserModsChanged;
client.LoadRequested += onLoadRequested;
client.MatchEvent += onMatchEvent;
beatmapAvailabilityTracker.Availability.BindValueChanged(onBeatmapAvailabilityChanged, true);
@@ -594,6 +599,18 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
}
}
private void onMatchEvent(MatchServerEvent ev)
{
switch (ev)
{
case RollEvent rollEvent:
var user = client.Room?.Users.SingleOrDefault(u => u.UserID == rollEvent.UserID)?.User ?? new APIUser { Username = "Unknown user" };
string text = $"{user.Username} rolled {"point".ToQuantity(rollEvent.Result)} out of {rollEvent.Max}.";
chat.Channel.Value?.AddNewMessages(new InfoMessage(text));
break;
}
}
/// <summary>
/// Responds to changes in the local user's beatmap availability to notify the server and prepare the gameplay session.
/// </summary>
@@ -282,7 +282,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
Schedule(() => userModsDisplay.Current.Value = userRuleset == null ? Array.Empty<Mod>() : user.Mods.Select(m => m.ToMod(userRuleset)).ToList());
}
userStateDisplay.UpdateStatus(user.State, user.BeatmapAvailability);
userStateDisplay.UpdateStatus(user);
if (user.BeatmapAvailability.State == DownloadState.LocallyAvailable && user.State != MultiplayerUserState.Spectating)
{
@@ -85,17 +85,25 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
private OsuColour colours = null!;
public void UpdateStatus(MultiplayerUserState state, BeatmapAvailability availability)
public void UpdateStatus(MultiplayerRoomUser user)
{
// 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);
this.FadeIn(fade_time);
switch (state)
if (user.Role == MultiplayerRoomUserRole.Referee)
{
text.Text = "referee";
icon.Icon = OsuIcon.EditorWhistle;
icon.Colour = colours.BlueLight;
return;
}
switch (user.State)
{
case MultiplayerUserState.Idle:
showBeatmapAvailability(availability);
showBeatmapAvailability(user.BeatmapAvailability);
break;
case MultiplayerUserState.Ready:
@@ -142,7 +150,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
break;
default:
throw new ArgumentOutOfRangeException(nameof(state), state, null);
throw new ArgumentOutOfRangeException(nameof(user.State), user.State, null);
}
}
@@ -11,6 +11,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
@@ -39,6 +40,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
private OsuClickableContainer clickableContent = null!;
private Drawable box = null!;
private SpriteIcon lockIcon = null!;
private Sample? sampleTeamSwap;
public TeamDisplay()
@@ -57,20 +59,32 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
Alpha = 0,
Scale = new Vector2(0, 1),
RelativeSizeAxes = Axes.Y,
Child = box = new Container
Children = new[]
{
RelativeSizeAxes = Axes.Both,
CornerRadius = 5,
Masking = true,
Scale = new Vector2(0, 1),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Child = new Box
box = new Container
{
Colour = Color4.White,
RelativeSizeAxes = Axes.Both,
CornerRadius = 5,
Masking = true,
Scale = new Vector2(0, 1),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Child = new Box
{
Colour = Color4.White,
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
}
},
lockIcon = new SpriteIcon
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(12),
Icon = FontAwesome.Solid.Lock,
Colour = Colour4.Black,
Alpha = 0,
}
}
};
@@ -83,7 +97,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
base.LoadComplete();
client.RoomUpdated += onRoomUpdated;
current.BindValueChanged(_ => updateUser(), true);
current.BindValueChanged(_ => updateState(false), true);
}
private void changeTeam()
@@ -96,19 +110,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
public int? DisplayedTeam { get; private set; }
private void updateUser()
{
var user = current.Value;
if (client.LocalUser?.Equals(user) == true)
{
clickableContent.Action = changeTeam;
clickableContent.TooltipText = "Change team";
}
updateState(false);
}
private void onRoomUpdated() => Scheduler.AddOnce(() => updateState(true));
private void updateState(bool playSamples)
@@ -118,8 +119,23 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
var user = current.Value;
var userRoomState = client.Room?.Users.FirstOrDefault(u => u.Equals(user))?.MatchState;
bool roomLocked = (client.Room?.MatchState as TeamVersusRoomState)?.Locked == true;
if (client.LocalUser?.Equals(user) == true && !roomLocked)
{
clickableContent.Action = changeTeam;
clickableContent.TooltipText = "Change team";
}
else
{
clickableContent.Action = null;
clickableContent.TooltipText = default;
}
const double duration = 400;
lockIcon.FadeTo(roomLocked ? 0.4f : 0, duration, Easing.OutQuint);
int? newTeam = (userRoomState as TeamVersusUserState)?.TeamID;
if (newTeam == DisplayedTeam)
@@ -10,6 +10,7 @@ using MessagePack;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Online;
using osu.Game.Online.API;
@@ -433,6 +434,16 @@ namespace osu.Game.Tests.Visual.Multiplayer
await StopCountdown(ServerRoom.ActiveCountdowns.First(c => c.ID == stopCountdown.ID)).ConfigureAwait(false);
break;
case RollRequest rollRequest:
int max = (int)(rollRequest.Max ?? 100);
await ((IMultiplayerClient)this).MatchEvent(new RollEvent
{
UserID = userId,
Max = (uint)max,
Result = (uint)RNG.Next(1, max + 1)
}).ConfigureAwait(false);
break;
case MatchmakingAvatarActionRequest avatarAction:
await ((IMultiplayerClient)this).MatchEvent(new MatchmakingAvatarActionEvent
{