Persistence rework: - Drop auto-load on setup and auto-save on teardown; the painter now always seeds fresh from the material. - Named slots under MeshPaint/Saved/<name>.paint via the existing CHMP machinery: SavePaintAs / LoadPaintByName / DeletePaint / ListSavedPaintings / PaintExists. Slot files carry the display name in the header. - Remove the obsolete persistence partials (PlayerCharacterController, PlayerPoseController, ThirdPersonOrbitInput) and their references. Paintings HUD panel: - Authored Panel_Paintings + Save/Load/Delete dialogs into PaintHudPanel.prefab (TMP throughout); PaintHud.Library.cs is now a pure binder that clones the entry template per slot. - Save dialog blocks empty/existing names; Load and Delete confirm first. - Fix dialog z-order: open dialogs SetAsLastSibling so a dragged panel can no longer cover them. - Lib_ListContent horizontal offsets corrected to 0 (was clipping entry labels out of the masked viewport). Multiplayer: - Loading a painting re-syncs to all peers via the snapshot broadcast path (BroadcastLoadedPaint), covering later late-joiners for free. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1223 lines
57 KiB
C#
1223 lines
57 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 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("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>
|
|
/// When set, the body faces this world-space yaw (degrees) every frame instead of turning toward its
|
|
/// movement direction. First-person sets it so the character always faces where the camera looks;
|
|
/// null restores movement-facing. 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): 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>
|
|
/// True while airborne and descending faster than <see cref="fallAnimationSpeed"/> (scaled by player
|
|
/// size): not grounded and not wall-climbing. Rising after a jump, standing, moving on the ground, and
|
|
/// wall/ceiling states all read false. Exposed so the pose system can pick its fall clip.
|
|
/// </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: 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 bool _isFalling; // snapshot: airborne & descending past the fall threshold (exposed via IsFalling)
|
|
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 int _wallClimbSign; // latched up/down direction: whichever W/S key pushes into the wall climbs UP (camera picks it once per climb); 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
|
|
_isFalling = false; // attached to a surface, not falling
|
|
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;
|
|
|
|
// Facing: first-person pins the body to the camera yaw (even while idle); otherwise turn toward
|
|
// the movement direction, unless the right-button turn is actively steering the body.
|
|
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);
|
|
}
|
|
|
|
// 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
|
|
// Falling: airborne and descending past the threshold (wall-climb already returned above, so
|
|
// not-grounded here means free in the air). Scaled by player size like gravity/jump.
|
|
_isFalling = !_controller.isGrounded && _verticalVelocity < -fallAnimationSpeed * CapsuleScale;
|
|
}
|
|
|
|
/// <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;
|
|
_wallClimbSign = 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;
|
|
|
|
// Map W/S to UP/DOWN by which key pushes INTO the wall, the same way A/D's _wallStrafeSign
|
|
// picks sideways from the camera: whichever vertical key drives the player toward the wall
|
|
// climbs up. Facing the wall that's W; with the camera on the wall's side (walking backward
|
|
// into it) it's S. Without this remap the grab (decided on the camera-relative world
|
|
// direction) and the base-release (decided on raw S) disagree, so the climb flickers on/off.
|
|
// Latched once per press and held until released; only walls remap (the ceiling crawl is
|
|
// already camera-relative). vEff is the effective climb axis used for every up/down test below.
|
|
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;
|
|
// camFwd aligned with the outward wall normal = camera faces away from the wall, so
|
|
// forward (W) moves away from it and S is the into-wall, climb-up key.
|
|
_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 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 && vEff > 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 (vEff > 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 * vEff + 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(WorldHalfHeight))
|
|
{
|
|
_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.
|
|
float maxPerpSpeed = 8f * CapsuleScale;
|
|
float gapError = _controller.skinWidth * CapsuleScale - 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 (vEff > 0). Arriving
|
|
// at the floor by descending, OR pressing down/away (the effective down key, vEff < 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 && vEff > 0f)
|
|
{
|
|
TopOutDetach(); // topped out upward onto walkable ground: hop over the lip (holding the up key carries on)
|
|
return;
|
|
}
|
|
else if (_climbLeftGround || vEff < 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;
|
|
}
|
|
|
|
// The CharacterController's serialized center/radius/height are authored at the player's base
|
|
// (scale-1) size; transform.localScale resizes the ACTUAL collision capsule via lossyScale but
|
|
// leaves those serialized numbers untouched. Every climb probe below raycasts in WORLD space, so
|
|
// it must use the scaled (world) capsule dimensions and scale its world-distance paddings too --
|
|
// otherwise the wall grab range, head-to-ceiling gap and mantle distance are all measured at the
|
|
// authored size on a shrunk/grown player (PlayerScaler scales the tunables but not these probes).
|
|
// 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 ray length beyond the capsule surface (scale-1 units)
|
|
|
|
/// <summary>
|
|
/// True if the collider belongs to ANY player avatar -- this one OR a networked remote. Every climb
|
|
/// probe must skip players: avatars still collide physically (this player's CharacterController vs a
|
|
/// remote's baked MeshCollider), but another chameleon's body must never register as a climbable
|
|
/// wall/ceiling/ground. Replaces the old self-only IsChildOf check -- self is a player too, so it
|
|
/// stays excluded. Finds the controller even on remotes, where the component is disabled (only the
|
|
/// GameObject's active state gates GetComponentInParent, not the component's enabled flag).
|
|
/// </summary>
|
|
private static bool IsPlayerCollider(Collider c) => c.GetComponentInParent<PlayerCharacterController>();
|
|
|
|
/// <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 = WorldCapsuleCenter;
|
|
Vector3 dir = -_wallNormal;
|
|
float extent = CapsuleExtent(dir);
|
|
float maxDist = extent + wallMaxOffset + ProbeReachSlack * CapsuleScale;
|
|
|
|
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 (IsPlayerCollider(_wallProbe[i].collider)) // skip self and other player avatars
|
|
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 = WorldRadius;
|
|
return radius + Mathf.Abs(dir.y) * (WorldHalfHeight - 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 = WorldCapsuleCenter;
|
|
float half = WorldHalfHeight;
|
|
float maxDist = half + wallMaxOffset + ProbeReachSlack * CapsuleScale;
|
|
|
|
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 (IsPlayerCollider(_wallProbe[i].collider))
|
|
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(WorldHalfHeight))
|
|
_mantleClearedFeet = true;
|
|
}
|
|
else
|
|
{
|
|
// Phase 2: move forward onto the top surface, then finish.
|
|
_controller.Move(forward * (speed * dt));
|
|
_mantleOverDist += speed * dt;
|
|
if (_mantleOverDist >= WorldRadius * 2f + 0.15f * CapsuleScale || _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 = WorldCapsuleCenter - Vector3.up * downOffset;
|
|
float maxDist = WorldRadius + wallMaxOffset + ProbeReachSlack * CapsuleScale;
|
|
|
|
int count = Physics.RaycastNonAlloc(
|
|
origin, -_wallNormal, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
if (IsPlayerCollider(_wallProbe[i].collider))
|
|
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 = WorldCapsuleCenter;
|
|
float maxDist = (_controller.center.y + _controller.stepOffset) * CapsuleScale;
|
|
|
|
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 (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 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;
|
|
_wallClimbSign = 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;
|
|
}
|
|
}
|
|
}
|