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:
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,
|
||||
|
||||
Reference in new issue
Block a user