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:
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.")]
|
||||
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;
|
||||
|
||||
[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 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 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
|
||||
@@ -518,6 +519,7 @@ public class PlayerCharacterController : MonoBehaviour
|
||||
_wallNormal = _wallNormalAccum.normalized;
|
||||
_onCeiling = _wallNormal.y < CeilingNormalThreshold;
|
||||
_lostContactTimer = 0f;
|
||||
_rimConfirmTimer = 0f;
|
||||
_verticalVelocity = 0f;
|
||||
_slideSpeed = 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
|
||||
// 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
|
||||
&& ProbeCeiling(out Vector3 ceilNormal, out float ceilHeadGap)
|
||||
&& ceilHeadGap <= ceilingGrabDistance)
|
||||
&& ceilHeadGap <= ceilingGrabDistance
|
||||
&& ceilNormal.y < CeilingEnterThreshold)
|
||||
{
|
||||
_wallNormal = ceilNormal;
|
||||
_onCeiling = true;
|
||||
@@ -613,7 +618,16 @@ public class PlayerCharacterController : MonoBehaviour
|
||||
else
|
||||
{
|
||||
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;
|
||||
_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
|
||||
// to grounded movement; hop only when actually moving up onto it (a real top-out).
|
||||
// Topped out onto a slope: the climbed face has flattened into something walkable. The swept
|
||||
// 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
|
||||
&& Vector3.Angle(_wallNormal, Vector3.up) < wallMinAngle)
|
||||
{
|
||||
if (vEff > 0f) TopOutDetach();
|
||||
else ExitWallClimb();
|
||||
return;
|
||||
bool faceIsSlope = RaycastWallFace(out Vector3 faceNormal)
|
||||
&& Vector3.Angle(faceNormal, Vector3.up) < wallMinAngle;
|
||||
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;
|
||||
@@ -659,16 +723,25 @@ public class PlayerCharacterController : MonoBehaviour
|
||||
alongSurface = wallUp * vEff + wallRight * h;
|
||||
}
|
||||
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;
|
||||
|
||||
if (!found)
|
||||
{
|
||||
_lostContactTimer += dt;
|
||||
|
||||
// Wall top-out: probe lost the wall while climbing up, wall still at the feet, clear
|
||||
// headroom above the center. Confirmed briefly before committing so a transient miss
|
||||
// (which re-acquires within a frame or two) never mantles mid-face.
|
||||
if (!onCeiling && alongSurface.y > 0f && AtWallTopEdge())
|
||||
// Classify the miss: a top edge has wall at the feet but none above the center.
|
||||
bool wallBelow = !onCeiling
|
||||
&& ProbeWallAt(-WorldHalfHeight, out Vector3 lowNormal, out _)
|
||||
&& 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)
|
||||
{
|
||||
@@ -680,6 +753,22 @@ public class PlayerCharacterController : MonoBehaviour
|
||||
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.
|
||||
else if (_lostContactTimer >= wallExitGrace)
|
||||
{
|
||||
@@ -894,6 +983,8 @@ public class PlayerCharacterController : MonoBehaviour
|
||||
private float WorldRadius => _controller.radius * 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)
|
||||
// Forward travel of the mantle's over-the-edge phase; also where HasStandableTop samples.
|
||||
private float MantleForwardDistance => WorldRadius * 2f + 0.15f * CapsuleScale;
|
||||
|
||||
/// <summary>
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// 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.
|
||||
@@ -1028,7 +1187,7 @@ public class PlayerCharacterController : MonoBehaviour
|
||||
{
|
||||
_controller.Move(forward * (speed * dt));
|
||||
_mantleOverDist += speed * dt;
|
||||
if (_mantleOverDist >= WorldRadius * 2f + 0.15f * CapsuleScale || _controller.isGrounded)
|
||||
if (_mantleOverDist >= MantleForwardDistance || _controller.isGrounded)
|
||||
{
|
||||
_reentryCooldown = TopOutRegrabCooldown;
|
||||
ExitWallClimb();
|
||||
@@ -1045,26 +1204,73 @@ public class PlayerCharacterController : MonoBehaviour
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The top-edge test that gates the mantle: a genuine top edge still has wall at the feet but is
|
||||
/// clear just above the center (that the center probe itself is clear is already known: it failed).
|
||||
/// The headroom check is the discriminator against a mid-face gap — there the wall continues
|
||||
/// above whatever the probe slipped through, so no mantle fires.
|
||||
/// 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
|
||||
/// beyond the capsule surface like <see cref="ProbeWall"/> does from the center.
|
||||
/// </summary>
|
||||
private bool AtWallTopEdge()
|
||||
{
|
||||
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)
|
||||
private bool ProbeWallAt(float upOffset, out Vector3 normal, out float gap)
|
||||
{
|
||||
Vector3 dir = -_wallNormal;
|
||||
Vector3 origin = WorldCapsuleCenter + Vector3.up * upOffset;
|
||||
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>
|
||||
@@ -1075,12 +1281,59 @@ public class PlayerCharacterController : MonoBehaviour
|
||||
/// </summary>
|
||||
private bool WalkableGroundBelow()
|
||||
{
|
||||
float radius = WorldRadius;
|
||||
float maxDist = (_controller.center.y + _controller.stepOffset) * CapsuleScale;
|
||||
return WalkableBelow(
|
||||
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(
|
||||
WorldCapsuleCenter + Vector3.up * radius, radius, Vector3.down,
|
||||
_wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
|
||||
WorldCapsuleCenter - Vector3.up * radius, radius, Vector3.up,
|
||||
_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);
|
||||
|
||||
float best = float.MaxValue;
|
||||
@@ -1121,6 +1374,7 @@ public class PlayerCharacterController : MonoBehaviour
|
||||
_isWallClimbing = false;
|
||||
_onCeiling = false;
|
||||
_lostContactTimer = 0f;
|
||||
_rimConfirmTimer = 0f;
|
||||
_verticalVelocity = 0f;
|
||||
_climbVelocity = Vector3.zero;
|
||||
// Hand the current lean off to the residual so it eases home instead of snapping.
|
||||
|
||||
Reference in new issue
Block a user