Files
chameleon/Assets/Scripts/PlayerCharacterController.cs
T
adminandClaude Opus 4.8 d867c617df Wall-climb mesh lean + separate climb/mantle/sprint speeds; reworked rig
PlayerCharacterController:
- Wall lean now moves only the visual model + its paint collider (auto-resolved
  mesh root parenting both renderer and bones), not the CharacterController
  capsule, so the mesh can sink into the wall (wallMaxInset) as well as lean out
  (wallMaxOffset); capsule stays flush.
- Lean eases back to the authored pose when leaving the wall instead of snapping.
- Sprint multiplier now also applies while wall/ceiling climbing.
- Separate wallMantleSpeedMultiplier for pulling up over the top edge,
  independent of the climb speed.

Assets:
- Re-exported paintman_UV.FBX and added Paintman.blend: rescaled armature to
  mesh, merged seam duplicate vertices, fresh automatic weights, inner-thigh
  weight fix (no webbing on leg spread).
- Updated anims, animator controller, font SDF and scene.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 20:57:15 +02:00

935 lines
41 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 up/down leans the visual mesh out
/// of or into the wall (only the model + its paint collider move; the capsule stays put, so the
/// lean can sink into the wall); holding the right mouse button and dragging left/right turns the
/// body. Either pauses the camera orbit; the lean 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 partial class PlayerCharacterController : MonoBehaviour
{
[Header("Movement")]
[Tooltip("Horizontal movement speed (units/second).")]
public float moveSpeed = 4f;
[Tooltip("Speed multiplier applied to ground movement and wall/ceiling climbing while the sprint key (Left Shift) is held.")]
public float sprintSpeedMultiplier = 1.8f;
[Tooltip("How quickly ground movement ramps up to the target speed (units/second²). Lower = more gradual start.")]
public float acceleration = 30f;
[Tooltip("How quickly ground movement slows to a stop when input eases off (units/second²). Lower = more glide.")]
public float deceleration = 45f;
[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;
public Animator animator;
[Tooltip("Animator bool parameter driven true while running on the ground (false in air and while wall/ceiling-climbing).")]
public string runningParameter = "Running";
[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("Speed multiplier for the mantle (pulling up over the top edge), independent of the climb speed. Multiplies moveSpeed; 1 = full walk speed, >1 = snappier pull-up.")]
[Min(0f)]
public float wallMantleSpeedMultiplier = 0.5f;
[Tooltip("Farthest the mesh can lean out from the wall via the mouse-drag offset (units).")]
public float wallMaxOffset = .2f;
[Tooltip("Farthest the mesh can sink INTO the wall via the mouse-drag offset (units). Only the visual model + its paint collider move; the CharacterController capsule stays put, so this isn't blocked by the wall collider.")]
public float wallMaxInset = .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;
[Tooltip("Visual model root moved by the wall-climb lean offset (into/out of the wall) while the capsule stays put. Must parent both the skinned mesh and its bones. Auto-resolved from the SkinnedMeshRenderer if empty.")]
[SerializeField] private Transform meshOffsetTarget;
/// <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;
// Below this horizontal speed (m/s, squared) the character counts as idle for the run animation.
private const float RunningSpeedThresholdSqr = 0.04f; // (0.2 m/s)^2
private CharacterController _controller;
private int _runningHash;
private float _verticalVelocity;
private Vector3 _horizontalVelocity; // smoothed horizontal move velocity (accel/decel ramp)
// 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; // signed mesh lean (mouse drag): + = out from the wall, - = into it; moves only the model, not the capsule
private float _wallYaw; // body yaw offset from facing the wall (mouse drag); held until leaving
private int _wallStrafeSign; // latched sideways direction (camera picks it once per strafe); 0 = unlatched
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 Vector3 _meshBaseLocalPos; // meshOffsetTarget's authored local position; the lean offset is applied relative to this
private float _meshOffsetResidual; // leftover mesh lean still easing back to 0 after leaving the wall (no longer climbing)
private Vector3 _meshOffsetExitNormal; // wall normal latched at exit; the residual eases along it (live _wallNormal may shift on stray contact)
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>();
_runningHash = Animator.StringToHash(runningParameter);
if (!cameraTransform && Camera.main)
cameraTransform = Camera.main.transform;
if (!cameraOrbit)
cameraOrbit = FindFirstObjectByType<ThirdPersonOrbitInput>();
// Resolve the model root to offset: walk up from the skinned mesh to the topmost node still
// under this transform, so it parents BOTH the renderer and its bones. Offsetting only the
// renderer would move just the baked paint collider, not the bone-driven visible mesh.
if (!meshOffsetTarget)
{
var smr = GetComponentInChildren<SkinnedMeshRenderer>();
if (smr)
{
Transform t = smr.transform;
while (t.parent && t.parent != transform) t = t.parent;
meshOffsetTarget = t;
}
}
if (meshOffsetTarget) _meshBaseLocalPos = meshOffsetTarget.localPosition;
}
void Update()
{
float h = 0f, v = 0f;
bool jumpPressed = false;
bool sprinting = 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;
sprinting = keyboard.leftShiftKey.isPressed;
}
#else
h = Input.GetAxisRaw("Horizontal");
v = Input.GetAxisRaw("Vertical");
jumpPressed = Input.GetButtonDown("Jump");
sprinting = Input.GetKey(KeyCode.LeftShift);
#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, sprinting, Time.deltaTime);
else
_offsetDragging = false;
if (cameraOrbit)
cameraOrbit.LookSuppressed = _offsetDragging;
_horizontalVelocity = Vector3.zero; // drop ground momentum so resuming starts from rest
SetAnimatorRunning(false); // climbing is its own pose, not the ground run
return;
}
if (cameraOrbit)
cameraOrbit.LookSuppressed = false;
// Glide the leftover wall lean back to the authored pose (set when leaving the wall).
EaseMeshOffsetToRest(Time.deltaTime);
// 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;
}
float currentSpeed = sprinting ? moveSpeed * sprintSpeedMultiplier : moveSpeed;
// Ease horizontal velocity toward the target so movement accelerates/decelerates
// instead of snapping. Accel while there is input, decel when it eases off.
Vector3 targetHorizontal = moveDir * currentSpeed;
float accelRate = moveDir.sqrMagnitude > 0.0001f ? acceleration : deceleration;
_horizontalVelocity = Vector3.MoveTowards(_horizontalVelocity, targetHorizontal, accelRate * Time.deltaTime);
Vector3 velocity = _horizontalVelocity + 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);
}
// Run animation: only while grounded and actually moving (airborne/sliding-blocked = idle).
// Real horizontal speed, so pushing into a wall (blocked) reads as idle, not foot-sliding.
Vector3 horizontalVel = _controller.velocity;
horizontalVel.y = 0f;
SetAnimatorRunning(_controller.isGrounded && horizontalVel.sqrMagnitude > RunningSpeedThresholdSqr);
}
/// <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;
_meshOffsetResidual = 0f; // fresh grab: the climb path owns the mesh, drop any leftover ease
_wallYaw = 0f;
_wallStrafeSign = 0;
_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, the
/// left mouse button + vertical drag leans the mesh out of / into the wall via
/// <see cref="_wallOffset"/> (capsule stays put), and the right
/// mouse button + horizontal drag turns the body 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, bool sprinting, 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)
{
// Hop over the lip only when actually moving up onto the flattened face (a real
// top-out); if we reached it going down, just step off cleanly with no hop.
if (v > 0f) TopOutDetach();
else ExitWallClimb();
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();
// Pick the strafe sign ONCE from the camera when a strafe begins, then hold it.
// Re-deciding it every frame flips wallRight while rounding a curved (cylindrical)
// wall: exactly at 90 deg to the camera the dot crosses zero, so the sideways input
// inverts frame-to-frame and you lock there, jittering back and forth. Latching keeps
// a strafe continuous around the curve; it resets when the strafe input releases so
// the next one re-syncs to the camera.
bool strafing = Mathf.Abs(h) > 1e-4f;
if (!strafing)
_wallStrafeSign = 0;
else if (_wallStrafeSign == 0)
_wallStrafeSign = (cameraTransform && Vector3.Dot(cameraTransform.right, wallRight) < 0f) ? -1 : 1;
if (_wallStrafeSign < 0)
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 + vertical drag sets the lean offset (mouse up = out from the surface, mouse
// down = into it; the offset is signed and moves only the visual mesh, so it can sink into
// the wall where the capsule can't); right button + horizontal drag turns the body (wall
// only). The offset eases back to zero while moving and never detaches, the turn is held
// until leaving the wall. Either drag pauses the camera orbit.
bool offsetDrag = found && IsLeftMouseHeld();
bool yawDrag = found && IsRightMouseHeld();
if (offsetDrag || yawDrag)
{
_offsetDragging = true;
Vector2 drag = ReadMouseDelta();
if (offsetDrag)
_wallOffset = Mathf.Clamp(_wallOffset - drag.y * wallOffsetSensitivity, -wallMaxInset, wallMaxOffset);
if (yawDrag)
_wallYaw += drag.x * wallRotateSensitivity;
}
if (!offsetDrag && hasMoveInput)
{
_wallOffset = Mathf.MoveTowards(_wallOffset, 0f, wallOffsetReturnSpeed * dt);
}
ApplyMeshOffset(_wallOffset);
float climbSpeed = moveSpeed * wallClimbSpeedMultiplier;
if (sprinting) climbSpeed *= sprintSpeedMultiplier;
Vector3 velocity = Vector3.zero;
if (alongSurface.sqrMagnitude > 1e-6f)
velocity += alongSurface.normalized * climbSpeed;
if (found)
{
// Smoothly hold the capsule one skin-width off the surface (flush): the lean offset no
// longer moves the capsule (only the visual mesh), so it always rests against the wall.
// 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 spikes.
const float maxPerpSpeed = 8f;
float gapError = _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 back to normal grounded movement. The top-out hop
// (carry over the lip) fires ONLY when topping out upward over an edge (v > 0). Arriving
// at the floor by descending, OR pressing down/away (S, v < 0) at the wall's base while
// grounded, just lets go cleanly with no hop -- the latter is the only detach path that
// doesn't require having left the ground first, so a wall grabbed at its base can be
// released by pressing away. We test 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 && v > 0f)
{
TopOutDetach(); // topped out upward onto walkable ground: hop over the lip (holding W carries on)
return;
}
else if (_climbLeftGround || v < 0f)
{
ExitWallClimb(); // descended to the floor, or pulling down/away at the base: let go, no hop
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>
/// Offsets only the visual model (mesh + its baked paint collider) along the wall normal by the
/// given signed distance (+ = out from the wall, - = into it), relative to the model's authored
/// local position. The CharacterController capsule is untouched, so the lean can sink into the
/// wall where the capsule would be blocked. Applied in the player's local space so it tracks the
/// body yaw; offset 0 restores the authored pose.
/// </summary>
private void ApplyMeshOffset(float offset) => SetMeshLean(_wallNormal, offset);
/// <summary>
/// Places the visual model at the given signed lean along <paramref name="dir"/> (world space),
/// relative to its authored local pose. Local-space conversion tracks the live body yaw.
/// </summary>
private void SetMeshLean(Vector3 dir, float offset)
{
if (!meshOffsetTarget) return;
Vector3 worldOffset = dir * offset;
meshOffsetTarget.localPosition = _meshBaseLocalPos + transform.InverseTransformVector(worldOffset);
}
/// <summary>
/// Eases the leftover lean back to the authored pose after leaving the wall, so the model glides
/// home instead of snapping. Runs every non-climbing frame at the same rate as the in-climb
/// return; the direction is the wall normal latched at exit (a fresh grab clears the residual).
/// </summary>
private void EaseMeshOffsetToRest(float dt)
{
if (_meshOffsetResidual == 0f) return;
_meshOffsetResidual = Mathf.MoveTowards(_meshOffsetResidual, 0f, wallOffsetReturnSpeed * dt);
SetMeshLean(_meshOffsetExitNormal, _meshOffsetResidual);
}
/// <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 * wallMantleSpeedMultiplier;
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;
// Hand the current lean off to the residual so it eases home (EaseMeshOffsetToRest)
// instead of snapping; latch the normal since the live one may shift after detaching.
_meshOffsetResidual = _wallOffset;
_meshOffsetExitNormal = _wallNormal;
_wallOffset = 0f;
_wallYaw = 0f;
_wallStrafeSign = 0;
_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
}
bool IsRightMouseHeld()
{
#if ENABLE_INPUT_SYSTEM
return Mouse.current != null && Mouse.current.rightButton.isPressed;
#else
return Input.GetMouseButton(1);
#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>Drives the animator's ground-run bool (null-safe if no animator is assigned).</summary>
private void SetAnimatorRunning(bool running)
{
if (animator)
animator.SetBool(_runningHash, running);
}
/// <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;
}
}
}