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()
{