using Unity.Netcode; using UnityEngine; /// /// Per-avatar network glue on the spawned player. The local owner binds the scene singletons /// (GameModeManager, cameras) to itself via ; remote /// avatars stop simulating so their transform is driven purely by the NetworkTransform replication. /// Position-sync milestone: remote avatars still show their mesh/idle pose, just no local control. /// [RequireComponent(typeof(PlayerCharacterController))] [DisallowMultipleComponent] public class NetworkPlayerSetup : NetworkBehaviour { [Header("Spawn placement")] [Tooltip("All avatars spawn from the same prefab position, so without an offset they stack and shove " + "each other on connect. The owner places itself on a ring keyed by its client id (owner-" + "authoritative NetworkTransform replicates it). Id 0 (host) stays at the origin; later joiners " + "fan out. Spacing between ring slots, in world units. 0 disables placement.")] [SerializeField] private float spawnRingRadius = 2.5f; [Tooltip("Slots per ring before the radius steps outward, so spacing stays roughly constant as more join.")] [SerializeField] private int spawnRingSlots = 8; [Tooltip("Role-specific spawn areas, tried FIRST by an owner based on its Role (Hunter uses " + "hunterSpawnAreaTag, Hider uses hiderSpawnAreaTag). If a loaded scene holds a GameObject with the " + "matching tag, the owner spawns at a random point inside its collider. If this avatar's role area " + "is absent it falls back to spawnAreaTag, then to the client-id ring. Empty disables the role lookup.")] [SerializeField] private string hunterSpawnAreaTag = "HunterSpawnArea"; [SerializeField] private string hiderSpawnAreaTag = "HiderSpawnArea"; [Tooltip("Shared fallback spawn area, used when this avatar's role-specific area (hunter/hiderSpawnAreaTag) " + "is not present in any loaded scene. If a loaded scene holds a GameObject with this tag, owners " + "spawn at a RANDOM point inside its collider instead of the client-id ring (random scatter already " + "prevents the stack-up the ring guards against). Empty disables the lookup. The area collider " + "should be convex (a convex MeshCollider or a primitive) so the point is constrained to its actual " + "shape, not just its bounding box; a non-convex collider falls back to the bounding box.")] [SerializeField] private string spawnAreaTag = "SpawnArea"; [Tooltip("Metres the spawn is lifted above the spawn area's floor (collider bounds bottom) so the " + "CharacterController doesn't start embedded; gravity then settles it. Mirrors the lobby/match " + "spawn-centre lift.")] [SerializeField] private float spawnAreaGroundLift = 1f; // Tries to land a random sample inside the convex footprint before falling back to the bounds centre. // The footprint is typically a chunk of its AABB, so a handful of tries hits the inside with near-certainty. private const int SpawnAreaSampleAttempts = 24; [Header("Role")] [Tooltip("Which side this avatar plays. Baked per prefab variant (Hider on the base/Hider prefab, " + "Hunter on the Hunter prefab); GameModeManager reads it on spawn to build this role's mode set.")] [SerializeField] private PlayerRole role = PlayerRole.Hider; [Tooltip("False = lobby/neutral avatar (the base Player prefab) gets the FULL mode set; true = match " + "avatar (Hunter/Hider variant) gets only its role's modes. Baked per prefab variant, so the " + "mode set follows the spawned avatar with no replicated-phase timing race.")] [SerializeField] private bool scopedModes = false; /// The side this avatar plays (Hunter/Hider), baked into the prefab variant. public PlayerRole Role => role; /// True on the match role variants (scope the mode set to ); false on the /// 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) { PlaceAtSpawn(); var manager = FindFirstObjectByType(FindObjectsInactive.Include); if (manager) manager.BindLocalPlayer(controller); return; } // Remote avatar: NetworkTransform owns the transform here, so disable the local // simulation (input/movement) and the CharacterController's own depenetration. if (controller) controller.enabled = false; var cc = GetComponent(); if (cc) cc.enabled = false; // Owner cleanup: the concealment meter is a per-frame skinned-mesh bake + voxelization + // physics overlap whose result only feeds the LOCAL player (HUD / gameplay). A remote avatar's // concealment is computed on its own owner's machine, so running it here is pure wasted CPU. var concealment = GetComponentInChildren(true); if (concealment) concealment.enabled = false; // NOTE: SkinnedColliderBaker deliberately stays ENABLED on remotes. MeshPainter's stamp replay // (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; } /// /// Places the freshly-spawned owner. Prefers this avatar's role-specific spawn area (Hunter/Hider tag), /// then the shared area; if a matching area is loaded the owner drops at a /// random point inside that collider (designer-authored spawn region). With no area present it falls back /// to the deterministic client-id ring. Owner-authoritative either way - the NetworkTransform replicates /// the result, and PlayerCharacterController captures it on its first Update as the kill-plane rescue target. /// private void PlaceAtSpawn() { if (TryGetSpawnArea(out Collider area)) PlaceRandomlyInArea(area); else PlaceOnSpawnRing(); } // Resolves the spawn-area collider for this avatar: the Role-specific area (Hunter/Hider) takes priority, // then the shared spawnAreaTag area; a miss on both leaves it to the caller's client-id ring fallback. private bool TryGetSpawnArea(out Collider area) { string roleTag = role == PlayerRole.Hunter ? hunterSpawnAreaTag : hiderSpawnAreaTag; return TryFindAreaByTag(roleTag, out area) || TryFindAreaByTag(spawnAreaTag, out area); } // Finds a collider tagged 'tag' in any loaded scene, if present. FindWithTag throws when the tag is // undefined (e.g. stripped from a build), so it is guarded - a missing/undefined tag just means "not here". private static bool TryFindAreaByTag(string tag, out Collider area) { area = null; if (string.IsNullOrEmpty(tag)) return false; GameObject go; try { go = GameObject.FindWithTag(tag); } catch (UnityException) { return false; } if (!go) return false; area = go.GetComponent(); return area; } /// /// Drops the owner at a random point inside the spawn-area collider. X/Z are sampled within the /// collider's footprint (rejection-sampled against the convex shape so the irregular outline is honoured, /// not just its bounding box); Y sits a touch above the area floor so gravity settles the avatar onto the /// ground. The CharacterController is toggled around the move because it otherwise overrides direct /// transform writes (same pattern as ). /// private void PlaceRandomlyInArea(Collider area) { Bounds b = area.bounds; bool convex = SupportsClosestPoint(area); Vector3 pick = b.center; for (int i = 0; i < SpawnAreaSampleAttempts; i++) { Vector3 p = new Vector3(Random.Range(b.min.x, b.max.x), b.center.y, Random.Range(b.min.z, b.max.z)); // ClosestPoint returns the input unchanged when it lies inside a convex collider, so an unchanged // sample is a point within the real (possibly non-box) footprint. if (!convex || (area.ClosestPoint(p) - p).sqrMagnitude <= 1e-6f) { pick = p; break; } } Vector3 target = new Vector3(pick.x, b.min.y + spawnAreaGroundLift, pick.z); var cc = GetComponent(); bool reenable = cc && cc.enabled; if (cc) cc.enabled = false; transform.position = target; if (cc) cc.enabled = reenable; } // Collider.ClosestPoint only constrains to the real shape for primitives and CONVEX mesh colliders; // a non-convex mesh returns the input unchanged (no containment info), so we fall back to its AABB. private static bool SupportsClosestPoint(Collider c) { return !(c is MeshCollider mc) || mc.convex; } /// /// Offsets the freshly-spawned owner onto a deterministic ring keyed by its client id so avatars /// don't pile up at the shared prefab spawn point. Id 0 (the host) keeps the origin so single-player /// "host alone" is unchanged; every later joiner takes the next ring slot, stepping the radius out /// once a ring fills. Only X/Z are shifted (gravity settles Y), and the CharacterController is toggled /// around the move because it otherwise overrides direct transform writes. Owner-authoritative, so the /// NetworkTransform replicates the placement outward. /// private void PlaceOnSpawnRing() { if (spawnRingRadius <= 0f || spawnRingSlots < 1) return; ulong id = OwnerClientId; if (id == 0) return; // host stays centered ulong k = id - 1; int ring = (int)(k / (ulong)spawnRingSlots); int slot = (int)(k % (ulong)spawnRingSlots); float radius = spawnRingRadius * (ring + 1); // Half-slot stagger per ring so outer slots don't line up radially with inner ones. float angle = (slot / (float)spawnRingSlots + ring * 0.5f / spawnRingSlots) * Mathf.PI * 2f; Vector3 offset = new Vector3(Mathf.Cos(angle), 0f, Mathf.Sin(angle)) * radius; var cc = GetComponent(); bool reenable = cc && cc.enabled; if (cc) cc.enabled = false; transform.position += offset; if (cc) cc.enabled = reenable; } }