Files
chameleon/Assets/Scripts/Network/NetworkPlayerSetup.cs
T
adminandClaude Fable 5 0e7ce0c410 Add player names: settings persistence, replication, screen-space nameplates
- GameSettings: playerName field persisted in Settings.json
- NetworkPlayerSetup: owner-written DisplayName NetworkVariable
  (seeded from settings on spawn, sanitized/truncated, live rename push)
- PlayerNameField: binds the session panel name input to settings
- PlayerNameplates: screen-space overlay labels above players with
  distance-based font scaling (clamped min/max), pooling, FP self-hide

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 14:58:15 +02:00

655 lines
33 KiB
C#

using System.Collections.Generic;
using Unity.Collections;
using Unity.Netcode;
using UnityEngine;
using Chameleon.Components;
/// <summary>
/// Per-avatar network glue on the spawned player. The local owner binds the scene singletons
/// (GameModeManager, cameras) to itself via <see cref="GameModeManager.BindLocalPlayer"/>; remote
/// avatars stop simulating so their transform is driven purely by the NetworkTransform replication.
/// </summary>
[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("Conversion (mode 2)")]
[Tooltip("Seconds a freshly CONVERTED hunter (a hider shot during Seek in Conversion mode) stays hidden " +
"on other clients after it spawns, so nobody sees it pop in or interpolate from the spawn frame. " +
"The shot hider is despawned immediately, so there is a brief gap, then the hunter cleanly appears " +
"in its place. Per-client and cosmetic; the owner is first-person so it is never gated.")]
[Min(0f)]
[SerializeField] private float conversionRevealDelay = 0.2f;
[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;
/// <summary>The side this avatar plays (Hunter/Hider), baked into the prefab variant.</summary>
public PlayerRole Role => role;
/// <summary>True on the match role variants (scope the mode set to <see cref="Role"/>); false on the
/// neutral lobby Player prefab (full mode set). Baked per prefab variant.</summary>
public bool ScopedModes => scopedModes;
// Server-authoritative "this hider has been shot/found" (a hit is validated on the server, not by the
// hider's owner). When it flips true the body hides on every client and the owner drops to spectating.
private readonly NetworkVariable<bool> _isFound =
new NetworkVariable<bool>(false, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
/// <summary>True once a hunter has found this hider (replicated). Read by the shoot raycast to skip a
/// target that is already out of play.</summary>
public bool IsFound => _isFound.Value;
// Owner-published display name (seeded from GameSettings on spawn), consumed by the nameplate overlay
// on every client. Empty = unset; DisplayName then falls back to "Player <clientId>".
private readonly NetworkVariable<FixedString64Bytes> _displayName =
new NetworkVariable<FixedString64Bytes>(default, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
private string _displayNameCache; // resolved lazily, invalidated on replication (no per-frame ToString allocs)
/// <summary>This avatar's nameplate text: the owner-published name, or "Player &lt;clientId&gt;" when unset.
/// Returns the SAME string instance until the name changes, so consumers can change-detect by reference.</summary>
public string DisplayName
{
get
{
if (_displayNameCache == null)
{
FixedString64Bytes raw = _displayName.Value;
_displayNameCache = raw.IsEmpty ? $"Player {OwnerClientId}" : raw.ToString();
}
return _displayNameCache;
}
}
/// <summary>Owner-only: publishes a new display name (trimmed, length-capped) to every client. Called on
/// spawn with the persisted settings value and live from the session panel's name field on a rename.</summary>
public void PublishDisplayName(string name)
{
if (!IsOwner || !IsSpawned) return;
_displayName.Value = SanitizeName(name);
}
private static FixedString64Bytes SanitizeName(string name)
{
name = name == null ? "" : name.Trim();
const int maxChars = 24;
if (name.Length > maxChars) name = name.Substring(0, maxChars);
var fs = new FixedString64Bytes();
fs.CopyFromTruncated(name); // exotic chars can exceed the 61-byte UTF-8 capacity; truncate, never throw
return fs;
}
private void OnDisplayNameChanged(FixedString64Bytes previous, FixedString64Bytes current) =>
_displayNameCache = null;
// Owner-authoritative "this hider is buried too deep" flag: only the OWNER runs the ConcealmentMeter,
// so it compares EmbeddedPercent against the threshold and writes; every client reads it to switch
// the through-walls X-ray glow.
private readonly NetworkVariable<bool> _embeddedReveal =
new NetworkVariable<bool>(false, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
// Conversion-mode in-place respawn position. The owner holds transform authority, so the server's
// Instantiate position is NOT honoured there; the server writes the tagged position here so it rides
// the spawn snapshot and the owner restores it itself. NaN sentinel = no override (normal placement).
private static readonly Vector3 NoSpawnOverride = new Vector3(float.NaN, float.NaN, float.NaN);
private readonly NetworkVariable<Vector3> _spawnOverride =
new NetworkVariable<Vector3>(NoSpawnOverride, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
// Server-only handoff from MatchManager.ConvertToHunter, pushed into _spawnOverride during
// OnNetworkSpawn (a pre-spawn write to the NetworkVariable is silently dropped).
private Vector3? _conversionSpawnPos;
/// <summary>Server-only. Call on a freshly-instantiated Conversion hunter BEFORE spawning it so the owner
/// restores the tagged position (the owner-authoritative transform ignores the server's Instantiate pos).</summary>
public void MarkConversionSpawn(Vector3 worldPos) => _conversionSpawnPos = worldPos;
private Renderer[] _bodyRenderers; // skinned body mesh(es), toggled by the per-client visibility pass
private FurShellRenderer[] _furRenderers; // fur draws via a custom GPU-instanced pass (NOT a Renderer); gated with the body so the avatar reveals atomically and never shows stale pooled fur
private bool _visibilityApplied;
private bool _lastVisible = true;
private bool _renderGate; // conversion: body force-hidden until the fresh hunter has settled into place
private float _renderGateUntil; // Time.time deadline that lifts _renderGate; frame-driven (see UpdateVisibility) so a mid-settle disable/despawn can't strand the body hidden forever
private MeshPainter _painter; // owns the per-avatar material instance carrying _XRayColor
private PlayerPaintSync _paintSync; // receiver-side reveal gate: body hidden until this client applied the painting
private ConcealmentMeter _concealment; // owner-only embedded-percent source (disabled on remotes)
private PlayerCharacterController _controller; // sibling, cached (the freeze re-asserts per frame)
private bool _xrayApplied;
private bool _lastXRay;
private bool _lastFlash;
private bool _frozenApplied;
private bool _lastFrozen;
private float _xrayFlashUntil; // Time.time deadline for the just-shot X-ray flash (local per client)
private HunterLaserBeam _laser; // shot beam visual, lazily attached on the hunter avatar
public override void OnNetworkSpawn()
{
_active.Add(this); // scoreboard registry (paired with OnNetworkDespawn)
ActiveVersion++; // signal the player-collision reconciler that the avatar set changed
_bodyRenderers = GetComponentsInChildren<SkinnedMeshRenderer>(true);
_furRenderers = GetComponentsInChildren<FurShellRenderer>(true);
_painter = GetComponentInChildren<MeshPainter>(true);
_paintSync = GetComponentInChildren<PlayerPaintSync>(true);
_concealment = GetComponentInChildren<ConcealmentMeter>(true);
_isFound.OnValueChanged += OnFoundChanged;
_displayName.OnValueChanged += OnDisplayNameChanged;
if (_isFound.Value) ApplyFound(); // defensive: an avatar is never normally spawned already-found
// Push the Conversion position into the replicated override before the spawn snapshot goes out.
if (IsServer && _conversionSpawnPos.HasValue)
_spawnOverride.Value = _conversionSpawnPos.Value;
var controller = _controller = GetComponent<PlayerCharacterController>();
if (IsOwner)
{
PublishDisplayName(GameSettings.PlayerName);
// Conversion respawn: restore the server-tagged position (the owner-authoritative transform
// ignores the server's Instantiate pos); NaN override = normal spawn placement.
Vector3 overridePos = _spawnOverride.Value;
if (float.IsNaN(overridePos.x))
PlaceAtSpawn();
else
ApplyOwnerPosition(overridePos);
var manager = FindFirstObjectByType<GameModeManager>(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.
DisableLocalSimulation();
// Receiver-side reveal gate: hide the body from the very first frame until this client has applied
// the owner's painting baseline (see PlayerPaintSync.PaintRevealReady). Without this a freshly
// spawned remote flashes the default grey/white material before its painting arrives.
SetBodyRenderers(false);
// The concealment meter (per-frame bake + voxelization + overlap) only feeds the LOCAL player;
// a remote's concealment is computed on its own owner's machine, so here it is wasted CPU.
if (_concealment) _concealment.enabled = false;
// NOTE: SkinnedColliderBaker deliberately stays ENABLED on remotes - stamp replay rasterizes its
// live per-frame BakedMesh to map 3D brush placements into UV space; freezing the bake would
// mis-map replicated paint onto any non-rest pose.
// Force AlwaysAnimate so the rig keeps evaluating while this body is HIDDEN locally (hide phase /
// off-screen): the authored CullUpdateTransforms stops writing bones when the renderer isn't
// visible, the pose reset then freezes the rig at rest, SkinnedColliderBaker bakes that wrong
// pose, and replicated paint lands on the wrong body part - a desync that persists once shown
// again. Remote replicas only (an owner is never hidden from its own camera).
foreach (var anim in GetComponentsInChildren<Animator>(true))
anim.cullingMode = AnimatorCullingMode.AlwaysAnimate;
// Conversion: keep the fresh in-place hunter hidden on this remote client for a short settle
// window so nobody sees it pop in or interpolate from the spawn frame. Detected via the
// replicated override (already valid here); the owner returned early above, so it is never gated.
if (!float.IsNaN(_spawnOverride.Value.x))
{
_renderGate = true;
_renderGateUntil = Time.time + conversionRevealDelay;
SetBodyRenderers(false); // hide immediately, before the first UpdateVisibility runs
}
}
public override void OnNetworkDespawn()
{
_active.Remove(this);
ActiveVersion++;
_isFound.OnValueChanged -= OnFoundChanged;
_displayName.OnValueChanged -= OnDisplayNameChanged;
}
/// <summary>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.</summary>
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<SkinnedColliderBaker>(true);
if (baker) baker.enabled = false;
var meshCol = GetComponentInChildren<MeshCollider>(true);
if (meshCol) meshCol.enabled = false;
if (IsOwner)
{
DisableLocalSimulation();
var manager = FindFirstObjectByType<GameModeManager>(FindObjectsInactive.Include);
if (manager) manager.BindSpectator();
}
}
// Stops this avatar simulating locally: movement input and the CharacterController's own depenetration.
private void DisableLocalSimulation()
{
var controller = GetComponent<PlayerCharacterController>();
if (controller) controller.enabled = false;
var cc = GetComponent<CharacterController>();
if (cc) cc.enabled = false;
}
private void Update()
{
UpdateVisibility();
UpdateEmbeddedReveal();
UpdateXRayReveal();
UpdateResolutionFreeze();
}
// Per-client. During Resolution every still-in-play hider is frozen: the pose graph holds the frame
// (PlayerPoseController.Frozen, change-guarded) and the owner's movement input is cut - re-asserted
// every frozen frame so a stray mode toggle can't quietly re-enable movement during the reveal.
// Nothing is restored: EndMatch despawns this avatar.
private void UpdateResolutionFreeze()
{
bool freeze = ComputeResolutionFreeze();
if (freeze && IsOwner && _controller)
_controller.ControlEnabled = false;
if (_frozenApplied && freeze == _lastFrozen) return;
_lastFrozen = freeze;
_frozenApplied = true;
var pose = GetComponent<PlayerPoseController>();
if (pose) pose.Frozen = freeze;
}
// True while THIS avatar is a still-in-play hider during the local resolution phase (the reveal window).
private bool ComputeResolutionFreeze()
{
var match = MatchManager.Instance;
return match && match.MatchActive &&
match.LocalSubPhase == MatchManager.MatchSubPhase.Resolution &&
role == PlayerRole.Hider && !_isFound.Value;
}
// Per-client visibility: each machine decides whether to render THIS avatar's body from the LOCAL
// viewer's perspective (see ComputeVisible). The owner's own body is left alone unless found, so this
// never fights the FirstPersonController's own-body self-hide.
private void UpdateVisibility()
{
// Frame-driven gate expiry (replaces a WaitForSeconds coroutine): a coroutine is stranded if the
// object is disabled or the converting owner disconnects mid-settle, leaving the body hidden forever.
// Time.time lifts it here every frame the component runs, so the gate can never get stuck.
if (_renderGate && Time.time >= _renderGateUntil) _renderGate = false;
bool found = _isFound.Value;
if (IsOwner && !found) return;
// While the conversion render gate is up, the body stays hidden no matter what ComputeVisible says.
// The paint gate is an additional hard hide: a remote body never shows before this client has
// applied its painting (owner is always ready; a null sync - offline/single-player - never gates).
bool paintReady = !_paintSync || _paintSync.PaintRevealReady;
bool visible = paintReady && !_renderGate && ComputeVisible(found);
if (_visibilityApplied && visible == _lastVisible) return;
SetBodyRenderers(visible);
}
private void SetBodyRenderers(bool visible)
{
if (_bodyRenderers != null)
foreach (var r in _bodyRenderers)
if (r) r.enabled = visible;
// Fur is drawn by a custom GPU-instanced pass, NOT a Renderer, so the skinned-mesh toggle above
// misses it; gate it too (enabled=false unsubscribes its camera hook), or a deferred/reveal-gated
// body would show stale pooled fur strands while its own mesh is hidden.
if (_furRenderers != null)
foreach (var f in _furRenderers)
if (f) f.enabled = visible;
_lastVisible = visible;
_visibilityApplied = true;
}
// Decides whether THIS avatar's body is rendered for the local viewer. Hunters and non-match avatars are
// always visible; a Classic-found hider is always hidden; Resolution reveals everyone. The rest is the
// hider-visibility matrix per mode:
// Classic - Hide: hidden from hunters only (hiders still see each other); Seek: visible to all.
// Conversion - Hide: hidden from EVERYONE (hiders don't see each other either); Seek: only hunters see
// hiders (so they can shoot them; hiders stay blind to each other).
private bool ComputeVisible(bool found)
{
if (found) return false; // Classic: a found hider is out of play, hidden for all
var match = MatchManager.Instance;
if (!match || !match.MatchActive) return true; // lobby / no match -> everything visible
if (role != PlayerRole.Hider) return true; // hunters are always visible
var phase = match.LocalSubPhase;
// The pre-match loading window conceals hiders exactly like Hide, so a fast-loading client can't reveal
// a hider (once its paint applies) before the match proper even begins.
if (phase == MatchManager.MatchSubPhase.Loading) phase = MatchManager.MatchSubPhase.Hide;
if (phase == MatchManager.MatchSubPhase.Resolution) return true; // all revealed at the end
if (match.LocalMatchMode == MatchManager.MatchMode.Conversion)
{
// Hiders are hidden from each other the whole match; only hunters get to see them, and only
// during Seek (so the hide phase truly hides them from everyone but themselves).
if (phase == MatchManager.MatchSubPhase.Hide) return false;
if (phase == MatchManager.MatchSubPhase.Seek) return LocalViewerIsHunter();
return true;
}
// Classic: hiders are hidden from hunters during Hide only; visible to everyone otherwise.
if (phase == MatchManager.MatchSubPhase.Hide && LocalViewerIsHunter()) return false;
return true;
}
// Owner-only (the meter runs owner-side, so EmbeddedPercent is only available here). Publishes the
// buried-too-deep flag; one network write per actual edge (guarded - the variable dirties on assignment).
private void UpdateEmbeddedReveal()
{
if (!IsOwner) return;
bool over = false;
var match = MatchManager.Instance;
if (match && match.MatchActive && role == PlayerRole.Hider && !_isFound.Value &&
_concealment && _concealment.HasResult)
over = _concealment.EmbeddedPercent >= match.EmbeddedRevealPercent;
if (over != _embeddedReveal.Value) _embeddedReveal.Value = over;
}
// Per-client X-ray glow via the material's _XRayColor alpha (through MeshPainter). Two OR-combined
// triggers: the replicated embedded flag and the local resolution phase. Change-guarded so the
// material is only touched on an actual transition.
private void UpdateXRayReveal()
{
if (!_painter) return;
// Just-shot flash: a hunter's hit lights this hider up for a brief window (server-broadcast). It drives
// a distinct, much stronger flash mode (steady + boosted), so it is tracked apart from the normal reveal.
bool flash = Time.time < _xrayFlashUntil;
bool reveal = flash || ComputeXRay();
if (_xrayApplied && reveal == _lastXRay && flash == _lastFlash) return;
_painter.SetXRayReveal(reveal, flash);
_lastXRay = reveal;
_lastFlash = flash;
_xrayApplied = true;
}
// The normal (non-flash) X-ray reveal predicate for this avatar on the local machine. The just-shot flash
// is OR-combined by the caller so it can select the stronger flash mode.
private bool ComputeXRay()
{
// Buried-hider reveal: already gated to hider + running match on the owner before it is published.
if (_embeddedReveal.Value) return true;
// Resolution reveals every remaining (un-found) hider through walls, on every client -
// exactly the still-in-play-hider-during-Resolution predicate the freeze uses.
return ComputeResolutionFreeze();
}
// --- Hunter shot beam ------------------------------------------------------------------------------
// Cosmetic and client-trusted (a co-op hide & seek, matching the ReportHit trust model). The local
// shooter draws its own beam instantly for responsiveness and relays the world-space segment to the
// server, which echoes it to every other client. The RPCs live on the hunter avatar (this
// NetworkObject), so they only run on this shooter's instances - other avatars never draw a beam.
/// <summary>Owner-only. Draws the shot beam locally now and replicates the world-space segment so every
/// other client sees the same shot.</summary>
public void FireShot(Vector3 start, Vector3 end)
{
if (!IsOwner) return;
EnsureLaser().Fire(start, end);
FireShotServerRpc(start, end);
}
[ServerRpc]
private void FireShotServerRpc(Vector3 start, Vector3 end) => FireShotClientRpc(start, end);
[ClientRpc]
private void FireShotClientRpc(Vector3 start, Vector3 end)
{
if (IsOwner) return; // the shooter already drew it locally
EnsureLaser().Fire(start, end);
}
private HunterLaserBeam EnsureLaser()
{
if (!_laser) _laser = GetComponentInChildren<HunterLaserBeam>(true);
if (!_laser) _laser = gameObject.AddComponent<HunterLaserBeam>();
return _laser;
}
// --- Hunter hit flash ------------------------------------------------------------------------------
/// <summary>Server-only. Flashes THIS hider's through-walls X-ray glow on every client for
/// <paramref name="seconds"/> (the just-shot feedback), overriding the normal reveal predicate for that
/// window. MatchManager delays the actual removal by the same span so the glow is seen before it leaves play.</summary>
public void FlashHitReveal(float seconds)
{
if (!IsServer) return;
FlashHitRevealClientRpc(Mathf.Max(0f, seconds));
}
[ClientRpc]
private void FlashHitRevealClientRpc(float seconds) => _xrayFlashUntil = Time.time + seconds;
// True if THIS machine's local player is a Hunter role variant (so it must not see hiders during hide).
// Shared with MatchHud. Called per frame per avatar during hide/seek, so the GetComponent result is
// cached per local PlayerObject; a respawn/conversion swaps the object and refreshes the cache.
private static NetworkObject _localViewerPo;
private static bool _localViewerIsHunter;
internal static bool LocalViewerIsHunter()
{
var nm = NetworkManager.Singleton;
if (!nm || nm.LocalClient == null) return false;
var po = nm.LocalClient.PlayerObject;
if (!po) return false;
if (po != _localViewerPo)
{
_localViewerPo = po;
var setup = po.GetComponent<NetworkPlayerSetup>();
_localViewerIsHunter = setup && setup.ScopedModes && setup.Role == PlayerRole.Hunter;
}
return _localViewerIsHunter;
}
// Live scoreboard source. Every match avatar is a network object present on ALL machines, so counting the
// spawned role variants is client-safe - no server-only match state (e.g. _pendingHiders) is needed.
// Hunters = hunter variants; hiders = un-found hider variants (a Classic-found hider leaves the count, a
// Conversion hit despawns the hider object and spawns a hunter one, so both sides update from the registry).
private static readonly List<NetworkPlayerSetup> _active = new List<NetworkPlayerSetup>();
/// <summary>Read-only view of all avatars spawned on this machine (the same registry the scoreboard uses).
/// Consumed by the player-collision reconciler to find the bodies the local player should pass through.</summary>
public static IReadOnlyList<NetworkPlayerSetup> Active => _active;
/// <summary>Bumped on every spawn/despawn so a per-frame consumer can cheaply detect a membership change
/// without rescanning the list.</summary>
public static int ActiveVersion { get; private set; }
// The runtime body MeshCollider (baked skinned mesh) other players' capsules collide with. MeshPainter
// creates it in Start - a frame after OnNetworkSpawn - so resolve it lazily and cache once it exists.
private MeshCollider _bodyCollider;
/// <summary>This avatar's body collider (the paint MeshCollider), or null until it has been created.
/// Cached on first successful resolve.</summary>
public MeshCollider BodyCollider
{
get
{
if (!_bodyCollider) _bodyCollider = GetComponentInChildren<MeshCollider>(true);
return _bodyCollider;
}
}
internal static void CountRoles(out int hunters, out int hiders)
{
hunters = 0;
hiders = 0;
for (int i = 0; i < _active.Count; i++)
{
var p = _active[i];
if (!p || !p.scopedModes) continue; // ignore the neutral lobby avatar
if (p.role == PlayerRole.Hunter) hunters++;
else if (!p._isFound.Value) hiders++;
}
}
/// <summary>
/// Places the freshly-spawned owner: role-specific spawn area first, then the shared area (a random
/// point inside the collider), falling back to the deterministic client-id ring. Owner-authoritative -
/// the NetworkTransform replicates the result.
/// </summary>
private void PlaceAtSpawn()
{
if (TryGetSpawnArea(out Collider area))
PlaceRandomlyInArea(area);
else
PlaceOnSpawnRing();
}
/// <summary>
/// Re-asserts the server-delivered Conversion position on the owner (whose transform is the source of
/// truth), BEFORE PlayerCharacterController's first Update snapshots the kill-plane rescue target -
/// otherwise a hider tagged mid-climb could fall and be rescued to the still-zero default (origin).
/// The CharacterController is toggled around the write (it overrides direct transform writes).
/// </summary>
private void ApplyOwnerPosition(Vector3 worldPos)
{
var cc = GetComponent<CharacterController>();
CharacterTeleport.Move(cc, transform, worldPos);
}
// 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 rejection-sampled against
/// the convex shape (honours the real outline, not just the AABB), Y a touch above the floor so
/// gravity settles. The CharacterController is toggled around the move (see ApplyOwnerPosition).
/// </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);
ApplyOwnerPosition(target);
}
// 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 (host) keeps the origin; later joiners take
/// ring slots, stepping the radius out once a ring fills. Only X/Z shift (gravity settles Y).
/// </summary>
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;
ApplyOwnerPosition(transform.position + offset);
}
}