using System; using System.Collections.Generic; using Unity.Netcode; using Unity.Services.Authentication; using Unity.Services.Core; using Unity.Services.Multiplayer; using UnityEngine; /// /// 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 /StartClient 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 /// 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 , 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. /// [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 _browse = new List(); 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(); _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(); } }