Refine wall-climb: stable strafe, ground detach, push-in grab

- Strafe (A/D) along the wall's horizontal tangent instead of the live
  camera-right vector to stop the sideways jitter from the orbit feedback loop.
- Detach from the wall once the character reaches the ground (gated so it
  only triggers after the climb has left the ground).
- Dead zone on the perpendicular offset controller to suppress depenetration buzz.
- Grab the wall only when actively pushing into it (wallGrabMinPush), preventing
  re-grab at the wall base.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-22 19:33:00 +02:00
1 parent 4c9eef18f4
commit 27301f34d9
1 file changed
+56 -16
+56 -16
View File
@@ -7,13 +7,13 @@ using UnityEngine.InputSystem;
/// CharacterController-based player: camera-relative WASD movement, manual gravity
/// and jumping, sliding off too-steep slopes/edges, and a wall-climb mode.
///
/// Wall-climb (entered on wall/ceiling contact): W/S climb straight up/down the wall at a
/// constant speed, A/D strafe left/right relative to the camera, and the body always faces
/// the wall. Holding the left mouse button and dragging sets the wall pose (vertical = lean
/// Wall-climb (entered by pushing the move input into a wall): W/S climb straight up/down the
/// wall at a constant speed, A/D strafe left/right relative to the camera, and the body always
/// faces the wall. Holding the left mouse button and dragging sets the wall pose (vertical = lean
/// out from / back to the wall, horizontal = turn the body) and pauses the camera orbit; the
/// lean-out offset eases back to zero while moving, the turn is held until leaving the wall.
/// On leaving, the body eases back to its movement direction. Jumping detaches and works
/// normally; re-grab is blocked while rising and resumes once falling near a wall.
/// normally; re-grab is blocked while rising and resumes once falling and pushing into a wall.
/// Reaching the top edge mantles the character over onto the surface.
///
/// Named PlayerCharacterController to avoid a clash with Unity.VisualScripting's
@@ -65,13 +65,17 @@ public class PlayerCharacterController : MonoBehaviour
public float slideControl = 0.3f;
[Header("Wall Climb")]
[Tooltip("Enter wall-climb mode on wall/ceiling contact: stick to the surface, move at reduced speed, ignore gravity and jumping.")]
[Tooltip("Enter wall-climb mode by pushing into a wall: stick to the surface, move at reduced speed, ignore gravity and jumping.")]
public bool enableWallClimb = true;
[Tooltip("Minimum surface angle from 'up' (degrees) that counts as a climbable wall/ceiling rather than a slope.")]
[Range(50f, 180f)]
public float wallMinAngle = 80f;
[Tooltip("How directly the move input must point into a wall to grab it (0 = any inward nudge, 1 = head-on only). Stops mere contact (e.g. standing at a wall's base) from grabbing.")]
[Range(0f, 1f)]
public float wallGrabMinPush = 0.3f;
[Tooltip("Speed multiplier for climbing up and strafing while wall-climbing (0.5 = half speed).")]
[Range(0f, 1f)]
public float wallClimbSpeedMultiplier = 0.5f;
@@ -120,6 +124,7 @@ public class PlayerCharacterController : MonoBehaviour
private float _wallOffset; // current lean-out distance from the wall (mouse drag)
private float _wallYaw; // body yaw offset from facing the wall (mouse drag); held until leaving
private bool _offsetDragging; // true while the offset/rotation is being set (pauses camera orbit)
private bool _climbLeftGround; // true once the capsule has left the ground while climbing (enables ground-detach)
private readonly RaycastHit[] _wallProbe = new RaycastHit[8];
// Mantle (top-out) state.
@@ -185,7 +190,7 @@ public class PlayerCharacterController : MonoBehaviour
moveDir = inputDir;
}
UpdateWallClimbState();
UpdateWallClimbState(moveDir);
// Latch the wall normal for this frame, then reset the per-frame contact
// accumulators (OnControllerColliderHit refills them during Move).
@@ -260,10 +265,11 @@ public class PlayerCharacterController : MonoBehaviour
}
/// <summary>
/// Enters wall-climb mode on any wall/ceiling contact. Leaving while climbing
/// is handled in <see cref="ApplyWallClimbMovement"/>.
/// Enters wall-climb mode only when the move input actively pushes into a wall
/// (not on mere contact). Leaving while climbing is handled in
/// <see cref="ApplyWallClimbMovement"/>.
/// </summary>
private void UpdateWallClimbState()
private void UpdateWallClimbState(Vector3 moveDir)
{
if (_reentryCooldown > 0f)
_reentryCooldown -= Time.deltaTime;
@@ -275,11 +281,22 @@ public class PlayerCharacterController : MonoBehaviour
return;
}
// Grab only when the move input points into the contacted wall, not on mere
// contact: standing at a wall's base or sliding down onto the ground keeps
// contact without pushing in, so it won't (re-)grab there.
bool pushingIntoWall = false;
if (_wallContactThisFrame && moveDir.sqrMagnitude > 1e-4f
&& _wallNormalAccum.sqrMagnitude > 1e-6f)
{
Vector3 wallNormal = _wallNormalAccum.normalized;
pushingIntoWall = Vector3.Dot(moveDir.normalized, wallNormal) <= -wallGrabMinPush;
}
// Re-grab only while falling/standing (not while rising from a jump) and
// only under player control; once climbing we stay attached across control
// toggles (e.g. paint mode) so switching modes never drops us off the wall.
// The cooldown prevents instantly re-grabbing the edge we just mantled over.
if (!_isWallClimbing && ControlEnabled && _wallContactThisFrame
if (!_isWallClimbing && ControlEnabled && pushingIntoWall
&& _reentryCooldown <= 0f && _verticalVelocity <= 0f)
{
_isWallClimbing = true;
@@ -288,6 +305,7 @@ public class PlayerCharacterController : MonoBehaviour
_slideSpeed = 0f;
_wallOffset = 0f;
_wallYaw = 0f;
_climbLeftGround = false;
}
}
@@ -330,11 +348,17 @@ public class PlayerCharacterController : MonoBehaviour
// W/S climb straight up/down the wall at a constant speed (camera pitch never
// tilts or slows them); the camera only sets left/right through A/D.
Vector3 wallUp = Vector3.ProjectOnPlane(Vector3.up, _wallNormal);
Vector3 wallRight = cameraTransform
? Vector3.ProjectOnPlane(cameraTransform.right, _wallNormal)
: Vector3.zero;
if (wallUp.sqrMagnitude > 1e-6f) wallUp.Normalize();
// Strafe along the wall's own horizontal tangent, not the live camera-right vector:
// feeding camera-right in here coupled movement to the orbit camera's per-frame
// re-aim, and that feedback loop jittered sideways motion. The camera now only picks
// which way is "right" (sign), so A/D stay camera-relative without the wobble.
Vector3 wallRight = Vector3.Cross(_wallNormal, wallUp);
if (wallRight.sqrMagnitude > 1e-6f) wallRight.Normalize();
if (cameraTransform && Vector3.Dot(cameraTransform.right, wallRight) < 0f)
wallRight = -wallRight;
Vector3 alongWall = wallUp * v + wallRight * h;
if (alongWall.sqrMagnitude > 1f) alongWall.Normalize();
bool hasMoveInput = (h * h + v * v) > 1e-4f;
@@ -382,14 +406,29 @@ public class PlayerCharacterController : MonoBehaviour
velocity += alongWall.normalized * climbSpeed;
if (found)
{
// Drive the gap toward the desired offset; the closed loop also keeps light
// contact when flush (offset 0). Clamp guards against probe spikes.
// Hold the chosen standoff (offset, 0 = flush) without fighting the controller's
// depenetration. A deadzone wider than the skin-width settling distance ignores
// sub-threshold gap noise that otherwise buzzed the body back and forth along the
// wall normal. Clamp guards against probe spikes.
const float maxPerpSpeed = 8f;
velocity += _wallNormal * Mathf.Clamp((_wallOffset - gap) / dt, -maxPerpSpeed, maxPerpSpeed);
float gapError = _wallOffset - gap;
if (Mathf.Abs(gapError) > _controller.skinWidth + 0.02f)
velocity += _wallNormal * Mathf.Clamp(gapError / dt, -maxPerpSpeed, maxPerpSpeed);
}
_controller.Move(velocity * dt);
// Reaching the ground detaches and drops back to normal grounded movement. Gated on
// having left the ground first, so grabbing a wall while standing at its base (still
// grounded) doesn't instantly detach before any climbing has happened.
if (!_controller.isGrounded)
_climbLeftGround = true;
else if (_climbLeftGround)
{
ExitWallClimb();
return;
}
// Always face the wall (forward = into the wall), turned by the held mouse-drag yaw.
// This holds the whole climb; on leaving, the ground rotation eases the body back to
// its movement direction (RotateTowards, so it interpolates rather than snapping).
@@ -513,6 +552,7 @@ public class PlayerCharacterController : MonoBehaviour
_wallOffset = 0f;
_wallYaw = 0f;
_offsetDragging = false;
_climbLeftGround = false;
_mantling = false;
_mantleClearedFeet = false;
_mantleOverDist = 0f;