Files
DeviantMobile-Rohan/Assets/Scripts/CashSystem/CashSystemAI.cs

2168 lines
78 KiB
C#

using UnityEngine;
using UnityEngine.AI;
using System.Collections.Generic;
/// <summary>
/// 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.
/// </summary>
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 CashBag 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<SensedBag> sensedBags = new List<SensedBag>(16);
private readonly List<SensedEnemy> sensedEnemies = new List<SensedEnemy>(8);
private struct SensedBag
{
public CashBag 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<CashBag, CashSystemAI> _claimedBags = new Dictionary<CashBag, CashSystemAI>();
// Track all active CashSystemAI instances per team for role assignment
private static readonly List<CashSystemAI> _allAIs = new List<CashSystemAI>(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}");
}
/// <summary>
/// SINGLE-AUTHORITY: Disable TeamAIController and EnemyAIController so they
/// cannot interfere with NavMeshAgent. CashSystemAI is the SOLE movement authority.
/// </summary>
private void DisableCompetingControllers()
{
var teamAI = GetComponent<TeamAIController>();
if (teamAI != null)
{
teamAI.enabled = false;
DevLog.Log($"[CashSystemAI] Disabled TeamAIController on {gameObject.name}");
}
var enemyAI = GetComponent<EnemyAIController>();
if (enemyAI != null)
{
enemyAI.enabled = false;
DevLog.Log($"[CashSystemAI] Disabled EnemyAIController on {gameObject.name}");
}
}
private void OnDestroy()
{
_allAIs.Remove(this);
UnclaimBag();
GameEvents.OnDamageDealt -= OnDamageTaken;
}
/// <summary>
/// Called by CashSystemManager after all AI are spawned.
/// Deterministically assigns 1 Defender + 2 Collectors per team.
/// </summary>
public static void AssignTeamRoles()
{
// Group AIs by team
var playerTeam = new List<CashSystemAI>();
var enemyTeam = new List<CashSystemAI>();
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<CashSystemAI> 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");
}
}
}
/// <summary>Clear all static state (call from GameEvents.ClearAll or scene unload).</summary>
public static void ClearAll()
{
_claimedBags.Clear();
_allAIs.Clear();
}
private void CacheComponents()
{
cashCarrier = GetComponent<CashCarrier>();
health = GetComponent<HealthNew>();
aiController = GetComponent<CharacterAIController>();
navAgent = GetComponent<NavMeshAgent>();
teamMember = GetComponent<TeamMember>();
characterAttacks = GetComponent<CharacterAttacks>();
animator = GetComponent<Animator>();
myTeamTag = gameObject.tag;
}
private void FindVaults()
{
var vaults = EntityRegistry<TeamVault>.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<TeamVault>();
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<CashoutStation>.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}");
}
/// <summary>
/// Project a world position down to the nearest NavMesh surface.
/// Essential for vault positions which may be 15+ meters above the NavMesh.
/// </summary>
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();
}
/// <summary>
/// Sync walk/run/idle animation from NavMeshAgent velocity.
/// Runs every frame for smooth blending.
/// </summary>
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<ExtractionVault>.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<CashCarrier>();
if (enemyCarrier != null && enemyCarrier.CarriedCash > 0)
score += 40f + enemyCarrier.CarriedCash * 0.2f;
HealthNew enemyHP = enemy.GetComponent<HealthNew>();
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);
}
/// <summary>Extraction-mode decision evaluation.</summary>
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})";
}
}
}
/// <summary>Legacy bag-collection decision evaluation (pre-extraction mode).</summary>
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())
{
CashBag 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<CashCarrier>();
if (enemyCarrier != null && enemyCarrier.CarriedCash > 0)
score += 40f + enemyCarrier.CarriedCash * 0.2f;
// Enemy is weak = finish them
HealthNew enemyHP = enemy.GetComponent<HealthNew>();
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)
{
CashBag 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)
{
CashBag 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 ──────────────────────────────────
/// <summary>
/// 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.
/// </summary>
private void ScanEnvironment()
{
ScanBags();
ScanEnemies();
}
private void ScanBags()
{
sensedBags.Clear();
var bags = EntityRegistry<CashBag>.GetAll();
for (int i = 0; i < bags.Count; i++)
{
CashBag 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<HealthNew>();
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<CashCarrier>();
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;
}
CashBag bag = currentTarget.GetComponent<CashBag>();
if (bag == null || (!bag.IsAvailable && !bag.IsDropped))
{
// Bag taken or destroyed — find another
UnclaimBag();
CashBag 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
CashBag 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<HealthNew>();
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
CashBag 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<HealthNew>();
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 ──────────────────────────────────
/// <summary>Check if this AI is currently carrying an ExtractionVault.</summary>
private bool IsCarryingExtractionVault()
{
var vaults = EntityRegistry<ExtractionVault>.GetAll();
for (int i = 0; i < vaults.Count; i++)
{
if (vaults[i] != null && vaults[i].IsCarried && vaults[i].Carrier == gameObject)
return true;
}
return false;
}
/// <summary>Check if a specific enemy is carrying an ExtractionVault.</summary>
private bool IsEnemyCarryingVault(GameObject enemy)
{
var vaults = EntityRegistry<ExtractionVault>.GetAll();
for (int i = 0; i < vaults.Count; i++)
{
if (vaults[i] != null && vaults[i].IsCarried && vaults[i].Carrier == enemy)
return true;
}
return false;
}
/// <summary>Find the enemy currently carrying a vault.</summary>
private GameObject FindEnemyCarryingVaultObj()
{
var vaults = EntityRegistry<ExtractionVault>.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;
}
/// <summary>Find nearest enemy within a radius of a specific position.</summary>
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<HealthNew>();
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) ────────────────────
/// <summary>Find the best enemy to attack — uses sensor cache for efficiency.</summary>
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;
}
/// <summary>Find nearest enemy — uses sensor cache (already sorted by distance).</summary>
private GameObject FindNearestEnemy()
{
for (int i = 0; i < sensedEnemies.Count; i++)
{
if (sensedEnemies[i].enemy != null)
return sensedEnemies[i].enemy;
}
return null;
}
/// <summary>Find enemy near our vault (for Defender).</summary>
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<HealthNew>();
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;
}
/// <summary>Find the best cash bag not claimed by a teammate — uses sensor cache.</summary>
private CashBag 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;
CashBag[] candidates = new CashBag[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++)
{
CashBag 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(CashBag 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(CashBag 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<HealthNew>();
if (hp != null && hp.isDead) continue;
if (Vector3.Distance(member.transform.position, position) < radius)
count++;
}
return count;
}
/// <summary>Count enemies near a position using sensor cache (faster).</summary>
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) ──
/// <summary>
/// Move toward a position using NavMeshAgent DIRECTLY.
/// No delegation to CharacterAIController — eliminates interference.
/// </summary>
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);
}
/// <summary>Stop the NavMeshAgent directly.</summary>
private void StopNavAgent()
{
if (navAgent != null && navAgent.isActiveAndEnabled && navAgent.isOnNavMesh)
{
navAgent.isStopped = true;
navAgent.velocity = Vector3.zero;
}
}
/// <summary>Rotate to face a target position.</summary>
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) ─────────────────
/// <summary>
/// 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.
/// </summary>
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 ───────────────────────────────────────
/// <summary>
/// Perform an attack directly using CharacterAttacks component.
/// No delegation to CharacterAIController — CashSystemAI owns combat too.
/// </summary>
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;
}
}
}