PlayerController: robust wall-climb hold on thin boards

ProbeWall now falls back to a horizontal multi-sample fan across the capsule
cross-section when the center ray misses, so a thin/narrow board keeps its
grab instead of dropping after wallExitGrace (which stays at 0.15). The
top-out mantle no longer commits on the first probe miss: it requires the
top-edge signature (wall at foot, moving up, headroom clear) to persist for
wallTopOutConfirm (new tunable, default 0.06s) and uses hardened multi-sample
foot/headroom probes, so a transient mid-face miss or a VHACD hull gap no
longer triggers a false mantle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-07-08 07:23:56 +02:00
1 parent f4d31b0c24
commit e5b5138232
2 files changed
+130 -12

No files matched your search

@@ -127,6 +127,9 @@ 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 (center probe clear, wall still at the feet, clear headroom above) must persist before committing to a top-out mantle. Filters transient center-probe misses (thin boards / VHACD hull gaps the ray slips through) so they no longer instantly 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.")]
public float ceilingGrabDistance = 0.02f;
@@ -728,21 +731,32 @@ public class PlayerCharacterController : MonoBehaviour
if (!found)
{
// Wall only: losing the center ray while climbing up the face = top edge → mantle.
if (!onCeiling && alongSurface.y > 0f && WallInFront(WorldHalfHeight))
{
_mantling = true;
_mantleClearedFeet = false;
_mantleOverDist = 0f;
_mantleTimer = 0f;
MantleOverEdge(dt);
return;
}
_lostContactTimer += dt;
// Wall only: the center probe (incl. its horizontal fallback) losing the wall while climbing
// up, with wall still at the feet AND clear headroom above the center, is the top-edge
// signature. Confirm it briefly before committing: the mantle used to fire on the very first
// miss frame (zero tolerance, checked before the graced exit), so any transient center miss -
// a thin/narrow board or a VHACD hull gap the ray slips through - instantly triggered a mantle
// mid-face. AtWallTopEdge rejects such a gap spatially (multi-sample foot test + headroom
// check); the confirm window rejects it temporally (a transient miss re-acquires within a
// frame or two, resetting _lostContactTimer before wallTopOutConfirm elapses).
if (!onCeiling && alongSurface.y > 0f && AtWallTopEdge())
{
if (_lostContactTimer >= wallTopOutConfirm)
{
_mantling = true;
_mantleClearedFeet = false;
_mantleOverDist = 0f;
_mantleTimer = 0f;
MantleOverEdge(dt);
return;
}
// Within the confirm window: keep climbing (fall through) and re-probe next frame.
}
// Surface genuinely gone (incl. crawling off a ceiling edge): leave after grace,
// dropping into normal gravity.
_lostContactTimer += dt;
if (_lostContactTimer >= wallExitGrace)
else if (_lostContactTimer >= wallExitGrace)
{
ExitWallClimb();
return;
@@ -980,6 +994,13 @@ public class PlayerCharacterController : MonoBehaviour
private float WorldHalfHeight => _controller.height * 0.5f * CapsuleScale;
private const float ProbeReachSlack = 0.5f; // extra ray length beyond the capsule surface (scale-1 units)
// Horizontal-only fallback samples for ProbeWall: fractions of the capsule radius along the wall's
// horizontal tangent. Fired ONLY when the zero-width center ray misses (thin/narrow boards the center
// line slips past). Horizontal-only on purpose: sideways offsets keep the same height as the center,
// so a real top-edge still misses on ALL samples -> the top-out mantle (which lives under !found) is
// untouched. A vertical-down offset would keep found=true over the lip and defeat mantling.
private static readonly float[] WallProbeTangentSamples = { 0.9f, -0.9f, 0.5f, -0.5f };
/// <summary>
/// True if the collider belongs to ANY player avatar (self or a networked remote): avatars still
/// collide physically, but another chameleon's body must never register as climbable. Finds the
@@ -1008,9 +1029,47 @@ public class PlayerCharacterController : MonoBehaviour
float extent = CapsuleExtent(dir);
float maxDist = extent + wallMaxOffset + ProbeReachSlack * CapsuleScale;
// Center ray first: unchanged behaviour on ordinary walls (no regression).
if (NearestClimbHit(origin, dir, maxDist, extent, ceilingOnly: false, out normal, out gap))
return true;
// Fallback: the zero-width center ray slips past thin/narrow boards whenever the capsule
// drifts a hair sideways (which is why standing still drops you - the volumetric re-grab that
// masks it needs move input). Sample a few origins spread across the capsule's horizontal
// footprint; the offsets are perpendicular to dir, so gap/normal stay consistent on a flat face.
Vector3 wallUp = Vector3.ProjectOnPlane(Vector3.up, _wallNormal);
wallUp = wallUp.sqrMagnitude > 1e-6f ? wallUp.normalized : Vector3.up;
Vector3 wallRight = Vector3.Cross(_wallNormal, wallUp);
if (wallRight.sqrMagnitude <= 1e-6f)
{
normal = _wallNormal;
return false;
}
wallRight.Normalize();
float radius = WorldRadius;
bool foundAny = false;
float bestGap = float.MaxValue;
Vector3 bestNormal = _wallNormal;
for (int i = 0; i < WallProbeTangentSamples.Length; i++)
{
Vector3 o = origin + wallRight * (WallProbeTangentSamples[i] * radius);
if (NearestClimbHit(o, dir, maxDist, extent, ceilingOnly: false, out Vector3 n, out float g)
&& g < bestGap)
{
bestGap = g;
bestNormal = n;
foundAny = true;
}
}
if (foundAny)
{
normal = bestNormal;
gap = bestGap;
return true;
}
normal = _wallNormal;
return false;
}
@@ -1150,6 +1209,64 @@ public class PlayerCharacterController : MonoBehaviour
return false;
}
/// <summary>
/// The hardened top-edge test that gates the mantle: a genuine wall top edge still has wall at the
/// feet but is clear at (already known: the failed <see cref="ProbeWall"/>) and just above the
/// center. Both wall tests sample the whole horizontal footprint (not just the center line), so a
/// narrow board isn't a false negative at the feet; the headroom sample above the center is the key
/// discriminator against a mid-face VHACD hull gap - there the wall continues above the gap the
/// center ray slipped through, so this returns false and no mantle fires.
/// </summary>
private bool AtWallTopEdge()
{
return WallPresentAtOffset(-WorldHalfHeight) // wall still below (feet)
&& !WallPresentAtOffset(WorldHalfHeight * 0.5f); // but clear above the center -> real top
}
/// <summary>
/// Multi-sample horizontal wall test at a vertical offset from the capsule center (positive = above):
/// like <see cref="WallInFront"/> but sampling across the capsule's horizontal footprint, so a thin
/// board or hull gap under the center line doesn't read as "no wall". True if ANY sample hits a
/// climbable surface.
/// </summary>
private bool WallPresentAtOffset(float upOffset)
{
Vector3 baseOrigin = WorldCapsuleCenter + Vector3.up * upOffset;
Vector3 dir = -_wallNormal;
float maxDist = WorldRadius + wallMaxOffset + ProbeReachSlack * CapsuleScale;
if (WallHitAt(baseOrigin, dir, maxDist)) return true;
Vector3 wallUp = Vector3.ProjectOnPlane(Vector3.up, _wallNormal);
wallUp = wallUp.sqrMagnitude > 1e-6f ? wallUp.normalized : Vector3.up;
Vector3 wallRight = Vector3.Cross(_wallNormal, wallUp);
if (wallRight.sqrMagnitude <= 1e-6f) return false;
wallRight.Normalize();
float radius = WorldRadius;
for (int i = 0; i < WallProbeTangentSamples.Length; i++)
{
if (WallHitAt(baseOrigin + wallRight * (WallProbeTangentSamples[i] * radius), dir, maxDist))
return true;
}
return false;
}
/// <summary>Single climbable-surface hit test along a ray; skips player/no-climb colliders.</summary>
private bool WallHitAt(Vector3 origin, Vector3 dir, float maxDist)
{
int count = Physics.RaycastNonAlloc(
origin, dir, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
WarnIfProbeSaturated(count);
for (int i = 0; i < count; i++)
{
if (IsPlayerCollider(_wallProbe[i].collider) || IsNoClimb(_wallProbe[i].collider))
continue;
return true;
}
return false;
}
/// <summary>
/// Short downward ray from the capsule center: true when walkable ground (angle within the
/// controller's slope limit) sits within step range below the feet. Used to top out of a