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:
1 parent
f758c96dc5
commit
4c9eef18f4
2 files changed
+150
-52
No files matched your search
@@ -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.
|
||||
|
||||
@@ -64,6 +64,13 @@ public class ThirdPersonOrbitInput : MonoBehaviour
|
||||
set { lookMode = value; ApplyCursorState(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// While true, mouse-look is paused (orbit frozen) without changing the cursor
|
||||
/// state. The player controller sets this while dragging the wall-climb offset so
|
||||
/// the same mouse motion is not also spent orbiting the camera.
|
||||
/// </summary>
|
||||
public bool LookSuppressed { get; set; }
|
||||
|
||||
void Awake()
|
||||
{
|
||||
_orbit = GetComponent<CinemachineOrbitalFollow>();
|
||||
@@ -80,12 +87,12 @@ public class ThirdPersonOrbitInput : MonoBehaviour
|
||||
if (!_orbit)
|
||||
return;
|
||||
|
||||
bool rotating = ShouldRotate();
|
||||
bool modeWantsLook = ShouldRotate();
|
||||
|
||||
if (manageCursor)
|
||||
SetCursorLocked(rotating);
|
||||
SetCursorLocked(modeWantsLook);
|
||||
|
||||
if (rotating)
|
||||
if (modeWantsLook && !LookSuppressed)
|
||||
{
|
||||
Vector2 delta = ReadMouseDelta();
|
||||
if (delta.sqrMagnitude > 0f)
|
||||
|
||||
Reference in new issue
Block a user