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; } ///