Files
chameleon/Assets/Scripts/PlayerCharacterController.cs
T
adminandClaude Opus 4.8 f758c96dc5 Trim comments and use implicit null checks for Unity objects
Reduce comments to non-obvious "why" notes and convert UnityEngine.Object
null checks to implicit bool (if (obj) / if (!obj)). Input System devices
(Mouse.current, Keyboard.current) and plain arrays keep explicit != null.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:26:41 +02:00

477 lines
17 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 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".
/// </summary>
[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;
/// <summary>
/// When false, movement/jump input is ignored but gravity and ground contact
/// keep running. Toggled by the GameModeManager.
/// </summary>
public bool ControlEnabled { get; set; } = true;
/// <summary>True while in wall-climb mode.</summary>
public bool IsWallClimbing => _isWallClimbing;
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<CharacterController>();
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);
}
}
/// <summary>
/// Enters wall-climb mode on any wall/ceiling contact. Leaving while climbing
/// is handled in <see cref="ApplyWallClimbMovement"/>.
/// </summary>
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;
}
}
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <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 * 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();
}
}
/// <summary>
/// Horizontal ray toward the wall from a point below the center. Distinguishes a
/// top edge (wall still present lower down) from the wall being truly gone.
/// </summary>
private bool WallInFront(float downOffset)
{
Vector3 origin = transform.position + _controller.center - Vector3.up * downOffset;
float maxDist = _controller.radius + 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;
}
/// <summary>Leaves wall-climb mode and resets vertical velocity so the player falls cleanly.</summary>
private void ExitWallClimb()
{
_isWallClimbing = false;
_lostContactTimer = 0f;
_verticalVelocity = 0f;
_mantling = false;
_mantleClearedFeet = false;
_mantleOverDist = 0f;
_mantleTimer = 0f;
}
/// <summary>
/// Collects contact normals during Move: wall/ceiling contacts feed the
/// wall-climb detector, the flattest upward contact becomes the ground normal.
/// </summary>
private void OnControllerColliderHit(ControllerColliderHit hit)
{
if (enableWallClimb)
{
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;
}
}
}