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(); capsule = GetComponent(); playerCamera = GetComponent(); 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; } }