From f68eec85d09bc7cbe9c353b728f8852aacd1c904 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Mon, 22 Jun 2026 20:28:42 +0200 Subject: [PATCH] Add ceiling climbing to wall-climb mode Reuse the climb mode for ceilings (surface normal pointing down): grab by jumping up into a ceiling or by climbing a wall up into an overhang. The body stays upright and crawls camera-relative in the horizontal plane; jump or a ceiling edge releases into a fall. Generalize the wall probe to use the capsule's support extent (radius sideways, half-height vertically) so the ceiling gap is correct. Co-Authored-By: Claude Opus 4.8 --- Assets/Scripts/PlayerCharacterController.cs | 235 ++++++++++++++------ 1 file changed, 168 insertions(+), 67 deletions(-) diff --git a/Assets/Scripts/PlayerCharacterController.cs b/Assets/Scripts/PlayerCharacterController.cs index b64c81f..cc090a8 100644 --- a/Assets/Scripts/PlayerCharacterController.cs +++ b/Assets/Scripts/PlayerCharacterController.cs @@ -16,6 +16,11 @@ using UnityEngine.InputSystem; /// 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. /// +/// Ceiling-climb shares the same mode: entered by jumping up into a ceiling or by climbing a +/// wall up into an overhang. The body stays upright and clings to the ceiling, moving in the +/// horizontal plane camera-relative (like on the ground). Jump (or reaching a ceiling edge) +/// lets go and drops. +/// /// Named PlayerCharacterController to avoid a clash with Unity.VisualScripting's /// example "PlayerController". /// @@ -98,6 +103,9 @@ public class PlayerCharacterController : MonoBehaviour [Tooltip("Grace period (seconds) of lost contact tolerated before leaving wall-climb mode.")] public float wallExitGrace = 0.15f; + [Tooltip("Head-to-ceiling distance (units) at which climbing a wall up into an overhang hands off to the ceiling.")] + public float ceilingGrabDistance = 0.3f; + [Tooltip("Camera orbit input; paused while dragging the wall-climb offset. Auto-found if empty.")] [SerializeField] private ThirdPersonOrbitInput cameraOrbit; @@ -118,7 +126,10 @@ public class PlayerCharacterController : MonoBehaviour private bool _groundNormalUpdated; private float _slideSpeed; - // Wall-climb state. + // Wall-climb state. A climb surface counts as a ceiling (horizontal-crawl model) rather + // than a wall (up/down model) once its normal points down past this threshold (~>120 deg + // from up); between that and wallMinAngle it is treated as a wall. + private const float CeilingNormalThreshold = -0.5f; private bool _isWallClimbing; private Vector3 _wallNormal; private Vector3 _wallNormalAccum; // summed wall normals during the latest Move @@ -207,7 +218,7 @@ public class PlayerCharacterController : MonoBehaviour // 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); + ApplyWallClimbMovement(h, v, moveDir, jumpPressed, Time.deltaTime); else _offsetDragging = false; if (cameraOrbit) @@ -284,25 +295,29 @@ 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) + // Decide whether the contacted surface is being pushed into. A wall is grabbed by + // pushing the move input horizontally into it while not rising (mere contact, e.g. + // standing at a wall's base, must not grab). A ceiling can't be pushed into + // horizontally, so it is grabbed by rising up into it (jumping up against it). + bool pushingIn = false; + if (_wallContactThisFrame && _wallNormalAccum.sqrMagnitude > 1e-6f) { - Vector3 wallNormal = _wallNormalAccum.normalized; - pushingIntoWall = Vector3.Dot(moveDir.normalized, wallNormal) <= -wallGrabMinPush; + Vector3 surfaceNormal = _wallNormalAccum.normalized; + if (surfaceNormal.y < CeilingNormalThreshold) + pushingIn = _verticalVelocity > 0f; + else + pushingIn = moveDir.sqrMagnitude > 1e-4f + && Vector3.Dot(moveDir.normalized, surfaceNormal) <= -wallGrabMinPush + && _verticalVelocity <= 0f; } - // 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. + // Grab only under player control; once climbing we stay attached across control + // toggles (e.g. paint mode) so switching modes never drops us off the surface. // The cooldown prevents instantly re-grabbing the edge we just mantled over. - if (!_isWallClimbing && ControlEnabled && pushingIntoWall - && _reentryCooldown <= 0f && _verticalVelocity <= 0f) + if (!_isWallClimbing && ControlEnabled && pushingIn && _reentryCooldown <= 0f) { _isWallClimbing = true; + _wallNormal = _wallNormalAccum.normalized; _lostContactTimer = 0f; _verticalVelocity = 0f; _slideSpeed = 0f; @@ -313,16 +328,17 @@ public class PlayerCharacterController : MonoBehaviour } /// - /// 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. + /// Movement while climbing, for both walls and ceilings (distinguished by the surface + /// normal). On a WALL: W/S climb straight up/down at a constant speed (camera pitch never + /// tilts them), A/D strafe left/right relative to the camera, the body faces the wall, and + /// holding the left mouse button drags the wall pose (vertical = lean out via + /// , horizontal = body yaw via ). On a + /// CEILING: the body stays upright and moves WASD camera-relative in the horizontal plane + /// (like on the ground), facing the move direction; the lean-out drag pulls down off the + /// ceiling. Surface normal and gap are re-measured each frame; reaching a wall's top edge + /// mantles, climbing a wall up into an overhang hands off to the ceiling. /// - private void ApplyWallClimbMovement(float h, float v, bool jumpPressed, float dt) + private void ApplyWallClimbMovement(float h, float v, Vector3 moveDir, bool jumpPressed, float dt) { _offsetDragging = false; @@ -332,44 +348,64 @@ public class PlayerCharacterController : MonoBehaviour return; } - // Jump off the wall: detach and jump straight up. Re-grab stays blocked - // while rising (UpdateWallClimbState) and resumes once falling near a wall. + bool onCeiling = _wallNormal.y < CeilingNormalThreshold; + + // Jump detaches. Off a wall it pushes straight up; off a ceiling it just lets go and + // drops. Re-grab stays blocked appropriately by UpdateWallClimbState's push-in test. if (jumpPressed) { ExitWallClimb(); - _verticalVelocity = Mathf.Sqrt(jumpHeight * -2f * gravity); + _verticalVelocity = onCeiling ? 0f : Mathf.Sqrt(jumpHeight * -2f * gravity); return; } + // Wall->ceiling hand-off: while climbing up a wall into an overhang, switch to the + // ceiling right above the head instead of trying to mantle over a (non-existent) top. + if (!onCeiling && v > 0f + && ProbeCeiling(out Vector3 ceilNormal, out float ceilHeadGap) + && ceilHeadGap <= ceilingGrabDistance) + { + _wallNormal = ceilNormal; + onCeiling = true; + _wallOffset = 0f; + } + bool found = ProbeWall(out Vector3 probedNormal, out float gap); if (found) { _wallNormal = probedNormal; _lostContactTimer = 0f; + onCeiling = _wallNormal.y < CeilingNormalThreshold; } - // 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); - 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(); + Vector3 alongSurface; + if (onCeiling) + { + // Crawl across the ceiling in the horizontal plane, camera-relative (moveDir is + // already the ground-projected camera input); project onto the ceiling for safety. + alongSurface = Vector3.ProjectOnPlane(moveDir, _wallNormal); + } + else + { + // W/S climb straight up/down the wall at a constant speed (camera pitch never tilts + // them). 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 jittered sideways motion. The camera only picks the sign. + Vector3 wallUp = Vector3.ProjectOnPlane(Vector3.up, _wallNormal); + if (wallUp.sqrMagnitude > 1e-6f) wallUp.Normalize(); + Vector3 wallRight = Vector3.Cross(_wallNormal, wallUp); + if (wallRight.sqrMagnitude > 1e-6f) wallRight.Normalize(); + if (cameraTransform && Vector3.Dot(cameraTransform.right, wallRight) < 0f) + wallRight = -wallRight; + alongSurface = wallUp * v + wallRight * h; + } + if (alongSurface.sqrMagnitude > 1f) alongSurface.Normalize(); bool hasMoveInput = (h * h + v * v) > 1e-4f; if (!found) { - // Center ray lost the wall while moving up its face -> top edge: mantle. - if (alongWall.y > 0f && WallInFront(_controller.height * 0.5f)) + // Wall only: losing the center ray while climbing up the face = top edge → mantle. + if (!onCeiling && alongSurface.y > 0f && WallInFront(_controller.height * 0.5f)) { _mantling = true; _mantleClearedFeet = false; @@ -379,7 +415,8 @@ public class PlayerCharacterController : MonoBehaviour return; } - // Wall genuinely gone: leave after the grace period. + // Surface genuinely gone (incl. crawling off a ceiling edge): leave after grace, + // dropping into normal gravity. _lostContactTimer += dt; if (_lostContactTimer >= wallExitGrace) { @@ -388,9 +425,9 @@ public class PlayerCharacterController : MonoBehaviour } } - // 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. + // Left button + drag adjusts the pose: vertical = lean-out offset (mouse down = away + // from the surface; down off a ceiling), horizontal = body yaw (wall only). The offset + // eases back to zero while moving and never detaches. Dragging pauses the camera orbit. if (found && IsLeftMouseHeld()) { _offsetDragging = true; @@ -405,8 +442,8 @@ public class PlayerCharacterController : MonoBehaviour float climbSpeed = moveSpeed * wallClimbSpeedMultiplier; Vector3 velocity = Vector3.zero; - if (alongWall.sqrMagnitude > 1e-6f) - velocity += alongWall.normalized * climbSpeed; + if (alongSurface.sqrMagnitude > 1e-6f) + velocity += alongSurface.normalized * climbSpeed; if (found) { // Smoothly approach the chosen standoff. Target one skin-width beyond the offset so @@ -432,26 +469,42 @@ public class PlayerCharacterController : MonoBehaviour 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). if (rotateTowardMovement) { - Vector3 faceWall = -_wallNormal; - faceWall.y = 0f; - if (faceWall.sqrMagnitude > 0.0001f) + if (onCeiling) { - 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); + // Stay upright and face the horizontal move direction, like on the ground. + Vector3 dir = alongSurface; + dir.y = 0f; + if (dir.sqrMagnitude > 1e-4f) + { + Quaternion target = Quaternion.LookRotation(dir.normalized, Vector3.up); + transform.rotation = Quaternion.RotateTowards( + transform.rotation, target, rotationSpeed * dt); + } + } + else + { + // Face the wall (forward = into the wall), turned by the held mouse-drag yaw. + // On leaving, the ground rotation eases the body back to its movement direction. + 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); + } } } } /// - /// Raycasts from the character center toward the wall to find the current - /// normal and the gap to it. Our own colliders are skipped. False = no wall. + /// Raycasts from the character center toward the surface (along -_wallNormal) to find the + /// current normal and the gap (surface clearance) to it. The capsule's support distance + /// along the probe direction is the radius sideways but up to the half-height vertically, + /// so the gap is correct for ceilings too. Our own colliders are skipped. False = no hit. /// private bool ProbeWall(out Vector3 normal, out float gap) { @@ -459,11 +512,12 @@ public class PlayerCharacterController : MonoBehaviour gap = 0f; Vector3 origin = transform.position + _controller.center; - float radius = _controller.radius; - float maxDist = radius + wallMaxOffset + 0.5f; + Vector3 dir = -_wallNormal; + float extent = CapsuleExtent(dir); + float maxDist = extent + wallMaxOffset + 0.5f; int count = Physics.RaycastNonAlloc( - origin, -_wallNormal, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore); + origin, dir, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore); float best = float.MaxValue; bool found = false; @@ -480,7 +534,54 @@ public class PlayerCharacterController : MonoBehaviour } if (found) - gap = Mathf.Max(0f, best - radius); + gap = Mathf.Max(0f, best - extent); + return found; + } + + /// + /// Capsule support distance from the center along a unit direction: the radius sideways, + /// the half-height straight up/down, interpolated by the vertical component in between. + /// + private float CapsuleExtent(Vector3 dir) + { + float radius = _controller.radius; + return radius + Mathf.Abs(dir.y) * (_controller.height * 0.5f - radius); + } + + /// + /// Raycasts straight up from the center to find a ceiling overhead and its head clearance + /// (top of the capsule to the surface). Only downward-facing (ceiling) hits count. + /// + private bool ProbeCeiling(out Vector3 normal, out float gap) + { + normal = Vector3.down; + gap = 0f; + + Vector3 origin = transform.position + _controller.center; + float half = _controller.height * 0.5f; + float maxDist = half + wallMaxOffset + 0.5f; + + int count = Physics.RaycastNonAlloc( + origin, Vector3.up, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore); + + float best = float.MaxValue; + bool found = false; + for (int i = 0; i < count; i++) + { + if (_wallProbe[i].collider.transform.IsChildOf(transform)) + continue; + if (_wallProbe[i].normal.y >= CeilingNormalThreshold) // not a ceiling + continue; + if (_wallProbe[i].distance < best) + { + best = _wallProbe[i].distance; + normal = _wallProbe[i].normal; + found = true; + } + } + + if (found) + gap = Mathf.Max(0f, best - half); return found; }