From 0f108fce47c46c0e2c242d4c1d7a6cb8aa45c450 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Mon, 6 Jul 2026 09:33:15 +0200 Subject: [PATCH] Add conversion statues shown in resolution phase In Conversion mode, capture a snapshot (pose index + paint) of each hider at conversion time and materialize a frozen, painted statue at the found spot for the resolution phase, independent of the now-hunter avatar. - MatchStatue.cs (new): server-init pose/paint, network-driven pose then freeze, reveal-gate (hidden until paint applied), kills inherited sim. - PlayerPaintSync: _isStatue (sibling auto-detect) suppresses the three auto-behaviours so MatchStatue drives the paint flow deterministically. - MatchManager: statuePrefab; capture in ConvertToHunter; spawn on resolution enter; despawn + record clear on EndMatch/teardown/start. Co-Authored-By: Claude Opus 4.8 --- Assets/Scripts/Network/MatchManager.cs | 83 +++++++++++++ Assets/Scripts/Network/MatchStatue.cs | 131 +++++++++++++++++++++ Assets/Scripts/Network/MatchStatue.cs.meta | 2 + Assets/Scripts/Network/PlayerPaintSync.cs | 21 +++- 4 files changed, 232 insertions(+), 5 deletions(-) create mode 100644 Assets/Scripts/Network/MatchStatue.cs create mode 100644 Assets/Scripts/Network/MatchStatue.cs.meta diff --git a/Assets/Scripts/Network/MatchManager.cs b/Assets/Scripts/Network/MatchManager.cs index 481a608..97fe2bc 100644 --- a/Assets/Scripts/Network/MatchManager.cs +++ b/Assets/Scripts/Network/MatchManager.cs @@ -72,6 +72,12 @@ public class MatchManager : MonoBehaviour "free-cam rig is a separate scene object. Must be a registered network prefab.")] [SerializeField] private GameObject spectatorPrefab; + [Tooltip("CONVERSION mode only: a frozen, painted 'statue' spawned at the start of Resolution at the exact " + + "spot / stance / painting each hider held the moment it was converted into a hunter, so everyone " + + "sees where each hider was caught. Purely presentational (no controller / collision / role). Must " + + "be a registered network prefab (a stripped Hider variant). Empty = statues disabled.")] + [SerializeField] private GameObject statuePrefab; + [Header("Roles")] [Tooltip("How many randomly chosen players become Hunters at match start; everyone else is a Hider. " + "0 = all Hiders; a value at or above the player count makes everyone a Hunter. Default 1.")] @@ -279,6 +285,13 @@ public class MatchManager : MonoBehaviour private int _loadGenerationClient; private Coroutine _clientLoadPoll; + // Server-only. Conversion statues: a snapshot of each converted hider (spot / stance / painting) captured + // on the conversion, materialized as frozen statue objects only at the start of Resolution and despawned + // when the match ends. Only populated in Conversion mode (ConvertToHunter is the sole capture site). + private class StatueRecord { public Vector3 pos; public Quaternion rot; public int poseIndex; public byte[] paint; } + private readonly List _conversionRecords = new List(); + private readonly List _statues = new List(); + private void Awake() { if (Instance && Instance != this) { Destroy(this); return; } @@ -424,6 +437,9 @@ public class MatchManager : MonoBehaviour _matchAvatarIds.Clear(); _loadTargetIds.Clear(); _clientLoadPoll = null; + // Teardown unloads the gameplay scenes (destroys the statue objects); just drop our references. + _statues.Clear(); + _conversionRecords.Clear(); if (_net) UnhookSessionCallbacks(); ReturnToMainOnly(); @@ -613,6 +629,8 @@ public class MatchManager : MonoBehaviour private IEnumerator MatchSequence(HashSet hunterIds) { + _conversionRecords.Clear(); // fresh match inherits no stale statue captures (EndMatch also clears) + // Freeze everyone in Loading BEFORE unloading the lobby: the lobby floor vanishes a moment before the // map's is loaded, so the still-bound lobby avatars would otherwise drop into the void during the swap. // Loading gates movement, gravity AND the local own-body mesh on every client (see GameModeManager); the @@ -653,6 +671,9 @@ public class MatchManager : MonoBehaviour yield return RunSeekPhase(seekDuration); SetPhaseServer(MatchSubPhase.Resolution, resolveDuration); + // Conversion mode: materialize a frozen statue of every converted hider at its capture spot, now that + // Resolution (which reveals everyone) has begun. No-op with no records / no statue prefab / in Classic. + SpawnConversionStatues(); yield return WaitPhase(resolveDuration, null); _phaseRoutine = null; @@ -1121,6 +1142,9 @@ public class MatchManager : MonoBehaviour } Vector3 pos = current.transform.position; Quaternion rot = current.transform.rotation; + // Snapshot the hider's spot / stance / painting NOW, before it despawns - a frozen statue is + // materialized from this at the start of Resolution (see SpawnConversionStatues). + CaptureConversionStatue(pos, rot, current); current.Despawn(true); var go = Instantiate(hunterPrefab, pos, rot); @@ -1131,6 +1155,63 @@ public class MatchManager : MonoBehaviour go.GetComponent().SpawnAsPlayerObject(clientId); } + // Server-only. Captures a converting hider's spot / stance / painting so a frozen statue can be + // materialized from it at Resolution. Skipped entirely when no statue prefab is assigned. The painting + // is captured from the SERVER's own replica (the host holds the full paint via the live stream); a + // capture miss stores a null snapshot -> the statue simply stays on the blank seed look. + private void CaptureConversionStatue(Vector3 pos, Quaternion rot, NetworkObject hider) + { + if (!statuePrefab) return; + + int poseIndex = 0; + var pose = hider.GetComponentInChildren(true); + if (pose) poseIndex = pose.CurrentIndex; + + byte[] paint = null; + var painter = hider.GetComponentInChildren(true); + if (painter) painter.TryCaptureSnapshot(out paint); + + _conversionRecords.Add(new StatueRecord { pos = pos, rot = rot, poseIndex = poseIndex, paint = paint }); + } + + // Server-only. Spawns one frozen statue per captured conversion at its recorded pos/rot, handing each the + // stance + painting snapshot before it spawns (server-owned, non-player). Called once at Resolution start; + // the records are consumed here. No-op with no prefab / no records (Classic never records any). + private void SpawnConversionStatues() + { + if (!statuePrefab || _conversionRecords.Count == 0) return; + + foreach (var rec in _conversionRecords) + { + var go = Instantiate(statuePrefab, rec.pos, rec.rot); + var statue = go.GetComponent(); + if (statue) statue.ServerInit(rec.poseIndex, rec.paint); + var netObj = go.GetComponent(); + if (netObj) + { + netObj.Spawn(); // server-owned, non-player: replicates to every client as an observer + _statues.Add(netObj); + } + else + { + Debug.LogError("[MatchManager] Statue prefab has no NetworkObject - cannot spawn.", this); + Destroy(go); + } + } + _conversionRecords.Clear(); + } + + // Server-only. Despawns every live conversion statue (match ending / back to lobby) and drops the list. + private void DespawnConversionStatues() + { + for (int i = 0; i < _statues.Count; i++) + { + var s = _statues[i]; + if (s && s.IsSpawned) s.Despawn(true); + } + _statues.Clear(); + } + // True if the client's current avatar is a Hunter role variant (used to gate shooting server-side). private bool IsHunter(ulong clientId) { @@ -1150,6 +1231,8 @@ public class MatchManager : MonoBehaviour // the replicated sub-phase so every client drops its match HUD/visibility before the scene swaps. if (_phaseRoutine != null) { StopCoroutine(_phaseRoutine); _phaseRoutine = null; } _pendingHiders.Clear(); + DespawnConversionStatues(); // drop any Resolution statues before the map unloads + _conversionRecords.Clear(); // and any captures from a match ended before Resolution SetPhaseServer(MatchSubPhase.None, 0f); Phase = MatchPhase.Transitioning; diff --git a/Assets/Scripts/Network/MatchStatue.cs b/Assets/Scripts/Network/MatchStatue.cs new file mode 100644 index 0000000..fe9d6a8 --- /dev/null +++ b/Assets/Scripts/Network/MatchStatue.cs @@ -0,0 +1,131 @@ +using Unity.Netcode; +using UnityEngine; +using Chameleon.Components; + +/// +/// A frozen, painted "statue" of a hider at the exact spot / stance / painting it held the moment it was +/// CONVERTED into a hunter (Conversion mode only). captures those on the +/// conversion and spawns one statue per converted hider at the start of the Resolution phase, so every +/// player sees where each hider was caught as a faithful representation (same skinned body, camo painting +/// and fur) - independent of the now-hunter avatar, which has long since moved on. +/// +/// Purely presentational: no controller, no collision, no gameplay role. It is a dedicated prefab (NOT a +/// avatar), so it never pollutes the scoreboard, per-client visibility +/// matrix or resolution X-ray reveal - those all key off NetworkPlayerSetup, which the statue lacks. +/// +/// Server-owned. Three replicated ingredients rebuild it identically on every client: +/// +/// Transform - the server Instantiates it at the captured pos/rot; the (server-authoritative) +/// NetworkTransform replicates that. +/// Painting - rides the existing snapshot transfer; this object +/// drives the push explicitly (server loads the captured snapshot, then broadcasts it), and PlayerPaintSync +/// suppresses its own auto pull/publish for a statue receiver so the two never race. +/// Stance - a single replicated pose index the pose controller reproduces (network-driven), +/// then freezes. +/// +/// +[DisallowMultipleComponent] +public class MatchStatue : NetworkBehaviour +{ + [Tooltip("Seconds the captured stance is allowed to blend in (as the network-driven idle of that pose) " + + "before the rig is frozen on that frame. Keeps the pose from snapping in mid-blend.")] + [Min(0f)] + [SerializeField] private float poseSettle = 0.35f; + + // Server-only handoff from MatchManager.SpawnConversionStatues, consumed in OnNetworkSpawn / the first + // ready frame. The paint may be null (capture failed) -> the statue stays on the blank seed look. + private byte[] _serverPaint; + private int _serverPoseIndex; + + // The captured stance, replicated so every client poses the rig identically (server-written on spawn). + private readonly NetworkVariable _poseIndex = + new NetworkVariable(0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); + + private MeshPainter _painter; + private PlayerPaintSync _paintSync; + private PlayerPoseController _pose; + private SkinnedMeshRenderer[] _bodyRenderers; + private FurShellRenderer[] _furRenderers; + + private bool _hostPaintDone; // server: the captured snapshot has been loaded + pushed to peers (one-shot) + private bool _bodyShown; // reveal-gate change guard + private bool _frozen; // pose freeze applied + private float _freezeAt; // Time.time deadline that pins the settled pose + + /// Server-only. Call on a freshly-instantiated statue BEFORE spawning it: the captured painting + /// snapshot (may be null) and the stance pose index to reproduce. + public void ServerInit(int poseIndex, byte[] paint) + { + _serverPoseIndex = poseIndex; + _serverPaint = paint; + } + + public override void OnNetworkSpawn() + { + _painter = GetComponentInChildren(true); + _paintSync = GetComponentInChildren(true); + _pose = GetComponentInChildren(true); + _bodyRenderers = GetComponentsInChildren(true); + _furRenderers = GetComponentsInChildren(true); + + // Pure presentation: kill any inherited simulation so nothing moves/falls or fights the held pose. + // Done before the disabled controllers' first Update (OnNetworkSpawn runs during the spawn), so no + // gravity frame slips through. + var controller = GetComponent(); + if (controller) controller.enabled = false; + var cc = GetComponent(); + if (cc) cc.enabled = false; + + if (IsServer) _poseIndex.Value = _serverPoseIndex; + + // Hide the body until the painting has actually landed locally, so no client flashes the blank seed + // material before its snapshot applies (mirrors the avatar's receiver-side reveal gate). + SetBodyVisible(false); + + // Reproduce the captured stance: network-driven so no local input / capsule / camera work runs; the + // idle of that pose plays for a short settle, then Update freezes the rig on that frame. + if (_pose) + { + _pose.EnableNetworkDrive(); + _pose.SetNetworkState(_poseIndex.Value, PlayerPoseController.Locomotion.Idle); + } + _freezeAt = Time.time + Mathf.Max(0f, poseSettle); + } + + private void Update() + { + // Server owner: once the painter is set up, load the captured snapshot and push it to every peer over + // the existing PlayerPaintSync transfer. Always publish a baseline (even a null capture -> the blank + // seed) so a client's receiver-side gate opens and the body can reveal. One-shot. + if (IsServer && !_hostPaintDone && _painter && _painter.IsPaintReady && _paintSync) + { + _hostPaintDone = true; + if (_serverPaint != null) _painter.LoadPaintFromSnapshot(_serverPaint); + _paintSync.BroadcastLoadedPaint(); + _serverPaint = null; + } + + // Reveal once the painting is applied locally: the server right after its load/publish, a client once + // its received snapshot has caught up to the published baseline. + bool paintReady = IsServer ? _hostPaintDone : (_paintSync && _paintSync.PaintRevealReady); + if (paintReady && !_bodyShown) SetBodyVisible(true); + + // Re-assert the stance until it has settled (covers a pose index arriving a frame late), then freeze. + if (_pose && !_frozen) + { + _pose.SetNetworkState(_poseIndex.Value, PlayerPoseController.Locomotion.Idle); + if (Time.time >= _freezeAt) { _pose.Frozen = true; _frozen = true; } + } + } + + // Toggles the skinned body AND the custom-pass fur together (the fur is not a Renderer, so the skinned + // toggle misses it) - same atomic reveal the avatar uses, so a statue never shows stale pooled fur. + private void SetBodyVisible(bool visible) + { + _bodyShown = visible; + if (_bodyRenderers != null) + foreach (var r in _bodyRenderers) if (r) r.enabled = visible; + if (_furRenderers != null) + foreach (var f in _furRenderers) if (f) f.enabled = visible; + } +} diff --git a/Assets/Scripts/Network/MatchStatue.cs.meta b/Assets/Scripts/Network/MatchStatue.cs.meta new file mode 100644 index 0000000..ff09564 --- /dev/null +++ b/Assets/Scripts/Network/MatchStatue.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: eeaab136fd6a85343b3ffe981dad693f \ No newline at end of file diff --git a/Assets/Scripts/Network/PlayerPaintSync.cs b/Assets/Scripts/Network/PlayerPaintSync.cs index 6504c71..ba8fc79 100644 --- a/Assets/Scripts/Network/PlayerPaintSync.cs +++ b/Assets/Scripts/Network/PlayerPaintSync.cs @@ -138,7 +138,18 @@ public class PlayerPaintSync : NetworkBehaviour // spawn), applied in LateUpdate once this fresh painter is ready. Null = nothing to carry. private byte[] _pendingCarry; - private void Awake() => _painter = GetComponentInChildren(true); + // True when this receiver sits on a MatchStatue: a statue drives its painting explicitly (the server + // loads the captured snapshot then broadcasts it), so the owner-carry take, the client auto snapshot- + // request and the no-carry blank publish are all suppressed to keep them from racing that push. + private bool _isStatue; + + private void Awake() + { + _painter = GetComponentInChildren(true); + // Detected from the sibling component so no inspector flag can be forgotten on the prefab. + var statue = GetComponentInChildren(true); + _isStatue = statue; + } public override void OnNetworkSpawn() { @@ -151,7 +162,7 @@ public class PlayerPaintSync : NetworkBehaviour _painter.OnHistoryCheckpoint += OnLocalCheckpoint; _painter.OnHistoryRestore += OnLocalRestore; - if (MatchManager.Instance) _pendingCarry = MatchManager.Instance.TakeCarriedPaint(); + if (MatchManager.Instance && !_isStatue) _pendingCarry = MatchManager.Instance.TakeCarriedPaint(); } else { @@ -178,8 +189,8 @@ public class PlayerPaintSync : NetworkBehaviour _heightApplyPending = true; // The host's replica already holds the full paint via the live stream; only a genuine - // late joiner needs the baseline pulled over. - if (!IsServer) + // late joiner needs the baseline pulled over. A statue never pulls - the server pushes it. + if (!IsServer && !_isStatue) { _awaitingSnapshot = true; if (logSync) LogSync("late joiner: requesting baseline snapshot"); @@ -293,7 +304,7 @@ public class PlayerPaintSync : NetworkBehaviour // avatar must still hand every peer an explicit (blank) snapshot, otherwise its body stays hidden // forever behind the receiver-side reveal gate. Carry/manual-load already publish (and set the // cutoff), so this only fires for the no-carry case. - if (_publishedCutoff.Value < 0 && _pendingCarry == null && _painter && _painter.IsPaintReady) + if (_publishedCutoff.Value < 0 && _pendingCarry == null && !_isStatue && _painter && _painter.IsPaintReady) BroadcastLoadedPaint(); // Delta-only publish: the NetworkVariable dirties only on an actual change.