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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-30 12:03:22 +02:00
1 parent 1e17b90358
commit c1a13f6338
1 file changed
+105 -1
+105 -1
View File
@@ -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<GameModeManager>(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.
}
/// <summary>
/// Places the freshly-spawned owner. Prefers this avatar's role-specific spawn area (Hunter/Hider tag),
/// then the shared <see cref="spawnAreaTag"/> 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.
/// </summary>
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<Collider>();
return area;
}
/// <summary>
/// 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 <see cref="PlaceOnSpawnRing"/>).
/// </summary>
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<CharacterController>();
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;
}
/// <summary>
/// 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