diff --git a/Assets/Scripts/Components/Controller/PlayerCharacterController.cs b/Assets/Scripts/Components/Controller/PlayerCharacterController.cs
index 6990d96..d5bb6f4 100644
--- a/Assets/Scripts/Components/Controller/PlayerCharacterController.cs
+++ b/Assets/Scripts/Components/Controller/PlayerCharacterController.cs
@@ -5,16 +5,10 @@ using UnityEngine.InputSystem;
///
/// CharacterController-based player: camera-relative WASD movement, manual gravity and jumping,
-/// sliding off too-steep slopes/edges, and a wall/ceiling-climb mode.
-///
-/// Wall-climb (entered by pushing the move input into a wall): W/S climb straight up/down (velocity
-/// ramps in for precise placement), A/D strafe camera-relative, the body faces the wall. Left-drag
-/// leans the visual mesh out of / into the wall (capsule stays put), right-drag turns the body;
-/// either pauses the camera orbit. Jumping detaches; reaching the top edge mantles over. Ceiling-climb
-/// shares the mode (entered by jumping up into a ceiling or climbing a wall into an overhang): the
-/// body stays upright and moves camera-relative in the horizontal plane; jump lets go. On the ground
-/// the same mouse scheme raises/lowers the mesh and turns the body without moving the capsule.
-///
+/// 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).
/// Named PlayerCharacterController to avoid a clash with Unity.VisualScripting's "PlayerController".
///
[RequireComponent(typeof(CharacterController))]
@@ -127,7 +121,7 @@ public class PlayerCharacterController : MonoBehaviour
[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 (center probe clear, wall still at the feet, clear headroom above) must persist before committing to a top-out mantle. Filters transient center-probe misses (thin boards / VHACD hull gaps the ray slips through) so they no longer instantly trigger a mantle mid-face. Keep small; a couple of frames is enough.")]
+ [Tooltip("How long (seconds) the top-edge signature (probe clear, wall still at the feet, clear headroom above) 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("Head-to-ceiling distance (units) at which climbing a wall up into an overhang hands off to the ceiling.")]
@@ -148,63 +142,52 @@ public class PlayerCharacterController : MonoBehaviour
[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.
- ///
+ /// 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 and the vertical velocity is
- /// held at rest, so the avatar hovers at its spawn position instead of settling. Set by the
- /// GameModeManager during the pre-Hide loading barrier (alongside ) and
- /// restored when the Hide phase begins.
- ///
+ /// 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 sets it so the character always faces where the camera looks;
- /// null restores movement-facing. Only affects ground/air locomotion — wall-climb owns its own facing.
- ///
+ /// 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): standing on the floor
- /// below the run threshold, or attached to a wall/ceiling with no climb input. Snapshotted at the
- /// end of Update so a sibling reads a stable, order-independent value.
- ///
+ /// 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 (ground, air, wall, or ceiling).
+ /// 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 (the ground-locomotion state);
- /// airborne / wall / standing all read false. The pose system picks its walk/run clips off this.
+ /// 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). The pose system picks its fall clip off this.
+ /// (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: it would be moot (the graph overrides it) and Unity would
- /// log "Animator is not playing an AnimatorController" every frame.
+ /// 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 — with the humanoid rig the Animator sits on this same node, so it
- /// lands in the reset subtree and would otherwise be snapped back to base every frame, wiping the offset.
+ /// 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;
@@ -212,10 +195,10 @@ public class PlayerCharacterController : MonoBehaviour
/// (driven by ) eases back to this when no pose overrides it.
public float BaseCapsuleCenterY { get; private set; }
- /// Authored CharacterController capsule radius, captured in Awake (see ).
+ /// Authored CharacterController capsule radius, captured in Awake.
public float BaseCapsuleRadius { get; private set; }
- /// Authored CharacterController capsule height, captured in Awake (see ).
+ /// 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.
@@ -224,59 +207,53 @@ public class PlayerCharacterController : MonoBehaviour
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 (accel/decel ramp)
- private Vector3 _climbVelocity; // smoothed along-surface climb velocity (accel ramp for precise placement)
- private bool _sprinting; // exposed via IsSprinting
- private bool _isIdle; // exposed via IsIdle
- private bool _movingOnGround; // exposed via IsMovingOnGround
- private bool _isFalling; // 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)
+ 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)
- // Ground contact normal from the latest Move (for slope sliding).
private Vector3 _groundNormal = Vector3.up;
private bool _groundNormalUpdated;
private float _slideSpeed;
- // Wall-climb state. A climb surface counts as a ceiling (horizontal-crawl model) rather
- // than a wall (up/down model) once its normal points down past this threshold (~>120 deg
- // from up); between that and wallMinAngle it is treated as a wall.
+ // 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;
- // Hysteresis band around the threshold: in the inner wall/ceiling corner the capsule touches
- // both surfaces, so the per-frame contact normal mixes horizontal (wall) and downward (ceiling)
- // and its y crosses -0.5 back and forth -- flipping the climb mode and snapping the body's yaw
- // between "face the wall" and "face the move direction". Latch the class and only flip when the
- // normal is unambiguously past one edge of the band.
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; see thresholds above)
+ 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 _wallOffset; // signed mesh lean (mouse drag): + = out from the wall, - = into it; moves only the model, not the capsule
- private float _wallYaw; // body yaw offset from facing the wall (mouse drag); held until leaving
- private int _wallStrafeSign; // latched sideways direction (camera picks it once per strafe); 0 = unlatched
- private int _wallClimbSign; // latched up/down direction: whichever W/S key pushes into the wall climbs UP (camera picks it once per climb); 0 = unlatched
+ 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 (enables ground-detach)
- private Vector3 _meshBaseLocalPos; // meshOffsetTarget's authored local position; the lean offset is applied relative to this
- private Vector3 _cameraTargetBaseLocalPos; // cameraTarget's authored local position; the pose/ceiling camera offset is applied relative to this
- private float _cameraYOffset; // last camera-target local-up offset written (cached so an unchanged value skips the transform write)
- private float _meshOffsetResidual; // leftover mesh lean still easing back to 0 after leaving the wall (no longer climbing)
- private Vector3 _meshOffsetExitNormal; // wall normal latched at exit; the residual eases along it (live _wallNormal may shift on stray contact)
+ 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 wall/ground
+ 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 any top-out so holding W tops out cleanly instead of detach/re-grab buzzing
+ 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; // blocks re-grabbing right after topping out
+ private float _reentryCooldown;
void Awake()
{
@@ -290,9 +267,9 @@ public class PlayerCharacterController : MonoBehaviour
if (!cameraOrbit)
cameraOrbit = FindFirstObjectByType();
- // Resolve the model root to offset: walk up from the skinned mesh to the topmost node still
- // under this transform, so it parents BOTH the renderer and its bones. Offsetting only the
- // renderer would move just the baked paint collider, not the bone-driven visible mesh.
+ // 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();
@@ -315,10 +292,7 @@ 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).
+ // Kill-plane fail-safe; runs ahead of the control gate so it rescues in any mode.
if (!_spawnCaptured)
{
_spawnPosition = transform.position;
@@ -334,8 +308,7 @@ public class PlayerCharacterController : MonoBehaviour
bool jumpPressed = false;
bool sprinting = false;
- // Skip input when control is disabled (e.g. paint mode); gravity and
- // grounding below still run so the player stays planted.
+ // Skip input when control is disabled (e.g. paint mode); gravity and grounding still run.
if (ControlEnabled)
{
#if ENABLE_INPUT_SYSTEM
@@ -366,7 +339,6 @@ public class PlayerCharacterController : MonoBehaviour
Vector3 moveDir;
if (cameraRelative && cameraTransform)
{
- // Project the camera axes onto the ground plane so movement stays horizontal.
Vector3 forward = cameraTransform.forward;
forward.y = 0f;
forward.Normalize();
@@ -383,10 +355,8 @@ public class PlayerCharacterController : MonoBehaviour
UpdateWallClimbState(moveDir);
// Latch the wall normal from contacts ONLY while not yet climbing (the grab needs it).
- // During a climb the probes own _wallNormal: the per-frame contact blend mixes ceiling
- // and wall normals in an inner corner (e.g. ~(0,-0.97,-0.26)), which would fight the
- // probed surface and flip the wall/ceiling classification frame to frame. Reset the
- // accumulators regardless (OnControllerColliderHit refills them during Move).
+ // 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;
@@ -394,24 +364,21 @@ public class PlayerCharacterController : MonoBehaviour
if (_isWallClimbing)
{
- // Frozen on the wall while control is off (e.g. paint mode): keep the
- // grip, no movement or gravity, so switching modes never drops us.
+ // 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; // drop ground momentum so resuming starts from rest
- _isIdle = ControlEnabled && (h * h + v * v) <= 1e-4f; // attached & no climb input = holding a pose
- _movingOnGround = false; // on a wall/ceiling, never ground locomotion
- _isFalling = false; // attached to a surface, not falling
+ _horizontalVelocity = Vector3.zero;
+ _isIdle = ControlEnabled && (h * h + v * v) <= 1e-4f;
+ _movingOnGround = false;
+ _isFalling = false;
return;
}
- // Ground pose-adjust (mouse): left button + vertical drag raises/lowers the mesh, right
- // button + horizontal drag turns the body -- the wall scheme mirrored to the ground. Either
- // pauses the camera orbit so the same drag isn't also spent orbiting. Gated to player control
+ // 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)
@@ -421,8 +388,6 @@ public class PlayerCharacterController : MonoBehaviour
if (cameraOrbit)
cameraOrbit.LookSuppressed = _offsetDragging;
- // Glide the leftover wall lean home and re-apply the composed mesh offset (residual lean +
- // ground raise + pose y-offset) in this frame's single write of meshOffsetTarget.
EaseMeshOffsetToRest(Time.deltaTime);
// A ground normal steeper than the slope limit is too steep to stand on
@@ -435,8 +400,6 @@ public class PlayerCharacterController : MonoBehaviour
if (!GravityEnabled)
{
- // Loading barrier freeze: no gravity or ground-stick, hold vertical velocity at rest so the
- // avatar keeps its spawn position (movement input is already cut via ControlEnabled).
_verticalVelocity = 0f;
}
else
@@ -446,7 +409,6 @@ public class PlayerCharacterController : MonoBehaviour
if (_verticalVelocity < 0f)
_verticalVelocity = groundedStickVelocity;
- // No jumping off a slope too steep to stand on.
if (jumpPressed && !onSteepSlope)
_verticalVelocity = Mathf.Sqrt(jumpHeight * -2f * gravity);
}
@@ -460,7 +422,7 @@ public class PlayerCharacterController : MonoBehaviour
Vector3 slideDir = Vector3.ProjectOnPlane(Vector3.down, _groundNormal).normalized;
_slideSpeed = Mathf.Min(_slideSpeed + slideAcceleration * Time.deltaTime, maxSlideSpeed);
slideVelocity = slideDir * _slideSpeed;
- moveDir *= slideControl; // reduced steering while sliding
+ moveDir *= slideControl;
}
else
{
@@ -468,22 +430,17 @@ public class PlayerCharacterController : MonoBehaviour
}
float currentSpeed = sprinting ? moveSpeed * sprintSpeedMultiplier : moveSpeed;
- // Ease horizontal velocity toward the target so movement accelerates/decelerates
- // instead of snapping. Accel while there is input, decel when it eases off.
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;
- // fall back to "up" if nothing was hit.
+ // Move fires OnControllerColliderHit, which refills the ground normal.
_groundNormalUpdated = false;
_controller.Move(velocity * Time.deltaTime);
if (!_groundNormalUpdated)
_groundNormal = Vector3.up;
- // Facing: first-person pins the body to the camera yaw (even while idle); otherwise turn toward
- // the movement direction, unless the right-button turn is actively steering the body.
if (FaceYaw.HasValue)
{
transform.rotation = Quaternion.Euler(0f, FaceYaw.Value, 0f);
@@ -495,22 +452,16 @@ public class PlayerCharacterController : MonoBehaviour
transform.rotation, target, rotationSpeed * Time.deltaTime);
}
- // Run animation: only while grounded and actually moving (airborne/sliding-blocked = idle).
// 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; // standing still on the floor
- // Falling: airborne and descending past the threshold (wall-climb already returned above, so
- // not-grounded here means free in the air). Scaled by player size like gravity/jump.
+ _isIdle = ControlEnabled && _controller.isGrounded && !movingOnGround;
_isFalling = !_controller.isGrounded && _verticalVelocity < -fallAnimationSpeed * CapsuleScale;
}
- ///
- /// Kill-plane rescue: snap the avatar back to its captured spawn point and clear all momentum.
- /// Owner-only; the authoritative NetworkTransform replicates the new position to remotes.
- ///
+ /// Kill-plane rescue: snap back to the captured spawn point and clear all momentum.
private void RescueToSpawn()
{
CharacterTeleport.Move(_controller, transform, _spawnPosition);
@@ -521,8 +472,7 @@ public class PlayerCharacterController : MonoBehaviour
_slideSpeed = 0f;
}
- // Hunters may not grab a wall during the hide phase (they wait it out); every other role/phase climbs
- // normally. The controller only runs for the local owner, so _setup is this avatar's own role.
+ // Hunters may not grab a wall during the hide phase; every other role/phase climbs normally.
private bool WallClimbBlockedNow()
{
var match = MatchManager.Instance;
@@ -546,10 +496,8 @@ public class PlayerCharacterController : MonoBehaviour
return;
}
- // Decide whether the contacted surface is being pushed into. A wall is grabbed by
- // pushing the move input horizontally into it while not rising (mere contact, e.g.
- // standing at a wall's base, must not grab). A ceiling can't be pushed into
- // horizontally, so it is grabbed by rising up into it (jumping up against it).
+ // 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)
{
@@ -562,8 +510,7 @@ public class PlayerCharacterController : MonoBehaviour
&& _verticalVelocity <= 0f;
}
- // Grab only under player control; once climbing we stay attached across control
- // toggles (e.g. paint mode) so switching modes never drops us off the surface.
+ // 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)
{
@@ -574,22 +521,21 @@ public class PlayerCharacterController : MonoBehaviour
_verticalVelocity = 0f;
_slideSpeed = 0f;
_wallOffset = 0f;
- _groundOffset = 0f; // drop any ground pose-adjust raise so it doesn't ride onto the wall
- _meshOffsetResidual = 0f; // fresh grab: the climb path owns the mesh, drop any leftover ease
+ _groundOffset = 0f;
+ _meshOffsetResidual = 0f;
_wallYaw = 0f;
_wallStrafeSign = 0;
_wallClimbSign = 0;
_climbLeftGround = false;
- _climbVelocity = Vector3.zero; // start the climb ramp from rest
+ _climbVelocity = Vector3.zero;
}
}
///
/// 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; left-drag leans the
- /// mesh via (capsule stays put), right-drag turns via .
- /// CEILING: upright, WASD camera-relative in the horizontal plane like on the ground. The surface is
- /// re-probed each frame; a wall's top edge mantles, climbing into an overhang hands off to the ceiling.
+ /// 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)
{
@@ -604,11 +550,9 @@ public class PlayerCharacterController : MonoBehaviour
UpdateCeilingClass();
bool onCeiling = _onCeiling;
- // Map W/S to UP/DOWN by which key pushes INTO the wall (like A/D's strafe sign): facing the wall
- // that's W; with the camera on the wall's side (walking backward into it) it's S. Without the
- // remap the grab (camera-relative) and the base-release (raw S) disagree and the climb flickers.
- // Latched once per press; only walls remap (the ceiling crawl is already camera-relative).
- // vEff is the effective climb axis used for every up/down test below.
+ // 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;
@@ -616,14 +560,11 @@ public class PlayerCharacterController : MonoBehaviour
{
Vector3 camFwd = cameraTransform ? cameraTransform.forward : Vector3.zero;
camFwd.y = 0f;
- // camFwd aligned with the outward wall normal = camera faces away from the wall, so
- // forward (W) moves away from it and S is the into-wall, climb-up key.
_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 and
- // drops. Re-grab stays blocked appropriately by UpdateWallClimbState's push-in test.
+ // Jump detaches: off a wall it pushes straight up, off a ceiling it just lets go.
if (jumpPressed)
{
ExitWallClimb();
@@ -631,8 +572,8 @@ public class PlayerCharacterController : MonoBehaviour
return;
}
- // Wall->ceiling hand-off: while climbing up a wall into an overhang, switch to the
- // ceiling right above the head instead of trying to mantle over a (non-existent) top.
+ // 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.
if (!onCeiling && vEff > 0f
&& ProbeCeiling(out Vector3 ceilNormal, out float ceilHeadGap)
&& ceilHeadGap <= ceilingGrabDistance)
@@ -644,9 +585,8 @@ public class PlayerCharacterController : MonoBehaviour
}
// 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", the hand-off re-promotes next frame, and the body's facing flips ~9 deg
- // every frame (the corner twitch). Stay latched until the ceiling is genuinely gone overhead.
+ // 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)
@@ -682,14 +622,11 @@ public class PlayerCharacterController : MonoBehaviour
}
}
- // Topped out onto a slope: the climbed face has flattened into something walkable (no
- // longer steep enough to be a wall). Hand back to normal grounded movement instead of
- // clinging to a "wall" that is really just ground now.
+ // Topped out onto a slope: the climbed face has flattened into something walkable. Hand back
+ // to grounded movement; hop only when actually moving up onto it (a real top-out).
if (found && !onCeiling && _climbLeftGround
&& Vector3.Angle(_wallNormal, Vector3.up) < wallMinAngle)
{
- // Hop over the lip only when actually moving up onto the flattened face (a real
- // top-out); if we reached it going down, just step off cleanly with no hop.
if (vEff > 0f) TopOutDetach();
else ExitWallClimb();
return;
@@ -698,24 +635,19 @@ public class PlayerCharacterController : MonoBehaviour
Vector3 alongSurface;
if (onCeiling)
{
- // Crawl across the ceiling in the horizontal plane, camera-relative (moveDir is
- // already the ground-projected camera input); project onto the ceiling for safety.
alongSurface = Vector3.ProjectOnPlane(moveDir, _wallNormal);
}
else
{
- // W/S climb straight up/down (camera pitch never tilts them). Strafe along the wall's own
- // horizontal tangent, not live camera-right: that coupled movement to the orbit camera's
- // per-frame re-aim and jittered sideways motion. The camera only picks the sign.
+ // 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 flips wallRight while rounding a curved wall (at 90 deg to the camera the dot
- // crosses zero and the input inverts frame-to-frame, locking you there). Resets when the
- // strafe input releases so the next one re-syncs to the camera.
+ // 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;
@@ -733,14 +665,9 @@ public class PlayerCharacterController : MonoBehaviour
{
_lostContactTimer += dt;
- // Wall only: the center probe (incl. its horizontal fallback) losing the wall while climbing
- // up, with wall still at the feet AND clear headroom above the center, is the top-edge
- // signature. Confirm it briefly before committing: the mantle used to fire on the very first
- // miss frame (zero tolerance, checked before the graced exit), so any transient center miss -
- // a thin/narrow board or a VHACD hull gap the ray slips through - instantly triggered a mantle
- // mid-face. AtWallTopEdge rejects such a gap spatially (multi-sample foot test + headroom
- // check); the confirm window rejects it temporally (a transient miss re-acquires within a
- // frame or two, resetting _lostContactTimer before wallTopOutConfirm elapses).
+ // Wall top-out: probe lost the wall while climbing up, wall still at the feet, clear
+ // headroom above the center. Confirmed briefly before committing so a transient miss
+ // (which re-acquires within a frame or two) never mantles mid-face.
if (!onCeiling && alongSurface.y > 0f && AtWallTopEdge())
{
if (_lostContactTimer >= wallTopOutConfirm)
@@ -752,10 +679,8 @@ public class PlayerCharacterController : MonoBehaviour
MantleOverEdge(dt);
return;
}
- // Within the confirm window: keep climbing (fall through) and re-probe next frame.
}
- // Surface genuinely gone (incl. crawling off a ceiling edge): leave after grace,
- // dropping into normal gravity.
+ // Surface genuinely gone (incl. crawling off a ceiling edge): leave after grace.
else if (_lostContactTimer >= wallExitGrace)
{
ExitWallClimb();
@@ -763,9 +688,8 @@ public class PlayerCharacterController : MonoBehaviour
}
}
- // Left drag = signed lean (moves only the visual mesh, so it can sink into the wall where the
- // capsule can't); right drag = body turn (WALL: held yaw offset from the wall normal; CEILING:
- // rotates the transform directly). The lean eases home while moving; either drag pauses the orbit.
+ // 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)
@@ -786,13 +710,12 @@ public class PlayerCharacterController : MonoBehaviour
{
_wallOffset = Mathf.MoveTowards(_wallOffset, 0f, wallOffsetReturnSpeed * dt);
}
- ApplyMeshOffset(_wallOffset);
+ 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 (precise
- // placement). Sprint scales the accel too, so it reaches its higher speed just as quickly;
- // releasing decelerates at the ground rate for a prompt, controllable stop.
+ // 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;
@@ -803,10 +726,8 @@ public class PlayerCharacterController : MonoBehaviour
Vector3 velocity = _climbVelocity;
if (found)
{
- // Smoothly hold the capsule one skin-width off the surface (flush): the lean offset no
- // longer moves the capsule (only the visual mesh), so it always rests against the wall.
- // The controller's depenetration never fights back, so there's no buzz and no quantizing
- // deadzone is needed. Exponential follow keeps it framerate-stable; clamp guards spikes.
+ // Servo the capsule to one skin-width off the surface. Exponential follow keeps it
+ // framerate-stable; the clamp guards spikes.
float maxPerpSpeed = 8f * CapsuleScale;
float gapError = _controller.skinWidth * CapsuleScale - gap;
float follow = 1f - Mathf.Exp(-wallOffsetFollow * dt);
@@ -815,12 +736,10 @@ public class PlayerCharacterController : MonoBehaviour
_controller.Move(velocity * dt);
- // Reaching walkable ground detaches. The top-out hop fires ONLY when topping out upward
- // (vEff > 0); descending to the floor, or pressing away (vEff < 0) at the base while grounded,
- // lets go cleanly with no hop - the latter 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. Walkable
- // ground is probed under the feet rather than trusting isGrounded (the standoff correction can
- // lift the capsule just off the surface, so at a slope's top isGrounded stays false).
+ // 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.
bool onWalkableGround = _controller.isGrounded || WalkableGroundBelow();
if (!onWalkableGround)
{
@@ -841,8 +760,8 @@ public class PlayerCharacterController : MonoBehaviour
{
if (onCeiling)
{
- // Stay upright and face the horizontal move direction, like on the ground -- unless
- // the right-button turn is actively steering the body (then the manual yaw holds).
+ // Stay upright and face the horizontal move direction, unless the right-button
+ // turn is actively steering the body.
if (!yawDrag)
{
Vector3 dir = alongSurface;
@@ -858,7 +777,6 @@ public class PlayerCharacterController : MonoBehaviour
else
{
// Face the wall (forward = into the wall), turned by the held mouse-drag yaw.
- // On leaving, the ground rotation eases the body back to its movement direction.
Vector3 faceWall = -_wallNormal;
faceWall.y = 0f;
if (faceWall.sqrMagnitude > 0.0001f)
@@ -870,11 +788,7 @@ public class PlayerCharacterController : MonoBehaviour
}
}
- ///
- /// Updates the latched wall/ceiling classification with hysteresis on the surface normal's
- /// downward tilt. In the inner corner the live normal jitters across the single threshold; the
- /// band keeps the climb mode (and the body yaw it drives) from flipping frame to frame.
- ///
+ /// Updates the latched wall/ceiling classification (hysteretic; see the thresholds).
private void UpdateCeilingClass()
{
if (_onCeiling)
@@ -887,16 +801,10 @@ public class PlayerCharacterController : MonoBehaviour
}
}
- ///
- /// Offsets only the visual model (mesh + its baked paint collider) along the wall normal by the
- /// given signed distance (+ = out, - = into the wall), relative to its authored local position.
- /// The capsule is untouched, so the lean can sink into the wall where the capsule would be blocked.
- ///
- private void ApplyMeshOffset(float offset) => SetMeshLean(_wallNormal, offset);
-
///
/// Places the visual model at the given signed lean along (world space),
- /// relative to its authored local pose. Local-space conversion tracks the live body yaw.
+ /// 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)
{
@@ -904,7 +812,7 @@ public class PlayerCharacterController : MonoBehaviour
Vector3 worldOffset = dir * offset;
meshOffsetTarget.localPosition = _meshBaseLocalPos
+ transform.InverseTransformVector(worldOffset)
- + Vector3.up * _groundOffset; // ground mouse raise (0 unless in use)
+ + Vector3.up * _groundOffset;
}
///
@@ -921,15 +829,13 @@ public class PlayerCharacterController : MonoBehaviour
///
/// Ground pose-adjust, mirroring the wall-climb mouse scheme: LEFT drag raises/lowers the visual
- /// mesh (, capsule untouched), RIGHT drag turns the body. The raise
- /// eases back while moving and holds while standing; the turn persists until movement re-aims the
- /// body. Returns true while a right-button turn is active so the caller skips
- /// rotate-toward-movement that frame. The call site gates on ControlEnabled (paint mode owns the left button).
+ /// 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;
@@ -953,10 +859,9 @@ public class PlayerCharacterController : MonoBehaviour
}
///
- /// Resizes the CharacterController capsule (center Y, radius, height) to the active pose's dimensions,
- /// driven by so a low pose can fit where the default standing capsule
- /// is too tall. Only the collision capsule changes — the visual model and camera target are untouched.
- /// Cached so an unchanged value skips the write (a resize re-bakes the capsule internally).
+ /// 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)
{
@@ -969,10 +874,9 @@ public class PlayerCharacterController : MonoBehaviour
}
///
- /// Places the camera target (the ThirdPersonCamera's follow/look target) at the given local-up
- /// offset relative to its authored local position, without moving the body. Driven by
- /// with the active pose's camera y-offset plus the ceiling offset;
- /// 0 restores the authored target height. Cached so an unchanged value skips the write.
+ /// 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)
{
@@ -981,95 +885,41 @@ public class PlayerCharacterController : MonoBehaviour
cameraTarget.localPosition = _cameraTargetBaseLocalPos + Vector3.up * y;
}
- // The CharacterController's serialized center/radius/height are authored at the player's base
- // (scale-1) size; transform.localScale resizes the ACTUAL collision capsule via lossyScale but
- // leaves those serialized numbers untouched. Every climb probe below raycasts in WORLD space, so
- // it must use the scaled (world) capsule dimensions and scale its world-distance paddings too --
- // otherwise the wall grab range, head-to-ceiling gap and mantle distance are all measured at the
- // authored size on a shrunk/grown player (PlayerScaler scales the tunables but not these probes).
- // Uniform player scale is enforced by PlayerScaler, so a single lossyScale component suffices.
+ // 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 ray length beyond the capsule surface (scale-1 units)
-
- // Horizontal-only fallback samples for ProbeWall: fractions of the capsule radius along the wall's
- // horizontal tangent. Fired ONLY when the zero-width center ray misses (thin/narrow boards the center
- // line slips past). Horizontal-only on purpose: sideways offsets keep the same height as the center,
- // so a real top-edge still misses on ALL samples -> the top-out mantle (which lives under !found) is
- // untouched. A vertical-down offset would keep found=true over the lip and defeat mantling.
- private static readonly float[] WallProbeTangentSamples = { 0.9f, -0.9f, 0.5f, -0.5f };
+ private const float ProbeReachSlack = 0.5f; // extra cast length beyond the capsule surface (scale-1 units)
///
/// 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. Finds the
- /// controller even on remotes, where the component is disabled (GetComponentInParent only checks
- /// the GameObject's active state, not the component's enabled flag).
+ /// 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 (): 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).
+ /// 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);
///
- /// 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
- /// along the probe direction is the radius sideways but up to the half-height vertically,
- /// so the gap is correct for ceilings too. Our own colliders are skipped. False = no hit.
+ /// 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 origin = WorldCapsuleCenter;
Vector3 dir = -_wallNormal;
float extent = CapsuleExtent(dir);
float maxDist = extent + wallMaxOffset + ProbeReachSlack * CapsuleScale;
- // Center ray first: unchanged behaviour on ordinary walls (no regression).
- if (NearestClimbHit(origin, dir, maxDist, extent, ceilingOnly: false, out normal, out gap))
+ if (NearestClimbHit(WorldCapsuleCenter, dir, maxDist, extent, ceilingOnly: false, out normal, out gap))
return true;
- // Fallback: the zero-width center ray slips past thin/narrow boards whenever the capsule
- // drifts a hair sideways (which is why standing still drops you - the volumetric re-grab that
- // masks it needs move input). Sample a few origins spread across the capsule's horizontal
- // footprint; the offsets are perpendicular to dir, so gap/normal stay consistent on a flat face.
- Vector3 wallUp = Vector3.ProjectOnPlane(Vector3.up, _wallNormal);
- wallUp = wallUp.sqrMagnitude > 1e-6f ? wallUp.normalized : Vector3.up;
- Vector3 wallRight = Vector3.Cross(_wallNormal, wallUp);
- if (wallRight.sqrMagnitude <= 1e-6f)
- {
- normal = _wallNormal;
- return false;
- }
- wallRight.Normalize();
-
- float radius = WorldRadius;
- bool foundAny = false;
- float bestGap = float.MaxValue;
- Vector3 bestNormal = _wallNormal;
- for (int i = 0; i < WallProbeTangentSamples.Length; i++)
- {
- Vector3 o = origin + wallRight * (WallProbeTangentSamples[i] * radius);
- if (NearestClimbHit(o, dir, maxDist, extent, ceilingOnly: false, out Vector3 n, out float g)
- && g < bestGap)
- {
- bestGap = g;
- bestNormal = n;
- foundAny = true;
- }
- }
-
- if (foundAny)
- {
- normal = bestNormal;
- gap = bestGap;
- return true;
- }
-
normal = _wallNormal;
return false;
}
@@ -1085,16 +935,15 @@ public class PlayerCharacterController : MonoBehaviour
}
///
- /// Raycasts straight up from the center to find a ceiling overhead and its head clearance
+ /// 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)
{
- Vector3 origin = WorldCapsuleCenter;
float half = WorldHalfHeight;
float maxDist = half + wallMaxOffset + ProbeReachSlack * CapsuleScale;
- if (NearestClimbHit(origin, Vector3.up, maxDist, half, ceilingOnly: true, out normal, out gap))
+ if (NearestClimbHit(WorldCapsuleCenter, Vector3.up, maxDist, half, ceilingOnly: true, out normal, out gap))
return true;
normal = Vector3.down;
@@ -1102,11 +951,17 @@ public class PlayerCharacterController : MonoBehaviour
}
///
- /// Raycasts 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 along the probe direction. False = no valid hit
- /// (/ are then zero; callers supply their fallback).
+ /// 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)
@@ -1114,17 +969,20 @@ public class PlayerCharacterController : MonoBehaviour
normal = Vector3.zero;
gap = 0f;
- int count = Physics.RaycastNonAlloc(
- origin, dir, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
+ 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) // not a ceiling
+ if (ceilingOnly && _wallProbe[i].normal.y >= CeilingNormalThreshold)
continue;
if (_wallProbe[i].distance < best)
{
@@ -1147,9 +1005,9 @@ public class PlayerCharacterController : MonoBehaviour
}
///
- /// 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.
+ /// 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)
{
@@ -1162,14 +1020,12 @@ public class PlayerCharacterController : MonoBehaviour
if (!_mantleClearedFeet)
{
- // Phase 1: climb straight up until no wall is left in front of the feet.
_controller.Move(Vector3.up * (speed * dt));
- if (!WallInFront(WorldHalfHeight))
+ if (!WallPresentAt(-WorldHalfHeight))
_mantleClearedFeet = true;
}
else
{
- // Phase 2: move forward onto the top surface, then finish.
_controller.Move(forward * (speed * dt));
_mantleOverDist += speed * dt;
if (_mantleOverDist >= WorldRadius * 2f + 0.15f * CapsuleScale || _controller.isGrounded)
@@ -1189,97 +1045,42 @@ public class PlayerCharacterController : MonoBehaviour
}
///
- /// Horizontal ray toward the wall from a point below the center. Distinguishes a
- /// top edge (wall still present lower down) from the wall being truly gone.
- ///
- private bool WallInFront(float downOffset)
- {
- Vector3 origin = WorldCapsuleCenter - Vector3.up * downOffset;
- float maxDist = WorldRadius + wallMaxOffset + ProbeReachSlack * CapsuleScale;
-
- int count = Physics.RaycastNonAlloc(
- origin, -_wallNormal, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
- WarnIfProbeSaturated(count);
- for (int i = 0; i < count; i++)
- {
- if (IsPlayerCollider(_wallProbe[i].collider) || IsNoClimb(_wallProbe[i].collider))
- continue;
- return true;
- }
- return false;
- }
-
- ///
- /// The hardened top-edge test that gates the mantle: a genuine wall top edge still has wall at the
- /// feet but is clear at (already known: the failed ) and just above the
- /// center. Both wall tests sample the whole horizontal footprint (not just the center line), so a
- /// narrow board isn't a false negative at the feet; the headroom sample above the center is the key
- /// discriminator against a mid-face VHACD hull gap - there the wall continues above the gap the
- /// center ray slipped through, so this returns false and no mantle fires.
+ /// The top-edge test that gates the mantle: a genuine top edge still has wall at the feet but is
+ /// clear just above the center (that the center probe itself is clear is already known: it failed).
+ /// The headroom check is the discriminator against a mid-face gap — there the wall continues
+ /// above whatever the probe slipped through, so no mantle fires.
///
private bool AtWallTopEdge()
{
- return WallPresentAtOffset(-WorldHalfHeight) // wall still below (feet)
- && !WallPresentAtOffset(WorldHalfHeight * 0.5f); // but clear above the center -> real top
+ return WallPresentAt(-WorldHalfHeight)
+ && !WallPresentAt(WorldHalfHeight * 0.5f);
}
///
- /// Multi-sample horizontal wall test at a vertical offset from the capsule center (positive = above):
- /// like but sampling across the capsule's horizontal footprint, so a thin
- /// board or hull gap under the center line doesn't read as "no wall". True if ANY sample hits a
- /// climbable surface.
+ /// Volumetric wall-presence test toward -_wallNormal at a vertical offset from the capsule
+ /// center (negative = below/feet, positive = above/headroom).
///
- private bool WallPresentAtOffset(float upOffset)
+ private bool WallPresentAt(float upOffset)
{
- Vector3 baseOrigin = WorldCapsuleCenter + Vector3.up * upOffset;
- Vector3 dir = -_wallNormal;
+ Vector3 origin = WorldCapsuleCenter + Vector3.up * upOffset;
float maxDist = WorldRadius + wallMaxOffset + ProbeReachSlack * CapsuleScale;
-
- if (WallHitAt(baseOrigin, dir, maxDist)) return true;
-
- Vector3 wallUp = Vector3.ProjectOnPlane(Vector3.up, _wallNormal);
- wallUp = wallUp.sqrMagnitude > 1e-6f ? wallUp.normalized : Vector3.up;
- Vector3 wallRight = Vector3.Cross(_wallNormal, wallUp);
- if (wallRight.sqrMagnitude <= 1e-6f) return false;
- wallRight.Normalize();
-
- float radius = WorldRadius;
- for (int i = 0; i < WallProbeTangentSamples.Length; i++)
- {
- if (WallHitAt(baseOrigin + wallRight * (WallProbeTangentSamples[i] * radius), dir, maxDist))
- return true;
- }
- return false;
- }
-
- /// Single climbable-surface hit test along a ray; skips player/no-climb colliders.
- private bool WallHitAt(Vector3 origin, Vector3 dir, float maxDist)
- {
- int count = Physics.RaycastNonAlloc(
- origin, dir, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
- WarnIfProbeSaturated(count);
- for (int i = 0; i < count; i++)
- {
- if (IsPlayerCollider(_wallProbe[i].collider) || IsNoClimb(_wallProbe[i].collider))
- continue;
- return true;
- }
- return false;
+ return NearestClimbHit(origin, -_wallNormal, maxDist, 0f, ceilingOnly: false, out _, out _);
}
///
- /// Short downward ray from the capsule center: true when walkable ground (angle within the
- /// controller's slope limit) sits within step range below the feet. Used to top out of a
- /// climb back onto solid ground without trusting isGrounded, which the standoff correction
- /// can falsify by lifting the capsule slightly off the surface.
+ /// 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()
{
- Vector3 origin = WorldCapsuleCenter;
+ float radius = WorldRadius;
float maxDist = (_controller.center.y + _controller.stepOffset) * CapsuleScale;
- int count = Physics.RaycastNonAlloc(
- origin, Vector3.down, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
+ int count = Physics.SphereCastNonAlloc(
+ WorldCapsuleCenter + Vector3.up * radius, radius, Vector3.down,
+ _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
WarnIfProbeSaturated(count);
float best = float.MaxValue;
@@ -1287,6 +1088,8 @@ public class PlayerCharacterController : MonoBehaviour
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)
@@ -1301,9 +1104,8 @@ public class PlayerCharacterController : MonoBehaviour
///
/// Detaches at the top of a climbed wall/slope with an upward hop, so holding W carries the
- /// player over the lip onto the ledge 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 (), so the hop is never cut short.
+ /// 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()
{
@@ -1321,8 +1123,7 @@ public class PlayerCharacterController : MonoBehaviour
_lostContactTimer = 0f;
_verticalVelocity = 0f;
_climbVelocity = Vector3.zero;
- // Hand the current lean off to the residual so it eases home (EaseMeshOffsetToRest)
- // instead of snapping; latch the normal since the live one may shift after detaching.
+ // Hand the current lean off to the residual so it eases home instead of snapping.
_meshOffsetResidual = _wallOffset;
_meshOffsetExitNormal = _wallNormal;
_wallOffset = 0f;
@@ -1338,8 +1139,8 @@ public class PlayerCharacterController : MonoBehaviour
}
///
- /// Collects contact normals during Move: wall/ceiling contacts feed the
- /// wall-climb detector, the flattest upward contact becomes the ground normal.
+ /// 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)
{