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.