Add ledge grab: walking over an edge attaches to the wall below (not while sprinting)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Fable 5 committed 2026-07-10 19:28:22 +02:00
1 parent ec7ab45ff1
commit b30db72e0c
1 file changed
+96
@@ -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
}
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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,