Files
Emberwild/Assets/GAME/Script/Player/Locomotion/PlayerLocomotion.cs
T
2026-06-22 16:18:34 +02:00

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;
}
}