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;
}