From 70e5323125be69aab56bcb2cd5318666b014dbd0 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Mon, 22 Jun 2026 21:13:30 +0200 Subject: [PATCH] Fix wall-climb top-out on slopes with an upward hop Detach back to grounded movement when the climbed face flattens into walkable ground or walkable ground sits below the feet, testing an explicit downward probe instead of isGrounded (which the standoff correction can falsify by lifting the capsule off the surface). Top-out now applies an upward hop so holding W carries the player over the lip instead of dropping back onto the steep face and re-grabbing, and a shared re-grab cooldown spans the hop to prevent detach/re-grab buzzing. Co-Authored-By: Claude Opus 4.8 --- Assets/Scripts/PlayerCharacterController.cs | 76 +++++++++++++++++++-- 1 file changed, 69 insertions(+), 7 deletions(-) diff --git a/Assets/Scripts/PlayerCharacterController.cs b/Assets/Scripts/PlayerCharacterController.cs index cc090a8..8ded7ce 100644 --- a/Assets/Scripts/PlayerCharacterController.cs +++ b/Assets/Scripts/PlayerCharacterController.cs @@ -106,6 +106,9 @@ public class PlayerCharacterController : MonoBehaviour [Tooltip("Head-to-ceiling distance (units) at which climbing a wall up into an overhang hands off to the ceiling.")] public float ceilingGrabDistance = 0.3f; + [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 = 1.2f; + [Tooltip("Camera orbit input; paused while dragging the wall-climb offset. Auto-found if empty.")] [SerializeField] private ThirdPersonOrbitInput cameraOrbit; @@ -142,6 +145,7 @@ public class PlayerCharacterController : MonoBehaviour private readonly RaycastHit[] _wallProbe = new RaycastHit[8]; // 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 bool _mantling; private bool _mantleClearedFeet; // phase 1 done: capsule fully above the edge private float _mantleOverDist; // phase 2 forward distance over the top @@ -378,6 +382,16 @@ public class PlayerCharacterController : MonoBehaviour onCeiling = _wallNormal.y < CeilingNormalThreshold; } + // 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. + if (found && !onCeiling && _climbLeftGround + && Vector3.Angle(_wallNormal, Vector3.up) < wallMinAngle) + { + TopOutDetach(); // climbed face flattened into walkable: hop over the lip instead of dropping onto the steep section below + return; + } + Vector3 alongSurface; if (onCeiling) { @@ -458,14 +472,17 @@ public class PlayerCharacterController : MonoBehaviour _controller.Move(velocity * dt); - // Reaching the ground detaches and drops back to normal grounded movement. Gated on - // having left the ground first, so grabbing a wall while standing at its base (still - // grounded) doesn't instantly detach before any climbing has happened. - if (!_controller.isGrounded) + // Reaching walkable ground detaches and drops back to normal grounded movement. Gated + // on having left the ground first, so grabbing a wall at its base (still grounded) + // doesn't instantly detach. We test for walkable ground 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 and the old check never fired. + bool onWalkableGround = _controller.isGrounded || WalkableGroundBelow(); + if (!onWalkableGround) _climbLeftGround = true; else if (_climbLeftGround) { - ExitWallClimb(); + TopOutDetach(); // reached walkable ground at the top: hop up onto it instead of sliding back down the face return; } @@ -613,7 +630,7 @@ public class PlayerCharacterController : MonoBehaviour _mantleOverDist += speed * dt; if (_mantleOverDist >= _controller.radius * 2f + 0.15f || _controller.isGrounded) { - _reentryCooldown = 0.3f; + _reentryCooldown = TopOutRegrabCooldown; ExitWallClimb(); return; } @@ -622,7 +639,7 @@ public class PlayerCharacterController : MonoBehaviour // Safety: never mantle indefinitely (e.g. blocked by other geometry). if (_mantleTimer >= 2f) { - _reentryCooldown = 0.3f; + _reentryCooldown = TopOutRegrabCooldown; ExitWallClimb(); } } @@ -647,6 +664,51 @@ public class PlayerCharacterController : MonoBehaviour return false; } + /// + /// 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. + /// + private bool WalkableGroundBelow() + { + Vector3 origin = transform.position + _controller.center; + float maxDist = _controller.center.y + _controller.stepOffset; + + int count = Physics.RaycastNonAlloc( + origin, Vector3.down, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore); + + float best = float.MaxValue; + Vector3 normal = Vector3.up; + bool found = false; + for (int i = 0; i < count; i++) + { + if (_wallProbe[i].collider.transform.IsChildOf(transform)) + 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 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. + /// + 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() {