Rework wall-climb controls: constant W/S climb, mouse-drag wall pose

- W/S climb straight up/down at a constant speed (camera pitch no longer
  tilts or slows them); the camera only drives A/D strafe along the wall.
- The body now always faces the wall. Holding LMB and dragging sets the
  wall pose: vertical drag leans out from / back to the wall (offset eases
  back to zero while moving, never detaches), horizontal drag turns the
  body (yaw held until leaving the wall, eased back by ground rotation).
- Pause the camera orbit while dragging the wall pose (LookSuppressed).
- Drop the camera-to-wall angle logic and the old approach/detach-distance
  model; add wallMaxOffset, wallOffsetSensitivity, wallOffsetReturnSpeed
  and wallRotateSensitivity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-22 18:36:58 +02:00
1 parent f758c96dc5
commit 4c9eef18f4
2 files changed
+150 -52

No files matched your search

+140 -49
View File
@@ -7,10 +7,14 @@ 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) uses wall-relative controls: W
/// moves toward the wall and climbs up once against it, S backs off and detaches,
/// A/D strafe along the wall. Gravity and jumping are disabled while climbing;
/// reaching the top edge mantles the character over onto the surface.
/// 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
/// 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.
/// Reaching the top edge mantles the character over onto the surface.
///
/// Named PlayerCharacterController to avoid a clash with Unity.VisualScripting's
/// example "PlayerController".
@@ -72,18 +76,24 @@ public class PlayerCharacterController : MonoBehaviour
[Range(0f, 1f)]
public float wallClimbSpeedMultiplier = 0.5f;
[Tooltip("Slow speed for moving toward (W) or away from (S) the wall while climbing.")]
public float wallApproachSpeed = 0.5f;
[Tooltip("Farthest the character can lean out from the wall via the mouse-drag offset (units).")]
public float wallMaxOffset = 1.2f;
[Tooltip("Gap below which the character counts as fully against the wall (W then climbs up).")]
public float wallContactDistance = 0.05f;
[Tooltip("Units the lean-out offset changes per pixel of vertical mouse drag (left button held).")]
public float wallOffsetSensitivity = 0.01f;
[Tooltip("Gap from the wall at which holding back (S) leaves wall-climb mode.")]
public float wallDetachDistance = 0.25f;
[Tooltip("Degrees the body turns per pixel of horizontal mouse drag (left button held) while climbing.")]
public float wallRotateSensitivity = 0.2f;
[Tooltip("How fast the lean-out offset eases back to the wall while moving (units/second).")]
public float wallOffsetReturnSpeed = 2f;
[Tooltip("Grace period (seconds) of lost contact tolerated before leaving wall-climb mode.")]
public float wallExitGrace = 0.15f;
[Tooltip("Camera orbit input; paused while dragging the wall-climb offset. Auto-found if empty.")]
[SerializeField] private ThirdPersonOrbitInput cameraOrbit;
/// <summary>
/// When false, movement/jump input is ignored but gravity and ground contact
/// keep running. Toggled by the GameModeManager.
@@ -107,6 +117,9 @@ public class PlayerCharacterController : MonoBehaviour
private Vector3 _wallNormalAccum; // summed wall normals during the latest Move
private bool _wallContactThisFrame;
private float _lostContactTimer;
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 readonly RaycastHit[] _wallProbe = new RaycastHit[8];
// Mantle (top-out) state.
@@ -121,6 +134,8 @@ public class PlayerCharacterController : MonoBehaviour
_controller = GetComponent<CharacterController>();
if (!cameraTransform && Camera.main)
cameraTransform = Camera.main.transform;
if (!cameraOrbit)
cameraOrbit = FindFirstObjectByType<ThirdPersonOrbitInput>();
}
void Update()
@@ -181,10 +196,20 @@ public class PlayerCharacterController : MonoBehaviour
if (_isWallClimbing)
{
ApplyWallClimbMovement(h, v, Time.deltaTime);
// Frozen on the wall while control is off (e.g. paint mode): keep the
// grip, no movement or gravity, so switching modes never drops us.
if (ControlEnabled)
ApplyWallClimbMovement(h, v, jumpPressed, Time.deltaTime);
else
_offsetDragging = false;
if (cameraOrbit)
cameraOrbit.LookSuppressed = _offsetDragging;
return;
}
if (cameraOrbit)
cameraOrbit.LookSuppressed = false;
// A ground normal steeper than the slope limit is too steep to stand on
// (also covers balancing on a sharp box edge).
float slopeAngle = Vector3.Angle(_groundNormal, Vector3.up);
@@ -243,47 +268,81 @@ public class PlayerCharacterController : MonoBehaviour
if (_reentryCooldown > 0f)
_reentryCooldown -= Time.deltaTime;
if (!enableWallClimb || !ControlEnabled)
if (!enableWallClimb)
{
if (_isWallClimbing)
ExitWallClimb();
return;
}
// 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 && _wallContactThisFrame && _reentryCooldown <= 0f)
if (!_isWallClimbing && ControlEnabled && _wallContactThisFrame
&& _reentryCooldown <= 0f && _verticalVelocity <= 0f)
{
_isWallClimbing = true;
_lostContactTimer = 0f;
_verticalVelocity = 0f;
_slideSpeed = 0f;
_wallOffset = 0f;
_wallYaw = 0f;
}
}
/// <summary>
/// Wall-relative movement while climbing: W approaches the wall then climbs up,
/// S backs off and detaches, A/D strafe along the wall. The wall normal and gap
/// are measured each frame by a raycast. No gravity, jump or forced alignment.
/// Movement while climbing. W/S climb straight up/down the wall at a constant speed
/// (camera pitch never tilts them); A/D strafe left/right along the wall relative to
/// the camera. The body always faces the wall. Holding the left mouse button and
/// dragging sets the wall pose and pauses the camera orbit: vertical drag leans out
/// from / back to the wall via <see cref="_wallOffset"/> (mouse down = away; eases back
/// to zero while moving, never detaches), horizontal drag turns the body via
/// <see cref="_wallYaw"/> (held until leaving the wall). Wall normal and gap are
/// re-measured each frame.
/// </summary>
private void ApplyWallClimbMovement(float h, float v, float dt)
private void ApplyWallClimbMovement(float h, float v, bool jumpPressed, float dt)
{
_offsetDragging = false;
if (_mantling)
{
MantleOverEdge(dt);
return;
}
// Jump off the wall: detach and jump straight up. Re-grab stays blocked
// while rising (UpdateWallClimbState) and resumes once falling near a wall.
if (jumpPressed)
{
ExitWallClimb();
_verticalVelocity = Mathf.Sqrt(jumpHeight * -2f * gravity);
return;
}
bool found = ProbeWall(out Vector3 probedNormal, out float gap);
if (found)
{
_wallNormal = probedNormal;
_lostContactTimer = 0f;
}
else
// 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();
if (wallRight.sqrMagnitude > 1e-6f) wallRight.Normalize();
Vector3 alongWall = wallUp * v + wallRight * h;
if (alongWall.sqrMagnitude > 1f) alongWall.Normalize();
bool hasMoveInput = (h * h + v * v) > 1e-4f;
if (!found)
{
// The center ray lost the wall. If we are climbing up (W) and the wall
// still exists at foot level, we reached the top edge -> mantle over.
if (v > 0f && WallInFront(_controller.height * 0.5f))
// Center ray lost the wall while moving up its face -> top edge: mantle.
if (alongWall.y > 0f && WallInFront(_controller.height * 0.5f))
{
_mantling = true;
_mantleClearedFeet = false;
@@ -295,7 +354,6 @@ public class PlayerCharacterController : MonoBehaviour
// Wall genuinely gone: leave after the grace period.
_lostContactTimer += dt;
gap = wallDetachDistance + 1f; // treat as "far" so it cannot block exit
if (_lostContactTimer >= wallExitGrace)
{
ExitWallClimb();
@@ -303,38 +361,50 @@ public class PlayerCharacterController : MonoBehaviour
}
}
// Basis on the wall plane: "up along the wall" and "right".
Vector3 climbUp = Vector3.ProjectOnPlane(Vector3.up, _wallNormal);
if (climbUp.sqrMagnitude < 1e-6f) // degenerate on a flat ceiling
climbUp = Vector3.ProjectOnPlane(transform.forward, _wallNormal);
climbUp.Normalize();
Vector3 right = Vector3.Cross(_wallNormal, climbUp).normalized;
// Left button + drag adjusts the wall pose: vertical = lean-out offset (mouse down =
// away), horizontal = body yaw. The offset eases back to zero while moving and never
// detaches; the yaw is held until leaving the wall. Dragging pauses the camera orbit.
if (found && IsLeftMouseHeld())
{
_offsetDragging = true;
Vector2 drag = ReadMouseDelta();
_wallOffset = Mathf.Clamp(_wallOffset - drag.y * wallOffsetSensitivity, 0f, wallMaxOffset);
_wallYaw += drag.x * wallRotateSensitivity;
}
else if (hasMoveInput)
{
_wallOffset = Mathf.MoveTowards(_wallOffset, 0f, wallOffsetReturnSpeed * dt);
}
float climbSpeed = moveSpeed * wallClimbSpeedMultiplier;
Vector3 velocity = right * (h * climbSpeed); // A/D strafe
bool detach = false;
if (v > 0f)
Vector3 velocity = Vector3.zero;
if (alongWall.sqrMagnitude > 1e-6f)
velocity += alongWall.normalized * climbSpeed;
if (found)
{
// W: approach the wall while there is a gap, otherwise climb up.
if (gap > wallContactDistance)
velocity += -_wallNormal * (v * wallApproachSpeed);
else
velocity += climbUp * (v * climbSpeed);
}
else if (v < 0f)
{
// S: back away (v < 0 => away from wall); detach once far enough.
velocity += -_wallNormal * (v * wallApproachSpeed);
if (gap >= wallDetachDistance)
detach = true;
// Drive the gap toward the desired offset; the closed loop also keeps light
// contact when flush (offset 0). Clamp guards against probe spikes.
const float maxPerpSpeed = 8f;
velocity += _wallNormal * Mathf.Clamp((_wallOffset - gap) / dt, -maxPerpSpeed, maxPerpSpeed);
}
_controller.Move(velocity * dt);
if (detach)
ExitWallClimb();
// 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).
if (rotateTowardMovement)
{
Vector3 faceWall = -_wallNormal;
faceWall.y = 0f;
if (faceWall.sqrMagnitude > 0.0001f)
{
Vector3 dir = Quaternion.AngleAxis(_wallYaw, Vector3.up) * faceWall.normalized;
Quaternion target = Quaternion.LookRotation(dir, Vector3.up);
transform.rotation = Quaternion.RotateTowards(
transform.rotation, target, rotationSpeed * dt);
}
}
}
/// <summary>
@@ -348,7 +418,7 @@ public class PlayerCharacterController : MonoBehaviour
Vector3 origin = transform.position + _controller.center;
float radius = _controller.radius;
float maxDist = radius + wallDetachDistance + 0.5f;
float maxDist = radius + wallMaxOffset + 0.5f;
int count = Physics.RaycastNonAlloc(
origin, -_wallNormal, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
@@ -421,7 +491,7 @@ public class PlayerCharacterController : MonoBehaviour
private bool WallInFront(float downOffset)
{
Vector3 origin = transform.position + _controller.center - Vector3.up * downOffset;
float maxDist = _controller.radius + wallDetachDistance + 0.5f;
float maxDist = _controller.radius + wallMaxOffset + 0.5f;
int count = Physics.RaycastNonAlloc(
origin, -_wallNormal, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
@@ -440,12 +510,33 @@ public class PlayerCharacterController : MonoBehaviour
_isWallClimbing = false;
_lostContactTimer = 0f;
_verticalVelocity = 0f;
_wallOffset = 0f;
_wallYaw = 0f;
_offsetDragging = false;
_mantling = false;
_mantleClearedFeet = false;
_mantleOverDist = 0f;
_mantleTimer = 0f;
}
bool IsLeftMouseHeld()
{
#if ENABLE_INPUT_SYSTEM
return Mouse.current != null && Mouse.current.leftButton.isPressed;
#else
return Input.GetMouseButton(0);
#endif
}
Vector2 ReadMouseDelta()
{
#if ENABLE_INPUT_SYSTEM
return Mouse.current != null ? Mouse.current.delta.ReadValue() : Vector2.zero;
#else
return new Vector2(Input.GetAxisRaw("Mouse X"), Input.GetAxisRaw("Mouse Y")) * 10f;
#endif
}
/// <summary>
/// Collects contact normals during Move: wall/ceiling contacts feed the
/// wall-climb detector, the flattest upward contact becomes the ground normal.