The center re-probe could miss the still-touching climbed face and adopt a hit on a different collider much farther out, flipping the wall normal and letting the standoff servo drag the capsule across the pinch every other frame. When the probed gap is well beyond the standoff and the full-capsule span still holds a steep surface meaningfully closer, keep that contact instead of defecting to the far hit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1679 lines
76 KiB
C#
1679 lines
76 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, and a wall/ceiling-climb mode. Wall-climb is entered by pushing
|
|
/// the move input into a wall: W/S climb up/down, A/D strafe, the body faces the wall; left-drag
|
|
/// leans the visual mesh, right-drag turns the body. Jumping detaches, the top edge mantles over,
|
|
/// climbing into an overhang hands off to the ceiling (upright, camera-relative crawl), and small
|
|
/// steps on the climbed surface are passed over (wallStepOffset, the climb's ground-step analogue).
|
|
/// Named PlayerCharacterController to avoid a clash with Unity.VisualScripting's "PlayerController".
|
|
/// </summary>
|
|
[RequireComponent(typeof(CharacterController))]
|
|
[DisallowMultipleComponent]
|
|
public 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;
|
|
|
|
[Tooltip("Downward speed (units/second) past which the player counts as falling, for the fall " +
|
|
"animation. Only while airborne (not grounded, not wall-climbing). Scales with player size.")]
|
|
public float fallAnimationSpeed = 4f;
|
|
|
|
[Header("Rescue")]
|
|
[Tooltip("Kill-plane fail-safe: if the player falls below this world Y (e.g. off the map into the void), " +
|
|
"they are teleported back to where this avatar spawned (spawn center + per-client ring offset) " +
|
|
"with all momentum cleared. Owner-only; the authoritative NetworkTransform replicates the reset.")]
|
|
public float rescueMinY = -100f;
|
|
|
|
[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("How quickly wall/ceiling climb movement ramps up to speed (units/second²). Kept low so brief " +
|
|
"taps nudge only a little for precise placement; scaled up while sprinting so sprint stays snappy.")]
|
|
[Min(0.01f)]
|
|
public float wallClimbAcceleration = 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("How long (seconds) the top-edge signature (probe clear, wall still at the feet, clear headroom above, standable top ahead) must persist before committing to a top-out mantle. Filters transient probe misses so they never trigger a mantle mid-face. Keep small; a couple of frames is enough.")]
|
|
public float wallTopOutConfirm = 0.06f;
|
|
|
|
[Tooltip("Largest ledge/step on the climbed surface (units, measured along the surface normal) that climbing passes over — the wall/ceiling counterpart of the CharacterController's ground Step Offset. When along-surface movement is blocked by a small trim/board/beam, the capsule lifts off the surface just enough to clear it and continues; 0 disables.")]
|
|
[Min(0f)]
|
|
public float wallStepOffset = 0.1f;
|
|
|
|
[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("Colliders with this tag are invisible to wall-climb and ceiling-grab: they still block the capsule physically, but can never be grabbed, re-probed onto, or used as a ceiling. Leave empty to disable.")]
|
|
public string noClimbTag = "NoClimb";
|
|
|
|
[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>When false, gravity, ground-stick and slope-sliding are suspended so the avatar
|
|
/// hovers at its spawn position. Set by the GameModeManager during the pre-Hide loading barrier.</summary>
|
|
public bool GravityEnabled { get; set; } = true;
|
|
|
|
/// <summary>When set, the body faces this world-space yaw (degrees) every frame instead of turning
|
|
/// toward its movement direction (first-person). Only affects ground/air locomotion — wall-climb
|
|
/// owns its own facing.</summary>
|
|
public float? FaceYaw { get; set; }
|
|
|
|
/// <summary>True while in wall-climb mode.</summary>
|
|
public bool IsWallClimbing => _isWallClimbing;
|
|
|
|
/// <summary>True while the player is idle (under control and essentially still). 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.</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 moving above the run threshold; airborne / wall / standing
|
|
/// all read false. The pose system picks its walk/run clips off this.</summary>
|
|
public bool IsMovingOnGround => _movingOnGround;
|
|
|
|
/// <summary>True while airborne (not wall-climbing) and descending faster than
|
|
/// <see cref="fallAnimationSpeed"/> (scaled by player size).</summary>
|
|
public bool IsFalling => _isFalling;
|
|
|
|
/// <summary>
|
|
/// Set by <see cref="PlayerPoseController"/> while a pose Playable graph owns the Animator output.
|
|
/// While set, the run-bool is not poked: 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 (the Animator sits on this same node and would otherwise wipe
|
|
/// the offset every frame).
|
|
/// </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.</summary>
|
|
public float BaseCapsuleRadius { get; private set; }
|
|
|
|
/// <summary>Authored CharacterController capsule height, captured in Awake.</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 NetworkPlayerSetup _setup; // sibling role source; null on non-networked test instances
|
|
private float _verticalVelocity;
|
|
private Vector3 _horizontalVelocity; // smoothed horizontal move velocity
|
|
private Vector3 _climbVelocity; // smoothed along-surface climb velocity
|
|
private bool _sprinting;
|
|
private bool _isIdle;
|
|
private bool _movingOnGround;
|
|
private bool _isFalling;
|
|
private float _groundOffset; // signed vertical mesh raise from the ground mouse pose-adjust
|
|
private Vector3 _spawnPosition; // rescue (kill-plane) teleport target
|
|
private bool _spawnCaptured; // false until the first Update (after OnNetworkSpawn's ring offset has landed)
|
|
|
|
private Vector3 _groundNormal = Vector3.up;
|
|
private bool _groundNormalUpdated;
|
|
private float _slideSpeed;
|
|
|
|
// A climb surface counts as a ceiling once its normal points down past this threshold; the
|
|
// enter/exit band is hysteretic because in an inner wall/ceiling corner the per-frame contact
|
|
// normal crosses a single threshold back and forth, flipping the climb mode and body yaw.
|
|
private const float CeilingNormalThreshold = -0.5f;
|
|
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)
|
|
private bool _isWallClimbing;
|
|
private Vector3 _wallNormal;
|
|
private Vector3 _wallNormalAccum; // summed wall normals during the latest Move
|
|
private bool _wallContactThisFrame;
|
|
private float _lostContactTimer;
|
|
private float _rimConfirmTimer; // debounces the bare-rim vault so a transient mid-face edge graze can't top out
|
|
private const float StepPassServoHold = 0.25f; // seconds the standoff servo skips pulling in after a step-pass lift
|
|
private float _stepPassTimer; // holds a step-pass lift so the servo doesn't pull straight back into the ledge
|
|
private float _wallOffset; // signed mesh lean (mouse drag); moves only the model, not the capsule
|
|
private float _wallYaw; // body yaw offset from facing the wall (mouse drag)
|
|
private int _wallStrafeSign; // latched strafe direction; 0 = unlatched
|
|
private int _wallClimbSign; // latched up/down direction; 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
|
|
private Vector3 _meshBaseLocalPos;
|
|
private Vector3 _cameraTargetBaseLocalPos;
|
|
private float _cameraYOffset; // cached so an unchanged value skips the transform write
|
|
private float _meshOffsetResidual; // leftover mesh lean still easing back to 0 after leaving the wall
|
|
private Vector3 _meshOffsetExitNormal; // wall normal latched at exit (the live one may shift after detaching)
|
|
private readonly RaycastHit[] _wallProbe = new RaycastHit[16];
|
|
private bool _wallProbeSaturated; // log-once: a full buffer can hide the nearest valid hit
|
|
|
|
// Mantle (top-out) state.
|
|
private const float TopOutRegrabCooldown = 0.3f; // blocks re-grab right after a top-out (no 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;
|
|
|
|
void Awake()
|
|
{
|
|
_controller = GetComponent<CharacterController>();
|
|
_setup = GetComponent<NetworkPlayerSetup>();
|
|
BaseCapsuleCenterY = _controller.center.y;
|
|
BaseCapsuleRadius = _controller.radius;
|
|
BaseCapsuleHeight = _controller.height;
|
|
if (!cameraTransform && Camera.main)
|
|
cameraTransform = Camera.main.transform;
|
|
if (!cameraOrbit)
|
|
cameraOrbit = FindFirstObjectByType<ThirdPersonOrbitInput>();
|
|
|
|
// Walk up from the skinned mesh to the topmost node under this transform so the offset root
|
|
// 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()
|
|
{
|
|
// Kill-plane fail-safe; runs ahead of the control gate so it rescues in any mode.
|
|
if (!_spawnCaptured)
|
|
{
|
|
_spawnPosition = transform.position;
|
|
_spawnCaptured = true;
|
|
}
|
|
else if (transform.position.y < rescueMinY)
|
|
{
|
|
RescueToSpawn();
|
|
return;
|
|
}
|
|
|
|
float h = 0f, v = 0f;
|
|
bool jumpPressed = false;
|
|
bool sprinting = false;
|
|
|
|
// Skip input when control is disabled (e.g. paint mode); gravity and grounding still run.
|
|
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)
|
|
{
|
|
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: contact normals mix wall and ceiling in inner
|
|
// corners and would flip the classification frame to frame.
|
|
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: keep the grip 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;
|
|
_isIdle = ControlEnabled && (h * h + v * v) <= 1e-4f;
|
|
_movingOnGround = false;
|
|
_isFalling = false;
|
|
return;
|
|
}
|
|
|
|
// Ground pose-adjust (the wall mouse scheme mirrored to the ground). 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;
|
|
|
|
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
|
|
&& GravityEnabled
|
|
&& _controller.isGrounded
|
|
&& slopeAngle > _controller.slopeLimit + 0.01f;
|
|
|
|
if (!GravityEnabled)
|
|
{
|
|
_verticalVelocity = 0f;
|
|
}
|
|
else
|
|
{
|
|
if (_controller.isGrounded)
|
|
{
|
|
if (_verticalVelocity < 0f)
|
|
_verticalVelocity = groundedStickVelocity;
|
|
|
|
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;
|
|
}
|
|
else
|
|
{
|
|
_slideSpeed = 0f;
|
|
}
|
|
|
|
float currentSpeed = sprinting ? moveSpeed * sprintSpeedMultiplier : moveSpeed;
|
|
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.
|
|
bool wasGrounded = _controller.isGrounded;
|
|
_groundNormalUpdated = false;
|
|
_controller.Move(velocity * Time.deltaTime);
|
|
if (!_groundNormalUpdated)
|
|
_groundNormal = Vector3.up;
|
|
|
|
// Walked over a ledge (not sprinting, not jumping): grab the ledge's face and climb instead
|
|
// of falling. Sprint deliberately opts out so running off an edge still drops.
|
|
if (wasGrounded && !_controller.isGrounded && !sprinting && _verticalVelocity <= 0f
|
|
&& GravityEnabled && enableWallClimb && ControlEnabled && _reentryCooldown <= 0f
|
|
&& !WallClimbBlockedNow() && moveDir.sqrMagnitude > 1e-4f
|
|
&& TryLedgeGrab(moveDir))
|
|
{
|
|
_horizontalVelocity = Vector3.zero;
|
|
_isIdle = false;
|
|
_movingOnGround = false;
|
|
_isFalling = false;
|
|
return;
|
|
}
|
|
|
|
if (FaceYaw.HasValue)
|
|
{
|
|
transform.rotation = Quaternion.Euler(0f, FaceYaw.Value, 0f);
|
|
}
|
|
else if (rotateTowardMovement && !groundYawDrag && moveDir.sqrMagnitude > 0.0001f)
|
|
{
|
|
Quaternion target = Quaternion.LookRotation(moveDir, Vector3.up);
|
|
transform.rotation = Quaternion.RotateTowards(
|
|
transform.rotation, target, rotationSpeed * Time.deltaTime);
|
|
}
|
|
|
|
// 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;
|
|
_isFalling = !_controller.isGrounded && _verticalVelocity < -fallAnimationSpeed * CapsuleScale;
|
|
}
|
|
|
|
/// <summary>Kill-plane rescue: snap back to the captured spawn point and clear all momentum.</summary>
|
|
private void RescueToSpawn()
|
|
{
|
|
CharacterTeleport.Move(_controller, transform, _spawnPosition);
|
|
|
|
_verticalVelocity = 0f;
|
|
_horizontalVelocity = Vector3.zero;
|
|
_climbVelocity = Vector3.zero;
|
|
_slideSpeed = 0f;
|
|
}
|
|
|
|
// Hunters may not grab a wall during the hide phase; every other role/phase climbs normally.
|
|
private bool WallClimbBlockedNow()
|
|
{
|
|
var match = MatchManager.Instance;
|
|
return match && match.LocalSubPhase == MatchManager.MatchSubPhase.Hide
|
|
&& _setup && _setup.Role == PlayerRole.Hunter;
|
|
}
|
|
|
|
/// <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 || WallClimbBlockedNow())
|
|
{
|
|
if (_isWallClimbing)
|
|
ExitWallClimb();
|
|
return;
|
|
}
|
|
|
|
// A wall is grabbed by pushing horizontally into it while not rising; a ceiling can't be
|
|
// pushed into horizontally, so it is grabbed by rising up into 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.
|
|
// 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;
|
|
_rimConfirmTimer = 0f;
|
|
_stepPassTimer = 0f;
|
|
_verticalVelocity = 0f;
|
|
_slideSpeed = 0f;
|
|
_wallOffset = 0f;
|
|
_groundOffset = 0f;
|
|
_meshOffsetResidual = 0f;
|
|
_wallYaw = 0f;
|
|
_wallStrafeSign = 0;
|
|
_wallClimbSign = 0;
|
|
_climbLeftGround = false;
|
|
_climbVelocity = Vector3.zero;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attaches to the vertical face of a ledge just walked over instead of falling. The face sits
|
|
/// BELOW the edge, so it is probed with horizontal sphere casts back against the walk direction
|
|
/// at the foot sphere and two bands below it (the capsule hangs level with the ledge top on the
|
|
/// frame it leaves the ground). Its normal must be wall-steep and point roughly along the walk
|
|
/// direction (outward from the ledge) — side walls and slopes never grab. A short drop (walkable
|
|
/// ground within a capsule height under the feet) is a normal walk-off and falls through.
|
|
/// On success the climb state is initialized exactly like a push-in grab, already off-ground.
|
|
/// </summary>
|
|
private bool TryLedgeGrab(Vector3 moveDir)
|
|
{
|
|
Vector3 outDir = moveDir;
|
|
outDir.y = 0f;
|
|
if (outDir.sqrMagnitude < 1e-6f) return false;
|
|
outDir.Normalize();
|
|
|
|
// Short drop: land normally instead of sticking to every step's side.
|
|
if (WalkableBelow(WorldCapsuleCenter + Vector3.up * WorldRadius,
|
|
(_controller.center.y + _controller.height) * CapsuleScale))
|
|
return false;
|
|
|
|
Vector3 dir = -outDir; // back toward the ledge face
|
|
float radius = WorldRadius;
|
|
float maxDist = radius + wallMaxOffset + ProbeReachSlack * CapsuleScale;
|
|
float footY = -(WorldHalfHeight - radius);
|
|
|
|
for (int band = 0; band <= 2; band++)
|
|
{
|
|
Vector3 origin = WorldCapsuleCenter + Vector3.up * (footY - band * radius * 2f);
|
|
int count = Physics.SphereCastNonAlloc(
|
|
origin - dir * radius, radius, dir, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
|
|
WarnIfProbeSaturated(count);
|
|
|
|
float best = float.MaxValue;
|
|
Vector3 bestNormal = Vector3.zero;
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
if (_wallProbe[i].distance <= 0f)
|
|
continue;
|
|
if (IsPlayerCollider(_wallProbe[i].collider) || IsNoClimb(_wallProbe[i].collider))
|
|
continue;
|
|
if (_wallProbe[i].distance < best)
|
|
{
|
|
best = _wallProbe[i].distance;
|
|
bestNormal = _wallProbe[i].normal;
|
|
}
|
|
}
|
|
if (best == float.MaxValue)
|
|
continue;
|
|
// The foot band typically grazes the plateau's rounded top edge (up-tilted normal):
|
|
// skip to the lower bands, which hit the face flat, rather than rejecting outright.
|
|
if (Vector3.Angle(bestNormal, Vector3.up) < wallMinAngle
|
|
|| bestNormal.y < CeilingNormalThreshold
|
|
|| Vector3.Dot(bestNormal, outDir) < 0.3f)
|
|
continue;
|
|
|
|
_isWallClimbing = true;
|
|
_wallNormal = bestNormal;
|
|
_onCeiling = false;
|
|
_lostContactTimer = 0f;
|
|
_rimConfirmTimer = 0f;
|
|
_stepPassTimer = 0f;
|
|
_verticalVelocity = 0f;
|
|
_slideSpeed = 0f;
|
|
_wallOffset = 0f;
|
|
_groundOffset = 0f;
|
|
_meshOffsetResidual = 0f;
|
|
_wallYaw = 0f;
|
|
_wallStrafeSign = 0;
|
|
_wallClimbSign = 0;
|
|
_climbLeftGround = true; // already airborne; also keeps the ledge top from reading as genuine ground
|
|
_climbVelocity = Vector3.zero;
|
|
// Seat the capsule onto the face: at grab it hangs level with the ledge top, where the
|
|
// center probe (and possibly the span) would miss and grace out. One radius down puts
|
|
// the lower cap solidly onto the face; the air just left has nothing to collide with.
|
|
_controller.Move(Vector3.down * radius);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Movement while climbing, walls and ceilings (distinguished by the surface normal). WALL: W/S
|
|
/// climb straight up/down, A/D strafe camera-relative, the body faces the wall. CEILING: upright,
|
|
/// WASD camera-relative in the horizontal plane. The surface is re-probed each frame; a wall's
|
|
/// top edge mantles, climbing 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;
|
|
}
|
|
|
|
if (_stepPassTimer > 0f)
|
|
_stepPassTimer -= dt;
|
|
|
|
UpdateCeilingClass();
|
|
bool onCeiling = _onCeiling;
|
|
|
|
// Map W/S to UP/DOWN by which key pushes INTO the wall (facing the wall that's W; with the
|
|
// camera on the wall's side it's S). Latched once per press; without the remap the grab
|
|
// (camera-relative) and the base-release (raw S) disagree and the climb flickers.
|
|
bool climbing = Mathf.Abs(v) > 1e-4f;
|
|
if (!climbing)
|
|
_wallClimbSign = 0;
|
|
else if (_wallClimbSign == 0 && !onCeiling)
|
|
{
|
|
Vector3 camFwd = cameraTransform ? cameraTransform.forward : Vector3.zero;
|
|
camFwd.y = 0f;
|
|
_wallClimbSign = (cameraTransform && Vector3.Dot(camFwd, _wallNormal) > 0f) ? -1 : 1;
|
|
}
|
|
float vEff = _wallClimbSign != 0 ? v * _wallClimbSign : v;
|
|
|
|
// Jump detaches: off a wall it pushes straight up, off a ceiling it just lets go.
|
|
if (jumpPressed)
|
|
{
|
|
ExitWallClimb();
|
|
_verticalVelocity = onCeiling ? 0f : Mathf.Sqrt(jumpHeight * -2f * gravity);
|
|
return;
|
|
}
|
|
|
|
// Wall->ceiling hand-off: while climbing up into an overhang, switch to the ceiling right
|
|
// above the head instead of trying to mantle over a (non-existent) top. Gated to clearly
|
|
// down-facing surfaces: a grazed front edge (the next shelf board above) returns a tilted
|
|
// edge normal and must park the climb instead of handing off. A small protruding ledge
|
|
// within the step allowance must NOT hand off either: skipping it lets the upward move
|
|
// block against its underside, and the step pass then carries the climb around its edge
|
|
// (the hand-off runs before ClimbMove, so without this gate it always wins).
|
|
if (!onCeiling && vEff > 0f
|
|
&& ProbeCeiling(out Vector3 ceilNormal, out float ceilHeadGap)
|
|
&& ceilHeadGap <= ceilingGrabDistance
|
|
&& ceilNormal.y < CeilingEnterThreshold
|
|
&& !OverheadIsPassableStep())
|
|
{
|
|
_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, not along -_wallNormal: in an inner corner that ray would clip the wall and
|
|
// demote us to "wall", flipping the body's facing every frame.
|
|
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);
|
|
// A down-facing hit here is never the climbed wall: it is an edge graze (the wall's
|
|
// bottom edge, or the underside of the next shelf board clipped while sweeping past
|
|
// it). Adopting it would latch ceiling mode mid-wall — the wall->ceiling hand-off
|
|
// happens ONLY via the explicit head-gap check above — so treat it as lost contact
|
|
// and let the span probe / grace decide.
|
|
if (found && probedNormal.y < CeilingNormalThreshold)
|
|
{
|
|
found = false;
|
|
}
|
|
else if (found)
|
|
{
|
|
// Pinch between two surfaces: the center band can miss the (still touching) climbed
|
|
// face and catch a DIFFERENT collider much farther out. Adopting that hit flips the
|
|
// normal and the standoff servo drags the capsule across the pinch — oscillating
|
|
// every other frame with the span probe snapping it back. When the full-capsule span
|
|
// still holds a steep surface meaningfully closer, THAT contact is the climbed face:
|
|
// keep it instead of defecting to the far hit.
|
|
if (gap > (_controller.skinWidth + 0.05f) * CapsuleScale
|
|
&& ProbeWallSpan(out Vector3 heldNormal, out float heldGap)
|
|
&& heldGap + 0.05f * CapsuleScale < gap
|
|
&& Vector3.Angle(heldNormal, Vector3.up) >= wallMinAngle
|
|
&& heldNormal.y > CeilingNormalThreshold)
|
|
{
|
|
probedNormal = heldNormal;
|
|
gap = heldGap;
|
|
}
|
|
_wallNormal = probedNormal;
|
|
_lostContactTimer = 0f;
|
|
UpdateCeilingClass();
|
|
onCeiling = _onCeiling;
|
|
}
|
|
}
|
|
|
|
// Topped out onto a slope: the climbed face has flattened into something walkable. The swept
|
|
// sphere ALSO tilts its normal upward as it rolls over a thin board's top edge, faking this —
|
|
// so the flattening is confirmed against the TRUE face normal from a thin ray (a real slope
|
|
// reads shallow there too; a vertical board still reads steep). Even then a genuine stand must
|
|
// exist; otherwise it is a bare rim (thin board / knife edge). At a bare rim, holding UP vaults
|
|
// the body over the edge (rise until the feet clear, then carry forward) — landing on top if
|
|
// there's room, else cresting and dropping the far side; deliberate, because W is held. Without
|
|
// up-input the climb parks: upward motion is clamped below, strafe/down/jump stay live.
|
|
bool bareTopRim = false;
|
|
if (found && !onCeiling && _climbLeftGround
|
|
&& Vector3.Angle(_wallNormal, Vector3.up) < wallMinAngle)
|
|
{
|
|
bool faceIsSlope = RaycastWallFace(out Vector3 faceNormal)
|
|
&& Vector3.Angle(faceNormal, Vector3.up) < wallMinAngle;
|
|
if (faceIsSlope && (_controller.isGrounded || WalkableGroundBelow() || HasStandableTop()))
|
|
{
|
|
if (vEff > 0f) TopOutDetach();
|
|
else ExitWallClimb();
|
|
return;
|
|
}
|
|
|
|
// The swept sphere's normal tilts up while the center crosses the board's top edge and
|
|
// STAYS tilted for the whole crossing — and it self-amplifies, because the next probe
|
|
// casts along the already-tilted normal, diving deeper into the edge. A debounce alone
|
|
// cannot tell that from a genuine rim, and "wall above the head" can't either (a thin
|
|
// board has nothing above in both cases). The full-capsule span CAN: while the board
|
|
// still overlaps the capsule lower down it returns the true steep face — re-adopt that
|
|
// normal (breaking the tilt feedback) and keep climbing. Only when neither the head band
|
|
// nor the span see anything steep is the rim genuine; then it must still persist briefly.
|
|
bool wallAbove = WallContinuesAbove();
|
|
bool steepOnSpan = ProbeWallSpan(out Vector3 rimSpanNormal, out _)
|
|
&& Vector3.Angle(rimSpanNormal, Vector3.up) >= wallMinAngle;
|
|
if (steepOnSpan)
|
|
_wallNormal = rimSpanNormal;
|
|
if (wallAbove || steepOnSpan)
|
|
_rimConfirmTimer = 0f;
|
|
else
|
|
_rimConfirmTimer += dt;
|
|
|
|
if (_rimConfirmTimer >= wallTopOutConfirm)
|
|
{
|
|
// Bare rim confirmed, pushing up: vault over. Headroom-gated so a shelf compartment
|
|
// above still parks (its rise would only stall against the ceiling and time out).
|
|
if (vEff > 0f && HasMantleHeadroom())
|
|
{
|
|
_mantling = true;
|
|
_mantleClearedFeet = false;
|
|
_mantleOverDist = 0f;
|
|
_mantleTimer = 0f;
|
|
MantleOverEdge(dt);
|
|
return;
|
|
}
|
|
bareTopRim = true;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_rimConfirmTimer = 0f;
|
|
}
|
|
|
|
Vector3 alongSurface;
|
|
if (onCeiling)
|
|
{
|
|
alongSurface = Vector3.ProjectOnPlane(moveDir, _wallNormal);
|
|
}
|
|
else
|
|
{
|
|
// W/S climb straight up/down; strafe along the wall's own horizontal tangent, not live
|
|
// camera-right (that coupled movement to the orbit camera's per-frame re-aim).
|
|
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 per frame inverts the input while rounding a curved wall.
|
|
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 * vEff + wallRight * h;
|
|
}
|
|
if (alongSurface.sqrMagnitude > 1f) alongSurface.Normalize();
|
|
// Parked at a bare top rim: nothing may push the capsule higher over an edge that has no
|
|
// surface to stand on (cresting it would only end in a drop).
|
|
if (bareTopRim && alongSurface.y > 0f)
|
|
alongSurface = Vector3.ProjectOnPlane(alongSurface, Vector3.up);
|
|
bool hasMoveInput = (h * h + v * v) > 1e-4f;
|
|
|
|
if (!found)
|
|
{
|
|
_lostContactTimer += dt;
|
|
|
|
// Classify the miss: a top edge has wall at the feet but none above the center.
|
|
bool wallBelow = !onCeiling
|
|
&& ProbeWallAt(-WorldHalfHeight, out Vector3 lowNormal, out _)
|
|
&& lowNormal.y > CeilingNormalThreshold;
|
|
bool atTopEdge = wallBelow && !WallPresentAt(WorldHalfHeight * 0.5f);
|
|
|
|
// Wall top-out: cleared above a genuine ledge (somewhere to stand, room to rise).
|
|
// Confirmed briefly before committing so a transient miss never mantles mid-face.
|
|
if (atTopEdge && alongSurface.y > 0f && HasStandableTop() && HasMantleHeadroom())
|
|
{
|
|
if (_lostContactTimer >= wallTopOutConfirm)
|
|
{
|
|
_mantling = true;
|
|
_mantleClearedFeet = false;
|
|
_mantleOverDist = 0f;
|
|
_mantleTimer = 0f;
|
|
MantleOverEdge(dt);
|
|
return;
|
|
}
|
|
}
|
|
// The center probe only covers its own band of the capsule's height; a thin board
|
|
// (shelf edge) above or below that band still physically holds the capsule, so a
|
|
// full-capsule sweep keeps the climb attached anywhere along the capsule. At a top
|
|
// edge with nothing to stand on (or no room to mantle), upward motion is clamped so
|
|
// the climb parks at the rim instead of cresting into a drop.
|
|
else if (!onCeiling && ProbeWallSpan(out Vector3 spanNormal, out float spanGap))
|
|
{
|
|
found = true;
|
|
gap = spanGap;
|
|
if (Vector3.Angle(spanNormal, Vector3.up) >= wallMinAngle
|
|
&& spanNormal.y > CeilingNormalThreshold)
|
|
_wallNormal = spanNormal;
|
|
_lostContactTimer = 0f;
|
|
if (atTopEdge && alongSurface.y > 0f)
|
|
alongSurface = Vector3.ProjectOnPlane(alongSurface, Vector3.up);
|
|
}
|
|
// Surface genuinely gone (incl. crawling off a ceiling edge): leave after grace.
|
|
else if (_lostContactTimer >= wallExitGrace)
|
|
{
|
|
ExitWallClimb();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Left drag = signed lean (moves only the visual mesh); right drag = body turn.
|
|
// The lean eases home while moving; either drag pauses the camera orbit.
|
|
bool offsetDrag = found && InputUtil.LeftMouseHeld();
|
|
bool yawDrag = found && InputUtil.RightMouseHeld();
|
|
if (offsetDrag || yawDrag)
|
|
{
|
|
_offsetDragging = true;
|
|
Vector2 drag = InputUtil.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);
|
|
}
|
|
SetMeshLean(_wallNormal, _wallOffset);
|
|
|
|
float climbSpeed = moveSpeed * wallClimbSpeedMultiplier;
|
|
if (sprinting) climbSpeed *= sprintSpeedMultiplier;
|
|
// Ramp the along-surface velocity instead of snapping: a brief tap moves only a little.
|
|
// Sprint scales the accel too; releasing decelerates at the ground rate.
|
|
Vector3 targetAlong = alongSurface.sqrMagnitude > 1e-6f
|
|
? alongSurface.normalized * climbSpeed
|
|
: Vector3.zero;
|
|
float climbAccel = targetAlong.sqrMagnitude > 1e-6f
|
|
? wallClimbAcceleration * (sprinting ? sprintSpeedMultiplier : 1f)
|
|
: deceleration;
|
|
_climbVelocity = Vector3.MoveTowards(_climbVelocity, targetAlong, climbAccel * dt);
|
|
Vector3 velocity = _climbVelocity;
|
|
if (found)
|
|
{
|
|
// Servo the capsule to one skin-width off the surface. Exponential follow keeps it
|
|
// framerate-stable; the clamp guards spikes. While a step-pass lift is held, pulling
|
|
// back in is skipped — it would re-block against the ledge every frame.
|
|
float maxPerpSpeed = 8f * CapsuleScale;
|
|
float gapError = _controller.skinWidth * CapsuleScale - gap;
|
|
float follow = 1f - Mathf.Exp(-wallOffsetFollow * dt);
|
|
float correction = Mathf.Clamp(gapError * follow / dt, -maxPerpSpeed, maxPerpSpeed);
|
|
if (_stepPassTimer > 0f)
|
|
correction = Mathf.Max(correction, 0f);
|
|
velocity += _wallNormal * correction;
|
|
}
|
|
|
|
ClimbMove(velocity, alongSurface, dt);
|
|
|
|
// Reaching walkable ground detaches. Probed under the feet rather than trusting isGrounded
|
|
// (the standoff servo can hold the capsule just off the surface). The vEff < 0 release is
|
|
// the only detach that doesn't require having left the ground first, so a wall grabbed at
|
|
// its base can be released by pressing away. A step being passed (wallStepOffset) also
|
|
// reads grounded/walkable off its narrow top; genuine ground is told apart by extending
|
|
// outward from the climbed face — and the upward hop additionally by the wall no longer
|
|
// rising overhead — so a mid-wall step never detaches the climb.
|
|
bool onWalkableGround = _controller.isGrounded || WalkableGroundBelow();
|
|
bool genuineGround = onWalkableGround
|
|
&& (onCeiling || !_climbLeftGround || GroundExtendsOutward());
|
|
if (!onWalkableGround)
|
|
{
|
|
_climbLeftGround = true;
|
|
}
|
|
else if (_climbLeftGround && vEff > 0f)
|
|
{
|
|
if (genuineGround || !WallContinuesAbove())
|
|
{
|
|
TopOutDetach();
|
|
return;
|
|
}
|
|
}
|
|
else if ((_climbLeftGround || vEff < 0f) && genuineGround)
|
|
{
|
|
ExitWallClimb();
|
|
return;
|
|
}
|
|
|
|
if (rotateTowardMovement)
|
|
{
|
|
if (onCeiling)
|
|
{
|
|
// Stay upright and face the horizontal move direction, unless the right-button
|
|
// turn is actively steering the body.
|
|
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.
|
|
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 (hysteretic; see the thresholds).</summary>
|
|
private void UpdateCeilingClass()
|
|
{
|
|
if (_onCeiling)
|
|
{
|
|
if (_wallNormal.y > CeilingExitThreshold) _onCeiling = false;
|
|
}
|
|
else if (_wallNormal.y < CeilingEnterThreshold)
|
|
{
|
|
_onCeiling = true;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Places the visual model at the given signed lean along <paramref name="dir"/> (world space),
|
|
/// relative to its authored local pose; the capsule is untouched, so the lean can sink into the
|
|
/// wall where the capsule would be blocked.
|
|
/// </summary>
|
|
private void SetMeshLean(Vector3 dir, float offset)
|
|
{
|
|
if (!meshOffsetTarget) return;
|
|
Vector3 worldOffset = dir * offset;
|
|
meshOffsetTarget.localPosition = _meshBaseLocalPos
|
|
+ transform.InverseTransformVector(worldOffset)
|
|
+ Vector3.up * _groundOffset;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Re-applies the resting mesh offset every non-climbing frame in a single write: eases the
|
|
/// leftover wall lean home (along the latched exit normal) while composing in the ground raise.
|
|
/// Writing unconditionally keeps this the sole owner of the offset transform.
|
|
/// </summary>
|
|
private void EaseMeshOffsetToRest(float dt)
|
|
{
|
|
if (_meshOffsetResidual != 0f)
|
|
_meshOffsetResidual = Mathf.MoveTowards(_meshOffsetResidual, 0f, wallOffsetReturnSpeed * dt);
|
|
SetMeshLean(_meshOffsetExitNormal, _meshOffsetResidual);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ground pose-adjust, mirroring the wall-climb mouse scheme: LEFT drag raises/lowers the visual
|
|
/// mesh (capsule untouched), RIGHT drag turns the body. Returns true while a right-button turn
|
|
/// is active so the caller skips rotate-toward-movement that frame.
|
|
/// </summary>
|
|
private bool HandleGroundPoseAdjust(float h, float v, float dt)
|
|
{
|
|
_offsetDragging = false;
|
|
|
|
bool offsetDrag = InputUtil.LeftMouseHeld();
|
|
bool yawDrag = InputUtil.RightMouseHeld();
|
|
bool hasMoveInput = (h * h + v * v) > 1e-4f;
|
|
|
|
if (offsetDrag || yawDrag)
|
|
{
|
|
_offsetDragging = true;
|
|
Vector2 drag = InputUtil.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 to the active pose's dimensions, driven by
|
|
/// <see cref="PlayerPoseController"/>. Only the collision capsule changes. 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 at the given local-up offset relative to its authored local position,
|
|
/// without moving the body. Driven by <see cref="PlayerPoseController"/>; 0 restores the authored
|
|
/// 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;
|
|
}
|
|
|
|
// The serialized capsule center/radius/height are authored at scale-1; transform.localScale
|
|
// resizes the actual collision capsule but not those numbers. Every climb probe casts in WORLD
|
|
// space, so it must use the scaled dimensions and scale its distance paddings too. Uniform
|
|
// player scale is enforced by PlayerScaler, so a single lossyScale component suffices.
|
|
private float CapsuleScale => transform.lossyScale.y;
|
|
private Vector3 WorldCapsuleCenter => transform.TransformPoint(_controller.center);
|
|
private float WorldRadius => _controller.radius * CapsuleScale;
|
|
private float WorldHalfHeight => _controller.height * 0.5f * CapsuleScale;
|
|
private const float ProbeReachSlack = 0.5f; // extra cast length beyond the capsule surface (scale-1 units)
|
|
// Forward travel of the mantle's over-the-edge phase; also where HasStandableTop samples.
|
|
private float MantleForwardDistance => WorldRadius * 2f + 0.15f * CapsuleScale;
|
|
|
|
/// <summary>
|
|
/// True if the collider belongs to ANY player avatar (self or a networked remote): avatars still
|
|
/// collide physically, but another chameleon's body must never register as climbable.
|
|
/// </summary>
|
|
private static bool IsPlayerCollider(Collider c) => c.GetComponentInParent<PlayerCharacterController>();
|
|
|
|
/// <summary>
|
|
/// True if the collider is tagged as non-climbable (<see cref="noClimbTag"/>): it blocks the
|
|
/// capsule but is never grabbed, re-probed onto, or treated as a ceiling. Empty tag disables.
|
|
/// </summary>
|
|
private bool IsNoClimb(Collider c) => !string.IsNullOrEmpty(noClimbTag) && c && c.CompareTag(noClimbTag);
|
|
|
|
/// <summary>
|
|
/// Probes from the capsule center toward the climbed surface (along -_wallNormal) for the
|
|
/// current normal and the gap (clearance beyond the capsule surface). False = no hit.
|
|
/// </summary>
|
|
private bool ProbeWall(out Vector3 normal, out float gap)
|
|
{
|
|
Vector3 dir = -_wallNormal;
|
|
float extent = CapsuleExtent(dir);
|
|
float maxDist = extent + wallMaxOffset + ProbeReachSlack * CapsuleScale;
|
|
|
|
if (NearestClimbHit(WorldCapsuleCenter, dir, maxDist, extent, ceilingOnly: false, out normal, out gap))
|
|
return true;
|
|
|
|
normal = _wallNormal;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the TRUE face normal straight ahead of the capsule center with a thin, strictly
|
|
/// HORIZONTAL ray. The swept climb probe returns smoothly tilted normals near an edge — a thin
|
|
/// board's vertical face then reads as a "flattened slope" — so the slope top-out is confirmed
|
|
/// against this ray. Casting along the (already tilted) wall normal would dive into the board's
|
|
/// horizontal TOP face and read flat, faking the very slope it must reject; flattening the
|
|
/// direction to the horizontal plane samples the real vertical face instead. A real ramp still
|
|
/// reads shallow here, a vertical board reads steep, and clearing the edge makes the ray miss.
|
|
/// False = nothing straight ahead. Player avatars and no-climb surfaces are ignored.
|
|
/// </summary>
|
|
private bool RaycastWallFace(out Vector3 faceNormal)
|
|
{
|
|
faceNormal = _wallNormal;
|
|
Vector3 dir = -_wallNormal;
|
|
dir.y = 0f;
|
|
if (dir.sqrMagnitude < 1e-6f) return false;
|
|
dir.Normalize();
|
|
float maxDist = WorldRadius + wallMaxOffset + ProbeReachSlack * CapsuleScale;
|
|
if (Physics.Raycast(WorldCapsuleCenter, dir, out RaycastHit hit, maxDist, ~0, QueryTriggerInteraction.Ignore)
|
|
&& !IsPlayerCollider(hit.collider) && !IsNoClimb(hit.collider))
|
|
{
|
|
faceNormal = hit.normal;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// True while the climbed face still rises above the head: a strictly HORIZONTAL forward sphere
|
|
/// (flattened -_wallNormal) from the capsule's upper band hits a wall-like surface. The swept
|
|
/// climb probe tilts its normal upward whenever the sphere grazes the board's TOP edge from
|
|
/// below, faking a rim mid-face; this separates a genuine cleared rim (nothing ahead above) from
|
|
/// that jitter (board still there), so the climb keeps rising instead of vaulting/parking early.
|
|
/// </summary>
|
|
private bool WallContinuesAbove()
|
|
{
|
|
Vector3 dir = -_wallNormal;
|
|
dir.y = 0f;
|
|
if (dir.sqrMagnitude < 1e-6f) return false;
|
|
dir.Normalize();
|
|
|
|
float radius = WorldRadius;
|
|
float half = WorldHalfHeight;
|
|
float maxDist = radius + wallMaxOffset + ProbeReachSlack * CapsuleScale;
|
|
|
|
// Sample a band from just above center up to the capsule top. A board thinner than the probe
|
|
// sphere gives a noisy single-height result, so any wall-like hit ANYWHERE across the band
|
|
// means the face still rises above the head — keep climbing rather than vault/park early.
|
|
for (float f = 0.25f; f <= 1.01f; f += 0.25f)
|
|
{
|
|
Vector3 origin = WorldCapsuleCenter + Vector3.up * (half * f);
|
|
int count = Physics.SphereCastNonAlloc(
|
|
origin - dir * radius, radius, dir, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
|
|
WarnIfProbeSaturated(count);
|
|
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
if (_wallProbe[i].distance <= 0f)
|
|
continue;
|
|
if (IsPlayerCollider(_wallProbe[i].collider) || IsNoClimb(_wallProbe[i].collider))
|
|
continue;
|
|
if (_wallProbe[i].normal.y > CeilingNormalThreshold)
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// <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 = WorldRadius;
|
|
return radius + Mathf.Abs(dir.y) * (WorldHalfHeight - radius);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Probes straight up from the center for 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)
|
|
{
|
|
float half = WorldHalfHeight;
|
|
float maxDist = half + wallMaxOffset + ProbeReachSlack * CapsuleScale;
|
|
|
|
if (NearestClimbHit(WorldCapsuleCenter, Vector3.up, maxDist, half, ceilingOnly: true, out normal, out gap))
|
|
return true;
|
|
|
|
normal = Vector3.down;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sweeps a capsule-wide sphere along <paramref name="dir"/> and returns the nearest hit that a
|
|
/// climb probe accepts (player avatars and no-climb surfaces never count;
|
|
/// <paramref name="ceilingOnly"/> additionally rejects non-ceiling normals).
|
|
/// <paramref name="gap"/> is the clearance beyond the capsule's support <paramref name="extent"/>.
|
|
///
|
|
/// A swept sphere instead of a ray, for two reasons: its volume keeps contact with thin/narrow
|
|
/// surfaces (boards) that a zero-width ray slips past, and its edge contacts return smoothly
|
|
/// varying normals — a ray flips between adjacent face normals at an edge (cylinder facets,
|
|
/// wall corners), snapping the body's facing back and forth. The cast starts one radius back so
|
|
/// a surface at skin-width standoff can never be an (undetectable) initial overlap; with that
|
|
/// backup, hit.distance measures from <paramref name="origin"/> like a plain ray would.
|
|
/// </summary>
|
|
private bool NearestClimbHit(Vector3 origin, Vector3 dir, float maxDist, float extent,
|
|
bool ceilingOnly, out Vector3 normal, out float gap)
|
|
{
|
|
normal = Vector3.zero;
|
|
gap = 0f;
|
|
|
|
float radius = WorldRadius;
|
|
int count = Physics.SphereCastNonAlloc(
|
|
origin - dir * radius, radius, dir, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
|
|
WarnIfProbeSaturated(count);
|
|
|
|
float best = float.MaxValue;
|
|
bool found = false;
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
if (_wallProbe[i].distance <= 0f) // initial overlap; no usable point/normal
|
|
continue;
|
|
if (IsPlayerCollider(_wallProbe[i].collider) || IsNoClimb(_wallProbe[i].collider))
|
|
continue;
|
|
if (ceilingOnly && _wallProbe[i].normal.y >= CeilingNormalThreshold)
|
|
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;
|
|
}
|
|
|
|
private void WarnIfProbeSaturated(int hitCount)
|
|
{
|
|
if (hitCount != _wallProbe.Length || _wallProbeSaturated) return;
|
|
_wallProbeSaturated = true;
|
|
Debug.LogWarning($"PlayerCharacterController: climb probe buffer saturated ({hitCount}); the nearest surface may be missed.", this);
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
_controller.Move(Vector3.up * (speed * dt));
|
|
if (!WallPresentAt(-WorldHalfHeight))
|
|
_mantleClearedFeet = true;
|
|
}
|
|
else
|
|
{
|
|
_controller.Move(forward * (speed * dt));
|
|
_mantleOverDist += speed * dt;
|
|
if (_mantleOverDist >= MantleForwardDistance || _controller.isGrounded)
|
|
{
|
|
_reentryCooldown = TopOutRegrabCooldown;
|
|
ExitWallClimb();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Safety: never mantle indefinitely (e.g. blocked by other geometry).
|
|
if (_mantleTimer >= 2f)
|
|
{
|
|
_reentryCooldown = TopOutRegrabCooldown;
|
|
ExitWallClimb();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Moves the climbing capsule, passing over small steps on the climbed surface — the
|
|
/// wall/ceiling counterpart of the ground Step Offset. When the along-surface travel comes up
|
|
/// blocked (a trim/offset on the wall, a beam on the ceiling), the capsule lifts off the
|
|
/// surface by the smallest sub-step of <see cref="wallStepOffset"/> whose lifted path is clear
|
|
/// and retries the remainder; the standoff servo holds the lift briefly and re-seats past the
|
|
/// ledge. Clearance is verified before committing so a genuine obstacle (tall ledge, inner
|
|
/// corner) never starts a lift it can't finish.
|
|
/// </summary>
|
|
private void ClimbMove(Vector3 velocity, Vector3 alongSurface, float dt)
|
|
{
|
|
Vector3 before = transform.position;
|
|
_controller.Move(velocity * dt);
|
|
|
|
if (wallStepOffset <= 0f || alongSurface.sqrMagnitude < 1e-6f)
|
|
return;
|
|
Vector3 travel = alongSurface.normalized;
|
|
float intended = Vector3.Dot(velocity, travel) * dt;
|
|
if (intended < 1e-4f * CapsuleScale)
|
|
return;
|
|
float actual = Vector3.Dot(transform.position - before, travel);
|
|
if (actual >= intended * 0.5f)
|
|
return;
|
|
|
|
float remaining = intended - actual;
|
|
float maxLift = wallStepOffset * CapsuleScale;
|
|
for (int i = 1; i <= 4; i++)
|
|
{
|
|
float lift = maxLift * (i * 0.25f);
|
|
if (!CapsuleSweepClear(Vector3.zero, _wallNormal, lift)
|
|
|| !CapsuleSweepClear(_wallNormal * lift, travel,
|
|
remaining + _controller.skinWidth * CapsuleScale))
|
|
continue;
|
|
|
|
Vector3 mid = transform.position;
|
|
_controller.Move(_wallNormal * lift);
|
|
_controller.Move(travel * remaining);
|
|
// Hold the lift only when the retry actually got past the ledge; a mispredicted
|
|
// (still blocked) retry lets the servo re-seat instead of drifting further out.
|
|
if (Vector3.Dot(transform.position - mid, travel) > remaining * 0.5f)
|
|
_stepPassTimer = StepPassServoHold;
|
|
return;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when the capsule, shifted by <paramref name="offset"/>, can sweep <paramref name="dist"/>
|
|
/// along <paramref name="dir"/> without hitting anything. Avatars never block a step-pass;
|
|
/// no-climb colliders still block physically, so they count.
|
|
/// </summary>
|
|
private bool CapsuleSweepClear(Vector3 offset, Vector3 dir, float dist)
|
|
{
|
|
if (dist <= 0f) return true;
|
|
float radius = WorldRadius;
|
|
Vector3 center = WorldCapsuleCenter + offset;
|
|
Vector3 axis = Vector3.up * Mathf.Max(0f, WorldHalfHeight - radius);
|
|
int count = Physics.CapsuleCastNonAlloc(
|
|
center - axis, center + axis, radius, dir, _wallProbe, dist, ~0, QueryTriggerInteraction.Ignore);
|
|
WarnIfProbeSaturated(count);
|
|
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
if (_wallProbe[i].distance <= 0f)
|
|
continue;
|
|
if (IsPlayerCollider(_wallProbe[i].collider))
|
|
continue;
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when the down-facing surface overhead is merely a small ledge the step pass can carry
|
|
/// the climb around: some outward lift within <see cref="wallStepOffset"/> (same sub-steps as
|
|
/// <see cref="ClimbMove"/>) gives the capsule a clear path upward past it. Gates the
|
|
/// wall->ceiling hand-off — without it, climbing up into a small protruding ledge latches
|
|
/// ceiling mode on its underside instead of stepping over its edge. A genuine overhang/ceiling
|
|
/// extends outward beyond the step allowance, fails every lift, and still hands off.
|
|
/// </summary>
|
|
private bool OverheadIsPassableStep()
|
|
{
|
|
if (wallStepOffset <= 0f) return false;
|
|
Vector3 outDir = _wallNormal;
|
|
outDir.y = 0f;
|
|
if (outDir.sqrMagnitude < 1e-6f) return false;
|
|
outDir.Normalize();
|
|
|
|
float maxLift = wallStepOffset * CapsuleScale;
|
|
float rise = WorldRadius;
|
|
for (int i = 1; i <= 4; i++)
|
|
{
|
|
float lift = maxLift * (i * 0.25f);
|
|
if (CapsuleSweepClear(Vector3.zero, outDir, lift)
|
|
&& CapsuleSweepClear(outDir * lift, Vector3.up, rise))
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Volumetric wall probe toward -_wallNormal from a vertical offset off the capsule center
|
|
/// (negative = below/feet, positive = above/headroom), reporting the hit normal and the gap
|
|
/// beyond the capsule surface like <see cref="ProbeWall"/> does from the center.
|
|
/// </summary>
|
|
private bool ProbeWallAt(float upOffset, out Vector3 normal, out float gap)
|
|
{
|
|
Vector3 dir = -_wallNormal;
|
|
Vector3 origin = WorldCapsuleCenter + Vector3.up * upOffset;
|
|
float maxDist = WorldRadius + wallMaxOffset + ProbeReachSlack * CapsuleScale;
|
|
return NearestClimbHit(origin, dir, maxDist, CapsuleExtent(dir), ceilingOnly: false, out normal, out gap);
|
|
}
|
|
|
|
private bool WallPresentAt(float upOffset) => ProbeWallAt(upOffset, out _, out _);
|
|
|
|
/// <summary>
|
|
/// Sweeps the FULL capsule toward the climbed surface (along -_wallNormal): contact anywhere
|
|
/// along the capsule's height counts, so a thin board keeps holding while the center band has
|
|
/// already cleared it (or hasn't reached it). A down-tilted edge graze (a capsule cap against
|
|
/// a board's bottom edge) still counts as HOLDING contact — the wall is only lost once the
|
|
/// capsule no longer overlaps it — but <paramref name="normal"/> reports the nearest wall-like
|
|
/// hit (falling back to the current normal) so a graze never steers the climb into ceiling
|
|
/// mode. <paramref name="gap"/> is the capsule-surface clearance.
|
|
/// </summary>
|
|
private bool ProbeWallSpan(out Vector3 normal, out float gap)
|
|
{
|
|
normal = _wallNormal;
|
|
gap = 0f;
|
|
|
|
// Strictly horizontal sweep: near an edge _wallNormal is already tilted, and sweeping along
|
|
// it would dive above/below the face instead of across it (same flattening as RaycastWallFace).
|
|
Vector3 dir = -_wallNormal;
|
|
dir.y = 0f;
|
|
if (dir.sqrMagnitude < 1e-6f) return false;
|
|
dir.Normalize();
|
|
float radius = WorldRadius;
|
|
Vector3 axis = Vector3.up * Mathf.Max(0f, WorldHalfHeight - radius);
|
|
// Same one-radius backup as NearestClimbHit: a surface at skin-width standoff must never be
|
|
// an (unreported) initial overlap; hit.distance then measures from the capsule axis.
|
|
Vector3 back = dir * radius;
|
|
float maxDist = radius + wallMaxOffset + ProbeReachSlack * CapsuleScale;
|
|
|
|
int count = Physics.CapsuleCastNonAlloc(
|
|
WorldCapsuleCenter - axis - back, WorldCapsuleCenter + axis - back, radius, dir,
|
|
_wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
|
|
WarnIfProbeSaturated(count);
|
|
|
|
float bestWall = float.MaxValue;
|
|
float bestAny = float.MaxValue;
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
if (_wallProbe[i].distance <= 0f)
|
|
continue;
|
|
if (IsPlayerCollider(_wallProbe[i].collider) || IsNoClimb(_wallProbe[i].collider))
|
|
continue;
|
|
if (_wallProbe[i].distance < bestAny)
|
|
bestAny = _wallProbe[i].distance;
|
|
if (_wallProbe[i].normal.y > CeilingNormalThreshold && _wallProbe[i].distance < bestWall)
|
|
{
|
|
bestWall = _wallProbe[i].distance;
|
|
normal = _wallProbe[i].normal;
|
|
}
|
|
}
|
|
|
|
if (bestAny == float.MaxValue)
|
|
return false;
|
|
gap = Mathf.Max(0f, (bestWall != float.MaxValue ? bestWall : bestAny) - radius);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when walkable ground (angle within the controller's slope limit) sits within step range
|
|
/// below the capsule. Used to top out of a climb back onto solid ground without trusting
|
|
/// isGrounded, which the standoff servo can falsify by holding the capsule just off the surface.
|
|
/// No-climb surfaces still count: they can't be grabbed but can be stood on.
|
|
/// </summary>
|
|
private bool WalkableGroundBelow()
|
|
{
|
|
return WalkableBelow(
|
|
WorldCapsuleCenter + Vector3.up * WorldRadius,
|
|
(_controller.center.y + _controller.stepOffset) * CapsuleScale);
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when the walkable surface below also extends outward from the climbed face, sampled a
|
|
/// capsule radius plus the step allowance away from the wall. A genuine floor/terrace does; the
|
|
/// narrow top of a step/trim being passed (<see cref="wallStepOffset"/>) does not.
|
|
/// </summary>
|
|
private bool GroundExtendsOutward()
|
|
{
|
|
Vector3 outDir = _wallNormal;
|
|
outDir.y = 0f;
|
|
if (outDir.sqrMagnitude < 1e-6f)
|
|
return true; // no horizontal facing: keep the plain ground test
|
|
outDir.Normalize();
|
|
float outDist = WorldRadius + wallStepOffset * CapsuleScale;
|
|
return WalkableBelow(
|
|
WorldCapsuleCenter + outDir * outDist + Vector3.up * WorldRadius,
|
|
(_controller.center.y + _controller.stepOffset) * CapsuleScale);
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when a walkable surface lies where the mantle's forward phase would land (just past the
|
|
/// climbed edge). Gates both top-out flavors: a thick wall or a flattening slope has a standable
|
|
/// top, a thin board's bare rim does not — detaching there would only drop the player.
|
|
/// </summary>
|
|
private bool HasStandableTop()
|
|
{
|
|
Vector3 forward = -_wallNormal;
|
|
forward.y = 0f;
|
|
if (forward.sqrMagnitude < 1e-6f) return false;
|
|
forward.Normalize();
|
|
|
|
Vector3 origin = WorldCapsuleCenter + forward * MantleForwardDistance + Vector3.up * WorldHalfHeight;
|
|
float maxDist = WorldHalfHeight * 2f + _controller.stepOffset * CapsuleScale;
|
|
return WalkableBelow(origin, maxDist);
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when nothing blocks the mantle's rise: phase 1 climbs up to roughly a capsule height
|
|
/// before the feet clear the edge. Under a low ceiling (shelf compartment, overhang beyond the
|
|
/// ceiling-grab range) the mantle would stall against it and time out into a drop — the climb
|
|
/// parks at the rim instead. No-climb colliders still block physically, so they count.
|
|
/// </summary>
|
|
private bool HasMantleHeadroom()
|
|
{
|
|
float radius = WorldRadius;
|
|
int count = Physics.SphereCastNonAlloc(
|
|
WorldCapsuleCenter - Vector3.up * radius, radius, Vector3.up,
|
|
_wallProbe, WorldHalfHeight * 2f, ~0, QueryTriggerInteraction.Ignore);
|
|
WarnIfProbeSaturated(count);
|
|
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
if (_wallProbe[i].distance <= 0f)
|
|
continue;
|
|
if (IsPlayerCollider(_wallProbe[i].collider))
|
|
continue;
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Nearest non-player hit of a capsule-wide sphere cast straight down; true when its
|
|
/// surface angle is within the controller's slope limit.</summary>
|
|
private bool WalkableBelow(Vector3 origin, float maxDist)
|
|
{
|
|
int count = Physics.SphereCastNonAlloc(
|
|
origin, WorldRadius, Vector3.down, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
|
|
WarnIfProbeSaturated(count);
|
|
|
|
float best = float.MaxValue;
|
|
Vector3 normal = Vector3.up;
|
|
bool found = false;
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
if (_wallProbe[i].distance <= 0f)
|
|
continue;
|
|
if (IsPlayerCollider(_wallProbe[i].collider))
|
|
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 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.
|
|
/// </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;
|
|
_rimConfirmTimer = 0f;
|
|
_stepPassTimer = 0f;
|
|
_verticalVelocity = 0f;
|
|
_climbVelocity = Vector3.zero;
|
|
// Hand the current lean off to the residual so it eases home instead of snapping.
|
|
_meshOffsetResidual = _wallOffset;
|
|
_meshOffsetExitNormal = _wallNormal;
|
|
_wallOffset = 0f;
|
|
_wallYaw = 0f;
|
|
_wallStrafeSign = 0;
|
|
_wallClimbSign = 0;
|
|
_offsetDragging = false;
|
|
_climbLeftGround = false;
|
|
_mantling = false;
|
|
_mantleClearedFeet = false;
|
|
_mantleOverDist = 0f;
|
|
_mantleTimer = 0f;
|
|
}
|
|
|
|
/// <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 && !IsNoClimb(hit.collider))
|
|
{
|
|
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;
|
|
}
|
|
}
|
|
}
|