Fix wall climb on thin boards: rim detection, vault, edge-normal hardening

The swept-sphere probes tilt their contact normal while rolling over a
thin board's edge, which faked slope top-outs, ceiling hand-offs and
bare-rim vaults mid-face. Confirmed via temporary exit-path logging
(stripped again) and fixed at the verified causes:

- RaycastWallFace: confirm "face flattened into a slope" with a thin,
  strictly horizontal ray instead of trusting the tilted sphere normal
  (a vertical board still reads steep; real ramps still top out).
- Rim judging: the full-capsule span probe is the arbiter — while it
  still sees a steep face anywhere along the capsule, re-adopt that true
  normal (breaking the tilt self-amplification feedback loop) and keep
  climbing; only a persistent rim (wallTopOutConfirm debounce) vaults.
- WallContinuesAbove: band-sampled horizontal sweep so a grazed shelf
  edge above never reads as a cleared rim.
- Bare rim: parks by default (upward motion clamped); holding up vaults
  over the edge via MantleOverEdge, headroom-gated.
- ProbeWallSpan sweeps strictly horizontally; center probe always
  rejects down-facing hits (edge grazes), wall->ceiling hand-off only
  via the explicit head-gap check against clearly down-facing surfaces.
- Slope/ground top-outs additionally gated on a genuine stand
  (HasStandableTop) so a board's own top edge no longer detaches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Fable 5 committed 2026-07-08 12:51:34 +02:00
