using System.Collections.Generic; using Unity.Netcode; using UnityEngine; using Chameleon.Components; /// /// 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. /// [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; /// 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" (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 _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; // 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 _embeddedReveal = new NetworkVariable(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 _spawnOverride = new NetworkVariable(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; /// 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). 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(true); _furRenderers = GetComponentsInChildren(true); _painter = GetComponentInChildren(true); _paintSync = GetComponentInChildren(true); _concealment = GetComponentInChildren(true); _isFound.OnValueChanged += OnFoundChanged; 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(); if (IsOwner) { // 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(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(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; } /// 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) { DisableLocalSimulation(); var manager = FindFirstObjectByType(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(); if (controller) controller.enabled = false; var cc = GetComponent(); 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(); 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. /// Owner-only. Draws the shot beam locally now and replicates the world-space segment so every /// other client sees the same shot. 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(true); if (!_laser) _laser = gameObject.AddComponent(); return _laser; } // --- Hunter hit flash ------------------------------------------------------------------------------ /// Server-only. Flashes THIS hider's through-walls X-ray glow on every client for /// (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. 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(); _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 _active = new List(); /// 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. public static IReadOnlyList Active => _active; /// Bumped on every spawn/despawn so a per-frame consumer can cheaply detect a membership change /// without rescanning the list. 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; /// This avatar's body collider (the paint MeshCollider), or null until it has been created. /// Cached on first successful resolve. public MeshCollider BodyCollider { get { if (!_bodyCollider) _bodyCollider = GetComponentInChildren(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++; } } /// /// 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. /// private void PlaceAtSpawn() { if (TryGetSpawnArea(out Collider area)) PlaceRandomlyInArea(area); else PlaceOnSpawnRing(); } /// /// 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). /// private void ApplyOwnerPosition(Vector3 worldPos) { var cc = GetComponent(); 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(); return area; } /// /// 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). /// 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; } /// /// 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). /// 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); } }