2168 lines
78 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|