From f706cdb5a79a0b6680963180495c40213fe191ac Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Sun, 28 Jun 2026 09:34:18 +0200 Subject: [PATCH] Grant match-start/end authority to first client on dedicated server Host keeps authority as before; a dedicated server (no host player) hands start/end authority to the first connecting client and reassigns it if that client leaves. The Start Match trigger now polls a server-side physics overlap instead of relying on OnTriggerEnter, so it fires for the authority client's remote avatar (moved by NetworkTransform, no CharacterController.Move on the server) as well as the host's owned avatar. Esc-to-end routes a named message from the authority client to the server when that client is not the host. Co-Authored-By: Claude Opus 4.8 --- Assets/Scripts/MatchManager.cs | 160 ++++++++++++++++++++++++++-- Assets/Scripts/StartMatchTrigger.cs | 72 +++++++++++-- 2 files changed, 213 insertions(+), 19 deletions(-) diff --git a/Assets/Scripts/MatchManager.cs b/Assets/Scripts/MatchManager.cs index 4865028..12f41bd 100644 --- a/Assets/Scripts/MatchManager.cs +++ b/Assets/Scripts/MatchManager.cs @@ -1,5 +1,6 @@ using System.Collections; using System.Collections.Generic; +using Unity.Collections; using Unity.Netcode; using UnityEngine; using UnityEngine.SceneManagement; @@ -36,9 +37,17 @@ using UnityEngine.InputSystem; /// (strictly serialized - an NGO constraint, only one scene op in flight); then every player object is /// despawned and respawned as its role's prefab variant. /// +/// Match-control AUTHORITY: the client allowed to start (walk into the trigger) and end (Esc) the match. +/// A host has its own player, so the host is the authority. A DEDICATED server (StartServer, no local +/// player) hands authority to the FIRST client that connects, so a server nobody plays on is still +/// controllable. The id is server-local; the designated remote client is told via a named message so it +/// reads Esc locally and asks the server (which validates the sender) to end. If the authority leaves, +/// it is reassigned to another connected client. +/// /// End / join / teardown: -/// - The host presses Esc -> : Test Map is swapped back to Lobby and everyone is -/// respawned as the neutral Player. Server-gated, so only the host can end the match. +/// - The match authority presses Esc -> : Test Map is swapped back to Lobby and +/// everyone is respawned as the neutral Player. The host/server ends directly; a remote authority +/// client requests it over a named message that only the authority's id is honored for. /// - A client that joins MID-MATCH spawns as a SPECTATOR: a dedicated EMPTY prefab (no body/collider) /// bound to free-cam-only, so it flies around the running map and watches but does not play. It /// respawns as a neutral lobby Player when the match ends. @@ -104,6 +113,19 @@ public class MatchManager : MonoBehaviour // previous one's painting; a normal respawn keeps Main loaded, so the parked copy survives it. private byte[] _carriedPaint; + // Match-control authority. Server-local: which client may start/end the match. Host -> the host + // itself (set at server start); dedicated server -> the first client to connect (set on connect). + private bool _hasAuthorityClient; + private ulong _authorityClientId; + // Client-side mirror, set by the grant message below: this machine's local client is the authority, + // so it reads Esc and asks the server to end the match. + private bool _isLocalAuthority; + + // Named messages (CustomMessagingManager). One bridges server->authority-client ("you have authority"), + // the other client->server ("please end the match"); the server only honors it from the authority id. + private const string MsgAuthorityGranted = "Match_AuthorityGranted"; + private const string MsgRequestEndMatch = "Match_RequestEndMatch"; + private void Awake() { if (Instance && Instance != this) { Destroy(this); return; } @@ -140,7 +162,13 @@ public class MatchManager : MonoBehaviour _net.OnServerStopped -= OnNetStopped; _net.OnClientStopped -= OnNetStopped; _net.OnClientConnectedCallback -= OnClientConnected; + _net.OnClientDisconnectCallback -= OnClientDisconnected; if (_net.SceneManager != null) _net.SceneManager.OnSceneEvent -= OnSceneEvent; + if (_net.CustomMessagingManager != null) + { + _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgAuthorityGranted); + _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgRequestEndMatch); + } } if (Instance == this) Instance = null; } @@ -158,9 +186,19 @@ public class MatchManager : MonoBehaviour Phase = MatchPhase.Lobby; _sceneReady = false; + // Match-control authority. A host has its own player, so it is the authority right away. A + // dedicated server has no local player; the first client to connect claims it (OnClientConnected). + _hasAuthorityClient = false; + if (_net.IsHost) { _authorityClientId = _net.LocalClientId; _hasAuthorityClient = true; } + + if (_net.CustomMessagingManager != null) + _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgRequestEndMatch, OnRequestEndMatchMessage); + // Spawn late joiners on connect (server-side callback). Re-hook defensively in case of re-host. _net.OnClientConnectedCallback -= OnClientConnected; _net.OnClientConnectedCallback += OnClientConnected; + _net.OnClientDisconnectCallback -= OnClientDisconnected; + _net.OnClientDisconnectCallback += OnClientDisconnected; StartCoroutine(LoadLobbyRoutine()); } @@ -168,7 +206,13 @@ public class MatchManager : MonoBehaviour private void OnClientStarted() { _cleanedUp = false; + _isLocalAuthority = false; HookSceneManager(); + + // Listen for the server granting this machine match authority (dedicated-server case). Registered + // here (client start) so the handler exists before the server sends the grant on connect. + if (_net.CustomMessagingManager != null) + _net.CustomMessagingManager.RegisterNamedMessageHandler(MsgAuthorityGranted, OnAuthorityGrantedMessage); } // Fired on shutdown / disconnect for both the server and the client (a host gets both, hence the @@ -182,26 +226,50 @@ public class MatchManager : MonoBehaviour StopAllCoroutines(); _hooked = false; _sceneReady = false; + _hasAuthorityClient = false; + _isLocalAuthority = false; Phase = MatchPhase.Lobby; if (_net) { _net.OnClientConnectedCallback -= OnClientConnected; + _net.OnClientDisconnectCallback -= OnClientDisconnected; if (_net.SceneManager != null) _net.SceneManager.OnSceneEvent -= OnSceneEvent; + if (_net.CustomMessagingManager != null) + { + _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgAuthorityGranted); + _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgRequestEndMatch); + } } ReturnToMainOnly(); } - // Host-only: Esc ends the running match and sends everyone back to the lobby. Read locally here - // (Input System reads the device directly, independent of focus/other readers); guarded to the server - // so pure clients pressing Esc do nothing. + // Esc ends the running match (sends everyone back to the lobby) - but only for the match AUTHORITY. + // The host/server reads it locally and ends directly. A remote authority client (dedicated server) + // can't end the match itself (the flow is server-authoritative), so it asks the server over a named + // message; the server validates the sender is the authority and that a match is running. private void Update() { - if (_net == null || !_net.IsServer || Phase != MatchPhase.InMatch) return; + if (_net == null) return; + + if (_net.IsServer) + { + if (Phase == MatchPhase.InMatch && IsMatchAuthority(_net.LocalClientId) && EscapePressed()) + EndMatch(); + return; + } + + // Pure client: Phase is server-local, so we don't gate on it here - the server checks it on receipt. + if (_isLocalAuthority && EscapePressed()) + RequestEndMatchFromServer(); + } + + private static bool EscapePressed() + { #if ENABLE_INPUT_SYSTEM - if (Keyboard.current != null && Keyboard.current.escapeKey.wasPressedThisFrame) EndMatch(); + return Keyboard.current != null && Keyboard.current.escapeKey.wasPressedThisFrame; #else - if (Input.GetKeyDown(KeyCode.Escape)) EndMatch(); + return Input.GetKeyDown(KeyCode.Escape); #endif } @@ -238,6 +306,18 @@ public class MatchManager : MonoBehaviour private void OnClientConnected(ulong clientId) { if (_net == null || !_net.IsServer) return; + + // Dedicated-server case: the first client to connect becomes the match authority (a host already + // claimed it at server start, so this only fires for a server with no local player). Tell the + // client over a named message so it reads Esc locally; the trigger picks it up server-side. + if (!_hasAuthorityClient) + { + _authorityClientId = clientId; + _hasAuthorityClient = true; + SendAuthorityGrant(clientId); + Debug.Log($"[MatchManager] Client {clientId} is now the match authority.", this); + } + // During a Lobby<->Test Map swap the active scene briefly falls back to Main (no floor); defer // and let the respawn loop spawn this joiner once the target scene is loaded. if (!_sceneReady || Phase == MatchPhase.Transitioning) return; @@ -248,6 +328,27 @@ public class MatchManager : MonoBehaviour SpawnFor(clientId, _playerPrefab, lobbySpawnCenter); } + // Server-side: if the match authority leaves, hand authority to another connected client (lowest + // remaining id) so a dedicated-server match stays controllable. No-op in the host case (the host + // holds authority and only "leaves" on shutdown, which teardown handles). + private void OnClientDisconnected(ulong clientId) + { + if (_net == null || !_net.IsServer) return; + if (!_hasAuthorityClient || clientId != _authorityClientId) return; + if (_net.IsHost && clientId == _net.LocalClientId) return; // host shutting down; teardown handles it + + _hasAuthorityClient = false; + foreach (ulong id in _net.ConnectedClientsIds) + { + if (id == clientId) continue; // the leaving client may still be listed momentarily + _authorityClientId = id; + _hasAuthorityClient = true; + SendAuthorityGrant(id); + Debug.Log($"[MatchManager] Match authority reassigned to client {id}.", this); + break; + } + } + // Spawns the given prefab as a client's player object into the currently active gameplay scene. No-op // if the client already owns a player object (double-spawn guard) or has since disconnected. private void SpawnFor(ulong clientId, GameObject prefab, Vector3 at) @@ -422,4 +523,47 @@ public class MatchManager : MonoBehaviour _carriedPaint = null; return d; } + + /// True if the given client currently holds match-control authority (may start and end the match). + public bool IsMatchAuthority(ulong clientId) => _hasAuthorityClient && clientId == _authorityClientId; + + // Server -> client: tell the designated client it is the match authority (so it reads Esc locally). + private void SendAuthorityGrant(ulong clientId) + { + var cmm = _net != null ? _net.CustomMessagingManager : null; + if (cmm == null) return; + using (var writer = new FastBufferWriter(sizeof(byte), Allocator.Temp)) + { + writer.WriteByteSafe(1); // payload unused; a named message still needs a buffer + cmm.SendNamedMessage(MsgAuthorityGranted, clientId, writer); + } + } + + // Client handler: this machine has been granted match authority. + private void OnAuthorityGrantedMessage(ulong senderClientId, FastBufferReader reader) + { + _isLocalAuthority = true; + Debug.Log("[MatchManager] This client is the match authority: walk into the Start Match trigger to begin, Esc to end.", this); + } + + // Client -> server: ask the server to end the match. Only honored from the authority client (and only + // while a match is running - EndMatch guards the phase). + private void RequestEndMatchFromServer() + { + var cmm = _net != null ? _net.CustomMessagingManager : null; + if (cmm == null) return; + using (var writer = new FastBufferWriter(sizeof(byte), Allocator.Temp)) + { + writer.WriteByteSafe(1); + cmm.SendNamedMessage(MsgRequestEndMatch, NetworkManager.ServerClientId, writer); + } + } + + // Server handler: an authority client asked to end the match. + private void OnRequestEndMatchMessage(ulong senderClientId, FastBufferReader reader) + { + if (_net == null || !_net.IsServer) return; + if (!IsMatchAuthority(senderClientId)) return; + EndMatch(); // guards Phase == InMatch itself + } } diff --git a/Assets/Scripts/StartMatchTrigger.cs b/Assets/Scripts/StartMatchTrigger.cs index 25b7a38..22e91c3 100644 --- a/Assets/Scripts/StartMatchTrigger.cs +++ b/Assets/Scripts/StartMatchTrigger.cs @@ -2,25 +2,75 @@ using Unity.Netcode; using UnityEngine; /// -/// Sits on the Lobby's "Start Match" trigger volume (a BoxCollider with isTrigger on). When the HOST's -/// own player walks in, it asks the to start the match. Server-gated: pure -/// clients fire nothing (IsServer is false there), and only the host-owned player object counts, so a -/// remote avatar passing through never starts the match. The trigger lives in the additively-loaded -/// Lobby and is destroyed when the lobby unloads, so it cannot start a second match. +/// Sits on the Lobby's "Start Match" trigger volume (a BoxCollider with isTrigger on). When the match +/// AUTHORITY's avatar stands inside it, it asks the to start the match. +/// +/// Detection POLLS a server-side physics overlap every frame instead of relying on OnTriggerEnter. +/// Why: the authority can be a REMOTE client (a dedicated server with no host player hands authority to +/// the first client). On the server that client's avatar is a NetworkTransform-driven replica - its +/// CharacterController is disabled () and it carries no Rigidbody, so it +/// is moved by direct transform writes. Unity only sends OnTriggerEnter when one of the two colliders has +/// a Rigidbody (or is a CharacterController moving via Move()); a transform-moved non-Rigidbody collider +/// against a static trigger fires nothing. That is exactly why a host's own (Move()-driven) avatar tripped +/// the trigger but a remote authority never did. An explicit overlap query finds the avatar's (baked) +/// collider regardless of HOW it is moved, so start works for a host and a dedicated server alike. +/// +/// Server-gated: pure clients query nothing. Only the authority avatar starts the match; a passing +/// non-authority avatar is ignored. Runs only while in the lobby phase and stops once it has started a +/// match. The trigger lives in the additively-loaded Lobby and is destroyed when the lobby unloads, so a +/// re-match (back in the lobby) gets a fresh instance with its guard reset. /// [RequireComponent(typeof(Collider))] [DisallowMultipleComponent] public class StartMatchTrigger : MonoBehaviour { - private void OnTriggerEnter(Collider other) + private Collider _collider; + private readonly Collider[] _overlap = new Collider[16]; + private bool _started; + + private void Awake() => _collider = GetComponent(); + + private void Update() { + if (_started) return; + var nm = NetworkManager.Singleton; - if (!nm || !nm.IsServer) return; // only the server/host starts the match + if (!nm || !nm.IsServer) return; // detection + start are server-side + var match = MatchManager.Instance; + if (!match || match.Phase != MatchManager.MatchPhase.Lobby) return; - var netObj = other.GetComponentInParent(); - if (!netObj || !netObj.IsPlayerObject) return; // must be a player avatar - if (netObj.OwnerClientId != nm.LocalClientId) return; // ...and specifically the host's own + int count = OverlapTrigger(); + for (int i = 0; i < count; i++) + { + var netObj = _overlap[i].GetComponentInParent(); + if (!netObj || !netObj.IsPlayerObject) continue; // must be a player avatar + if (!match.IsMatchAuthority(netObj.OwnerClientId)) continue; // ...the authority's - if (MatchManager.Instance) MatchManager.Instance.StartMatch(); + _started = true; + match.StartMatch(); + return; + } + } + + // Overlaps the trigger's own volume. Uses the oriented box for a BoxCollider (the authored shape); + // falls back to the collider's world AABB for any other collider type. Triggers are ignored so the + // query skips this volume itself and reports only solid avatar colliders (e.g. the baked mesh). + private int OverlapTrigger() + { + if (_collider is BoxCollider box) + { + Vector3 center = transform.TransformPoint(box.center); + Vector3 scale = transform.lossyScale; + Vector3 half = new Vector3( + Mathf.Abs(box.size.x * scale.x), + Mathf.Abs(box.size.y * scale.y), + Mathf.Abs(box.size.z * scale.z)) * 0.5f; + return Physics.OverlapBoxNonAlloc( + center, half, _overlap, transform.rotation, ~0, QueryTriggerInteraction.Ignore); + } + + Bounds b = _collider.bounds; + return Physics.OverlapBoxNonAlloc( + b.center, b.extents, _overlap, Quaternion.identity, ~0, QueryTriggerInteraction.Ignore); } }