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 on wall/ceiling contact) uses wall-relative controls: W /// moves toward the wall and climbs up once against it, S backs off and detaches, /// A/D strafe along the wall. Gravity and jumping are disabled while climbing; /// reaching the top edge mantles the character over onto the surface. /// /// 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("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 on wall/ceiling contact: 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("Speed multiplier for climbing up and strafing while wall-climbing (0.5 = half speed).")] [Range(0f, 1f)] public float wallClimbSpeedMultiplier = 0.5f; [Tooltip("Slow speed for moving toward (W) or away from (S) the wall while climbing.")] public float wallApproachSpeed = 0.5f; [Tooltip("Gap below which the character counts as fully against the wall (W then climbs up).")] public float wallContactDistance = 0.05f; [Tooltip("Gap from the wall at which holding back (S) leaves wall-climb mode.")] public float wallDetachDistance = 0.25f; [Tooltip("Grace period (seconds) of lost contact tolerated before leaving wall-climb mode.")] public float wallExitGrace = 0.15f; /// /// 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. private bool _isWallClimbing; private Vector3 _wallNormal; private Vector3 _wallNormalAccum; // summed wall normals during the latest Move private bool _wallContactThisFrame; private float _lostContactTimer; private readonly RaycastHit[] _wallProbe = new RaycastHit[8]; // Mantle (top-out) state. 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; } void Update() { float h = 0f, v = 0f; bool jumpPressed = 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; } #else h = Input.GetAxisRaw("Horizontal"); v = Input.GetAxisRaw("Vertical"); jumpPressed = Input.GetButtonDown("Jump"); #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(); // 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) { ApplyWallClimbMovement(h, v, Time.deltaTime); return; } // 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; } Vector3 velocity = moveDir * moveSpeed + 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 on any wall/ceiling contact. Leaving while climbing /// is handled in . /// private void UpdateWallClimbState() { if (_reentryCooldown > 0f) _reentryCooldown -= Time.deltaTime; if (!enableWallClimb || !ControlEnabled) { if (_isWallClimbing) ExitWallClimb(); return; } // The cooldown prevents instantly re-grabbing the edge we just mantled over. if (!_isWallClimbing && _wallContactThisFrame && _reentryCooldown <= 0f) { _isWallClimbing = true; _lostContactTimer = 0f; _verticalVelocity = 0f; _slideSpeed = 0f; } } /// /// Wall-relative movement while climbing: W approaches the wall then climbs up, /// S backs off and detaches, A/D strafe along the wall. The wall normal and gap /// are measured each frame by a raycast. No gravity, jump or forced alignment. /// private void ApplyWallClimbMovement(float h, float v, float dt) { if (_mantling) { MantleOverEdge(dt); return; } bool found = ProbeWall(out Vector3 probedNormal, out float gap); if (found) { _wallNormal = probedNormal; _lostContactTimer = 0f; } else { // The center ray lost the wall. If we are climbing up (W) and the wall // still exists at foot level, we reached the top edge -> mantle over. if (v > 0f && WallInFront(_controller.height * 0.5f)) { _mantling = true; _mantleClearedFeet = false; _mantleOverDist = 0f; _mantleTimer = 0f; MantleOverEdge(dt); return; } // Wall genuinely gone: leave after the grace period. _lostContactTimer += dt; gap = wallDetachDistance + 1f; // treat as "far" so it cannot block exit if (_lostContactTimer >= wallExitGrace) { ExitWallClimb(); return; } } // Basis on the wall plane: "up along the wall" and "right". Vector3 climbUp = Vector3.ProjectOnPlane(Vector3.up, _wallNormal); if (climbUp.sqrMagnitude < 1e-6f) // degenerate on a flat ceiling climbUp = Vector3.ProjectOnPlane(transform.forward, _wallNormal); climbUp.Normalize(); Vector3 right = Vector3.Cross(_wallNormal, climbUp).normalized; float climbSpeed = moveSpeed * wallClimbSpeedMultiplier; Vector3 velocity = right * (h * climbSpeed); // A/D strafe bool detach = false; if (v > 0f) { // W: approach the wall while there is a gap, otherwise climb up. if (gap > wallContactDistance) velocity += -_wallNormal * (v * wallApproachSpeed); else velocity += climbUp * (v * climbSpeed); } else if (v < 0f) { // S: back away (v < 0 => away from wall); detach once far enough. velocity += -_wallNormal * (v * wallApproachSpeed); if (gap >= wallDetachDistance) detach = true; } _controller.Move(velocity * dt); if (detach) ExitWallClimb(); } /// /// Raycasts from the character center toward the wall to find the current /// normal and the gap to it. Our own colliders are skipped. False = no wall. /// private bool ProbeWall(out Vector3 normal, out float gap) { normal = _wallNormal; gap = 0f; Vector3 origin = transform.position + _controller.center; float radius = _controller.radius; float maxDist = radius + wallDetachDistance + 0.5f; int count = Physics.RaycastNonAlloc( origin, -_wallNormal, _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 - radius); 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 = 0.3f; ExitWallClimb(); return; } } // Safety: never mantle indefinitely (e.g. blocked by other geometry). if (_mantleTimer >= 2f) { _reentryCooldown = 0.3f; 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 + wallDetachDistance + 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; } /// Leaves wall-climb mode and resets vertical velocity so the player falls cleanly. private void ExitWallClimb() { _isWallClimbing = false; _lostContactTimer = 0f; _verticalVelocity = 0f; _mantling = false; _mantleClearedFeet = false; _mantleOverDist = 0f; _mantleTimer = 0f; } /// /// 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; } } }