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:
1 parent
4c9eef18f4
commit
27301f34d9
1 file changed
+56
-16
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user