1 parent 755fffa997
commit 78ab6e2dd6
1 file changed
+288 -34
@@ -121,7 +121,7 @@ public class PlayerCharacterController : MonoBehaviour
[Tooltip("Grace period (seconds) of lost contact tolerated before leaving wall-climb mode.")] [Tooltip("Grace period (seconds) of lost contact tolerated before leaving wall-climb mode.")]
public float wallExitGrace = 0.15f; public float wallExitGrace = 0.15f;
[Tooltip("How long (seconds) the top-edge signature (probe clear, wall still at the feet, clear headroom above) 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.")] [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; public float wallTopOutConfirm = 0.06f;
[Tooltip("Head-to-ceiling distance (units) at which climbing a wall up into an overhang hands off to the ceiling.")] [Tooltip("Head-to-ceiling distance (units) at which climbing a wall up into an overhang hands off to the ceiling.")]
@@ -233,6 +233,7 @@ public class PlayerCharacterController : MonoBehaviour
private Vector3 _wallNormalAccum; // summed wall normals during the latest Move private Vector3 _wallNormalAccum; // summed wall normals during the latest Move
private bool _wallContactThisFrame; private bool _wallContactThisFrame;
private float _lostContactTimer; private float _lostContactTimer;
private float _rimConfirmTimer; // debounces the bare-rim vault so a transient mid-face edge graze can't top out
private float _wallOffset; // signed mesh lean (mouse drag); moves only the model, not the capsule 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 float _wallYaw; // body yaw offset from facing the wall (mouse drag)
private int _wallStrafeSign; // latched strafe direction; 0 = unlatched private int _wallStrafeSign; // latched strafe direction; 0 = unlatched
@@ -518,6 +519,7 @@ public class PlayerCharacterController : MonoBehaviour
_wallNormal = _wallNormalAccum.normalized; _wallNormal = _wallNormalAccum.normalized;
_onCeiling = _wallNormal.y < CeilingNormalThreshold; _onCeiling = _wallNormal.y < CeilingNormalThreshold;
_lostContactTimer = 0f; _lostContactTimer = 0f;
_rimConfirmTimer = 0f;
_verticalVelocity = 0f; _verticalVelocity = 0f;
_slideSpeed = 0f; _slideSpeed = 0f;
_wallOffset = 0f; _wallOffset = 0f;
@@ -573,10 +575,13 @@ public class PlayerCharacterController : MonoBehaviour
} }
// Wall->ceiling hand-off: while climbing up into an overhang, switch to the ceiling right // 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. // 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.
if (!onCeiling && vEff > 0f if (!onCeiling && vEff > 0f
&& ProbeCeiling(out Vector3 ceilNormal, out float ceilHeadGap) && ProbeCeiling(out Vector3 ceilNormal, out float ceilHeadGap)
&& ceilHeadGap <= ceilingGrabDistance) && ceilHeadGap <= ceilingGrabDistance
&& ceilNormal.y < CeilingEnterThreshold)
{ {
_wallNormal = ceilNormal; _wallNormal = ceilNormal;
_onCeiling = true; _onCeiling = true;
@@ -613,7 +618,16 @@ public class PlayerCharacterController : MonoBehaviour
else else
{ {
found = ProbeWall(out Vector3 probedNormal, out gap); found = ProbeWall(out Vector3 probedNormal, out gap);
if (found) // A down-facing hit here is never the climbed wall: it is an edge graze (the wall's
// bottom edge, or the underside of the next shelf board clipped while sweeping past
// it). Adopting it would latch ceiling mode mid-wall — the wall->ceiling hand-off
// happens ONLY via the explicit head-gap check above — so treat it as lost contact
// and let the span probe / grace decide.
if (found && probedNormal.y < CeilingNormalThreshold)
{
found = false;
}
else if (found)
{ {
_wallNormal = probedNormal; _wallNormal = probedNormal;
_lostContactTimer = 0f; _lostContactTimer = 0f;
@@ -622,14 +636,64 @@ public class PlayerCharacterController : MonoBehaviour
} }
} }
// Topped out onto a slope: the climbed face has flattened into something walkable. Hand back // Topped out onto a slope: the climbed face has flattened into something walkable. The swept
// to grounded movement; hop only when actually moving up onto it (a real top-out). // sphere ALSO tilts its normal upward as it rolls over a thin board's top edge, faking this —
// so the flattening is confirmed against the TRUE face normal from a thin ray (a real slope
// reads shallow there too; a vertical board still reads steep). Even then a genuine stand must
// exist; otherwise it is a bare rim (thin board / knife edge). At a bare rim, holding UP vaults
// the body over the edge (rise until the feet clear, then carry forward) — landing on top if
// there's room, else cresting and dropping the far side; deliberate, because W is held. Without
// up-input the climb parks: upward motion is clamped below, strafe/down/jump stay live.
bool bareTopRim = false;
if (found && !onCeiling && _climbLeftGround if (found && !onCeiling && _climbLeftGround
&& Vector3.Angle(_wallNormal, Vector3.up) < wallMinAngle) && Vector3.Angle(_wallNormal, Vector3.up) < wallMinAngle)
{ {
if (vEff > 0f) TopOutDetach(); bool faceIsSlope = RaycastWallFace(out Vector3 faceNormal)
else ExitWallClimb(); && Vector3.Angle(faceNormal, Vector3.up) < wallMinAngle;
return; if (faceIsSlope && (_controller.isGrounded || WalkableGroundBelow() || HasStandableTop()))
{
if (vEff > 0f) TopOutDetach();
else ExitWallClimb();
return;
}
// The swept sphere's normal tilts up while the center crosses the board's top edge and
// STAYS tilted for the whole crossing — and it self-amplifies, because the next probe
// casts along the already-tilted normal, diving deeper into the edge. A debounce alone
// cannot tell that from a genuine rim, and "wall above the head" can't either (a thin
// board has nothing above in both cases). The full-capsule span CAN: while the board
// still overlaps the capsule lower down it returns the true steep face — re-adopt that
// normal (breaking the tilt feedback) and keep climbing. Only when neither the head band
// nor the span see anything steep is the rim genuine; then it must still persist briefly.
bool wallAbove = WallContinuesAbove();
bool steepOnSpan = ProbeWallSpan(out Vector3 rimSpanNormal, out _)
&& Vector3.Angle(rimSpanNormal, Vector3.up) >= wallMinAngle;
if (steepOnSpan)
_wallNormal = rimSpanNormal;
if (wallAbove || steepOnSpan)
_rimConfirmTimer = 0f;
else
_rimConfirmTimer += dt;
if (_rimConfirmTimer >= wallTopOutConfirm)
{
// Bare rim confirmed, pushing up: vault over. Headroom-gated so a shelf compartment
// above still parks (its rise would only stall against the ceiling and time out).
if (vEff > 0f && HasMantleHeadroom())
{
_mantling = true;
_mantleClearedFeet = false;
_mantleOverDist = 0f;
_mantleTimer = 0f;
MantleOverEdge(dt);
return;
}
bareTopRim = true;
}
}
else
{
_rimConfirmTimer = 0f;
} }
Vector3 alongSurface; Vector3 alongSurface;
@@ -659,16 +723,25 @@ public class PlayerCharacterController : MonoBehaviour
alongSurface = wallUp * vEff + wallRight * h; alongSurface = wallUp * vEff + wallRight * h;
} }
if (alongSurface.sqrMagnitude > 1f) alongSurface.Normalize(); if (alongSurface.sqrMagnitude > 1f) alongSurface.Normalize();
// Parked at a bare top rim: nothing may push the capsule higher over an edge that has no
// surface to stand on (cresting it would only end in a drop).
if (bareTopRim && alongSurface.y > 0f)
alongSurface = Vector3.ProjectOnPlane(alongSurface, Vector3.up);
bool hasMoveInput = (h * h + v * v) > 1e-4f; bool hasMoveInput = (h * h + v * v) > 1e-4f;
if (!found) if (!found)
{ {
_lostContactTimer += dt; _lostContactTimer += dt;
// Wall top-out: probe lost the wall while climbing up, wall still at the feet, clear // Classify the miss: a top edge has wall at the feet but none above the center.
// headroom above the center. Confirmed briefly before committing so a transient miss bool wallBelow = !onCeiling
// (which re-acquires within a frame or two) never mantles mid-face. && ProbeWallAt(-WorldHalfHeight, out Vector3 lowNormal, out _)
if (!onCeiling && alongSurface.y > 0f && AtWallTopEdge()) && lowNormal.y > CeilingNormalThreshold;
bool atTopEdge = wallBelow && !WallPresentAt(WorldHalfHeight * 0.5f);
// Wall top-out: cleared above a genuine ledge (somewhere to stand, room to rise).
// Confirmed briefly before committing so a transient miss never mantles mid-face.
if (atTopEdge && alongSurface.y > 0f && HasStandableTop() && HasMantleHeadroom())
{ {
if (_lostContactTimer >= wallTopOutConfirm) if (_lostContactTimer >= wallTopOutConfirm)
{ {
@@ -680,6 +753,22 @@ public class PlayerCharacterController : MonoBehaviour
return; return;
} }
} }
// The center probe only covers its own band of the capsule's height; a thin board
// (shelf edge) above or below that band still physically holds the capsule, so a
// full-capsule sweep keeps the climb attached anywhere along the capsule. At a top
// edge with nothing to stand on (or no room to mantle), upward motion is clamped so
// the climb parks at the rim instead of cresting into a drop.
else if (!onCeiling && ProbeWallSpan(out Vector3 spanNormal, out float spanGap))
{
found = true;
gap = spanGap;
if (Vector3.Angle(spanNormal, Vector3.up) >= wallMinAngle
&& spanNormal.y > CeilingNormalThreshold)
_wallNormal = spanNormal;
_lostContactTimer = 0f;
if (atTopEdge && alongSurface.y > 0f)
alongSurface = Vector3.ProjectOnPlane(alongSurface, Vector3.up);
}
// Surface genuinely gone (incl. crawling off a ceiling edge): leave after grace. // Surface genuinely gone (incl. crawling off a ceiling edge): leave after grace.
else if (_lostContactTimer >= wallExitGrace) else if (_lostContactTimer >= wallExitGrace)
{ {
@@ -894,6 +983,8 @@ public class PlayerCharacterController : MonoBehaviour
private float WorldRadius => _controller.radius * CapsuleScale; private float WorldRadius => _controller.radius * CapsuleScale;
private float WorldHalfHeight => _controller.height * 0.5f * CapsuleScale; private float WorldHalfHeight => _controller.height * 0.5f * CapsuleScale;
private const float ProbeReachSlack = 0.5f; // extra cast length beyond the capsule surface (scale-1 units) private const float ProbeReachSlack = 0.5f; // extra cast length beyond the capsule surface (scale-1 units)
// Forward travel of the mantle's over-the-edge phase; also where HasStandableTop samples.
private float MantleForwardDistance => WorldRadius * 2f + 0.15f * CapsuleScale;
/// <summary> /// <summary>
/// True if the collider belongs to ANY player avatar (self or a networked remote): avatars still /// True if the collider belongs to ANY player avatar (self or a networked remote): avatars still
@@ -924,6 +1015,74 @@ public class PlayerCharacterController : MonoBehaviour
return false; return false;
} }
/// <summary>
/// Reads the TRUE face normal straight ahead of the capsule center with a thin, strictly
/// HORIZONTAL ray. The swept climb probe returns smoothly tilted normals near an edge — a thin
/// board's vertical face then reads as a "flattened slope" — so the slope top-out is confirmed
/// against this ray. Casting along the (already tilted) wall normal would dive into the board's
/// horizontal TOP face and read flat, faking the very slope it must reject; flattening the
/// direction to the horizontal plane samples the real vertical face instead. A real ramp still
/// reads shallow here, a vertical board reads steep, and clearing the edge makes the ray miss.
/// False = nothing straight ahead. Player avatars and no-climb surfaces are ignored.
/// </summary>
private bool RaycastWallFace(out Vector3 faceNormal)
{
faceNormal = _wallNormal;
Vector3 dir = -_wallNormal;
dir.y = 0f;
if (dir.sqrMagnitude < 1e-6f) return false;
dir.Normalize();
float maxDist = WorldRadius + wallMaxOffset + ProbeReachSlack * CapsuleScale;
if (Physics.Raycast(WorldCapsuleCenter, dir, out RaycastHit hit, maxDist, ~0, QueryTriggerInteraction.Ignore)
&& !IsPlayerCollider(hit.collider) && !IsNoClimb(hit.collider))
{
faceNormal = hit.normal;
return true;
}
return false;
}
/// <summary>
/// True while the climbed face still rises above the head: a strictly HORIZONTAL forward sphere
/// (flattened -_wallNormal) from the capsule's upper band hits a wall-like surface. The swept
/// climb probe tilts its normal upward whenever the sphere grazes the board's TOP edge from
/// below, faking a rim mid-face; this separates a genuine cleared rim (nothing ahead above) from
/// that jitter (board still there), so the climb keeps rising instead of vaulting/parking early.
/// </summary>
private bool WallContinuesAbove()
{
Vector3 dir = -_wallNormal;
dir.y = 0f;
if (dir.sqrMagnitude < 1e-6f) return false;
dir.Normalize();
float radius = WorldRadius;
float half = WorldHalfHeight;
float maxDist = radius + wallMaxOffset + ProbeReachSlack * CapsuleScale;
// Sample a band from just above center up to the capsule top. A board thinner than the probe
// sphere gives a noisy single-height result, so any wall-like hit ANYWHERE across the band
// means the face still rises above the head — keep climbing rather than vault/park early.
for (float f = 0.25f; f <= 1.01f; f += 0.25f)
{
Vector3 origin = WorldCapsuleCenter + Vector3.up * (half * f);
int count = Physics.SphereCastNonAlloc(
origin - dir * radius, radius, dir, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
WarnIfProbeSaturated(count);
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].normal.y > CeilingNormalThreshold)
return true;
}
}
return false;
}
/// <summary> /// <summary>
/// Capsule support distance from the center along a unit direction: the radius sideways, /// Capsule support distance from the center along a unit direction: the radius sideways,
/// the half-height straight up/down, interpolated by the vertical component in between. /// the half-height straight up/down, interpolated by the vertical component in between.
@@ -1028,7 +1187,7 @@ public class PlayerCharacterController : MonoBehaviour
{ {
_controller.Move(forward * (speed * dt)); _controller.Move(forward * (speed * dt));
_mantleOverDist += speed * dt; _mantleOverDist += speed * dt;
if (_mantleOverDist >= WorldRadius * 2f + 0.15f * CapsuleScale || _controller.isGrounded) if (_mantleOverDist >= MantleForwardDistance || _controller.isGrounded)
{ {
_reentryCooldown = TopOutRegrabCooldown; _reentryCooldown = TopOutRegrabCooldown;
ExitWallClimb(); ExitWallClimb();
@@ -1045,26 +1204,73 @@ public class PlayerCharacterController : MonoBehaviour
} }
/// <summary> /// <summary>
/// The top-edge test that gates the mantle: a genuine top edge still has wall at the feet but is /// Volumetric wall probe toward -_wallNormal from a vertical offset off the capsule center
/// clear just above the center (that the center probe itself is clear is already known: it failed). /// (negative = below/feet, positive = above/headroom), reporting the hit normal and the gap
/// The headroom check is the discriminator against a mid-face gap — there the wall continues /// beyond the capsule surface like <see cref="ProbeWall"/> does from the center.
/// above whatever the probe slipped through, so no mantle fires.
/// </summary> /// </summary>
private bool AtWallTopEdge() private bool ProbeWallAt(float upOffset, out Vector3 normal, out float gap)
{
return WallPresentAt(-WorldHalfHeight)
&& !WallPresentAt(WorldHalfHeight * 0.5f);
}
/// <summary>
/// Volumetric wall-presence test toward -_wallNormal at a vertical offset from the capsule
/// center (negative = below/feet, positive = above/headroom).
/// </summary>
private bool WallPresentAt(float upOffset)
{ {
Vector3 dir = -_wallNormal;
Vector3 origin = WorldCapsuleCenter + Vector3.up * upOffset; Vector3 origin = WorldCapsuleCenter + Vector3.up * upOffset;
float maxDist = WorldRadius + wallMaxOffset + ProbeReachSlack * CapsuleScale; float maxDist = WorldRadius + wallMaxOffset + ProbeReachSlack * CapsuleScale;
return NearestClimbHit(origin, -_wallNormal, maxDist, 0f, ceilingOnly: false, out _, out _); return NearestClimbHit(origin, dir, maxDist, CapsuleExtent(dir), ceilingOnly: false, out normal, out gap);
}
private bool WallPresentAt(float upOffset) => ProbeWallAt(upOffset, out _, out _);
/// <summary>
/// Sweeps the FULL capsule toward the climbed surface (along -_wallNormal): contact anywhere
/// along the capsule's height counts, so a thin board keeps holding while the center band has
/// already cleared it (or hasn't reached it). A down-tilted edge graze (a capsule cap against
/// a board's bottom edge) still counts as HOLDING contact — the wall is only lost once the
/// capsule no longer overlaps it — but <paramref name="normal"/> reports the nearest wall-like
/// hit (falling back to the current normal) so a graze never steers the climb into ceiling
/// mode. <paramref name="gap"/> is the capsule-surface clearance.
/// </summary>
private bool ProbeWallSpan(out Vector3 normal, out float gap)
{
normal = _wallNormal;
gap = 0f;
// Strictly horizontal sweep: near an edge _wallNormal is already tilted, and sweeping along
// it would dive above/below the face instead of across it (same flattening as RaycastWallFace).
Vector3 dir = -_wallNormal;
dir.y = 0f;
if (dir.sqrMagnitude < 1e-6f) return false;
dir.Normalize();
float radius = WorldRadius;
Vector3 axis = Vector3.up * Mathf.Max(0f, WorldHalfHeight - radius);
// Same one-radius backup as NearestClimbHit: a surface at skin-width standoff must never be
// an (unreported) initial overlap; hit.distance then measures from the capsule axis.
Vector3 back = dir * radius;
float maxDist = radius + wallMaxOffset + ProbeReachSlack * CapsuleScale;
int count = Physics.CapsuleCastNonAlloc(
WorldCapsuleCenter - axis - back, WorldCapsuleCenter + axis - back, radius, dir,
_wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
WarnIfProbeSaturated(count);
float bestWall = float.MaxValue;
float bestAny = float.MaxValue;
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 < bestAny)
bestAny = _wallProbe[i].distance;
if (_wallProbe[i].normal.y > CeilingNormalThreshold && _wallProbe[i].distance < bestWall)
{
bestWall = _wallProbe[i].distance;
normal = _wallProbe[i].normal;
}
}
if (bestAny == float.MaxValue)
return false;
gap = Mathf.Max(0f, (bestWall != float.MaxValue ? bestWall : bestAny) - radius);
return true;
} }
/// <summary> /// <summary>
@@ -1075,12 +1281,59 @@ public class PlayerCharacterController : MonoBehaviour
/// </summary> /// </summary>
private bool WalkableGroundBelow() private bool WalkableGroundBelow()
{ {
float radius = WorldRadius; return WalkableBelow(
float maxDist = (_controller.center.y + _controller.stepOffset) * CapsuleScale; WorldCapsuleCenter + 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
/// top, a thin board's bare rim does not — detaching there would only drop the player.
/// </summary>
private bool HasStandableTop()
{
Vector3 forward = -_wallNormal;
forward.y = 0f;
if (forward.sqrMagnitude < 1e-6f) return false;
forward.Normalize();
Vector3 origin = WorldCapsuleCenter + forward * MantleForwardDistance + Vector3.up * WorldHalfHeight;
float maxDist = WorldHalfHeight * 2f + _controller.stepOffset * CapsuleScale;
return WalkableBelow(origin, maxDist);
}
/// <summary>
/// True when nothing blocks the mantle's rise: phase 1 climbs up to roughly a capsule height
/// before the feet clear the edge. Under a low ceiling (shelf compartment, overhang beyond the
/// ceiling-grab range) the mantle would stall against it and time out into a drop — the climb
/// parks at the rim instead. No-climb colliders still block physically, so they count.
/// </summary>
private bool HasMantleHeadroom()
{
float radius = WorldRadius;
int count = Physics.SphereCastNonAlloc( int count = Physics.SphereCastNonAlloc(
WorldCapsuleCenter + Vector3.up * radius, radius, Vector3.down, WorldCapsuleCenter - Vector3.up * radius, radius, Vector3.up,
_wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore); _wallProbe, WorldHalfHeight * 2f, ~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>Nearest non-player hit of a capsule-wide sphere cast straight down; true when its
/// surface angle is within the controller's slope limit.</summary>
private bool WalkableBelow(Vector3 origin, float maxDist)
{
int count = Physics.SphereCastNonAlloc(
origin, WorldRadius, Vector3.down, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
WarnIfProbeSaturated(count); WarnIfProbeSaturated(count);
float best = float.MaxValue; float best = float.MaxValue;
@@ -1121,6 +1374,7 @@ public class PlayerCharacterController : MonoBehaviour
_isWallClimbing = false; _isWallClimbing = false;
_onCeiling = false; _onCeiling = false;
_lostContactTimer = 0f; _lostContactTimer = 0f;
_rimConfirmTimer = 0f;
_verticalVelocity = 0f; _verticalVelocity = 0f;
_climbVelocity = Vector3.zero; _climbVelocity = Vector3.zero;
// Hand the current lean off to the residual so it eases home instead of snapping. // Hand the current lean off to the residual so it eases home instead of snapping.