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:
1 parent
f4d31b0c24
commit
e5b5138232
2 files changed
+130
-12
No files matched your search
@@ -142,6 +142,7 @@ MonoBehaviour:
|
||||
wallOffsetReturnSpeed: 2
|
||||
wallOffsetFollow: 12
|
||||
wallExitGrace: 0.15
|
||||
wallTopOutConfirm: 0.06
|
||||
ceilingGrabDistance: 0.02
|
||||
wallTopOutBoost: 0.3
|
||||
noClimbTag: NoClimb
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user