From 414beaa403c0bddea4e24113bf39b23357f50905 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Mon, 6 Jul 2026 11:17:19 +0200 Subject: [PATCH] Add player-collision toggle and make conversion statues follow it Add a per-machine "Players Collide" switch on GameModeManager: a self-healing reconciler routes the local capsule through other avatars' body colliders via Physics.IgnoreCollision when disabled, re-asserting every frame while off to survive the late-created runtime MeshCollider, conversion respawns and teleport resets. Expose Active/ActiveVersion/ BodyCollider on NetworkPlayerSetup for the reconciler. Give MatchStatue the same Active registry + BodyCollider pattern so the frozen conversion statues obey the same toggle (they carry their own paint MeshCollider but are deliberately not NetworkPlayerSetup avatars). Also drive the statue's steady X-ray reveal in Resolution, matching an un-found hider. Co-Authored-By: Claude Opus 4.8 --- Assets/Scripts/GameMode/GameModeManager.cs | 77 ++++++++++++++++++++ Assets/Scripts/Network/MatchStatue.cs | 41 ++++++++++- Assets/Scripts/Network/NetworkPlayerSetup.cs | 25 +++++++ 3 files changed, 142 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/GameMode/GameModeManager.cs b/Assets/Scripts/GameMode/GameModeManager.cs index 33e76dd..8ac99a0 100644 --- a/Assets/Scripts/GameMode/GameModeManager.cs +++ b/Assets/Scripts/GameMode/GameModeManager.cs @@ -42,6 +42,19 @@ public class GameModeManager : MonoBehaviour [Tooltip("Mode active at startup (0 = Player Control).")] public int startModeIndex = 0; + [Header("Player collision")] + [Tooltip("ON = players physically block each other (default). OFF = the local player passes through every " + + "other player - their CharacterController stops colliding with other players' body colliders. " + + "Only affects player-vs-player; environment collision is unchanged. Can be toggled live in Play mode.")] + [SerializeField] private bool playersCollide = true; + + /// Whether players physically block each other. Setting it re-applies on the next frame. + public bool PlayersCollide + { + get => playersCollide; + set { playersCollide = value; _playerCollisionDirty = true; } + } + public PlayerCharacterController Player => player; public ThirdPersonOrbitInput CameraInput => cameraInput; public MeshPainter Painter => painter; @@ -64,6 +77,12 @@ public class GameModeManager : MonoBehaviour private int _resumeIndex = -1; private bool _loadFrozen; // true while the pre-match Loading barrier is holding the local player still + // Player-collision reconciler state (see ReconcilePlayerCollision). Non-serialized: reset each play session. + private bool _playerCollisionDirty = true; // force a re-apply (startup, toggle flip, inspector edit) + private CharacterController _localCapsule; // the local player's capsule, cached to detect a respawn swap + + private void OnValidate() => _playerCollisionDirty = true; // an inspector toggle takes effect next frame + /// The currently active mode (null before startup). public GameMode ActiveMode => (_activeIndex >= 0 && _activeIndex < _modes.Count) ? _modes[_activeIndex] : null; @@ -318,6 +337,9 @@ public class GameModeManager : MonoBehaviour private void Update() { + // Independent of mode/pause: keep player-vs-player collision matching the toggle every frame. + ReconcilePlayerCollision(); + if (_paused) return; if (_modes.Count == 0) return; // No active mode = the inert no-player state (pre-lobby, or after ReleaseToNoPlayer). Ignore the @@ -393,6 +415,61 @@ public class GameModeManager : MonoBehaviour match.LocalSubPhase == MatchManager.MatchSubPhase.Loading; } + // Keeps player-vs-player collision in sync with `playersCollide`. Only the LOCAL owner's CharacterController + // actively depenetrates (remote replicas run with their controller disabled), and it collides with the other + // avatars' body MeshColliders (and conversion statues, which carry the same runtime paint collider) - so + // making the local capsule ignore those colliders is a complete, per-machine way to let the player pass + // through both. Environment collision is untouched. + // + // While OFF this re-asserts every frame (n = a handful of avatars, so it is cheap) because Physics.IgnoreCollision + // is fragile exactly here: its state resets whenever a collider is disabled/re-enabled (teleports toggle the + // capsule) and a freshly-spawned avatar's MeshCollider only appears a frame after spawn. Re-applying each frame + // sidesteps all of that. While ON it does one restoring pass on a change, then stays idle (collision is the default). + private void ReconcilePlayerCollision() + { + var capsule = player ? player.GetComponent() : null; + + bool changed = _playerCollisionDirty || capsule != _localCapsule; + _playerCollisionDirty = false; + _localCapsule = capsule; + + if (playersCollide) + { + // Collisions on: only act on a transition (toggle flip / new capsule) to restore the pairs we ignored. + if (changed) ApplyPlayerCollisionIgnore(capsule, false); + return; + } + + // Collisions off: re-assert the ignore against every current avatar (see the fragility note above). + ApplyPlayerCollisionIgnore(capsule, true); + } + + // Ignores (or restores) collision between the local capsule and every OTHER avatar's body collider, + // plus every conversion statue (its own paint MeshCollider - otherwise the local player still bumps into + // statues while the toggle is off, since statues aren't NetworkPlayerSetup avatars). + private void ApplyPlayerCollisionIgnore(CharacterController capsule, bool ignore) + { + if (!capsule) return; // no local player (pre-spawn / spectator / found) - nothing to gate + + var avatars = NetworkPlayerSetup.Active; + for (int i = 0; i < avatars.Count; i++) + { + var avatar = avatars[i]; + if (!avatar || avatar.IsOwner) continue; // skip the local player's own avatar + var col = avatar.BodyCollider; + if (col && col.enabled) Physics.IgnoreCollision(capsule, col, ignore); + } + + var statues = MatchStatue.Active; + for (int i = 0; i < statues.Count; i++) + { + var statue = statues[i]; + if (!statue) continue; + var col = statue.BodyCollider; + if (col && col.enabled) Physics.IgnoreCollision(capsule, col, ignore); + } + } + // Cached body/fur renderers of the currently bound local avatar, re-resolved only when `player` changes // (a lobby->match respawn rebinds mid-load), so the per-frame Loading hide doesn't walk the hierarchy each tick. private PlayerCharacterController _bodyHideOwner; diff --git a/Assets/Scripts/Network/MatchStatue.cs b/Assets/Scripts/Network/MatchStatue.cs index fe9d6a8..f5081c0 100644 --- a/Assets/Scripts/Network/MatchStatue.cs +++ b/Assets/Scripts/Network/MatchStatue.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using Unity.Netcode; using UnityEngine; using Chameleon.Components; @@ -62,6 +63,8 @@ public class MatchStatue : NetworkBehaviour public override void OnNetworkSpawn() { + _active.Add(this); // player-collision registry (paired with OnNetworkDespawn) + _painter = GetComponentInChildren(true); _paintSync = GetComponentInChildren(true); _pose = GetComponentInChildren(true); @@ -92,6 +95,35 @@ public class MatchStatue : NetworkBehaviour _freezeAt = Time.time + Mathf.Max(0f, poseSettle); } + public override void OnNetworkDespawn() + { + _active.Remove(this); + } + + // Statues carry their own runtime paint MeshCollider (MeshPainter bakes it), so the local player's capsule + // bumps into them like any body - but they are NOT NetworkPlayerSetup avatars and so aren't in that registry. + // Tracked here so GameModeManager's player-collision toggle can let the local player pass through statues + // too: a statue is a frozen player representation, so it follows the same "players collide" switch. + private static readonly List _active = new List(); + + /// Read-only view of the statues spawned on this machine. Consumed by the player-collision + /// reconciler so the local player passes through statues when player collision is off. + public static IReadOnlyList Active => _active; + + // The runtime body MeshCollider (baked skinned mesh) the local capsule collides with. MeshPainter creates + // it in Start - a frame after OnNetworkSpawn - so resolve it lazily and cache once it exists. + private MeshCollider _bodyCollider; + + /// This statue's body collider (the paint MeshCollider), or null until it has been created. + public MeshCollider BodyCollider + { + get + { + if (!_bodyCollider) _bodyCollider = GetComponentInChildren(true); + return _bodyCollider; + } + } + private void Update() { // Server owner: once the painter is set up, load the captured snapshot and push it to every peer over @@ -108,7 +140,14 @@ public class MatchStatue : NetworkBehaviour // 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); + if (paintReady && !_bodyShown) + { + SetBodyVisible(true); + // See-through-walls X-ray glow, exactly like an un-found hider during Resolution. The statue only + // ever exists in the resolution phase, so the reveal is steadily on (no just-shot flash). Raised + // together with the body so the authored _XRayColor eases in alongside it (per-client, cosmetic). + if (_painter) _painter.XRayRevealed = true; + } // Re-assert the stance until it has settled (covers a pose index arriving a frame late), then freeze. if (_pose && !_frozen) diff --git a/Assets/Scripts/Network/NetworkPlayerSetup.cs b/Assets/Scripts/Network/NetworkPlayerSetup.cs index fa4d8db..2eb60ac 100644 --- a/Assets/Scripts/Network/NetworkPlayerSetup.cs +++ b/Assets/Scripts/Network/NetworkPlayerSetup.cs @@ -124,6 +124,7 @@ public class NetworkPlayerSetup : NetworkBehaviour 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); @@ -193,6 +194,7 @@ public class NetworkPlayerSetup : NetworkBehaviour public override void OnNetworkDespawn() { _active.Remove(this); + ActiveVersion++; _isFound.OnValueChanged -= OnFoundChanged; } @@ -462,6 +464,29 @@ public class NetworkPlayerSetup : NetworkBehaviour // 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;