717 lines
29 KiB
C#
717 lines
29 KiB
C#
using UnityEngine;
|
|
|
|
namespace Ashwild.Player
|
|
{
|
|
[DisallowMultipleComponent]
|
|
public class PlayerLocomotion : MonoBehaviour
|
|
{
|
|
// ============================================================
|
|
// Tunables
|
|
// ============================================================
|
|
|
|
[Header("Walk")]
|
|
[SerializeField] private float walkSpeed = 5.5f;
|
|
[SerializeField] private float acceleration = 17.5f;
|
|
[SerializeField] private float deceleration = 20f;
|
|
[SerializeField, Range(0f, 1f), Tooltip("Multiplier on acceleration when airborne (0 = no control in air, 1 = same as ground).")]
|
|
private float airControl = 0.4f;
|
|
|
|
[Header("Capsule (the standing height is the player's base size — everything else scales from it)")]
|
|
[SerializeField] private float standingHeight = 2f;
|
|
[SerializeField] private float capsuleRadius = 0.5f;
|
|
[SerializeField, Tooltip("Optional PhysicsMaterial asset for the player capsule. If null, a frictionless one is created at runtime.")]
|
|
private PhysicsMaterial frictionlessMaterial;
|
|
|
|
[Header("Ground probe")]
|
|
[SerializeField] private float groundCheckRadius = 0.3f;
|
|
[SerializeField, Tooltip("Tiny leniency below the capsule bottom for stable ground detection. 0 = ground check flickers due to floating-point precision and may miss jump presses. 0.05 ≈ 5cm = barely visible float, much more reliable.")]
|
|
private float groundCheckDistance = 0.05f;
|
|
[SerializeField, Range(0f, 89f)] private float maxSlopeAngle = 45f;
|
|
[SerializeField] private LayerMask groundMask = ~0;
|
|
|
|
[Header("Gravity")]
|
|
[SerializeField] private float gravityMultiplier = 1.75f;
|
|
|
|
[Header("Sprint")]
|
|
[SerializeField] private bool enableSprint = true;
|
|
[SerializeField, Tooltip("True = hold sprint key. False = press to toggle.")]
|
|
private bool sprintHoldMode = true;
|
|
[SerializeField, Tooltip("True = sprint only allowed when input.y > 0 (forward).")]
|
|
private bool sprintRequiresForward = false;
|
|
[SerializeField] private float sprintSpeed = 10f;
|
|
|
|
[Header("Crouch")]
|
|
[SerializeField] private bool enableCrouch = true;
|
|
[SerializeField, Tooltip("True = press to toggle. False = hold to crouch.")]
|
|
private bool crouchToggleMode = true;
|
|
[SerializeField] private float crouchSpeed = 1.25f;
|
|
[SerializeField] private float crouchHeight = 1.25f;
|
|
[SerializeField] private float standingCameraHeight = 0.85f;
|
|
[SerializeField] private float crouchCameraHeight = 0.25f;
|
|
[SerializeField] private float crouchTransitionSpeed = 3f;
|
|
|
|
[Header("Jump")]
|
|
[SerializeField] private bool enableJump = true;
|
|
[SerializeField] private float jumpForce = 7f;
|
|
[SerializeField, Tooltip("Time after leaving the ground during which a jump still counts as a ground jump.")]
|
|
private float coyoteTime = 0.12f;
|
|
[SerializeField, Tooltip("Time before landing during which a press is buffered and consumed on touchdown.")]
|
|
private float jumpBufferTime = 0.15f;
|
|
|
|
[Header("Multi-jump (double jump, triple jump...)")]
|
|
[SerializeField] private bool enableMultiJump = false;
|
|
[SerializeField, Range(1, 4), Tooltip("Extra jumps allowed after the first ground jump. 1 = double jump.")]
|
|
private int extraJumps = 1;
|
|
[SerializeField, Tooltip("Force scale for air jumps (1 = same as ground jump).")]
|
|
private float multiJumpForceMultiplier = 1f;
|
|
|
|
[Header("Slide")]
|
|
[SerializeField] private bool enableSlide = false;
|
|
[SerializeField, Tooltip("Minimum horizontal speed required to start a slide.")]
|
|
private float slideMinStartSpeed = 6f;
|
|
[SerializeField, Tooltip("Burst of speed added at slide start.")]
|
|
private float slideInitialImpulse = 4f;
|
|
[SerializeField] private float slideMaxDuration = 1f;
|
|
[SerializeField, Tooltip("Slide cannot be canceled before this elapsed time.")]
|
|
private float slideMinDuration = 0.25f;
|
|
[SerializeField, Tooltip("How fast the slide loses speed (multiplier on deceleration).")]
|
|
private float slideDecelMultiplier = 2f;
|
|
[SerializeField] private float slideCapsuleHeight = 0.7f;
|
|
[SerializeField] private float slideCameraHeight = 0.1f;
|
|
[SerializeField] private bool slideCancelOnJump = true;
|
|
|
|
[Header("Landing stun")]
|
|
[SerializeField] private bool enableLandingStun = true;
|
|
[SerializeField] private float landingStunMinSpeed = 5f;
|
|
[SerializeField] private float landingStunMaxDuration = 1.2f;
|
|
|
|
// ============================================================
|
|
// Wiring
|
|
// ============================================================
|
|
|
|
private Rigidbody rb;
|
|
private CapsuleCollider capsule;
|
|
|
|
// Read-only ref to PlayerCamera to query Yaw (movement direction).
|
|
// The camera still drives yaw; locomotion only reads it.
|
|
private PlayerCamera playerCamera;
|
|
|
|
// ============================================================
|
|
// Runtime state
|
|
// ============================================================
|
|
|
|
private Vector2 moveInput;
|
|
private bool sprintInputHeld; // raw input
|
|
private bool sprintInputToggle; // toggled state if !sprintHoldMode
|
|
private bool isSprinting;
|
|
private bool isCrouching;
|
|
private bool isSliding;
|
|
private bool isGrounded;
|
|
private bool wasGrounded;
|
|
private bool isOnSlope;
|
|
private Vector3 slopeNormal = Vector3.up;
|
|
private PhysicsMaterial groundPhysicsMaterial;
|
|
|
|
private float timeSinceLeftGround;
|
|
private float jumpBufferTimer;
|
|
private int extraJumpsUsed;
|
|
|
|
// Jump requests queued by input handlers and applied at the very end of FixedUpdate
|
|
// (after movement / slope projection) so nothing can stomp the velocity Y.
|
|
private bool pendingGroundJump;
|
|
private bool pendingMultiJump;
|
|
private bool pendingSlideCancel;
|
|
|
|
private float slideTimer;
|
|
private Vector3 slideDirection;
|
|
|
|
private float airbornePeakY; // highest Y reached during the current airborne session
|
|
private float lastLandingDrop; // exposed for debugging; meters of actual drop on the last landing
|
|
private float landingStunTimer;
|
|
private float currentCameraHeight;
|
|
|
|
// ============================================================
|
|
// Public API (consumed by PlayerLifecycle and helpful for debugging)
|
|
// ============================================================
|
|
|
|
public bool IsGrounded => isGrounded;
|
|
public bool WasGrounded => wasGrounded;
|
|
public bool IsSprinting => isSprinting;
|
|
public bool IsCrouching => isCrouching;
|
|
public bool IsSliding => isSliding;
|
|
public Vector2 MoveInput => moveInput;
|
|
public float LastLandingDrop => lastLandingDrop;
|
|
public PhysicsMaterial GroundPhysicsMaterial => groundPhysicsMaterial;
|
|
|
|
public void SetDead(bool dead)
|
|
{
|
|
if (dead)
|
|
{
|
|
rb.linearVelocity = Vector3.zero;
|
|
ApplyCapsuleHeight(0.4f);
|
|
PlayerEvents.RaiseDied();
|
|
}
|
|
else
|
|
{
|
|
ApplyCapsuleHeight(standingHeight);
|
|
isCrouching = false;
|
|
isSliding = false;
|
|
PlayerEvents.RaiseRespawned();
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Lifecycle
|
|
// ============================================================
|
|
|
|
private void Awake()
|
|
{
|
|
rb = GetComponent<Rigidbody>();
|
|
capsule = GetComponent<CapsuleCollider>();
|
|
playerCamera = GetComponent<PlayerCamera>();
|
|
|
|
currentCameraHeight = standingCameraHeight;
|
|
|
|
rb.useGravity = false;
|
|
rb.freezeRotation = true;
|
|
rb.interpolation = RigidbodyInterpolation.Interpolate;
|
|
rb.collisionDetectionMode = CollisionDetectionMode.Continuous;
|
|
|
|
capsule.radius = capsuleRadius;
|
|
ApplyCapsuleHeight(standingHeight);
|
|
|
|
if (frictionlessMaterial != null)
|
|
{
|
|
capsule.material = frictionlessMaterial;
|
|
}
|
|
else
|
|
{
|
|
var mat = new PhysicsMaterial("PlayerNoFriction");
|
|
mat.staticFriction = 0f;
|
|
mat.dynamicFriction = 0f;
|
|
mat.frictionCombine = PhysicsMaterialCombine.Minimum;
|
|
capsule.material = mat;
|
|
}
|
|
}
|
|
|
|
private void OnEnable()
|
|
{
|
|
PlayerEvents.MoveInput += OnMoveInput;
|
|
PlayerEvents.SprintHeld += OnSprintHeld;
|
|
PlayerEvents.JumpPressed += OnJumpPressed;
|
|
PlayerEvents.CrouchPressed += OnCrouchPressed;
|
|
}
|
|
|
|
private void OnDisable()
|
|
{
|
|
PlayerEvents.MoveInput -= OnMoveInput;
|
|
PlayerEvents.SprintHeld -= OnSprintHeld;
|
|
PlayerEvents.JumpPressed -= OnJumpPressed;
|
|
PlayerEvents.CrouchPressed -= OnCrouchPressed;
|
|
}
|
|
|
|
// ============================================================
|
|
// Input handlers
|
|
// ============================================================
|
|
|
|
private void OnMoveInput(Vector2 v) => moveInput = v;
|
|
|
|
private void OnSprintHeld(bool held)
|
|
{
|
|
if (!enableSprint) return;
|
|
sprintInputHeld = held;
|
|
if (!sprintHoldMode && held) sprintInputToggle = !sprintInputToggle;
|
|
RecomputeSprintState();
|
|
}
|
|
|
|
private void OnJumpPressed()
|
|
{
|
|
if (!enableJump) return;
|
|
if (IsBlocked()) return;
|
|
|
|
// Slide → cancel slide + jump (queued)
|
|
if (isSliding && slideCancelOnJump && slideTimer >= slideMinDuration)
|
|
{
|
|
pendingSlideCancel = true;
|
|
pendingGroundJump = true;
|
|
return;
|
|
}
|
|
|
|
// Ground jump (or coyote) — queued
|
|
if (isGrounded || timeSinceLeftGround < coyoteTime)
|
|
{
|
|
pendingGroundJump = true;
|
|
return;
|
|
}
|
|
|
|
// Multi-jump in air — queued
|
|
if (enableMultiJump && extraJumpsUsed < extraJumps)
|
|
{
|
|
pendingMultiJump = true;
|
|
return;
|
|
}
|
|
|
|
// Buffer for an upcoming landing
|
|
jumpBufferTimer = jumpBufferTime;
|
|
}
|
|
|
|
private void OnCrouchPressed()
|
|
{
|
|
if (IsBlocked()) return;
|
|
|
|
// Slide trigger has priority over crouch
|
|
if (enableSlide && CanStartSlide())
|
|
{
|
|
StartSlide();
|
|
return;
|
|
}
|
|
|
|
if (!enableCrouch) return;
|
|
|
|
if (crouchToggleMode)
|
|
{
|
|
if (isCrouching) { TryStandUp(); }
|
|
else { isCrouching = true; PlayerEvents.RaiseCrouchChanged(true); }
|
|
}
|
|
else
|
|
{
|
|
// hold mode: pressing toggles only on rising edge here; release is handled in Update by checking input
|
|
// For now we treat OnCrouchPressed as a toggle since OnCrouchReleased doesn't exist on the bus.
|
|
// Hold-to-crouch is reserved for a future input pass.
|
|
if (isCrouching) { TryStandUp(); }
|
|
else { isCrouching = true; PlayerEvents.RaiseCrouchChanged(true); }
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Update loops
|
|
// ============================================================
|
|
|
|
private void Update()
|
|
{
|
|
if (IsBlocked()) return;
|
|
ApplyVisualCrouchHeight();
|
|
}
|
|
|
|
private void FixedUpdate()
|
|
{
|
|
if (IsBlocked()) { HandleBlockedPhysics(); return; }
|
|
|
|
TickTimers(Time.fixedDeltaTime);
|
|
|
|
wasGrounded = isGrounded;
|
|
GroundCheck();
|
|
|
|
// Track the airborne session for fall-damage-by-drop-distance.
|
|
if (!isGrounded && wasGrounded)
|
|
{
|
|
// Just left the ground — start the session at current Y.
|
|
airbornePeakY = transform.position.y;
|
|
timeSinceLeftGround = 0f;
|
|
}
|
|
else if (!isGrounded)
|
|
{
|
|
if (transform.position.y > airbornePeakY) airbornePeakY = transform.position.y;
|
|
}
|
|
|
|
if (isGrounded && !wasGrounded) HandleLanding();
|
|
|
|
if (landingStunTimer > 0f)
|
|
{
|
|
rb.linearVelocity = new Vector3(0f, rb.linearVelocity.y, 0f);
|
|
ApplyGravity();
|
|
return;
|
|
}
|
|
|
|
ApplyGravity();
|
|
ApplyCollisionSnap();
|
|
|
|
if (isSliding) HandleSlide();
|
|
else HandleMovement();
|
|
|
|
// Process jump requests LAST so neither gravity nor movement projections can stomp
|
|
// the jump impulse. Same pattern as the original PlayerMouvement.HandleJump().
|
|
ProcessPendingJumps();
|
|
}
|
|
|
|
private void ProcessPendingJumps()
|
|
{
|
|
if (pendingGroundJump)
|
|
{
|
|
if (pendingSlideCancel) { EndSlide(); pendingSlideCancel = false; }
|
|
PerformGroundJump();
|
|
pendingGroundJump = false;
|
|
}
|
|
else if (pendingMultiJump)
|
|
{
|
|
extraJumpsUsed++;
|
|
rb.linearVelocity = new Vector3(rb.linearVelocity.x, jumpForce * multiJumpForceMultiplier, rb.linearVelocity.z);
|
|
isCrouching = false;
|
|
PlayerEvents.RaiseMultiJumpPerformed(extraJumpsUsed);
|
|
pendingMultiJump = false;
|
|
}
|
|
}
|
|
|
|
// While input is locked (inventory / pause menu / death), the player must not keep its
|
|
// control momentum. Without this, FixedUpdate used to early-return BEFORE ApplyGravity,
|
|
// so the Rigidbody (manual gravity, frictionless capsule) kept its velocity forever and
|
|
// the player floated/flew. Here we keep gravity + ground detection so a player who pauses
|
|
// mid-jump still falls and lands, but we kill horizontal velocity so there is no gliding,
|
|
// and we clear any queued input so nothing fires when control resumes.
|
|
private void HandleBlockedPhysics()
|
|
{
|
|
moveInput = Vector2.zero;
|
|
sprintInputHeld = false;
|
|
pendingGroundJump = false;
|
|
pendingMultiJump = false;
|
|
pendingSlideCancel = false;
|
|
jumpBufferTimer = 0f;
|
|
|
|
wasGrounded = isGrounded;
|
|
GroundCheck();
|
|
|
|
ApplyGravity();
|
|
|
|
// Keep vertical velocity (so gravity/landing works), drop horizontal glide.
|
|
rb.linearVelocity = new Vector3(0f, rb.linearVelocity.y, 0f);
|
|
}
|
|
|
|
// ============================================================
|
|
// Ground probe
|
|
// ============================================================
|
|
|
|
private void GroundCheck()
|
|
{
|
|
// Spherecast straight down from the capsule center. The cast length is set so the
|
|
// sphere bottom reaches exactly capsule bottom + groundCheckDistance, so a value of
|
|
// 0 means "no float at all" and small values give a tiny leniency for terrain noise.
|
|
Vector3 origin = transform.position + capsule.center;
|
|
float distance = capsule.height * 0.5f - groundCheckRadius + groundCheckDistance;
|
|
bool grounded = Physics.SphereCast(origin, groundCheckRadius, Vector3.down, out RaycastHit hit, distance, groundMask, QueryTriggerInteraction.Ignore);
|
|
|
|
if (grounded)
|
|
{
|
|
isGrounded = true;
|
|
slopeNormal = hit.normal;
|
|
float angle = Vector3.Angle(Vector3.up, slopeNormal);
|
|
isOnSlope = angle > 0.1f && angle <= maxSlopeAngle;
|
|
var mat = hit.collider != null ? hit.collider.sharedMaterial : null;
|
|
if (mat != groundPhysicsMaterial)
|
|
{
|
|
groundPhysicsMaterial = mat;
|
|
PlayerEvents.RaiseGroundMaterialChanged(mat);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
isGrounded = false;
|
|
isOnSlope = false;
|
|
slopeNormal = Vector3.up;
|
|
}
|
|
|
|
if (isGrounded != wasGrounded)
|
|
{
|
|
float drop = isGrounded ? Mathf.Max(0f, airbornePeakY - transform.position.y) : 0f;
|
|
PlayerEvents.RaiseGroundedChanged(isGrounded, drop);
|
|
PlayerEvents.RaiseSlopeChanged(isOnSlope, slopeNormal);
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Landing
|
|
// ============================================================
|
|
|
|
private void HandleLanding()
|
|
{
|
|
extraJumpsUsed = 0;
|
|
|
|
// Drop distance is the only thing that should drive damage / stun magnitude.
|
|
// It uses peak Y reached while airborne, so a long forward jump with no real
|
|
// vertical drop deals no damage, and collisions that bleed Y velocity into X
|
|
// can't hide a big fall.
|
|
lastLandingDrop = Mathf.Max(0f, airbornePeakY - transform.position.y);
|
|
|
|
float damage = 0f;
|
|
if (PlayerStats.Instance != null)
|
|
damage = PlayerStats.Instance.ApplyFallDamage(lastLandingDrop);
|
|
|
|
if (damage > 0f)
|
|
{
|
|
PlayerEvents.RaiseFallDamageApplied(damage);
|
|
if (enableLandingStun)
|
|
{
|
|
float maxHp = PlayerStats.Instance != null ? PlayerStats.Instance.MaxHealth : 100f;
|
|
float ratio = Mathf.Clamp01(damage / maxHp);
|
|
landingStunTimer = ratio * landingStunMaxDuration;
|
|
if (landingStunTimer > 0f) PlayerEvents.RaiseLandingStunStarted(landingStunTimer);
|
|
}
|
|
}
|
|
|
|
// Jump buffer cashed in on landing — queued so movement projection can't kill it.
|
|
if (jumpBufferTimer > 0f && enableJump)
|
|
{
|
|
jumpBufferTimer = 0f;
|
|
pendingGroundJump = true;
|
|
}
|
|
|
|
airbornePeakY = transform.position.y;
|
|
}
|
|
|
|
// ============================================================
|
|
// Movement
|
|
// ============================================================
|
|
|
|
private void HandleMovement()
|
|
{
|
|
float targetSpeed = isCrouching ? crouchSpeed : (isSprinting ? sprintSpeed : walkSpeed);
|
|
|
|
// Camera owns yaw; we read it as a plain float (player root no longer rotates).
|
|
float yaw = playerCamera != null ? playerCamera.Yaw : 0f;
|
|
Quaternion lookDirection = Quaternion.Euler(0f, yaw, 0f);
|
|
Vector3 forward = lookDirection * Vector3.forward;
|
|
Vector3 right = lookDirection * Vector3.right;
|
|
Vector3 inputDirection = (right * moveInput.x + forward * moveInput.y).normalized;
|
|
|
|
Vector3 horizontal = new Vector3(rb.linearVelocity.x, 0f, rb.linearVelocity.z);
|
|
bool hasInput = moveInput.sqrMagnitude > 0.01f;
|
|
|
|
// Pick the rate: deceleration whenever there's no input OR the input is pushing
|
|
// against the current velocity. This kills the "ice skating" feel on quick turns.
|
|
float rate;
|
|
if (!hasInput)
|
|
{
|
|
rate = deceleration;
|
|
}
|
|
else if (horizontal.sqrMagnitude > 0.01f && Vector3.Dot(inputDirection, horizontal.normalized) < 0.5f)
|
|
{
|
|
rate = deceleration;
|
|
}
|
|
else
|
|
{
|
|
rate = acceleration;
|
|
}
|
|
if (!isGrounded) rate *= airControl;
|
|
|
|
if (isOnSlope && isGrounded)
|
|
{
|
|
if (!hasInput)
|
|
{
|
|
// No input on a slope — anchor in place. With a frictionless physics material
|
|
// and no gravity (we skip ApplyGravity on slope), MoveTowards toward zero is
|
|
// slow enough that physics penetration correction can drift the capsule
|
|
// visibly down the slope. Snapping velocity to zero kills that completely.
|
|
rb.linearVelocity = Vector3.zero;
|
|
}
|
|
else
|
|
{
|
|
// Project the input onto the slope tangent. Without compensation, the world-
|
|
// horizontal speed would drop to cos(slopeAngle) * targetSpeed — a 40° slope
|
|
// would visibly slow you to 76% of walking speed. We divide by the horizontal
|
|
// factor so the WORLD-HORIZONTAL speed always equals targetSpeed regardless
|
|
// of slope angle.
|
|
Vector3 slopeDirection = Vector3.ProjectOnPlane(inputDirection, slopeNormal).normalized;
|
|
float horizontalFactor = new Vector2(slopeDirection.x, slopeDirection.z).magnitude;
|
|
float compensatedSpeed = horizontalFactor > 0.1f ? targetSpeed / horizontalFactor : targetSpeed;
|
|
Vector3 targetVelocity = slopeDirection * compensatedSpeed;
|
|
rb.linearVelocity = Vector3.MoveTowards(rb.linearVelocity, targetVelocity, rate * Time.fixedDeltaTime);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Vector3 targetVelocity = inputDirection * targetSpeed;
|
|
horizontal = Vector3.MoveTowards(horizontal, targetVelocity, rate * Time.fixedDeltaTime);
|
|
rb.linearVelocity = new Vector3(horizontal.x, rb.linearVelocity.y, horizontal.z);
|
|
}
|
|
|
|
PlayerEvents.RaiseVelocityChanged(new Vector3(rb.linearVelocity.x, 0f, rb.linearVelocity.z));
|
|
}
|
|
|
|
// ============================================================
|
|
// Gravity
|
|
// ============================================================
|
|
|
|
private void ApplyGravity()
|
|
{
|
|
if (isGrounded)
|
|
{
|
|
// Zero only NEGATIVE Y velocity (small residual after landing). A POSITIVE Y
|
|
// means the player just pressed Jump in the previous input frame — we must
|
|
// preserve it, otherwise the jump dies before the physics has a chance to lift
|
|
// the capsule out of the ground-check sphere.
|
|
if (!isOnSlope && rb.linearVelocity.y < 0f)
|
|
rb.linearVelocity = new Vector3(rb.linearVelocity.x, 0f, rb.linearVelocity.z);
|
|
return;
|
|
}
|
|
rb.AddForce(Physics.gravity * gravityMultiplier, ForceMode.Acceleration);
|
|
}
|
|
|
|
private void ApplyCollisionSnap()
|
|
{
|
|
// Reserved: handles edge cases where the player gets micro-popped off the ground.
|
|
// Currently inherits the original behavior (no extra snap). Kept as a hook.
|
|
}
|
|
|
|
// ============================================================
|
|
// Sprint
|
|
// ============================================================
|
|
|
|
private void RecomputeSprintState()
|
|
{
|
|
bool wantsSprint = sprintHoldMode ? sprintInputHeld : sprintInputToggle;
|
|
if (sprintRequiresForward) wantsSprint &= moveInput.y > 0f;
|
|
|
|
bool nowSprinting = enableSprint && wantsSprint && !isCrouching && !isSliding;
|
|
if (nowSprinting != isSprinting)
|
|
{
|
|
isSprinting = nowSprinting;
|
|
PlayerEvents.RaiseSprintChanged(isSprinting);
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Crouch
|
|
// ============================================================
|
|
|
|
private void TryStandUp()
|
|
{
|
|
float clearance = standingHeight - capsule.height;
|
|
Vector3 origin = transform.position + capsule.center + Vector3.up * (capsule.height * 0.5f);
|
|
if (!Physics.Raycast(origin, Vector3.up, clearance + 0.1f, groundMask))
|
|
{
|
|
isCrouching = false;
|
|
PlayerEvents.RaiseCrouchChanged(false);
|
|
RecomputeSprintState();
|
|
}
|
|
}
|
|
|
|
private void ApplyVisualCrouchHeight()
|
|
{
|
|
float targetCollHeight =
|
|
isSliding ? slideCapsuleHeight :
|
|
isCrouching ? crouchHeight :
|
|
standingHeight;
|
|
if (!Mathf.Approximately(capsule.height, targetCollHeight))
|
|
ApplyCapsuleHeight(targetCollHeight);
|
|
|
|
float targetCamY =
|
|
isSliding ? slideCameraHeight :
|
|
isCrouching ? crouchCameraHeight :
|
|
standingCameraHeight;
|
|
|
|
currentCameraHeight = Mathf.Lerp(currentCameraHeight, targetCamY, crouchTransitionSpeed * Time.deltaTime);
|
|
PlayerEvents.RaiseCameraHeightChanged(currentCameraHeight);
|
|
}
|
|
|
|
private void ApplyCapsuleHeight(float height)
|
|
{
|
|
// Convention: player root pivot stays at the center of the standing capsule (matches
|
|
// the Mixamo character pivot). The capsule bottom is at root.y - standingHeight/2, and
|
|
// when the height shrinks (crouch/slide) only the TOP descends — the feet stay fixed.
|
|
float h = Mathf.Max(height, 2f * capsuleRadius);
|
|
capsule.height = h;
|
|
capsule.center = new Vector3(0f, -(standingHeight - h) * 0.5f, 0f);
|
|
PlayerEvents.RaiseCapsuleHeightChanged(h, capsule.center);
|
|
}
|
|
|
|
// ============================================================
|
|
// Jump
|
|
// ============================================================
|
|
|
|
private void PerformGroundJump()
|
|
{
|
|
rb.linearVelocity = new Vector3(rb.linearVelocity.x, jumpForce, rb.linearVelocity.z);
|
|
isCrouching = false;
|
|
extraJumpsUsed = 0;
|
|
timeSinceLeftGround = coyoteTime; // prevent immediate coyote re-jump
|
|
PlayerEvents.RaiseJumped();
|
|
}
|
|
|
|
// ============================================================
|
|
// Slide
|
|
// ============================================================
|
|
|
|
private bool CanStartSlide()
|
|
{
|
|
if (isSliding || isCrouching || !isGrounded) return false;
|
|
if (!isSprinting) return false;
|
|
Vector3 h = new Vector3(rb.linearVelocity.x, 0f, rb.linearVelocity.z);
|
|
return h.magnitude >= slideMinStartSpeed;
|
|
}
|
|
|
|
private void StartSlide()
|
|
{
|
|
isSliding = true;
|
|
slideTimer = 0f;
|
|
Vector3 h = new Vector3(rb.linearVelocity.x, 0f, rb.linearVelocity.z);
|
|
if (h.sqrMagnitude > 0.01f)
|
|
{
|
|
slideDirection = h.normalized;
|
|
}
|
|
else
|
|
{
|
|
// Player root never rotates anymore — derive forward from the camera yaw.
|
|
float yaw = playerCamera != null ? playerCamera.Yaw : 0f;
|
|
slideDirection = Quaternion.Euler(0f, yaw, 0f) * Vector3.forward;
|
|
}
|
|
rb.linearVelocity += slideDirection * slideInitialImpulse;
|
|
RecomputeSprintState();
|
|
PlayerEvents.RaiseSlideStarted();
|
|
}
|
|
|
|
private void EndSlide()
|
|
{
|
|
if (!isSliding) return;
|
|
isSliding = false;
|
|
RecomputeSprintState();
|
|
PlayerEvents.RaiseSlideEnded();
|
|
}
|
|
|
|
private void HandleSlide()
|
|
{
|
|
slideTimer += Time.fixedDeltaTime;
|
|
Vector3 h = new Vector3(rb.linearVelocity.x, 0f, rb.linearVelocity.z);
|
|
|
|
// Decelerate
|
|
float decel = deceleration * slideDecelMultiplier;
|
|
Vector3 newH = Vector3.MoveTowards(h, Vector3.zero, decel * Time.fixedDeltaTime);
|
|
rb.linearVelocity = new Vector3(newH.x, rb.linearVelocity.y, newH.z);
|
|
|
|
bool tooSlow = newH.magnitude < walkSpeed * 0.5f;
|
|
bool timedOut = slideTimer >= slideMaxDuration;
|
|
bool minMet = slideTimer >= slideMinDuration;
|
|
|
|
if ((tooSlow && minMet) || timedOut)
|
|
{
|
|
EndSlide();
|
|
// Smooth transition: stay crouching when exiting a slide so the camera doesn't pop
|
|
isCrouching = true;
|
|
PlayerEvents.RaiseCrouchChanged(true);
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Timers
|
|
// ============================================================
|
|
|
|
private void TickTimers(float dt)
|
|
{
|
|
if (!isGrounded) timeSinceLeftGround += dt;
|
|
else timeSinceLeftGround = 0f;
|
|
|
|
if (jumpBufferTimer > 0f) jumpBufferTimer -= dt;
|
|
|
|
if (landingStunTimer > 0f)
|
|
{
|
|
landingStunTimer -= dt;
|
|
if (landingStunTimer <= 0f) PlayerEvents.RaiseLandingStunEnded();
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Misc
|
|
// ============================================================
|
|
|
|
private bool IsBlocked() => PlayerEvents.InputLocked;
|
|
}
|
|
}
|