From e5b5138232dacd04fdae23d1e9a76b0f315f2aea Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Wed, 8 Jul 2026 07:23:56 +0200 Subject: [PATCH] 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 --- Assets/Prefabs/Player.prefab | 1 + .../Controller/PlayerCharacterController.cs | 141 ++++++++++++++++-- 2 files changed, 130 insertions(+), 12 deletions(-) diff --git a/Assets/Prefabs/Player.prefab b/Assets/Prefabs/Player.prefab index 25e9648..9fcb4f9 100644 --- a/Assets/Prefabs/Player.prefab +++ b/Assets/Prefabs/Player.prefab @@ -142,6 +142,7 @@ MonoBehaviour: wallOffsetReturnSpeed: 2 wallOffsetFollow: 12 wallExitGrace: 0.15 + wallTopOutConfirm: 0.06 ceilingGrabDistance: 0.02 wallTopOutBoost: 0.3 noClimbTag: NoClimb diff --git a/Assets/Scripts/Components/Controller/PlayerCharacterController.cs b/Assets/Scripts/Components/Controller/PlayerCharacterController.cs index df29f00..6990d96 100644 --- a/Assets/Scripts/Components/Controller/PlayerCharacterController.cs +++ b/Assets/Scripts/Components/Controller/PlayerCharacterController.cs @@ -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 }; + /// /// 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; } + /// + /// 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 ) 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. + /// + private bool AtWallTopEdge() + { + return WallPresentAtOffset(-WorldHalfHeight) // wall still below (feet) + && !WallPresentAtOffset(WorldHalfHeight * 0.5f); // but clear above the center -> real top + } + + /// + /// Multi-sample horizontal wall test at a vertical offset from the capsule center (positive = above): + /// like 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. + /// + 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; + } + + /// Single climbable-surface hit test along a ray; skips player/no-climb colliders. + 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; + } + /// /// 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