using UnityEngine; #if ENABLE_INPUT_SYSTEM using UnityEngine.InputSystem; #endif /// /// CharacterController-based player: camera-relative WASD movement, manual gravity /// and jumping, sliding off too-steep slopes/edges, and a wall-climb mode. /// /// Wall-climb (entered by pushing the move input into a wall): W/S climb straight up/down the /// wall at a constant speed, A/D strafe left/right relative to the camera, and the body always /// faces the wall. Holding the left mouse button and dragging sets the wall pose (vertical = lean /// out from / back to the wall, horizontal = turn the body) and pauses the camera orbit; the /// lean-out offset eases back to zero while moving, the turn is held until leaving the wall. /// On leaving, the body eases back to its movement direction. Jumping detaches and works /// normally; re-grab is blocked while rising and resumes once falling and pushing into a wall. /// Reaching the top edge mantles the character over onto the surface. /// /// Ceiling-climb shares the same mode: entered by jumping up into a ceiling or by climbing a /// wall up into an overhang. The body stays upright and clings to the ceiling, moving in the /// horizontal plane camera-relative (like on the ground). Jump (or reaching a ceiling edge) /// lets go and drops. /// /// Named PlayerCharacterController to avoid a clash with Unity.VisualScripting's /// example "PlayerController". /// [RequireComponent(typeof(CharacterController))] [DisallowMultipleComponent] public class PlayerCharacterController : MonoBehaviour { [Header("Movement")] [Tooltip("Horizontal movement speed (units/second).")] public float moveSpeed = 4f; [Tooltip("Speed multiplier applied to ground movement while the sprint key (Left Shift) is held.")] public float sprintSpeedMultiplier = 1.8f; [Tooltip("Interpret WASD relative to the camera (off = world X/Z axes).")] public bool cameraRelative = true; [Tooltip("Camera used for camera-relative movement. Empty = Camera.main.")] public Transform cameraTransform; [Header("Rotation")] [Tooltip("Smoothly turn the character to face its movement direction.")] public bool rotateTowardMovement = true; [Tooltip("Turn speed in degrees per second.")] public float rotationSpeed = 720f; [Header("Jump & Gravity")] [Tooltip("Peak jump height in units.")] public float jumpHeight = 1.2f; [Tooltip("Gravity in units per second squared (negative = downward).")] public float gravity = -19.62f; [Tooltip("Small downward velocity kept while grounded for reliable ground contact.")] public float groundedStickVelocity = -2f; [Header("Slope Sliding")] [Tooltip("Slide down surfaces steeper than the CharacterController's Slope Limit instead of balancing on them.")] public bool slideDownSteepSlopes = true; [Tooltip("How quickly the slide accelerates (units/second squared).")] public float slideAcceleration = 20f; [Tooltip("Maximum downhill slide speed (units/second).")] public float maxSlideSpeed = 8f; [Tooltip("Steering control kept while sliding (0 = none, 1 = full).")] [Range(0f, 1f)] public float slideControl = 0.3f; [Header("Wall Climb")] [Tooltip("Enter wall-climb mode by pushing into a wall: stick to the surface, move at reduced speed, ignore gravity and jumping.")] public bool enableWallClimb = true; [Tooltip("Minimum surface angle from 'up' (degrees) that counts as a climbable wall/ceiling rather than a slope.")] [Range(50f, 180f)] public float wallMinAngle = 80f; [Tooltip("How directly the move input must point into a wall to grab it (0 = any inward nudge, 1 = head-on only). Stops mere contact (e.g. standing at a wall's base) from grabbing.")] [Range(0f, 1f)] public float wallGrabMinPush = 0.3f; [Tooltip("Speed multiplier for climbing up and strafing while wall-climbing (0.5 = half speed).")] [Range(0f, 1f)] public float wallClimbSpeedMultiplier = 0.5f; [Tooltip("Farthest the character can lean out from the wall via the mouse-drag offset (units).")] public float wallMaxOffset = 1.2f; [Tooltip("Units the lean-out offset changes per pixel of vertical mouse drag (left button held).")] public float wallOffsetSensitivity = 0.01f; [Tooltip("Degrees the body turns per pixel of horizontal mouse drag (left button held) while climbing.")] public float wallRotateSensitivity = 0.2f; [Tooltip("How fast the lean-out offset eases back to the wall while moving (units/second).")] public float wallOffsetReturnSpeed = 2f; [Tooltip("How snappily the body follows the lean-out offset (1/second). Higher = tighter, lower = smoother.")] public float wallOffsetFollow = 12f; [Tooltip("Grace period (seconds) of lost contact tolerated before leaving wall-climb mode.")] public float wallExitGrace = 0.15f; [Tooltip("Head-to-ceiling distance (units) at which climbing a wall up into an overhang hands off to the ceiling.")] public float ceilingGrabDistance = 0.3f; [Tooltip("Upward hop when topping out over a wall/slope edge, so holding W carries you onto the ledge instead of dropping back onto the face and re-grabbing. In meters of jump height; 0 = no hop.")] public float wallTopOutBoost = 1.2f; [Tooltip("Camera orbit input; paused while dragging the wall-climb offset. Auto-found if empty.")] [SerializeField] private ThirdPersonOrbitInput cameraOrbit; /// /// When false, movement/jump input is ignored but gravity and ground contact /// keep running. Toggled by the GameModeManager. /// public bool ControlEnabled { get; set; } = true; /// True while in wall-climb mode. public bool IsWallClimbing => _isWallClimbing; private CharacterController _controller; private float _verticalVelocity; // Ground contact normal from the latest Move (for slope sliding). private Vector3 _groundNormal = Vector3.up; private bool _groundNormalUpdated; private float _slideSpeed; // Wall-climb state. A climb surface counts as a ceiling (horizontal-crawl model) rather // than a wall (up/down model) once its normal points down past this threshold (~>120 deg // from up); between that and wallMinAngle it is treated as a wall. private const float CeilingNormalThreshold = -0.5f; private bool _isWallClimbing; private Vector3 _wallNormal; private Vector3 _wallNormalAccum; // summed wall normals during the latest Move private bool _wallContactThisFrame; private float _lostContactTimer; private float _wallOffset; // current lean-out distance from the wall (mouse drag) private float _wallYaw; // body yaw offset from facing the wall (mouse drag); held until leaving private bool _offsetDragging; // true while the offset/rotation is being set (pauses camera orbit) private bool _climbLeftGround; // true once the capsule has left the ground while climbing (enables ground-detach) private readonly RaycastHit[] _wallProbe = new RaycastHit[8]; // Mantle (top-out) state. private const float TopOutRegrabCooldown = 0.3f; // blocks re-grab right after any top-out so holding W tops out cleanly instead of detach/re-grab buzzing private bool _mantling; private bool _mantleClearedFeet; // phase 1 done: capsule fully above the edge private float _mantleOverDist; // phase 2 forward distance over the top private float _mantleTimer; // safety cap private float _reentryCooldown; // blocks re-grabbing right after topping out void Awake() { _controller = GetComponent(); if (!cameraTransform && Camera.main) cameraTransform = Camera.main.transform; if (!cameraOrbit) cameraOrbit = FindFirstObjectByType(); } void Update() { float h = 0f, v = 0f; bool jumpPressed = false; bool sprinting = false; // Skip input when control is disabled (e.g. paint mode); gravity and // grounding below still run so the player stays planted. if (ControlEnabled) { #if ENABLE_INPUT_SYSTEM var keyboard = Keyboard.current; if (keyboard != null) { if (keyboard.aKey.isPressed) h -= 1f; if (keyboard.dKey.isPressed) h += 1f; if (keyboard.sKey.isPressed) v -= 1f; if (keyboard.wKey.isPressed) v += 1f; jumpPressed = keyboard.spaceKey.wasPressedThisFrame; sprinting = keyboard.leftShiftKey.isPressed; } #else h = Input.GetAxisRaw("Horizontal"); v = Input.GetAxisRaw("Vertical"); jumpPressed = Input.GetButtonDown("Jump"); sprinting = Input.GetKey(KeyCode.LeftShift); #endif } Vector3 inputDir = new Vector3(h, 0f, v); if (inputDir.sqrMagnitude > 1f) inputDir.Normalize(); Vector3 moveDir; if (cameraRelative && cameraTransform) { // Project the camera axes onto the ground plane so movement stays horizontal. Vector3 forward = cameraTransform.forward; forward.y = 0f; forward.Normalize(); Vector3 right = cameraTransform.right; right.y = 0f; right.Normalize(); moveDir = forward * inputDir.z + right * inputDir.x; } else { moveDir = inputDir; } UpdateWallClimbState(moveDir); // Latch the wall normal for this frame, then reset the per-frame contact // accumulators (OnControllerColliderHit refills them during Move). if (_wallContactThisFrame && _wallNormalAccum.sqrMagnitude > 1e-6f) _wallNormal = _wallNormalAccum.normalized; _wallContactThisFrame = false; _wallNormalAccum = Vector3.zero; if (_isWallClimbing) { // Frozen on the wall while control is off (e.g. paint mode): keep the // grip, no movement or gravity, so switching modes never drops us. if (ControlEnabled) ApplyWallClimbMovement(h, v, moveDir, jumpPressed, Time.deltaTime); else _offsetDragging = false; if (cameraOrbit) cameraOrbit.LookSuppressed = _offsetDragging; return; } if (cameraOrbit) cameraOrbit.LookSuppressed = false; // A ground normal steeper than the slope limit is too steep to stand on // (also covers balancing on a sharp box edge). float slopeAngle = Vector3.Angle(_groundNormal, Vector3.up); bool onSteepSlope = slideDownSteepSlopes && _controller.isGrounded && slopeAngle > _controller.slopeLimit + 0.01f; if (_controller.isGrounded) { if (_verticalVelocity < 0f) _verticalVelocity = groundedStickVelocity; // No jumping off a slope too steep to stand on. if (jumpPressed && !onSteepSlope) _verticalVelocity = Mathf.Sqrt(jumpHeight * -2f * gravity); } _verticalVelocity += gravity * Time.deltaTime; Vector3 slideVelocity = Vector3.zero; if (onSteepSlope) { Vector3 slideDir = Vector3.ProjectOnPlane(Vector3.down, _groundNormal).normalized; _slideSpeed = Mathf.Min(_slideSpeed + slideAcceleration * Time.deltaTime, maxSlideSpeed); slideVelocity = slideDir * _slideSpeed; moveDir *= slideControl; // reduced steering while sliding } else { _slideSpeed = 0f; } float currentSpeed = sprinting ? moveSpeed * sprintSpeedMultiplier : moveSpeed; Vector3 velocity = moveDir * currentSpeed + slideVelocity + Vector3.up * _verticalVelocity; // Move fires OnControllerColliderHit, which refills the ground normal; // fall back to "up" if nothing was hit. _groundNormalUpdated = false; _controller.Move(velocity * Time.deltaTime); if (!_groundNormalUpdated) _groundNormal = Vector3.up; if (rotateTowardMovement && moveDir.sqrMagnitude > 0.0001f) { Quaternion target = Quaternion.LookRotation(moveDir, Vector3.up); transform.rotation = Quaternion.RotateTowards( transform.rotation, target, rotationSpeed * Time.deltaTime); } } /// /// Enters wall-climb mode only when the move input actively pushes into a wall /// (not on mere contact). Leaving while climbing is handled in /// . /// private void UpdateWallClimbState(Vector3 moveDir) { if (_reentryCooldown > 0f) _reentryCooldown -= Time.deltaTime; if (!enableWallClimb) { if (_isWallClimbing) ExitWallClimb(); return; } // Decide whether the contacted surface is being pushed into. A wall is grabbed by // pushing the move input horizontally into it while not rising (mere contact, e.g. // standing at a wall's base, must not grab). A ceiling can't be pushed into // horizontally, so it is grabbed by rising up into it (jumping up against it). bool pushingIn = false; if (_wallContactThisFrame && _wallNormalAccum.sqrMagnitude > 1e-6f) { Vector3 surfaceNormal = _wallNormalAccum.normalized; if (surfaceNormal.y < CeilingNormalThreshold) pushingIn = _verticalVelocity > 0f; else pushingIn = moveDir.sqrMagnitude > 1e-4f && Vector3.Dot(moveDir.normalized, surfaceNormal) <= -wallGrabMinPush && _verticalVelocity <= 0f; } // Grab only under player control; once climbing we stay attached across control // toggles (e.g. paint mode) so switching modes never drops us off the surface. // The cooldown prevents instantly re-grabbing the edge we just mantled over. if (!_isWallClimbing && ControlEnabled && pushingIn && _reentryCooldown <= 0f) { _isWallClimbing = true; _wallNormal = _wallNormalAccum.normalized; _lostContactTimer = 0f; _verticalVelocity = 0f; _slideSpeed = 0f; _wallOffset = 0f; _wallYaw = 0f; _climbLeftGround = false; } } /// /// Movement while climbing, for both walls and ceilings (distinguished by the surface /// normal). On a WALL: W/S climb straight up/down at a constant speed (camera pitch never /// tilts them), A/D strafe left/right relative to the camera, the body faces the wall, and /// holding the left mouse button drags the wall pose (vertical = lean out via /// , horizontal = body yaw via ). On a /// CEILING: the body stays upright and moves WASD camera-relative in the horizontal plane /// (like on the ground), facing the move direction; the lean-out drag pulls down off the /// ceiling. Surface normal and gap are re-measured each frame; reaching a wall's top edge /// mantles, climbing a wall up into an overhang hands off to the ceiling. /// private void ApplyWallClimbMovement(float h, float v, Vector3 moveDir, bool jumpPressed, float dt) { _offsetDragging = false; if (_mantling) { MantleOverEdge(dt); return; } bool onCeiling = _wallNormal.y < CeilingNormalThreshold; // Jump detaches. Off a wall it pushes straight up; off a ceiling it just lets go and // drops. Re-grab stays blocked appropriately by UpdateWallClimbState's push-in test. if (jumpPressed) { ExitWallClimb(); _verticalVelocity = onCeiling ? 0f : Mathf.Sqrt(jumpHeight * -2f * gravity); return; } // Wall->ceiling hand-off: while climbing up a wall into an overhang, switch to the // ceiling right above the head instead of trying to mantle over a (non-existent) top. if (!onCeiling && v > 0f && ProbeCeiling(out Vector3 ceilNormal, out float ceilHeadGap) && ceilHeadGap <= ceilingGrabDistance) { _wallNormal = ceilNormal; onCeiling = true; _wallOffset = 0f; } bool found = ProbeWall(out Vector3 probedNormal, out float gap); if (found) { _wallNormal = probedNormal; _lostContactTimer = 0f; onCeiling = _wallNormal.y < CeilingNormalThreshold; } // Topped out onto a slope: the climbed face has flattened into something walkable (no // longer steep enough to be a wall). Hand back to normal grounded movement instead of // clinging to a "wall" that is really just ground now. if (found && !onCeiling && _climbLeftGround && Vector3.Angle(_wallNormal, Vector3.up) < wallMinAngle) { TopOutDetach(); // climbed face flattened into walkable: hop over the lip instead of dropping onto the steep section below return; } Vector3 alongSurface; if (onCeiling) { // Crawl across the ceiling in the horizontal plane, camera-relative (moveDir is // already the ground-projected camera input); project onto the ceiling for safety. alongSurface = Vector3.ProjectOnPlane(moveDir, _wallNormal); } else { // W/S climb straight up/down the wall at a constant speed (camera pitch never tilts // them). Strafe along the wall's own horizontal tangent, not the live camera-right // vector: feeding camera-right in here coupled movement to the orbit camera's // per-frame re-aim and jittered sideways motion. The camera only picks the sign. Vector3 wallUp = Vector3.ProjectOnPlane(Vector3.up, _wallNormal); if (wallUp.sqrMagnitude > 1e-6f) wallUp.Normalize(); Vector3 wallRight = Vector3.Cross(_wallNormal, wallUp); if (wallRight.sqrMagnitude > 1e-6f) wallRight.Normalize(); if (cameraTransform && Vector3.Dot(cameraTransform.right, wallRight) < 0f) wallRight = -wallRight; alongSurface = wallUp * v + wallRight * h; } if (alongSurface.sqrMagnitude > 1f) alongSurface.Normalize(); bool hasMoveInput = (h * h + v * v) > 1e-4f; if (!found) { // Wall only: losing the center ray while climbing up the face = top edge → mantle. if (!onCeiling && alongSurface.y > 0f && WallInFront(_controller.height * 0.5f)) { _mantling = true; _mantleClearedFeet = false; _mantleOverDist = 0f; _mantleTimer = 0f; MantleOverEdge(dt); return; } // Surface genuinely gone (incl. crawling off a ceiling edge): leave after grace, // dropping into normal gravity. _lostContactTimer += dt; if (_lostContactTimer >= wallExitGrace) { ExitWallClimb(); return; } } // Left button + drag adjusts the pose: vertical = lean-out offset (mouse down = away // from the surface; down off a ceiling), horizontal = body yaw (wall only). The offset // eases back to zero while moving and never detaches. Dragging pauses the camera orbit. if (found && IsLeftMouseHeld()) { _offsetDragging = true; Vector2 drag = ReadMouseDelta(); _wallOffset = Mathf.Clamp(_wallOffset - drag.y * wallOffsetSensitivity, 0f, wallMaxOffset); _wallYaw += drag.x * wallRotateSensitivity; } else if (hasMoveInput) { _wallOffset = Mathf.MoveTowards(_wallOffset, 0f, wallOffsetReturnSpeed * dt); } float climbSpeed = moveSpeed * wallClimbSpeedMultiplier; Vector3 velocity = Vector3.zero; if (alongSurface.sqrMagnitude > 1e-6f) velocity += alongSurface.normalized * climbSpeed; if (found) { // Smoothly approach the chosen standoff. Target one skin-width beyond the offset so // the capsule rests just outside the surface (offset 0 = flush): the controller's // depenetration never fights back, so there's no buzz and no quantizing deadzone is // needed. Exponential follow keeps it framerate-stable; clamp guards probe spikes. const float maxPerpSpeed = 8f; float gapError = (_wallOffset + _controller.skinWidth) - gap; float follow = 1f - Mathf.Exp(-wallOffsetFollow * dt); velocity += _wallNormal * Mathf.Clamp(gapError * follow / dt, -maxPerpSpeed, maxPerpSpeed); } _controller.Move(velocity * dt); // Reaching walkable ground detaches and drops back to normal grounded movement. Gated // on having left the ground first, so grabbing a wall at its base (still grounded) // doesn't instantly detach. We test for walkable ground under the feet rather than // trusting isGrounded: the standoff correction can lift the capsule just off the // surface, so at a slope's top isGrounded stays false and the old check never fired. bool onWalkableGround = _controller.isGrounded || WalkableGroundBelow(); if (!onWalkableGround) _climbLeftGround = true; else if (_climbLeftGround) { TopOutDetach(); // reached walkable ground at the top: hop up onto it instead of sliding back down the face return; } if (rotateTowardMovement) { if (onCeiling) { // Stay upright and face the horizontal move direction, like on the ground. Vector3 dir = alongSurface; dir.y = 0f; if (dir.sqrMagnitude > 1e-4f) { Quaternion target = Quaternion.LookRotation(dir.normalized, Vector3.up); transform.rotation = Quaternion.RotateTowards( transform.rotation, target, rotationSpeed * dt); } } else { // Face the wall (forward = into the wall), turned by the held mouse-drag yaw. // On leaving, the ground rotation eases the body back to its movement direction. Vector3 faceWall = -_wallNormal; faceWall.y = 0f; if (faceWall.sqrMagnitude > 0.0001f) { Vector3 dir = Quaternion.AngleAxis(_wallYaw, Vector3.up) * faceWall.normalized; Quaternion target = Quaternion.LookRotation(dir, Vector3.up); transform.rotation = Quaternion.RotateTowards( transform.rotation, target, rotationSpeed * dt); } } } } /// /// 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. /// private bool ProbeWall(out Vector3 normal, out float gap) { normal = _wallNormal; gap = 0f; Vector3 origin = transform.position + _controller.center; Vector3 dir = -_wallNormal; float extent = CapsuleExtent(dir); float maxDist = extent + wallMaxOffset + 0.5f; int count = Physics.RaycastNonAlloc( origin, dir, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore); float best = float.MaxValue; bool found = false; for (int i = 0; i < count; i++) { if (_wallProbe[i].collider.transform.IsChildOf(transform)) // skip our own colliders continue; if (_wallProbe[i].distance < best) { best = _wallProbe[i].distance; normal = _wallProbe[i].normal; found = true; } } if (found) gap = Mathf.Max(0f, best - extent); return found; } /// /// 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. /// private float CapsuleExtent(Vector3 dir) { float radius = _controller.radius; return radius + Mathf.Abs(dir.y) * (_controller.height * 0.5f - radius); } /// /// 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. /// private bool ProbeCeiling(out Vector3 normal, out float gap) { normal = Vector3.down; gap = 0f; Vector3 origin = transform.position + _controller.center; float half = _controller.height * 0.5f; float maxDist = half + wallMaxOffset + 0.5f; int count = Physics.RaycastNonAlloc( origin, Vector3.up, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore); float best = float.MaxValue; bool found = false; for (int i = 0; i < count; i++) { if (_wallProbe[i].collider.transform.IsChildOf(transform)) continue; if (_wallProbe[i].normal.y >= CeilingNormalThreshold) // not a ceiling continue; if (_wallProbe[i].distance < best) { best = _wallProbe[i].distance; normal = _wallProbe[i].normal; found = true; } } if (found) gap = Mathf.Max(0f, best - half); return found; } /// /// 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. /// private void MantleOverEdge(float dt) { _mantleTimer += dt; float speed = moveSpeed * wallClimbSpeedMultiplier; Vector3 forward = -_wallNormal; forward.y = 0f; forward = forward.sqrMagnitude > 1e-6f ? forward.normalized : transform.forward; if (!_mantleClearedFeet) { // Phase 1: climb straight up until no wall is left in front of the feet. _controller.Move(Vector3.up * (speed * dt)); if (!WallInFront(_controller.height * 0.5f)) _mantleClearedFeet = true; } else { // Phase 2: move forward onto the top surface, then finish. _controller.Move(forward * (speed * dt)); _mantleOverDist += speed * dt; if (_mantleOverDist >= _controller.radius * 2f + 0.15f || _controller.isGrounded) { _reentryCooldown = TopOutRegrabCooldown; ExitWallClimb(); return; } } // Safety: never mantle indefinitely (e.g. blocked by other geometry). if (_mantleTimer >= 2f) { _reentryCooldown = TopOutRegrabCooldown; ExitWallClimb(); } } /// /// 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. /// private bool WallInFront(float downOffset) { Vector3 origin = transform.position + _controller.center - Vector3.up * downOffset; float maxDist = _controller.radius + wallMaxOffset + 0.5f; int count = Physics.RaycastNonAlloc( origin, -_wallNormal, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore); for (int i = 0; i < count; i++) { if (_wallProbe[i].collider.transform.IsChildOf(transform)) continue; return true; } return false; } /// /// 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. /// private bool WalkableGroundBelow() { Vector3 origin = transform.position + _controller.center; float maxDist = _controller.center.y + _controller.stepOffset; int count = Physics.RaycastNonAlloc( origin, Vector3.down, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore); float best = float.MaxValue; Vector3 normal = Vector3.up; bool found = false; for (int i = 0; i < count; i++) { if (_wallProbe[i].collider.transform.IsChildOf(transform)) continue; if (_wallProbe[i].distance < best) { best = _wallProbe[i].distance; normal = _wallProbe[i].normal; found = true; } } return found && Vector3.Angle(normal, Vector3.up) <= _controller.slopeLimit; } /// /// 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 (), so the hop is never cut short. /// private void TopOutDetach() { _reentryCooldown = TopOutRegrabCooldown; ExitWallClimb(); if (wallTopOutBoost > 0f) _verticalVelocity = Mathf.Sqrt(wallTopOutBoost * -2f * gravity); } /// Leaves wall-climb mode and resets vertical velocity so the player falls cleanly. private void ExitWallClimb() { _isWallClimbing = false; _lostContactTimer = 0f; _verticalVelocity = 0f; _wallOffset = 0f; _wallYaw = 0f; _offsetDragging = false; _climbLeftGround = false; _mantling = false; _mantleClearedFeet = false; _mantleOverDist = 0f; _mantleTimer = 0f; } bool IsLeftMouseHeld() { #if ENABLE_INPUT_SYSTEM return Mouse.current != null && Mouse.current.leftButton.isPressed; #else return Input.GetMouseButton(0); #endif } Vector2 ReadMouseDelta() { #if ENABLE_INPUT_SYSTEM return Mouse.current != null ? Mouse.current.delta.ReadValue() : Vector2.zero; #else return new Vector2(Input.GetAxisRaw("Mouse X"), Input.GetAxisRaw("Mouse Y")) * 10f; #endif } /// /// Collects contact normals during Move: wall/ceiling contacts feed the /// wall-climb detector, the flattest upward contact becomes the ground normal. /// 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; } } }