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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-28 09:34:18 +02:00
1 parent 9d6570f7fe
commit f706cdb5a7
2 files changed
+213 -19

No files matched your search

+61 -11
View File
@@ -2,25 +2,75 @@ using Unity.Netcode;
using UnityEngine;
/// <summary>
/// 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 <see cref="MatchManager"/> 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 <see cref="MatchManager"/> 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 (<see cref="NetworkPlayerSetup"/>) 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.
/// </summary>
[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<Collider>();
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<NetworkObject>();
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<NetworkObject>();
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);
}
}