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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-30 16:18:53 +02:00
1 parent 24de7ef3da
commit fe8f12b8bb
2 files changed
+92 -16

No files matched your search

+5
View File
@@ -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<NetworkPlayerSetup>();
if (setup) setup.MarkConversionSpawn(pos);
go.GetComponent<NetworkObject>().SpawnAsPlayerObject(clientId);
}
+87 -16
View File
@@ -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.</summary>
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<Vector3> _spawnOverride =
new NetworkVariable<Vector3>(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;
/// <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 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<PlayerCharacterController>();
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<GameModeManager>(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<Animator>(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();
}
/// <summary>
/// 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).
/// </summary>
private void ApplyOwnerPosition(Vector3 worldPos)
{
var cc = GetComponent<CharacterController>();
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)