Add the hide/seek/resolution match state machine
Drive the match through three server-authoritative phases (hide, seek, resolution) with per-phase durations, broadcasting the active phase and countdown to clients over the existing named-message infrastructure so MatchManager stays a plain MonoBehaviour and Main keeps zero in-scene network objects. During hide, hider meshes are culled on hunter clients only (each client computes visibility locally from role, phase and found state). During seek, hunters shoot a single raycast from the first-person camera centre (left click, cooldown-gated); a validated hit marks the hider found, which hides its mesh for everyone and binds the owner to spectator freecam. Seek ends early once every hider is found, then resolution runs before returning to the lobby. Force AlwaysAnimate on remote replicas so the animator keeps writing bone transforms while the body is hidden or off-screen. The Paintman animator is authored CullUpdateTransforms, so a culled renderer froze the pose on the rest pose and the skinned collider baked that frozen pose, landing replicated paint stamps on the wrong body location and leaving the painting desynced after hiders were shown again. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
95effb91da
commit
5b3fe1d3d1
5 files changed
+555
-3
No files matched your search
@@ -65,6 +65,12 @@ public class MatchManager : MonoBehaviour
|
||||
{
|
||||
public enum MatchPhase { Lobby, Transitioning, InMatch }
|
||||
|
||||
/// <summary>The in-match sub-phase, replicated to every client over a named message (not via NGO
|
||||
/// NetworkVariables - this is a MonoBehaviour by design, see class summary). None = no match running.
|
||||
/// Hide -> Seek -> Resolution, then back to the lobby. Drives per-client visibility, hunter shooting
|
||||
/// and the match HUD countdown.</summary>
|
||||
public enum MatchSubPhase { None, Hide, Seek, Resolution }
|
||||
|
||||
public static MatchManager Instance { get; private set; }
|
||||
|
||||
[Header("Scenes (must be in Build Settings)")]
|
||||
@@ -97,9 +103,57 @@ public class MatchManager : MonoBehaviour
|
||||
"fan-out is applied on top (NetworkPlayerSetup.PlaceOnSpawnRing).")]
|
||||
[SerializeField] private Vector3 matchSpawnCenter = new Vector3(0f, 1f, 0f);
|
||||
|
||||
[Header("Match phases (seconds)")]
|
||||
[Tooltip("HIDE phase length. Hunters move freely but cannot see the hiders (hider meshes are hidden on " +
|
||||
"hunter clients); hiders scatter and hide.")]
|
||||
[Min(0f)]
|
||||
[SerializeField] private float hideDuration = 30f;
|
||||
|
||||
[Tooltip("SEEK phase length. Hunters can now see the hiders and shoot them. Ends early the moment every " +
|
||||
"hider has been found.")]
|
||||
[Min(0f)]
|
||||
[SerializeField] private float seekDuration = 120f;
|
||||
|
||||
[Tooltip("RESOLUTION phase length. Nothing happens here yet; after it elapses everyone returns to the lobby.")]
|
||||
[Min(0f)]
|
||||
[SerializeField] private float resolveDuration = 8f;
|
||||
|
||||
[Header("Hunter shooting")]
|
||||
[Tooltip("Max distance (metres) of the hunter's camera-centre shoot raycast.")]
|
||||
[Min(0f)]
|
||||
[SerializeField] private float shootRange = 100f;
|
||||
|
||||
[Tooltip("Minimum seconds between hunter shots (left-click cooldown).")]
|
||||
[Min(0f)]
|
||||
[SerializeField] private float shootCooldown = 0.4f;
|
||||
|
||||
/// <summary>Range of the hunter's shoot raycast (read by <see cref="FirstPersonMode"/>).</summary>
|
||||
public float ShootRange => shootRange;
|
||||
/// <summary>Hunter shoot cooldown in seconds (read by <see cref="FirstPersonMode"/>).</summary>
|
||||
public float ShootCooldown => shootCooldown;
|
||||
|
||||
/// <summary>Server-local phase guard. Not replicated - see class summary.</summary>
|
||||
public MatchPhase Phase { get; private set; } = MatchPhase.Lobby;
|
||||
|
||||
// --- Replicated sub-phase mirror (every machine, set by the server's broadcast; the server sets its
|
||||
// own copy directly in SetPhaseServer). Cosmetic countdown runs off the local clock from the broadcast
|
||||
// duration; the authoritative transition is always server-driven, so any drift self-corrects on the
|
||||
// next phase message. ---
|
||||
private MatchSubPhase _subPhase = MatchSubPhase.None;
|
||||
private float _phaseEndTime; // Time.time when the current sub-phase ends (for the HUD countdown)
|
||||
private float _phaseDuration;
|
||||
|
||||
/// <summary>True while a match sub-phase (Hide/Seek/Resolution) is running on this machine.</summary>
|
||||
public bool MatchActive => _subPhase != MatchSubPhase.None;
|
||||
/// <summary>This machine's current sub-phase (mirror of the server's broadcast).</summary>
|
||||
public MatchSubPhase LocalSubPhase => _subPhase;
|
||||
/// <summary>Seconds left in the current sub-phase for the HUD (clamped to 0; cosmetic).</summary>
|
||||
public float LocalPhaseRemaining => Mathf.Max(0f, _phaseEndTime - Time.time);
|
||||
/// <summary>Total length of the current sub-phase (for a HUD progress bar, if wanted).</summary>
|
||||
public float LocalPhaseDuration => _phaseDuration;
|
||||
/// <summary>Raised after the local sub-phase changes (argument = the new sub-phase). HUD/visibility follow it.</summary>
|
||||
public event System.Action<MatchSubPhase> SubPhaseChanged;
|
||||
|
||||
private NetworkManager _net;
|
||||
private GameObject _playerPrefab; // captured from NetworkConfig.PlayerPrefab; auto-spawn is then disabled so we spawn manually into a loaded scene
|
||||
private bool _hooked;
|
||||
@@ -131,6 +185,17 @@ public class MatchManager : MonoBehaviour
|
||||
// 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";
|
||||
// Server -> all clients: the sub-phase changed (byte phase + float duration). Client -> server: a hunter
|
||||
// reports a hit on a hider (ulong target client id); the server validates and marks the hider found.
|
||||
private const string MsgPhase = "Match_Phase";
|
||||
private const string MsgReportHit = "Match_ReportHit";
|
||||
|
||||
// Server-only sub-phase machine. _pendingHiders holds the hiders not yet found; seek ends the instant it
|
||||
// empties. _phaseRoutine is the running Hide->Seek->Resolution coroutine (stopped if the match is ended
|
||||
// early via the authority's Back-to-Lobby).
|
||||
private readonly HashSet<ulong> _pendingHiders = new HashSet<ulong>();
|
||||
private Coroutine _phaseRoutine;
|
||||
private readonly List<ulong> _phaseRecipients = new List<ulong>(); // reused scratch for the phase broadcast
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
@@ -147,6 +212,10 @@ public class MatchManager : MonoBehaviour
|
||||
_playerPrefab = _net.NetworkConfig.PlayerPrefab;
|
||||
_net.NetworkConfig.PlayerPrefab = null;
|
||||
}
|
||||
|
||||
// Placeholder on-screen match HUD (phase + countdown + hunter crosshair). Added at runtime so it
|
||||
// needs no scene/prefab authoring; trivially swapped for a proper Canvas later. Persistent like this.
|
||||
if (!GetComponent<MatchHud>()) gameObject.AddComponent<MatchHud>();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
@@ -174,6 +243,8 @@ public class MatchManager : MonoBehaviour
|
||||
{
|
||||
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgAuthorityGranted);
|
||||
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgRequestEndMatch);
|
||||
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgPhase);
|
||||
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgReportHit);
|
||||
}
|
||||
}
|
||||
if (Instance == this) Instance = null;
|
||||
@@ -198,7 +269,13 @@ public class MatchManager : MonoBehaviour
|
||||
if (_net.IsHost) { _authorityClientId = _net.LocalClientId; _hasAuthorityClient = true; }
|
||||
|
||||
if (_net.CustomMessagingManager != null)
|
||||
{
|
||||
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgRequestEndMatch, OnRequestEndMatchMessage);
|
||||
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgReportHit, OnReportHitMessage);
|
||||
}
|
||||
|
||||
_subPhase = MatchSubPhase.None;
|
||||
_pendingHiders.Clear();
|
||||
|
||||
// Spawn late joiners on connect (server-side callback). Re-hook defensively in case of re-host.
|
||||
_net.OnClientConnectedCallback -= OnClientConnected;
|
||||
@@ -215,10 +292,13 @@ public class MatchManager : MonoBehaviour
|
||||
_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.
|
||||
// Listen for the server granting this machine match authority (dedicated-server case) and for the
|
||||
// sub-phase broadcasts. Registered here (client start) so the handlers exist before the server sends.
|
||||
if (_net.CustomMessagingManager != null)
|
||||
{
|
||||
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgAuthorityGranted, OnAuthorityGrantedMessage);
|
||||
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgPhase, OnPhaseMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Fired on shutdown / disconnect for both the server and the client (a host gets both, hence the
|
||||
@@ -230,11 +310,14 @@ public class MatchManager : MonoBehaviour
|
||||
_cleanedUp = true;
|
||||
|
||||
StopAllCoroutines();
|
||||
_phaseRoutine = null;
|
||||
_hooked = false;
|
||||
_sceneReady = false;
|
||||
_hasAuthorityClient = false;
|
||||
_isLocalAuthority = false;
|
||||
Phase = MatchPhase.Lobby;
|
||||
ApplyPhaseLocal(MatchSubPhase.None, 0f);
|
||||
_pendingHiders.Clear();
|
||||
if (_net)
|
||||
{
|
||||
_net.OnClientConnectedCallback -= OnClientConnected;
|
||||
@@ -244,6 +327,8 @@ public class MatchManager : MonoBehaviour
|
||||
{
|
||||
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgAuthorityGranted);
|
||||
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgRequestEndMatch);
|
||||
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgPhase);
|
||||
_net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgReportHit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,7 +386,13 @@ public class MatchManager : MonoBehaviour
|
||||
// Mid-match joiner: spawn an empty SPECTATOR avatar (free-cam only) into the running map. They can
|
||||
// fly around and watch but don't play. When the host ends the match, EndMatchSequence's respawn
|
||||
// loop covers every connected client, so they turn into a neutral lobby Player with everyone else.
|
||||
if (Phase == MatchPhase.InMatch) { SpawnFor(clientId, spectatorPrefab, matchSpawnCenter); return; }
|
||||
if (Phase == MatchPhase.InMatch)
|
||||
{
|
||||
SpawnFor(clientId, spectatorPrefab, matchSpawnCenter);
|
||||
// Sync the joiner's HUD/visibility to the running sub-phase (remaining time as the duration).
|
||||
BroadcastPhase(_subPhase, LocalPhaseRemaining, clientId, false);
|
||||
return;
|
||||
}
|
||||
SpawnFor(clientId, _playerPrefab, lobbySpawnCenter);
|
||||
}
|
||||
|
||||
@@ -311,6 +402,10 @@ public class MatchManager : MonoBehaviour
|
||||
private void OnClientDisconnected(ulong clientId)
|
||||
{
|
||||
if (_net == null || !_net.IsServer) return;
|
||||
|
||||
// A hider that leaves mid-match can never be found - drop it so "all hiders found" can still trigger.
|
||||
_pendingHiders.Remove(clientId);
|
||||
|
||||
if (!_hasAuthorityClient || clientId != _authorityClientId) return;
|
||||
if (_net.IsHost && clientId == _net.LocalClientId) return; // host shutting down; teardown handles it
|
||||
|
||||
@@ -393,8 +488,156 @@ public class MatchManager : MonoBehaviour
|
||||
// 3. Respawn every player as its rolled role's prefab variant.
|
||||
RespawnAll(id => hunterIds.Contains(id) ? hunterPrefab : hiderPrefab, matchSpawnCenter);
|
||||
|
||||
// The hiders the seek phase has to find: every connected client that wasn't rolled a hunter.
|
||||
_pendingHiders.Clear();
|
||||
foreach (ulong id in _net.ConnectedClientsIds)
|
||||
if (!hunterIds.Contains(id)) _pendingHiders.Add(id);
|
||||
|
||||
Phase = MatchPhase.InMatch;
|
||||
Debug.Log("[MatchManager] Match started.", this);
|
||||
|
||||
// Drive Hide -> Seek -> Resolution -> lobby. Held in a field so an early Back-to-Lobby can stop it.
|
||||
_phaseRoutine = StartCoroutine(RunMatchPhases());
|
||||
}
|
||||
|
||||
// Server-only. Runs the three sub-phases back to back, then returns everyone to the lobby. Hide and
|
||||
// Resolution are fixed-length; Seek bails the instant the last hider is found (or its timer runs out).
|
||||
private IEnumerator RunMatchPhases()
|
||||
{
|
||||
SetPhaseServer(MatchSubPhase.Hide, hideDuration);
|
||||
yield return WaitPhase(hideDuration, null);
|
||||
|
||||
SetPhaseServer(MatchSubPhase.Seek, seekDuration);
|
||||
yield return WaitPhase(seekDuration, () => _pendingHiders.Count == 0);
|
||||
|
||||
SetPhaseServer(MatchSubPhase.Resolution, resolveDuration);
|
||||
yield return WaitPhase(resolveDuration, null);
|
||||
|
||||
_phaseRoutine = null;
|
||||
EndMatch(); // swaps the map back to the lobby and respawns everyone as the neutral Player
|
||||
}
|
||||
|
||||
// Waits up to 'seconds' of server real time, returning early the moment 'until' (if supplied) is true.
|
||||
private IEnumerator WaitPhase(float seconds, System.Func<bool> until)
|
||||
{
|
||||
float t = seconds;
|
||||
while (t > 0f)
|
||||
{
|
||||
if (until != null && until()) yield break;
|
||||
t -= Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Server-only. Applies the sub-phase to the server's own mirror and broadcasts it to every other client.
|
||||
private void SetPhaseServer(MatchSubPhase phase, float duration)
|
||||
{
|
||||
ApplyPhaseLocal(phase, duration);
|
||||
BroadcastPhase(phase, duration, 0, true);
|
||||
}
|
||||
|
||||
// Sets this machine's sub-phase mirror + countdown and notifies followers (HUD / visibility). Shared by
|
||||
// the server's SetPhaseServer and a client's OnPhaseMessage handler.
|
||||
private void ApplyPhaseLocal(MatchSubPhase phase, float duration)
|
||||
{
|
||||
bool changed = _subPhase != phase;
|
||||
_subPhase = phase;
|
||||
_phaseDuration = duration;
|
||||
_phaseEndTime = Time.time + duration;
|
||||
if (changed) SubPhaseChanged?.Invoke(phase);
|
||||
}
|
||||
|
||||
// Sends a sub-phase (byte) + its remaining duration (float) to one client, or to all clients except the
|
||||
// server itself (which applied it directly in SetPhaseServer, so it must not double-apply via loopback).
|
||||
private void BroadcastPhase(MatchSubPhase phase, float duration, ulong targetClient, bool toAll)
|
||||
{
|
||||
var cmm = _net != null ? _net.CustomMessagingManager : null;
|
||||
if (cmm == null) return;
|
||||
using (var writer = new FastBufferWriter(sizeof(byte) + sizeof(float), Allocator.Temp))
|
||||
{
|
||||
writer.WriteValueSafe((byte)phase);
|
||||
writer.WriteValueSafe(duration);
|
||||
if (toAll)
|
||||
{
|
||||
// Every client except the server itself (the host already applied it directly in
|
||||
// SetPhaseServer, so it must not double-apply via loopback). One send, no writer reuse.
|
||||
_phaseRecipients.Clear();
|
||||
foreach (ulong id in _net.ConnectedClientsIds)
|
||||
if (id != _net.LocalClientId) _phaseRecipients.Add(id);
|
||||
if (_phaseRecipients.Count > 0)
|
||||
cmm.SendNamedMessage(MsgPhase, _phaseRecipients, writer);
|
||||
}
|
||||
else
|
||||
{
|
||||
cmm.SendNamedMessage(MsgPhase, targetClient, writer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Client handler: the server broadcast a sub-phase change (or sync on join).
|
||||
private void OnPhaseMessage(ulong senderClientId, FastBufferReader reader)
|
||||
{
|
||||
reader.ReadValueSafe(out byte phase);
|
||||
reader.ReadValueSafe(out float duration);
|
||||
ApplyPhaseLocal((MatchSubPhase)phase, duration);
|
||||
}
|
||||
|
||||
/// <summary>Client-side: the local hunter's shoot raycast hit the hider owned by <paramref name="targetClientId"/>.
|
||||
/// Routes to the server, which validates phase/role/found-state. On a host this IS the server, so it
|
||||
/// applies directly. No-op outside the seek phase.</summary>
|
||||
public void ReportHit(ulong targetClientId)
|
||||
{
|
||||
if (_net == null || _subPhase != MatchSubPhase.Seek) return;
|
||||
|
||||
if (_net.IsServer)
|
||||
{
|
||||
if (_pendingHiders.Contains(targetClientId) && IsHunter(_net.LocalClientId))
|
||||
MarkHiderFound(targetClientId);
|
||||
return;
|
||||
}
|
||||
|
||||
var cmm = _net.CustomMessagingManager;
|
||||
if (cmm == null) return;
|
||||
using (var writer = new FastBufferWriter(sizeof(ulong), Allocator.Temp))
|
||||
{
|
||||
writer.WriteValueSafe(targetClientId);
|
||||
cmm.SendNamedMessage(MsgReportHit, NetworkManager.ServerClientId, writer);
|
||||
}
|
||||
}
|
||||
|
||||
// Server handler: a hunter client reports its shoot raycast hit a hider. Trusts the client's raycast for
|
||||
// the hit itself (a co-op hide & seek, not a competitive shooter); the server gates phase, shooter role
|
||||
// and the target still being an un-found hider.
|
||||
private void OnReportHitMessage(ulong senderClientId, FastBufferReader reader)
|
||||
{
|
||||
reader.ReadValueSafe(out ulong targetClientId);
|
||||
if (_net == null || !_net.IsServer) return;
|
||||
if (_subPhase != MatchSubPhase.Seek) return;
|
||||
if (!_pendingHiders.Contains(targetClientId)) return; // not a live (un-found) hider
|
||||
if (!IsHunter(senderClientId)) return; // only a hunter may shoot
|
||||
|
||||
MarkHiderFound(targetClientId);
|
||||
}
|
||||
|
||||
// Server-only. Flags the hider's avatar found (replicated by its NetworkPlayerSetup) and drops it from
|
||||
// the pending set; the seek loop ends the phase early once that empties.
|
||||
private void MarkHiderFound(ulong hiderClientId)
|
||||
{
|
||||
_pendingHiders.Remove(hiderClientId);
|
||||
if (_net.ConnectedClients.TryGetValue(hiderClientId, out var c) && c.PlayerObject)
|
||||
{
|
||||
var setup = c.PlayerObject.GetComponent<NetworkPlayerSetup>();
|
||||
if (setup) setup.ServerMarkFound();
|
||||
}
|
||||
Debug.Log($"[MatchManager] Hider {hiderClientId} found - {_pendingHiders.Count} hider(s) left.", this);
|
||||
}
|
||||
|
||||
// True if the client's current avatar is a Hunter role variant (used to gate shooting server-side).
|
||||
private bool IsHunter(ulong clientId)
|
||||
{
|
||||
if (!_net.ConnectedClients.TryGetValue(clientId, out var c) || !c.PlayerObject) return false;
|
||||
var setup = c.PlayerObject.GetComponent<NetworkPlayerSetup>();
|
||||
return setup && setup.ScopedModes && setup.Role == PlayerRole.Hunter;
|
||||
}
|
||||
|
||||
/// <summary>Server-only. Ends the running match: swap Test Map -> Lobby and respawn everyone as the
|
||||
@@ -404,6 +647,12 @@ public class MatchManager : MonoBehaviour
|
||||
if (_net == null || !_net.IsServer) return;
|
||||
if (Phase != MatchPhase.InMatch) return; // only endable mid-match
|
||||
|
||||
// Stop the sub-phase machine if the match is being ended early (authority's Back-to-Lobby) and clear
|
||||
// the replicated sub-phase so every client drops its match HUD/visibility before the scene swaps.
|
||||
if (_phaseRoutine != null) { StopCoroutine(_phaseRoutine); _phaseRoutine = null; }
|
||||
_pendingHiders.Clear();
|
||||
SetPhaseServer(MatchSubPhase.None, 0f);
|
||||
|
||||
Phase = MatchPhase.Transitioning;
|
||||
Debug.Log("[MatchManager] Ending match -> returning to lobby.", this);
|
||||
StartCoroutine(EndMatchSequence());
|
||||
|
||||
Reference in new issue
Block a user