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:
1 parent
534477f6f7
commit
f68eec85d0
1 file changed
+168
-67
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user