using UnityEngine; using UnityEngine.AI; using System.Collections; /// /// Unified AI Controller that handles both enemy and teammate AI. /// Replaces EnemyScript + EnemyNavigation with NavMeshAgent-based movement. /// Works with CashSystemAI for Cash System mode decisions. /// [RequireComponent(typeof(Animator))] public class CharacterAIController : MonoBehaviour { #region Enums public enum AIState { Idle, Chasing, Attacking, Retreating, Blocking, Dead, Patrolling // NEW: wander when no target in range } public enum AIMode { Practice, // 1v1 mode - simple attack behavior CashSystem // Team mode - coordinate with CashSystemAI } #endregion #region Fields [Header("AI Configuration")] [SerializeField] private CharacterStats characterStats; [SerializeField] private AIMode currentMode = AIMode.Practice; [Header("Combat Permission")] [Tooltip("Combat permission flag — toggled by RoundsScript/PlayerManagerNew between rounds")] public bool attack = true; [Header("Movement Settings")] [Tooltip("Base movement speed (replaces EnemyNavigation.speed)")] public float speed = 5f; [SerializeField] private float stoppingDistance = 1.5f; [SerializeField] private float rotationSpeed = 120f; [Header("Combat Settings")] [SerializeField] private float attackRange = 2f; [SerializeField] private float chaseRange = 20f; [SerializeField] private float attackCooldown = 1.5f; [SerializeField] private float randomAttackVariation = 0.5f; [Header("Retreat/Block Settings")] [SerializeField] private float retreatDistance = 4f; [SerializeField] private float retreatDuration = 1.5f; [SerializeField] private float blockDuration = 1.0f; [SerializeField] private float retreatChance = 0.15f; [SerializeField] private float blockChance = 0.20f; [SerializeField] private float healthRetreatThreshold = 30f; [Header("Decision Loop")] [SerializeField] private float decisionInterval = 0.3f; [SerializeField] private float farDecisionInterval = 0.8f; [SerializeField] private float lodDistance = 25f; [Header("Patrol Settings (Practice Mode)")] [SerializeField] private float patrolRadius = 10f; [SerializeField] private float patrolPauseMin = 1f; [SerializeField] private float patrolPauseMax = 2.5f; [Header("Grounding")] [Tooltip("Layers to raycast against for ground detection. Must exclude character layers to prevent floating.")] [SerializeField] private LayerMask groundLayerMask = 0; [Header("State")] [SerializeField] private AIState currentState = AIState.Idle; [SerializeField] private GameObject currentTarget; // Cached components private Animator anim; private NavMeshAgent navAgent; private TeamMember teamMember; private AnimationManager animationManager; private CharacterAttacks characterAttacks; private HealthNew health; private CashSystemAI cashSystemAI; // Cached external references private PlayerManagerNew cachedPlayerManager; private Camera cachedCamera; // Internal state private float lastAttackTime; private float distanceToTarget; private bool isAttacking; private float retreatEndTime; private float blockEndTime; private Vector3 retreatDirection; private Coroutine decisionCoroutine; // Patrol state private Vector3 patrolTarget; private bool hasPatrolTarget; private float patrolPauseEndTime; // Cash System combat — set by CashSystemAI when it wants us to fight private bool cashSystemCombatMode; // Gravity handling private float gvelocity; [SerializeField] private float gravity = -9.81f; [SerializeField] private float gravityScale = 0.75f; private bool jump; // Scale protection: animation clips with baked scale curves from FBX import // can shrink the character model. Save and restore original scale each frame. private Vector3 originalScale; #endregion #region Unity Lifecycle private void Awake() { originalScale = transform.localScale; CacheComponents(); } private void Start() { InitializeAI(); DetermineMode(); } private void OnEnable() { // Reset state when AI is enabled currentState = AIState.Idle; isAttacking = false; // Re-warp to NavMesh when re-enabled (e.g., after control switch) if (navAgent != null && navAgent.isActiveAndEnabled) { WarpToNavMesh(); } // Start the coroutine-based decision loop if (decisionCoroutine != null) StopCoroutine(decisionCoroutine); decisionCoroutine = StartCoroutine(AIDecisionLoop()); } private void OnDisable() { if (decisionCoroutine != null) { StopCoroutine(decisionCoroutine); decisionCoroutine = null; } } /// /// FixedUpdate handles gravity and grounding every physics tick. /// This MUST run independently of the AI decision loop so characters /// stay grounded even before combat starts (attack == false). /// private void FixedUpdate() { ApplyGravity(); EnsureUprightCharacter(); // Protect against animation clips overwriting scale if (transform.localScale != originalScale) { transform.localScale = originalScale; } } private void CacheComponents() { anim = GetComponent(); navAgent = GetComponent(); teamMember = GetComponent(); animationManager = GetComponent(); characterAttacks = GetComponent(); health = GetComponent(); cashSystemAI = GetComponent(); // Cache external singletons (lazy — may be null at Awake) cachedCamera = Camera.main; } private void InitializeAI() { // Apply stats if available if (characterStats != null) { attackRange = characterStats.preferredCombatRange; attackCooldown = 1f / characterStats.attackFrequency; } // Apply speed to NavMeshAgent if (navAgent != null) { navAgent.speed = speed; navAgent.stoppingDistance = stoppingDistance; // CRITICAL: Warp agent onto NavMesh surface to prevent floating. // NavMeshAgent can only manage grounding when it is ON the NavMesh. // If the agent was spawned slightly off the baked mesh, it floats. WarpToNavMesh(); } lastAttackTime = -attackCooldown; // Allow immediate first attack } /// /// Warp the NavMeshAgent to the nearest valid NavMesh point. /// Prevents floating caused by spawning off-NavMesh. /// private void WarpToNavMesh() { if (navAgent == null) return; NavMeshHit navHit; if (NavMesh.SamplePosition(transform.position, out navHit, 20f, NavMesh.AllAreas)) { navAgent.Warp(navHit.position); DevLog.Log($"[CharacterAIController] {gameObject.name}: Warped to NavMesh at Y={navHit.position.y} (was Y={transform.position.y})"); } else { DevLog.LogWarning($"[CharacterAIController] {gameObject.name}: NO NavMesh found within 20m! Character will float. " + "Ensure NavMesh is baked for this scene."); } } private void DetermineMode() { // Check if we're in Cash System mode if (SelectionOptions.Instance != null && SelectionOptions.Instance.isCashSystemSelected) { currentMode = AIMode.CashSystem; DevLog.Log($"[CharacterAIController] {gameObject.name} set to CashSystem mode"); } else { currentMode = AIMode.Practice; DevLog.Log($"[CharacterAIController] {gameObject.name} set to Practice mode"); } } #endregion #region Coroutine Decision Loop /// /// Coroutine-based decision loop. Runs at variable intervals based on /// distance from camera (AI LOD). Much cheaper than Update() polling. /// private IEnumerator AIDecisionLoop() { yield return new WaitForSeconds(Random.Range(0.1f, 0.5f)); // Stagger AI startup while (enabled) { if (!IsGamePaused() && attack) { // Choose interval based on distance from camera (AI LOD) float interval = GetDecisionInterval(); if (currentMode == AIMode.CashSystem) { // CashSystem mode: CashSystemAI is the SOLE authority. // We only sync animation — no state machine, no movement commands. // CashSystemAI drives NavMeshAgent directly. // (Nothing to do here — animation sync is in UpdateCashSystemBehavior // which is now ONLY called for combat that CashSystemAI delegates) } else { UpdatePracticeBehavior(); } yield return new WaitForSeconds(interval); } else if (currentMode == AIMode.CashSystem) { // CRITICAL: In CashSystem mode, do NOT call StopMovement() when attack=false. // CashSystemAI manages NavMeshAgent independently of the attack flag. // The attack flag only gates combat actions inside CashSystemAI.IsCombatEnabled(). // Collection, deposit, and patrol run regardless of the attack flag. yield return new WaitForSeconds(0.2f); } else { // Practice mode: Paused or attack disabled — stop moving, check again soon StopMovement(); anim?.SetFloat("Speed", 0f); yield return new WaitForSeconds(0.2f); } } } /// /// AI LOD: slower decisions when far from camera /// private float GetDecisionInterval() { if (cachedCamera == null) { cachedCamera = Camera.main; if (cachedCamera == null) return decisionInterval; } float distFromCamera = Vector3.Distance(transform.position, cachedCamera.transform.position); return distFromCamera > lodDistance ? farDecisionInterval : decisionInterval; } #endregion #region Practice Mode Behavior private void UpdatePracticeBehavior() { // Find target if none if (currentTarget == null) { FindTarget(); if (currentTarget == null) return; } // Check if target is still valid HealthNew targetHealth = currentTarget.GetComponent(); if (targetHealth != null && targetHealth.currentHealth <= 0) { currentTarget = null; SetState(AIState.Idle); StopMovement(); return; } // Calculate distance distanceToTarget = CalculateDistanceToTarget(); // State machine switch (currentState) { case AIState.Idle: HandleIdleState(); break; case AIState.Chasing: HandleChasingState(); break; case AIState.Attacking: HandleAttackingState(); break; case AIState.Retreating: HandleRetreatingState(); break; case AIState.Blocking: HandleBlockingState(); break; case AIState.Patrolling: HandlePatrollingState(); break; } // Always face target when in range if (distanceToTarget <= attackRange * 1.5f) { FaceTarget(); } } private void HandleIdleState() { if (currentTarget != null && distanceToTarget <= chaseRange) { SetState(AIState.Chasing); } else { // No target in range — patrol instead of standing still SetState(AIState.Patrolling); } } /// /// GTA-style patrol: pick random nearby NavMesh points and walk between them. /// Immediately interrupts when a target enters chase range. /// private void HandlePatrollingState() { // Priority: if a target is in chase range, switch to chasing if (currentTarget != null && distanceToTarget <= chaseRange) { SetState(AIState.Chasing); hasPatrolTarget = false; return; } // Try to find a target periodically if (currentTarget == null) { FindTarget(); if (currentTarget != null && CalculateDistanceToTarget() <= chaseRange) { SetState(AIState.Chasing); hasPatrolTarget = false; return; } } // Wait if pausing at patrol point if (Time.time < patrolPauseEndTime) { StopMovement(); anim?.SetFloat("Speed", 0f); return; } // Check if reached current patrol point if (hasPatrolTarget) { float distToPatrol = Vector3.Distance(transform.position, patrolTarget); if (distToPatrol < 2f) { // Arrived — pause briefly, then pick new point patrolPauseEndTime = Time.time + Random.Range(patrolPauseMin, patrolPauseMax); hasPatrolTarget = false; StopMovement(); anim?.SetFloat("Speed", 0f); return; } else { // Keep moving toward patrol point MoveToTarget(patrolTarget); anim?.SetFloat("Speed", 1f); return; } } // Pick a new random NavMesh point Vector3 randomDir = Random.insideUnitSphere * patrolRadius; randomDir.y = 0; Vector3 randomPoint = transform.position + randomDir; NavMeshHit hit; if (NavMesh.SamplePosition(randomPoint, out hit, patrolRadius, NavMesh.AllAreas)) { patrolTarget = hit.position; hasPatrolTarget = true; MoveToTarget(patrolTarget); anim?.SetFloat("Speed", 1f); } } private void HandleChasingState() { if (currentTarget == null) { SetState(AIState.Idle); return; } MoveToTarget(currentTarget.transform.position); if (distanceToTarget <= attackRange && CanAttack()) { SetState(AIState.Attacking); } } private void HandleAttackingState() { // Stop moving when attacking StopMovement(); if (!isAttacking && CanAttack()) { // Chance to retreat or block instead of attacking if (ShouldRetreat()) { StartRetreat(); return; } if (ShouldBlock()) { StartBlock(); return; } PerformAttack(); } // Return to chasing if out of range if (distanceToTarget > attackRange * 1.2f) { SetState(AIState.Chasing); } } private void HandleRetreatingState() { if (Time.time >= retreatEndTime) { // Retreat done — go back to chasing SetState(AIState.Chasing); return; } // Move away from target if (navAgent != null && navAgent.isActiveAndEnabled && navAgent.isOnNavMesh) { Vector3 retreatPos = transform.position + retreatDirection * retreatDistance; navAgent.isStopped = false; navAgent.SetDestination(retreatPos); if (anim != null) { float spd = navAgent.velocity.magnitude / Mathf.Max(navAgent.speed, 0.01f); anim.SetFloat("Speed", spd); } } } private void HandleBlockingState() { StopMovement(); if (Time.time >= blockEndTime) { // Block done — counterattack or chase if (anim != null) anim.SetBool("isBlocking", false); SetState(distanceToTarget <= attackRange ? AIState.Attacking : AIState.Chasing); } } private bool ShouldRetreat() { if (health == null) return false; // Higher chance to retreat when low health float healthPercent = (float)health.currentHealth / 100f; float adjustedChance = retreatChance; if (healthPercent * 100f < healthRetreatThreshold) { adjustedChance = retreatChance * 2.5f; } return Random.value < adjustedChance; } private bool ShouldBlock() { return Random.value < blockChance; } private void StartRetreat() { SetState(AIState.Retreating); retreatEndTime = Time.time + retreatDuration; // Calculate direction away from target if (currentTarget != null) { retreatDirection = (transform.position - currentTarget.transform.position).normalized; retreatDirection.y = 0; } else { retreatDirection = -transform.forward; } // Play retreat animation PlayAnimation(AnimationNames.JumpBack); } private void StartBlock() { SetState(AIState.Blocking); blockEndTime = Time.time + blockDuration; if (anim != null) anim.SetBool("isBlocking", true); } #endregion #region Cash System Mode Behavior /// /// CashSystem behavior: FULLY PASSIVE. /// CashSystemAI is the single authority — it drives NavMeshAgent directly, /// handles combat directly, and manages all decision-making. /// CharacterAIController does NOT touch NavMeshAgent in CashSystem mode. /// Animation is synced by CashSystemAI.SyncAnimation() directly. /// This method is kept as a no-op for backward compatibility. /// private void UpdateCashSystemBehavior() { // Intentionally empty — CashSystemAI owns everything in Cash System mode. // Animation sync is handled by CashSystemAI.SyncAnimation(). // Combat is handled by CashSystemAI.PerformDirectAttack(). // Navigation is handled by CashSystemAI.MoveToward(). } #endregion #region Combat private void FindTarget() { // Use static TeamMember registry if available (zero-allocation) if (teamMember != null) { float nearestDistance = float.MaxValue; foreach (TeamMember other in TeamMember.AllMembers) { if (other == null || other == teamMember) continue; if (other.GetTeam() == teamMember.GetTeam()) continue; float distance = Vector3.Distance(transform.position, other.transform.position); if (distance < nearestDistance) { nearestDistance = distance; currentTarget = other.gameObject; } } } // Fallback: find player by tag if (currentTarget == null) { GameObject player = GameObject.FindGameObjectWithTag("Player"); if (player != null && player != gameObject) { currentTarget = player; } } } private float CalculateDistanceToTarget() { if (currentTarget == null) return float.MaxValue; return Vector3.Distance(transform.position, currentTarget.transform.position); } private bool CanAttack() { return Time.time >= lastAttackTime + attackCooldown + Random.Range(-randomAttackVariation, randomAttackVariation); } private void PerformAttack() { if (isAttacking) return; isAttacking = true; lastAttackTime = Time.time; // Choose random attack type int attackType = Random.Range(0, 4); string animationName = ""; switch (attackType) { case 0: animationName = AnimationNames.Jab; break; case 1: animationName = AnimationNames.Right; break; case 2: animationName = AnimationNames.LowKick; break; case 3: animationName = AnimationNames.Haymaker; break; } // Play animation PlayAnimation(animationName); // Reset attack flag after animation StartCoroutine(ResetAttackFlag(0.8f)); } private IEnumerator ResetAttackFlag(float delay) { yield return new WaitForSeconds(delay); isAttacking = false; } private void PlayAnimation(string animationName) { if (animationManager != null) { animationManager.PlayAnimation(animationName); } else if (anim != null) { anim.Play(animationName); } } private void FaceTarget() { if (currentTarget == null) return; Vector3 direction = (currentTarget.transform.position - transform.position).normalized; direction.y = 0; if (direction != Vector3.zero) { Quaternion targetRotation = Quaternion.LookRotation(direction); transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, rotationSpeed * Time.deltaTime); } } #endregion #region Movement Helpers private void MoveToTarget(Vector3 position) { if (navAgent == null || !navAgent.isActiveAndEnabled) { DevLog.LogWarning($"[CharacterAIController] {gameObject.name}: NavMeshAgent null or disabled, cannot move!"); return; } // If agent fell off NavMesh, try to re-warp it if (!navAgent.isOnNavMesh) { DevLog.LogWarning($"[CharacterAIController] {gameObject.name}: Agent is OFF NavMesh! Attempting re-warp..."); WarpToNavMesh(); // If still not on NavMesh after warp, give up this frame if (!navAgent.isOnNavMesh) { Debug.LogError($"[CharacterAIController] {gameObject.name}: Re-warp FAILED. No NavMesh at position {transform.position}"); return; } } navAgent.isStopped = false; navAgent.SetDestination(position); if (anim != null) { float spd = navAgent.velocity.magnitude / Mathf.Max(navAgent.speed, 0.01f); anim.SetFloat("Speed", spd); } } private void StopMovement() { if (navAgent != null && navAgent.isActiveAndEnabled && navAgent.isOnNavMesh) { navAgent.isStopped = true; navAgent.velocity = Vector3.zero; } if (anim != null) { anim.SetFloat("Speed", 0f); } } #endregion #region Character Stability private bool IsGamePaused() { // Lazy-cache PlayerManagerNew if (cachedPlayerManager == null) { cachedPlayerManager = FindFirstObjectByType(); } return cachedPlayerManager != null && cachedPlayerManager.pause; } private void EnsureUprightCharacter() { if (IsCharacterTilted()) { Quaternion targetRotation = Quaternion.Euler(0, transform.rotation.eulerAngles.y, 0); transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, Time.deltaTime * 5f); } } private bool IsCharacterTilted() { return Mathf.Abs(transform.rotation.eulerAngles.x) > 5f || Mathf.Abs(transform.rotation.eulerAngles.z) > 5f; } private void ApplyGravity() { if (jump) return; // CRITICAL FIX: When NavMeshAgent is active and ON the NavMesh, // it handles Y-positioning automatically. Applying manual gravity // CONFLICTS with NavMeshAgent and causes jittering/floating. // Only apply manual gravity as a FALLBACK when the agent is off-mesh. if (navAgent != null && navAgent.isActiveAndEnabled && navAgent.isOnNavMesh) { // NavMeshAgent handles grounding — reset velocity and skip manual gravity gvelocity = 0; return; } gvelocity += gravity * gravityScale * Time.deltaTime; // Auto-configure ground layer mask if not set (exclude character layers) if (groundLayerMask.value == 0) { groundLayerMask = ~0; // Start with everything int playerLayer = LayerMask.NameToLayer("Player"); int enemyLayer = LayerMask.NameToLayer("Enemy"); int characterLayer = LayerMask.NameToLayer("Character"); int ignoreRaycast = LayerMask.NameToLayer("Ignore Raycast"); if (playerLayer >= 0) groundLayerMask &= ~(1 << playerLayer); if (enemyLayer >= 0) groundLayerMask &= ~(1 << enemyLayer); if (characterLayer >= 0) groundLayerMask &= ~(1 << characterLayer); if (ignoreRaycast >= 0) groundLayerMask &= ~(1 << ignoreRaycast); } // Fallback gravity: only runs when NavMeshAgent is NOT handling grounding float groundY = 0f; RaycastHit hit; if (Physics.Raycast(transform.position + Vector3.up * 0.5f, Vector3.down, out hit, 50f, groundLayerMask)) { groundY = hit.point.y; } if (transform.position.y > groundY + 0.1f && !jump) { transform.position = new Vector3( transform.position.x, Mathf.Max(groundY + 0.05f, transform.position.y + gvelocity * Time.deltaTime), transform.position.z ); } else { gvelocity = 0; } } #endregion #region Public API /// /// Set the current target for this AI /// public void SetTarget(GameObject target) { currentTarget = target; } /// /// Get the current AI state /// public AIState GetState() { return currentState; } /// /// Force the AI into a specific state /// public void SetState(AIState state) { if (currentState != state) { currentState = state; } } /// /// Set the AI mode /// public void SetMode(AIMode mode) { currentMode = mode; } /// /// Trigger an attack (can be called by CashSystemAI) /// public void TriggerAttack() { if (CanAttack()) { PerformAttack(); } } /// /// Update NavMeshAgent speed at runtime (replaces EnemyNavigation.speed setter) /// public void SetSpeed(float newSpeed) { speed = newSpeed; if (navAgent != null) { navAgent.speed = speed; } } /// /// Get current movement speed /// public float GetSpeed() { return speed; } /// /// Navigate to a world position (for CashSystemAI integration). /// Used for non-combat movement (collect, deposit, patrol). /// public void NavigateTo(Vector3 position) { cashSystemCombatMode = false; // Clear combat mode when navigating MoveToTarget(position); } /// /// Stop all movement (for CashSystemAI integration) /// public void Stop() { StopMovement(); } /// /// Enter combat mode for Cash System — enables the chase→attack→retreat→block /// state machine against the current target. Called by CashSystemAI.ExecuteAttack() /// and CashSystemAI.ExecuteDefend(). /// public void SetCashSystemCombatTarget(Vector3 targetPosition) { cashSystemCombatMode = true; // Ensure we start chasing if currently idle or patrolling if (currentState == AIState.Idle || currentState == AIState.Patrolling) SetState(AIState.Chasing); } /// /// Exit combat mode (called when CashSystemAI switches to non-combat state). /// public void ClearCashSystemCombat() { cashSystemCombatMode = false; } #endregion }