From 5b3fe1d3d14583c22fda8f498fb8e181200166f0 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Tue, 30 Jun 2026 13:59:01 +0200 Subject: [PATCH] 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 --- Assets/Scripts/FirstPersonMode.cs | 77 ++++++++ Assets/Scripts/MatchHud.cs | 106 +++++++++++ Assets/Scripts/MatchHud.cs.meta | 2 + Assets/Scripts/MatchManager.cs | 255 ++++++++++++++++++++++++++- Assets/Scripts/NetworkPlayerSetup.cs | 118 +++++++++++++ 5 files changed, 555 insertions(+), 3 deletions(-) create mode 100644 Assets/Scripts/MatchHud.cs create mode 100644 Assets/Scripts/MatchHud.cs.meta diff --git a/Assets/Scripts/FirstPersonMode.cs b/Assets/Scripts/FirstPersonMode.cs index b5b389c..ee8bec6 100644 --- a/Assets/Scripts/FirstPersonMode.cs +++ b/Assets/Scripts/FirstPersonMode.cs @@ -1,3 +1,9 @@ +using UnityEngine; +using Unity.Netcode; +#if ENABLE_INPUT_SYSTEM +using UnityEngine.InputSystem; +#endif + /// /// First-person mode. The player keeps walking (WASD, camera-relative) but the camera moves to the /// player's eye (the CameraTarget) with free mouse-look, and the player's mesh is hidden so the body @@ -44,4 +50,75 @@ public class FirstPersonMode : GameMode Manager.CameraInput.SyncToView(); } } + + // --- Hunter shooting ------------------------------------------------------------------------------- + // A hunter in first person shoots a single ray from the camera centre on left-click (with a cooldown), + // but only during the SEEK sub-phase. A hit on an un-found hider is reported to the server, which + // validates and marks the hider found. Gated on the local avatar actually being a hunter role variant, + // so the legacy lobby first-person view never shoots. + + private float _nextShootTime; + private static readonly RaycastHit[] _hits = new RaycastHit[16]; + + public override void Tick() + { + if (!CanShoot()) return; + if (Time.time < _nextShootTime) return; + if (!ClickedThisFrame()) return; + + _nextShootTime = Time.time + Mathf.Max(0f, MatchManager.Instance.ShootCooldown); + TryShoot(); + } + + private bool CanShoot() + { + var match = MatchManager.Instance; + if (!match || !match.MatchActive || match.LocalSubPhase != MatchManager.MatchSubPhase.Seek) return false; + if (!Manager.Player || !Manager.FirstPerson) return false; + + var setup = Manager.Player.GetComponent(); + return setup && setup.ScopedModes && setup.Role == PlayerRole.Hunter; + } + + private void TryShoot() + { + var match = MatchManager.Instance; + Transform cam = Manager.FirstPerson.transform; // FP vcam sits at the eye, facing the look direction + float range = Mathf.Max(0f, match.ShootRange); + + int count = Physics.RaycastNonAlloc(cam.position, cam.forward, _hits, range, ~0, QueryTriggerInteraction.Ignore); + if (count == 0) return; + + // Nearest-first so the shot stops at the first solid thing it meets (a wall blocks; a nearer avatar + // shields a farther one). The hunter's own body is skipped so looking down doesn't self-block. + System.Array.Sort(_hits, 0, count, RaycastDistanceComparer.Instance); + + NetworkObject self = Manager.Player ? Manager.Player.GetComponent() : null; + for (int i = 0; i < count; i++) + { + var netObj = _hits[i].collider.GetComponentInParent(); + if (netObj && self && netObj == self) continue; // ignore our own collider + if (!netObj || !netObj.IsPlayerObject) return; // first solid hit is world geometry -> blocked + + var setup = netObj.GetComponent(); + if (setup && setup.Role == PlayerRole.Hider && !setup.IsFound) + match.ReportHit(netObj.OwnerClientId); + return; // the first non-self hit decides the shot + } + } + + private static bool ClickedThisFrame() + { +#if ENABLE_INPUT_SYSTEM + return Mouse.current != null && Mouse.current.leftButton.wasPressedThisFrame; +#else + return Input.GetMouseButtonDown(0); +#endif + } + + private sealed class RaycastDistanceComparer : System.Collections.Generic.IComparer + { + public static readonly RaycastDistanceComparer Instance = new RaycastDistanceComparer(); + public int Compare(RaycastHit a, RaycastHit b) => a.distance.CompareTo(b.distance); + } } diff --git a/Assets/Scripts/MatchHud.cs b/Assets/Scripts/MatchHud.cs new file mode 100644 index 0000000..ec1bc9b --- /dev/null +++ b/Assets/Scripts/MatchHud.cs @@ -0,0 +1,106 @@ +using UnityEngine; + +/// +/// Placeholder on-screen match HUD: the current sub-phase + countdown (top centre) and a hunter crosshair +/// during the seek phase. Drawn with IMGUI (OnGUI) on purpose - it needs no Canvas / font asset authoring, +/// so the match-state logic can be exercised immediately; swap it for a proper uGUI canvas once the look is +/// finalised. Added at runtime by (AddComponent), so it rides the persistent +/// manager object and reads on every machine. +/// +[DisallowMultipleComponent] +public class MatchHud : MonoBehaviour +{ + private GUIStyle _phaseStyle; + private GUIStyle _timeStyle; + + private void OnGUI() + { + var match = MatchManager.Instance; + if (!match || !match.MatchActive) return; + + EnsureStyles(); + + const float w = 360f; + var phaseRect = new Rect((Screen.width - w) * 0.5f, 12f, w, 30f); + var timeRect = new Rect(phaseRect.x, phaseRect.y + 28f, w, 36f); + DrawShadowed(phaseRect, PhaseLabel(match.LocalSubPhase), _phaseStyle); + DrawShadowed(timeRect, FormatTime(match.LocalPhaseRemaining), _timeStyle); + + if (match.LocalSubPhase == MatchManager.MatchSubPhase.Seek && LocalIsHunter()) + DrawCrosshair(); + } + + private static string PhaseLabel(MatchManager.MatchSubPhase phase) + { + switch (phase) + { + case MatchManager.MatchSubPhase.Hide: return "Verstecken"; + case MatchManager.MatchSubPhase.Seek: return "Suchen"; + case MatchManager.MatchSubPhase.Resolution: return "Auflösung"; + default: return ""; + } + } + + private static string FormatTime(float seconds) + { + int s = Mathf.CeilToInt(Mathf.Max(0f, seconds)); + return $"{s / 60:0}:{s % 60:00}"; + } + + private static bool LocalIsHunter() + { + var nm = Unity.Netcode.NetworkManager.Singleton; + if (!nm || nm.LocalClient == null) return false; + var po = nm.LocalClient.PlayerObject; + if (!po) return false; + var setup = po.GetComponent(); + return setup && setup.ScopedModes && setup.Role == PlayerRole.Hunter; + } + + private static void DrawCrosshair() + { + float cx = Screen.width * 0.5f; + float cy = Screen.height * 0.5f; + const float r = 8f, t = 2f; + Color prev = GUI.color; + GUI.color = new Color(1f, 1f, 1f, 0.85f); + GUI.DrawTexture(new Rect(cx - r, cy - t * 0.5f, r * 2f, t), Texture2D.whiteTexture); + GUI.DrawTexture(new Rect(cx - t * 0.5f, cy - r, t, r * 2f), Texture2D.whiteTexture); + GUI.color = prev; + } + + // One label drawn twice (dark offset behind, then the real colour) so it stays readable on any backdrop. + private static void DrawShadowed(Rect r, string text, GUIStyle style) + { + if (string.IsNullOrEmpty(text)) return; + Color real = style.normal.textColor; + style.normal.textColor = new Color(0f, 0f, 0f, 0.7f); + GUI.Label(new Rect(r.x + 1f, r.y + 1f, r.width, r.height), text, style); + style.normal.textColor = real; + GUI.Label(r, text, style); + } + + private void EnsureStyles() + { + if (_phaseStyle == null) + { + _phaseStyle = new GUIStyle(GUI.skin.label) + { + alignment = TextAnchor.MiddleCenter, + fontSize = 22, + fontStyle = FontStyle.Bold, + }; + _phaseStyle.normal.textColor = Color.white; + } + if (_timeStyle == null) + { + _timeStyle = new GUIStyle(GUI.skin.label) + { + alignment = TextAnchor.MiddleCenter, + fontSize = 28, + fontStyle = FontStyle.Bold, + }; + _timeStyle.normal.textColor = Color.white; + } + } +} diff --git a/Assets/Scripts/MatchHud.cs.meta b/Assets/Scripts/MatchHud.cs.meta new file mode 100644 index 0000000..ebffa49 --- /dev/null +++ b/Assets/Scripts/MatchHud.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 130fbae3929f3274a87fa23acc5b45f5 \ No newline at end of file diff --git a/Assets/Scripts/MatchManager.cs b/Assets/Scripts/MatchManager.cs index 09a5730..b130ed5 100644 --- a/Assets/Scripts/MatchManager.cs +++ b/Assets/Scripts/MatchManager.cs @@ -65,6 +65,12 @@ public class MatchManager : MonoBehaviour { public enum MatchPhase { Lobby, Transitioning, InMatch } + /// 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. + 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; + + /// Range of the hunter's shoot raycast (read by ). + public float ShootRange => shootRange; + /// Hunter shoot cooldown in seconds (read by ). + public float ShootCooldown => shootCooldown; + /// Server-local phase guard. Not replicated - see class 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; + + /// True while a match sub-phase (Hide/Seek/Resolution) is running on this machine. + public bool MatchActive => _subPhase != MatchSubPhase.None; + /// This machine's current sub-phase (mirror of the server's broadcast). + public MatchSubPhase LocalSubPhase => _subPhase; + /// Seconds left in the current sub-phase for the HUD (clamped to 0; cosmetic). + public float LocalPhaseRemaining => Mathf.Max(0f, _phaseEndTime - Time.time); + /// Total length of the current sub-phase (for a HUD progress bar, if wanted). + public float LocalPhaseDuration => _phaseDuration; + /// Raised after the local sub-phase changes (argument = the new sub-phase). HUD/visibility follow it. + public event System.Action 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 _pendingHiders = new HashSet(); + private Coroutine _phaseRoutine; + private readonly List _phaseRecipients = new List(); // 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()) gameObject.AddComponent(); } 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 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); + } + + /// Client-side: the local hunter's shoot raycast hit the hider owned by . + /// 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. + 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(); + 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(); + return setup && setup.ScopedModes && setup.Role == PlayerRole.Hunter; } /// 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()); diff --git a/Assets/Scripts/NetworkPlayerSetup.cs b/Assets/Scripts/NetworkPlayerSetup.cs index 854be91..5267e4a 100644 --- a/Assets/Scripts/NetworkPlayerSetup.cs +++ b/Assets/Scripts/NetworkPlayerSetup.cs @@ -62,8 +62,26 @@ public class NetworkPlayerSetup : NetworkBehaviour /// neutral lobby Player prefab (full mode set). Baked per prefab variant. public bool ScopedModes => scopedModes; + // Server-authoritative "this hider has been shot/found". Server-write (a hunter's hit is validated on + // the server, not by the hider's owner); read by everyone. When it flips true the body is hidden on + // every client and the owning client drops into free-cam-only spectating. Stays false on hunters/lobby. + private readonly NetworkVariable _isFound = + new NetworkVariable(false, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); + + /// True once a hunter has found this hider (replicated). Read by the shoot raycast to skip a + /// target that is already out of play. + public bool IsFound => _isFound.Value; + + private Renderer[] _bodyRenderers; // skinned body mesh(es), toggled by the per-client visibility pass + private bool _visibilityApplied; + private bool _lastVisible = true; + public override void OnNetworkSpawn() { + _bodyRenderers = GetComponentsInChildren(true); + _isFound.OnValueChanged += OnFoundChanged; + if (_isFound.Value) ApplyFound(); // defensive: an avatar is never normally spawned already-found + var controller = GetComponent(); if (IsOwner) @@ -92,6 +110,106 @@ public class NetworkPlayerSetup : NetworkBehaviour // (ApplyNetworkStamp -> DrawBrushGpu) rasterizes the baker's live, per-frame BakedMesh to map a // 3D brush placement into UV space; freezing the bake would mis-map replicated paint onto any // non-rest pose. Its bake is the necessary cost of correct remote paint, not wasted work. + + // The rig must keep evaluating even while this body is HIDDEN on the local client. During the hide + // phase a hider's body is hidden on hunter clients (UpdateVisibility sets renderer.enabled = false), + // and a remote can also sit off-screen. The avatar's Animator is authored as CullUpdateTransforms, + // which STOPS writing the bone transforms whenever its renderer isn't visible. PlayerPoseController + // drives the rig through a PlayableGraph on this Animator and resets un-keyed bones to rest every + // frame, so a culled Animator leaves the rig frozen at the rest/T-pose. SkinnedColliderBaker then + // bakes that wrong pose, and any paint stamp replicated during the hidden window lands on the wrong + // body part - a desync that persists once the body is shown again. Forcing AlwaysAnimate keeps the + // pose (and thus the baked mesh paint replay rasterizes) correct regardless of visibility. Remote + // replicas only: an owner is never hidden from its own camera. + foreach (var anim in GetComponentsInChildren(true)) + anim.cullingMode = AnimatorCullingMode.AlwaysAnimate; + } + + public override void OnNetworkDespawn() + { + _isFound.OnValueChanged -= OnFoundChanged; + } + + /// Server-only: flag this hider as found by a hunter. Replicates to every client (the body is + /// hidden for all; the owner drops into free-cam-only spectating). Idempotent. + public void ServerMarkFound() + { + if (!IsServer || _isFound.Value) return; + _isFound.Value = true; // OnValueChanged -> ApplyFound on every machine, including this server/host + } + + private void OnFoundChanged(bool previous, bool current) + { + if (current) ApplyFound(); + } + + // A found hider leaves play: the per-client visibility pass hides its body, its baked collider is dropped + // (so the invisible body neither blocks nor can be hit again), and on the owning client it switches to + // free-cam-only so the player can still watch the rest of the match. Idempotent. + private void ApplyFound() + { + var baker = GetComponentInChildren(true); + if (baker) baker.enabled = false; + var meshCol = GetComponentInChildren(true); + if (meshCol) meshCol.enabled = false; + + if (IsOwner) + { + var controller = GetComponent(); + if (controller) controller.enabled = false; + var cc = GetComponent(); + if (cc) cc.enabled = false; + + var manager = FindFirstObjectByType(FindObjectsInactive.Include); + if (manager) manager.BindSpectator(); + } + } + + private void Update() => UpdateVisibility(); + + // Per-client visibility: each machine decides whether to render THIS avatar's body from the LOCAL + // viewer's perspective. A found hider is hidden for everyone; during HIDE, hiders are hidden on hunter + // clients only (the spec - hunters can't see hiders yet). The local owner's own body is left alone unless + // found, so this never fights the FirstPersonController's own-body self-hide. + private void UpdateVisibility() + { + bool found = _isFound.Value; + if (IsOwner && !found) return; + + bool visible = ComputeVisible(found); + if (_visibilityApplied && visible == _lastVisible) return; + + if (_bodyRenderers != null) + foreach (var r in _bodyRenderers) + if (r) r.enabled = visible; + + _lastVisible = visible; + _visibilityApplied = true; + } + + private bool ComputeVisible(bool found) + { + if (found) return false; + + var match = MatchManager.Instance; + if (match && match.MatchActive + && match.LocalSubPhase == MatchManager.MatchSubPhase.Hide + && role == PlayerRole.Hider + && LocalViewerIsHunter()) + return false; + + return true; + } + + // True if THIS machine's local player is a Hunter role variant (so it must not see hiders during hide). + private static bool LocalViewerIsHunter() + { + var nm = NetworkManager.Singleton; + if (!nm || nm.LocalClient == null) return false; + var po = nm.LocalClient.PlayerObject; + if (!po) return false; + var setup = po.GetComponent(); + return setup && setup.ScopedModes && setup.Role == PlayerRole.Hunter; } ///