mirror of
https://github.com/ppy/osu.git
synced 2024-11-11 14:17:26 +08:00
Rename multiplayer client classes
This commit is contained in:
parent
80a714a9c4
commit
d197a7f6f5
@ -195,7 +195,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
|
||||
private class TestMultiplayer : Screens.OnlinePlay.Multiplayer.Multiplayer
|
||||
{
|
||||
[Cached(typeof(StatefulMultiplayerClient))]
|
||||
[Cached(typeof(MultiplayerClient))]
|
||||
public readonly TestMultiplayerClient Client;
|
||||
|
||||
public TestMultiplayer()
|
||||
|
@ -3,132 +3,621 @@
|
||||
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Extensions.ObjectExtensions;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Logging;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.Online.API;
|
||||
using osu.Game.Online.API.Requests;
|
||||
using osu.Game.Online.Rooms;
|
||||
using osu.Game.Online.Rooms.RoomStatuses;
|
||||
using osu.Game.Rulesets;
|
||||
using osu.Game.Rulesets.Mods;
|
||||
using osu.Game.Users;
|
||||
using osu.Game.Utils;
|
||||
|
||||
namespace osu.Game.Online.Multiplayer
|
||||
{
|
||||
public class MultiplayerClient : StatefulMultiplayerClient
|
||||
public abstract class MultiplayerClient : Component, IMultiplayerClient, IMultiplayerRoomServer
|
||||
{
|
||||
private readonly string endpoint;
|
||||
/// <summary>
|
||||
/// Invoked when any change occurs to the multiplayer room.
|
||||
/// </summary>
|
||||
public event Action? RoomUpdated;
|
||||
|
||||
private IHubClientConnector? connector;
|
||||
/// <summary>
|
||||
/// Invoked when the multiplayer server requests the current beatmap to be loaded into play.
|
||||
/// </summary>
|
||||
public event Action? LoadRequested;
|
||||
|
||||
public override IBindable<bool> IsConnected { get; } = new BindableBool();
|
||||
/// <summary>
|
||||
/// Invoked when the multiplayer server requests gameplay to be started.
|
||||
/// </summary>
|
||||
public event Action? MatchStarted;
|
||||
|
||||
private HubConnection? connection => connector?.CurrentConnection;
|
||||
/// <summary>
|
||||
/// Invoked when the multiplayer server has finished collating results.
|
||||
/// </summary>
|
||||
public event Action? ResultsReady;
|
||||
|
||||
public MultiplayerClient(EndpointConfiguration endpoints)
|
||||
/// <summary>
|
||||
/// Whether the <see cref="MultiplayerClient"/> is currently connected.
|
||||
/// This is NOT thread safe and usage should be scheduled.
|
||||
/// </summary>
|
||||
public abstract IBindable<bool> IsConnected { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The joined <see cref="MultiplayerRoom"/>.
|
||||
/// </summary>
|
||||
public MultiplayerRoom? Room { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// The users in the joined <see cref="Room"/> which are participating in the current gameplay loop.
|
||||
/// </summary>
|
||||
public readonly BindableList<int> CurrentMatchPlayingUserIds = new BindableList<int>();
|
||||
|
||||
public readonly Bindable<PlaylistItem?> CurrentMatchPlayingItem = new Bindable<PlaylistItem?>();
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="MultiplayerRoomUser"/> corresponding to the local player, if available.
|
||||
/// </summary>
|
||||
public MultiplayerRoomUser? LocalUser => Room?.Users.SingleOrDefault(u => u.User?.Id == API.LocalUser.Value.Id);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the <see cref="LocalUser"/> is the host in <see cref="Room"/>.
|
||||
/// </summary>
|
||||
public bool IsHost
|
||||
{
|
||||
endpoint = endpoints.MultiplayerEndpointUrl;
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load(IAPIProvider api)
|
||||
{
|
||||
connector = api.GetHubConnector(nameof(MultiplayerClient), endpoint);
|
||||
|
||||
if (connector != null)
|
||||
get
|
||||
{
|
||||
connector.ConfigureConnection = connection =>
|
||||
{
|
||||
// this is kind of SILLY
|
||||
// https://github.com/dotnet/aspnetcore/issues/15198
|
||||
connection.On<MultiplayerRoomState>(nameof(IMultiplayerClient.RoomStateChanged), ((IMultiplayerClient)this).RoomStateChanged);
|
||||
connection.On<MultiplayerRoomUser>(nameof(IMultiplayerClient.UserJoined), ((IMultiplayerClient)this).UserJoined);
|
||||
connection.On<MultiplayerRoomUser>(nameof(IMultiplayerClient.UserLeft), ((IMultiplayerClient)this).UserLeft);
|
||||
connection.On<int>(nameof(IMultiplayerClient.HostChanged), ((IMultiplayerClient)this).HostChanged);
|
||||
connection.On<MultiplayerRoomSettings>(nameof(IMultiplayerClient.SettingsChanged), ((IMultiplayerClient)this).SettingsChanged);
|
||||
connection.On<int, MultiplayerUserState>(nameof(IMultiplayerClient.UserStateChanged), ((IMultiplayerClient)this).UserStateChanged);
|
||||
connection.On(nameof(IMultiplayerClient.LoadRequested), ((IMultiplayerClient)this).LoadRequested);
|
||||
connection.On(nameof(IMultiplayerClient.MatchStarted), ((IMultiplayerClient)this).MatchStarted);
|
||||
connection.On(nameof(IMultiplayerClient.ResultsReady), ((IMultiplayerClient)this).ResultsReady);
|
||||
connection.On<int, IEnumerable<APIMod>>(nameof(IMultiplayerClient.UserModsChanged), ((IMultiplayerClient)this).UserModsChanged);
|
||||
connection.On<int, BeatmapAvailability>(nameof(IMultiplayerClient.UserBeatmapAvailabilityChanged), ((IMultiplayerClient)this).UserBeatmapAvailabilityChanged);
|
||||
};
|
||||
|
||||
IsConnected.BindTo(connector.IsConnected);
|
||||
var localUser = LocalUser;
|
||||
return localUser != null && Room?.Host != null && localUser.Equals(Room.Host);
|
||||
}
|
||||
}
|
||||
|
||||
protected override Task<MultiplayerRoom> JoinRoom(long roomId)
|
||||
{
|
||||
if (!IsConnected.Value)
|
||||
return Task.FromCanceled<MultiplayerRoom>(new CancellationToken(true));
|
||||
[Resolved]
|
||||
protected IAPIProvider API { get; private set; } = null!;
|
||||
|
||||
return connection.InvokeAsync<MultiplayerRoom>(nameof(IMultiplayerServer.JoinRoom), roomId);
|
||||
[Resolved]
|
||||
protected RulesetStore Rulesets { get; private set; } = null!;
|
||||
|
||||
[Resolved]
|
||||
private UserLookupCache userLookupCache { get; set; } = null!;
|
||||
|
||||
private Room? apiRoom;
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load()
|
||||
{
|
||||
IsConnected.BindValueChanged(connected =>
|
||||
{
|
||||
// clean up local room state on server disconnect.
|
||||
if (!connected.NewValue && Room != null)
|
||||
{
|
||||
Logger.Log("Connection to multiplayer server was lost.", LoggingTarget.Runtime, LogLevel.Important);
|
||||
LeaveRoom();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected override Task LeaveRoomInternal()
|
||||
{
|
||||
if (!IsConnected.Value)
|
||||
return Task.FromCanceled(new CancellationToken(true));
|
||||
private readonly TaskChain joinOrLeaveTaskChain = new TaskChain();
|
||||
private CancellationTokenSource? joinCancellationSource;
|
||||
|
||||
return connection.InvokeAsync(nameof(IMultiplayerServer.LeaveRoom));
|
||||
/// <summary>
|
||||
/// Joins the <see cref="MultiplayerRoom"/> for a given API <see cref="Room"/>.
|
||||
/// </summary>
|
||||
/// <param name="room">The API <see cref="Room"/>.</param>
|
||||
public async Task JoinRoom(Room room)
|
||||
{
|
||||
var cancellationSource = joinCancellationSource = new CancellationTokenSource();
|
||||
|
||||
await joinOrLeaveTaskChain.Add(async () =>
|
||||
{
|
||||
if (Room != null)
|
||||
throw new InvalidOperationException("Cannot join a multiplayer room while already in one.");
|
||||
|
||||
Debug.Assert(room.RoomID.Value != null);
|
||||
|
||||
// Join the server-side room.
|
||||
var joinedRoom = await JoinRoom(room.RoomID.Value.Value).ConfigureAwait(false);
|
||||
Debug.Assert(joinedRoom != null);
|
||||
|
||||
// Populate users.
|
||||
Debug.Assert(joinedRoom.Users != null);
|
||||
await Task.WhenAll(joinedRoom.Users.Select(PopulateUser)).ConfigureAwait(false);
|
||||
|
||||
// Update the stored room (must be done on update thread for thread-safety).
|
||||
await scheduleAsync(() =>
|
||||
{
|
||||
Room = joinedRoom;
|
||||
apiRoom = room;
|
||||
foreach (var user in joinedRoom.Users)
|
||||
updateUserPlayingState(user.UserID, user.State);
|
||||
}, cancellationSource.Token).ConfigureAwait(false);
|
||||
|
||||
// Update room settings.
|
||||
await updateLocalRoomSettings(joinedRoom.Settings, cancellationSource.Token).ConfigureAwait(false);
|
||||
}, cancellationSource.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public override Task TransferHost(int userId)
|
||||
/// <summary>
|
||||
/// Joins the <see cref="MultiplayerRoom"/> with a given ID.
|
||||
/// </summary>
|
||||
/// <param name="roomId">The room ID.</param>
|
||||
/// <returns>The joined <see cref="MultiplayerRoom"/>.</returns>
|
||||
protected abstract Task<MultiplayerRoom> JoinRoom(long roomId);
|
||||
|
||||
public Task LeaveRoom()
|
||||
{
|
||||
if (!IsConnected.Value)
|
||||
// The join may have not completed yet, so certain tasks that either update the room or reference the room should be cancelled.
|
||||
// This includes the setting of Room itself along with the initial update of the room settings on join.
|
||||
joinCancellationSource?.Cancel();
|
||||
|
||||
// Leaving rooms is expected to occur instantaneously whilst the operation is finalised in the background.
|
||||
// However a few members need to be reset immediately to prevent other components from entering invalid states whilst the operation hasn't yet completed.
|
||||
// For example, if a room was left and the user immediately pressed the "create room" button, then the user could be taken into the lobby if the value of Room is not reset in time.
|
||||
var scheduledReset = scheduleAsync(() =>
|
||||
{
|
||||
apiRoom = null;
|
||||
Room = null;
|
||||
CurrentMatchPlayingUserIds.Clear();
|
||||
|
||||
RoomUpdated?.Invoke();
|
||||
});
|
||||
|
||||
return joinOrLeaveTaskChain.Add(async () =>
|
||||
{
|
||||
await scheduledReset.ConfigureAwait(false);
|
||||
await LeaveRoomInternal().ConfigureAwait(false);
|
||||
});
|
||||
}
|
||||
|
||||
protected abstract Task LeaveRoomInternal();
|
||||
|
||||
/// <summary>
|
||||
/// Change the current <see cref="MultiplayerRoom"/> settings.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A room must be joined for this to have any effect.
|
||||
/// </remarks>
|
||||
/// <param name="name">The new room name, if any.</param>
|
||||
/// <param name="item">The new room playlist item, if any.</param>
|
||||
public Task ChangeSettings(Optional<string> name = default, Optional<PlaylistItem> item = default)
|
||||
{
|
||||
if (Room == null)
|
||||
throw new InvalidOperationException("Must be joined to a match to change settings.");
|
||||
|
||||
// A dummy playlist item filled with the current room settings (except mods).
|
||||
var existingPlaylistItem = new PlaylistItem
|
||||
{
|
||||
Beatmap =
|
||||
{
|
||||
Value = new BeatmapInfo
|
||||
{
|
||||
OnlineBeatmapID = Room.Settings.BeatmapID,
|
||||
MD5Hash = Room.Settings.BeatmapChecksum
|
||||
}
|
||||
},
|
||||
RulesetID = Room.Settings.RulesetID
|
||||
};
|
||||
|
||||
return ChangeSettings(new MultiplayerRoomSettings
|
||||
{
|
||||
Name = name.GetOr(Room.Settings.Name),
|
||||
BeatmapID = item.GetOr(existingPlaylistItem).BeatmapID,
|
||||
BeatmapChecksum = item.GetOr(existingPlaylistItem).Beatmap.Value.MD5Hash,
|
||||
RulesetID = item.GetOr(existingPlaylistItem).RulesetID,
|
||||
RequiredMods = item.HasValue ? item.Value.AsNonNull().RequiredMods.Select(m => new APIMod(m)).ToList() : Room.Settings.RequiredMods,
|
||||
AllowedMods = item.HasValue ? item.Value.AsNonNull().AllowedMods.Select(m => new APIMod(m)).ToList() : Room.Settings.AllowedMods,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggles the <see cref="LocalUser"/>'s ready state.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">If a toggle of ready state is not valid at this time.</exception>
|
||||
public async Task ToggleReady()
|
||||
{
|
||||
var localUser = LocalUser;
|
||||
|
||||
if (localUser == null)
|
||||
return;
|
||||
|
||||
switch (localUser.State)
|
||||
{
|
||||
case MultiplayerUserState.Idle:
|
||||
await ChangeState(MultiplayerUserState.Ready).ConfigureAwait(false);
|
||||
return;
|
||||
|
||||
case MultiplayerUserState.Ready:
|
||||
await ChangeState(MultiplayerUserState.Idle).ConfigureAwait(false);
|
||||
return;
|
||||
|
||||
default:
|
||||
throw new InvalidOperationException($"Cannot toggle ready when in {localUser.State}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggles the <see cref="LocalUser"/>'s spectating state.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">If a toggle of the spectating state is not valid at this time.</exception>
|
||||
public async Task ToggleSpectate()
|
||||
{
|
||||
var localUser = LocalUser;
|
||||
|
||||
if (localUser == null)
|
||||
return;
|
||||
|
||||
switch (localUser.State)
|
||||
{
|
||||
case MultiplayerUserState.Idle:
|
||||
case MultiplayerUserState.Ready:
|
||||
await ChangeState(MultiplayerUserState.Spectating).ConfigureAwait(false);
|
||||
return;
|
||||
|
||||
case MultiplayerUserState.Spectating:
|
||||
await ChangeState(MultiplayerUserState.Idle).ConfigureAwait(false);
|
||||
return;
|
||||
|
||||
default:
|
||||
throw new InvalidOperationException($"Cannot toggle spectate when in {localUser.State}");
|
||||
}
|
||||
}
|
||||
|
||||
public abstract Task TransferHost(int userId);
|
||||
|
||||
public abstract Task ChangeSettings(MultiplayerRoomSettings settings);
|
||||
|
||||
public abstract Task ChangeState(MultiplayerUserState newState);
|
||||
|
||||
public abstract Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability);
|
||||
|
||||
/// <summary>
|
||||
/// Change the local user's mods in the currently joined room.
|
||||
/// </summary>
|
||||
/// <param name="newMods">The proposed new mods, excluding any required by the room itself.</param>
|
||||
public Task ChangeUserMods(IEnumerable<Mod> newMods) => ChangeUserMods(newMods.Select(m => new APIMod(m)).ToList());
|
||||
|
||||
public abstract Task ChangeUserMods(IEnumerable<APIMod> newMods);
|
||||
|
||||
public abstract Task StartMatch();
|
||||
|
||||
Task IMultiplayerClient.RoomStateChanged(MultiplayerRoomState state)
|
||||
{
|
||||
if (Room == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
return connection.InvokeAsync(nameof(IMultiplayerServer.TransferHost), userId);
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
Debug.Assert(apiRoom != null);
|
||||
|
||||
Room.State = state;
|
||||
|
||||
switch (state)
|
||||
{
|
||||
case MultiplayerRoomState.Open:
|
||||
apiRoom.Status.Value = new RoomStatusOpen();
|
||||
break;
|
||||
|
||||
case MultiplayerRoomState.Playing:
|
||||
apiRoom.Status.Value = new RoomStatusPlaying();
|
||||
break;
|
||||
|
||||
case MultiplayerRoomState.Closed:
|
||||
apiRoom.Status.Value = new RoomStatusEnded();
|
||||
break;
|
||||
}
|
||||
|
||||
RoomUpdated?.Invoke();
|
||||
}, false);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task ChangeSettings(MultiplayerRoomSettings settings)
|
||||
async Task IMultiplayerClient.UserJoined(MultiplayerRoomUser user)
|
||||
{
|
||||
if (!IsConnected.Value)
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
await PopulateUser(user).ConfigureAwait(false);
|
||||
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
// for sanity, ensure that there can be no duplicate users in the room user list.
|
||||
if (Room.Users.Any(existing => existing.UserID == user.UserID))
|
||||
return;
|
||||
|
||||
Room.Users.Add(user);
|
||||
|
||||
RoomUpdated?.Invoke();
|
||||
}, false);
|
||||
}
|
||||
|
||||
Task IMultiplayerClient.UserLeft(MultiplayerRoomUser user)
|
||||
{
|
||||
if (Room == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeSettings), settings);
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
Room.Users.Remove(user);
|
||||
CurrentMatchPlayingUserIds.Remove(user.UserID);
|
||||
|
||||
RoomUpdated?.Invoke();
|
||||
}, false);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task ChangeState(MultiplayerUserState newState)
|
||||
Task IMultiplayerClient.HostChanged(int userId)
|
||||
{
|
||||
if (!IsConnected.Value)
|
||||
if (Room == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeState), newState);
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
Debug.Assert(apiRoom != null);
|
||||
|
||||
var user = Room.Users.FirstOrDefault(u => u.UserID == userId);
|
||||
|
||||
Room.Host = user;
|
||||
apiRoom.Host.Value = user?.User;
|
||||
|
||||
RoomUpdated?.Invoke();
|
||||
}, false);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability)
|
||||
Task IMultiplayerClient.SettingsChanged(MultiplayerRoomSettings newSettings)
|
||||
{
|
||||
if (!IsConnected.Value)
|
||||
updateLocalRoomSettings(newSettings);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task IMultiplayerClient.UserStateChanged(int userId, MultiplayerUserState state)
|
||||
{
|
||||
if (Room == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeBeatmapAvailability), newBeatmapAvailability);
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
Room.Users.Single(u => u.UserID == userId).State = state;
|
||||
|
||||
updateUserPlayingState(userId, state);
|
||||
|
||||
RoomUpdated?.Invoke();
|
||||
}, false);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task ChangeUserMods(IEnumerable<APIMod> newMods)
|
||||
Task IMultiplayerClient.UserBeatmapAvailabilityChanged(int userId, BeatmapAvailability beatmapAvailability)
|
||||
{
|
||||
if (!IsConnected.Value)
|
||||
if (Room == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeUserMods), newMods);
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
var user = Room?.Users.SingleOrDefault(u => u.UserID == userId);
|
||||
|
||||
// errors here are not critical - beatmap availability state is mostly for display.
|
||||
if (user == null)
|
||||
return;
|
||||
|
||||
user.BeatmapAvailability = beatmapAvailability;
|
||||
|
||||
RoomUpdated?.Invoke();
|
||||
}, false);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task StartMatch()
|
||||
public Task UserModsChanged(int userId, IEnumerable<APIMod> mods)
|
||||
{
|
||||
if (!IsConnected.Value)
|
||||
if (Room == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
return connection.InvokeAsync(nameof(IMultiplayerServer.StartMatch));
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
var user = Room?.Users.SingleOrDefault(u => u.UserID == userId);
|
||||
|
||||
// errors here are not critical - user mods are mostly for display.
|
||||
if (user == null)
|
||||
return;
|
||||
|
||||
user.Mods = mods;
|
||||
|
||||
RoomUpdated?.Invoke();
|
||||
}, false);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task<BeatmapSetInfo> GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default)
|
||||
Task IMultiplayerClient.LoadRequested()
|
||||
{
|
||||
var tcs = new TaskCompletionSource<BeatmapSetInfo>();
|
||||
var req = new GetBeatmapSetRequest(beatmapId, BeatmapSetLookupType.BeatmapId);
|
||||
if (Room == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
req.Success += res =>
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
LoadRequested?.Invoke();
|
||||
}, false);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task IMultiplayerClient.MatchStarted()
|
||||
{
|
||||
if (Room == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
MatchStarted?.Invoke();
|
||||
}, false);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task IMultiplayerClient.ResultsReady()
|
||||
{
|
||||
if (Room == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
ResultsReady?.Invoke();
|
||||
}, false);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populates the <see cref="User"/> for a given <see cref="MultiplayerRoomUser"/>.
|
||||
/// </summary>
|
||||
/// <param name="multiplayerUser">The <see cref="MultiplayerRoomUser"/> to populate.</param>
|
||||
protected async Task PopulateUser(MultiplayerRoomUser multiplayerUser) => multiplayerUser.User ??= await userLookupCache.GetUserAsync(multiplayerUser.UserID).ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the local room settings with the given <see cref="MultiplayerRoomSettings"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This updates both the joined <see cref="MultiplayerRoom"/> and the respective API <see cref="Room"/>.
|
||||
/// </remarks>
|
||||
/// <param name="settings">The new <see cref="MultiplayerRoomSettings"/> to update from.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to cancel the update.</param>
|
||||
private Task updateLocalRoomSettings(MultiplayerRoomSettings settings, CancellationToken cancellationToken = default) => scheduleAsync(() =>
|
||||
{
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
Debug.Assert(apiRoom != null);
|
||||
|
||||
// Update a few properties of the room instantaneously.
|
||||
Room.Settings = settings;
|
||||
apiRoom.Name.Value = Room.Settings.Name;
|
||||
|
||||
// The current item update is delayed until an online beatmap lookup (below) succeeds.
|
||||
// In-order for the client to not display an outdated beatmap, the current item is forcefully cleared here.
|
||||
CurrentMatchPlayingItem.Value = null;
|
||||
|
||||
RoomUpdated?.Invoke();
|
||||
|
||||
GetOnlineBeatmapSet(settings.BeatmapID, cancellationToken).ContinueWith(set => Schedule(() =>
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
updatePlaylist(settings, set.Result);
|
||||
}), TaskContinuationOptions.OnlyOnRanToCompletion);
|
||||
}, cancellationToken);
|
||||
|
||||
private void updatePlaylist(MultiplayerRoomSettings settings, BeatmapSetInfo beatmapSet)
|
||||
{
|
||||
if (Room == null || !Room.Settings.Equals(settings))
|
||||
return;
|
||||
|
||||
Debug.Assert(apiRoom != null);
|
||||
|
||||
var beatmap = beatmapSet.Beatmaps.Single(b => b.OnlineBeatmapID == settings.BeatmapID);
|
||||
beatmap.MD5Hash = settings.BeatmapChecksum;
|
||||
|
||||
var ruleset = Rulesets.GetRuleset(settings.RulesetID).CreateInstance();
|
||||
var mods = settings.RequiredMods.Select(m => m.ToMod(ruleset));
|
||||
var allowedMods = settings.AllowedMods.Select(m => m.ToMod(ruleset));
|
||||
|
||||
// Try to retrieve the existing playlist item from the API room.
|
||||
var playlistItem = apiRoom.Playlist.FirstOrDefault(i => i.ID == settings.PlaylistItemId);
|
||||
|
||||
if (playlistItem != null)
|
||||
updateItem(playlistItem);
|
||||
else
|
||||
{
|
||||
// An existing playlist item does not exist, so append a new one.
|
||||
updateItem(playlistItem = new PlaylistItem());
|
||||
apiRoom.Playlist.Add(playlistItem);
|
||||
}
|
||||
|
||||
CurrentMatchPlayingItem.Value = playlistItem;
|
||||
|
||||
void updateItem(PlaylistItem item)
|
||||
{
|
||||
item.ID = settings.PlaylistItemId;
|
||||
item.Beatmap.Value = beatmap;
|
||||
item.Ruleset.Value = ruleset.RulesetInfo;
|
||||
item.RequiredMods.Clear();
|
||||
item.RequiredMods.AddRange(mods);
|
||||
item.AllowedMods.Clear();
|
||||
item.AllowedMods.AddRange(allowedMods);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a <see cref="BeatmapSetInfo"/> from an online source.
|
||||
/// </summary>
|
||||
/// <param name="beatmapId">The beatmap set ID.</param>
|
||||
/// <param name="cancellationToken">A token to cancel the request.</param>
|
||||
/// <returns>The <see cref="BeatmapSetInfo"/> retrieval task.</returns>
|
||||
protected abstract Task<BeatmapSetInfo> GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// For the provided user ID, update whether the user is included in <see cref="CurrentMatchPlayingUserIds"/>.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user's ID.</param>
|
||||
/// <param name="state">The new state of the user.</param>
|
||||
private void updateUserPlayingState(int userId, MultiplayerUserState state)
|
||||
{
|
||||
bool wasPlaying = CurrentMatchPlayingUserIds.Contains(userId);
|
||||
bool isPlaying = state >= MultiplayerUserState.WaitingForLoad && state <= MultiplayerUserState.FinishedPlay;
|
||||
|
||||
if (isPlaying == wasPlaying)
|
||||
return;
|
||||
|
||||
if (isPlaying)
|
||||
CurrentMatchPlayingUserIds.Add(userId);
|
||||
else
|
||||
CurrentMatchPlayingUserIds.Remove(userId);
|
||||
}
|
||||
|
||||
private Task scheduleAsync(Action action, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
@ -136,20 +625,18 @@ namespace osu.Game.Online.Multiplayer
|
||||
return;
|
||||
}
|
||||
|
||||
tcs.SetResult(res.ToBeatmapSet(Rulesets));
|
||||
};
|
||||
|
||||
req.Failure += e => tcs.SetException(e);
|
||||
|
||||
API.Queue(req);
|
||||
try
|
||||
{
|
||||
action();
|
||||
tcs.SetResult(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
tcs.SetException(ex);
|
||||
}
|
||||
});
|
||||
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
protected override void Dispose(bool isDisposing)
|
||||
{
|
||||
base.Dispose(isDisposing);
|
||||
connector?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
158
osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs
Normal file
158
osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs
Normal file
@ -0,0 +1,158 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
#nullable enable
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Online.API;
|
||||
using osu.Game.Online.API.Requests;
|
||||
using osu.Game.Online.Rooms;
|
||||
|
||||
namespace osu.Game.Online.Multiplayer
|
||||
{
|
||||
/// <summary>
|
||||
/// A <see cref="MultiplayerClient"/> with online connectivity.
|
||||
/// </summary>
|
||||
public class OnlineMultiplayerClient : MultiplayerClient
|
||||
{
|
||||
private readonly string endpoint;
|
||||
|
||||
private IHubClientConnector? connector;
|
||||
|
||||
public override IBindable<bool> IsConnected { get; } = new BindableBool();
|
||||
|
||||
private HubConnection? connection => connector?.CurrentConnection;
|
||||
|
||||
public OnlineMultiplayerClient(EndpointConfiguration endpoints)
|
||||
{
|
||||
endpoint = endpoints.MultiplayerEndpointUrl;
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load(IAPIProvider api)
|
||||
{
|
||||
connector = api.GetHubConnector(nameof(OnlineMultiplayerClient), endpoint);
|
||||
|
||||
if (connector != null)
|
||||
{
|
||||
connector.ConfigureConnection = connection =>
|
||||
{
|
||||
// this is kind of SILLY
|
||||
// https://github.com/dotnet/aspnetcore/issues/15198
|
||||
connection.On<MultiplayerRoomState>(nameof(IMultiplayerClient.RoomStateChanged), ((IMultiplayerClient)this).RoomStateChanged);
|
||||
connection.On<MultiplayerRoomUser>(nameof(IMultiplayerClient.UserJoined), ((IMultiplayerClient)this).UserJoined);
|
||||
connection.On<MultiplayerRoomUser>(nameof(IMultiplayerClient.UserLeft), ((IMultiplayerClient)this).UserLeft);
|
||||
connection.On<int>(nameof(IMultiplayerClient.HostChanged), ((IMultiplayerClient)this).HostChanged);
|
||||
connection.On<MultiplayerRoomSettings>(nameof(IMultiplayerClient.SettingsChanged), ((IMultiplayerClient)this).SettingsChanged);
|
||||
connection.On<int, MultiplayerUserState>(nameof(IMultiplayerClient.UserStateChanged), ((IMultiplayerClient)this).UserStateChanged);
|
||||
connection.On(nameof(IMultiplayerClient.LoadRequested), ((IMultiplayerClient)this).LoadRequested);
|
||||
connection.On(nameof(IMultiplayerClient.MatchStarted), ((IMultiplayerClient)this).MatchStarted);
|
||||
connection.On(nameof(IMultiplayerClient.ResultsReady), ((IMultiplayerClient)this).ResultsReady);
|
||||
connection.On<int, IEnumerable<APIMod>>(nameof(IMultiplayerClient.UserModsChanged), ((IMultiplayerClient)this).UserModsChanged);
|
||||
connection.On<int, BeatmapAvailability>(nameof(IMultiplayerClient.UserBeatmapAvailabilityChanged), ((IMultiplayerClient)this).UserBeatmapAvailabilityChanged);
|
||||
};
|
||||
|
||||
IsConnected.BindTo(connector.IsConnected);
|
||||
}
|
||||
}
|
||||
|
||||
protected override Task<MultiplayerRoom> JoinRoom(long roomId)
|
||||
{
|
||||
if (!IsConnected.Value)
|
||||
return Task.FromCanceled<MultiplayerRoom>(new CancellationToken(true));
|
||||
|
||||
return connection.InvokeAsync<MultiplayerRoom>(nameof(IMultiplayerServer.JoinRoom), roomId);
|
||||
}
|
||||
|
||||
protected override Task LeaveRoomInternal()
|
||||
{
|
||||
if (!IsConnected.Value)
|
||||
return Task.FromCanceled(new CancellationToken(true));
|
||||
|
||||
return connection.InvokeAsync(nameof(IMultiplayerServer.LeaveRoom));
|
||||
}
|
||||
|
||||
public override Task TransferHost(int userId)
|
||||
{
|
||||
if (!IsConnected.Value)
|
||||
return Task.CompletedTask;
|
||||
|
||||
return connection.InvokeAsync(nameof(IMultiplayerServer.TransferHost), userId);
|
||||
}
|
||||
|
||||
public override Task ChangeSettings(MultiplayerRoomSettings settings)
|
||||
{
|
||||
if (!IsConnected.Value)
|
||||
return Task.CompletedTask;
|
||||
|
||||
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeSettings), settings);
|
||||
}
|
||||
|
||||
public override Task ChangeState(MultiplayerUserState newState)
|
||||
{
|
||||
if (!IsConnected.Value)
|
||||
return Task.CompletedTask;
|
||||
|
||||
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeState), newState);
|
||||
}
|
||||
|
||||
public override Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability)
|
||||
{
|
||||
if (!IsConnected.Value)
|
||||
return Task.CompletedTask;
|
||||
|
||||
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeBeatmapAvailability), newBeatmapAvailability);
|
||||
}
|
||||
|
||||
public override Task ChangeUserMods(IEnumerable<APIMod> newMods)
|
||||
{
|
||||
if (!IsConnected.Value)
|
||||
return Task.CompletedTask;
|
||||
|
||||
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeUserMods), newMods);
|
||||
}
|
||||
|
||||
public override Task StartMatch()
|
||||
{
|
||||
if (!IsConnected.Value)
|
||||
return Task.CompletedTask;
|
||||
|
||||
return connection.InvokeAsync(nameof(IMultiplayerServer.StartMatch));
|
||||
}
|
||||
|
||||
protected override Task<BeatmapSetInfo> GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<BeatmapSetInfo>();
|
||||
var req = new GetBeatmapSetRequest(beatmapId, BeatmapSetLookupType.BeatmapId);
|
||||
|
||||
req.Success += res =>
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
tcs.SetCanceled();
|
||||
return;
|
||||
}
|
||||
|
||||
tcs.SetResult(res.ToBeatmapSet(Rulesets));
|
||||
};
|
||||
|
||||
req.Failure += e => tcs.SetException(e);
|
||||
|
||||
API.Queue(req);
|
||||
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
protected override void Dispose(bool isDisposing)
|
||||
{
|
||||
base.Dispose(isDisposing);
|
||||
connector?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
@ -1,642 +0,0 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Bindables;
|
||||
using osu.Framework.Extensions.ObjectExtensions;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Logging;
|
||||
using osu.Game.Beatmaps;
|
||||
using osu.Game.Database;
|
||||
using osu.Game.Online.API;
|
||||
using osu.Game.Online.Rooms;
|
||||
using osu.Game.Online.Rooms.RoomStatuses;
|
||||
using osu.Game.Rulesets;
|
||||
using osu.Game.Rulesets.Mods;
|
||||
using osu.Game.Users;
|
||||
using osu.Game.Utils;
|
||||
|
||||
namespace osu.Game.Online.Multiplayer
|
||||
{
|
||||
public abstract class StatefulMultiplayerClient : Component, IMultiplayerClient, IMultiplayerRoomServer
|
||||
{
|
||||
/// <summary>
|
||||
/// Invoked when any change occurs to the multiplayer room.
|
||||
/// </summary>
|
||||
public event Action? RoomUpdated;
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when the multiplayer server requests the current beatmap to be loaded into play.
|
||||
/// </summary>
|
||||
public event Action? LoadRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when the multiplayer server requests gameplay to be started.
|
||||
/// </summary>
|
||||
public event Action? MatchStarted;
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when the multiplayer server has finished collating results.
|
||||
/// </summary>
|
||||
public event Action? ResultsReady;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the <see cref="StatefulMultiplayerClient"/> is currently connected.
|
||||
/// This is NOT thread safe and usage should be scheduled.
|
||||
/// </summary>
|
||||
public abstract IBindable<bool> IsConnected { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The joined <see cref="MultiplayerRoom"/>.
|
||||
/// </summary>
|
||||
public MultiplayerRoom? Room { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// The users in the joined <see cref="Room"/> which are participating in the current gameplay loop.
|
||||
/// </summary>
|
||||
public readonly BindableList<int> CurrentMatchPlayingUserIds = new BindableList<int>();
|
||||
|
||||
public readonly Bindable<PlaylistItem?> CurrentMatchPlayingItem = new Bindable<PlaylistItem?>();
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="MultiplayerRoomUser"/> corresponding to the local player, if available.
|
||||
/// </summary>
|
||||
public MultiplayerRoomUser? LocalUser => Room?.Users.SingleOrDefault(u => u.User?.Id == API.LocalUser.Value.Id);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the <see cref="LocalUser"/> is the host in <see cref="Room"/>.
|
||||
/// </summary>
|
||||
public bool IsHost
|
||||
{
|
||||
get
|
||||
{
|
||||
var localUser = LocalUser;
|
||||
return localUser != null && Room?.Host != null && localUser.Equals(Room.Host);
|
||||
}
|
||||
}
|
||||
|
||||
[Resolved]
|
||||
protected IAPIProvider API { get; private set; } = null!;
|
||||
|
||||
[Resolved]
|
||||
protected RulesetStore Rulesets { get; private set; } = null!;
|
||||
|
||||
[Resolved]
|
||||
private UserLookupCache userLookupCache { get; set; } = null!;
|
||||
|
||||
private Room? apiRoom;
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load()
|
||||
{
|
||||
IsConnected.BindValueChanged(connected =>
|
||||
{
|
||||
// clean up local room state on server disconnect.
|
||||
if (!connected.NewValue && Room != null)
|
||||
{
|
||||
Logger.Log("Connection to multiplayer server was lost.", LoggingTarget.Runtime, LogLevel.Important);
|
||||
LeaveRoom();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private readonly TaskChain joinOrLeaveTaskChain = new TaskChain();
|
||||
private CancellationTokenSource? joinCancellationSource;
|
||||
|
||||
/// <summary>
|
||||
/// Joins the <see cref="MultiplayerRoom"/> for a given API <see cref="Room"/>.
|
||||
/// </summary>
|
||||
/// <param name="room">The API <see cref="Room"/>.</param>
|
||||
public async Task JoinRoom(Room room)
|
||||
{
|
||||
var cancellationSource = joinCancellationSource = new CancellationTokenSource();
|
||||
|
||||
await joinOrLeaveTaskChain.Add(async () =>
|
||||
{
|
||||
if (Room != null)
|
||||
throw new InvalidOperationException("Cannot join a multiplayer room while already in one.");
|
||||
|
||||
Debug.Assert(room.RoomID.Value != null);
|
||||
|
||||
// Join the server-side room.
|
||||
var joinedRoom = await JoinRoom(room.RoomID.Value.Value).ConfigureAwait(false);
|
||||
Debug.Assert(joinedRoom != null);
|
||||
|
||||
// Populate users.
|
||||
Debug.Assert(joinedRoom.Users != null);
|
||||
await Task.WhenAll(joinedRoom.Users.Select(PopulateUser)).ConfigureAwait(false);
|
||||
|
||||
// Update the stored room (must be done on update thread for thread-safety).
|
||||
await scheduleAsync(() =>
|
||||
{
|
||||
Room = joinedRoom;
|
||||
apiRoom = room;
|
||||
foreach (var user in joinedRoom.Users)
|
||||
updateUserPlayingState(user.UserID, user.State);
|
||||
}, cancellationSource.Token).ConfigureAwait(false);
|
||||
|
||||
// Update room settings.
|
||||
await updateLocalRoomSettings(joinedRoom.Settings, cancellationSource.Token).ConfigureAwait(false);
|
||||
}, cancellationSource.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Joins the <see cref="MultiplayerRoom"/> with a given ID.
|
||||
/// </summary>
|
||||
/// <param name="roomId">The room ID.</param>
|
||||
/// <returns>The joined <see cref="MultiplayerRoom"/>.</returns>
|
||||
protected abstract Task<MultiplayerRoom> JoinRoom(long roomId);
|
||||
|
||||
public Task LeaveRoom()
|
||||
{
|
||||
// The join may have not completed yet, so certain tasks that either update the room or reference the room should be cancelled.
|
||||
// This includes the setting of Room itself along with the initial update of the room settings on join.
|
||||
joinCancellationSource?.Cancel();
|
||||
|
||||
// Leaving rooms is expected to occur instantaneously whilst the operation is finalised in the background.
|
||||
// However a few members need to be reset immediately to prevent other components from entering invalid states whilst the operation hasn't yet completed.
|
||||
// For example, if a room was left and the user immediately pressed the "create room" button, then the user could be taken into the lobby if the value of Room is not reset in time.
|
||||
var scheduledReset = scheduleAsync(() =>
|
||||
{
|
||||
apiRoom = null;
|
||||
Room = null;
|
||||
CurrentMatchPlayingUserIds.Clear();
|
||||
|
||||
RoomUpdated?.Invoke();
|
||||
});
|
||||
|
||||
return joinOrLeaveTaskChain.Add(async () =>
|
||||
{
|
||||
await scheduledReset.ConfigureAwait(false);
|
||||
await LeaveRoomInternal().ConfigureAwait(false);
|
||||
});
|
||||
}
|
||||
|
||||
protected abstract Task LeaveRoomInternal();
|
||||
|
||||
/// <summary>
|
||||
/// Change the current <see cref="MultiplayerRoom"/> settings.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A room must be joined for this to have any effect.
|
||||
/// </remarks>
|
||||
/// <param name="name">The new room name, if any.</param>
|
||||
/// <param name="item">The new room playlist item, if any.</param>
|
||||
public Task ChangeSettings(Optional<string> name = default, Optional<PlaylistItem> item = default)
|
||||
{
|
||||
if (Room == null)
|
||||
throw new InvalidOperationException("Must be joined to a match to change settings.");
|
||||
|
||||
// A dummy playlist item filled with the current room settings (except mods).
|
||||
var existingPlaylistItem = new PlaylistItem
|
||||
{
|
||||
Beatmap =
|
||||
{
|
||||
Value = new BeatmapInfo
|
||||
{
|
||||
OnlineBeatmapID = Room.Settings.BeatmapID,
|
||||
MD5Hash = Room.Settings.BeatmapChecksum
|
||||
}
|
||||
},
|
||||
RulesetID = Room.Settings.RulesetID
|
||||
};
|
||||
|
||||
return ChangeSettings(new MultiplayerRoomSettings
|
||||
{
|
||||
Name = name.GetOr(Room.Settings.Name),
|
||||
BeatmapID = item.GetOr(existingPlaylistItem).BeatmapID,
|
||||
BeatmapChecksum = item.GetOr(existingPlaylistItem).Beatmap.Value.MD5Hash,
|
||||
RulesetID = item.GetOr(existingPlaylistItem).RulesetID,
|
||||
RequiredMods = item.HasValue ? item.Value.AsNonNull().RequiredMods.Select(m => new APIMod(m)).ToList() : Room.Settings.RequiredMods,
|
||||
AllowedMods = item.HasValue ? item.Value.AsNonNull().AllowedMods.Select(m => new APIMod(m)).ToList() : Room.Settings.AllowedMods,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggles the <see cref="LocalUser"/>'s ready state.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">If a toggle of ready state is not valid at this time.</exception>
|
||||
public async Task ToggleReady()
|
||||
{
|
||||
var localUser = LocalUser;
|
||||
|
||||
if (localUser == null)
|
||||
return;
|
||||
|
||||
switch (localUser.State)
|
||||
{
|
||||
case MultiplayerUserState.Idle:
|
||||
await ChangeState(MultiplayerUserState.Ready).ConfigureAwait(false);
|
||||
return;
|
||||
|
||||
case MultiplayerUserState.Ready:
|
||||
await ChangeState(MultiplayerUserState.Idle).ConfigureAwait(false);
|
||||
return;
|
||||
|
||||
default:
|
||||
throw new InvalidOperationException($"Cannot toggle ready when in {localUser.State}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggles the <see cref="LocalUser"/>'s spectating state.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">If a toggle of the spectating state is not valid at this time.</exception>
|
||||
public async Task ToggleSpectate()
|
||||
{
|
||||
var localUser = LocalUser;
|
||||
|
||||
if (localUser == null)
|
||||
return;
|
||||
|
||||
switch (localUser.State)
|
||||
{
|
||||
case MultiplayerUserState.Idle:
|
||||
case MultiplayerUserState.Ready:
|
||||
await ChangeState(MultiplayerUserState.Spectating).ConfigureAwait(false);
|
||||
return;
|
||||
|
||||
case MultiplayerUserState.Spectating:
|
||||
await ChangeState(MultiplayerUserState.Idle).ConfigureAwait(false);
|
||||
return;
|
||||
|
||||
default:
|
||||
throw new InvalidOperationException($"Cannot toggle spectate when in {localUser.State}");
|
||||
}
|
||||
}
|
||||
|
||||
public abstract Task TransferHost(int userId);
|
||||
|
||||
public abstract Task ChangeSettings(MultiplayerRoomSettings settings);
|
||||
|
||||
public abstract Task ChangeState(MultiplayerUserState newState);
|
||||
|
||||
public abstract Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability);
|
||||
|
||||
/// <summary>
|
||||
/// Change the local user's mods in the currently joined room.
|
||||
/// </summary>
|
||||
/// <param name="newMods">The proposed new mods, excluding any required by the room itself.</param>
|
||||
public Task ChangeUserMods(IEnumerable<Mod> newMods) => ChangeUserMods(newMods.Select(m => new APIMod(m)).ToList());
|
||||
|
||||
public abstract Task ChangeUserMods(IEnumerable<APIMod> newMods);
|
||||
|
||||
public abstract Task StartMatch();
|
||||
|
||||
Task IMultiplayerClient.RoomStateChanged(MultiplayerRoomState state)
|
||||
{
|
||||
if (Room == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
Debug.Assert(apiRoom != null);
|
||||
|
||||
Room.State = state;
|
||||
|
||||
switch (state)
|
||||
{
|
||||
case MultiplayerRoomState.Open:
|
||||
apiRoom.Status.Value = new RoomStatusOpen();
|
||||
break;
|
||||
|
||||
case MultiplayerRoomState.Playing:
|
||||
apiRoom.Status.Value = new RoomStatusPlaying();
|
||||
break;
|
||||
|
||||
case MultiplayerRoomState.Closed:
|
||||
apiRoom.Status.Value = new RoomStatusEnded();
|
||||
break;
|
||||
}
|
||||
|
||||
RoomUpdated?.Invoke();
|
||||
}, false);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
async Task IMultiplayerClient.UserJoined(MultiplayerRoomUser user)
|
||||
{
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
await PopulateUser(user).ConfigureAwait(false);
|
||||
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
// for sanity, ensure that there can be no duplicate users in the room user list.
|
||||
if (Room.Users.Any(existing => existing.UserID == user.UserID))
|
||||
return;
|
||||
|
||||
Room.Users.Add(user);
|
||||
|
||||
RoomUpdated?.Invoke();
|
||||
}, false);
|
||||
}
|
||||
|
||||
Task IMultiplayerClient.UserLeft(MultiplayerRoomUser user)
|
||||
{
|
||||
if (Room == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
Room.Users.Remove(user);
|
||||
CurrentMatchPlayingUserIds.Remove(user.UserID);
|
||||
|
||||
RoomUpdated?.Invoke();
|
||||
}, false);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task IMultiplayerClient.HostChanged(int userId)
|
||||
{
|
||||
if (Room == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
Debug.Assert(apiRoom != null);
|
||||
|
||||
var user = Room.Users.FirstOrDefault(u => u.UserID == userId);
|
||||
|
||||
Room.Host = user;
|
||||
apiRoom.Host.Value = user?.User;
|
||||
|
||||
RoomUpdated?.Invoke();
|
||||
}, false);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task IMultiplayerClient.SettingsChanged(MultiplayerRoomSettings newSettings)
|
||||
{
|
||||
updateLocalRoomSettings(newSettings);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task IMultiplayerClient.UserStateChanged(int userId, MultiplayerUserState state)
|
||||
{
|
||||
if (Room == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
Room.Users.Single(u => u.UserID == userId).State = state;
|
||||
|
||||
updateUserPlayingState(userId, state);
|
||||
|
||||
RoomUpdated?.Invoke();
|
||||
}, false);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task IMultiplayerClient.UserBeatmapAvailabilityChanged(int userId, BeatmapAvailability beatmapAvailability)
|
||||
{
|
||||
if (Room == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
var user = Room?.Users.SingleOrDefault(u => u.UserID == userId);
|
||||
|
||||
// errors here are not critical - beatmap availability state is mostly for display.
|
||||
if (user == null)
|
||||
return;
|
||||
|
||||
user.BeatmapAvailability = beatmapAvailability;
|
||||
|
||||
RoomUpdated?.Invoke();
|
||||
}, false);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UserModsChanged(int userId, IEnumerable<APIMod> mods)
|
||||
{
|
||||
if (Room == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
var user = Room?.Users.SingleOrDefault(u => u.UserID == userId);
|
||||
|
||||
// errors here are not critical - user mods are mostly for display.
|
||||
if (user == null)
|
||||
return;
|
||||
|
||||
user.Mods = mods;
|
||||
|
||||
RoomUpdated?.Invoke();
|
||||
}, false);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task IMultiplayerClient.LoadRequested()
|
||||
{
|
||||
if (Room == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
LoadRequested?.Invoke();
|
||||
}, false);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task IMultiplayerClient.MatchStarted()
|
||||
{
|
||||
if (Room == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
MatchStarted?.Invoke();
|
||||
}, false);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task IMultiplayerClient.ResultsReady()
|
||||
{
|
||||
if (Room == null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
ResultsReady?.Invoke();
|
||||
}, false);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populates the <see cref="User"/> for a given <see cref="MultiplayerRoomUser"/>.
|
||||
/// </summary>
|
||||
/// <param name="multiplayerUser">The <see cref="MultiplayerRoomUser"/> to populate.</param>
|
||||
protected async Task PopulateUser(MultiplayerRoomUser multiplayerUser) => multiplayerUser.User ??= await userLookupCache.GetUserAsync(multiplayerUser.UserID).ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the local room settings with the given <see cref="MultiplayerRoomSettings"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This updates both the joined <see cref="MultiplayerRoom"/> and the respective API <see cref="Room"/>.
|
||||
/// </remarks>
|
||||
/// <param name="settings">The new <see cref="MultiplayerRoomSettings"/> to update from.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to cancel the update.</param>
|
||||
private Task updateLocalRoomSettings(MultiplayerRoomSettings settings, CancellationToken cancellationToken = default) => scheduleAsync(() =>
|
||||
{
|
||||
if (Room == null)
|
||||
return;
|
||||
|
||||
Debug.Assert(apiRoom != null);
|
||||
|
||||
// Update a few properties of the room instantaneously.
|
||||
Room.Settings = settings;
|
||||
apiRoom.Name.Value = Room.Settings.Name;
|
||||
|
||||
// The current item update is delayed until an online beatmap lookup (below) succeeds.
|
||||
// In-order for the client to not display an outdated beatmap, the current item is forcefully cleared here.
|
||||
CurrentMatchPlayingItem.Value = null;
|
||||
|
||||
RoomUpdated?.Invoke();
|
||||
|
||||
GetOnlineBeatmapSet(settings.BeatmapID, cancellationToken).ContinueWith(set => Schedule(() =>
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
updatePlaylist(settings, set.Result);
|
||||
}), TaskContinuationOptions.OnlyOnRanToCompletion);
|
||||
}, cancellationToken);
|
||||
|
||||
private void updatePlaylist(MultiplayerRoomSettings settings, BeatmapSetInfo beatmapSet)
|
||||
{
|
||||
if (Room == null || !Room.Settings.Equals(settings))
|
||||
return;
|
||||
|
||||
Debug.Assert(apiRoom != null);
|
||||
|
||||
var beatmap = beatmapSet.Beatmaps.Single(b => b.OnlineBeatmapID == settings.BeatmapID);
|
||||
beatmap.MD5Hash = settings.BeatmapChecksum;
|
||||
|
||||
var ruleset = Rulesets.GetRuleset(settings.RulesetID).CreateInstance();
|
||||
var mods = settings.RequiredMods.Select(m => m.ToMod(ruleset));
|
||||
var allowedMods = settings.AllowedMods.Select(m => m.ToMod(ruleset));
|
||||
|
||||
// Try to retrieve the existing playlist item from the API room.
|
||||
var playlistItem = apiRoom.Playlist.FirstOrDefault(i => i.ID == settings.PlaylistItemId);
|
||||
|
||||
if (playlistItem != null)
|
||||
updateItem(playlistItem);
|
||||
else
|
||||
{
|
||||
// An existing playlist item does not exist, so append a new one.
|
||||
updateItem(playlistItem = new PlaylistItem());
|
||||
apiRoom.Playlist.Add(playlistItem);
|
||||
}
|
||||
|
||||
CurrentMatchPlayingItem.Value = playlistItem;
|
||||
|
||||
void updateItem(PlaylistItem item)
|
||||
{
|
||||
item.ID = settings.PlaylistItemId;
|
||||
item.Beatmap.Value = beatmap;
|
||||
item.Ruleset.Value = ruleset.RulesetInfo;
|
||||
item.RequiredMods.Clear();
|
||||
item.RequiredMods.AddRange(mods);
|
||||
item.AllowedMods.Clear();
|
||||
item.AllowedMods.AddRange(allowedMods);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a <see cref="BeatmapSetInfo"/> from an online source.
|
||||
/// </summary>
|
||||
/// <param name="beatmapId">The beatmap set ID.</param>
|
||||
/// <param name="cancellationToken">A token to cancel the request.</param>
|
||||
/// <returns>The <see cref="BeatmapSetInfo"/> retrieval task.</returns>
|
||||
protected abstract Task<BeatmapSetInfo> GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// For the provided user ID, update whether the user is included in <see cref="CurrentMatchPlayingUserIds"/>.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user's ID.</param>
|
||||
/// <param name="state">The new state of the user.</param>
|
||||
private void updateUserPlayingState(int userId, MultiplayerUserState state)
|
||||
{
|
||||
bool wasPlaying = CurrentMatchPlayingUserIds.Contains(userId);
|
||||
bool isPlaying = state >= MultiplayerUserState.WaitingForLoad && state <= MultiplayerUserState.FinishedPlay;
|
||||
|
||||
if (isPlaying == wasPlaying)
|
||||
return;
|
||||
|
||||
if (isPlaying)
|
||||
CurrentMatchPlayingUserIds.Add(userId);
|
||||
else
|
||||
CurrentMatchPlayingUserIds.Remove(userId);
|
||||
}
|
||||
|
||||
private Task scheduleAsync(Action action, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
|
||||
Scheduler.Add(() =>
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
tcs.SetCanceled();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
action();
|
||||
tcs.SetResult(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
tcs.SetException(ex);
|
||||
}
|
||||
});
|
||||
|
||||
return tcs.Task;
|
||||
}
|
||||
}
|
||||
}
|
@ -86,7 +86,7 @@ namespace osu.Game
|
||||
protected IAPIProvider API;
|
||||
|
||||
private SpectatorStreamingClient spectatorStreaming;
|
||||
private StatefulMultiplayerClient multiplayerClient;
|
||||
private MultiplayerClient multiplayerClient;
|
||||
|
||||
protected MenuCursorContainer MenuCursorContainer;
|
||||
|
||||
@ -241,7 +241,7 @@ namespace osu.Game
|
||||
dependencies.CacheAs(API ??= new APIAccess(LocalConfig, endpoints, VersionHash));
|
||||
|
||||
dependencies.CacheAs(spectatorStreaming = new SpectatorStreamingClient(endpoints));
|
||||
dependencies.CacheAs(multiplayerClient = new MultiplayerClient(endpoints));
|
||||
dependencies.CacheAs(multiplayerClient = new OnlineMultiplayerClient(endpoints));
|
||||
|
||||
var defaultBeatmap = new DummyWorkingBeatmap(Audio, Textures);
|
||||
|
||||
|
@ -14,7 +14,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
|
||||
private IBindable<bool> operationInProgress;
|
||||
|
||||
[Resolved]
|
||||
private StatefulMultiplayerClient multiplayerClient { get; set; }
|
||||
private MultiplayerClient multiplayerClient { get; set; }
|
||||
|
||||
[Resolved]
|
||||
private OngoingOperationTracker ongoingOperationTracker { get; set; }
|
||||
|
@ -59,7 +59,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
|
||||
private IRoomManager manager { get; set; }
|
||||
|
||||
[Resolved]
|
||||
private StatefulMultiplayerClient client { get; set; }
|
||||
private MultiplayerClient client { get; set; }
|
||||
|
||||
[Resolved]
|
||||
private Bindable<Room> currentRoom { get; set; }
|
||||
|
@ -15,7 +15,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
|
||||
public class Multiplayer : OnlinePlayScreen
|
||||
{
|
||||
[Resolved]
|
||||
private StatefulMultiplayerClient client { get; set; }
|
||||
private MultiplayerClient client { get; set; }
|
||||
|
||||
public override void OnResuming(IScreen last)
|
||||
{
|
||||
|
@ -18,7 +18,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
|
||||
protected override RoomSubScreen CreateRoomSubScreen(Room room) => new MultiplayerMatchSubScreen(room);
|
||||
|
||||
[Resolved]
|
||||
private StatefulMultiplayerClient client { get; set; }
|
||||
private MultiplayerClient client { get; set; }
|
||||
|
||||
public override void Open(Room room)
|
||||
{
|
||||
|
@ -17,7 +17,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
|
||||
public class MultiplayerMatchSongSelect : OnlinePlaySongSelect
|
||||
{
|
||||
[Resolved]
|
||||
private StatefulMultiplayerClient client { get; set; }
|
||||
private MultiplayerClient client { get; set; }
|
||||
|
||||
private LoadingLayer loadingLayer;
|
||||
|
||||
|
@ -43,7 +43,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
|
||||
public override string ShortTitle => "room";
|
||||
|
||||
[Resolved]
|
||||
private StatefulMultiplayerClient client { get; set; }
|
||||
private MultiplayerClient client { get; set; }
|
||||
|
||||
[Resolved]
|
||||
private OngoingOperationTracker ongoingOperationTracker { get; set; }
|
||||
|
@ -26,7 +26,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
|
||||
protected override bool CheckModsAllowFailure() => false;
|
||||
|
||||
[Resolved]
|
||||
private StatefulMultiplayerClient client { get; set; }
|
||||
private MultiplayerClient client { get; set; }
|
||||
|
||||
private IBindable<bool> isConnected;
|
||||
|
||||
|
@ -13,7 +13,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
|
||||
protected MultiplayerRoom Room => Client.Room;
|
||||
|
||||
[Resolved]
|
||||
protected StatefulMultiplayerClient Client { get; private set; }
|
||||
protected MultiplayerClient Client { get; private set; }
|
||||
|
||||
protected override void LoadComplete()
|
||||
{
|
||||
|
@ -19,7 +19,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
|
||||
public class MultiplayerRoomManager : RoomManager
|
||||
{
|
||||
[Resolved]
|
||||
private StatefulMultiplayerClient multiplayerClient { get; set; }
|
||||
private MultiplayerClient multiplayerClient { get; set; }
|
||||
|
||||
public readonly Bindable<double> TimeBetweenListingPolls = new Bindable<double>();
|
||||
public readonly Bindable<double> TimeBetweenSelectionPolls = new Bindable<double>();
|
||||
|
@ -10,7 +10,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
|
||||
public class ParticipantsListHeader : OverlinedHeader
|
||||
{
|
||||
[Resolved]
|
||||
private StatefulMultiplayerClient client { get; set; }
|
||||
private MultiplayerClient client { get; set; }
|
||||
|
||||
public ParticipantsListHeader()
|
||||
: base("Participants")
|
||||
|
@ -31,7 +31,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
|
||||
private SpectatorStreamingClient spectatorClient { get; set; }
|
||||
|
||||
[Resolved]
|
||||
private StatefulMultiplayerClient multiplayerClient { get; set; }
|
||||
private MultiplayerClient multiplayerClient { get; set; }
|
||||
|
||||
private readonly PlayerArea[] instances;
|
||||
private MasterGameplayClockContainer masterClockContainer;
|
||||
|
@ -25,7 +25,7 @@ namespace osu.Game.Screens.Play.HUD
|
||||
private SpectatorStreamingClient streamingClient { get; set; }
|
||||
|
||||
[Resolved]
|
||||
private StatefulMultiplayerClient multiplayerClient { get; set; }
|
||||
private MultiplayerClient multiplayerClient { get; set; }
|
||||
|
||||
[Resolved]
|
||||
private UserLookupCache userLookupCache { get; set; }
|
||||
|
@ -19,7 +19,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
public const int PLAYER_1_ID = 55;
|
||||
public const int PLAYER_2_ID = 56;
|
||||
|
||||
[Cached(typeof(StatefulMultiplayerClient))]
|
||||
[Cached(typeof(MultiplayerClient))]
|
||||
public TestMultiplayerClient Client { get; }
|
||||
|
||||
[Cached(typeof(IRoomManager))]
|
||||
|
@ -20,7 +20,7 @@ using osu.Game.Users;
|
||||
|
||||
namespace osu.Game.Tests.Visual.Multiplayer
|
||||
{
|
||||
public class TestMultiplayerClient : StatefulMultiplayerClient
|
||||
public class TestMultiplayerClient : MultiplayerClient
|
||||
{
|
||||
public override IBindable<bool> IsConnected => isConnected;
|
||||
private readonly Bindable<bool> isConnected = new Bindable<bool>(true);
|
||||
|
@ -16,7 +16,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
|
||||
protected override Container<Drawable> Content => content;
|
||||
private readonly Container content;
|
||||
|
||||
[Cached(typeof(StatefulMultiplayerClient))]
|
||||
[Cached(typeof(MultiplayerClient))]
|
||||
public readonly TestMultiplayerClient Client;
|
||||
|
||||
[Cached(typeof(IRoomManager))]
|
||||
|
Loading…
Reference in New Issue
Block a user