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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-22 20:28:42 +02:00
1 parent 534477f6f7
commit f68eec85d0
1 file changed
+168 -67
+168 -67
View File
@@ -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".
/// </summary>
@@ -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
}
/// <summary>
/// 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.
/// 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
/// <see cref="_wallOffset"/>, horizontal = body yaw via <see cref="_wallYaw"/>). 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.
/// </summary>
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);
}
}
}
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
private float CapsuleExtent(Vector3 dir)
{
float radius = _controller.radius;
return radius + Mathf.Abs(dir.y) * (_controller.height * 0.5f - radius);
}
/// <summary>
/// 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.
/// </summary>
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;
}