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;