From 78ab6e2dd6bcd553d07446989ee7dbd1a3d45ddd Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Wed, 8 Jul 2026 12:51:34 +0200 Subject: [PATCH] Fix wall climb on thin boards: rim detection, vault, edge-normal hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Controller/PlayerCharacterController.cs | 322 ++++++++++++++++-- 1 file changed, 288 insertions(+), 34 deletions(-) diff --git a/Assets/Scripts/Components/Controller/PlayerCharacterController.cs b/Assets/Scripts/Components/Controller/PlayerCharacterController.cs index d5bb6f4..dcdf0f6 100644 --- a/Assets/Scripts/Components/Controller/PlayerCharacterController.cs +++ b/Assets/Scripts/Components/Controller/PlayerCharacterController.cs @@ -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; /// /// 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; } + /// + /// 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. + /// + 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; + } + + /// + /// 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. + /// + 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; + } + /// /// 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 } /// - /// 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 does from the center. /// - private bool AtWallTopEdge() - { - return WallPresentAt(-WorldHalfHeight) - && !WallPresentAt(WorldHalfHeight * 0.5f); - } - - /// - /// Volumetric wall-presence test toward -_wallNormal at a vertical offset from the capsule - /// center (negative = below/feet, positive = above/headroom). - /// - 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 _); + + /// + /// 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 reports the nearest wall-like + /// hit (falling back to the current normal) so a graze never steers the climb into ceiling + /// mode. is the capsule-surface clearance. + /// + 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; } /// @@ -1075,12 +1281,59 @@ public class PlayerCharacterController : MonoBehaviour /// 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); + } + /// + /// 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. + /// + 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); + } + + /// + /// 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. + /// + 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; + } + + /// Nearest non-player hit of a capsule-wide sphere cast straight down; true when its + /// surface angle is within the controller's slope limit. + 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.