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
|
wallOffsetReturnSpeed: 2
|
||||||
wallOffsetFollow: 12
|
wallOffsetFollow: 12
|
||||||
wallExitGrace: 0.15
|
wallExitGrace: 0.15
|
||||||
|
wallTopOutConfirm: 0.06
|
||||||
ceilingGrabDistance: 0.02
|
ceilingGrabDistance: 0.02
|
||||||
wallTopOutBoost: 0.3
|
wallTopOutBoost: 0.3
|
||||||
noClimbTag: NoClimb
|
noClimbTag: NoClimb
|
||||||
|
|||||||
@@ -127,6 +127,9 @@ 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 (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.")]
|
[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;
|
public float ceilingGrabDistance = 0.02f;
|
||||||
|
|
||||||
@@ -728,21 +731,32 @@ public class PlayerCharacterController : MonoBehaviour
|
|||||||
|
|
||||||
if (!found)
|
if (!found)
|
||||||
{
|
{
|
||||||
// Wall only: losing the center ray while climbing up the face = top edge → mantle.
|
_lostContactTimer += dt;
|
||||||
if (!onCeiling && alongSurface.y > 0f && WallInFront(WorldHalfHeight))
|
|
||||||
{
|
|
||||||
_mantling = true;
|
|
||||||
_mantleClearedFeet = false;
|
|
||||||
_mantleOverDist = 0f;
|
|
||||||
_mantleTimer = 0f;
|
|
||||||
MantleOverEdge(dt);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 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,
|
// Surface genuinely gone (incl. crawling off a ceiling edge): leave after grace,
|
||||||
// dropping into normal gravity.
|
// dropping into normal gravity.
|
||||||
_lostContactTimer += dt;
|
else if (_lostContactTimer >= wallExitGrace)
|
||||||
if (_lostContactTimer >= wallExitGrace)
|
|
||||||
{
|
{
|
||||||
ExitWallClimb();
|
ExitWallClimb();
|
||||||
return;
|
return;
|
||||||
@@ -980,6 +994,13 @@ public class PlayerCharacterController : MonoBehaviour
|
|||||||
private float WorldHalfHeight => _controller.height * 0.5f * CapsuleScale;
|
private float WorldHalfHeight => _controller.height * 0.5f * CapsuleScale;
|
||||||
private const float ProbeReachSlack = 0.5f; // extra ray length beyond the capsule surface (scale-1 units)
|
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>
|
/// <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
|
||||||
/// collide physically, but another chameleon's body must never register as climbable. Finds the
|
/// 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 extent = CapsuleExtent(dir);
|
||||||
float maxDist = extent + wallMaxOffset + ProbeReachSlack * CapsuleScale;
|
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))
|
if (NearestClimbHit(origin, dir, maxDist, extent, ceilingOnly: false, out normal, out gap))
|
||||||
return true;
|
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;
|
normal = _wallNormal;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1150,6 +1209,64 @@ public class PlayerCharacterController : MonoBehaviour
|
|||||||
return false;
|
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>
|
/// <summary>
|
||||||
/// Short downward ray from the capsule center: true when walkable ground (angle within the
|
/// 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
|
/// controller's slope limit) sits within step range below the feet. Used to top out of a
|
||||||
|
|||||||
Reference in new issue
Block a user