Files
chameleon/Assets/Scripts/OnlineSessionManager.cs
T
adminandClaude Opus 4.8 f8b11be290 Add UGS Sessions online hosting with no port-forwarding
Players can now host and join over the internet without port-forwarding
via Unity Gaming Services Relay + Lobby, exposed through the unified
Multiplayer Services SDK (Sessions API).

- OnlineSessionManager: anonymous auth, CreateSessionAsync (public,
  relay-backed), QuerySessionsAsync server browser, JoinSessionByIdAsync,
  and session leave wired into the existing net-stop teardown.
- Per-install auth profile derived from the project path
  (mirror_<sha1(dataPath)>) so junction-mirror clones get distinct UGS
  player ids, avoiding the same-machine 409 LobbyConflict on join.
- Install com.unity.services.multiplayer 2.2.4 (pulls Relay/Lobby/
  Authentication/Core) and attach OnlineSessionManager to the
  NetworkManager in Main.

Loopback dev flow (NetworkBootstrap H/C/J/K) is left untouched; the
online path is additive. MatchManager and gameplay are unchanged since
Sessions starts the host/client through NGO as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 10:17:28 +02:00

244 lines
9.2 KiB
C#

using System;
using System.Collections.Generic;
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 so players can connect across the internet
/// without port forwarding. Uses the unified Multiplayer Services "Sessions" API, which bundles
/// Relay (NAT-free messaging via Unity's relay servers) + Lobby (the public server list) + anonymous
/// Authentication behind single calls, and configures + starts the NGO transport itself - so we do
/// NOT call <see cref="NetworkManager.StartHost"/>/<c>StartClient</c> here; CreateSessionAsync starts
/// the host and JoinSession* starts the client.
///
/// Terminology: the UGS "session" is the entry in the public server list (network layer). It is a
/// different thing from the in-game "Lobby" scene (the pre-match staging area that <see cref="MatchManager"/>
/// loads after the connection is up). This component does not touch the match flow at all - the usual
/// NetworkManager callbacks fire exactly as they do for loopback hosting, so MatchManager, spawning and
/// authority are unaffected.
///
/// This is a quick IMGUI server browser for clone testing; replace with proper uGUI later. The loopback
/// dev path (see <see cref="NetworkBootstrap"/>, H/C/J/K) stays for fast local testing - online is additive.
/// 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.
/// Attach to the same GameObject as the NetworkManager.
/// </summary>
[RequireComponent(typeof(NetworkManager))]
[DisallowMultipleComponent]
public class OnlineSessionManager : MonoBehaviour
{
[Tooltip("Maximum players per hosted session (host included).")]
public int maxPlayers = 8;
[Tooltip("Draw the on-screen online server-browser panel (below the dev panel).")]
public bool showGui = true;
private NetworkManager _net;
private ISession _session; // active session (host or client); null when not in one
private IList<ISessionInfo> _browse = 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
private Vector2 _scroll;
private void Awake()
{
_net = GetComponent<NetworkManager>();
_hostName = $"{SystemInfo.deviceName} game";
}
private void OnEnable()
{
if (!_net) return;
_net.OnServerStopped += OnNetworkStopped;
_net.OnClientStopped += OnNetworkStopped;
}
private void OnDisable()
{
if (!_net) return;
_net.OnServerStopped -= OnNetworkStopped;
_net.OnClientStopped -= OnNetworkStopped;
}
// 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);
}
private async void LeaveSilently(ISession session)
{
try { await session.LeaveAsync(); }
catch (Exception e) { Debug.LogWarning($"[OnlineSession] leave-on-stop failed: {e.Message}"); }
}
private async System.Threading.Tasks.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 UnityServices.InitializeAsync(options);
}
if (!AuthenticationService.Instance.IsSignedIn)
await 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();
}
}
private async void HostAsync()
{
if (_busy) return;
_busy = true;
try
{
_status = "Signing in…";
await EnsureSignedInAsync();
_status = "Creating session…";
var options = new SessionOptions
{
Name = string.IsNullOrWhiteSpace(_hostName) ? "Chameleon game" : _hostName.Trim(),
MaxPlayers = Mathf.Max(2, maxPlayers)
}.WithRelayNetwork();
_session = await MultiplayerService.Instance.CreateSessionAsync(options);
_status = $"Hosting \"{_session.Name}\" (code {_session.Code})";
}
catch (Exception e)
{
_status = $"Host failed: {e.Message}";
Debug.LogException(e);
}
finally { _busy = false; }
}
private async void RefreshAsync()
{
if (_busy) return;
_busy = true;
try
{
_status = "Signing in…";
await EnsureSignedInAsync();
_status = "Querying sessions…";
var results = await MultiplayerService.Instance.QuerySessionsAsync(new QuerySessionsOptions());
_browse = results.Sessions;
_status = $"{_browse.Count} session(s) found";
}
catch (Exception e)
{
_status = $"Refresh failed: {e.Message}";
Debug.LogException(e);
}
finally { _busy = false; }
}
private async void JoinAsync(string sessionId)
{
if (_busy) return;
_busy = true;
try
{
_status = "Signing in…";
await EnsureSignedInAsync();
_status = "Joining session…";
_session = await MultiplayerService.Instance.JoinSessionByIdAsync(sessionId);
_status = $"Joined \"{_session.Name}\"";
}
catch (Exception e)
{
_status = $"Join failed: {e.Message}";
Debug.LogException(e);
}
finally { _busy = false; }
}
private async void LeaveAsync()
{
if (_busy || _session == null) return;
_busy = true;
_leaving = true;
try
{
_status = "Leaving…";
await _session.LeaveAsync(); // also tears down the NGO connection
_status = "Left session";
}
catch (Exception e)
{
_status = $"Leave failed: {e.Message}";
Debug.LogException(e);
}
finally { _session = null; _leaving = false; _busy = false; }
}
private void OnGUI()
{
if (!showGui || !_net) return;
GUILayout.BeginArea(new Rect(10, 180, 340, 360), GUI.skin.box);
GUILayout.Label("Online (UGS Relay + Lobby)");
GUILayout.Label(_status);
GUI.enabled = !_busy;
if (!_net.IsListening)
{
GUILayout.Label("Session name:");
_hostName = GUILayout.TextField(_hostName);
if (GUILayout.Button("Host Online")) HostAsync();
GUILayout.Space(6);
if (GUILayout.Button("Refresh List")) RefreshAsync();
_scroll = GUILayout.BeginScrollView(_scroll, GUILayout.Height(180));
if (_browse.Count == 0)
GUILayout.Label("No sessions. Click Refresh.");
foreach (var s in _browse)
{
GUILayout.BeginHorizontal();
int taken = Mathf.Max(0, s.MaxPlayers - s.AvailableSlots);
GUILayout.Label($"{s.Name} ({taken}/{s.MaxPlayers})");
if (GUILayout.Button("Join", GUILayout.Width(60))) JoinAsync(s.Id);
GUILayout.EndHorizontal();
}
GUILayout.EndScrollView();
}
else if (_session != null)
{
GUILayout.Label($"In session: {_session.Name}");
if (GUILayout.Button("Leave Session")) LeaveAsync();
}
else
{
GUILayout.Label("Local (loopback) session active.");
}
GUI.enabled = true;
GUILayout.EndArea();
}
}