- W/S climb straight up/down at a constant speed (camera pitch no longer tilts or slows them); the camera only drives A/D strafe along the wall. - The body now always faces the wall. Holding LMB and dragging sets the wall pose: vertical drag leans out from / back to the wall (offset eases back to zero while moving, never detaches), horizontal drag turns the body (yaw held until leaving the wall, eased back by ground rotation). - Pause the camera orbit while dragging the wall pose (LookSuppressed). - Drop the camera-to-wall angle logic and the old approach/detach-distance model; add wallMaxOffset, wallOffsetSensitivity, wallOffsetReturnSpeed and wallRotateSensitivity. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
568 lines
21 KiB
C#
568 lines
21 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): 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 near a wall.
|
|
/// 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("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("Grace period (seconds) of lost contact tolerated before leaving wall-climb mode.")]
|
|
public float wallExitGrace = 0.15f;
|
|
|
|
[Tooltip("Camera orbit input; paused while dragging the wall-climb offset. Auto-found if empty.")]
|
|
[SerializeField] private ThirdPersonOrbitInput cameraOrbit;
|
|
|
|
/// <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 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 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;
|
|
if (!cameraOrbit)
|
|
cameraOrbit = FindFirstObjectByType<ThirdPersonOrbitInput>();
|
|
}
|
|
|
|
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)
|
|
{
|
|
// 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, 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;
|
|
}
|
|
|
|
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)
|
|
{
|
|
if (_isWallClimbing)
|
|
ExitWallClimb();
|
|
return;
|
|
}
|
|
|
|
// Re-grab only while falling/standing (not while rising from a jump) and
|
|
// only under player control; once climbing we stay attached across control
|
|
// toggles (e.g. paint mode) so switching modes never drops us off the wall.
|
|
// The cooldown prevents instantly re-grabbing the edge we just mantled over.
|
|
if (!_isWallClimbing && ControlEnabled && _wallContactThisFrame
|
|
&& _reentryCooldown <= 0f && _verticalVelocity <= 0f)
|
|
{
|
|
_isWallClimbing = true;
|
|
_lostContactTimer = 0f;
|
|
_verticalVelocity = 0f;
|
|
_slideSpeed = 0f;
|
|
_wallOffset = 0f;
|
|
_wallYaw = 0f;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Movement while climbing. W/S climb straight up/down the wall at a constant speed
|
|
/// (camera pitch never tilts them); A/D strafe left/right along the wall relative to
|
|
/// the camera. The body always faces the wall. Holding the left mouse button and
|
|
/// dragging sets the wall pose and pauses the camera orbit: vertical drag leans out
|
|
/// from / back to the wall via <see cref="_wallOffset"/> (mouse down = away; eases back
|
|
/// to zero while moving, never detaches), horizontal drag turns the body via
|
|
/// <see cref="_wallYaw"/> (held until leaving the wall). Wall normal and gap are
|
|
/// re-measured each frame.
|
|
/// </summary>
|
|
private void ApplyWallClimbMovement(float h, float v, bool jumpPressed, float dt)
|
|
{
|
|
_offsetDragging = false;
|
|
|
|
if (_mantling)
|
|
{
|
|
MantleOverEdge(dt);
|
|
return;
|
|
}
|
|
|
|
// Jump off the wall: detach and jump straight up. Re-grab stays blocked
|
|
// while rising (UpdateWallClimbState) and resumes once falling near a wall.
|
|
if (jumpPressed)
|
|
{
|
|
ExitWallClimb();
|
|
_verticalVelocity = Mathf.Sqrt(jumpHeight * -2f * gravity);
|
|
return;
|
|
}
|
|
|
|
bool found = ProbeWall(out Vector3 probedNormal, out float gap);
|
|
if (found)
|
|
{
|
|
_wallNormal = probedNormal;
|
|
_lostContactTimer = 0f;
|
|
}
|
|
|
|
// W/S climb straight up/down the wall at a constant speed (camera pitch never
|
|
// tilts or slows them); the camera only sets left/right through A/D.
|
|
Vector3 wallUp = Vector3.ProjectOnPlane(Vector3.up, _wallNormal);
|
|
Vector3 wallRight = cameraTransform
|
|
? Vector3.ProjectOnPlane(cameraTransform.right, _wallNormal)
|
|
: Vector3.zero;
|
|
if (wallUp.sqrMagnitude > 1e-6f) wallUp.Normalize();
|
|
if (wallRight.sqrMagnitude > 1e-6f) wallRight.Normalize();
|
|
Vector3 alongWall = wallUp * v + wallRight * h;
|
|
if (alongWall.sqrMagnitude > 1f) alongWall.Normalize();
|
|
bool hasMoveInput = (h * h + v * v) > 1e-4f;
|
|
|
|
if (!found)
|
|
{
|
|
// Center ray lost the wall while moving up its face -> top edge: mantle.
|
|
if (alongWall.y > 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;
|
|
if (_lostContactTimer >= wallExitGrace)
|
|
{
|
|
ExitWallClimb();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Left button + drag adjusts the wall pose: vertical = lean-out offset (mouse down =
|
|
// away), horizontal = body yaw. The offset eases back to zero while moving and never
|
|
// detaches; the yaw is held until leaving the wall. 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 (alongWall.sqrMagnitude > 1e-6f)
|
|
velocity += alongWall.normalized * climbSpeed;
|
|
if (found)
|
|
{
|
|
// Drive the gap toward the desired offset; the closed loop also keeps light
|
|
// contact when flush (offset 0). Clamp guards against probe spikes.
|
|
const float maxPerpSpeed = 8f;
|
|
velocity += _wallNormal * Mathf.Clamp((_wallOffset - gap) / dt, -maxPerpSpeed, maxPerpSpeed);
|
|
}
|
|
|
|
_controller.Move(velocity * dt);
|
|
|
|
// Always face the wall (forward = into the wall), turned by the held mouse-drag yaw.
|
|
// This holds the whole climb; on leaving, the ground rotation eases the body back to
|
|
// its movement direction (RotateTowards, so it interpolates rather than snapping).
|
|
if (rotateTowardMovement)
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <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 + wallMaxOffset + 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 + 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;
|
|
}
|
|
|
|
/// <summary>Leaves wall-climb mode and resets vertical velocity so the player falls cleanly.</summary>
|
|
private void ExitWallClimb()
|
|
{
|
|
_isWallClimbing = false;
|
|
_lostContactTimer = 0f;
|
|
_verticalVelocity = 0f;
|
|
_wallOffset = 0f;
|
|
_wallYaw = 0f;
|
|
_offsetDragging = 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
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|
|
}
|