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;
// 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 && cashSystemAI != null)
{
UpdateCashSystemBehavior();
}
else
{
UpdatePracticeBehavior();
}
yield return new WaitForSeconds(interval);
}
else
{
// 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
private void UpdateCashSystemBehavior()
{
// In Cash System mode, CashSystemAI tells us what to do
// We handle animation sync and stability
if (cashSystemAI == null) return;
if (navAgent != null && navAgent.isActiveAndEnabled && navAgent.hasPath)
{
float spd = navAgent.velocity.magnitude / Mathf.Max(navAgent.speed, 0.01f);
anim?.SetFloat("Speed", spd > 0.1f ? 1f : 0f);
}
else
{
anim?.SetFloat("Speed", 0f);
}
}
#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)
///
public void NavigateTo(Vector3 position)
{
MoveToTarget(position);
}
///
/// Stop all movement (for CashSystemAI integration)
///
public void Stop()
{
StopMovement();
}
#endregion
}