931 lines
27 KiB
C#
931 lines
27 KiB
C#
using UnityEngine;
|
|
using UnityEngine.AI;
|
|
using System.Collections;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
private void FixedUpdate()
|
|
{
|
|
ApplyGravity();
|
|
EnsureUprightCharacter();
|
|
|
|
// Protect against animation clips overwriting scale
|
|
if (transform.localScale != originalScale)
|
|
{
|
|
transform.localScale = originalScale;
|
|
}
|
|
}
|
|
|
|
private void CacheComponents()
|
|
{
|
|
anim = GetComponent<Animator>();
|
|
navAgent = GetComponent<NavMeshAgent>();
|
|
teamMember = GetComponent<TeamMember>();
|
|
animationManager = GetComponent<AnimationManager>();
|
|
characterAttacks = GetComponent<CharacterAttacks>();
|
|
health = GetComponent<HealthNew>();
|
|
cashSystemAI = GetComponent<CashSystemAI>();
|
|
|
|
// 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
|
|
}
|
|
|
|
/// <summary>
|
|
/// Warp the NavMeshAgent to the nearest valid NavMesh point.
|
|
/// Prevents floating caused by spawning off-NavMesh.
|
|
/// </summary>
|
|
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
|
|
|
|
/// <summary>
|
|
/// Coroutine-based decision loop. Runs at variable intervals based on
|
|
/// distance from camera (AI LOD). Much cheaper than Update() polling.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// AI LOD: slower decisions when far from camera
|
|
/// </summary>
|
|
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<HealthNew>();
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// GTA-style patrol: pick random nearby NavMesh points and walk between them.
|
|
/// Immediately interrupts when a target enters chase range.
|
|
/// </summary>
|
|
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<PlayerManagerNew>();
|
|
}
|
|
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
|
|
|
|
/// <summary>
|
|
/// Set the current target for this AI
|
|
/// </summary>
|
|
public void SetTarget(GameObject target)
|
|
{
|
|
currentTarget = target;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get the current AI state
|
|
/// </summary>
|
|
public AIState GetState()
|
|
{
|
|
return currentState;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Force the AI into a specific state
|
|
/// </summary>
|
|
public void SetState(AIState state)
|
|
{
|
|
if (currentState != state)
|
|
{
|
|
currentState = state;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Set the AI mode
|
|
/// </summary>
|
|
public void SetMode(AIMode mode)
|
|
{
|
|
currentMode = mode;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Trigger an attack (can be called by CashSystemAI)
|
|
/// </summary>
|
|
public void TriggerAttack()
|
|
{
|
|
if (CanAttack())
|
|
{
|
|
PerformAttack();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update NavMeshAgent speed at runtime (replaces EnemyNavigation.speed setter)
|
|
/// </summary>
|
|
public void SetSpeed(float newSpeed)
|
|
{
|
|
speed = newSpeed;
|
|
if (navAgent != null)
|
|
{
|
|
navAgent.speed = speed;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get current movement speed
|
|
/// </summary>
|
|
public float GetSpeed()
|
|
{
|
|
return speed;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Navigate to a world position (for CashSystemAI integration)
|
|
/// </summary>
|
|
public void NavigateTo(Vector3 position)
|
|
{
|
|
MoveToTarget(position);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stop all movement (for CashSystemAI integration)
|
|
/// </summary>
|
|
public void Stop()
|
|
{
|
|
StopMovement();
|
|
}
|
|
|
|
#endregion
|
|
}
|