diff --git a/Assets/Scripts/Components/Controller/PlayerCharacterController.cs b/Assets/Scripts/Components/Controller/PlayerCharacterController.cs
index dcdf0f6..a65ebae 100644
--- a/Assets/Scripts/Components/Controller/PlayerCharacterController.cs
+++ b/Assets/Scripts/Components/Controller/PlayerCharacterController.cs
@@ -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".
///
[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
}
}
+ ///
+ /// 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 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.
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// True when the capsule, shifted by , can sweep
+ /// along without hitting anything. Avatars never block a step-pass;
+ /// no-climb colliders still block physically, so they count.
+ ///
+ 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;
+ }
+
+ ///
+ /// True when the down-facing surface overhead is merely a small ledge the step pass can carry
+ /// the climb around: some outward lift within (same sub-steps as
+ /// ) 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.
+ ///
+ 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;
+ }
+
///
/// 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);
}
+ ///
+ /// 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 () does not.
+ ///
+ 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);
+ }
+
///
/// 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.