MiniGames/Assets/Scripts/ChaseOn/ChasePlayerController.cs

543 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections;
using UnityEngine;
/// <summary>
/// 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
/// </summary>
[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<float> 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;
private ChaseScoreManager scoreManager;
// ========= Unity =========
private void Start()
{
// Components
rb = GetComponent<Rigidbody>();
animator = GetComponent<Animator>();
col = GetComponent<Collider>();
scoreManager = FindObjectOfType<ChaseScoreManager>();
// 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);
originalMoveSpeed = moveSpeed;
targetLaneX = LaneToX(currentLane);
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 || waitingForGameOver) return;
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);
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)
Vector3 forwardStep = 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;
}
// ========= Obstacle / Fall flow (kept) =========
public void OnObstacleHit()
{
if (waitingForGameOver) return;
if (Time.time - lastObstacleHitTime <= secondHitWindow)
{
waitingForGameOver = true;
moveSpeed = 0;
StartCoroutine(PlayStateAndGameOver(fallingStateName, fallingShortHash));
}
else
{
lastObstacleHitTime = Time.time;
PlayStateOnce(fallStateName);
originalMoveSpeed = moveSpeed;
moveSpeed = 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)
{
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)
{
ForceRunStart(ignoreGuards: true);
SetMoveSpeed(originalMoveSpeed);
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;
}