Sweep over 48 files (Editor/ excluded): remove comments that restate the code, condense class summaries, fix three stale doc statements (HsvColorPicker alpha bar, PaintHud layoutPrefab, MasterVolume FallbackDefault). Inline two single-use wrappers (IsPointerOverUi, BuildModes), drop an unused using, replace a magic literal with the existing ParallaxBase constant, and convert ~10 UnityEngine.Object null checks to the implicit-bool convention. No serialized fields, signatures, RPC/NetworkVariable names, defaults, or #if blocks touched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
306 lines
13 KiB
C#
306 lines
13 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Unity.Netcode;
|
|
using Unity.Services.Authentication;
|
|
using Unity.Services.Core;
|
|
using Unity.Services.Multiplayer;
|
|
using UnityEngine;
|
|
|
|
/// <summary>
|
|
/// Online hosting/joining over Unity Gaming Services (unified Multiplayer "Sessions" API: Relay + Lobby +
|
|
/// anonymous Authentication behind single calls). The Sessions API configures and starts the NGO transport
|
|
/// itself, so we do NOT call <see cref="NetworkManager.StartHost"/>/<c>StartClient</c> here -
|
|
/// CreateSessionAsync starts the host, JoinSession* starts the client; the usual NetworkManager callbacks
|
|
/// then fire exactly as for loopback hosting, so MatchManager, spawning and authority are unaffected.
|
|
/// Note the UGS "session" (the public server-list entry) is distinct from the in-game "Lobby" scene.
|
|
///
|
|
/// Logic layer only, surfaced through the <see cref="Changed"/>/<see cref="SessionsChanged"/> events; the
|
|
/// uGUI browser driving it is <see cref="OnlineSessionBrowserUI"/>. Requires the project to be linked to a
|
|
/// UGS cloud project (Project Settings > Services) with Relay, Lobby and Authentication (Anonymous)
|
|
/// enabled; otherwise sign-in fails with a clear status message.
|
|
/// </summary>
|
|
[RequireComponent(typeof(NetworkManager))]
|
|
[DisallowMultipleComponent]
|
|
public class OnlineSessionManager : MonoBehaviour
|
|
{
|
|
[Tooltip("Maximum players per hosted session (host included).")]
|
|
public int maxPlayers = 8;
|
|
|
|
[Tooltip("Give up on a hosting/joining/sign-in attempt that hangs longer than this many seconds (0 = wait forever).")]
|
|
public float connectTimeoutSeconds = 25f;
|
|
|
|
private NetworkManager _net;
|
|
private ISession _session; // active session (host or client); null when not in one
|
|
private List<ISessionInfo> _sessions = new List<ISessionInfo>();
|
|
private string _hostName = "";
|
|
private string _status = "Idle";
|
|
private bool _busy;
|
|
private bool _leaving; // suppress the defensive leave while an explicit leave is in flight
|
|
|
|
/// <summary>Raised when status, busy state, or the connection state changes, so a bound UI can re-render.
|
|
/// Async continuations resume on the main thread, so handlers run there.</summary>
|
|
public event Action Changed;
|
|
|
|
/// <summary>Raised after the browsable session list is replaced (a refresh completed).</summary>
|
|
public event Action SessionsChanged;
|
|
|
|
public string Status => _status;
|
|
public bool IsBusy => _busy;
|
|
public bool IsConnected => _net && _net.IsListening;
|
|
public IReadOnlyList<ISessionInfo> Sessions => _sessions;
|
|
|
|
private void Awake()
|
|
{
|
|
_net = GetComponent<NetworkManager>();
|
|
_hostName = $"{SystemInfo.deviceName} game";
|
|
}
|
|
|
|
private void OnEnable()
|
|
{
|
|
if (!_net) return;
|
|
_net.OnServerStarted += OnNetworkStarted;
|
|
_net.OnClientStarted += OnNetworkStarted;
|
|
_net.OnServerStopped += OnNetworkStopped;
|
|
_net.OnClientStopped += OnNetworkStopped;
|
|
}
|
|
|
|
private void OnDisable()
|
|
{
|
|
if (!_net) return;
|
|
_net.OnServerStarted -= OnNetworkStarted;
|
|
_net.OnClientStarted -= OnNetworkStarted;
|
|
_net.OnServerStopped -= OnNetworkStopped;
|
|
_net.OnClientStopped -= OnNetworkStopped;
|
|
}
|
|
|
|
private void OnNetworkStarted() => Changed?.Invoke();
|
|
|
|
// The match-end / disconnect / Shutdown paths stop the NetworkManager without telling UGS. Leave the
|
|
// session here too so the Relay allocation and the public lobby entry are released.
|
|
private void OnNetworkStopped(bool _)
|
|
{
|
|
var stale = _session;
|
|
_session = null;
|
|
if (stale != null && !_leaving) LeaveSilently(stale);
|
|
Changed?.Invoke();
|
|
}
|
|
|
|
private async void LeaveSilently(ISession session)
|
|
{
|
|
try { await session.LeaveAsync(); }
|
|
catch (Exception e) { Debug.LogWarning($"[OnlineSession] leave-on-stop failed: {e.Message}"); }
|
|
}
|
|
|
|
// ----- Public API (driven by OnlineSessionBrowserUI) -------------------------------------------------
|
|
|
|
public void Host() => HostAsync();
|
|
public void Refresh() => RefreshAsync();
|
|
public void Join(string sessionId) => JoinAsync(sessionId);
|
|
public void Disconnect() => DisconnectAsync();
|
|
|
|
// ----- Implementation --------------------------------------------------------------------------------
|
|
|
|
private void SetStatus(string status)
|
|
{
|
|
_status = status;
|
|
Changed?.Invoke();
|
|
}
|
|
|
|
private void SetBusy(bool busy)
|
|
{
|
|
_busy = busy;
|
|
Changed?.Invoke();
|
|
}
|
|
|
|
private async Task EnsureSignedInAsync()
|
|
{
|
|
if (UnityServices.State != ServicesInitializationState.Initialized)
|
|
{
|
|
// Anonymous tokens are cached per machine under company/product, NOT per project path - so
|
|
// junction-mirror clones (shared ProjectSettings) would otherwise resolve to one UGS player id
|
|
// and collide on join (409 LobbyConflict). Namespacing the auth cache by a hash of this install's
|
|
// path gives each clone a stable, distinct identity. Harmless in production - separate machines
|
|
// already have separate caches; this only matters for same-machine clone testing.
|
|
var options = new InitializationOptions();
|
|
options.SetProfile(ProfileForThisInstall());
|
|
await Timed(UnityServices.InitializeAsync(options));
|
|
}
|
|
if (!AuthenticationService.Instance.IsSignedIn)
|
|
await Timed(AuthenticationService.Instance.SignInAnonymouslyAsync());
|
|
}
|
|
|
|
// Deterministic, regex-safe (^[a-zA-Z0-9_-]{1,30}$) profile derived from the install path, so the same
|
|
// clone is always the same UGS player across restarts - no random per-launch identity, no orphaned ghost
|
|
// players/lobbies piling up. SHA1 over dataPath -> "mirror_" + 12 hex chars = 19 chars (fits the limit).
|
|
private static string ProfileForThisInstall()
|
|
{
|
|
using (var sha1 = System.Security.Cryptography.SHA1.Create())
|
|
{
|
|
var bytes = sha1.ComputeHash(System.Text.Encoding.UTF8.GetBytes(Application.dataPath));
|
|
var name = new System.Text.StringBuilder("mirror_", 19);
|
|
for (int i = 0; i < 6; i++) name.Append(bytes[i].ToString("x2"));
|
|
return name.ToString();
|
|
}
|
|
}
|
|
|
|
// Shared busy/sign-in/error skeleton around one session operation; the body carries the
|
|
// op-specific statuses and state. ownsConnection = the op starts NGO (host/join), so a timeout that
|
|
// left the transport half-up must tear it down.
|
|
private async Task RunSessionOpAsync(string failLabel, bool ownsConnection, Func<Task> body)
|
|
{
|
|
SetBusy(true);
|
|
try
|
|
{
|
|
SetStatus("Signing in…");
|
|
await EnsureSignedInAsync();
|
|
await body();
|
|
}
|
|
catch (TimeoutException)
|
|
{
|
|
// A create/join can bring the shared transport up before it hangs; drop that partial host so we
|
|
// don't leave the user "connected" with no session. Only for host/join - a refresh may legitimately
|
|
// run while already hosting, and must not shut that host down.
|
|
if (ownsConnection && IsConnected) _net.Shutdown();
|
|
SetStatus($"{failLabel} timed out after {connectTimeoutSeconds:0}s");
|
|
Debug.LogWarning($"[OnlineSession] {failLabel} timed out after {connectTimeoutSeconds:0}s");
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
SetStatus($"{failLabel} failed: {e.Message}");
|
|
Debug.LogException(e);
|
|
}
|
|
finally { SetBusy(false); }
|
|
}
|
|
|
|
// The UGS session calls take no CancellationToken, so a hung host/join/sign-in would otherwise block
|
|
// forever - _busy never clears and the browser UI locks permanently. WhenAny bounds each call. The
|
|
// abandoned op keeps running (hot task, no cooperative cancel), so disposeLate tears down any session
|
|
// that materializes AFTER we reported the timeout - preventing an orphaned Relay allocation / ghost host.
|
|
private async Task<T> Timed<T>(Task<T> op, Func<T, Task> disposeLate = null)
|
|
{
|
|
if (connectTimeoutSeconds <= 0f) return await op;
|
|
using (var timer = new CancellationTokenSource())
|
|
{
|
|
if (await Task.WhenAny(op, Task.Delay(TimeSpan.FromSeconds(connectTimeoutSeconds), timer.Token)) == op)
|
|
{
|
|
timer.Cancel();
|
|
return await op;
|
|
}
|
|
}
|
|
DiscardLate(op, disposeLate);
|
|
throw new TimeoutException($"timed out after {connectTimeoutSeconds:0}s");
|
|
}
|
|
|
|
private async Task Timed(Task op)
|
|
{
|
|
if (connectTimeoutSeconds <= 0f) { await op; return; }
|
|
using (var timer = new CancellationTokenSource())
|
|
{
|
|
if (await Task.WhenAny(op, Task.Delay(TimeSpan.FromSeconds(connectTimeoutSeconds), timer.Token)) == op)
|
|
{
|
|
timer.Cancel();
|
|
await op;
|
|
return;
|
|
}
|
|
}
|
|
Observe(op);
|
|
throw new TimeoutException($"timed out after {connectTimeoutSeconds:0}s");
|
|
}
|
|
|
|
private static async void DiscardLate<T>(Task<T> op, Func<T, Task> disposeLate)
|
|
{
|
|
T result;
|
|
try { result = await op; }
|
|
catch { return; } // op faulted late: nothing was created
|
|
if (disposeLate == null) return;
|
|
try { await disposeLate(result); }
|
|
catch (Exception e) { Debug.LogWarning($"[OnlineSession] late-timeout cleanup failed: {e.Message}"); }
|
|
}
|
|
|
|
// Observe an abandoned task so its result/exception is never left unhandled.
|
|
private static async void Observe(Task op)
|
|
{
|
|
try { await op; } catch { }
|
|
}
|
|
|
|
// Late-cleanup for a timed-out create/join that yields a session anyway. We share ONE NetworkManager, so
|
|
// leaving `late` also tears down the live transport - only reclaim the ghost when the user hasn't since
|
|
// established a tracked session (a successful retry sets _session). Runs on the main thread: no race.
|
|
private Task LeaveIfDisplaced(ISession late)
|
|
=> (late != null && _session == null) ? late.LeaveAsync() : Task.CompletedTask;
|
|
|
|
private async void HostAsync()
|
|
{
|
|
if (_busy || IsConnected) return;
|
|
await RunSessionOpAsync("Host", true, async () =>
|
|
{
|
|
SetStatus("Creating session…");
|
|
var options = new SessionOptions
|
|
{
|
|
Name = string.IsNullOrWhiteSpace(_hostName) ? "Chameleon game" : _hostName.Trim(),
|
|
MaxPlayers = Mathf.Max(2, maxPlayers)
|
|
}.WithRelayNetwork();
|
|
_session = await Timed(MultiplayerService.Instance.CreateSessionAsync(options), LeaveIfDisplaced);
|
|
SetStatus($"Hosting \"{_session.Name}\" (code {_session.Code})");
|
|
});
|
|
}
|
|
|
|
private async void RefreshAsync()
|
|
{
|
|
if (_busy) return;
|
|
await RunSessionOpAsync("Refresh", false, async () =>
|
|
{
|
|
SetStatus("Querying sessions…");
|
|
var results = await Timed(MultiplayerService.Instance.QuerySessionsAsync(new QuerySessionsOptions()));
|
|
_sessions = new List<ISessionInfo>(results.Sessions);
|
|
SessionsChanged?.Invoke();
|
|
SetStatus($"{_sessions.Count} session(s) found");
|
|
});
|
|
}
|
|
|
|
private async void JoinAsync(string sessionId)
|
|
{
|
|
if (_busy || IsConnected || string.IsNullOrEmpty(sessionId)) return;
|
|
await RunSessionOpAsync("Join", true, async () =>
|
|
{
|
|
SetStatus("Joining session…");
|
|
_session = await Timed(MultiplayerService.Instance.JoinSessionByIdAsync(sessionId), LeaveIfDisplaced);
|
|
SetStatus($"Joined \"{_session.Name}\"");
|
|
});
|
|
}
|
|
|
|
private async void DisconnectAsync()
|
|
{
|
|
if (_busy) return;
|
|
|
|
// A UGS session leaves cleanly (LeaveAsync also tears down the NGO connection). A plain loopback
|
|
// host/client - if one is ever active without a session - just shuts the transport down.
|
|
if (_session != null)
|
|
{
|
|
SetBusy(true);
|
|
_leaving = true;
|
|
var session = _session;
|
|
_session = null;
|
|
try
|
|
{
|
|
SetStatus("Leaving…");
|
|
await session.LeaveAsync();
|
|
SetStatus("Left session");
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
SetStatus($"Leave failed: {e.Message}");
|
|
Debug.LogException(e);
|
|
}
|
|
finally { _leaving = false; SetBusy(false); }
|
|
}
|
|
else if (IsConnected)
|
|
{
|
|
_net.Shutdown();
|
|
SetStatus("Disconnected");
|
|
}
|
|
}
|
|
}
|