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