Files
chameleon/Assets/Scripts/PlayerCharacterController.cs
T
adminandClaude Opus 4.8 70e5323125 Fix wall-climb top-out on slopes with an upward hop
Detach back to grounded movement when the climbed face flattens into
walkable ground or walkable ground sits below the feet, testing an
explicit downward probe instead of isGrounded (which the standoff
correction can falsify by lifting the capsule off the surface). Top-out
now applies an upward hop so holding W carries the player over the lip
instead of dropping back onto the steep face and re-grabbing, and a
shared re-grab cooldown spans the hop to prevent detach/re-grab buzzing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 21:13:56 +02:00

774 lines
32 KiB
C#

using UnityEngine;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
/// <summary>
/// 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 by pushing the move input into a wall): 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 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>
[RequireComponent(typeof(CharacterController))]
[DisallowMultipleComponent]
public class PlayerCharacterController : MonoBehaviour
{
[Header("Movement")]
[Tooltip("Horizontal movement speed (units/second).")]
public float moveSpeed = 4f;
[Tooltip("Interpret WASD relative to the camera (off = world X/Z axes).")]
public bool cameraRelative = true;
[Tooltip("Camera used for camera-relative movement. Empty = Camera.main.")]
public Transform cameraTransform;
[Header("Rotation")]
[Tooltip("Smoothly turn the character to face its movement direction.")]
public bool rotateTowardMovement = true;
[Tooltip("Turn speed in degrees per second.")]
public float rotationSpeed = 720f;
[Header("Jump & Gravity")]
[Tooltip("Peak jump height in units.")]
public float jumpHeight = 1.2f;
[Tooltip("Gravity in units per second squared (negative = downward).")]
public float gravity = -19.62f;
[Tooltip("Small downward velocity kept while grounded for reliable ground contact.")]
public float groundedStickVelocity = -2f;
[Header("Slope Sliding")]
[Tooltip("Slide down surfaces steeper than the CharacterController's Slope Limit instead of balancing on them.")]
public bool slideDownSteepSlopes = true;
[Tooltip("How quickly the slide accelerates (units/second squared).")]
public float slideAcceleration = 20f;
[Tooltip("Maximum downhill slide speed (units/second).")]
public float maxSlideSpeed = 8f;
[Tooltip("Steering control kept while sliding (0 = none, 1 = full).")]
[Range(0f, 1f)]
public float slideControl = 0.3f;
[Header("Wall Climb")]
[Tooltip("Enter wall-climb mode by pushing into a wall: stick to the surface, move at reduced speed, ignore gravity and jumping.")]
public bool enableWallClimb = true;
[Tooltip("Minimum surface angle from 'up' (degrees) that counts as a climbable wall/ceiling rather than a slope.")]
[Range(50f, 180f)]
public float wallMinAngle = 80f;
[Tooltip("How directly the move input must point into a wall to grab it (0 = any inward nudge, 1 = head-on only). Stops mere contact (e.g. standing at a wall's base) from grabbing.")]
[Range(0f, 1f)]
public float wallGrabMinPush = 0.3f;
[Tooltip("Speed multiplier for climbing up and strafing while wall-climbing (0.5 = half speed).")]
[Range(0f, 1f)]
public float wallClimbSpeedMultiplier = 0.5f;
[Tooltip("Farthest the character can lean out from the wall via the mouse-drag offset (units).")]
public float wallMaxOffset = 1.2f;
[Tooltip("Units the lean-out offset changes per pixel of vertical mouse drag (left button held).")]
public float wallOffsetSensitivity = 0.01f;
[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("How snappily the body follows the lean-out offset (1/second). Higher = tighter, lower = smoother.")]
public float wallOffsetFollow = 12f;
[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("Upward hop when topping out over a wall/slope edge, so holding W carries you onto the ledge instead of dropping back onto the face and re-grabbing. In meters of jump height; 0 = no hop.")]
public float wallTopOutBoost = 1.2f;
[Tooltip("Camera orbit input; paused while dragging the wall-climb offset. Auto-found if empty.")]
[SerializeField] private ThirdPersonOrbitInput cameraOrbit;
/// <summary>
/// When false, movement/jump input is ignored but gravity and ground contact
/// keep running. Toggled by the GameModeManager.
/// </summary>
public bool ControlEnabled { get; set; } = true;
/// <summary>True while in wall-climb mode.</summary>
public bool IsWallClimbing => _isWallClimbing;
private CharacterController _controller;
private float _verticalVelocity;
// Ground contact normal from the latest Move (for slope sliding).
private Vector3 _groundNormal = Vector3.up;
private bool _groundNormalUpdated;
private float _slideSpeed;
// 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
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 bool _climbLeftGround; // true once the capsule has left the ground while climbing (enables ground-detach)
private readonly RaycastHit[] _wallProbe = new RaycastHit[8];
// Mantle (top-out) state.
private const float TopOutRegrabCooldown = 0.3f; // blocks re-grab right after any top-out so holding W tops out cleanly instead of detach/re-grab buzzing
private bool _mantling;
private bool _mantleClearedFeet; // phase 1 done: capsule fully above the edge
private float _mantleOverDist; // phase 2 forward distance over the top
private float _mantleTimer; // safety cap
private float _reentryCooldown; // blocks re-grabbing right after topping out
void Awake()
{
_controller = GetComponent<CharacterController>();
if (!cameraTransform && Camera.main)
cameraTransform = Camera.main.transform;
if (!cameraOrbit)
cameraOrbit = FindFirstObjectByType<ThirdPersonOrbitInput>();
}
void Update()
{
float h = 0f, v = 0f;
bool jumpPressed = false;
// Skip input when control is disabled (e.g. paint mode); gravity and
// grounding below still run so the player stays planted.
if (ControlEnabled)
{
#if ENABLE_INPUT_SYSTEM
var keyboard = Keyboard.current;
if (keyboard != null)
{
if (keyboard.aKey.isPressed) h -= 1f;
if (keyboard.dKey.isPressed) h += 1f;
if (keyboard.sKey.isPressed) v -= 1f;
if (keyboard.wKey.isPressed) v += 1f;
jumpPressed = keyboard.spaceKey.wasPressedThisFrame;
}
#else
h = Input.GetAxisRaw("Horizontal");
v = Input.GetAxisRaw("Vertical");
jumpPressed = Input.GetButtonDown("Jump");
#endif
}
Vector3 inputDir = new Vector3(h, 0f, v);
if (inputDir.sqrMagnitude > 1f)
inputDir.Normalize();
Vector3 moveDir;
if (cameraRelative && cameraTransform)
{
// Project the camera axes onto the ground plane so movement stays horizontal.
Vector3 forward = cameraTransform.forward;
forward.y = 0f;
forward.Normalize();
Vector3 right = cameraTransform.right;
right.y = 0f;
right.Normalize();
moveDir = forward * inputDir.z + right * inputDir.x;
}
else
{
moveDir = inputDir;
}
UpdateWallClimbState(moveDir);
// Latch the wall normal for this frame, then reset the per-frame contact
// accumulators (OnControllerColliderHit refills them during Move).
if (_wallContactThisFrame && _wallNormalAccum.sqrMagnitude > 1e-6f)
_wallNormal = _wallNormalAccum.normalized;
_wallContactThisFrame = false;
_wallNormalAccum = Vector3.zero;
if (_isWallClimbing)
{
// 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, moveDir, 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);
bool onSteepSlope = slideDownSteepSlopes
&& _controller.isGrounded
&& slopeAngle > _controller.slopeLimit + 0.01f;
if (_controller.isGrounded)
{
if (_verticalVelocity < 0f)
_verticalVelocity = groundedStickVelocity;
// No jumping off a slope too steep to stand on.
if (jumpPressed && !onSteepSlope)
_verticalVelocity = Mathf.Sqrt(jumpHeight * -2f * gravity);
}
_verticalVelocity += gravity * Time.deltaTime;
Vector3 slideVelocity = Vector3.zero;
if (onSteepSlope)
{
Vector3 slideDir = Vector3.ProjectOnPlane(Vector3.down, _groundNormal).normalized;
_slideSpeed = Mathf.Min(_slideSpeed + slideAcceleration * Time.deltaTime, maxSlideSpeed);
slideVelocity = slideDir * _slideSpeed;
moveDir *= slideControl; // reduced steering while sliding
}
else
{
_slideSpeed = 0f;
}
Vector3 velocity = moveDir * moveSpeed + slideVelocity + Vector3.up * _verticalVelocity;
// Move fires OnControllerColliderHit, which refills the ground normal;
// fall back to "up" if nothing was hit.
_groundNormalUpdated = false;
_controller.Move(velocity * Time.deltaTime);
if (!_groundNormalUpdated)
_groundNormal = Vector3.up;
if (rotateTowardMovement && moveDir.sqrMagnitude > 0.0001f)
{
Quaternion target = Quaternion.LookRotation(moveDir, Vector3.up);
transform.rotation = Quaternion.RotateTowards(
transform.rotation, target, rotationSpeed * Time.deltaTime);
}
}
/// <summary>
/// Enters wall-climb mode only when the move input actively pushes into a wall
/// (not on mere contact). Leaving while climbing is handled in
/// <see cref="ApplyWallClimbMovement"/>.
/// </summary>
private void UpdateWallClimbState(Vector3 moveDir)
{
if (_reentryCooldown > 0f)
_reentryCooldown -= Time.deltaTime;
if (!enableWallClimb)
{
if (_isWallClimbing)
ExitWallClimb();
return;
}
// 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 surfaceNormal = _wallNormalAccum.normalized;
if (surfaceNormal.y < CeilingNormalThreshold)
pushingIn = _verticalVelocity > 0f;
else
pushingIn = moveDir.sqrMagnitude > 1e-4f
&& Vector3.Dot(moveDir.normalized, surfaceNormal) <= -wallGrabMinPush
&& _verticalVelocity <= 0f;
}
// 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 && pushingIn && _reentryCooldown <= 0f)
{
_isWallClimbing = true;
_wallNormal = _wallNormalAccum.normalized;
_lostContactTimer = 0f;
_verticalVelocity = 0f;
_slideSpeed = 0f;
_wallOffset = 0f;
_wallYaw = 0f;
_climbLeftGround = false;
}
}
/// <summary>
/// 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, Vector3 moveDir, bool jumpPressed, float dt)
{
_offsetDragging = false;
if (_mantling)
{
MantleOverEdge(dt);
return;
}
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 = 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;
}
// Topped out onto a slope: the climbed face has flattened into something walkable (no
// longer steep enough to be a wall). Hand back to normal grounded movement instead of
// clinging to a "wall" that is really just ground now.
if (found && !onCeiling && _climbLeftGround
&& Vector3.Angle(_wallNormal, Vector3.up) < wallMinAngle)
{
TopOutDetach(); // climbed face flattened into walkable: hop over the lip instead of dropping onto the steep section below
return;
}
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)
{
// 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;
_mantleOverDist = 0f;
_mantleTimer = 0f;
MantleOverEdge(dt);
return;
}
// Surface genuinely gone (incl. crawling off a ceiling edge): leave after grace,
// dropping into normal gravity.
_lostContactTimer += dt;
if (_lostContactTimer >= wallExitGrace)
{
ExitWallClimb();
return;
}
}
// 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;
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 = Vector3.zero;
if (alongSurface.sqrMagnitude > 1e-6f)
velocity += alongSurface.normalized * climbSpeed;
if (found)
{
// Smoothly approach the chosen standoff. Target one skin-width beyond the offset so
// the capsule rests just outside the surface (offset 0 = flush): the controller's
// depenetration never fights back, so there's no buzz and no quantizing deadzone is
// needed. Exponential follow keeps it framerate-stable; clamp guards probe spikes.
const float maxPerpSpeed = 8f;
float gapError = (_wallOffset + _controller.skinWidth) - gap;
float follow = 1f - Mathf.Exp(-wallOffsetFollow * dt);
velocity += _wallNormal * Mathf.Clamp(gapError * follow / dt, -maxPerpSpeed, maxPerpSpeed);
}
_controller.Move(velocity * dt);
// Reaching walkable ground detaches and drops back to normal grounded movement. Gated
// on having left the ground first, so grabbing a wall at its base (still grounded)
// doesn't instantly detach. We test for walkable ground under the feet rather than
// trusting isGrounded: the standoff correction can lift the capsule just off the
// surface, so at a slope's top isGrounded stays false and the old check never fired.
bool onWalkableGround = _controller.isGrounded || WalkableGroundBelow();
if (!onWalkableGround)
_climbLeftGround = true;
else if (_climbLeftGround)
{
TopOutDetach(); // reached walkable ground at the top: hop up onto it instead of sliding back down the face
return;
}
if (rotateTowardMovement)
{
if (onCeiling)
{
// 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 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)
{
normal = _wallNormal;
gap = 0f;
Vector3 origin = transform.position + _controller.center;
Vector3 dir = -_wallNormal;
float extent = CapsuleExtent(dir);
float maxDist = extent + wallMaxOffset + 0.5f;
int count = Physics.RaycastNonAlloc(
origin, dir, _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)) // skip our own colliders
continue;
if (_wallProbe[i].distance < best)
{
best = _wallProbe[i].distance;
normal = _wallProbe[i].normal;
found = true;
}
}
if (found)
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;
}
/// <summary>
/// Pulls the character up and over the wall's top edge: phase 1 rises until the
/// capsule clears the edge, phase 2 moves forward onto the surface, then exits.
/// A short re-grab cooldown stops the passed edge from re-triggering a climb.
/// </summary>
private void MantleOverEdge(float dt)
{
_mantleTimer += dt;
float speed = moveSpeed * wallClimbSpeedMultiplier;
Vector3 forward = -_wallNormal;
forward.y = 0f;
forward = forward.sqrMagnitude > 1e-6f ? forward.normalized : transform.forward;
if (!_mantleClearedFeet)
{
// Phase 1: climb straight up until no wall is left in front of the feet.
_controller.Move(Vector3.up * (speed * dt));
if (!WallInFront(_controller.height * 0.5f))
_mantleClearedFeet = true;
}
else
{
// Phase 2: move forward onto the top surface, then finish.
_controller.Move(forward * (speed * dt));
_mantleOverDist += speed * dt;
if (_mantleOverDist >= _controller.radius * 2f + 0.15f || _controller.isGrounded)
{
_reentryCooldown = TopOutRegrabCooldown;
ExitWallClimb();
return;
}
}
// Safety: never mantle indefinitely (e.g. blocked by other geometry).
if (_mantleTimer >= 2f)
{
_reentryCooldown = TopOutRegrabCooldown;
ExitWallClimb();
}
}
/// <summary>
/// Horizontal ray toward the wall from a point below the center. Distinguishes a
/// top edge (wall still present lower down) from the wall being truly gone.
/// </summary>
private bool WallInFront(float downOffset)
{
Vector3 origin = transform.position + _controller.center - Vector3.up * downOffset;
float maxDist = _controller.radius + wallMaxOffset + 0.5f;
int count = Physics.RaycastNonAlloc(
origin, -_wallNormal, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
for (int i = 0; i < count; i++)
{
if (_wallProbe[i].collider.transform.IsChildOf(transform))
continue;
return true;
}
return false;
}
/// <summary>
/// Short downward ray from the capsule center: true when walkable ground (angle within the
/// controller's slope limit) sits within step range below the feet. Used to top out of a
/// climb back onto solid ground without trusting isGrounded, which the standoff correction
/// can falsify by lifting the capsule slightly off the surface.
/// </summary>
private bool WalkableGroundBelow()
{
Vector3 origin = transform.position + _controller.center;
float maxDist = _controller.center.y + _controller.stepOffset;
int count = Physics.RaycastNonAlloc(
origin, Vector3.down, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
float best = float.MaxValue;
Vector3 normal = Vector3.up;
bool found = false;
for (int i = 0; i < count; i++)
{
if (_wallProbe[i].collider.transform.IsChildOf(transform))
continue;
if (_wallProbe[i].distance < best)
{
best = _wallProbe[i].distance;
normal = _wallProbe[i].normal;
found = true;
}
}
return found && Vector3.Angle(normal, Vector3.up) <= _controller.slopeLimit;
}
/// <summary>
/// Detaches at the top of a climbed wall/slope with an upward hop, so holding W carries the
/// player over the lip onto the ledge instead of dropping back onto the face (which would
/// re-grab and stall). The re-grab cooldown spans the hop, and the grab test only fires when
/// not rising (<see cref="UpdateWallClimbState"/>), so the hop is never cut short.
/// </summary>
private void TopOutDetach()
{
_reentryCooldown = TopOutRegrabCooldown;
ExitWallClimb();
if (wallTopOutBoost > 0f)
_verticalVelocity = Mathf.Sqrt(wallTopOutBoost * -2f * gravity);
}
/// <summary>Leaves wall-climb mode and resets vertical velocity so the player falls cleanly.</summary>
private void ExitWallClimb()
{
_isWallClimbing = false;
_lostContactTimer = 0f;
_verticalVelocity = 0f;
_wallOffset = 0f;
_wallYaw = 0f;
_offsetDragging = false;
_climbLeftGround = 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
}
/// <summary>
/// Collects contact normals during Move: wall/ceiling contacts feed the
/// wall-climb detector, the flattest upward contact becomes the ground normal.
/// </summary>
private void OnControllerColliderHit(ControllerColliderHit hit)
{
if (enableWallClimb)
{
float angle = Vector3.Angle(hit.normal, Vector3.up);
if (angle >= wallMinAngle)
{
_wallContactThisFrame = true;
_wallNormalAccum += hit.normal;
}
}
// Ignore near-vertical walls and ceilings for ground-normal collection.
if (hit.normal.y <= 0.05f)
return;
// Keep the flattest (most supportive) contact seen this frame.
if (!_groundNormalUpdated || hit.normal.y > _groundNormal.y)
{
_groundNormal = hit.normal;
_groundNormalUpdated = true;
}
}
}