From b30db72e0cdd81bfb7981f3146605b3e35898c11 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Fri, 10 Jul 2026 19:28:22 +0200 Subject: [PATCH] Add ledge grab: walking over an edge attaches to the wall below (not while sprinting) Co-Authored-By: Claude Fable 5 --- .../Controller/PlayerCharacterController.cs | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) 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,