using System.Collections; using DG.Tweening; using UnityEngine; /// /// Endless runner controller: /// - Forward motion (−Z), 3 lanes with smooth switching (ground & air) /// - Tap = small jump, Hold (within window) = higher jump /// - Coyote time + jump buffer for forgiving inputs /// - Robust ground detection (collider-based sphere cast with optional probe) /// - Animator driving preserved; obstacle/fall flow preserved /// [RequireComponent(typeof(Rigidbody))] public class ChasePlayerController : MonoBehaviour { // ===== Movement ===== [Header("Movement")] public float moveSpeed = 5f; [SerializeField] private float laneDistance = 2.5f; // 0=Left,1=Mid,2=Right [SerializeField] private float laneSwitchSpeed = 10f; // units/sec // ===== Jump (Tap / Hold) ===== [Header("Jump (Tap/Hold)")] [SerializeField] private float maxHoldTime = 0.25f; // hold window [SerializeField] private float initialJumpVelocity = 6.5f; [SerializeField] private float holdUpwardAcceleration = 35f; [SerializeField] private float lowJumpGravityMultiplier = 2.0f; [SerializeField] private float fallGravityMultiplier = 2.5f; [SerializeField] private float coyoteTime = 0.12f; // after leaving ground [SerializeField] private float jumpBufferTime = 0.12f; // before landing [SerializeField] private float maxVerticalSpeed = 15f; [Header("Air Control")] [SerializeField] private bool allowAirLaneSwitch = true; // ===== Ground Check ===== [Header("Ground Check")] [Tooltip("Optional feet probe; if null, uses collider bounds.")] [SerializeField] private Transform groundProbe; [SerializeField] private LayerMask groundMask = ~0; [SerializeField] private float groundCheckRadius = 0.22f; [SerializeField] private float groundCheckExtraDown = 0.06f; // ===== Animator Driving ===== [Header("Animator Driving")] [SerializeField] private bool useSpeedBlendTree = true; [SerializeField] private string speedParamName = "Speed"; [SerializeField] private float runSpeedParamValue = 2f; [SerializeField] private string runStateName = "Locomotion"; [SerializeField] private int baseLayer = 0; // ===== Fall / GameOver ===== [Header("Fall / GameOver")] public string fallStateName = "Fall"; public string fallingStateName = "Falling"; [SerializeField] private float secondHitWindow = 10f; [SerializeField] private float stateWaitTimeout = 3f; [Header("Threat-Based Speedup")] [SerializeField] private bool speedUpFromBomb = true; [SerializeField] private Transform bombEnemy; // assign in Inspector or auto-find by tag "BombEnemy" [SerializeField] private float boostDistance = 12f; // within this range -> up to max boost [SerializeField] private float maxSpeedBoost = 4f; // extra speed added at zero distance [SerializeField] private float boostLerp = 5f; // smoothing (higher = snappier) private float baseMoveSpeed; // remembers your normal run speed public static System.Action OnMoveSpeedChanged; // ----- runtime state ----- private int currentLane = 1; // 0,1,2 private float targetLaneX = 0f; private Rigidbody rb; private Animator animator; private Collider col; // any non-trigger collider private bool isGrounded = true; private bool isJumping = false; private float lastGroundedTime = -999f; private float jumpPressTime = -999f; private float jumpHoldTimer = 0f; private bool jumpHeld = false; private Vector2 startTouchPosition; private bool swipeDetected = false; private const float MIN_SWIPE = 50f; private float lastObstacleHitTime = -999f; [HideInInspector] public bool waitingForGameOver = false; [SerializeField] private bool validateStatesOnStart = true; [SerializeField] private string runTag = "Run"; [SerializeField] private string fallTag = "Fall"; [SerializeField] private string fallingTag = "Falling"; public int runShortHash, fallShortHash, fallingShortHash; private bool hasSpeedFloat, hasIsGroundedBool, hasJumpTrigger, hasLandTrigger; private float originalMoveSpeed; private bool unableToMove = false; // NEW: hard pause flag to block forward steps & threat boost private bool speedPaused = false; private ChaseScoreManager scoreManager; // Add near your other fields: [SerializeField] private bool jumpViaUI = false; [SerializeField] private RectTransform jumpImageRect; // <-- drag your Image here [SerializeField] private Canvas jumpImageCanvas; // optional; drag the Canvas (for camera) private bool TouchBeganOnJumpImage(Vector2 screenPos) { if (jumpImageRect == null) return false; var cam = jumpImageCanvas ? jumpImageCanvas.worldCamera : null; return RectTransformUtility.RectangleContainsScreenPoint(jumpImageRect, screenPos, cam); } // Add these public methods anywhere in the class: public void OnJumpPress() { jumpPressTime = Time.time; TryStartJump(); jumpHeld = true; jumpHoldTimer = 0f; } public void OnJumpRelease() { jumpHeld = false; } // ========= Unity ========= private void Start() { // Components rb = GetComponent(); animator = GetComponent(); col = GetComponent(); scoreManager = FindObjectOfType(); // Rigidbody setup (smooth & robust) rb.interpolation = RigidbodyInterpolation.Interpolate; rb.collisionDetectionMode = CollisionDetectionMode.ContinuousDynamic; rb.constraints = RigidbodyConstraints.FreezeRotation; // Animator hashes runShortHash = Animator.StringToHash(runStateName); fallShortHash = Animator.StringToHash(fallStateName); fallingShortHash = Animator.StringToHash(fallingStateName); if (bombEnemy == null) { var bomb = GameObject.FindGameObjectWithTag("BombEnemy"); if (bomb) bombEnemy = bomb.transform; } // Validate states (optional) if (validateStatesOnStart) { if (!animator.HasState(baseLayer, runShortHash)) Debug.LogError($"Run state '{runStateName}' not found"); if (!animator.HasState(baseLayer, fallShortHash)) Debug.LogError($"Fall state '{fallStateName}' not found"); if (!animator.HasState(baseLayer, fallingShortHash)) Debug.LogError($"Falling state '{fallingStateName}' not found"); } // Cache animator parameters once hasSpeedFloat = HasParam(speedParamName, AnimatorControllerParameterType.Float); hasIsGroundedBool = HasParam("IsGrounded", AnimatorControllerParameterType.Bool); hasJumpTrigger = HasParam("Jump", AnimatorControllerParameterType.Trigger); hasLandTrigger = HasParam("Land", AnimatorControllerParameterType.Trigger); // Baselines baseMoveSpeed = moveSpeed; // IMPORTANT: baseline for threat boost originalMoveSpeed = moveSpeed; targetLaneX = LaneToX(currentLane); // Ensure physics-driven motion (root motion off) if (animator) animator.applyRootMotion = false; ForceRunStart(); } public void SetMoveSpeed(float newSpeed) { moveSpeed = newSpeed; OnMoveSpeedChanged?.Invoke(newSpeed); } private void Update() { UpdateGrounded(); DriveRunAnimation(); if (!unableToMove) { ReadKeyboardInput(); ReadTouchInput(); HandleJumpHold(); } ApplyBetterJumpGravity(); ClampVerticalSpeed(); ApplyThreatSpeedup(); } private void ApplyThreatSpeedup() { if (!speedUpFromBomb || bombEnemy == null) return; if (waitingForGameOver || speedPaused) return; if (baseMoveSpeed <= 0.001f) return; // don't auto-start when baseline is zero float dist = Vector3.Distance(transform.position, bombEnemy.position); // 0 when far, 1 when enemy is on top of us float t = Mathf.Clamp01((boostDistance - dist) / boostDistance); float target = baseMoveSpeed + maxSpeedBoost * t; // Smoothly approach the target; no event fire here so enemy doesn't copy this boost moveSpeed = Mathf.Lerp(moveSpeed, target, Time.deltaTime * boostLerp); } private void FixedUpdate() { MoveForwardAndSide(); } // ========= Input ========= private void ReadKeyboardInput() { // Lane switching (in-air allowed if enabled) if ((isGrounded || allowAirLaneSwitch) && Input.GetKeyDown(KeyCode.LeftArrow) && currentLane > 0) SetLane(currentLane - 1); if ((isGrounded || allowAirLaneSwitch) && Input.GetKeyDown(KeyCode.RightArrow) && currentLane < 2) SetLane(currentLane + 1); // Jump if (Input.GetKeyDown(KeyCode.Space)) { jumpPressTime = Time.time; TryStartJump(); jumpHeld = true; jumpHoldTimer = 0f; } if (Input.GetKeyUp(KeyCode.Space)) { jumpHeld = false; } } private void ReadTouchInput() { if (Input.touchCount != 1) return; var t = Input.GetTouch(0); // ===== When jump is routed via UI image ===== if (jumpViaUI) { // We’ll only use touches that START outside the jump image for lane swipes. // Touches that start on the image are ignored here (the Image script calls OnJumpPress/OnJumpRelease). switch (t.phase) { case TouchPhase.Began: startTouchPosition = t.position; swipeDetected = !TouchBeganOnJumpImage(t.position); break; case TouchPhase.Ended: if (!swipeDetected) break; // began on image -> no swipe Vector2 swipe = t.position - startTouchPosition; if (swipe.magnitude >= MIN_SWIPE && Mathf.Abs(swipe.x) > Mathf.Abs(swipe.y)) { if ((isGrounded || allowAirLaneSwitch) && swipe.x > 0 && currentLane < 2) SetLane(currentLane + 1); else if ((isGrounded || allowAirLaneSwitch) && swipe.x < 0 && currentLane > 0) SetLane(currentLane - 1); } swipeDetected = false; break; } return; // don't fall through to the non-UI path } // ===== Original touch logic (no UI jump) ===== switch (t.phase) { case TouchPhase.Began: startTouchPosition = t.position; swipeDetected = true; jumpHeld = true; jumpHoldTimer = 0f; jumpPressTime = Time.time; break; case TouchPhase.Ended: if (!swipeDetected) { jumpHeld = false; break; } Vector2 swipe = t.position - startTouchPosition; if (swipe.magnitude >= MIN_SWIPE) { if (Mathf.Abs(swipe.x) > Mathf.Abs(swipe.y)) { if ((isGrounded || allowAirLaneSwitch) && swipe.x > 0 && currentLane < 2) SetLane(currentLane + 1); else if ((isGrounded || allowAirLaneSwitch) && swipe.x < 0 && currentLane > 0) SetLane(currentLane - 1); } else if (swipe.y > 0) TryStartJump(); } else { // Tap TryStartJump(); } swipeDetected = false; jumpHeld = false; break; } } // ========= Movement ========= private float LaneToX(int laneIndex) => (laneIndex - 1) * laneDistance; private void SetLane(int laneIndex) { currentLane = Mathf.Clamp(laneIndex, 0, 2); targetLaneX = LaneToX(currentLane); } private void MoveForwardAndSide() { // Forward (−Z) — blocked when paused Vector3 forwardStep = speedPaused ? Vector3.zero : Vector3.back * moveSpeed * Time.fixedDeltaTime; // Side toward target lane X float newX = Mathf.MoveTowards(rb.position.x, targetLaneX, laneSwitchSpeed * Time.fixedDeltaTime); Vector3 targetPos = new Vector3(newX, rb.position.y, rb.position.z) + forwardStep; rb.MovePosition(targetPos); } // ========= Jump System ========= private void TryStartJump() { bool canCoyote = (Time.time - lastGroundedTime) <= coyoteTime; if ((isGrounded || canCoyote) && !waitingForGameOver) { var v = rb.velocity; v.y = initialJumpVelocity; rb.velocity = v; isJumping = true; isGrounded = false; jumpPressTime = -999f; // consume buffer if (hasJumpTrigger) animator.SetTrigger("Jump"); if (hasIsGroundedBool) animator.SetBool("IsGrounded", false); } } private void HandleJumpHold() { if (!isJumping || !jumpHeld) return; if (jumpHoldTimer < maxHoldTime && rb.velocity.y > 0f) { rb.AddForce(Vector3.up * holdUpwardAcceleration, ForceMode.Acceleration); jumpHoldTimer += Time.deltaTime; } } private void ApplyBetterJumpGravity() { if (rb == null) return; if (rb.velocity.y < 0f) { rb.AddForce(Physics.gravity * (fallGravityMultiplier - 1f), ForceMode.Acceleration); } else if (!jumpHeld && rb.velocity.y > 0f) { rb.AddForce(Physics.gravity * (lowJumpGravityMultiplier - 1f), ForceMode.Acceleration); } } private void ClampVerticalSpeed() { var v = rb.velocity; if (v.y > maxVerticalSpeed) v.y = maxVerticalSpeed; if (v.y < -maxVerticalSpeed) v.y = -maxVerticalSpeed; rb.velocity = v; } // ========= Grounding ========= private void UpdateGrounded() { bool groundedNow = GroundProbe(out _); if (groundedNow) { if (!isGrounded) { if (hasLandTrigger) animator.SetTrigger("Land"); if (hasIsGroundedBool) animator.SetBool("IsGrounded", true); isJumping = false; ForceRunStart(); } isGrounded = true; lastGroundedTime = Time.time; // Buffered jump pressed just before landing if ((Time.time - jumpPressTime) <= jumpBufferTime) { jumpPressTime = -999f; TryStartJump(); } } else { isGrounded = false; if (hasIsGroundedBool) animator.SetBool("IsGrounded", false); } } private bool GroundProbe(out Vector3 hitPoint) { hitPoint = transform.position; if (col != null) { Bounds b = col.bounds; Vector3 feet = new Vector3(b.center.x, b.min.y + 0.02f, b.center.z); Vector3 origin = groundProbe ? groundProbe.position : feet; // SphereCast small distance down if (Physics.SphereCast(origin, groundCheckRadius, Vector3.down, out RaycastHit hit, groundCheckExtraDown + 0.02f, groundMask, QueryTriggerInteraction.Ignore)) { hitPoint = hit.point; return true; } // Fallback overlap if (Physics.CheckSphere(origin + Vector3.down * groundCheckExtraDown, groundCheckRadius, groundMask, QueryTriggerInteraction.Ignore)) { hitPoint = origin + Vector3.down * groundCheckExtraDown; return true; } return false; } // No collider → simple overlap at probe or near feet Vector3 o = groundProbe ? groundProbe.position : (transform.position + Vector3.down * 0.95f); bool ok = Physics.CheckSphere(o, groundCheckRadius, groundMask, QueryTriggerInteraction.Ignore); if (ok) hitPoint = o; return ok; } private void OnCollisionStay(Collision collision) { // Extra safety: contact with ground layer & low vertical speed => grounded if (((1 << collision.gameObject.layer) & groundMask) != 0 && Mathf.Abs(rb.velocity.y) < 0.1f) { isGrounded = true; lastGroundedTime = Time.time; } } private void OnCollisionEnter(Collision collision) { if (collision.gameObject.CompareTag("Ground")) { isGrounded = true; lastGroundedTime = Time.time; isJumping = false; if (hasIsGroundedBool) animator.SetBool("IsGrounded", true); ForceRunStart(); } } // ========= Animator ========= private void ForceRunStart(bool ignoreGuards = false) { if (!ignoreGuards && (IsInOrGoingTo(fallShortHash) || IsInOrGoingTo(fallingShortHash) || waitingForGameOver)) return; if (useSpeedBlendTree && hasSpeedFloat) animator.SetFloat(speedParamName, runSpeedParamValue); else if (!string.IsNullOrEmpty(runStateName)) animator.CrossFadeInFixedTime(runStateName, 0.1f, baseLayer, 0f); } private void DriveRunAnimation() { if (IsInOrGoingTo(fallShortHash) || IsInOrGoingTo(fallingShortHash) || isJumping) return; if (useSpeedBlendTree && hasSpeedFloat) { animator.SetFloat(speedParamName, runSpeedParamValue, 0.1f, Time.deltaTime); } else { var st = animator.GetCurrentAnimatorStateInfo(baseLayer); var nxt = animator.GetNextAnimatorStateInfo(baseLayer); bool inRunNow = st.shortNameHash == runShortHash || st.IsTag(runTag); bool goingToRun = animator.IsInTransition(baseLayer) && (nxt.shortNameHash == runShortHash || nxt.IsTag(runTag)); if (!isJumping && !inRunNow && !goingToRun) animator.CrossFadeInFixedTime(runStateName, 0.1f, baseLayer, 0f); } } private bool HasParam(string name, AnimatorControllerParameterType type) { foreach (var p in animator.parameters) if (p.type == type && p.name == name) return true; return false; } private bool IsInOrGoingTo(int shortHash) { var st = animator.GetCurrentAnimatorStateInfo(baseLayer); if (st.shortNameHash == shortHash) return true; if (animator.IsInTransition(baseLayer)) { var nxt = animator.GetNextAnimatorStateInfo(baseLayer); if (nxt.shortNameHash == shortHash) return true; } return false; } // ======= Squish Kill ======= public void KillBySquish(Transform squashTarget = null, float squashTime = 0.25f, float postDelay = 0.35f) { if (waitingForGameOver) return; waitingForGameOver = true; unableToMove = true; speedPaused = true; // Halt motion & physics jank if (rb) { rb.velocity = Vector3.zero; rb.angularVelocity = Vector3.zero; rb.isKinematic = true; // lock out further physics bumps } // Optional: stop lane movement immediately SetMoveSpeed(0); // Optional: stop player colliders from re-triggering anything else var allCols = GetComponentsInChildren(); for (int i = 0; i < allCols.Length; i++) allCols[i].enabled = false; // Target to squash (player root if not provided) Transform t = squashTarget ? squashTarget : transform; // Preserve original scale to avoid double-squish if something else calls us Vector3 start = t.localScale; // A little “pancake spread” feels nice; tweak if you want pure Y=0 only float widen = 1.1f; Sequence seq = DOTween.Sequence(); seq.Append(t.DOScaleY(0f, squashTime).SetEase(Ease.InCubic)) .Join(t.DOScaleX(start.x * widen, squashTime)) .Join(t.DOScaleZ(start.z * widen, squashTime)) .AppendInterval(postDelay) .OnComplete(() => { if (scoreManager) scoreManager.GameOver(); }); } // ========= Obstacle / Fall flow (kept) ========= public void OnObstacleHit() { if (waitingForGameOver) return; Camera.main.DOShakePosition(0.25f, new Vector3(0.15f, 0.15f, 0.15f), 20); if (Time.time - lastObstacleHitTime <= secondHitWindow) { // Deadly second hit → stop everything and go to game over sequence waitingForGameOver = true; speedPaused = true; moveSpeed = 0; if (rb) { rb.velocity = Vector3.zero; rb.angularVelocity = Vector3.zero; } StartCoroutine(PlayStateAndGameOver(fallingStateName, fallingShortHash)); } else { // First hit → pause movement, play fall, then resume after lastObstacleHitTime = Time.time; speedPaused = true; if (rb) { rb.velocity = Vector3.zero; rb.angularVelocity = Vector3.zero; } PlayStateOnce(fallStateName); // Remember normal run speed as baseline to restore originalMoveSpeed = baseMoveSpeed; SetMoveSpeed(0); StartCoroutine(ResumeRunAfter(fallStateName, fallShortHash)); } } private void PlayStateOnce(string stateName, float xfade = 0.08f) { if (!string.IsNullOrEmpty(stateName)) animator.CrossFadeInFixedTime(stateName, xfade, baseLayer, 0f); } private IEnumerator ResumeRunAfter(string stateName, int shortHash, float xfade = 0.1f) { // optional fixed pause before checking state completion yield return new WaitForSeconds(1.5f); float t0 = Time.time; while (!IsInOrGoingTo(shortHash) && !TimedOut(t0)) yield return null; while (animator.IsInTransition(baseLayer)) yield return null; t0 = Time.time; while (!StateFinished(shortHash) && !TimedOut(t0)) yield return null; if (!waitingForGameOver) { // resume speedPaused = false; ForceRunStart(ignoreGuards: true); // restore baseline & current speed baseMoveSpeed = originalMoveSpeed; // baseline for threat boost SetMoveSpeed(originalMoveSpeed); // actual run speed now animator.CrossFadeInFixedTime(runStateName, 0.1f, baseLayer, 0f); } } public IEnumerator PlayStateAndGameOver(string stateName, int shortHash, float xfade = 0.08f) { unableToMove = true; if (string.IsNullOrEmpty(stateName) || animator == null) { if (scoreManager) scoreManager.GameOver(); yield break; } animator.CrossFadeInFixedTime(stateName, xfade, baseLayer, 0f); float t0 = Time.time; while (!IsInOrGoingTo(shortHash) && !TimedOut(t0)) yield return null; while (animator.IsInTransition(baseLayer)) yield return null; t0 = Time.time; while (!StateFinished(shortHash) && !TimedOut(t0)) yield return null; if (scoreManager) scoreManager.GameOver(); } private bool StateFinished(int shortHash) { var st = animator.GetCurrentAnimatorStateInfo(baseLayer); return st.shortNameHash == shortHash && !animator.IsInTransition(baseLayer) && st.normalizedTime >= 1f; } private bool TimedOut(float startTime) => stateWaitTimeout > 0f && (Time.time - startTime) > stateWaitTimeout; }