From 4c9eef18f4b96e3c35d13d814256c8da18fc51b0 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Mon, 22 Jun 2026 18:36:58 +0200 Subject: [PATCH] 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 --- Assets/Scripts/PlayerCharacterController.cs | 189 +++++++++++++++----- Assets/Scripts/ThirdPersonOrbitInput.cs | 13 +- 2 files changed, 150 insertions(+), 52 deletions(-) diff --git a/Assets/Scripts/PlayerCharacterController.cs b/Assets/Scripts/PlayerCharacterController.cs index e0ae282..e697844 100644 --- a/Assets/Scripts/PlayerCharacterController.cs +++ b/Assets/Scripts/PlayerCharacterController.cs @@ -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; + /// /// 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(); if (!cameraTransform && Camera.main) cameraTransform = Camera.main.transform; + if (!cameraOrbit) + cameraOrbit = FindFirstObjectByType(); } 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; } } /// - /// 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 (mouse down = away; eases back + /// to zero while moving, never detaches), horizontal drag turns the body via + /// (held until leaving the wall). Wall normal and gap are + /// re-measured each frame. /// - 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); + } + } } /// @@ -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 + } + /// /// Collects contact normals during Move: wall/ceiling contacts feed the /// wall-climb detector, the flattest upward contact becomes the ground normal. diff --git a/Assets/Scripts/ThirdPersonOrbitInput.cs b/Assets/Scripts/ThirdPersonOrbitInput.cs index e19d22f..963e052 100644 --- a/Assets/Scripts/ThirdPersonOrbitInput.cs +++ b/Assets/Scripts/ThirdPersonOrbitInput.cs @@ -64,6 +64,13 @@ public class ThirdPersonOrbitInput : MonoBehaviour set { lookMode = value; ApplyCursorState(); } } + /// + /// 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. + /// + public bool LookSuppressed { get; set; } + void Awake() { _orbit = GetComponent(); @@ -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)