using UnityEngine; using UnityEngine.AI; using System.Collections.Generic; /// /// CashSystemAI — Goal-oriented AI decision layer for Cash System mode. /// /// ARCHITECTURE: Single-authority utility AI with direct NavMeshAgent control. /// /// This is the SOLE owner of the NavMeshAgent in Cash System mode. /// It drives navigation directly (no middleman), handles its own combat, /// and disables competing controllers (TeamAIController, EnemyAIController) /// to prevent NavMeshAgent conflicts. /// /// Each AI evaluates all possible actions every decision tick, scores them /// using weighted utility functions, and executes the highest-scoring action. /// Roles bias the weights but any AI can take any action if the score is high enough. /// /// ROLES: /// Defender (1 per team) — Biases toward vault defense, enemy interception /// Collector (2 per team) — Biases toward cash collection, deposit, opportunistic combat /// /// COORDINATION: /// Static team registries prevent clustering — AI won't target a bag another /// teammate is already after. Defenders "claim" the vault area. /// /// ENVIRONMENT SENSOR: /// Periodically scans for nearby cash bags, enemies, and vaults using /// EntityRegistry and TeamMember queries. Cached sorted lists provide /// fast access for decision-making without per-frame allocations. /// public class CashSystemAI : MonoBehaviour { // ─── Enums ─────────────────────────────────────────────── public enum AIState { Idle, CollectCash, DepositCash, AttackEnemy, StealFromVault, DefendVault, Retreat, Patrol, // ─── Extraction Mode States ────────────────────────── SeekVault, // Navigate to a sealed ExtractionVault OpenVault, // Channel to open a sealed vault ($1,000 reward) CarryVault, // Carrying an opened vault to CashoutStation (encumbered) DepositVault, // Depositing vault at CashoutStation DefendCashout, // Defending an active cashout timer AttackCashout, // Attacking/hacking enemy cashout InterceptCarrier // Hunting an enemy carrying a vault } public enum AIRole { Collector, Defender } // ─── Inspector Fields ──────────────────────────────────── [Header("Role")] [SerializeField] private AIRole role = AIRole.Collector; [Header("State (read-only)")] [SerializeField] private AIState currentState = AIState.Idle; [Header("Detection Ranges")] [SerializeField] private float bagScanRadius = 500f; // Full-map awareness (was 50 — too small for large maps) [SerializeField] private float enemyDetectRange = 40f; // COMBAT FIX: Was 15 — too small, AI ignored nearby enemies [SerializeField] private float vaultDefenseRadius = 12f; [SerializeField] private float depositRange = 3.5f; [SerializeField] private float bagPickupRange = 2.5f; [Header("Thresholds")] [SerializeField] private float depositCashThreshold = 200f; // Collect 2+ bags before depositing [SerializeField] private float retreatHealthThreshold = 25f; [SerializeField] private float maxChaseDistance = 20f; [Header("Timing")] [SerializeField] private float decisionInterval = 0.3f; [SerializeField] private float stateMinDuration = 0.6f; [SerializeField] private float sensorInterval = 0.5f; [Header("Combat")] [SerializeField] private float attackRange = 2.5f; [SerializeField] private float attackCooldown = 1.2f; [SerializeField] private float combatApproachSpeed = 1f; [SerializeField] private float aggressionLevel = 0.8f; // 0-1, how eager AI is to fight (0.8 = very aggressive) [Header("Patrol")] [SerializeField] private float patrolRadius = 15f; [SerializeField] private float patrolPauseMin = 0.5f; [SerializeField] private float patrolPauseMax = 2f; [Header("Extraction Mode")] [SerializeField] private float vaultInteractRange = 4f; [SerializeField] private float cashoutStationRange = 5f; [SerializeField] private float carrierInterceptRange = 40f; [Header("Difficulty")] [SerializeField] private int aiLevel = 1; [Header("Debug")] [SerializeField] private bool showDebugGizmos = true; [SerializeField] private string debugLastDecision = ""; // ─── Cached Components ─────────────────────────────────── private CashCarrier cashCarrier; private HealthNew health; private CharacterAIController aiController; private NavMeshAgent navAgent; private TeamMember teamMember; private CharacterAttacks characterAttacks; private Animator animator; // ─── Runtime State ─────────────────────────────────────── private float lastDecisionTime; private float stateStartTime; private Transform currentTarget; private TeamVault myVault; private TeamVault enemyVault; private Vector3 myVaultGroundPos; // Vault position projected to NavMesh level private Vector3 enemyVaultGroundPos; // Vault position projected to NavMesh level private string myTeamTag; private float lastAttackTime; private float lastHeartbeatTime; // For periodic diagnostic logging // Patrol private Vector3 patrolDestination; private bool hasPatrolDestination; private float patrolPauseEndTime; // Coordination — which bag this AI is targeting (prevents clustering) private CashMag claimedBag; // ─── Extraction Mode State ─────────────────────────────── private CashoutStation myStation; private CashoutStation enemyStation; private Vector3 myStationGroundPos; private Vector3 enemyStationGroundPos; private ExtractionVault targetVault; // Vault we're currently going after private bool isCarryingVault; // Are we carrying an ExtractionVault? private GameObject lastDamageAttacker; // Who last hit us (for reactive fight-back) private float lastDamageTime; // When we were last hit // ─── Environment Sensor Cache ──────────────────────────── private float lastSensorTime; private readonly List sensedBags = new List(16); private readonly List sensedEnemies = new List(8); private struct SensedBag { public CashMag bag; public float distance; public float score; } private struct SensedEnemy { public GameObject enemy; public float distance; public float carriedCash; public float healthPercent; } // ─── Static Team Coordination ──────────────────────────── // Track which bags are claimed by which AI (per team) to prevent clustering private static readonly Dictionary _claimedBags = new Dictionary(); // Track all active CashSystemAI instances per team for role assignment private static readonly List _allAIs = new List(8); // ─── Properties ────────────────────────────────────────── public AIState CurrentState => currentState; public AIRole CurrentRole => role; public Transform CurrentTarget => currentTarget; // ─── Lifecycle ─────────────────────────────────────────── private void Start() { if (SelectionOptions.Instance == null || !SelectionOptions.Instance.isCashSystemSelected) { enabled = false; return; } CacheComponents(); FindVaults(); ApplyDifficultyScaling(); DisableCompetingControllers(); _allAIs.Add(this); // Initial sensor scan so we have data immediately ScanEnvironment(); // Subscribe to damage events for reactive fight-back GameEvents.OnDamageDealt += OnDamageTaken; DevLog.Log($"[CashSystemAI] Initialized on {gameObject.name}, Team: {myTeamTag}, " + $"NavAgent: {navAgent != null}, CharAttacks: {characterAttacks != null}"); } /// /// SINGLE-AUTHORITY: Disable TeamAIController and EnemyAIController so they /// cannot interfere with NavMeshAgent. CashSystemAI is the SOLE movement authority. /// private void DisableCompetingControllers() { var teamAI = GetComponent(); if (teamAI != null) { teamAI.enabled = false; DevLog.Log($"[CashSystemAI] Disabled TeamAIController on {gameObject.name}"); } //var enemyAI = GetComponent(); //if (enemyAI != null) //{ // enemyAI.enabled = false; // DevLog.Log($"[CashSystemAI] Disabled EnemyAIController on {gameObject.name}"); //} } private void OnDestroy() { _allAIs.Remove(this); UnclaimBag(); GameEvents.OnDamageDealt -= OnDamageTaken; } /// /// Called by CashSystemManager after all AI are spawned. /// Deterministically assigns 1 Defender + 2 Collectors per team. /// public static void AssignTeamRoles() { // Group AIs by team var playerTeam = new List(); var enemyTeam = new List(); for (int i = 0; i < _allAIs.Count; i++) { if (_allAIs[i] == null) continue; if (_allAIs[i].myTeamTag == "Player") playerTeam.Add(_allAIs[i]); else enemyTeam.Add(_allAIs[i]); } AssignRolesForTeam(playerTeam); AssignRolesForTeam(enemyTeam); } private static void AssignRolesForTeam(List team) { if (team.Count == 0) return; // First AI = Defender, rest = Collectors // Pick the AI closest to the vault as Defender CashSystemAI bestDefender = null; float bestDist = float.MaxValue; for (int i = 0; i < team.Count; i++) { if (team[i].myVault == null) continue; float d = Vector3.Distance(team[i].transform.position, team[i].myVaultGroundPos); if (d < bestDist) { bestDist = d; bestDefender = team[i]; } } for (int i = 0; i < team.Count; i++) { if (team[i] == bestDefender) { team[i].role = AIRole.Defender; DevLog.Log($"[CashSystemAI] {team[i].gameObject.name} assigned DEFENDER"); } else { team[i].role = AIRole.Collector; DevLog.Log($"[CashSystemAI] {team[i].gameObject.name} assigned COLLECTOR"); } } } /// Clear all static state (call from GameEvents.ClearAll or scene unload). public static void ClearAll() { _claimedBags.Clear(); _allAIs.Clear(); } private void CacheComponents() { cashCarrier = GetComponent(); health = GetComponent(); aiController = GetComponent(); navAgent = GetComponent(); teamMember = GetComponent(); characterAttacks = GetComponent(); animator = GetComponent(); myTeamTag = gameObject.tag; } private void FindVaults() { var vaults = EntityRegistry.GetAll(); for (int i = 0; i < vaults.Count; i++) { if (vaults[i] == null) continue; if (vaults[i].TeamTag == myTeamTag) myVault = vaults[i]; else enemyVault = vaults[i]; } // Fallback if (myVault == null || enemyVault == null) { TeamVault[] fallback = FindObjectsOfType(); foreach (var v in fallback) { if (v.TeamTag == myTeamTag) myVault = v; else enemyVault = v; } } // Project vault positions to NavMesh ground level. // Vaults can be floating high above the NavMesh (Y=15+). // NavMeshAgent can't path to those positions, so we cache // the nearest NavMesh point at the vault's XZ location. if (myVault != null) myVaultGroundPos = ProjectToNavMesh(myVault.transform.position); if (enemyVault != null) enemyVaultGroundPos = ProjectToNavMesh(enemyVault.transform.position); // Find CashoutStations for extraction mode var stations = EntityRegistry.GetAll(); for (int i = 0; i < stations.Count; i++) { if (stations[i] == null) continue; if (stations[i].TeamTag == myTeamTag) myStation = stations[i]; else enemyStation = stations[i]; } if (myStation != null) myStationGroundPos = ProjectToNavMesh(myStation.transform.position); if (enemyStation != null) enemyStationGroundPos = ProjectToNavMesh(enemyStation.transform.position); DevLog.Log($"[CashSystemAI] {gameObject.name} vaults: my={myVault != null} at {myVaultGroundPos}, " + $"enemy={enemyVault != null} at {enemyVaultGroundPos}, " + $"myStation={myStation != null}, enemyStation={enemyStation != null}"); } /// /// Project a world position down to the nearest NavMesh surface. /// Essential for vault positions which may be 15+ meters above the NavMesh. /// private Vector3 ProjectToNavMesh(Vector3 worldPos) { // Try at character's Y level first (most likely NavMesh surface) Vector3 probePos = new Vector3(worldPos.x, transform.position.y, worldPos.z); NavMeshHit hit; if (NavMesh.SamplePosition(probePos, out hit, 30f, NavMesh.AllAreas)) return hit.position; // Fallback: try from the original position with large radius if (NavMesh.SamplePosition(worldPos, out hit, 50f, NavMesh.AllAreas)) return hit.position; // Last resort: return XZ of vault at character Y return probePos; } private void ApplyDifficultyScaling() { if (aiLevel > 1) { float scale = 1f + (aiLevel - 1) * 0.15f; decisionInterval = Mathf.Max(0.15f, decisionInterval / (1f + (aiLevel - 1) * 0.2f)); if (navAgent != null) navAgent.speed *= 1f + (aiLevel - 1) * 0.1f; } } // ─── Main Update Loop ──────────────────────────────────── private void Update() { // ── Environment Sensor: periodic scan ── if (Time.time - lastSensorTime >= sensorInterval) { lastSensorTime = Time.time; ScanEnvironment(); } // ── Sync walk animation from NavMeshAgent velocity ── SyncAnimation(); // ── Decision tick ── if (Time.time - lastDecisionTime < decisionInterval) return; lastDecisionTime = Time.time + Random.Range(-0.05f, 0.05f); // Stagger // Lazy vault re-find if (myVault == null || enemyVault == null) FindVaults(); // Periodic diagnostic heartbeat (every 5s) if (Time.time - lastHeartbeatTime > 5f) { lastHeartbeatTime = Time.time; DevLog.Log($"[CashSystemAI] ♥ {gameObject.name}: state={currentState}, role={role}, " + $"pos={transform.position}, navOnMesh={navAgent != null && navAgent.isOnNavMesh}, " + $"navSpeed={navAgent?.speed:F1}, vel={navAgent?.velocity.magnitude:F1}, " + $"bags={sensedBags.Count}, enemies={sensedEnemies.Count}"); } // ALWAYS check retreat first (survival override) if (ShouldRetreat()) { SetState(AIState.Retreat); ExecuteCurrentState(); return; } // Don't switch state too fast (but always re-execute for movement) if (Time.time - stateStartTime < stateMinDuration && currentState != AIState.Idle) { ExecuteCurrentState(); return; } // Evaluate all possible actions and pick the best EvaluateAndDecide(); ExecuteCurrentState(); } /// /// Sync walk/run/idle animation from NavMeshAgent velocity. /// Runs every frame for smooth blending. /// private void SyncAnimation() { if (animator == null) return; if (navAgent != null && navAgent.isActiveAndEnabled && !navAgent.isStopped && navAgent.hasPath) { float spd = navAgent.velocity.magnitude / Mathf.Max(navAgent.speed, 0.01f); animator.SetFloat("Speed", spd > 0.05f ? Mathf.Clamp01(spd) : 0f); } else { animator.SetFloat("Speed", 0f); } } // ─── Decision System (Utility AI) ──────────────────────── private void EvaluateAndDecide() { float bestScore = -1f; AIState bestState = AIState.Patrol; // Never idle — patrol as minimum Transform bestTarget = null; string bestReason = "default patrol"; bool isCarrying = cashCarrier != null && cashCarrier.CarriedCash >= 0.01f; bool combatEnabled = IsCombatEnabled(); float myHealth = health != null ? health.currentHealth : 100f; // ── Check if extraction vaults exist — if so, use extraction logic ── bool hasExtractionVaults = EntityRegistry.GetAll().Count > 0 || myStation != null; isCarryingVault = IsCarryingExtractionVault(); if (hasExtractionVaults) { EvaluateExtractionActions(ref bestScore, ref bestState, ref bestTarget, ref bestReason, combatEnabled, myHealth); } else { // ── Legacy bag-collection mode ── EvaluateBagCollectionActions(ref bestScore, ref bestState, ref bestTarget, ref bestReason, isCarrying, combatEnabled, myHealth); } // ── ALWAYS: Attack enemies in range (applies to both modes) ── if (combatEnabled && !isCarryingVault) // Can't attack while carrying vault { GameObject enemy = FindBestAttackTarget(); if (enemy != null) { float dist = Vector3.Distance(transform.position, enemy.transform.position); if (dist < enemyDetectRange) { // BOOSTED: Base score now 50 (was 0) — combat is a REAL option float score = 50f; // BOOSTED: Proximity bonus now 40 (was 20) — close enemies are priority score += 40f * (1f - dist / enemyDetectRange); CashCarrier enemyCarrier = enemy.GetComponent(); if (enemyCarrier != null && enemyCarrier.CarriedCash > 0) score += 40f + enemyCarrier.CarriedCash * 0.2f; HealthNew enemyHP = enemy.GetComponent(); if (enemyHP != null && enemyHP.currentHealth < 40) score += 30f; if (role == AIRole.Defender && myVault != null) { float enemyToVault = Vector3.Distance(enemy.transform.position, myVaultGroundPos); if (enemyToVault < vaultDefenseRadius) score += 80f; } if (role == AIRole.Collector && isCarrying) score *= 0.4f; // In extraction mode, boost priority for intercepting vault carriers if (hasExtractionVaults && IsEnemyCarryingVault(enemy)) score += 150f; // Very high — they have THE vault // REACTIVE: Massive boost if we were recently attacked if (lastDamageAttacker != null && Time.time - lastDamageTime < 5f) score += 60f; // Being hit = FIGHT BACK // Apply aggression multiplier (inspector-tunable) score *= aggressionLevel; if (score > bestScore) { bestScore = score; bestState = isCarryingVault ? AIState.CarryVault : AIState.AttackEnemy; bestTarget = enemy.transform; bestReason = $"attack {enemy.name} (score {score:F0})"; } } } } // ── ALWAYS: Defender patrol (applies to both modes) ── if (role == AIRole.Defender && !isCarryingVault) { Vector3 defenseCenter = myStation != null ? myStationGroundPos : (myVault != null ? myVaultGroundPos : transform.position); float distToBase = Vector3.Distance(transform.position, defenseCenter); if (distToBase > vaultDefenseRadius && bestState == AIState.Patrol) { bestScore = 25f; bestState = AIState.DefendVault; bestTarget = myStation != null ? myStation.transform : (myVault != null ? myVault.transform : null); bestReason = "return to base"; } } currentTarget = bestTarget; debugLastDecision = bestReason; SetState(bestState); } /// Extraction-mode decision evaluation. private void EvaluateExtractionActions(ref float bestScore, ref AIState bestState, ref Transform bestTarget, ref string bestReason, bool combatEnabled, float myHealth) { // ── 1. CARRY VAULT TO CASHOUT STATION (highest priority if carrying) ── if (isCarryingVault && myStation != null) { float score = 300f; // Always go deposit when carrying if (score > bestScore) { bestScore = score; bestState = AIState.CarryVault; bestTarget = myStation.transform; bestReason = "carrying vault to cashout station"; } return; // Don't evaluate other actions while carrying } // ── 2. DEFEND ACTIVE CASHOUT (our station is cashing out) ── if (myStation != null && myStation.IsCashingOut) { float score = 150f; if (role == AIRole.Defender) score += 100f; // More valuable cashouts = more worth defending score += myStation.CashoutRemainingValue * 0.01f; if (score > bestScore) { bestScore = score; bestState = AIState.DefendCashout; bestTarget = myStation.transform; bestReason = $"defend cashout ({myStation.CashoutProgress:P0})"; } } // ── 3. ATTACK/HACK ENEMY CASHOUT (enemy station is cashing out) ── if (combatEnabled && enemyStation != null && enemyStation.IsCashingOut) { float dist = Vector3.Distance(transform.position, enemyStationGroundPos); float score = 100f; // Undefended = go for it int enemies = CountEnemiesNear(enemyStationGroundPos, 15f); if (enemies == 0) score += 80f; else if (enemies == 1 && myHealth > 60f) score += 30f; else score -= 40f; // Closer = more attractive score += 30f * Mathf.Clamp01(1f - dist / 100f); // Collectors more aggressive at hacking if (role == AIRole.Collector) score += 20f; if (score > bestScore) { bestScore = score; bestState = AIState.AttackCashout; bestTarget = enemyStation.transform; bestReason = $"hack enemy cashout (defenders:{enemies})"; } } // ── 4. INTERCEPT ENEMY VAULT CARRIER ── if (combatEnabled) { var enemyCarriedVault = FindEnemyCarryingVaultObj(); if (enemyCarriedVault != null) { float dist = Vector3.Distance(transform.position, enemyCarriedVault.transform.position); if (dist < carrierInterceptRange) { float score = 120f; score += 40f * Mathf.Clamp01(1f - dist / carrierInterceptRange); if (role == AIRole.Defender) score += 30f; if (score > bestScore) { bestScore = score; bestState = AIState.InterceptCarrier; bestTarget = enemyCarriedVault.transform; bestReason = $"intercept vault carrier ({dist:F0}m)"; } } } } // ── 5. PICK UP AVAILABLE VAULT (opened/dropped, not carried) ── ExtractionVault avail = ExtractionVault.FindNearestAvailable(transform.position); if (avail != null) { float dist = Vector3.Distance(transform.position, avail.transform.position); float score = 90f; score += 30f * Mathf.Clamp01(1f - dist / 100f); // Dropped vaults are urgent (they despawn) if (avail.IsDropped) score += 25f; if (role == AIRole.Collector) score += 20f; if (role == AIRole.Defender) score *= 0.4f; if (score > bestScore) { bestScore = score; bestState = AIState.SeekVault; bestTarget = avail.transform; bestReason = $"pick up vault ({dist:F0}m)"; targetVault = avail; } } // ── 6. SEEK SEALED VAULT (go open one) ── ExtractionVault sealedVault = ExtractionVault.FindNearestSealed(transform.position); if (sealedVault != null) { float dist = Vector3.Distance(transform.position, sealedVault.transform.position); float score = 70f; score += 20f * Mathf.Clamp01(1f - dist / 100f); if (role == AIRole.Collector) score += 15f; if (role == AIRole.Defender) score *= 0.3f; if (score > bestScore) { bestScore = score; bestState = AIState.SeekVault; bestTarget = sealedVault.transform; bestReason = $"open sealed vault ({dist:F0}m)"; targetVault = sealedVault; } } // ── 7. LEGACY: STEAL FROM ENEMY VAULT (fallback) ── if (combatEnabled && enemyVault != null && enemyVault.StoredCash > 50f) { float dist = Vector3.Distance(transform.position, enemyVaultGroundPos); int enemies = CountEnemiesNear(enemyVaultGroundPos, vaultDefenseRadius); float score = 10f + (enemyVault.StoredCash / 500f) * 15f; if (enemies == 0) score += 30f; else score -= 30f; if (role == AIRole.Defender) score *= 0.1f; if (score > bestScore) { bestScore = score; bestState = AIState.StealFromVault; bestTarget = enemyVault.transform; bestReason = $"steal from vault (defenders:{enemies})"; } } } /// Legacy bag-collection decision evaluation (pre-extraction mode). private void EvaluateBagCollectionActions(ref float bestScore, ref AIState bestState, ref Transform bestTarget, ref string bestReason, bool isCarrying, bool combatEnabled, float myHealth) { if (myVault != null && myVault.IsBeingStolen) { float score = 200f; // Critical priority if (role == AIRole.Defender) score += 100f; if (score > bestScore) { bestScore = score; bestState = AIState.DefendVault; bestTarget = myVault.transform; bestReason = "vault under attack!"; } } // ── 2. DEPOSIT CASH (if carrying) ── if (isCarrying && myVault != null) { float carried = cashCarrier.CarriedCash; int bags = cashCarrier.BagCount; float score = 20f + (carried / depositCashThreshold) * 50f; // Only 1 bag and more available? Low urgency to deposit if (bags == 1 && cashCarrier.CanPickUp()) { CashMag extraBag = FindBestUnclaimedBag(); if (extraBag != null) score *= 0.3f; // Heavily suppress — go grab another bag first } // Full bags = desperate to deposit if (bags >= 3) score += 80f; // Carrying + enemies nearby = deposit urgently GameObject nearEnemy = FindNearestEnemy(); if (nearEnemy != null) { float enemyDist = Vector3.Distance(transform.position, nearEnemy.transform.position); if (enemyDist < enemyDetectRange) score += 40f * (1f - enemyDist / enemyDetectRange); } if (score > bestScore) { bestScore = score; bestState = AIState.DepositCash; bestTarget = myVault.transform; bestReason = $"deposit ${carried:F0}"; } } // ── 3. ATTACK ENEMY (context-dependent) ── if (combatEnabled) { GameObject enemy = FindBestAttackTarget(); if (enemy != null) { float dist = Vector3.Distance(transform.position, enemy.transform.position); if (dist < enemyDetectRange) { // BOOSTED: Base score now 50 (was 0) — AI actually considers fighting float score = 50f; // BOOSTED: Proximity bonus now 40 (was 20) score += 40f * (1f - dist / enemyDetectRange); // Enemy carrying cash = juicy target CashCarrier enemyCarrier = enemy.GetComponent(); if (enemyCarrier != null && enemyCarrier.CarriedCash > 0) score += 40f + enemyCarrier.CarriedCash * 0.2f; // Enemy is weak = finish them HealthNew enemyHP = enemy.GetComponent(); if (enemyHP != null && enemyHP.currentHealth < 40) score += 30f; // Defender: boost if enemy near vault if (role == AIRole.Defender && myVault != null) { float enemyToVault = Vector3.Distance(enemy.transform.position, myVaultGroundPos); if (enemyToVault < vaultDefenseRadius) score += 80f; // Very high — they're in our territory } // Collector carrying cash = less eager to fight if (role == AIRole.Collector && isCarrying) score *= 0.4f; // REACTIVE: Massive boost if recently attacked if (lastDamageAttacker != null && Time.time - lastDamageTime < 5f) score += 60f; // Apply aggression multiplier score *= aggressionLevel; if (score > bestScore) { bestScore = score; bestState = AIState.AttackEnemy; bestTarget = enemy.transform; bestReason = $"attack {enemy.name} (score {score:F0})"; } } } } // ── 4. COLLECT CASH (Collector bread-and-butter) ── if (cashCarrier != null && cashCarrier.CanPickUp() && !isCarrying) { CashMag bag = FindBestUnclaimedBag(); if (bag != null) { float dist = Vector3.Distance(transform.position, bag.transform.position); float score = 60f; // Closer bags score higher (soft curve over 120m instead of bagScanRadius) score += 30f * Mathf.Clamp01(1f - dist / 120f); // Higher value bags score += bag.CashValue * 0.1f; // Hot drops are extra valuable if (bag.IsHotDrop) score += 25f; // Dropped bags from killed enemies if (bag.IsDropped) score += 15f; // Defender: less interested in collection if (role == AIRole.Defender) score *= 0.3f; if (score > bestScore) { bestScore = score; bestState = AIState.CollectCash; bestTarget = bag.transform; bestReason = $"collect bag (${bag.CashValue}, {dist:F0}m away)"; } } } // Also check if already carrying but can pick up more else if (cashCarrier != null && cashCarrier.CanPickUp() && isCarrying && cashCarrier.BagCount < 3) { CashMag bag = FindBestUnclaimedBag(); if (bag != null) { float dist = Vector3.Distance(transform.position, bag.transform.position); // Grab nearby bags when already carrying (expanded range from 10 to 30m) if (dist < 30f) { float score = 50f + 25f * Mathf.Clamp01(1f - dist / 30f); if (bag.IsDropped) score += 15f; if (role == AIRole.Defender) score *= 0.2f; if (score > bestScore) { bestScore = score; bestState = AIState.CollectCash; bestTarget = bag.transform; bestReason = $"grab extra bag (${bag.CashValue}, {dist:F0}m)"; } } } } // ── 5. STEAL FROM ENEMY VAULT (opportunistic Collector action) ── if (combatEnabled && enemyVault != null && enemyVault.StoredCash > 50f && !isCarrying) { float dist = Vector3.Distance(transform.position, enemyVaultGroundPos); int enemiesNearVault = CountEnemiesNear(enemyVaultGroundPos, vaultDefenseRadius); float score = 10f + (enemyVault.StoredCash / 500f) * 20f; // Undefended vault = go for it if (enemiesNearVault == 0) score += 40f; else if (enemiesNearVault == 1 && myHealth > 70f) score += 10f; else score -= 30f; // Too risky // Defender: rarely steals if (role == AIRole.Defender) score *= 0.15f; // Distance penalty score -= dist * 0.5f; if (score > bestScore) { bestScore = score; bestState = AIState.StealFromVault; bestTarget = enemyVault.transform; bestReason = $"steal (defenders: {enemiesNearVault})"; } } // ── 6. DEFENDER PATROL (stay near vault) ── if (role == AIRole.Defender && myVault != null) { float distToVault = Vector3.Distance(transform.position, myVaultGroundPos); if (distToVault > vaultDefenseRadius && bestState == AIState.Patrol) { // Return to vault area bestScore = 25f; bestState = AIState.DefendVault; bestTarget = myVault.transform; bestReason = "return to vault"; } } } private bool ShouldRetreat() { if (health == null) return false; if (health.currentHealth > retreatHealthThreshold) return false; // Only retreat if there's actually danger nearby for (int i = 0; i < sensedEnemies.Count; i++) { if (sensedEnemies[i].distance < enemyDetectRange) return true; } return false; } private bool IsCombatEnabled() { if (aiController != null) return aiController.attack; return true; } // ─── Environment Sensor ────────────────────────────────── /// /// Scans the environment for cash bags, enemies, and vaults. /// Results are cached in sorted lists for fast access by the decision system. /// This is the "sensor" system — AI becomes aware of its environment through this. /// private void ScanEnvironment() { ScanBags(); ScanEnemies(); } private void ScanBags() { sensedBags.Clear(); var bags = EntityRegistry.GetAll(); for (int i = 0; i < bags.Count; i++) { CashMag bag = bags[i]; if (bag == null) continue; if (!bag.IsAvailable && !bag.IsDropped) continue; float dist = Vector3.Distance(transform.position, bag.transform.position); // No hard distance cutoff — AI sees ALL bags on the map. // Distance is used for scoring only, not filtering. float score = 50f; // Closer bags score higher, but use a soft curve (not a hard wall) // Bags within 30m get full proximity bonus, farther bags get diminishing bonus float proximityBonus = 40f * Mathf.Clamp01(1f - dist / 120f); score += proximityBonus; score += bag.CashValue * 0.1f; if (bag.IsDropped) score += 20f; if (bag.IsHotDrop) score += 30f; sensedBags.Add(new SensedBag { bag = bag, distance = dist, score = score }); } // Sort by score descending (best bags first) sensedBags.Sort((a, b) => b.score.CompareTo(a.score)); } private void ScanEnemies() { sensedEnemies.Clear(); foreach (TeamMember member in TeamMember.AllMembers) { if (member == null || member.gameObject == gameObject) continue; if (member.gameObject.CompareTag(myTeamTag)) continue; HealthNew hp = member.GetComponent(); if (hp != null && hp.isDead) continue; float dist = Vector3.Distance(transform.position, member.transform.position); if (dist > Mathf.Max(enemyDetectRange, bagScanRadius)) continue; CashCarrier ec = member.GetComponent(); float carried = ec != null ? ec.CarriedCash : 0f; float healthPct = hp != null ? ((float)hp.currentHealth / Mathf.Max(hp.maxHealth, 1)) : 1f; sensedEnemies.Add(new SensedEnemy { enemy = member.gameObject, distance = dist, carriedCash = carried, healthPercent = healthPct }); } // Sort by distance ascending (closest first) sensedEnemies.Sort((a, b) => a.distance.CompareTo(b.distance)); } // ─── State Execution ───────────────────────────────────── private void SetState(AIState newState) { if (newState != currentState) { // Clean up old state if (currentState == AIState.CollectCash) UnclaimBag(); currentState = newState; stateStartTime = Time.time; hasPatrolDestination = false; DevLog.Log($"[CashSystemAI] {gameObject.name} → {newState} ({debugLastDecision})"); } } private void ExecuteCurrentState() { switch (currentState) { case AIState.Idle: ExecuteIdle(); break; case AIState.CollectCash: ExecuteCollect(); break; case AIState.DepositCash: ExecuteDeposit(); break; case AIState.AttackEnemy: ExecuteAttack(); break; case AIState.StealFromVault: ExecuteSteal(); break; case AIState.DefendVault: ExecuteDefend(); break; case AIState.Retreat: ExecuteRetreat(); break; case AIState.Patrol: ExecutePatrol(); break; // Extraction mode states case AIState.SeekVault: ExecuteSeekVault(); break; case AIState.OpenVault: ExecuteOpenVault(); break; case AIState.CarryVault: ExecuteCarryVault(); break; case AIState.DepositVault: ExecuteDepositVault(); break; case AIState.DefendCashout: ExecuteDefendCashout(); break; case AIState.AttackCashout: ExecuteAttackCashout(); break; case AIState.InterceptCarrier: ExecuteInterceptCarrier(); break; } } // ─── State Implementations ─────────────────────────────── private void ExecuteIdle() { // Idle should never persist — immediately transition SetState(AIState.Patrol); } private void ExecuteCollect() { // PROACTIVE: If an enemy is very close while collecting, engage them if (IsCombatEnabled() && !isCarryingVault) { GameObject enemy = FindBestAttackTarget(); if (enemy != null) { float enemyDist = Vector3.Distance(transform.position, enemy.transform.position); // Fight enemies within close range (60% of detect range) while collecting if (enemyDist < enemyDetectRange * 0.6f) { currentTarget = enemy.transform; SetState(AIState.AttackEnemy); debugLastDecision = $"ENGAGE {enemy.name} near bag ({enemyDist:F0}m)"; return; } } } if (currentTarget == null) { SetState(AIState.Patrol); return; } CashMag bag = currentTarget.GetComponent(); if (bag == null || (!bag.IsAvailable && !bag.IsDropped)) { // Bag taken or destroyed — find another UnclaimBag(); CashMag newBag = FindBestUnclaimedBag(); if (newBag != null) { ClaimBag(newBag); currentTarget = newBag.transform; } else { SetState(AIState.Patrol); return; } } float dist = Vector3.Distance(transform.position, currentTarget.position); // Close enough to pick up if (dist <= bagPickupRange) { if (cashCarrier != null && cashCarrier.CanPickUp() && bag != null) { bag.PickUp(cashCarrier); cashCarrier.OnBagPickedUp(bag); DevLog.Log($"[CashSystemAI] {gameObject.name} picked up bag (${bag.CashValue}), total ${cashCarrier.CarriedCash:F0}"); UnclaimBag(); // Decide next: deposit or grab another? if (cashCarrier.BagCount >= 2 || cashCarrier.CarriedCash >= depositCashThreshold) { SetState(AIState.DepositCash); currentTarget = myVault != null ? myVault.transform : null; } else { // Try to find another nearby bag CashMag next = FindBestUnclaimedBag(); if (next != null && Vector3.Distance(transform.position, next.transform.position) < 15f) { ClaimBag(next); currentTarget = next.transform; } else { SetState(AIState.DepositCash); currentTarget = myVault != null ? myVault.transform : null; } } } return; } // Move toward bag MoveToward(currentTarget.position); } private void ExecuteDeposit() { if (myVault == null) { FindVaults(); if (myVault == null) { SetState(AIState.Patrol); return; } } // Check we still have cash if (cashCarrier == null || cashCarrier.CarriedCash < 0.01f) { DevLog.Log($"[CashSystemAI] {gameObject.name} no cash to deposit, back to work"); SetState(AIState.Idle); return; } float dist = Vector3.Distance(transform.position, myVaultGroundPos); // Close enough — deposit if (dist <= depositRange) { float deposited = cashCarrier.DepositToVault(myVault); DevLog.Log($"[CashSystemAI] {gameObject.name} deposited ${deposited:F0}!"); SetState(AIState.Idle); return; } MoveToward(myVaultGroundPos); } private void ExecuteAttack() { if (currentTarget == null || !IsCombatEnabled()) { SetState(AIState.Idle); return; } // Check target still alive HealthNew targetHP = currentTarget.GetComponent(); if (targetHP != null && targetHP.isDead) { DevLog.Log($"[CashSystemAI] {gameObject.name} target died, scanning"); SetState(AIState.Idle); return; } float dist = Vector3.Distance(transform.position, currentTarget.position); // Don't chase too far (especially Defenders) float maxChase = role == AIRole.Defender ? vaultDefenseRadius * 1.5f : maxChaseDistance; if (dist > maxChase) { DevLog.Log($"[CashSystemAI] {gameObject.name} target too far, disengaging"); SetState(AIState.Idle); return; } // ── DIRECT COMBAT (no delegation to CharacterAIController) ── // Always face the target when in combat range if (dist < attackRange * 3f) FaceTarget(currentTarget.position); if (dist <= attackRange) { // In attack range — stop and punch StopNavAgent(); PerformDirectAttack(); } else { // Approach the target MoveToward(currentTarget.position); } } private void ExecuteSteal() { if (enemyVault == null) { SetState(AIState.Idle); return; } // Abort if defenders showed up int enemies = CountEnemiesNear(enemyVaultGroundPos, vaultDefenseRadius); if (enemies >= 2) { DevLog.Log($"[CashSystemAI] {gameObject.name} aborting steal — too many defenders ({enemies})"); SetState(AIState.Retreat); return; } MoveToward(enemyVaultGroundPos); } private void ExecuteDefend() { if (myVault == null) { SetState(AIState.Patrol); return; } // Priority: attack enemies near vault — DIRECT combat GameObject intruder = FindEnemyNearVault(); if (intruder != null) { currentTarget = intruder.transform; float dist = Vector3.Distance(transform.position, intruder.transform.position); FaceTarget(intruder.transform.position); if (dist <= attackRange) { StopNavAgent(); PerformDirectAttack(); } else { MoveToward(intruder.transform.position); } return; } // No intruders — patrol around vault float distToVault = Vector3.Distance(transform.position, myVaultGroundPos); if (distToVault > vaultDefenseRadius) { // Too far from vault, return MoveToward(myVaultGroundPos); return; } // Patrol within vault radius if (Time.time < patrolPauseEndTime) return; if (!hasPatrolDestination || HasReachedPosition(patrolDestination, 2f)) { if (hasPatrolDestination) { patrolPauseEndTime = Time.time + Random.Range(patrolPauseMin, patrolPauseMax); hasPatrolDestination = false; return; } // Pick point within vault defense radius — use GROUNDED vault position Vector3 randomDir = Random.insideUnitSphere * vaultDefenseRadius * 0.8f; randomDir.y = 0; Vector3 point = myVaultGroundPos + randomDir; NavMeshHit hit; if (NavMesh.SamplePosition(point, out hit, vaultDefenseRadius, NavMesh.AllAreas)) { patrolDestination = hit.position; hasPatrolDestination = true; MoveToward(patrolDestination); } } else { MoveToward(patrolDestination); } } private void ExecuteRetreat() { if (myVault == null) { FindVaults(); if (myVault == null) { SetState(AIState.Patrol); return; } } // Deposit cash while retreating float distToVault = Vector3.Distance(transform.position, myVaultGroundPos); if (distToVault < depositRange) { if (cashCarrier != null && cashCarrier.CarriedCash > 0) cashCarrier.DepositToVault(myVault); } // Head toward vault (safe haven) MoveToward(myVaultGroundPos); // Recover check — exit retreat when health recovered AND safe if (health != null && health.currentHealth > retreatHealthThreshold * 2f) { GameObject nearEnemy = FindNearestEnemy(); if (nearEnemy == null || Vector3.Distance(transform.position, nearEnemy.transform.position) > enemyDetectRange) { SetState(AIState.Idle); } } } private void ExecutePatrol() { if (Time.time < patrolPauseEndTime) return; // PROACTIVE COMBAT: While patrolling, actively hunt nearby enemies if (IsCombatEnabled() && !isCarryingVault) { GameObject enemy = FindBestAttackTarget(); if (enemy != null) { float dist = Vector3.Distance(transform.position, enemy.transform.position); // Engage enemies within detect range based on aggression level // At aggressionLevel 0.8, engage within 32m (80% of 40m range) if (dist < enemyDetectRange * aggressionLevel) { currentTarget = enemy.transform; SetState(AIState.AttackEnemy); debugLastDecision = $"HUNT {enemy.name} while patrolling ({dist:F0}m)"; DevLog.Log($"[CashSystemAI] {gameObject.name} hunting {enemy.name} during patrol!"); return; } } } // While patrolling, check for opportunities CashMag bag = FindBestUnclaimedBag(); if (bag != null) { ClaimBag(bag); currentTarget = bag.transform; SetState(AIState.CollectCash); return; } // Defender: always return to vault area when nothing to do if (role == AIRole.Defender && myVault != null) { float distToVault = Vector3.Distance(transform.position, myVaultGroundPos); if (distToVault > vaultDefenseRadius * 0.5f) { SetState(AIState.DefendVault); return; } } // Pick new random patrol point if (!hasPatrolDestination || HasReachedPosition(patrolDestination, 2f)) { if (hasPatrolDestination) { patrolPauseEndTime = Time.time + Random.Range(patrolPauseMin, patrolPauseMax); hasPatrolDestination = false; return; } // Collectors patrol far (explore), Defenders near vault float radius = role == AIRole.Defender ? vaultDefenseRadius : patrolRadius; Vector3 center = role == AIRole.Defender && myVault != null ? myVaultGroundPos : transform.position; Vector3 randomPoint = center + Random.insideUnitSphere * radius; randomPoint.y = transform.position.y; NavMeshHit hit; if (NavMesh.SamplePosition(randomPoint, out hit, radius, NavMesh.AllAreas)) { patrolDestination = hit.position; hasPatrolDestination = true; MoveToward(patrolDestination); } } else { MoveToward(patrolDestination); } } // ─── Extraction Mode State Implementations ────────────── private void ExecuteSeekVault() { if (targetVault == null) { // Try to find any vault targetVault = ExtractionVault.FindNearestSealed(transform.position); if (targetVault == null) targetVault = ExtractionVault.FindNearestAvailable(transform.position); if (targetVault == null) { SetState(AIState.Patrol); return; } currentTarget = targetVault.transform; } // Check if vault state changed (someone else grabbed it) if (targetVault.IsCarried || targetVault.State == ExtractionVault.VaultState.Deposited) { targetVault = null; SetState(AIState.Idle); return; } float dist = Vector3.Distance(transform.position, targetVault.transform.position); if (dist <= vaultInteractRange) { if (targetVault.IsSealed) { // Start opening targetVault.StartOpening(gameObject); SetState(AIState.OpenVault); return; } else if (targetVault.IsAvailableForPickup) { // Pick it up targetVault.PickUp(gameObject); isCarryingVault = true; SetState(AIState.CarryVault); currentTarget = myStation != null ? myStation.transform : (myVault != null ? myVault.transform : null); return; } } MoveToward(targetVault.transform.position); } private void ExecuteOpenVault() { if (targetVault == null || targetVault.State == ExtractionVault.VaultState.Deposited) { SetState(AIState.Idle); return; } // Stay still while opening (channel) StopNavAgent(); if (targetVault.IsAvailableForPickup) { // Opening complete, pick it up targetVault.PickUp(gameObject); isCarryingVault = true; SetState(AIState.CarryVault); currentTarget = myStation != null ? myStation.transform : (myVault != null ? myVault.transform : null); return; } // If vault is no longer being opened by us (cancelled due to damage etc.) if (targetVault.IsSealed && targetVault.OpenProgress < 0.01f) { // Retry opening float dist = Vector3.Distance(transform.position, targetVault.transform.position); if (dist <= vaultInteractRange) targetVault.StartOpening(gameObject); else MoveToward(targetVault.transform.position); } } private void ExecuteCarryVault() { // Navigate to cashout station (encumbered — speed already reduced by ExtractionVault) Vector3 depositPos = myStation != null ? myStationGroundPos : (myVault != null ? myVaultGroundPos : transform.position); float dist = Vector3.Distance(transform.position, depositPos); if (dist <= cashoutStationRange) { // We're at the station — deposit handled by trigger // But in case trigger didn't fire, force deposit if (myStation != null && targetVault != null && targetVault.IsCarried) { myStation.DepositVault(targetVault, gameObject); isCarryingVault = false; targetVault = null; SetState(AIState.DefendCashout); currentTarget = myStation.transform; return; } } // Check if we dropped the vault (got hit, died, etc.) if (!IsCarryingExtractionVault()) { isCarryingVault = false; targetVault = null; SetState(AIState.Idle); return; } MoveToward(depositPos); } private void ExecuteDepositVault() { // Same as CarryVault in practice — navigate to station ExecuteCarryVault(); } private void ExecuteDefendCashout() { if (myStation == null || !myStation.IsCashingOut) { SetState(AIState.Idle); return; } // Attack enemies near the cashout station GameObject intruder = FindEnemyNearPosition(myStationGroundPos, myStation.DefenseRadius); if (intruder != null) { currentTarget = intruder.transform; float dist = Vector3.Distance(transform.position, intruder.transform.position); FaceTarget(intruder.transform.position); if (dist <= attackRange) { StopNavAgent(); PerformDirectAttack(); } else { MoveToward(intruder.transform.position); } return; } // No intruders — patrol around station float distToStation = Vector3.Distance(transform.position, myStationGroundPos); if (distToStation > myStation.DefenseRadius) { MoveToward(myStationGroundPos); return; } // Patrol within defense radius if (Time.time < patrolPauseEndTime) return; if (!hasPatrolDestination || HasReachedPosition(patrolDestination, 2f)) { if (hasPatrolDestination) { patrolPauseEndTime = Time.time + Random.Range(patrolPauseMin, patrolPauseMax); hasPatrolDestination = false; return; } Vector3 randomDir = Random.insideUnitSphere * myStation.DefenseRadius * 0.7f; randomDir.y = 0; Vector3 point = myStationGroundPos + randomDir; NavMeshHit hit; if (NavMesh.SamplePosition(point, out hit, myStation.DefenseRadius, NavMesh.AllAreas)) { patrolDestination = hit.position; hasPatrolDestination = true; MoveToward(patrolDestination); } } else { MoveToward(patrolDestination); } } private void ExecuteAttackCashout() { if (enemyStation == null || !enemyStation.IsCashingOut) { SetState(AIState.Idle); return; } float dist = Vector3.Distance(transform.position, enemyStationGroundPos); // Abort if too many defenders int defenders = CountEnemiesNear(enemyStationGroundPos, 15f); if (defenders >= 3) { SetState(AIState.Retreat); return; } // Fight defenders first GameObject defender = FindEnemyNearPosition(enemyStationGroundPos, 15f); if (defender != null) { float defDist = Vector3.Distance(transform.position, defender.transform.position); if (defDist <= attackRange) { FaceTarget(defender.transform.position); StopNavAgent(); PerformDirectAttack(); return; } else if (defDist < enemyDetectRange) { MoveToward(defender.transform.position); return; } } // Move toward enemy cashout station to hack it (handled by trigger/proximity) if (dist > cashoutStationRange) { MoveToward(enemyStationGroundPos); } else { // In range — station's trigger system handles the hack StopNavAgent(); } } private void ExecuteInterceptCarrier() { if (currentTarget == null) { SetState(AIState.Idle); return; } // Check if enemy still has the vault HealthNew targetHP = currentTarget.GetComponent(); if (targetHP != null && targetHP.isDead) { SetState(AIState.Idle); return; } if (!IsEnemyCarryingVault(currentTarget.gameObject)) { // They dropped it or deposited — look for the vault SetState(AIState.Idle); return; } float dist = Vector3.Distance(transform.position, currentTarget.position); if (dist <= attackRange) { FaceTarget(currentTarget.position); StopNavAgent(); PerformDirectAttack(); } else { MoveToward(currentTarget.position); } } // ─── Extraction Helpers ────────────────────────────────── /// Check if this AI is currently carrying an ExtractionVault. private bool IsCarryingExtractionVault() { var vaults = EntityRegistry.GetAll(); for (int i = 0; i < vaults.Count; i++) { if (vaults[i] != null && vaults[i].IsCarried && vaults[i].Carrier == gameObject) return true; } return false; } /// Check if a specific enemy is carrying an ExtractionVault. private bool IsEnemyCarryingVault(GameObject enemy) { var vaults = EntityRegistry.GetAll(); for (int i = 0; i < vaults.Count; i++) { if (vaults[i] != null && vaults[i].IsCarried && vaults[i].Carrier == enemy) return true; } return false; } /// Find the enemy currently carrying a vault. private GameObject FindEnemyCarryingVaultObj() { var vaults = EntityRegistry.GetAll(); for (int i = 0; i < vaults.Count; i++) { if (vaults[i] == null || !vaults[i].IsCarried) continue; if (vaults[i].Carrier != null && !vaults[i].Carrier.CompareTag(myTeamTag)) return vaults[i].Carrier; } return null; } /// Find nearest enemy within a radius of a specific position. private GameObject FindEnemyNearPosition(Vector3 position, float radius) { GameObject best = null; float bestDist = float.MaxValue; foreach (TeamMember member in TeamMember.AllMembers) { if (member == null || member.gameObject == gameObject) continue; if (member.gameObject.CompareTag(myTeamTag)) continue; HealthNew hp = member.GetComponent(); if (hp != null && hp.isDead) continue; float dist = Vector3.Distance(member.transform.position, position); if (dist < radius && dist < bestDist) { bestDist = dist; best = member.gameObject; } } return best; } // ─── Target Finders (sensor-backed) ──────────────────── /// Find the best enemy to attack — uses sensor cache for efficiency. private GameObject FindBestAttackTarget() { GameObject best = null; float bestScore = 0f; for (int i = 0; i < sensedEnemies.Count; i++) { var se = sensedEnemies[i]; if (se.enemy == null || se.distance > enemyDetectRange) continue; float score = 10f; // Closer = better score += 20f * (1f - se.distance / enemyDetectRange); // Carrying cash = priority target if (se.carriedCash > 0) score += 30f + se.carriedCash * 0.3f; // Low health = easy kill score += (1f - se.healthPercent) * 30f; // Near our vault = high threat (Defender boost) if (role == AIRole.Defender && myVault != null) { float eDist = Vector3.Distance(se.enemy.transform.position, myVaultGroundPos); if (eDist < vaultDefenseRadius) score += 60f; } // Stealing from our vault = MAXIMUM priority if (myVault != null && myVault.IsBeingStolen) { float eDist = Vector3.Distance(se.enemy.transform.position, myVaultGroundPos); if (eDist < vaultDefenseRadius) score += 200f; } if (score > bestScore) { bestScore = score; best = se.enemy; } } return best; } /// Find nearest enemy — uses sensor cache (already sorted by distance). private GameObject FindNearestEnemy() { for (int i = 0; i < sensedEnemies.Count; i++) { if (sensedEnemies[i].enemy != null) return sensedEnemies[i].enemy; } return null; } /// Find enemy near our vault (for Defender). private GameObject FindEnemyNearVault() { if (myVault == null) return null; GameObject best = null; float bestDist = float.MaxValue; foreach (TeamMember member in TeamMember.AllMembers) { if (member == null || member.gameObject == gameObject) continue; if (member.gameObject.CompareTag(myTeamTag)) continue; HealthNew hp = member.GetComponent(); if (hp != null && hp.isDead) continue; float dist = Vector3.Distance(member.transform.position, myVaultGroundPos); if (dist < vaultDefenseRadius && dist < bestDist) { bestDist = dist; best = member.gameObject; } } return best; } /// Find the best cash bag not claimed by a teammate — uses sensor cache. private CashMag FindBestUnclaimedBag() { // Build a shortlist of the top-N valid bags, then pick randomly // to spread AI across the map instead of all chasing the same bag. const int TOP_N = 3; CashMag[] candidates = new CashMag[TOP_N]; float[] candidateScores = new float[TOP_N]; int found = 0; bool isCarrying = cashCarrier != null && cashCarrier.CarriedCash >= 0.01f; // sensedBags is already sorted by score descending for (int i = 0; i < sensedBags.Count && found < TOP_N; i++) { CashMag bag = sensedBags[i].bag; if (bag == null) continue; if (!bag.IsAvailable && !bag.IsDropped) continue; // Skip if claimed by a teammate (coordination) if (IsBagClaimedByTeammate(bag)) continue; // Penalize bags near enemies when we're carrying cash if (isCarrying) { int enemiesNear = CountEnemiesNearSensor(bag.transform.position, 8f); if (enemiesNear > 1) continue; // Skip dangerous bags when carrying } candidates[found] = bag; candidateScores[found] = sensedBags[i].score; found++; } if (found == 0) return null; if (found == 1) return candidates[0]; // Weighted random selection from top-N candidates // Higher-scoring bags are more likely but not guaranteed float totalScore = 0f; for (int i = 0; i < found; i++) totalScore += candidateScores[i]; float roll = Random.Range(0f, totalScore); float cumulative = 0f; for (int i = 0; i < found; i++) { cumulative += candidateScores[i]; if (roll <= cumulative) return candidates[i]; } return candidates[0]; // Fallback } // ─── Coordination Helpers ──────────────────────────────── private void ClaimBag(CashMag bag) { UnclaimBag(); if (bag != null) { claimedBag = bag; _claimedBags[bag] = this; } } private void UnclaimBag() { if (claimedBag != null) { if (_claimedBags.ContainsKey(claimedBag) && _claimedBags[claimedBag] == this) _claimedBags.Remove(claimedBag); claimedBag = null; } } private bool IsBagClaimedByTeammate(CashMag bag) { if (!_claimedBags.TryGetValue(bag, out CashSystemAI claimer)) return false; if (claimer == null || claimer == this) return false; // Only count same-team claims return claimer.myTeamTag == myTeamTag; } private int CountEnemiesNear(Vector3 position, float radius) { int count = 0; foreach (TeamMember member in TeamMember.AllMembers) { if (member == null || member.gameObject == gameObject) continue; if (member.gameObject.CompareTag(myTeamTag)) continue; HealthNew hp = member.GetComponent(); if (hp != null && hp.isDead) continue; if (Vector3.Distance(member.transform.position, position) < radius) count++; } return count; } /// Count enemies near a position using sensor cache (faster). private int CountEnemiesNearSensor(Vector3 position, float radius) { int count = 0; for (int i = 0; i < sensedEnemies.Count; i++) { if (sensedEnemies[i].enemy == null) continue; if (Vector3.Distance(sensedEnemies[i].enemy.transform.position, position) < radius) count++; } return count; } // ─── Movement (DIRECT NavMeshAgent — single authority) ── /// /// Move toward a position using NavMeshAgent DIRECTLY. /// No delegation to CharacterAIController — eliminates interference. /// private void MoveToward(Vector3 position) { if (navAgent == null || !navAgent.isActiveAndEnabled) return; // Re-warp if off NavMesh if (!navAgent.isOnNavMesh) { NavMeshHit hit; if (NavMesh.SamplePosition(transform.position, out hit, 20f, NavMesh.AllAreas)) navAgent.Warp(hit.position); else return; // Can't recover } navAgent.isStopped = false; navAgent.SetDestination(position); } /// Stop the NavMeshAgent directly. private void StopNavAgent() { if (navAgent != null && navAgent.isActiveAndEnabled && navAgent.isOnNavMesh) { navAgent.isStopped = true; navAgent.velocity = Vector3.zero; } } /// Rotate to face a target position. private void FaceTarget(Vector3 targetPos) { Vector3 dir = (targetPos - transform.position); dir.y = 0; if (dir.sqrMagnitude > 0.01f) { Quaternion lookRot = Quaternion.LookRotation(dir.normalized); transform.rotation = Quaternion.RotateTowards(transform.rotation, lookRot, 360f * Time.deltaTime); } } // ─── Reactive Combat (Damage Response) ───────────────── /// /// Called when ANY character takes damage. We check if WE are the victim /// and immediately set the attacker as our combat target. /// This is the critical "fight back" mechanic that was completely missing. /// private void OnDamageTaken(GameObject attacker, GameObject victim, float damage) { // Only react if WE are the victim if (victim != gameObject) return; if (attacker == null) return; if (health != null && health.isDead) return; // Don't react to friendly fire (shouldn't happen but safety check) if (attacker.CompareTag(myTeamTag)) return; lastDamageAttacker = attacker; lastDamageTime = Time.time; // CRITICAL: Immediately switch to attack if not already in a high-priority state if (currentState != AIState.CarryVault && currentState != AIState.Retreat) { // Force immediate re-evaluation — being attacked is highest priority currentTarget = attacker.transform; debugLastDecision = $"FIGHT BACK against {attacker.name}!"; if (!isCarryingVault) { SetState(AIState.AttackEnemy); DevLog.Log($"[CashSystemAI] {gameObject.name} FIGHTING BACK against {attacker.name}!"); } } } // ─── Direct Combat ─────────────────────────────────────── /// /// Perform an attack directly using CharacterAttacks component. /// No delegation to CharacterAIController — CashSystemAI owns combat too. /// private void PerformDirectAttack() { if (Time.time - lastAttackTime < attackCooldown) return; if (characterAttacks != null) { // Pick a random attack from light/medium/heavy pool string[] lightAttacks = { AnimationNames.Jab, AnimationNames.LowKick, AnimationNames.Chop }; string[] mediumAttacks = { AnimationNames.Haymaker, AnimationNames.SpinningBackfist, AnimationNames.BasicKick }; string[] heavyAttacks = { AnimationNames.ElbowSmash, AnimationNames.ChargedPunch, AnimationNames.AirPunch }; // Weighted random: 50% light, 35% medium, 15% heavy float roll = Random.value; string[] pool; if (roll < 0.5f) pool = lightAttacks; else if (roll < 0.85f) pool = mediumAttacks; else pool = heavyAttacks; string attack = pool[Random.Range(0, pool.Length)]; characterAttacks.PunchAttacksDirectly(attack); lastAttackTime = Time.time; } else if (animator != null) { animator.SetTrigger("attack"); lastAttackTime = Time.time; } } private bool HasReachedPosition(Vector3 pos, float threshold) { return Vector3.Distance(transform.position, pos) < threshold; } // ─── Debug Visualization ───────────────────────────────── #if UNITY_EDITOR private void OnDrawGizmos() { if (!showDebugGizmos || !Application.isPlaying) return; // Role indicator sphere above head Vector3 headPos = transform.position + Vector3.up * 3f; Gizmos.color = role == AIRole.Defender ? Color.blue : Color.green; Gizmos.DrawSphere(headPos, 0.3f); // State color Color stateColor = GetStateColor(); Gizmos.color = stateColor; // Line to current target if (currentTarget != null) { Gizmos.DrawLine(transform.position + Vector3.up, currentTarget.position + Vector3.up * 0.5f); } // Detection range Gizmos.color = new Color(1f, 0f, 0f, 0.15f); Gizmos.DrawWireSphere(transform.position, enemyDetectRange); // Vault defense radius (Defender only) if (role == AIRole.Defender && myVault != null) { Gizmos.color = new Color(0f, 0f, 1f, 0.2f); Gizmos.DrawWireSphere(myVaultGroundPos, vaultDefenseRadius); } // State label UnityEditor.Handles.Label( headPos + Vector3.up * 0.5f, $"{gameObject.name}\n[{role}] {currentState}\n{debugLastDecision}" + (cashCarrier != null ? $"\nCash: ${cashCarrier.CarriedCash:F0}" : "") ); } #endif private Color GetStateColor() { switch (currentState) { case AIState.CollectCash: return Color.yellow; case AIState.DepositCash: return Color.green; case AIState.AttackEnemy: return Color.red; case AIState.DefendVault: return Color.blue; case AIState.StealFromVault: return new Color(1f, 0.5f, 0f); // Orange case AIState.Retreat: return Color.magenta; case AIState.Patrol: return Color.cyan; case AIState.SeekVault: return new Color(0.8f, 0.8f, 0f); // Dark yellow case AIState.OpenVault: return new Color(1f, 0.8f, 0.2f); // Gold case AIState.CarryVault: return new Color(0f, 1f, 0.5f); // Mint case AIState.DepositVault: return new Color(0f, 0.8f, 0.3f); // Emerald case AIState.DefendCashout: return new Color(0.2f, 0.4f, 1f);// Royal blue case AIState.AttackCashout: return new Color(1f, 0.2f, 0.2f);// Bright red case AIState.InterceptCarrier: return new Color(0.8f, 0f, 0.4f); // Crimson default: return Color.gray; } } }