Add NoClimb climb filter and kill-plane rescue to player controller

Two player-controller additions:

NoClimb: colliders tagged "NoClimb" are now invisible to wall-climb and
ceiling-grab. The grab detector (OnControllerColliderHit) and all three
climb probes (ProbeWall, ProbeCeiling, WallInFront) skip them, so such a
surface still blocks the capsule physically but can never be grabbed,
re-probed onto, or treated as a ceiling. WalkableGroundBelow is left
untouched (it only accepts walkable ground). The tag is read from a
serialized field (default "NoClimb"); an empty tag disables the check.

Rescue: a kill-plane fail-safe. The owner captures its spawn point on the
first Update frame (after OnNetworkSpawn's ring offset has landed) and,
if it falls below rescueMinY (-100), teleports back there with all
momentum cleared, toggling the CharacterController around the write like
PlaceOnSpawnRing. Owner-only; the authoritative NetworkTransform
replicates the reset. Runs ahead of the control gate so it rescues in any
mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-28 15:15:45 +02:00
1 parent dadf9d88d4
commit 198db9a3ee
3 files changed
+60 -30

No files matched your search

+55 -4
View File
@@ -75,6 +75,12 @@ public class PlayerCharacterController : MonoBehaviour
"animation. Only while airborne (not grounded, not wall-climbing). Scales with player size.")]
public float fallAnimationSpeed = 4f;
[Header("Rescue")]
[Tooltip("Kill-plane fail-safe: if the player falls below this world Y (e.g. off the map into the void), " +
"they are teleported back to where this avatar spawned (spawn center + per-client ring offset) " +
"with all momentum cleared. Owner-only; the authoritative NetworkTransform replicates the reset.")]
public float rescueMinY = -100f;
[Header("Slope Sliding")]
[Tooltip("Slide down surfaces steeper than the CharacterController's Slope Limit instead of balancing on them.")]
public bool slideDownSteepSlopes = true;
@@ -136,6 +142,9 @@ public class PlayerCharacterController : MonoBehaviour
[Tooltip("Upward hop when topping out over a wall/slope edge, so holding W carries you onto the ledge instead of dropping back onto the face and re-grabbing. In meters of jump height; 0 = no hop.")]
public float wallTopOutBoost = 0.3f;
[Tooltip("Colliders with this tag are invisible to wall-climb and ceiling-grab: they still block the capsule physically, but can never be grabbed, re-probed onto, or used as a ceiling. Leave empty to disable.")]
public string noClimbTag = "NoClimb";
[Tooltip("Camera orbit input; paused while dragging the wall-climb offset. Auto-found if empty.")]
[SerializeField] private ThirdPersonOrbitInput cameraOrbit;
@@ -224,6 +233,8 @@ public class PlayerCharacterController : MonoBehaviour
private bool _movingOnGround; // snapshot: grounded & moving above the run threshold (exposed via IsMovingOnGround)
private bool _isFalling; // snapshot: airborne & descending past the fall threshold (exposed via IsFalling)
private float _groundOffset; // signed vertical mesh raise from the ground mouse pose-adjust (+ up / - down); folded into SetMeshLean
private Vector3 _spawnPosition; // world position this avatar spawned at; the rescue (kill-plane) teleport target
private bool _spawnCaptured; // false until the first Update captures the spawn position (after OnNetworkSpawn's ring offset has landed)
// Ground contact normal from the latest Move (for slope sliding).
private Vector3 _groundNormal = Vector3.up;
@@ -304,6 +315,21 @@ public class PlayerCharacterController : MonoBehaviour
void Update()
{
// Kill-plane fail-safe (owner-only: the controller is disabled on remotes). Capture the spawn
// point on the first frame -- by now OnNetworkSpawn's ring offset has landed -- then snap the
// player back if they fall through the world. Runs ahead of the control gate so it rescues in
// any mode (paint/free-cam included).
if (!_spawnCaptured)
{
_spawnPosition = transform.position;
_spawnCaptured = true;
}
else if (transform.position.y < rescueMinY)
{
RescueToSpawn();
return;
}
float h = 0f, v = 0f;
bool jumpPressed = false;
bool sprinting = false;
@@ -471,6 +497,24 @@ public class PlayerCharacterController : MonoBehaviour
_isFalling = !_controller.isGrounded && _verticalVelocity < -fallAnimationSpeed * CapsuleScale;
}
/// <summary>
/// Kill-plane rescue: snap the avatar back to its captured spawn point and clear all momentum. The
/// CharacterController overrides direct transform writes, so it is toggled around the move (same
/// pattern as <see cref="NetworkPlayerSetup.PlaceOnSpawnRing"/>). Owner-only; the authoritative
/// NetworkTransform replicates the new position to remotes.
/// </summary>
private void RescueToSpawn()
{
bool reenable = _controller && _controller.enabled;
if (_controller) _controller.enabled = false;
transform.position = _spawnPosition;
if (_controller) _controller.enabled = reenable;
_verticalVelocity = 0f;
_horizontalVelocity = Vector3.zero;
_slideSpeed = 0f;
}
/// <summary>
/// Enters wall-climb mode only when the move input actively pushes into a wall
/// (not on mere contact). Leaving while climbing is handled in
@@ -950,6 +994,13 @@ public class PlayerCharacterController : MonoBehaviour
/// </summary>
private static bool IsPlayerCollider(Collider c) => c.GetComponentInParent<PlayerCharacterController>();
/// <summary>
/// True if the collider is tagged as non-climbable (<see cref="noClimbTag"/>): every climb probe
/// and the grab detector skip it, so such a wall/ceiling blocks the capsule but is never grabbed,
/// re-probed onto, or treated as a ceiling. Empty/unset tag disables the check (never throws).
/// </summary>
private bool IsNoClimb(Collider c) => !string.IsNullOrEmpty(noClimbTag) && c && c.CompareTag(noClimbTag);
/// <summary>
/// Raycasts from the character center toward the surface (along -_wallNormal) to find the
/// current normal and the gap (surface clearance) to it. The capsule's support distance
@@ -973,7 +1024,7 @@ public class PlayerCharacterController : MonoBehaviour
bool found = false;
for (int i = 0; i < count; i++)
{
if (IsPlayerCollider(_wallProbe[i].collider)) // skip self and other player avatars
if (IsPlayerCollider(_wallProbe[i].collider) || IsNoClimb(_wallProbe[i].collider)) // skip self, other player avatars, and no-climb surfaces
continue;
if (_wallProbe[i].distance < best)
{
@@ -1018,7 +1069,7 @@ public class PlayerCharacterController : MonoBehaviour
bool found = false;
for (int i = 0; i < count; i++)
{
if (IsPlayerCollider(_wallProbe[i].collider))
if (IsPlayerCollider(_wallProbe[i].collider) || IsNoClimb(_wallProbe[i].collider))
continue;
if (_wallProbe[i].normal.y >= CeilingNormalThreshold) // not a ceiling
continue;
@@ -1090,7 +1141,7 @@ public class PlayerCharacterController : MonoBehaviour
origin, -_wallNormal, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
for (int i = 0; i < count; i++)
{
if (IsPlayerCollider(_wallProbe[i].collider))
if (IsPlayerCollider(_wallProbe[i].collider) || IsNoClimb(_wallProbe[i].collider))
continue;
return true;
}
@@ -1198,7 +1249,7 @@ public class PlayerCharacterController : MonoBehaviour
/// </summary>
private void OnControllerColliderHit(ControllerColliderHit hit)
{
if (enableWallClimb)
if (enableWallClimb && !IsNoClimb(hit.collider))
{
float angle = Vector3.Angle(hit.normal, Vector3.up);
if (angle >= wallMinAngle)