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>
This commit is contained in:
1 parent
f706cdb5a7
commit
f8b11be290
8 files changed
+385
-2
No files matched your search
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4e34b833ea5898745829ede65be71e5b
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: da17151fe2da8bc4a89aebf8f35a9bbe
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 64babb133b483be49972dd184b31d84b
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user