diff --git a/Assets/Scripts/Components/Controller/PlayerCharacterController.cs b/Assets/Scripts/Components/Controller/PlayerCharacterController.cs
index a65ebae..a43e198 100644
--- a/Assets/Scripts/Components/Controller/PlayerCharacterController.cs
+++ b/Assets/Scripts/Components/Controller/PlayerCharacterController.cs
@@ -444,11 +444,26 @@ public class PlayerCharacterController : MonoBehaviour
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);
@@ -541,6 +556,87 @@ public class PlayerCharacterController : MonoBehaviour
}
}
+ ///
+ /// 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,