From c1a13f633887836b90bb5596513223dcc7e5fdc3 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Tue, 30 Jun 2026 12:03:22 +0200 Subject: [PATCH] Pick role-specific spawn areas with a shared fallback Choose the spawn area by player role: Hunter avatars look for a HunterSpawnArea-tagged collider, Hiders for HiderSpawnArea. When the role-specific area is missing, fall back to the shared SpawnArea tag, and when that is absent too, keep the existing client-id ring placement. Co-Authored-By: Claude Opus 4.8 --- Assets/Scripts/NetworkPlayerSetup.cs | 106 ++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/NetworkPlayerSetup.cs b/Assets/Scripts/NetworkPlayerSetup.cs index 610ff8d..854be91 100644 --- a/Assets/Scripts/NetworkPlayerSetup.cs +++ b/Assets/Scripts/NetworkPlayerSetup.cs @@ -21,6 +21,30 @@ public class NetworkPlayerSetup : NetworkBehaviour [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.")] @@ -44,7 +68,7 @@ public class NetworkPlayerSetup : NetworkBehaviour if (IsOwner) { - PlaceOnSpawnRing(); + PlaceAtSpawn(); var manager = FindFirstObjectByType(FindObjectsInactive.Include); if (manager) @@ -70,6 +94,86 @@ public class NetworkPlayerSetup : NetworkBehaviour // non-rest pose. Its bake is the necessary cost of correct remote paint, not wasted work. } + /// + /// 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