using UnityEngine;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
///
/// CharacterController-based player: camera-relative WASD movement, manual gravity and jumping,
/// sliding off too-steep slopes, and a wall/ceiling-climb mode. Wall-climb is entered by pushing
/// the move input into a wall: W/S climb up/down, A/D strafe, the body faces the wall; left-drag
/// leans the visual mesh, right-drag turns the body. Jumping detaches, the top edge mantles over,
/// climbing into an overhang hands off to the ceiling (upright, camera-relative crawl), and small
/// steps on the climbed surface are passed over (wallStepOffset, the climb's ground-step analogue).
/// Named PlayerCharacterController to avoid a clash with Unity.VisualScripting's "PlayerController".
///
[RequireComponent(typeof(CharacterController))]
[DisallowMultipleComponent]
public class PlayerCharacterController : MonoBehaviour
{
[Header("Movement")]
[Tooltip("Horizontal movement speed (units/second).")]
public float moveSpeed = 2f;
[Tooltip("Speed multiplier applied to ground movement and wall/ceiling climbing while the sprint key (Left Shift) is held.")]
public float sprintSpeedMultiplier = 3.2f;
[Tooltip("How quickly ground movement ramps up to the target speed (units/second²). Lower = more gradual start.")]
public float acceleration = 30f;
[Tooltip("How quickly ground movement slows to a stop when input eases off (units/second²). Lower = more glide.")]
public float deceleration = 45f;
[Tooltip("Interpret WASD relative to the camera (off = world X/Z axes).")]
public bool cameraRelative = true;
[Tooltip("Camera used for camera-relative movement. Empty = Camera.main.")]
public Transform cameraTransform;
[Header("Rotation")]
[Tooltip("Smoothly turn the character to face its movement direction.")]
public bool rotateTowardMovement = true;
[Tooltip("Turn speed in degrees per second.")]
public float rotationSpeed = 540f;
[Header("Jump & Gravity")]
[Tooltip("Peak jump height in units.")]
public float jumpHeight = 1.2f;
[Tooltip("Gravity in units per second squared (negative = downward).")]
public float gravity = -14.715f;
[Tooltip("Small downward velocity kept while grounded for reliable ground contact.")]
public float groundedStickVelocity = -2f;
[Tooltip("Downward speed (units/second) past which the player counts as falling, for the fall " +
"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;
[Tooltip("How quickly the slide accelerates (units/second squared).")]
public float slideAcceleration = 20f;
[Tooltip("Maximum downhill slide speed (units/second).")]
public float maxSlideSpeed = 8f;
[Tooltip("Steering control kept while sliding (0 = none, 1 = full).")]
[Range(0f, 1f)]
public float slideControl = 0.3f;
[Header("Wall Climb")]
[Tooltip("Enter wall-climb mode by pushing into a wall: stick to the surface, move at reduced speed, ignore gravity and jumping.")]
public bool enableWallClimb = true;
[Tooltip("Minimum surface angle from 'up' (degrees) that counts as a climbable wall/ceiling rather than a slope.")]
[Range(50f, 180f)]
public float wallMinAngle = 60f;
[Tooltip("How directly the move input must point into a wall to grab it (0 = any inward nudge, 1 = head-on only). Stops mere contact (e.g. standing at a wall's base) from grabbing.")]
[Range(0f, 1f)]
public float wallGrabMinPush = 0.3f;
[Tooltip("Speed multiplier for climbing up and strafing while wall-climbing (0.5 = half speed).")]
[Range(0f, 1f)]
public float wallClimbSpeedMultiplier = 0.5f;
[Tooltip("How quickly wall/ceiling climb movement ramps up to speed (units/second²). Kept low so brief " +
"taps nudge only a little for precise placement; scaled up while sprinting so sprint stays snappy.")]
[Min(0.01f)]
public float wallClimbAcceleration = 5f;
[Tooltip("Speed multiplier for the mantle (pulling up over the top edge), independent of the climb speed. Multiplies moveSpeed; 1 = full walk speed, >1 = snappier pull-up.")]
[Min(0f)]
public float wallMantleSpeedMultiplier = 1.5f;
[Tooltip("Farthest the mesh can lean out from the wall via the mouse-drag offset (units).")]
public float wallMaxOffset = 0.1f;
[Tooltip("Farthest the mesh can sink INTO the wall via the mouse-drag offset (units). Only the visual model + its paint collider move; the CharacterController capsule stays put, so this isn't blocked by the wall collider.")]
public float wallMaxInset = 0.2f;
[Tooltip("Units the lean-out offset changes per pixel of vertical mouse drag (left button held).")]
public float wallOffsetSensitivity = 0.001f;
[Tooltip("Degrees the body turns per pixel of horizontal mouse drag (left button held) while climbing.")]
public float wallRotateSensitivity = 0.2f;
[Tooltip("How fast the lean-out offset eases back to the wall while moving (units/second).")]
public float wallOffsetReturnSpeed = 2f;
[Tooltip("How snappily the body follows the lean-out offset (1/second). Higher = tighter, lower = smoother.")]
public float wallOffsetFollow = 12f;
[Tooltip("Grace period (seconds) of lost contact tolerated before leaving wall-climb mode.")]
public float wallExitGrace = 0.15f;
[Tooltip("How long (seconds) the top-edge signature (probe clear, wall still at the feet, clear headroom above, standable top ahead) must persist before committing to a top-out mantle. Filters transient probe misses so they never trigger a mantle mid-face. Keep small; a couple of frames is enough.")]
public float wallTopOutConfirm = 0.06f;
[Tooltip("Largest ledge/step on the climbed surface (units, measured along the surface normal) that climbing passes over — the wall/ceiling counterpart of the CharacterController's ground Step Offset. When along-surface movement is blocked by a small trim/board/beam, the capsule lifts off the surface just enough to clear it and continues; 0 disables.")]
[Min(0f)]
public float wallStepOffset = 0.1f;
[Tooltip("Head-to-ceiling distance (units) at which climbing a wall up into an overhang hands off to the ceiling.")]
public float ceilingGrabDistance = 0.02f;
[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;
[Tooltip("Visual model root moved by the wall-climb lean offset (into/out of the wall) while the capsule stays put. Must parent both the skinned mesh and its bones. Auto-resolved from the SkinnedMeshRenderer if empty.")]
[SerializeField] private Transform meshOffsetTarget;
[Tooltip("Camera follow/look target (what the ThirdPersonCamera tracks). Moved vertically by the active pose's camera y-offset plus the ceiling offset, without moving the body. Auto-resolved from a child named \"CameraTarget\" if empty.")]
[SerializeField] private Transform cameraTarget;
/// When false, movement/jump input is ignored but gravity and ground contact keep
/// running. Toggled by the GameModeManager.
public bool ControlEnabled { get; set; } = true;
/// When false, gravity, ground-stick and slope-sliding are suspended so the avatar
/// hovers at its spawn position. Set by the GameModeManager during the pre-Hide loading barrier.
public bool GravityEnabled { get; set; } = true;
/// When set, the body faces this world-space yaw (degrees) every frame instead of turning
/// toward its movement direction (first-person). Only affects ground/air locomotion — wall-climb
/// owns its own facing.
public float? FaceYaw { get; set; }
/// True while in wall-climb mode.
public bool IsWallClimbing => _isWallClimbing;
/// True while the player is idle (under control and essentially still). Snapshotted at
/// the end of Update so a sibling reads a stable, order-independent value.
public bool IsIdle => _isIdle;
/// True while the sprint key is held, in any state.
public bool IsSprinting => _sprinting;
/// True while stuck to a ceiling (wall-climb mode with a downward-facing surface normal).
public bool IsOnCeiling => _isWallClimbing && _onCeiling;
/// True while grounded and moving above the run threshold; airborne / wall / standing
/// all read false. The pose system picks its walk/run clips off this.
public bool IsMovingOnGround => _movingOnGround;
/// True while airborne (not wall-climbing) and descending faster than
/// (scaled by player size).
public bool IsFalling => _isFalling;
///
/// Set by while a pose Playable graph owns the Animator output.
/// While set, the run-bool is not poked: the graph overrides it and Unity would log
/// "Animator is not playing an AnimatorController" every frame.
///
public bool AnimatorOverridden { get; set; }
///
/// The visual-model transform moved by the wall-lean / ceiling offset; its localPosition is owned
/// solely by . Exposed so can exclude
/// it from its per-frame rest reset (the Animator sits on this same node and would otherwise wipe
/// the offset every frame).
///
public Transform MeshOffsetTarget => meshOffsetTarget;
/// Authored CharacterController capsule center Y, captured in Awake. The per-pose capsule
/// (driven by ) eases back to this when no pose overrides it.
public float BaseCapsuleCenterY { get; private set; }
/// Authored CharacterController capsule radius, captured in Awake.
public float BaseCapsuleRadius { get; private set; }
/// Authored CharacterController capsule height, captured in Awake.
public float BaseCapsuleHeight { get; private set; }
// Below this horizontal speed (m/s, squared) the character counts as idle for the run animation.
private const float RunningSpeedThresholdSqr = 0.04f; // (0.2 m/s)^2
private CharacterController _controller;
private NetworkPlayerSetup _setup; // sibling role source; null on non-networked test instances
private float _verticalVelocity;
private Vector3 _horizontalVelocity; // smoothed horizontal move velocity
private Vector3 _climbVelocity; // smoothed along-surface climb velocity
private bool _sprinting;
private bool _isIdle;
private bool _movingOnGround;
private bool _isFalling;
private float _groundOffset; // signed vertical mesh raise from the ground mouse pose-adjust
private Vector3 _spawnPosition; // rescue (kill-plane) teleport target
private bool _spawnCaptured; // false until the first Update (after OnNetworkSpawn's ring offset has landed)
private Vector3 _groundNormal = Vector3.up;
private bool _groundNormalUpdated;
private float _slideSpeed;
// A climb surface counts as a ceiling once its normal points down past this threshold; the
// enter/exit band is hysteretic because in an inner wall/ceiling corner the per-frame contact
// normal crosses a single threshold back and forth, flipping the climb mode and body yaw.
private const float CeilingNormalThreshold = -0.5f;
private const float CeilingEnterThreshold = -0.6f; // wall -> ceiling only when clearly down-facing
private const float CeilingExitThreshold = -0.4f; // ceiling -> wall only when clearly not down-facing
private bool _onCeiling; // latched climb-surface class (hysteretic)
private bool _isWallClimbing;
private Vector3 _wallNormal;
private Vector3 _wallNormalAccum; // summed wall normals during the latest Move
private bool _wallContactThisFrame;
private float _lostContactTimer;
private float _rimConfirmTimer; // debounces the bare-rim vault so a transient mid-face edge graze can't top out
private const float StepPassServoHold = 0.25f; // seconds the standoff servo skips pulling in after a step-pass lift
private float _stepPassTimer; // holds a step-pass lift so the servo doesn't pull straight back into the ledge
private float _wallOffset; // signed mesh lean (mouse drag); moves only the model, not the capsule
private float _wallYaw; // body yaw offset from facing the wall (mouse drag)
private int _wallStrafeSign; // latched strafe direction; 0 = unlatched
private int _wallClimbSign; // latched up/down direction; 0 = unlatched
private bool _offsetDragging; // true while the offset/rotation is being set (pauses camera orbit)
private bool _climbLeftGround; // true once the capsule has left the ground while climbing
private Vector3 _meshBaseLocalPos;
private Vector3 _cameraTargetBaseLocalPos;
private float _cameraYOffset; // cached so an unchanged value skips the transform write
private float _meshOffsetResidual; // leftover mesh lean still easing back to 0 after leaving the wall
private Vector3 _meshOffsetExitNormal; // wall normal latched at exit (the live one may shift after detaching)
private readonly RaycastHit[] _wallProbe = new RaycastHit[16];
private bool _wallProbeSaturated; // log-once: a full buffer can hide the nearest valid hit
// Mantle (top-out) state.
private const float TopOutRegrabCooldown = 0.3f; // blocks re-grab right after a top-out (no detach/re-grab buzzing)
private bool _mantling;
private bool _mantleClearedFeet; // phase 1 done: capsule fully above the edge
private float _mantleOverDist; // phase 2 forward distance over the top
private float _mantleTimer; // safety cap
private float _reentryCooldown;
void Awake()
{
_controller = GetComponent();
_setup = GetComponent();
BaseCapsuleCenterY = _controller.center.y;
BaseCapsuleRadius = _controller.radius;
BaseCapsuleHeight = _controller.height;
if (!cameraTransform && Camera.main)
cameraTransform = Camera.main.transform;
if (!cameraOrbit)
cameraOrbit = FindFirstObjectByType();
// Walk up from the skinned mesh to the topmost node under this transform so the offset root
// parents BOTH the renderer and its bones (offsetting only the renderer would move just the
// baked paint collider, not the bone-driven visible mesh).
if (!meshOffsetTarget)
{
var smr = GetComponentInChildren();
if (smr)
{
Transform t = smr.transform;
while (t.parent && t.parent != transform) t = t.parent;
meshOffsetTarget = t;
}
}
if (meshOffsetTarget) _meshBaseLocalPos = meshOffsetTarget.localPosition;
if (!cameraTarget)
{
Transform t = transform.Find("CameraTarget");
if (t) cameraTarget = t;
}
if (cameraTarget) _cameraTargetBaseLocalPos = cameraTarget.localPosition;
}
void Update()
{
// Kill-plane fail-safe; runs ahead of the control gate so it rescues in any mode.
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;
// Skip input when control is disabled (e.g. paint mode); gravity and grounding still run.
if (ControlEnabled)
{
#if ENABLE_INPUT_SYSTEM
var keyboard = Keyboard.current;
if (keyboard != null)
{
if (keyboard.aKey.isPressed) h -= 1f;
if (keyboard.dKey.isPressed) h += 1f;
if (keyboard.sKey.isPressed) v -= 1f;
if (keyboard.wKey.isPressed) v += 1f;
jumpPressed = keyboard.spaceKey.wasPressedThisFrame;
sprinting = keyboard.leftShiftKey.isPressed;
}
#else
h = Input.GetAxisRaw("Horizontal");
v = Input.GetAxisRaw("Vertical");
jumpPressed = Input.GetButtonDown("Jump");
sprinting = Input.GetKey(KeyCode.LeftShift);
#endif
}
_sprinting = sprinting;
Vector3 inputDir = new Vector3(h, 0f, v);
if (inputDir.sqrMagnitude > 1f)
inputDir.Normalize();
Vector3 moveDir;
if (cameraRelative && cameraTransform)
{
Vector3 forward = cameraTransform.forward;
forward.y = 0f;
forward.Normalize();
Vector3 right = cameraTransform.right;
right.y = 0f;
right.Normalize();
moveDir = forward * inputDir.z + right * inputDir.x;
}
else
{
moveDir = inputDir;
}
UpdateWallClimbState(moveDir);
// Latch the wall normal from contacts ONLY while not yet climbing (the grab needs it).
// During a climb the probes own _wallNormal: contact normals mix wall and ceiling in inner
// corners and would flip the classification frame to frame.
if (!_isWallClimbing && _wallContactThisFrame && _wallNormalAccum.sqrMagnitude > 1e-6f)
_wallNormal = _wallNormalAccum.normalized;
_wallContactThisFrame = false;
_wallNormalAccum = Vector3.zero;
if (_isWallClimbing)
{
// Frozen on the wall while control is off: keep the grip so switching modes never drops us.
if (ControlEnabled)
ApplyWallClimbMovement(h, v, moveDir, jumpPressed, sprinting, Time.deltaTime);
else
_offsetDragging = false;
if (cameraOrbit)
cameraOrbit.LookSuppressed = _offsetDragging;
_horizontalVelocity = Vector3.zero;
_isIdle = ControlEnabled && (h * h + v * v) <= 1e-4f;
_movingOnGround = false;
_isFalling = false;
return;
}
// Ground pose-adjust (the wall mouse scheme mirrored to the ground). Gated to player control
// so it never fights painting (paint mode owns the left button).
bool groundYawDrag = false;
if (ControlEnabled)
groundYawDrag = HandleGroundPoseAdjust(h, v, Time.deltaTime);
else
_offsetDragging = false;
if (cameraOrbit)
cameraOrbit.LookSuppressed = _offsetDragging;
EaseMeshOffsetToRest(Time.deltaTime);
// A ground normal steeper than the slope limit is too steep to stand on
// (also covers balancing on a sharp box edge).
float slopeAngle = Vector3.Angle(_groundNormal, Vector3.up);
bool onSteepSlope = slideDownSteepSlopes
&& GravityEnabled
&& _controller.isGrounded
&& slopeAngle > _controller.slopeLimit + 0.01f;
if (!GravityEnabled)
{
_verticalVelocity = 0f;
}
else
{
if (_controller.isGrounded)
{
if (_verticalVelocity < 0f)
_verticalVelocity = groundedStickVelocity;
if (jumpPressed && !onSteepSlope)
_verticalVelocity = Mathf.Sqrt(jumpHeight * -2f * gravity);
}
_verticalVelocity += gravity * Time.deltaTime;
}
Vector3 slideVelocity = Vector3.zero;
if (onSteepSlope)
{
Vector3 slideDir = Vector3.ProjectOnPlane(Vector3.down, _groundNormal).normalized;
_slideSpeed = Mathf.Min(_slideSpeed + slideAcceleration * Time.deltaTime, maxSlideSpeed);
slideVelocity = slideDir * _slideSpeed;
moveDir *= slideControl;
}
else
{
_slideSpeed = 0f;
}
float currentSpeed = sprinting ? moveSpeed * sprintSpeedMultiplier : moveSpeed;
Vector3 targetHorizontal = moveDir * currentSpeed;
float accelRate = moveDir.sqrMagnitude > 0.0001f ? acceleration : deceleration;
_horizontalVelocity = Vector3.MoveTowards(_horizontalVelocity, targetHorizontal, accelRate * Time.deltaTime);
Vector3 velocity = _horizontalVelocity + slideVelocity + Vector3.up * _verticalVelocity;
// Move fires OnControllerColliderHit, which refills the ground normal.
bool wasGrounded = _controller.isGrounded;
_groundNormalUpdated = false;
_controller.Move(velocity * Time.deltaTime);
if (!_groundNormalUpdated)
_groundNormal = Vector3.up;
// Walked over a ledge (not sprinting, not jumping): grab the ledge's face and climb instead
// of falling. Sprint deliberately opts out so running off an edge still drops.
if (wasGrounded && !_controller.isGrounded && !sprinting && _verticalVelocity <= 0f
&& GravityEnabled && enableWallClimb && ControlEnabled && _reentryCooldown <= 0f
&& !WallClimbBlockedNow() && moveDir.sqrMagnitude > 1e-4f
&& TryLedgeGrab(moveDir))
{
_horizontalVelocity = Vector3.zero;
_isIdle = false;
_movingOnGround = false;
_isFalling = false;
return;
}
if (FaceYaw.HasValue)
{
transform.rotation = Quaternion.Euler(0f, FaceYaw.Value, 0f);
}
else if (rotateTowardMovement && !groundYawDrag && moveDir.sqrMagnitude > 0.0001f)
{
Quaternion target = Quaternion.LookRotation(moveDir, Vector3.up);
transform.rotation = Quaternion.RotateTowards(
transform.rotation, target, rotationSpeed * Time.deltaTime);
}
// Real horizontal speed, so pushing into a wall (blocked) reads as idle, not foot-sliding.
Vector3 horizontalVel = _controller.velocity;
horizontalVel.y = 0f;
bool movingOnGround = _controller.isGrounded && horizontalVel.sqrMagnitude > RunningSpeedThresholdSqr;
_movingOnGround = movingOnGround;
_isIdle = ControlEnabled && _controller.isGrounded && !movingOnGround;
_isFalling = !_controller.isGrounded && _verticalVelocity < -fallAnimationSpeed * CapsuleScale;
}
/// Kill-plane rescue: snap back to the captured spawn point and clear all momentum.
private void RescueToSpawn()
{
CharacterTeleport.Move(_controller, transform, _spawnPosition);
_verticalVelocity = 0f;
_horizontalVelocity = Vector3.zero;
_climbVelocity = Vector3.zero;
_slideSpeed = 0f;
}
// Hunters may not grab a wall during the hide phase; every other role/phase climbs normally.
private bool WallClimbBlockedNow()
{
var match = MatchManager.Instance;
return match && match.LocalSubPhase == MatchManager.MatchSubPhase.Hide
&& _setup && _setup.Role == PlayerRole.Hunter;
}
///
/// Enters wall-climb mode only when the move input actively pushes into a wall (not on mere
/// contact). Leaving while climbing is handled in .
///
private void UpdateWallClimbState(Vector3 moveDir)
{
if (_reentryCooldown > 0f)
_reentryCooldown -= Time.deltaTime;
if (!enableWallClimb || WallClimbBlockedNow())
{
if (_isWallClimbing)
ExitWallClimb();
return;
}
// A wall is grabbed by pushing horizontally into it while not rising; a ceiling can't be
// pushed into horizontally, so it is grabbed by rising up into it.
bool pushingIn = false;
if (_wallContactThisFrame && _wallNormalAccum.sqrMagnitude > 1e-6f)
{
Vector3 surfaceNormal = _wallNormalAccum.normalized;
if (surfaceNormal.y < CeilingNormalThreshold)
pushingIn = _verticalVelocity > 0f;
else
pushingIn = moveDir.sqrMagnitude > 1e-4f
&& Vector3.Dot(moveDir.normalized, surfaceNormal) <= -wallGrabMinPush
&& _verticalVelocity <= 0f;
}
// Grab only under player control; once climbing we stay attached across control toggles.
// The cooldown prevents instantly re-grabbing the edge we just mantled over.
if (!_isWallClimbing && ControlEnabled && pushingIn && _reentryCooldown <= 0f)
{
_isWallClimbing = true;
_wallNormal = _wallNormalAccum.normalized;
_onCeiling = _wallNormal.y < CeilingNormalThreshold;
_lostContactTimer = 0f;
_rimConfirmTimer = 0f;
_stepPassTimer = 0f;
_verticalVelocity = 0f;
_slideSpeed = 0f;
_wallOffset = 0f;
_groundOffset = 0f;
_meshOffsetResidual = 0f;
_wallYaw = 0f;
_wallStrafeSign = 0;
_wallClimbSign = 0;
_climbLeftGround = false;
_climbVelocity = Vector3.zero;
}
}
///
/// Attaches to the vertical face of a ledge just walked over instead of falling. The face sits
/// BELOW the edge, so it is probed with horizontal sphere casts back against the walk direction
/// at the foot sphere and two bands below it (the capsule hangs level with the ledge top on the
/// frame it leaves the ground). Its normal must be wall-steep and point roughly along the walk
/// direction (outward from the ledge) — side walls and slopes never grab. A short drop (walkable
/// ground within a capsule height under the feet) is a normal walk-off and falls through.
/// On success the climb state is initialized exactly like a push-in grab, already off-ground.
///
private bool TryLedgeGrab(Vector3 moveDir)
{
Vector3 outDir = moveDir;
outDir.y = 0f;
if (outDir.sqrMagnitude < 1e-6f) return false;
outDir.Normalize();
// Short drop: land normally instead of sticking to every step's side.
if (WalkableBelow(WorldCapsuleCenter + Vector3.up * WorldRadius,
(_controller.center.y + _controller.height) * CapsuleScale))
return false;
Vector3 dir = -outDir; // back toward the ledge face
float radius = WorldRadius;
float maxDist = radius + wallMaxOffset + ProbeReachSlack * CapsuleScale;
float footY = -(WorldHalfHeight - radius);
for (int band = 0; band <= 2; band++)
{
Vector3 origin = WorldCapsuleCenter + Vector3.up * (footY - band * radius * 2f);
int count = Physics.SphereCastNonAlloc(
origin - dir * radius, radius, dir, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
WarnIfProbeSaturated(count);
float best = float.MaxValue;
Vector3 bestNormal = Vector3.zero;
for (int i = 0; i < count; i++)
{
if (_wallProbe[i].distance <= 0f)
continue;
if (IsPlayerCollider(_wallProbe[i].collider) || IsNoClimb(_wallProbe[i].collider))
continue;
if (_wallProbe[i].distance < best)
{
best = _wallProbe[i].distance;
bestNormal = _wallProbe[i].normal;
}
}
if (best == float.MaxValue)
continue;
// The foot band typically grazes the plateau's rounded top edge (up-tilted normal):
// skip to the lower bands, which hit the face flat, rather than rejecting outright.
if (Vector3.Angle(bestNormal, Vector3.up) < wallMinAngle
|| bestNormal.y < CeilingNormalThreshold
|| Vector3.Dot(bestNormal, outDir) < 0.3f)
continue;
_isWallClimbing = true;
_wallNormal = bestNormal;
_onCeiling = false;
_lostContactTimer = 0f;
_rimConfirmTimer = 0f;
_stepPassTimer = 0f;
_verticalVelocity = 0f;
_slideSpeed = 0f;
_wallOffset = 0f;
_groundOffset = 0f;
_meshOffsetResidual = 0f;
_wallYaw = 0f;
_wallStrafeSign = 0;
_wallClimbSign = 0;
_climbLeftGround = true; // already airborne; also keeps the ledge top from reading as genuine ground
_climbVelocity = Vector3.zero;
// Seat the capsule onto the face: at grab it hangs level with the ledge top, where the
// center probe (and possibly the span) would miss and grace out. One radius down puts
// the lower cap solidly onto the face; the air just left has nothing to collide with.
_controller.Move(Vector3.down * radius);
return true;
}
return false;
}
///
/// Movement while climbing, walls and ceilings (distinguished by the surface normal). WALL: W/S
/// climb straight up/down, A/D strafe camera-relative, the body faces the wall. CEILING: upright,
/// WASD camera-relative in the horizontal plane. The surface is re-probed each frame; a wall's
/// top edge mantles, climbing into an overhang hands off to the ceiling.
///
private void ApplyWallClimbMovement(float h, float v, Vector3 moveDir, bool jumpPressed, bool sprinting, float dt)
{
_offsetDragging = false;
if (_mantling)
{
MantleOverEdge(dt);
return;
}
if (_stepPassTimer > 0f)
_stepPassTimer -= dt;
UpdateCeilingClass();
bool onCeiling = _onCeiling;
// Map W/S to UP/DOWN by which key pushes INTO the wall (facing the wall that's W; with the
// camera on the wall's side it's S). Latched once per press; without the remap the grab
// (camera-relative) and the base-release (raw S) disagree and the climb flickers.
bool climbing = Mathf.Abs(v) > 1e-4f;
if (!climbing)
_wallClimbSign = 0;
else if (_wallClimbSign == 0 && !onCeiling)
{
Vector3 camFwd = cameraTransform ? cameraTransform.forward : Vector3.zero;
camFwd.y = 0f;
_wallClimbSign = (cameraTransform && Vector3.Dot(camFwd, _wallNormal) > 0f) ? -1 : 1;
}
float vEff = _wallClimbSign != 0 ? v * _wallClimbSign : v;
// Jump detaches: off a wall it pushes straight up, off a ceiling it just lets go.
if (jumpPressed)
{
ExitWallClimb();
_verticalVelocity = onCeiling ? 0f : Mathf.Sqrt(jumpHeight * -2f * gravity);
return;
}
// Wall->ceiling hand-off: while climbing up into an overhang, switch to the ceiling right
// above the head instead of trying to mantle over a (non-existent) top. Gated to clearly
// down-facing surfaces: a grazed front edge (the next shelf board above) returns a tilted
// edge normal and must park the climb instead of handing off. A small protruding ledge
// within the step allowance must NOT hand off either: skipping it lets the upward move
// block against its underside, and the step pass then carries the climb around its edge
// (the hand-off runs before ClimbMove, so without this gate it always wins).
if (!onCeiling && vEff > 0f
&& ProbeCeiling(out Vector3 ceilNormal, out float ceilHeadGap)
&& ceilHeadGap <= ceilingGrabDistance
&& ceilNormal.y < CeilingEnterThreshold
&& !OverheadIsPassableStep())
{
_wallNormal = ceilNormal;
_onCeiling = true;
onCeiling = true;
_wallOffset = 0f;
}
// Re-measure the surface each frame, probing the kind we're committed to. On a ceiling probe
// straight UP, not along -_wallNormal: in an inner corner that ray would clip the wall and
// demote us to "wall", flipping the body's facing every frame.
bool found;
float gap;
if (onCeiling)
{
found = ProbeCeiling(out Vector3 ceilNormal2, out float ceilGap);
if (found)
{
_wallNormal = ceilNormal2;
gap = ceilGap;
_lostContactTimer = 0f;
}
else
{
found = ProbeWall(out Vector3 wallNormal, out gap);
if (found)
{
_wallNormal = wallNormal;
_lostContactTimer = 0f;
}
UpdateCeilingClass();
onCeiling = _onCeiling;
}
}
else
{
found = ProbeWall(out Vector3 probedNormal, out gap);
// A down-facing hit here is never the climbed wall: it is an edge graze (the wall's
// bottom edge, or the underside of the next shelf board clipped while sweeping past
// it). Adopting it would latch ceiling mode mid-wall — the wall->ceiling hand-off
// happens ONLY via the explicit head-gap check above — so treat it as lost contact
// and let the span probe / grace decide.
if (found && probedNormal.y < CeilingNormalThreshold)
{
found = false;
}
else if (found)
{
// Pinch between two surfaces: the center band can miss the (still touching) climbed
// face and catch a DIFFERENT collider much farther out. Adopting that hit flips the
// normal and the standoff servo drags the capsule across the pinch — oscillating
// every other frame with the span probe snapping it back. When the full-capsule span
// still holds a steep surface meaningfully closer, THAT contact is the climbed face:
// keep it instead of defecting to the far hit.
if (gap > (_controller.skinWidth + 0.05f) * CapsuleScale
&& ProbeWallSpan(out Vector3 heldNormal, out float heldGap)
&& heldGap + 0.05f * CapsuleScale < gap
&& Vector3.Angle(heldNormal, Vector3.up) >= wallMinAngle
&& heldNormal.y > CeilingNormalThreshold)
{
probedNormal = heldNormal;
gap = heldGap;
}
_wallNormal = probedNormal;
_lostContactTimer = 0f;
UpdateCeilingClass();
onCeiling = _onCeiling;
}
}
// Topped out onto a slope: the climbed face has flattened into something walkable. The swept
// sphere ALSO tilts its normal upward as it rolls over a thin board's top edge, faking this —
// so the flattening is confirmed against the TRUE face normal from a thin ray (a real slope
// reads shallow there too; a vertical board still reads steep). Even then a genuine stand must
// exist; otherwise it is a bare rim (thin board / knife edge). At a bare rim, holding UP vaults
// the body over the edge (rise until the feet clear, then carry forward) — landing on top if
// there's room, else cresting and dropping the far side; deliberate, because W is held. Without
// up-input the climb parks: upward motion is clamped below, strafe/down/jump stay live.
bool bareTopRim = false;
if (found && !onCeiling && _climbLeftGround
&& Vector3.Angle(_wallNormal, Vector3.up) < wallMinAngle)
{
bool faceIsSlope = RaycastWallFace(out Vector3 faceNormal)
&& Vector3.Angle(faceNormal, Vector3.up) < wallMinAngle;
if (faceIsSlope && (_controller.isGrounded || WalkableGroundBelow() || HasStandableTop()))
{
if (vEff > 0f) TopOutDetach();
else ExitWallClimb();
return;
}
// The swept sphere's normal tilts up while the center crosses the board's top edge and
// STAYS tilted for the whole crossing — and it self-amplifies, because the next probe
// casts along the already-tilted normal, diving deeper into the edge. A debounce alone
// cannot tell that from a genuine rim, and "wall above the head" can't either (a thin
// board has nothing above in both cases). The full-capsule span CAN: while the board
// still overlaps the capsule lower down it returns the true steep face — re-adopt that
// normal (breaking the tilt feedback) and keep climbing. Only when neither the head band
// nor the span see anything steep is the rim genuine; then it must still persist briefly.
bool wallAbove = WallContinuesAbove();
bool steepOnSpan = ProbeWallSpan(out Vector3 rimSpanNormal, out _)
&& Vector3.Angle(rimSpanNormal, Vector3.up) >= wallMinAngle;
if (steepOnSpan)
_wallNormal = rimSpanNormal;
if (wallAbove || steepOnSpan)
_rimConfirmTimer = 0f;
else
_rimConfirmTimer += dt;
if (_rimConfirmTimer >= wallTopOutConfirm)
{
// Bare rim confirmed, pushing up: vault over. Headroom-gated so a shelf compartment
// above still parks (its rise would only stall against the ceiling and time out).
if (vEff > 0f && HasMantleHeadroom())
{
_mantling = true;
_mantleClearedFeet = false;
_mantleOverDist = 0f;
_mantleTimer = 0f;
MantleOverEdge(dt);
return;
}
bareTopRim = true;
}
}
else
{
_rimConfirmTimer = 0f;
}
Vector3 alongSurface;
if (onCeiling)
{
alongSurface = Vector3.ProjectOnPlane(moveDir, _wallNormal);
}
else
{
// W/S climb straight up/down; strafe along the wall's own horizontal tangent, not live
// camera-right (that coupled movement to the orbit camera's per-frame re-aim).
Vector3 wallUp = Vector3.ProjectOnPlane(Vector3.up, _wallNormal);
if (wallUp.sqrMagnitude > 1e-6f) wallUp.Normalize();
Vector3 wallRight = Vector3.Cross(_wallNormal, wallUp);
if (wallRight.sqrMagnitude > 1e-6f) wallRight.Normalize();
// Pick the strafe sign ONCE from the camera when a strafe begins, then hold it:
// re-deciding per frame inverts the input while rounding a curved wall.
bool strafing = Mathf.Abs(h) > 1e-4f;
if (!strafing)
_wallStrafeSign = 0;
else if (_wallStrafeSign == 0)
_wallStrafeSign = (cameraTransform && Vector3.Dot(cameraTransform.right, wallRight) < 0f) ? -1 : 1;
if (_wallStrafeSign < 0)
wallRight = -wallRight;
alongSurface = wallUp * vEff + wallRight * h;
}
if (alongSurface.sqrMagnitude > 1f) alongSurface.Normalize();
// Parked at a bare top rim: nothing may push the capsule higher over an edge that has no
// surface to stand on (cresting it would only end in a drop).
if (bareTopRim && alongSurface.y > 0f)
alongSurface = Vector3.ProjectOnPlane(alongSurface, Vector3.up);
bool hasMoveInput = (h * h + v * v) > 1e-4f;
if (!found)
{
_lostContactTimer += dt;
// Classify the miss: a top edge has wall at the feet but none above the center.
bool wallBelow = !onCeiling
&& ProbeWallAt(-WorldHalfHeight, out Vector3 lowNormal, out _)
&& lowNormal.y > CeilingNormalThreshold;
bool atTopEdge = wallBelow && !WallPresentAt(WorldHalfHeight * 0.5f);
// Wall top-out: cleared above a genuine ledge (somewhere to stand, room to rise).
// Confirmed briefly before committing so a transient miss never mantles mid-face.
if (atTopEdge && alongSurface.y > 0f && HasStandableTop() && HasMantleHeadroom())
{
if (_lostContactTimer >= wallTopOutConfirm)
{
_mantling = true;
_mantleClearedFeet = false;
_mantleOverDist = 0f;
_mantleTimer = 0f;
MantleOverEdge(dt);
return;
}
}
// The center probe only covers its own band of the capsule's height; a thin board
// (shelf edge) above or below that band still physically holds the capsule, so a
// full-capsule sweep keeps the climb attached anywhere along the capsule. At a top
// edge with nothing to stand on (or no room to mantle), upward motion is clamped so
// the climb parks at the rim instead of cresting into a drop.
else if (!onCeiling && ProbeWallSpan(out Vector3 spanNormal, out float spanGap))
{
found = true;
gap = spanGap;
if (Vector3.Angle(spanNormal, Vector3.up) >= wallMinAngle
&& spanNormal.y > CeilingNormalThreshold)
_wallNormal = spanNormal;
_lostContactTimer = 0f;
if (atTopEdge && alongSurface.y > 0f)
alongSurface = Vector3.ProjectOnPlane(alongSurface, Vector3.up);
}
// Surface genuinely gone (incl. crawling off a ceiling edge): leave after grace.
else if (_lostContactTimer >= wallExitGrace)
{
ExitWallClimb();
return;
}
}
// Left drag = signed lean (moves only the visual mesh); right drag = body turn.
// The lean eases home while moving; either drag pauses the camera orbit.
bool offsetDrag = found && InputUtil.LeftMouseHeld();
bool yawDrag = found && InputUtil.RightMouseHeld();
if (offsetDrag || yawDrag)
{
_offsetDragging = true;
Vector2 drag = InputUtil.ReadMouseDelta();
if (offsetDrag)
_wallOffset = Mathf.Clamp(_wallOffset - drag.y * wallOffsetSensitivity, -wallMaxInset, wallMaxOffset);
if (yawDrag)
{
if (onCeiling)
transform.Rotate(0f, drag.x * wallRotateSensitivity, 0f, Space.World);
else
_wallYaw += drag.x * wallRotateSensitivity;
}
}
if (!offsetDrag && hasMoveInput)
{
_wallOffset = Mathf.MoveTowards(_wallOffset, 0f, wallOffsetReturnSpeed * dt);
}
SetMeshLean(_wallNormal, _wallOffset);
float climbSpeed = moveSpeed * wallClimbSpeedMultiplier;
if (sprinting) climbSpeed *= sprintSpeedMultiplier;
// Ramp the along-surface velocity instead of snapping: a brief tap moves only a little.
// Sprint scales the accel too; releasing decelerates at the ground rate.
Vector3 targetAlong = alongSurface.sqrMagnitude > 1e-6f
? alongSurface.normalized * climbSpeed
: Vector3.zero;
float climbAccel = targetAlong.sqrMagnitude > 1e-6f
? wallClimbAcceleration * (sprinting ? sprintSpeedMultiplier : 1f)
: deceleration;
_climbVelocity = Vector3.MoveTowards(_climbVelocity, targetAlong, climbAccel * dt);
Vector3 velocity = _climbVelocity;
if (found)
{
// Servo the capsule to one skin-width off the surface. Exponential follow keeps it
// framerate-stable; the clamp guards spikes. While a step-pass lift is held, pulling
// back in is skipped — it would re-block against the ledge every frame.
float maxPerpSpeed = 8f * CapsuleScale;
float gapError = _controller.skinWidth * CapsuleScale - gap;
float follow = 1f - Mathf.Exp(-wallOffsetFollow * dt);
float correction = Mathf.Clamp(gapError * follow / dt, -maxPerpSpeed, maxPerpSpeed);
if (_stepPassTimer > 0f)
correction = Mathf.Max(correction, 0f);
velocity += _wallNormal * correction;
}
ClimbMove(velocity, alongSurface, dt);
// Reaching walkable ground detaches. Probed under the feet rather than trusting isGrounded
// (the standoff servo can hold the capsule just off the surface). The vEff < 0 release is
// the only detach that doesn't require having left the ground first, so a wall grabbed at
// its base can be released by pressing away. A step being passed (wallStepOffset) also
// reads grounded/walkable off its narrow top; genuine ground is told apart by extending
// outward from the climbed face — and the upward hop additionally by the wall no longer
// rising overhead — so a mid-wall step never detaches the climb.
bool onWalkableGround = _controller.isGrounded || WalkableGroundBelow();
bool genuineGround = onWalkableGround
&& (onCeiling || !_climbLeftGround || GroundExtendsOutward());
if (!onWalkableGround)
{
_climbLeftGround = true;
}
else if (_climbLeftGround && vEff > 0f)
{
if (genuineGround || !WallContinuesAbove())
{
TopOutDetach();
return;
}
}
else if ((_climbLeftGround || vEff < 0f) && genuineGround)
{
ExitWallClimb();
return;
}
if (rotateTowardMovement)
{
if (onCeiling)
{
// Stay upright and face the horizontal move direction, unless the right-button
// turn is actively steering the body.
if (!yawDrag)
{
Vector3 dir = alongSurface;
dir.y = 0f;
if (dir.sqrMagnitude > 1e-4f)
{
Quaternion target = Quaternion.LookRotation(dir.normalized, Vector3.up);
transform.rotation = Quaternion.RotateTowards(
transform.rotation, target, rotationSpeed * dt);
}
}
}
else
{
// Face the wall (forward = into the wall), turned by the held mouse-drag yaw.
Vector3 faceWall = -_wallNormal;
faceWall.y = 0f;
if (faceWall.sqrMagnitude > 0.0001f)
{
Vector3 dir = Quaternion.AngleAxis(_wallYaw, Vector3.up) * faceWall.normalized;
transform.rotation = Quaternion.LookRotation(dir, Vector3.up);
}
}
}
}
/// Updates the latched wall/ceiling classification (hysteretic; see the thresholds).
private void UpdateCeilingClass()
{
if (_onCeiling)
{
if (_wallNormal.y > CeilingExitThreshold) _onCeiling = false;
}
else if (_wallNormal.y < CeilingEnterThreshold)
{
_onCeiling = true;
}
}
///
/// Places the visual model at the given signed lean along (world space),
/// relative to its authored local pose; the capsule is untouched, so the lean can sink into the
/// wall where the capsule would be blocked.
///
private void SetMeshLean(Vector3 dir, float offset)
{
if (!meshOffsetTarget) return;
Vector3 worldOffset = dir * offset;
meshOffsetTarget.localPosition = _meshBaseLocalPos
+ transform.InverseTransformVector(worldOffset)
+ Vector3.up * _groundOffset;
}
///
/// Re-applies the resting mesh offset every non-climbing frame in a single write: eases the
/// leftover wall lean home (along the latched exit normal) while composing in the ground raise.
/// Writing unconditionally keeps this the sole owner of the offset transform.
///
private void EaseMeshOffsetToRest(float dt)
{
if (_meshOffsetResidual != 0f)
_meshOffsetResidual = Mathf.MoveTowards(_meshOffsetResidual, 0f, wallOffsetReturnSpeed * dt);
SetMeshLean(_meshOffsetExitNormal, _meshOffsetResidual);
}
///
/// Ground pose-adjust, mirroring the wall-climb mouse scheme: LEFT drag raises/lowers the visual
/// mesh (capsule untouched), RIGHT drag turns the body. Returns true while a right-button turn
/// is active so the caller skips rotate-toward-movement that frame.
///
private bool HandleGroundPoseAdjust(float h, float v, float dt)
{
_offsetDragging = false;
bool offsetDrag = InputUtil.LeftMouseHeld();
bool yawDrag = InputUtil.RightMouseHeld();
bool hasMoveInput = (h * h + v * v) > 1e-4f;
if (offsetDrag || yawDrag)
{
_offsetDragging = true;
Vector2 drag = InputUtil.ReadMouseDelta();
if (offsetDrag)
_groundOffset = Mathf.Clamp(
_groundOffset + drag.y * wallOffsetSensitivity, -wallMaxInset, wallMaxOffset);
if (yawDrag)
transform.Rotate(0f, drag.x * wallRotateSensitivity, 0f, Space.World);
}
// The raise eases home only while actually moving; standing still holds it for posing.
if (!offsetDrag && hasMoveInput)
_groundOffset = Mathf.MoveTowards(_groundOffset, 0f, wallOffsetReturnSpeed * dt);
return yawDrag;
}
///
/// Resizes the CharacterController capsule to the active pose's dimensions, driven by
/// . Only the collision capsule changes. Cached so an
/// unchanged value skips the write (a resize re-bakes the capsule internally).
///
public void SetCapsule(float centerY, float radius, float height)
{
Vector3 c = _controller.center;
if (c.y == centerY && _controller.radius == radius && _controller.height == height) return;
c.y = centerY;
_controller.center = c;
_controller.radius = radius;
_controller.height = height;
}
///
/// Places the camera target at the given local-up offset relative to its authored local position,
/// without moving the body. Driven by ; 0 restores the authored
/// height. Cached so an unchanged value skips the write.
///
public void SetCameraTargetLocalYOffset(float y)
{
if (!cameraTarget || _cameraYOffset == y) return;
_cameraYOffset = y;
cameraTarget.localPosition = _cameraTargetBaseLocalPos + Vector3.up * y;
}
// The serialized capsule center/radius/height are authored at scale-1; transform.localScale
// resizes the actual collision capsule but not those numbers. Every climb probe casts in WORLD
// space, so it must use the scaled dimensions and scale its distance paddings too. Uniform
// player scale is enforced by PlayerScaler, so a single lossyScale component suffices.
private float CapsuleScale => transform.lossyScale.y;
private Vector3 WorldCapsuleCenter => transform.TransformPoint(_controller.center);
private float WorldRadius => _controller.radius * CapsuleScale;
private float WorldHalfHeight => _controller.height * 0.5f * CapsuleScale;
private const float ProbeReachSlack = 0.5f; // extra cast length beyond the capsule surface (scale-1 units)
// Forward travel of the mantle's over-the-edge phase; also where HasStandableTop samples.
private float MantleForwardDistance => WorldRadius * 2f + 0.15f * CapsuleScale;
///
/// True if the collider belongs to ANY player avatar (self or a networked remote): avatars still
/// collide physically, but another chameleon's body must never register as climbable.
///
private static bool IsPlayerCollider(Collider c) => c.GetComponentInParent();
///
/// True if the collider is tagged as non-climbable (): it blocks the
/// capsule but is never grabbed, re-probed onto, or treated as a ceiling. Empty tag disables.
///
private bool IsNoClimb(Collider c) => !string.IsNullOrEmpty(noClimbTag) && c && c.CompareTag(noClimbTag);
///
/// Probes from the capsule center toward the climbed surface (along -_wallNormal) for the
/// current normal and the gap (clearance beyond the capsule surface). False = no hit.
///
private bool ProbeWall(out Vector3 normal, out float gap)
{
Vector3 dir = -_wallNormal;
float extent = CapsuleExtent(dir);
float maxDist = extent + wallMaxOffset + ProbeReachSlack * CapsuleScale;
if (NearestClimbHit(WorldCapsuleCenter, dir, maxDist, extent, ceilingOnly: false, out normal, out gap))
return true;
normal = _wallNormal;
return false;
}
///
/// Reads the TRUE face normal straight ahead of the capsule center with a thin, strictly
/// HORIZONTAL ray. The swept climb probe returns smoothly tilted normals near an edge — a thin
/// board's vertical face then reads as a "flattened slope" — so the slope top-out is confirmed
/// against this ray. Casting along the (already tilted) wall normal would dive into the board's
/// horizontal TOP face and read flat, faking the very slope it must reject; flattening the
/// direction to the horizontal plane samples the real vertical face instead. A real ramp still
/// reads shallow here, a vertical board reads steep, and clearing the edge makes the ray miss.
/// False = nothing straight ahead. Player avatars and no-climb surfaces are ignored.
///
private bool RaycastWallFace(out Vector3 faceNormal)
{
faceNormal = _wallNormal;
Vector3 dir = -_wallNormal;
dir.y = 0f;
if (dir.sqrMagnitude < 1e-6f) return false;
dir.Normalize();
float maxDist = WorldRadius + wallMaxOffset + ProbeReachSlack * CapsuleScale;
if (Physics.Raycast(WorldCapsuleCenter, dir, out RaycastHit hit, maxDist, ~0, QueryTriggerInteraction.Ignore)
&& !IsPlayerCollider(hit.collider) && !IsNoClimb(hit.collider))
{
faceNormal = hit.normal;
return true;
}
return false;
}
///
/// True while the climbed face still rises above the head: a strictly HORIZONTAL forward sphere
/// (flattened -_wallNormal) from the capsule's upper band hits a wall-like surface. The swept
/// climb probe tilts its normal upward whenever the sphere grazes the board's TOP edge from
/// below, faking a rim mid-face; this separates a genuine cleared rim (nothing ahead above) from
/// that jitter (board still there), so the climb keeps rising instead of vaulting/parking early.
///
private bool WallContinuesAbove()
{
Vector3 dir = -_wallNormal;
dir.y = 0f;
if (dir.sqrMagnitude < 1e-6f) return false;
dir.Normalize();
float radius = WorldRadius;
float half = WorldHalfHeight;
float maxDist = radius + wallMaxOffset + ProbeReachSlack * CapsuleScale;
// Sample a band from just above center up to the capsule top. A board thinner than the probe
// sphere gives a noisy single-height result, so any wall-like hit ANYWHERE across the band
// means the face still rises above the head — keep climbing rather than vault/park early.
for (float f = 0.25f; f <= 1.01f; f += 0.25f)
{
Vector3 origin = WorldCapsuleCenter + Vector3.up * (half * f);
int count = Physics.SphereCastNonAlloc(
origin - dir * radius, radius, dir, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
WarnIfProbeSaturated(count);
for (int i = 0; i < count; i++)
{
if (_wallProbe[i].distance <= 0f)
continue;
if (IsPlayerCollider(_wallProbe[i].collider) || IsNoClimb(_wallProbe[i].collider))
continue;
if (_wallProbe[i].normal.y > CeilingNormalThreshold)
return true;
}
}
return false;
}
///
/// Capsule support distance from the center along a unit direction: the radius sideways,
/// the half-height straight up/down, interpolated by the vertical component in between.
///
private float CapsuleExtent(Vector3 dir)
{
float radius = WorldRadius;
return radius + Mathf.Abs(dir.y) * (WorldHalfHeight - radius);
}
///
/// Probes straight up from the center for a ceiling overhead and its head clearance
/// (top of the capsule to the surface). Only downward-facing (ceiling) hits count.
///
private bool ProbeCeiling(out Vector3 normal, out float gap)
{
float half = WorldHalfHeight;
float maxDist = half + wallMaxOffset + ProbeReachSlack * CapsuleScale;
if (NearestClimbHit(WorldCapsuleCenter, Vector3.up, maxDist, half, ceilingOnly: true, out normal, out gap))
return true;
normal = Vector3.down;
return false;
}
///
/// Sweeps a capsule-wide sphere along and returns the nearest hit that a
/// climb probe accepts (player avatars and no-climb surfaces never count;
/// additionally rejects non-ceiling normals).
/// is the clearance beyond the capsule's support .
///
/// A swept sphere instead of a ray, for two reasons: its volume keeps contact with thin/narrow
/// surfaces (boards) that a zero-width ray slips past, and its edge contacts return smoothly
/// varying normals — a ray flips between adjacent face normals at an edge (cylinder facets,
/// wall corners), snapping the body's facing back and forth. The cast starts one radius back so
/// a surface at skin-width standoff can never be an (undetectable) initial overlap; with that
/// backup, hit.distance measures from like a plain ray would.
///
private bool NearestClimbHit(Vector3 origin, Vector3 dir, float maxDist, float extent,
bool ceilingOnly, out Vector3 normal, out float gap)
{
normal = Vector3.zero;
gap = 0f;
float radius = WorldRadius;
int count = Physics.SphereCastNonAlloc(
origin - dir * radius, radius, dir, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
WarnIfProbeSaturated(count);
float best = float.MaxValue;
bool found = false;
for (int i = 0; i < count; i++)
{
if (_wallProbe[i].distance <= 0f) // initial overlap; no usable point/normal
continue;
if (IsPlayerCollider(_wallProbe[i].collider) || IsNoClimb(_wallProbe[i].collider))
continue;
if (ceilingOnly && _wallProbe[i].normal.y >= CeilingNormalThreshold)
continue;
if (_wallProbe[i].distance < best)
{
best = _wallProbe[i].distance;
normal = _wallProbe[i].normal;
found = true;
}
}
if (found)
gap = Mathf.Max(0f, best - extent);
return found;
}
private void WarnIfProbeSaturated(int hitCount)
{
if (hitCount != _wallProbe.Length || _wallProbeSaturated) return;
_wallProbeSaturated = true;
Debug.LogWarning($"PlayerCharacterController: climb probe buffer saturated ({hitCount}); the nearest surface may be missed.", this);
}
///
/// Pulls the character up and over the wall's top edge: phase 1 rises until the capsule clears
/// the edge, phase 2 moves forward onto the surface, then exits. A short re-grab cooldown stops
/// the passed edge from re-triggering a climb.
///
private void MantleOverEdge(float dt)
{
_mantleTimer += dt;
float speed = moveSpeed * wallMantleSpeedMultiplier;
Vector3 forward = -_wallNormal;
forward.y = 0f;
forward = forward.sqrMagnitude > 1e-6f ? forward.normalized : transform.forward;
if (!_mantleClearedFeet)
{
_controller.Move(Vector3.up * (speed * dt));
if (!WallPresentAt(-WorldHalfHeight))
_mantleClearedFeet = true;
}
else
{
_controller.Move(forward * (speed * dt));
_mantleOverDist += speed * dt;
if (_mantleOverDist >= MantleForwardDistance || _controller.isGrounded)
{
_reentryCooldown = TopOutRegrabCooldown;
ExitWallClimb();
return;
}
}
// Safety: never mantle indefinitely (e.g. blocked by other geometry).
if (_mantleTimer >= 2f)
{
_reentryCooldown = TopOutRegrabCooldown;
ExitWallClimb();
}
}
///
/// Moves the climbing capsule, passing over small steps on the climbed surface — the
/// wall/ceiling counterpart of the ground Step Offset. When the along-surface travel comes up
/// blocked (a trim/offset on the wall, a beam on the ceiling), the capsule lifts off the
/// surface by the smallest sub-step of whose lifted path is clear
/// and retries the remainder; the standoff servo holds the lift briefly and re-seats past the
/// ledge. Clearance is verified before committing so a genuine obstacle (tall ledge, inner
/// corner) never starts a lift it can't finish.
///
private void ClimbMove(Vector3 velocity, Vector3 alongSurface, float dt)
{
Vector3 before = transform.position;
_controller.Move(velocity * dt);
if (wallStepOffset <= 0f || alongSurface.sqrMagnitude < 1e-6f)
return;
Vector3 travel = alongSurface.normalized;
float intended = Vector3.Dot(velocity, travel) * dt;
if (intended < 1e-4f * CapsuleScale)
return;
float actual = Vector3.Dot(transform.position - before, travel);
if (actual >= intended * 0.5f)
return;
float remaining = intended - actual;
float maxLift = wallStepOffset * CapsuleScale;
for (int i = 1; i <= 4; i++)
{
float lift = maxLift * (i * 0.25f);
if (!CapsuleSweepClear(Vector3.zero, _wallNormal, lift)
|| !CapsuleSweepClear(_wallNormal * lift, travel,
remaining + _controller.skinWidth * CapsuleScale))
continue;
Vector3 mid = transform.position;
_controller.Move(_wallNormal * lift);
_controller.Move(travel * remaining);
// Hold the lift only when the retry actually got past the ledge; a mispredicted
// (still blocked) retry lets the servo re-seat instead of drifting further out.
if (Vector3.Dot(transform.position - mid, travel) > remaining * 0.5f)
_stepPassTimer = StepPassServoHold;
return;
}
}
///
/// True when the capsule, shifted by , can sweep
/// along without hitting anything. Avatars never block a step-pass;
/// no-climb colliders still block physically, so they count.
///
private bool CapsuleSweepClear(Vector3 offset, Vector3 dir, float dist)
{
if (dist <= 0f) return true;
float radius = WorldRadius;
Vector3 center = WorldCapsuleCenter + offset;
Vector3 axis = Vector3.up * Mathf.Max(0f, WorldHalfHeight - radius);
int count = Physics.CapsuleCastNonAlloc(
center - axis, center + axis, radius, dir, _wallProbe, dist, ~0, QueryTriggerInteraction.Ignore);
WarnIfProbeSaturated(count);
for (int i = 0; i < count; i++)
{
if (_wallProbe[i].distance <= 0f)
continue;
if (IsPlayerCollider(_wallProbe[i].collider))
continue;
return false;
}
return true;
}
///
/// True when the down-facing surface overhead is merely a small ledge the step pass can carry
/// the climb around: some outward lift within (same sub-steps as
/// ) gives the capsule a clear path upward past it. Gates the
/// wall->ceiling hand-off — without it, climbing up into a small protruding ledge latches
/// ceiling mode on its underside instead of stepping over its edge. A genuine overhang/ceiling
/// extends outward beyond the step allowance, fails every lift, and still hands off.
///
private bool OverheadIsPassableStep()
{
if (wallStepOffset <= 0f) return false;
Vector3 outDir = _wallNormal;
outDir.y = 0f;
if (outDir.sqrMagnitude < 1e-6f) return false;
outDir.Normalize();
float maxLift = wallStepOffset * CapsuleScale;
float rise = WorldRadius;
for (int i = 1; i <= 4; i++)
{
float lift = maxLift * (i * 0.25f);
if (CapsuleSweepClear(Vector3.zero, outDir, lift)
&& CapsuleSweepClear(outDir * lift, Vector3.up, rise))
return true;
}
return false;
}
///
/// Volumetric wall probe toward -_wallNormal from a vertical offset off the capsule center
/// (negative = below/feet, positive = above/headroom), reporting the hit normal and the gap
/// beyond the capsule surface like does from the center.
///
private bool ProbeWallAt(float upOffset, out Vector3 normal, out float gap)
{
Vector3 dir = -_wallNormal;
Vector3 origin = WorldCapsuleCenter + Vector3.up * upOffset;
float maxDist = WorldRadius + wallMaxOffset + ProbeReachSlack * CapsuleScale;
return NearestClimbHit(origin, dir, maxDist, CapsuleExtent(dir), ceilingOnly: false, out normal, out gap);
}
private bool WallPresentAt(float upOffset) => ProbeWallAt(upOffset, out _, out _);
///
/// Sweeps the FULL capsule toward the climbed surface (along -_wallNormal): contact anywhere
/// along the capsule's height counts, so a thin board keeps holding while the center band has
/// already cleared it (or hasn't reached it). A down-tilted edge graze (a capsule cap against
/// a board's bottom edge) still counts as HOLDING contact — the wall is only lost once the
/// capsule no longer overlaps it — but reports the nearest wall-like
/// hit (falling back to the current normal) so a graze never steers the climb into ceiling
/// mode. is the capsule-surface clearance.
///
private bool ProbeWallSpan(out Vector3 normal, out float gap)
{
normal = _wallNormal;
gap = 0f;
// Strictly horizontal sweep: near an edge _wallNormal is already tilted, and sweeping along
// it would dive above/below the face instead of across it (same flattening as RaycastWallFace).
Vector3 dir = -_wallNormal;
dir.y = 0f;
if (dir.sqrMagnitude < 1e-6f) return false;
dir.Normalize();
float radius = WorldRadius;
Vector3 axis = Vector3.up * Mathf.Max(0f, WorldHalfHeight - radius);
// Same one-radius backup as NearestClimbHit: a surface at skin-width standoff must never be
// an (unreported) initial overlap; hit.distance then measures from the capsule axis.
Vector3 back = dir * radius;
float maxDist = radius + wallMaxOffset + ProbeReachSlack * CapsuleScale;
int count = Physics.CapsuleCastNonAlloc(
WorldCapsuleCenter - axis - back, WorldCapsuleCenter + axis - back, radius, dir,
_wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
WarnIfProbeSaturated(count);
float bestWall = float.MaxValue;
float bestAny = float.MaxValue;
for (int i = 0; i < count; i++)
{
if (_wallProbe[i].distance <= 0f)
continue;
if (IsPlayerCollider(_wallProbe[i].collider) || IsNoClimb(_wallProbe[i].collider))
continue;
if (_wallProbe[i].distance < bestAny)
bestAny = _wallProbe[i].distance;
if (_wallProbe[i].normal.y > CeilingNormalThreshold && _wallProbe[i].distance < bestWall)
{
bestWall = _wallProbe[i].distance;
normal = _wallProbe[i].normal;
}
}
if (bestAny == float.MaxValue)
return false;
gap = Mathf.Max(0f, (bestWall != float.MaxValue ? bestWall : bestAny) - radius);
return true;
}
///
/// True when walkable ground (angle within the controller's slope limit) sits within step range
/// below the capsule. Used to top out of a climb back onto solid ground without trusting
/// isGrounded, which the standoff servo can falsify by holding the capsule just off the surface.
/// No-climb surfaces still count: they can't be grabbed but can be stood on.
///
private bool WalkableGroundBelow()
{
return WalkableBelow(
WorldCapsuleCenter + Vector3.up * WorldRadius,
(_controller.center.y + _controller.stepOffset) * CapsuleScale);
}
///
/// True when the walkable surface below also extends outward from the climbed face, sampled a
/// capsule radius plus the step allowance away from the wall. A genuine floor/terrace does; the
/// narrow top of a step/trim being passed () does not.
///
private bool GroundExtendsOutward()
{
Vector3 outDir = _wallNormal;
outDir.y = 0f;
if (outDir.sqrMagnitude < 1e-6f)
return true; // no horizontal facing: keep the plain ground test
outDir.Normalize();
float outDist = WorldRadius + wallStepOffset * CapsuleScale;
return WalkableBelow(
WorldCapsuleCenter + outDir * outDist + Vector3.up * WorldRadius,
(_controller.center.y + _controller.stepOffset) * CapsuleScale);
}
///
/// True when a walkable surface lies where the mantle's forward phase would land (just past the
/// climbed edge). Gates both top-out flavors: a thick wall or a flattening slope has a standable
/// top, a thin board's bare rim does not — detaching there would only drop the player.
///
private bool HasStandableTop()
{
Vector3 forward = -_wallNormal;
forward.y = 0f;
if (forward.sqrMagnitude < 1e-6f) return false;
forward.Normalize();
Vector3 origin = WorldCapsuleCenter + forward * MantleForwardDistance + Vector3.up * WorldHalfHeight;
float maxDist = WorldHalfHeight * 2f + _controller.stepOffset * CapsuleScale;
return WalkableBelow(origin, maxDist);
}
///
/// True when nothing blocks the mantle's rise: phase 1 climbs up to roughly a capsule height
/// before the feet clear the edge. Under a low ceiling (shelf compartment, overhang beyond the
/// ceiling-grab range) the mantle would stall against it and time out into a drop — the climb
/// parks at the rim instead. No-climb colliders still block physically, so they count.
///
private bool HasMantleHeadroom()
{
float radius = WorldRadius;
int count = Physics.SphereCastNonAlloc(
WorldCapsuleCenter - Vector3.up * radius, radius, Vector3.up,
_wallProbe, WorldHalfHeight * 2f, ~0, QueryTriggerInteraction.Ignore);
WarnIfProbeSaturated(count);
for (int i = 0; i < count; i++)
{
if (_wallProbe[i].distance <= 0f)
continue;
if (IsPlayerCollider(_wallProbe[i].collider))
continue;
return false;
}
return true;
}
/// Nearest non-player hit of a capsule-wide sphere cast straight down; true when its
/// surface angle is within the controller's slope limit.
private bool WalkableBelow(Vector3 origin, float maxDist)
{
int count = Physics.SphereCastNonAlloc(
origin, WorldRadius, Vector3.down, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
WarnIfProbeSaturated(count);
float best = float.MaxValue;
Vector3 normal = Vector3.up;
bool found = false;
for (int i = 0; i < count; i++)
{
if (_wallProbe[i].distance <= 0f)
continue;
if (IsPlayerCollider(_wallProbe[i].collider))
continue;
if (_wallProbe[i].distance < best)
{
best = _wallProbe[i].distance;
normal = _wallProbe[i].normal;
found = true;
}
}
return found && Vector3.Angle(normal, Vector3.up) <= _controller.slopeLimit;
}
///
/// Detaches at the top of a climbed wall/slope with an upward hop, so holding W carries the
/// player over the lip instead of dropping back onto the face (which would re-grab and stall).
/// The re-grab cooldown spans the hop, and the grab test only fires when not rising.
///
private void TopOutDetach()
{
_reentryCooldown = TopOutRegrabCooldown;
ExitWallClimb();
if (wallTopOutBoost > 0f)
_verticalVelocity = Mathf.Sqrt(wallTopOutBoost * -2f * gravity);
}
/// Leaves wall-climb mode and resets vertical velocity so the player falls cleanly.
private void ExitWallClimb()
{
_isWallClimbing = false;
_onCeiling = false;
_lostContactTimer = 0f;
_rimConfirmTimer = 0f;
_stepPassTimer = 0f;
_verticalVelocity = 0f;
_climbVelocity = Vector3.zero;
// Hand the current lean off to the residual so it eases home instead of snapping.
_meshOffsetResidual = _wallOffset;
_meshOffsetExitNormal = _wallNormal;
_wallOffset = 0f;
_wallYaw = 0f;
_wallStrafeSign = 0;
_wallClimbSign = 0;
_offsetDragging = false;
_climbLeftGround = false;
_mantling = false;
_mantleClearedFeet = false;
_mantleOverDist = 0f;
_mantleTimer = 0f;
}
///
/// Collects contact normals during Move: wall/ceiling contacts feed the wall-climb detector,
/// the flattest upward contact becomes the ground normal.
///
private void OnControllerColliderHit(ControllerColliderHit hit)
{
if (enableWallClimb && !IsNoClimb(hit.collider))
{
float angle = Vector3.Angle(hit.normal, Vector3.up);
if (angle >= wallMinAngle)
{
_wallContactThisFrame = true;
_wallNormalAccum += hit.normal;
}
}
// Ignore near-vertical walls and ceilings for ground-normal collection.
if (hit.normal.y <= 0.05f)
return;
// Keep the flattest (most supportive) contact seen this frame.
if (!_groundNormalUpdated || hit.normal.y > _groundNormal.y)
{
_groundNormal = hit.normal;
_groundNormalUpdated = true;
}
}
}