In an inner wall/ceiling corner the body's facing flipped ~9 deg every frame. Recorded frame data showed the wall/ceiling classification flipping 160 times: _wallNormal.y swung the full range (0.0 wall <-> -0.966 ceiling) each frame, far past the hysteresis band, so the Schmitt trigger could not hold it. Root cause: _wallNormal had two competing per-frame sources. - The contact-normal blend (Update) mixes ceiling and wall contacts in the corner into ~(0,-0.97,-0.26), classified as ceiling. - ProbeWall raycasts along -_wallNormal, which tilts toward the wall, so the ray clips the wall and returns (0,0,-1), classified as wall. Wall and ceiling drive different rotation targets (snap to face-wall vs. face movement direction), producing the per-frame twitch. Fix: - The contact blend no longer overwrites _wallNormal during an active climb (it only seeds the initial grab); the probes own the normal while climbing. - Surface-aware probing: on a ceiling, probe straight up (ProbeCeiling) instead of along -_wallNormal, so the angled ray can no longer clip the wall and demote us. Stay latched on the ceiling until it is genuinely gone overhead, then fall back to the wall probe. Also retains the ceiling-class hysteresis (Schmitt trigger on the normal's downward tilt) from the prior attempt as added stability. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1149 lines
52 KiB
C#
1149 lines
52 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.
|
|
///
|
|
/// On the ground the same mouse scheme fine-tunes a pose without moving the capsule: holding the
|
|
/// left mouse button and dragging up/down raises/lowers the visual mesh, holding the right mouse
|
|
/// button and dragging left/right turns the body. The raise eases back while walking and holds while
|
|
/// standing; the turn holds until movement re-aims the body. Both pause the camera orbit while held.
|
|
///
|
|
/// 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 = 2f;
|
|
|
|
[Tooltip("Speed multiplier applied to ground movement and wall/ceiling climbing while the sprint key (Left Shift) is held.")]
|
|
public float sprintSpeedMultiplier = 3.2f;
|
|
|
|
[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;
|
|
|
|
[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 = 540f;
|
|
|
|
[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 = -14.715f;
|
|
|
|
[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 = 60f;
|
|
|
|
[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 = 1.5f;
|
|
|
|
[Tooltip("Farthest the mesh can lean out from the wall via the mouse-drag offset (units).")]
|
|
public float wallMaxOffset = 0.1f;
|
|
|
|
[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 = 0.2f;
|
|
|
|
[Tooltip("Units the lean-out offset changes per pixel of vertical mouse drag (left button held).")]
|
|
public float wallOffsetSensitivity = 0.001f;
|
|
|
|
[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.02f;
|
|
|
|
[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 = 0.3f;
|
|
|
|
[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;
|
|
|
|
[Tooltip("Camera follow/look target (what the ThirdPersonCamera tracks). Moved vertically by the active pose's camera y-offset plus the ceiling offset, without moving the body. Auto-resolved from a child named \"CameraTarget\" if empty.")]
|
|
[SerializeField] private Transform cameraTarget;
|
|
|
|
/// <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;
|
|
|
|
/// <summary>
|
|
/// True while the player is idle (under control and essentially still): standing on the floor
|
|
/// below the run threshold, or attached to a wall/ceiling with no climb input. Snapshotted at the
|
|
/// end of Update so a sibling reads a stable, order-independent value.
|
|
/// </summary>
|
|
public bool IsIdle => _isIdle;
|
|
|
|
/// <summary>True while the sprint key is held, in any state (ground, air, wall, or ceiling).</summary>
|
|
public bool IsSprinting => _sprinting;
|
|
|
|
/// <summary>True while stuck to a ceiling (wall-climb mode with a downward-facing surface normal).</summary>
|
|
public bool IsOnCeiling => _isWallClimbing && _onCeiling;
|
|
|
|
/// <summary>
|
|
/// True while grounded and actually moving above the run threshold (the ground-locomotion state).
|
|
/// Airborne, wall/ceiling, and standing still all read false. This is the same signal that drives
|
|
/// the run bool, exposed so the pose system can pick its walk/run locomotion clips.
|
|
/// </summary>
|
|
public bool IsMovingOnGround => _movingOnGround;
|
|
|
|
/// <summary>
|
|
/// Set by <see cref="PlayerPoseController"/> while a pose Playable graph owns the Animator output.
|
|
/// While set, the run-bool is not poked: it would be moot (the graph overrides it) and Unity would
|
|
/// log "Animator is not playing an AnimatorController" every frame.
|
|
/// </summary>
|
|
public bool AnimatorOverridden { get; set; }
|
|
|
|
/// <summary>
|
|
/// The visual-model transform moved by the wall-lean / ceiling offset; its localPosition is owned
|
|
/// solely by <see cref="SetMeshLean"/>. Exposed so <see cref="PlayerPoseController"/> can exclude it
|
|
/// from its per-frame rest reset — with the humanoid rig the Animator sits on this same node, so it
|
|
/// lands in the reset subtree and would otherwise be snapped back to base every frame, wiping the offset.
|
|
/// </summary>
|
|
public Transform MeshOffsetTarget => meshOffsetTarget;
|
|
|
|
/// <summary>Authored CharacterController capsule center Y, captured in Awake. The per-pose capsule
|
|
/// (driven by <see cref="PlayerPoseController"/>) eases back to this when no pose overrides it.</summary>
|
|
public float BaseCapsuleCenterY { get; private set; }
|
|
|
|
/// <summary>Authored CharacterController capsule radius, captured in Awake (see <see cref="BaseCapsuleCenterY"/>).</summary>
|
|
public float BaseCapsuleRadius { get; private set; }
|
|
|
|
/// <summary>Authored CharacterController capsule height, captured in Awake (see <see cref="BaseCapsuleCenterY"/>).</summary>
|
|
public float BaseCapsuleHeight { get; private set; }
|
|
|
|
// 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 float _verticalVelocity;
|
|
private Vector3 _horizontalVelocity; // smoothed horizontal move velocity (accel/decel ramp)
|
|
private bool _sprinting; // sprint key state this frame (exposed via IsSprinting)
|
|
private bool _isIdle; // snapshot: player essentially still this frame (exposed via IsIdle)
|
|
private bool _movingOnGround; // snapshot: grounded & moving above the run threshold (exposed via IsMovingOnGround)
|
|
private float _groundOffset; // signed vertical mesh raise from the ground mouse pose-adjust (+ up / - down); folded into SetMeshLean
|
|
|
|
// 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;
|
|
// Hysteresis band around the threshold: in the inner wall/ceiling corner the capsule touches
|
|
// both surfaces, so the per-frame contact normal mixes horizontal (wall) and downward (ceiling)
|
|
// and its y crosses -0.5 back and forth -- flipping the climb mode and snapping the body's yaw
|
|
// between "face the wall" and "face the move direction". Latch the class and only flip when the
|
|
// normal is unambiguously past one edge of the band.
|
|
private const float CeilingEnterThreshold = -0.6f; // wall -> ceiling only when clearly down-facing
|
|
private const float CeilingExitThreshold = -0.4f; // ceiling -> wall only when clearly not down-facing
|
|
private bool _onCeiling; // latched climb-surface class (hysteretic; see thresholds above)
|
|
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 Vector3 _cameraTargetBaseLocalPos; // cameraTarget's authored local position; the pose/ceiling camera offset is applied relative to this
|
|
private float _cameraYOffset; // last camera-target local-up offset written (cached so an unchanged value skips the transform write)
|
|
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>();
|
|
BaseCapsuleCenterY = _controller.center.y;
|
|
BaseCapsuleRadius = _controller.radius;
|
|
BaseCapsuleHeight = _controller.height;
|
|
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;
|
|
|
|
if (!cameraTarget)
|
|
{
|
|
Transform t = transform.Find("CameraTarget");
|
|
if (t) cameraTarget = t;
|
|
}
|
|
if (cameraTarget) _cameraTargetBaseLocalPos = cameraTarget.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
|
|
}
|
|
|
|
_sprinting = sprinting;
|
|
|
|
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 from contacts ONLY while not yet climbing (the grab needs it).
|
|
// During a climb the probes own _wallNormal: the per-frame contact blend mixes ceiling
|
|
// and wall normals in an inner corner (e.g. ~(0,-0.97,-0.26)), which would fight the
|
|
// probed surface and flip the wall/ceiling classification frame to frame. Reset the
|
|
// accumulators regardless (OnControllerColliderHit refills them during Move).
|
|
if (!_isWallClimbing && _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
|
|
_isIdle = ControlEnabled && (h * h + v * v) <= 1e-4f; // attached & no climb input = holding a pose
|
|
_movingOnGround = false; // on a wall/ceiling, never ground locomotion
|
|
return;
|
|
}
|
|
|
|
// Ground pose-adjust (mouse): left button + vertical drag raises/lowers the mesh, right
|
|
// button + horizontal drag turns the body -- the wall scheme mirrored to the ground. Either
|
|
// pauses the camera orbit so the same drag isn't also spent orbiting. Gated to player control
|
|
// so it never fights painting (paint mode owns the left button).
|
|
bool groundYawDrag = false;
|
|
if (ControlEnabled)
|
|
groundYawDrag = HandleGroundPoseAdjust(h, v, Time.deltaTime);
|
|
else
|
|
_offsetDragging = false;
|
|
if (cameraOrbit)
|
|
cameraOrbit.LookSuppressed = _offsetDragging;
|
|
|
|
// Glide the leftover wall lean home and re-apply the composed mesh offset (residual lean +
|
|
// ground raise + pose y-offset) in this frame's single write of meshOffsetTarget.
|
|
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;
|
|
|
|
// Face the movement direction, unless the right-button turn is actively steering the body.
|
|
if (rotateTowardMovement && !groundYawDrag && 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;
|
|
bool movingOnGround = _controller.isGrounded && horizontalVel.sqrMagnitude > RunningSpeedThresholdSqr;
|
|
_movingOnGround = movingOnGround;
|
|
_isIdle = ControlEnabled && _controller.isGrounded && !movingOnGround; // standing still on the floor
|
|
}
|
|
|
|
/// <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;
|
|
_onCeiling = _wallNormal.y < CeilingNormalThreshold;
|
|
_lostContactTimer = 0f;
|
|
_verticalVelocity = 0f;
|
|
_slideSpeed = 0f;
|
|
_wallOffset = 0f;
|
|
_groundOffset = 0f; // drop any ground pose-adjust raise so it doesn't ride onto the wall
|
|
_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 unless the right mouse button + horizontal
|
|
/// drag turns it; 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;
|
|
}
|
|
|
|
UpdateCeilingClass();
|
|
bool onCeiling = _onCeiling;
|
|
|
|
// 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;
|
|
onCeiling = true;
|
|
_wallOffset = 0f;
|
|
}
|
|
|
|
// Re-measure the surface each frame, probing the kind we're committed to. On a ceiling
|
|
// probe straight UP (ProbeCeiling), NOT along -_wallNormal: the ceiling normal tilts a
|
|
// little toward the wall in an inner corner, so the -_wallNormal ray would clip the wall
|
|
// and demote us to "wall" — then the hand-off re-promotes next frame, flipping the body's
|
|
// facing ~9 deg every frame (the corner twitch). Stay latched on the ceiling until it is
|
|
// genuinely gone overhead; only then fall back to the wall probe.
|
|
bool found;
|
|
float gap;
|
|
if (onCeiling)
|
|
{
|
|
found = ProbeCeiling(out Vector3 ceilNormal2, out float ceilGap);
|
|
if (found)
|
|
{
|
|
_wallNormal = ceilNormal2;
|
|
gap = ceilGap;
|
|
_lostContactTimer = 0f;
|
|
}
|
|
else
|
|
{
|
|
found = ProbeWall(out Vector3 wallNormal, out gap);
|
|
if (found)
|
|
{
|
|
_wallNormal = wallNormal;
|
|
_lostContactTimer = 0f;
|
|
}
|
|
UpdateCeilingClass();
|
|
onCeiling = _onCeiling;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
found = ProbeWall(out Vector3 probedNormal, out gap);
|
|
if (found)
|
|
{
|
|
_wallNormal = probedNormal;
|
|
_lostContactTimer = 0f;
|
|
UpdateCeilingClass();
|
|
onCeiling = _onCeiling;
|
|
}
|
|
}
|
|
|
|
// 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. The
|
|
// offset eases back to zero while moving and never detaches. On a WALL the turn is a held
|
|
// yaw offset from the wall normal (kept until leaving); on a CEILING the body is free like
|
|
// on the ground, so the drag rotates the transform directly. Either drag pauses the 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)
|
|
{
|
|
if (onCeiling)
|
|
transform.Rotate(0f, drag.x * wallRotateSensitivity, 0f, Space.World);
|
|
else
|
|
_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 -- unless
|
|
// the right-button turn is actively steering the body (then the manual yaw holds).
|
|
if (!yawDrag)
|
|
{
|
|
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;
|
|
transform.rotation = Quaternion.LookRotation(dir, Vector3.up);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the latched wall/ceiling classification with hysteresis on the surface normal's
|
|
/// downward tilt. In the inner corner the live normal jitters across the single threshold; the
|
|
/// band keeps the climb mode (and the body yaw it drives) from flipping frame to frame.
|
|
/// </summary>
|
|
private void UpdateCeilingClass()
|
|
{
|
|
if (_onCeiling)
|
|
{
|
|
if (_wallNormal.y > CeilingExitThreshold) _onCeiling = false;
|
|
}
|
|
else if (_wallNormal.y < CeilingEnterThreshold)
|
|
{
|
|
_onCeiling = true;
|
|
}
|
|
}
|
|
|
|
/// <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)
|
|
+ Vector3.up * _groundOffset; // ground mouse raise (0 unless in use)
|
|
}
|
|
|
|
/// <summary>
|
|
/// Re-applies the resting mesh offset every non-climbing frame in a single write of
|
|
/// <see cref="meshOffsetTarget"/>: eases the leftover wall lean back toward the authored pose
|
|
/// (latched exit normal) while composing in the ground mouse raise and any pose y-offset. Writing
|
|
/// unconditionally (even at rest) keeps this the sole owner of the offset transform, so a held
|
|
/// ground raise stays applied after the residual has reached zero. Offset 0 restores the pose.
|
|
/// </summary>
|
|
private void EaseMeshOffsetToRest(float dt)
|
|
{
|
|
if (_meshOffsetResidual != 0f)
|
|
_meshOffsetResidual = Mathf.MoveTowards(_meshOffsetResidual, 0f, wallOffsetReturnSpeed * dt);
|
|
SetMeshLean(_meshOffsetExitNormal, _meshOffsetResidual);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ground pose-adjust via the mouse, mirroring the wall-climb scheme: holding the LEFT button and
|
|
/// dragging up/down raises/lowers the visual mesh vertically (<see cref="_groundOffset"/>, capsule
|
|
/// untouched), holding the RIGHT button and dragging left/right turns the body (world-up yaw). The
|
|
/// raise eases back to the authored height while moving and holds while standing or dragging; the
|
|
/// turn persists until movement re-aims the body. Returns true while a right-button turn is active
|
|
/// so the caller skips auto-rotate-toward-movement that frame. Call site gates on ControlEnabled so
|
|
/// paint mode keeps the left button for painting.
|
|
/// </summary>
|
|
private bool HandleGroundPoseAdjust(float h, float v, float dt)
|
|
{
|
|
_offsetDragging = false;
|
|
|
|
bool offsetDrag = IsLeftMouseHeld();
|
|
bool yawDrag = IsRightMouseHeld();
|
|
bool hasMoveInput = (h * h + v * v) > 1e-4f;
|
|
|
|
if (offsetDrag || yawDrag)
|
|
{
|
|
_offsetDragging = true;
|
|
Vector2 drag = ReadMouseDelta();
|
|
if (offsetDrag)
|
|
_groundOffset = Mathf.Clamp(
|
|
_groundOffset + drag.y * wallOffsetSensitivity, -wallMaxInset, wallMaxOffset);
|
|
if (yawDrag)
|
|
transform.Rotate(0f, drag.x * wallRotateSensitivity, 0f, Space.World);
|
|
}
|
|
|
|
// The raise eases home only while actually moving; standing still holds it for posing.
|
|
if (!offsetDrag && hasMoveInput)
|
|
_groundOffset = Mathf.MoveTowards(_groundOffset, 0f, wallOffsetReturnSpeed * dt);
|
|
|
|
return yawDrag;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resizes the CharacterController capsule (center Y, radius, height) to the active pose's dimensions,
|
|
/// driven by <see cref="PlayerPoseController"/> so a low pose can fit where the default standing capsule
|
|
/// is too tall. Only the collision capsule changes — the visual model and camera target are untouched.
|
|
/// Cached so an unchanged value skips the write (a resize re-bakes the capsule internally).
|
|
/// </summary>
|
|
public void SetCapsule(float centerY, float radius, float height)
|
|
{
|
|
Vector3 c = _controller.center;
|
|
if (c.y == centerY && _controller.radius == radius && _controller.height == height) return;
|
|
c.y = centerY;
|
|
_controller.center = c;
|
|
_controller.radius = radius;
|
|
_controller.height = height;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Places the camera target (the ThirdPersonCamera's follow/look target) at the given local-up
|
|
/// offset relative to its authored local position, without moving the body. Driven by
|
|
/// <see cref="PlayerPoseController"/> with the active pose's camera y-offset plus the ceiling offset;
|
|
/// 0 restores the authored target height. Cached so an unchanged value skips the write.
|
|
/// </summary>
|
|
public void SetCameraTargetLocalYOffset(float y)
|
|
{
|
|
if (!cameraTarget || _cameraYOffset == y) return;
|
|
_cameraYOffset = y;
|
|
cameraTarget.localPosition = _cameraTargetBaseLocalPos + Vector3.up * y;
|
|
}
|
|
|
|
/// <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;
|
|
_onCeiling = 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>
|
|
/// 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;
|
|
}
|
|
}
|
|
}
|