From f8b11be2908030888f37e4286327061f25cc4d7f Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Sun, 28 Jun 2026 10:17:28 +0200 Subject: [PATCH] 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_) 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 --- Assets/Resources.meta | 8 + .../UnityPlayerAccountSettings.asset | 19 ++ .../UnityPlayerAccountSettings.asset.meta | 8 + Assets/Scenes/Main.unity | 15 ++ Assets/Scripts/OnlineSessionManager.cs | 243 ++++++++++++++++++ Assets/Scripts/OnlineSessionManager.cs.meta | 2 + Packages/manifest.json | 1 + Packages/packages-lock.json | 91 ++++++- 8 files changed, 385 insertions(+), 2 deletions(-) create mode 100644 Assets/Resources.meta create mode 100644 Assets/Resources/UnityPlayerAccountSettings.asset create mode 100644 Assets/Resources/UnityPlayerAccountSettings.asset.meta create mode 100644 Assets/Scripts/OnlineSessionManager.cs create mode 100644 Assets/Scripts/OnlineSessionManager.cs.meta diff --git a/Assets/Resources.meta b/Assets/Resources.meta new file mode 100644 index 0000000..a2c15a0 --- /dev/null +++ b/Assets/Resources.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4e34b833ea5898745829ede65be71e5b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/UnityPlayerAccountSettings.asset b/Assets/Resources/UnityPlayerAccountSettings.asset new file mode 100644 index 0000000..19d196c --- /dev/null +++ b/Assets/Resources/UnityPlayerAccountSettings.asset @@ -0,0 +1,19 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: b58a3bc647e70774b8c7225eb3b51e7e, type: 3} + m_Name: UnityPlayerAccountSettings + m_EditorClassIdentifier: Unity.Services.Authentication.PlayerAccounts::Unity.Services.Authentication.PlayerAccounts.UnityPlayerAccountSettings + clientId: + scopeMask: 7 + useCustomDeepLinkUri: 0 + customScheme: + customHost: diff --git a/Assets/Resources/UnityPlayerAccountSettings.asset.meta b/Assets/Resources/UnityPlayerAccountSettings.asset.meta new file mode 100644 index 0000000..75785b2 --- /dev/null +++ b/Assets/Resources/UnityPlayerAccountSettings.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: da17151fe2da8bc4a89aebf8f35a9bbe +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scenes/Main.unity b/Assets/Scenes/Main.unity index c082255..d6275cd 100644 --- a/Assets/Scenes/Main.unity +++ b/Assets/Scenes/Main.unity @@ -365,6 +365,7 @@ GameObject: - component: {fileID: 284910515} - component: {fileID: 284910514} - component: {fileID: 284910518} + - component: {fileID: 284910519} m_Layer: 0 m_Name: NetworkManager m_TagString: Untagged @@ -494,6 +495,20 @@ MonoBehaviour: spectatorPrefab: {fileID: 2945604797830805689, guid: 6e001668808a3d64680522e4c8d582b0, type: 3} lobbySpawnCenter: {x: 0, y: 1, z: 0} matchSpawnCenter: {x: 0, y: 1, z: 0} +--- !u!114 &284910519 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 284910513} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 64babb133b483be49972dd184b31d84b, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::OnlineSessionManager + maxPlayers: 8 + showGui: 1 --- !u!1 &330585543 GameObject: m_ObjectHideFlags: 0 diff --git a/Assets/Scripts/OnlineSessionManager.cs b/Assets/Scripts/OnlineSessionManager.cs new file mode 100644 index 0000000..c529477 --- /dev/null +++ b/Assets/Scripts/OnlineSessionManager.cs @@ -0,0 +1,243 @@ +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(); + } +} diff --git a/Assets/Scripts/OnlineSessionManager.cs.meta b/Assets/Scripts/OnlineSessionManager.cs.meta new file mode 100644 index 0000000..c1ce29c --- /dev/null +++ b/Assets/Scripts/OnlineSessionManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 64babb133b483be49972dd184b31d84b \ No newline at end of file diff --git a/Packages/manifest.json b/Packages/manifest.json index 5e2abd8..8f88e58 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -7,6 +7,7 @@ "com.unity.multiplayer.center": "1.0.1", "com.unity.netcode.gameobjects": "2.13.0", "com.unity.render-pipelines.universal": "17.3.0", + "com.unity.services.multiplayer": "2.2.4", "com.unity.timeline": "1.8.12", "com.unity.ugui": "2.0.0", "com.unity.visualscripting": "1.9.11", diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index 685dd7c..af8ced0 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -39,7 +39,7 @@ }, "com.unity.collections": { "version": "2.6.2", - "depth": 2, + "depth": 1, "source": "registry", "dependencies": { "com.unity.burst": "1.8.23", @@ -152,6 +152,87 @@ "dependencies": {}, "url": "https://packages.unity.com" }, + "com.unity.services.authentication": { + "version": "3.6.0", + "depth": 1, + "source": "registry", + "dependencies": { + "com.unity.ugui": "1.0.0", + "com.unity.services.core": "1.15.1", + "com.unity.nuget.newtonsoft-json": "3.2.1", + "com.unity.modules.unitywebrequest": "1.0.0" + }, + "url": "https://packages.unity.com" + }, + "com.unity.services.core": { + "version": "1.16.0", + "depth": 1, + "source": "registry", + "dependencies": { + "com.unity.modules.androidjni": "1.0.0", + "com.unity.nuget.newtonsoft-json": "3.2.1", + "com.unity.modules.unitywebrequest": "1.0.0" + }, + "url": "https://packages.unity.com" + }, + "com.unity.services.deployment": { + "version": "1.7.1", + "depth": 1, + "source": "registry", + "dependencies": { + "com.unity.services.core": "1.15.1", + "com.unity.services.deployment.api": "1.1.2" + }, + "url": "https://packages.unity.com" + }, + "com.unity.services.deployment.api": { + "version": "1.1.3", + "depth": 2, + "source": "registry", + "dependencies": {}, + "url": "https://packages.unity.com" + }, + "com.unity.services.multiplayer": { + "version": "2.2.4", + "depth": 0, + "source": "registry", + "dependencies": { + "com.unity.transport": "2.6.0", + "com.unity.collections": "2.2.1", + "com.unity.services.qos": "1.4.1", + "com.unity.services.core": "1.16.0", + "com.unity.services.wire": "1.4.4", + "com.unity.services.deployment": "1.7.1", + "com.unity.nuget.newtonsoft-json": "3.2.2", + "com.unity.modules.unitywebrequest": "1.0.0", + "com.unity.services.authentication": "3.6.0" + }, + "url": "https://packages.unity.com" + }, + "com.unity.services.qos": { + "version": "1.4.1", + "depth": 1, + "source": "registry", + "dependencies": { + "com.unity.collections": "1.2.4", + "com.unity.services.core": "1.12.5", + "com.unity.nuget.newtonsoft-json": "3.0.2", + "com.unity.modules.unitywebrequest": "1.0.0", + "com.unity.services.authentication": "3.5.2" + }, + "url": "https://packages.unity.com" + }, + "com.unity.services.wire": { + "version": "1.4.4", + "depth": 1, + "source": "registry", + "dependencies": { + "com.unity.services.core": "1.12.5", + "com.unity.nuget.newtonsoft-json": "3.2.1", + "com.unity.services.authentication": "2.7.4" + }, + "url": "https://packages.unity.com" + }, "com.unity.settings-manager": { "version": "2.1.1", "depth": 2, @@ -191,7 +272,7 @@ }, "com.unity.test-framework.performance": { "version": "3.2.0", - "depth": 3, + "depth": 2, "source": "registry", "dependencies": { "com.unity.test-framework": "1.1.33", @@ -249,6 +330,12 @@ "com.unity.modules.subsystems": "1.0.0" } }, + "com.unity.modules.androidjni": { + "version": "1.0.0", + "depth": 2, + "source": "builtin", + "dependencies": {} + }, "com.unity.modules.animation": { "version": "1.0.0", "depth": 0,