From fe8f12b8bb0893d7db368e8f7fc0217af8810942 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Tue, 30 Jun 2026 16:18:53 +0200 Subject: [PATCH] Spawn converted hunters in place and smooth their reveal Fix the in-place Conversion respawn snapping to the origin (0,0,0), which surfaced when a hider was tagged mid wall-climb. The owner holds authority over the NetworkTransform, so the server's Instantiate position is ignored on the owner. Deliver the tagged world position through a replicated _spawnOverride the server writes in OnNetworkSpawn, and have the owner re-assert it before PlayerCharacterController's first Update snapshots the kill-plane rescue target. This replaces the fragile SpawnedMidSeek phase check. Also hide a freshly converted hunter on other clients for a short settle window (conversionRevealDelay) so nobody sees it pop in or interpolate from the spawn frame; the shot hider is already despawned, then the hunter cleanly materializes in its place. Co-Authored-By: Claude Opus 4.8 --- Assets/Scripts/MatchManager.cs | 5 ++ Assets/Scripts/NetworkPlayerSetup.cs | 103 ++++++++++++++++++++++----- 2 files changed, 92 insertions(+), 16 deletions(-) diff --git a/Assets/Scripts/MatchManager.cs b/Assets/Scripts/MatchManager.cs index 033ebd2..2d64cb2 100644 --- a/Assets/Scripts/MatchManager.cs +++ b/Assets/Scripts/MatchManager.cs @@ -682,6 +682,11 @@ public class MatchManager : MonoBehaviour current.Despawn(true); var go = Instantiate(hunterPrefab, pos, rot); + // Hand the tagged world position to the owner: the owner-authoritative transform ignores the server's + // Instantiate pos, so without this the converted hunter snaps to the prefab origin (0,0,0). Must be set + // BEFORE the spawn so OnNetworkSpawn can push it into the replicated _spawnOverride snapshot. + var setup = go.GetComponent(); + if (setup) setup.MarkConversionSpawn(pos); go.GetComponent().SpawnAsPlayerObject(clientId); } diff --git a/Assets/Scripts/NetworkPlayerSetup.cs b/Assets/Scripts/NetworkPlayerSetup.cs index af3bb6f..31842e8 100644 --- a/Assets/Scripts/NetworkPlayerSetup.cs +++ b/Assets/Scripts/NetworkPlayerSetup.cs @@ -1,3 +1,4 @@ +using System.Collections; using Unity.Netcode; using UnityEngine; @@ -45,6 +46,14 @@ public class NetworkPlayerSetup : NetworkBehaviour // 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.")] @@ -72,9 +81,28 @@ public class NetworkPlayerSetup : NetworkBehaviour /// target that is already out of play. public bool IsFound => _isFound.Value; + // Conversion-mode in-place respawn position. The owner holds authority over the NetworkTransform, so the + // server's Instantiate position is NOT honoured on the owner (it would otherwise snap to the prefab origin, + // 0,0,0). The server writes the tagged world position here in its OnNetworkSpawn so it rides the initial + // spawn snapshot to the owner, which then restores it itself. The NaN sentinel means "no override" (a + // normal lobby/match-start spawn) -> the owner runs its usual spawn-area/ring 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: the world position the converted hunter must keep. + // Pushed into the replicated _spawnOverride during OnNetworkSpawn (where the variable is spawned and the + // server is allowed to write it - a pre-spawn write is silently dropped); a plain field needs no replication. + 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 bool _visibilityApplied; private bool _lastVisible = true; + private bool _renderGate; // conversion: body force-hidden until the fresh hunter has settled into place public override void OnNetworkSpawn() { @@ -82,16 +110,24 @@ public class NetworkPlayerSetup : NetworkBehaviour _isFound.OnValueChanged += OnFoundChanged; if (_isFound.Value) ApplyFound(); // defensive: an avatar is never normally spawned already-found + // Server pushes a Conversion respawn's tagged position into the replicated override before the spawn + // snapshot goes out, so the owner (which owns the transform) can restore it in its own OnNetworkSpawn. + if (IsServer && _conversionSpawnPos.HasValue) + _spawnOverride.Value = _conversionSpawnPos.Value; + var controller = GetComponent(); if (IsOwner) { - // A Conversion-mode tag respawns the hider as this Hunter variant mid-Seek; keep the server-set - // position (it converts where it was tagged) by skipping the spawn-area/ring placement. Match-start - // variants spawn while the sub-phase is still None, so they DO place; end-match lobby avatars spawn - // after the sub-phase is cleared, so they place too. Only an in-place conversion lands here mid-Seek. - if (!SpawnedMidSeek()) + // A Conversion-mode tag respawns the hider as this Hunter variant in place. The owner-authoritative + // transform ignores the server's Instantiate position, so without help the owner snaps to the prefab + // origin (0,0,0); the server delivers the tagged position via _spawnOverride, restored here. A normal + // spawn (NaN override) runs the usual spawn-area/ring placement instead. + Vector3 overridePos = _spawnOverride.Value; + if (float.IsNaN(overridePos.x)) PlaceAtSpawn(); + else + ApplyOwnerPosition(overridePos); var manager = FindFirstObjectByType(FindObjectsInactive.Include); if (manager) @@ -128,6 +164,19 @@ public class NetworkPlayerSetup : NetworkBehaviour // replicas only: an owner is never hidden from its own camera. foreach (var anim in GetComponentsInChildren(true)) anim.cullingMode = AnimatorCullingMode.AlwaysAnimate; + + // Conversion mode: a hider shot during Seek is despawned and respawned in place as this Hunter + // variant. Keep the fresh hunter hidden on this (remote) client for a short settle window so nobody + // sees it appear or interpolate from the spawn frame - the shot hider is already gone, then the + // hunter cleanly materializes in its place. Detected via the replicated override the server wrote + // above; it rides the spawn snapshot, so it is already valid here. The owner returned early above + // (first-person, own body self-hidden), so it is never gated. + if (!float.IsNaN(_spawnOverride.Value.x)) + { + _renderGate = true; + SetBodyRenderers(false); // hide immediately, before the first UpdateVisibility runs + StartCoroutine(RevealAfterSettle()); + } } public override void OnNetworkDespawn() @@ -182,17 +231,30 @@ public class NetworkPlayerSetup : NetworkBehaviour bool found = _isFound.Value; if (IsOwner && !found) return; - bool visible = ComputeVisible(found); + // While the conversion render gate is up, the body stays hidden no matter what ComputeVisible says. + bool visible = !_renderGate && ComputeVisible(found); if (_visibilityApplied && visible == _lastVisible) return; + SetBodyRenderers(visible); + } + // Toggles every body renderer and records the applied state for the change-guard above. + private void SetBodyRenderers(bool visible) + { if (_bodyRenderers != null) foreach (var r in _bodyRenderers) if (r) r.enabled = visible; - _lastVisible = visible; _visibilityApplied = true; } + // Conversion: drop the render gate after the settle delay so the next visibility pass reveals the hunter + // at its final, replicated position. Per-client and cosmetic (each client reveals on its own clock). + private IEnumerator RevealAfterSettle() + { + yield return new WaitForSeconds(conversionRevealDelay); + _renderGate = false; + } + // 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: @@ -224,15 +286,6 @@ public class NetworkPlayerSetup : NetworkBehaviour return true; } - // True if this avatar is spawning during the SEEK sub-phase - i.e. a Conversion-mode tag respawning a - // hider as the Hunter variant in place. Match-start and end-match respawns never happen during Seek - // (start spawns before Hide, end spawns after the phase is cleared), so this uniquely flags a conversion. - private static bool SpawnedMidSeek() - { - var match = MatchManager.Instance; - return match && match.LocalSubPhase == MatchManager.MatchSubPhase.Seek; - } - // True if THIS machine's local player is a Hunter role variant (so it must not see hiders during hide). private static bool LocalViewerIsHunter() { @@ -259,6 +312,24 @@ public class NetworkPlayerSetup : NetworkBehaviour PlaceOnSpawnRing(); } + /// + /// Re-asserts the server-delivered Conversion position on the owner. The owner-authoritative + /// NetworkTransform makes the owner's transform the source of truth, so the server's Instantiate + /// position alone doesn't stick - the owner must set it here, BEFORE PlayerCharacterController's first + /// Update snapshots the kill-plane rescue target (otherwise a hider tagged mid-wall-climb spawns, falls + /// off the fresh hunter capsule below the kill plane, and is rescued to the still-zero default target = + /// origin). The CharacterController is toggled around the write because it otherwise overrides direct + /// transform writes (same pattern as PlaceOnSpawnRing/PlaceRandomlyInArea). + /// + private void ApplyOwnerPosition(Vector3 worldPos) + { + var cc = GetComponent(); + bool reenable = cc && cc.enabled; + if (cc) cc.enabled = false; + transform.position = worldPos; + if (cc) cc.enabled = reenable; + } + // 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)