Add step pass to wall climb for small ledges on walls and ceilings

Ground-stepOffset analogue for climbing (wallStepOffset, default 0.1):
when climb movement is blocked by a small ledge, the capsule lifts
along the wall normal in pre-swept sub-steps and continues, with a
short servo hold to prevent being pulled back into the ledge. Ground
detach while climbing down now requires floor that extends away from
the wall, so narrow step tops no longer cause a mid-wall exit.

Wall->ceiling handoff additionally checks OverheadIsPassableStep():
if the overhead surface is only a small protrusion within the step
allowance, the handoff is skipped and the step pass carries the
capsule over the edge. Genuine overhangs hand off unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Fable 5 committed 2026-07-09 15:45:31 +02:00
1 parent bde3e71873
commit ae36b2076d
1 file changed
+155 -10
@@ -8,7 +8,8 @@ using UnityEngine.InputSystem;
/// sliding off too-steep slopes, and a wall/ceiling-climb mode. Wall-climb is entered by pushing
/// the move input into a wall: W/S climb up/down, A/D strafe, the body faces the wall; left-drag
/// leans the visual mesh, right-drag turns the body. Jumping detaches, the top edge mantles over,
/// climbing into an overhang hands off to the ceiling (upright, camera-relative crawl).
/// climbing into an overhang hands off to the ceiling (upright, camera-relative crawl), and small
/// steps on the climbed surface are passed over (wallStepOffset, the climb's ground-step analogue).
/// Named PlayerCharacterController to avoid a clash with Unity.VisualScripting's "PlayerController".
/// </summary>
[RequireComponent(typeof(CharacterController))]
@@ -124,6 +125,10 @@ public class PlayerCharacterController : MonoBehaviour
[Tooltip("How long (seconds) the top-edge signature (probe clear, wall still at the feet, clear headroom above, standable top ahead) must persist before committing to a top-out mantle. Filters transient probe misses so they never trigger a mantle mid-face. Keep small; a couple of frames is enough.")]
public float wallTopOutConfirm = 0.06f;
[Tooltip("Largest ledge/step on the climbed surface (units, measured along the surface normal) that climbing passes over — the wall/ceiling counterpart of the CharacterController's ground Step Offset. When along-surface movement is blocked by a small trim/board/beam, the capsule lifts off the surface just enough to clear it and continues; 0 disables.")]
[Min(0f)]
public float wallStepOffset = 0.1f;
[Tooltip("Head-to-ceiling distance (units) at which climbing a wall up into an overhang hands off to the ceiling.")]
public float ceilingGrabDistance = 0.02f;
@@ -234,6 +239,8 @@ public class PlayerCharacterController : MonoBehaviour
private bool _wallContactThisFrame;
private float _lostContactTimer;
private float _rimConfirmTimer; // debounces the bare-rim vault so a transient mid-face edge graze can't top out
private const float StepPassServoHold = 0.25f; // seconds the standoff servo skips pulling in after a step-pass lift
private float _stepPassTimer; // holds a step-pass lift so the servo doesn't pull straight back into the ledge
private float _wallOffset; // signed mesh lean (mouse drag); moves only the model, not the capsule
private float _wallYaw; // body yaw offset from facing the wall (mouse drag)
private int _wallStrafeSign; // latched strafe direction; 0 = unlatched
@@ -520,6 +527,7 @@ public class PlayerCharacterController : MonoBehaviour
_onCeiling = _wallNormal.y < CeilingNormalThreshold;
_lostContactTimer = 0f;
_rimConfirmTimer = 0f;
_stepPassTimer = 0f;
_verticalVelocity = 0f;
_slideSpeed = 0f;
_wallOffset = 0f;
@@ -549,6 +557,9 @@ public class PlayerCharacterController : MonoBehaviour
return;
}
if (_stepPassTimer > 0f)
_stepPassTimer -= dt;
UpdateCeilingClass();
bool onCeiling = _onCeiling;
@@ -577,11 +588,15 @@ public class PlayerCharacterController : MonoBehaviour
// Wall->ceiling hand-off: while climbing up into an overhang, switch to the ceiling right
// above the head instead of trying to mantle over a (non-existent) top. Gated to clearly
// down-facing surfaces: a grazed front edge (the next shelf board above) returns a tilted
// edge normal and must park the climb instead of handing off.
// edge normal and must park the climb instead of handing off. A small protruding ledge
// within the step allowance must NOT hand off either: skipping it lets the upward move
// block against its underside, and the step pass then carries the climb around its edge
// (the hand-off runs before ClimbMove, so without this gate it always wins).
if (!onCeiling && vEff > 0f
&& ProbeCeiling(out Vector3 ceilNormal, out float ceilHeadGap)
&& ceilHeadGap <= ceilingGrabDistance
&& ceilNormal.y < CeilingEnterThreshold)
&& ceilNormal.y < CeilingEnterThreshold
&& !OverheadIsPassableStep())
{
_wallNormal = ceilNormal;
_onCeiling = true;
@@ -816,30 +831,42 @@ public class PlayerCharacterController : MonoBehaviour
if (found)
{
// Servo the capsule to one skin-width off the surface. Exponential follow keeps it
// framerate-stable; the clamp guards spikes.
// framerate-stable; the clamp guards spikes. While a step-pass lift is held, pulling
// back in is skipped — it would re-block against the ledge every frame.
float maxPerpSpeed = 8f * CapsuleScale;
float gapError = _controller.skinWidth * CapsuleScale - gap;
float follow = 1f - Mathf.Exp(-wallOffsetFollow * dt);
velocity += _wallNormal * Mathf.Clamp(gapError * follow / dt, -maxPerpSpeed, maxPerpSpeed);
float correction = Mathf.Clamp(gapError * follow / dt, -maxPerpSpeed, maxPerpSpeed);
if (_stepPassTimer > 0f)
correction = Mathf.Max(correction, 0f);
velocity += _wallNormal * correction;
}
_controller.Move(velocity * dt);
ClimbMove(velocity, alongSurface, dt);
// Reaching walkable ground detaches. Probed under the feet rather than trusting isGrounded
// (the standoff servo can hold the capsule just off the surface). The vEff < 0 release is
// the only detach that doesn't require having left the ground first, so a wall grabbed at
// its base can be released by pressing away.
// its base can be released by pressing away. A step being passed (wallStepOffset) also
// reads grounded/walkable off its narrow top; genuine ground is told apart by extending
// outward from the climbed face — and the upward hop additionally by the wall no longer
// rising overhead — so a mid-wall step never detaches the climb.
bool onWalkableGround = _controller.isGrounded || WalkableGroundBelow();
bool genuineGround = onWalkableGround
&& (onCeiling || !_climbLeftGround || GroundExtendsOutward());
if (!onWalkableGround)
{
_climbLeftGround = true;
}
else if (_climbLeftGround && vEff > 0f)
{
TopOutDetach();
return;
if (genuineGround || !WallContinuesAbove())
{
TopOutDetach();
return;
}
}
else if (_climbLeftGround || vEff < 0f)
else if ((_climbLeftGround || vEff < 0f) && genuineGround)
{
ExitWallClimb();
return;
@@ -1203,6 +1230,105 @@ public class PlayerCharacterController : MonoBehaviour
}
}
/// <summary>
/// Moves the climbing capsule, passing over small steps on the climbed surface — the
/// wall/ceiling counterpart of the ground Step Offset. When the along-surface travel comes up
/// blocked (a trim/offset on the wall, a beam on the ceiling), the capsule lifts off the
/// surface by the smallest sub-step of <see cref="wallStepOffset"/> whose lifted path is clear
/// and retries the remainder; the standoff servo holds the lift briefly and re-seats past the
/// ledge. Clearance is verified before committing so a genuine obstacle (tall ledge, inner
/// corner) never starts a lift it can't finish.
/// </summary>
private void ClimbMove(Vector3 velocity, Vector3 alongSurface, float dt)
{
Vector3 before = transform.position;
_controller.Move(velocity * dt);
if (wallStepOffset <= 0f || alongSurface.sqrMagnitude < 1e-6f)
return;
Vector3 travel = alongSurface.normalized;
float intended = Vector3.Dot(velocity, travel) * dt;
if (intended < 1e-4f * CapsuleScale)
return;
float actual = Vector3.Dot(transform.position - before, travel);
if (actual >= intended * 0.5f)
return;
float remaining = intended - actual;
float maxLift = wallStepOffset * CapsuleScale;
for (int i = 1; i <= 4; i++)
{
float lift = maxLift * (i * 0.25f);
if (!CapsuleSweepClear(Vector3.zero, _wallNormal, lift)
|| !CapsuleSweepClear(_wallNormal * lift, travel,
remaining + _controller.skinWidth * CapsuleScale))
continue;
Vector3 mid = transform.position;
_controller.Move(_wallNormal * lift);
_controller.Move(travel * remaining);
// Hold the lift only when the retry actually got past the ledge; a mispredicted
// (still blocked) retry lets the servo re-seat instead of drifting further out.
if (Vector3.Dot(transform.position - mid, travel) > remaining * 0.5f)
_stepPassTimer = StepPassServoHold;
return;
}
}
/// <summary>
/// True when the capsule, shifted by <paramref name="offset"/>, can sweep <paramref name="dist"/>
/// along <paramref name="dir"/> without hitting anything. Avatars never block a step-pass;
/// no-climb colliders still block physically, so they count.
/// </summary>
private bool CapsuleSweepClear(Vector3 offset, Vector3 dir, float dist)
{
if (dist <= 0f) return true;
float radius = WorldRadius;
Vector3 center = WorldCapsuleCenter + offset;
Vector3 axis = Vector3.up * Mathf.Max(0f, WorldHalfHeight - radius);
int count = Physics.CapsuleCastNonAlloc(
center - axis, center + axis, radius, dir, _wallProbe, dist, ~0, QueryTriggerInteraction.Ignore);
WarnIfProbeSaturated(count);
for (int i = 0; i < count; i++)
{
if (_wallProbe[i].distance <= 0f)
continue;
if (IsPlayerCollider(_wallProbe[i].collider))
continue;
return false;
}
return true;
}
/// <summary>
/// True when the down-facing surface overhead is merely a small ledge the step pass can carry
/// the climb around: some outward lift within <see cref="wallStepOffset"/> (same sub-steps as
/// <see cref="ClimbMove"/>) gives the capsule a clear path upward past it. Gates the
/// wall->ceiling hand-off — without it, climbing up into a small protruding ledge latches
/// ceiling mode on its underside instead of stepping over its edge. A genuine overhang/ceiling
/// extends outward beyond the step allowance, fails every lift, and still hands off.
/// </summary>
private bool OverheadIsPassableStep()
{
if (wallStepOffset <= 0f) return false;
Vector3 outDir = _wallNormal;
outDir.y = 0f;
if (outDir.sqrMagnitude < 1e-6f) return false;
outDir.Normalize();
float maxLift = wallStepOffset * CapsuleScale;
float rise = WorldRadius;
for (int i = 1; i <= 4; i++)
{
float lift = maxLift * (i * 0.25f);
if (CapsuleSweepClear(Vector3.zero, outDir, lift)
&& CapsuleSweepClear(outDir * lift, Vector3.up, rise))
return true;
}
return false;
}
/// <summary>
/// Volumetric wall probe toward -_wallNormal from a vertical offset off the capsule center
/// (negative = below/feet, positive = above/headroom), reporting the hit normal and the gap
@@ -1286,6 +1412,24 @@ public class PlayerCharacterController : MonoBehaviour
(_controller.center.y + _controller.stepOffset) * CapsuleScale);
}
/// <summary>
/// True when the walkable surface below also extends outward from the climbed face, sampled a
/// capsule radius plus the step allowance away from the wall. A genuine floor/terrace does; the
/// narrow top of a step/trim being passed (<see cref="wallStepOffset"/>) does not.
/// </summary>
private bool GroundExtendsOutward()
{
Vector3 outDir = _wallNormal;
outDir.y = 0f;
if (outDir.sqrMagnitude < 1e-6f)
return true; // no horizontal facing: keep the plain ground test
outDir.Normalize();
float outDist = WorldRadius + wallStepOffset * CapsuleScale;
return WalkableBelow(
WorldCapsuleCenter + outDir * outDist + Vector3.up * WorldRadius,
(_controller.center.y + _controller.stepOffset) * CapsuleScale);
}
/// <summary>
/// True when a walkable surface lies where the mantle's forward phase would land (just past the
/// climbed edge). Gates both top-out flavors: a thick wall or a flattening slope has a standable
@@ -1375,6 +1519,7 @@ public class PlayerCharacterController : MonoBehaviour
_onCeiling = false;
_lostContactTimer = 0f;
_rimConfirmTimer = 0f;
_stepPassTimer = 0f;
_verticalVelocity = 0f;
_climbVelocity = Vector3.zero;
// Hand the current lean off to the residual so it eases home instead of snapping.