chunk 1: core gameplay scripts scenes runtime assets

This commit is contained in:
2026-04-06 11:02:34 +03:00
parent fa0388bc79
commit 0d11a097d8
703 changed files with 2292651 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: bbb831ed5741e5286af289e75349418b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,72 @@
using UnityEngine;
/// <summary>
/// AIAction — Abstract base class for all AI actions.
/// Each action knows how to score itself (utility) and execute.
/// Stateless scoring — all context comes from AIBlackboard.
///
/// Subclasses: CollectBagAction, DepositBagAction, AttackEnemyAction,
/// DefendVaultAction, PatrolAction, RetreatAction,
/// EscortCarrierAction, InterceptCarrierAction
/// </summary>
public abstract class AIAction
{
/// <summary>Display name for debug.</summary>
public abstract string Name { get; }
/// <summary>
/// Evaluate how desirable this action is (0-1 range, higher = more desirable).
/// Called every AI decision tick. Must be allocation-free.
/// </summary>
/// <param name="blackboard">Per-AI knowledge store</param>
/// <param name="teamBoard">Shared team knowledge</param>
/// <returns>Utility score 0-1</returns>
public abstract float Score(AIBlackboard blackboard, TeamBlackboard teamBoard);
/// <summary>
/// Execute this action for one tick.
/// Called every AI tick while this is the active action.
/// </summary>
/// <param name="brain">The AI brain executing this action</param>
/// <param name="blackboard">Per-AI knowledge</param>
/// <param name="teamBoard">Team knowledge</param>
public abstract void Execute(AIBrain brain, AIBlackboard blackboard, TeamBlackboard teamBoard);
/// <summary>Called when this action becomes active (optional setup).</summary>
public virtual void OnEnter(AIBrain brain, AIBlackboard blackboard, TeamBlackboard teamBoard) { }
/// <summary>Called when this action is replaced by another (optional cleanup).</summary>
public virtual void OnExit(AIBrain brain, AIBlackboard blackboard, TeamBlackboard teamBoard) { }
/// <summary>
/// Whether this action can interrupt the current action.
/// High-priority actions (defend vault, retreat) return true.
/// </summary>
public virtual bool CanInterrupt => false;
// ─── Utility Helpers ─────────────────────────────────────
/// <summary>Smooth curve: high when value is low, low when value is high.</summary>
protected float InverseCurve(float value, float max)
{
return Mathf.Clamp01(1f - (value / max));
}
/// <summary>Smooth curve: high when value is high.</summary>
protected float LinearCurve(float value, float max)
{
return Mathf.Clamp01(value / max);
}
/// <summary>Step function: 1 if above threshold, 0 otherwise.</summary>
protected float StepAbove(float value, float threshold)
{
return value >= threshold ? 1f : 0f;
}
/// <summary>Step function: 1 if below threshold, 0 otherwise.</summary>
protected float StepBelow(float value, float threshold)
{
return value < threshold ? 1f : 0f;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fde981af550c7988ebf29c73e80a06b2

View File

@@ -0,0 +1,471 @@
using UnityEngine;
/// <summary>
/// DepositBagAction — Navigate to own vault and deposit carried bags.
/// Score increases with more bags carried and when vault is nearby.
/// </summary>
public class DepositBagAction : AIAction
{
public override string Name => "DepositBag";
public override float Score(AIBlackboard bb, TeamBlackboard tb)
{
if (bb.isDead || !bb.isCarrying) return 0f;
if (bb.myVault == null) return 0f;
float score = 0.4f; // Base desire to deposit
// More bags = stronger urge to deposit (risk increases)
score += bb.carriedBagCount * 0.2f;
// High value = deposit urgently
if (bb.totalCarriedValue >= 200f)
score += 0.15f;
// Enemies nearby while carrying = deposit NOW
if (bb.nearbyEnemyCount > 0 && bb.nearestEnemyDistance < 12f)
score += 0.25f;
// Low health while carrying = deposit ASAP
if (bb.healthPercent < 0.4f)
score += 0.2f;
// Close to vault = just do it
float vaultDist = Vector3.Distance(
bb.myVault.transform.position,
bb.myVault.transform.position // Will be overridden by brain position
);
// Bonus if losing (need to score to catch up)
if (bb.scoreDifference < -200f)
score += 0.1f;
return Mathf.Clamp01(score);
}
public override void Execute(AIBrain brain, AIBlackboard bb, TeamBlackboard tb)
{
if (bb.myVault == null) return;
brain.NavigateTo(bb.myVault.transform.position);
// Check if close enough to deposit
float dist = Vector3.Distance(brain.transform.position, bb.myVault.transform.position);
if (dist < 3f)
{
CashCarrier carrier = brain.GetComponent<CashCarrier>();
if (carrier != null && carrier.IsCarrying)
{
carrier.DepositToVault(bb.myVault);
}
}
}
}
/// <summary>
/// AttackEnemyAction — Engage nearest enemy. Prioritizes bag carriers and low-HP targets.
/// Respects team engagement limits (max 2 AI per enemy).
/// </summary>
public class AttackEnemyAction : AIAction
{
public override string Name => "AttackEnemy";
public override bool CanInterrupt => true;
public override float Score(AIBlackboard bb, TeamBlackboard tb)
{
if (bb.isDead) return 0f;
if (bb.nearbyEnemyCount == 0) return 0f;
float score = 0.35f; // Base aggression
// Enemy carrier spotted — HIGH priority
if (bb.nearestEnemyCarrier != null)
{
float carrierDist = Vector3.Distance(bb.nearestEnemyCarrier.transform.position,
bb.myVault != null ? bb.myVault.transform.position : Vector3.zero);
score += 0.35f;
// More bags on carrier = more urgent
score += bb.nearestEnemyCarrier.BagCount * 0.1f;
}
// Close enemy = react aggressively
float proximityFactor = InverseCurve(bb.nearestEnemyDistance, 15f);
score *= (0.5f + proximityFactor * 0.5f);
// Outnumber advantage
if (bb.nearbyAllyCount > bb.nearbyEnemyCount)
score += 0.1f;
// Don't be aggressive when low health (unless enemy carrier)
if (bb.healthPercent < 0.3f && bb.nearestEnemyCarrier == null)
score *= 0.3f;
// Carrying bags = less aggressive (protect the payload)
if (bb.carriedBagCount > 0)
score *= 0.5f;
return Mathf.Clamp01(score);
}
public override void Execute(AIBrain brain, AIBlackboard bb, TeamBlackboard tb)
{
// Choose target: carrier > low HP > nearest
Transform target = null;
float bestPriority = float.MinValue;
for (int i = 0; i < bb.perceivedEnemies.Count; i++)
{
PerceivedEntity enemy = bb.perceivedEnemies[i];
if (enemy.gameObject == null) continue;
// Skip if too many teammates already targeting
int engagedCount = tb.GetEnemyTargetCount(enemy.transform);
if (engagedCount >= 2) continue;
float priority = 0f;
// Carrier priority
if (enemy.isCarryingBag)
priority += 50f + enemy.carriedBagCount * 20f;
// Low HP priority
priority += (1f - enemy.healthPercent) * 30f;
// Distance priority (closer = higher)
priority += InverseCurve(enemy.distance, 15f) * 20f;
// Visible bonus
if (enemy.isVisible) priority += 10f;
if (priority > bestPriority)
{
bestPriority = priority;
target = enemy.transform;
}
}
if (target == null) return;
// Register targeting
bb.currentTarget = target;
tb.AddEnemyTarget(target);
// Delegate combat to CharacterAIController
brain.EngageTarget(target);
}
public override void OnExit(AIBrain brain, AIBlackboard bb, TeamBlackboard tb)
{
if (bb.currentTarget != null)
tb.RemoveEnemyTarget(bb.currentTarget);
bb.currentTarget = null;
}
}
/// <summary>
/// DefendVaultAction — Rush to own vault when under attack.
/// Highest priority interrupt action.
/// </summary>
public class DefendVaultAction : AIAction
{
public override string Name => "DefendVault";
public override bool CanInterrupt => true;
public override float Score(AIBlackboard bb, TeamBlackboard tb)
{
if (bb.isDead) return 0f;
if (!bb.myVaultUnderAttack) return 0f;
float score = 0.9f; // Very high base — vault defense is critical
// Closer to vault = we should definitely defend
if (bb.myVault != null)
{
float dist = Vector3.Distance(bb.myVault.transform.position, bb.myVault.transform.position);
score += InverseCurve(dist, 30f) * 0.1f;
}
return Mathf.Clamp01(score);
}
public override void Execute(AIBrain brain, AIBlackboard bb, TeamBlackboard tb)
{
if (bb.myVault == null) return;
// If carrying, deposit first
CashCarrier carrier = brain.GetComponent<CashCarrier>();
float vaultDist = Vector3.Distance(brain.transform.position, bb.myVault.transform.position);
if (carrier != null && carrier.IsCarrying && vaultDist < 3f)
{
carrier.DepositToVault(bb.myVault);
}
// Check for enemies near vault
Transform nearestEnemyAtVault = null;
float nearestDist = float.MaxValue;
for (int i = 0; i < bb.perceivedEnemies.Count; i++)
{
PerceivedEntity enemy = bb.perceivedEnemies[i];
if (enemy.gameObject == null) continue;
float enemyVaultDist = Vector3.Distance(enemy.transform.position, bb.myVault.transform.position);
if (enemyVaultDist < 10f && enemyVaultDist < nearestDist)
{
nearestDist = enemyVaultDist;
nearestEnemyAtVault = enemy.transform;
}
}
if (nearestEnemyAtVault != null)
{
brain.EngageTarget(nearestEnemyAtVault);
}
else
{
brain.NavigateTo(bb.myVault.transform.position);
}
}
}
/// <summary>
/// PatrolAction — Wander randomly via NavMesh when no other objective.
/// Lowest-priority fallback action.
/// </summary>
public class PatrolAction : AIAction
{
public override string Name => "Patrol";
private float _pauseEndTime;
public override float Score(AIBlackboard bb, TeamBlackboard tb)
{
if (bb.isDead) return 0f;
// Patrol is the default — returns low score so anything else takes priority
return 0.1f;
}
public override void Execute(AIBrain brain, AIBlackboard bb, TeamBlackboard tb)
{
if (Time.time < _pauseEndTime) return;
if (!bb.hasPatrolDestination || brain.HasReachedDestination())
{
if (bb.hasPatrolDestination)
{
// Pause at patrol point
_pauseEndTime = Time.time + Random.Range(1f, 3f);
bb.hasPatrolDestination = false;
return;
}
// Generate new patrol destination
Vector3 randomPoint = brain.transform.position + Random.insideUnitSphere * 12f;
randomPoint.y = brain.transform.position.y;
UnityEngine.AI.NavMeshHit hit;
if (UnityEngine.AI.NavMesh.SamplePosition(randomPoint, out hit, 12f, UnityEngine.AI.NavMesh.AllAreas))
{
bb.patrolDestination = hit.position;
bb.hasPatrolDestination = true;
brain.NavigateTo(bb.patrolDestination);
}
}
else
{
brain.NavigateTo(bb.patrolDestination);
}
}
public override void OnExit(AIBrain brain, AIBlackboard bb, TeamBlackboard tb)
{
bb.hasPatrolDestination = false;
}
}
/// <summary>
/// RetreatAction — Fall back to vault when low health.
/// Deposits any carried bags along the way.
/// </summary>
public class RetreatAction : AIAction
{
public override string Name => "Retreat";
public override bool CanInterrupt => true;
public override float Score(AIBlackboard bb, TeamBlackboard tb)
{
if (bb.isDead) return 0f;
// Only retreat when health is low
if (bb.healthPercent > 0.3f) return 0f;
float score = 0.7f; // High priority when triggered
// Lower health = more urgent
score += InverseCurve(bb.healthPercent, 0.3f) * 0.3f;
// Even more urgent if carrying bags
if (bb.isCarrying)
score += 0.1f;
// Less urgent if no enemies nearby
if (bb.nearbyEnemyCount == 0)
score *= 0.6f;
return Mathf.Clamp01(score);
}
public override void Execute(AIBrain brain, AIBlackboard bb, TeamBlackboard tb)
{
if (bb.myVault == null) return;
brain.NavigateTo(bb.myVault.transform.position);
// Deposit if close to vault
float vaultDist = Vector3.Distance(brain.transform.position, bb.myVault.transform.position);
if (vaultDist < 3f)
{
CashCarrier carrier = brain.GetComponent<CashCarrier>();
if (carrier != null && carrier.IsCarrying)
carrier.DepositToVault(bb.myVault);
}
// If health recovered, exit retreat
if (bb.healthPercent > 0.5f)
{
// Score will drop, brain will switch naturally
}
}
}
/// <summary>
/// EscortCarrierAction — Follow and protect an ally who is carrying bags.
/// Maintains distance, engages threats approaching the carrier.
/// </summary>
public class EscortCarrierAction : AIAction
{
public override string Name => "EscortCarrier";
private const float ESCORT_DISTANCE = 4f;
private const float THREAT_RANGE = 10f;
public override float Score(AIBlackboard bb, TeamBlackboard tb)
{
if (bb.isDead) return 0f;
if (bb.isCarrying) return 0f; // Don't escort if carrying ourselves
// Need an ally carrier to escort
if (bb.nearestAllyCarrier == null) return 0f;
float score = 0.45f;
// More bags on ally = more valuable to escort
score += bb.nearestAllyCarrier.BagCount * 0.1f;
// Enemies near the carrier = escort more important
float carrierPos = 0;
for (int i = 0; i < bb.perceivedEnemies.Count; i++)
{
float enemyDist = Vector3.Distance(
bb.perceivedEnemies[i].transform.position,
bb.nearestAllyCarrier.transform.position);
if (enemyDist < THREAT_RANGE)
score += 0.15f;
}
// Already close to carrier = maintain escort
float distToCarrier = Vector3.Distance(bb.nearestAllyCarrier.transform.position, bb.myVault.transform.position);
if (distToCarrier < ESCORT_DISTANCE * 2f)
score += 0.1f;
return Mathf.Clamp01(score);
}
public override void Execute(AIBrain brain, AIBlackboard bb, TeamBlackboard tb)
{
if (bb.nearestAllyCarrier == null) return;
Vector3 carrierPos = bb.nearestAllyCarrier.transform.position;
// Check for threats near carrier
Transform nearestThreat = null;
float nearestThreatDist = float.MaxValue;
for (int i = 0; i < bb.perceivedEnemies.Count; i++)
{
PerceivedEntity enemy = bb.perceivedEnemies[i];
float distToCarrier = Vector3.Distance(enemy.transform.position, carrierPos);
if (distToCarrier < THREAT_RANGE && distToCarrier < nearestThreatDist)
{
nearestThreatDist = distToCarrier;
nearestThreat = enemy.transform;
}
}
if (nearestThreat != null)
{
// Engage threat
brain.EngageTarget(nearestThreat);
}
else
{
// Follow carrier at distance
float distToCarrier = Vector3.Distance(brain.transform.position, carrierPos);
if (distToCarrier > ESCORT_DISTANCE)
{
brain.NavigateTo(carrierPos);
}
else
{
brain.StopNavigation();
}
}
}
}
/// <summary>
/// InterceptCarrierAction — Chase and attack an enemy who is carrying bags.
/// Highest combat priority.
/// </summary>
public class InterceptCarrierAction : AIAction
{
public override string Name => "InterceptCarrier";
public override bool CanInterrupt => true;
public override float Score(AIBlackboard bb, TeamBlackboard tb)
{
if (bb.isDead) return 0f;
if (bb.nearestEnemyCarrier == null) return 0f;
float score = 0.75f; // Very high base priority
// More bags = more vital to intercept
score += bb.nearestEnemyCarrier.BagCount * 0.08f;
// Close to our vault = CRITICAL
if (bb.myVault != null)
{
float carrierToVault = Vector3.Distance(
bb.nearestEnemyCarrier.transform.position,
bb.myVault.transform.position);
if (carrierToVault < 15f)
score += 0.15f;
}
// Low health = less interception
if (bb.healthPercent < 0.25f)
score *= 0.4f;
return Mathf.Clamp01(score);
}
public override void Execute(AIBrain brain, AIBlackboard bb, TeamBlackboard tb)
{
if (bb.nearestEnemyCarrier == null) return;
brain.EngageTarget(bb.nearestEnemyCarrier.transform);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ba24431079dff36f3822b97125261be6

View File

@@ -0,0 +1,107 @@
using UnityEngine;
/// <summary>
/// CollectBagAction — Navigate to nearest unclaimed available/dropped cash box.
/// Prioritizes hot-drop boxes (bonus value) and closer boxes.
/// Respects TeamBlackboard claims to prevent clustering.
/// </summary>
public class CollectBagAction : AIAction
{
public override string Name => "CollectBag";
public override float Score(AIBlackboard bb, TeamBlackboard tb)
{
// Can't collect if dead or already full
if (bb.isDead) return 0f;
if (bb.carriedBagCount >= 3) return 0f;
// No bags nearby
int totalNearby = bb.nearbyAvailableBags.Count + bb.nearbyDroppedBags.Count;
if (totalNearby == 0) return 0f;
float score = 0.5f; // Base desire to collect
// Bonus for hot-drop bags (time-sensitive)
for (int i = 0; i < bb.nearbyDroppedBags.Count; i++)
{
if (bb.nearbyDroppedBags[i].IsHotDrop)
{
score += 0.2f;
break;
}
}
// Distance factor — closer bags = higher score
float distanceFactor = InverseCurve(bb.nearestBagDistance, 25f);
score *= (0.5f + distanceFactor * 0.5f);
// Less desire to collect when already carrying (deposit instead)
if (bb.carriedBagCount >= 2)
score *= 0.3f;
else if (bb.carriedBagCount >= 1)
score *= 0.6f;
// Reduce if nearby enemies (risky to collect)
if (bb.nearbyEnemyCount > 0 && bb.nearestEnemyDistance < 8f)
score *= 0.6f;
// Low health = less collecting
score *= (0.3f + bb.healthPercent * 0.7f);
return Mathf.Clamp01(score);
}
public override void Execute(AIBrain brain, AIBlackboard bb, TeamBlackboard tb)
{
// Find best bag (prefer dropped hot-drops, then closest available)
CashBag bestBag = null;
float bestScore = float.MinValue;
// Check dropped bags first (higher priority)
for (int i = 0; i < bb.nearbyDroppedBags.Count; i++)
{
CashBag bag = bb.nearbyDroppedBags[i];
if (bag == null || bag.IsCarried) continue;
if (tb.IsBagClaimed(bag, brain)) continue;
float dist = Vector3.Distance(brain.transform.position, bag.transform.position);
float bagScore = bag.GetEffectiveValue() / Mathf.Max(dist, 1f);
if (bag.IsHotDrop) bagScore *= 2f; // Big bonus for hot drops
if (bagScore > bestScore) { bestScore = bagScore; bestBag = bag; }
}
// Then available bags
if (bestBag == null)
{
for (int i = 0; i < bb.nearbyAvailableBags.Count; i++)
{
CashBag bag = bb.nearbyAvailableBags[i];
if (bag == null || bag.IsCarried) continue;
if (tb.IsBagClaimed(bag, brain)) continue;
float dist = Vector3.Distance(brain.transform.position, bag.transform.position);
float bagScore = bag.Value / Mathf.Max(dist, 1f);
if (bagScore > bestScore) { bestScore = bagScore; bestBag = bag; }
}
}
if (bestBag == null)
{
// No unclaimed bags, go idle
return;
}
// Claim and navigate
tb.TryClaimBag(bestBag, brain);
bb.targetBag = bestBag;
brain.NavigateTo(bestBag.transform.position);
}
public override void OnExit(AIBrain brain, AIBlackboard bb, TeamBlackboard tb)
{
tb.ReleaseBagClaim(brain);
bb.targetBag = null;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 12d6d7471a2b608a38e68d2073778452

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: bf57712f1987ec7d7b6160acc2e1fa7f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,274 @@
using UnityEngine;
using System.Collections.Generic;
/// <summary>
/// AIBlackboard — Per-AI knowledge store.
/// Holds perception results, targets, and state without allocations.
/// Updated by AIPerceptionSystem and read by AIActions/UtilityScorer.
/// </summary>
[System.Serializable]
public class AIBlackboard
{
// ─── Identity ────────────────────────────────────────────
public string teamTag;
public TeamMember.Team team;
// ─── Current Targets ─────────────────────────────────────
public Transform currentTarget;
public CashBag targetBag;
public TeamVault myVault;
public TeamVault enemyVault;
public CashCarrier nearestEnemyCarrier; // Priority target
public Transform escortTarget; // Ally to escort
// ─── Self State ──────────────────────────────────────────
public float currentHealth;
public float maxHealth;
public float healthPercent;
public bool isDead;
public int carriedBagCount;
public float totalCarriedValue;
public bool isCarrying;
// ─── Perception Results (updated by AIPerceptionSystem) ──
public readonly List<PerceivedEntity> perceivedEnemies = new List<PerceivedEntity>(8);
public readonly List<PerceivedEntity> perceivedAllies = new List<PerceivedEntity>(8);
public readonly List<CashBag> nearbyAvailableBags = new List<CashBag>(8);
public readonly List<CashBag> nearbyDroppedBags = new List<CashBag>(4);
public CashCarrier nearestAllyCarrier;
public int nearbyEnemyCount;
public int nearbyAllyCount;
public float nearestEnemyDistance;
public float nearestBagDistance;
// ─── Team State (from TeamBlackboard) ────────────────────
public float myTeamScore;
public float enemyTeamScore;
public bool myVaultUnderAttack;
public float scoreDifference; // positive = winning
// ─── Navigation ──────────────────────────────────────────
public Vector3 patrolDestination;
public bool hasPatrolDestination;
public float timeAtCurrentState;
/// <summary>Clear all perception data (called before perception update).</summary>
public void ClearPerception()
{
perceivedEnemies.Clear();
perceivedAllies.Clear();
nearbyAvailableBags.Clear();
nearbyDroppedBags.Clear();
nearestEnemyCarrier = null;
nearestAllyCarrier = null;
nearbyEnemyCount = 0;
nearbyAllyCount = 0;
nearestEnemyDistance = float.MaxValue;
nearestBagDistance = float.MaxValue;
}
}
/// <summary>
/// Data about a perceived entity (enemy or ally).
/// Reused from pool to avoid allocation.
/// </summary>
[System.Serializable]
public struct PerceivedEntity
{
public Transform transform;
public GameObject gameObject;
public float distance;
public float sqrDistance;
public float healthPercent;
public bool isCarryingBag;
public int carriedBagCount;
public bool isVisible; // In line of sight
public Vector3 lastKnownPosition;
public float timeSinceLastSeen;
}
/// <summary>
/// TeamBlackboard — Shared knowledge between all AI on a team.
/// Prevents clustering and enables coordination.
/// Singleton per team — accessed via TeamBlackboard.Get(teamTag).
/// </summary>
public class TeamBlackboard : MonoBehaviour
{
// ─── Static Access ───────────────────────────────────────
private static TeamBlackboard _playerTeam;
private static TeamBlackboard _enemyTeam;
public static TeamBlackboard Get(string teamTag)
{
if (teamTag == "Player")
{
if (_playerTeam == null)
{
var go = new GameObject("TeamBlackboard_Player");
_playerTeam = go.AddComponent<TeamBlackboard>();
_playerTeam._teamTag = "Player";
}
return _playerTeam;
}
else
{
if (_enemyTeam == null)
{
var go = new GameObject("TeamBlackboard_Enemy");
_enemyTeam = go.AddComponent<TeamBlackboard>();
_enemyTeam._teamTag = "Enemy";
}
return _enemyTeam;
}
}
public static void ClearAll()
{
if (_playerTeam != null) { Destroy(_playerTeam.gameObject); _playerTeam = null; }
if (_enemyTeam != null) { Destroy(_enemyTeam.gameObject); _enemyTeam = null; }
}
// ─── Team Data ───────────────────────────────────────────
[SerializeField] private string _teamTag;
/// <summary>Which bags are claimed by AI (prevents all AI chasing same bag).</summary>
public readonly Dictionary<CashBag, AIBrain> claimedBags = new Dictionary<CashBag, AIBrain>(8);
/// <summary>Which enemies are being targeted (limit engagement per enemy).</summary>
public readonly Dictionary<Transform, int> targetedEnemies = new Dictionary<Transform, int>(8);
/// <summary>Who on our team is carrying bags?</summary>
public readonly List<CashCarrier> teamCarriers = new List<CashCarrier>(4);
/// <summary>Is our vault under attack?</summary>
public bool vaultUnderAttack;
/// <summary>Our team's score.</summary>
public float teamScore;
// ─── Claim System ────────────────────────────────────────
/// <summary>Try to claim a bag for this AI. Returns true if claimed successfully.</summary>
public bool TryClaimBag(CashBag bag, AIBrain claimant)
{
if (bag == null) return false;
// Clean stale claims
if (claimedBags.TryGetValue(bag, out AIBrain existing))
{
if (existing == null || !existing.gameObject.activeInHierarchy)
{
claimedBags[bag] = claimant;
return true;
}
return existing == claimant; // Already claimed by us
}
claimedBags[bag] = claimant;
return true;
}
/// <summary>Release claim on a bag.</summary>
public void ReleaseBagClaim(AIBrain claimant)
{
CashBag toRemove = null;
foreach (var kvp in claimedBags)
{
if (kvp.Value == claimant) { toRemove = kvp.Key; break; }
}
if (toRemove != null) claimedBags.Remove(toRemove);
}
/// <summary>Is this bag already claimed by a teammate?</summary>
public bool IsBagClaimed(CashBag bag, AIBrain excludeSelf = null)
{
if (!claimedBags.TryGetValue(bag, out AIBrain claimant)) return false;
if (claimant == null || !claimant.gameObject.activeInHierarchy)
{
claimedBags.Remove(bag);
return false;
}
return claimant != excludeSelf;
}
/// <summary>Register an enemy as being targeted. Returns current count.</summary>
public int AddEnemyTarget(Transform enemy)
{
if (enemy == null) return 0;
if (!targetedEnemies.ContainsKey(enemy))
targetedEnemies[enemy] = 0;
targetedEnemies[enemy]++;
return targetedEnemies[enemy];
}
/// <summary>Unregister from targeting an enemy.</summary>
public void RemoveEnemyTarget(Transform enemy)
{
if (enemy == null) return;
if (targetedEnemies.ContainsKey(enemy))
{
targetedEnemies[enemy]--;
if (targetedEnemies[enemy] <= 0)
targetedEnemies.Remove(enemy);
}
}
/// <summary>How many teammates are targeting this enemy?</summary>
public int GetEnemyTargetCount(Transform enemy)
{
if (enemy == null) return 0;
return targetedEnemies.TryGetValue(enemy, out int count) ? count : 0;
}
/// <summary>Update team carriers list.</summary>
public void RefreshCarriers()
{
teamCarriers.Clear();
var allCarriers = EntityRegistry<CashCarrier>.All;
for (int i = 0; i < allCarriers.Count; i++)
{
if (allCarriers[i] != null && allCarriers[i].TeamTag == _teamTag && allCarriers[i].IsCarrying)
teamCarriers.Add(allCarriers[i]);
}
}
/// <summary>Clean stale entries (call periodically).</summary>
public void CleanStaleEntries()
{
// Clean bag claims
var staleBags = new List<CashBag>();
foreach (var kvp in claimedBags)
{
if (kvp.Key == null || kvp.Value == null || !kvp.Value.gameObject.activeInHierarchy)
staleBags.Add(kvp.Key);
}
for (int i = 0; i < staleBags.Count; i++)
claimedBags.Remove(staleBags[i]);
// Clean enemy targets
var staleTargets = new List<Transform>();
foreach (var kvp in targetedEnemies)
{
if (kvp.Key == null) staleTargets.Add(kvp.Key);
}
for (int i = 0; i < staleTargets.Count; i++)
targetedEnemies.Remove(staleTargets[i]);
}
private float _lastCleanTime;
private void Update()
{
if (Time.time - _lastCleanTime > 3f)
{
CleanStaleEntries();
RefreshCarriers();
_lastCleanTime = Time.time;
}
}
private void OnDestroy()
{
if (_playerTeam == this) _playerTeam = null;
if (_enemyTeam == this) _enemyTeam = null;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f851e5eb528f0fa12a0efb0e7ecde6f6

View File

@@ -0,0 +1,352 @@
using UnityEngine;
using UnityEngine.AI;
/// <summary>
/// AIBrain — Central AI orchestrator. Replaces CashSystemAI.
/// Runs perception → utility scoring → action execution on a configurable tick.
///
/// Features:
/// - AI LOD (distance-based tick scaling)
/// - Zero FindObjectOfType calls
/// - Modular scoring via UtilityScorer
/// - Delegates combat to existing CharacterAIController
/// - Delegates navigation to AINavigationHelper
///
/// Attach to all AI characters (both player-team AI and enemy-team AI).
/// </summary>
public class AIBrain : MonoBehaviour
{
[Header("AI Settings")]
[SerializeField] private UtilityScorer.AIRole role = UtilityScorer.AIRole.Balanced;
[SerializeField] private bool autoAssignRole = true;
[Header("Tick Rate (AI LOD)")]
[SerializeField] private float baseTick = 0.3f; // Tier 0: on-screen, close
[SerializeField] private float midTick = 0.5f; // Tier 1: on-screen, far
[SerializeField] private float farTick = 1.0f; // Tier 2: off-screen
[SerializeField] private float lodMidDistance = 15f;
[SerializeField] private float lodFarDistance = 30f;
[Header("Behavior")]
[SerializeField] private float minActionDuration = 1.5f; // Minimum time before switching actions
// ─── Components ──────────────────────────────────────────
private AIPerceptionSystem _perception;
private AINavigationHelper _navigation;
private CharacterAIController _aiController;
private CashCarrier _carrier;
private HealthNew _health;
private TeamMember _teamMember;
// ─── AI State ────────────────────────────────────────────
private AIBlackboard _blackboard;
private TeamBlackboard _teamBlackboard;
private UtilityScorer _scorer;
private AIAction _currentAction;
private float _lastTickTime;
private float _currentTick;
private Transform _mainCamera;
private bool _combatEnabled;
// ─── Properties ──────────────────────────────────────────
public AIBlackboard Blackboard => _blackboard;
public AIAction CurrentAction => _currentAction;
public UtilityScorer.AIRole Role => role;
public string CurrentActionName => _scorer?.CurrentActionName ?? "Initializing";
// ─── Lifecycle ───────────────────────────────────────────
private void Awake()
{
// Ensure components exist
_perception = GetComponent<AIPerceptionSystem>();
if (_perception == null) _perception = gameObject.AddComponent<AIPerceptionSystem>();
_navigation = GetComponent<AINavigationHelper>();
if (_navigation == null) _navigation = gameObject.AddComponent<AINavigationHelper>();
}
private void Start()
{
if (SelectionOptions.Instance == null || !SelectionOptions.Instance.isCashSystemSelected)
{
enabled = false;
return;
}
// Cache components
_aiController = GetComponent<CharacterAIController>();
_carrier = GetComponent<CashCarrier>();
_health = GetComponent<HealthNew>();
_teamMember = GetComponent<TeamMember>();
// Find camera for LOD
if (Camera.main != null)
_mainCamera = Camera.main.transform;
// Initialize blackboard
_blackboard = new AIBlackboard();
_blackboard.teamTag = gameObject.tag;
_blackboard.team = _teamMember != null ? _teamMember.CurrentTeam : TeamMember.Team.EnemyTeam;
// Initialize perception
_perception.Initialize(_blackboard.teamTag);
// Find vaults (one-time, cached)
FindVaults();
// Auto-assign role
if (autoAssignRole)
AssignRandomRole();
// Initialize scorer
_scorer = new UtilityScorer(role);
// Get team blackboard
_teamBlackboard = TeamBlackboard.Get(_blackboard.teamTag);
_currentTick = baseTick;
// Subscribe to events
GameEvents.OnCombatEnabled += OnCombatEnabled;
GameEvents.OnVaultUnderAttack += OnVaultAttacked;
GameEvents.OnMatchEnd += OnMatchEnd;
}
private void OnDestroy()
{
GameEvents.OnCombatEnabled -= OnCombatEnabled;
GameEvents.OnVaultUnderAttack -= OnVaultAttacked;
GameEvents.OnMatchEnd -= OnMatchEnd;
// Release claims
_teamBlackboard?.ReleaseBagClaim(this);
}
private void Update()
{
if (!_combatEnabled) return;
if (_health != null && _health.isDead) return;
// AI LOD — adjust tick rate based on distance to camera
UpdateAILOD();
// Tick check
if (Time.time - _lastTickTime < _currentTick) return;
_lastTickTime = Time.time;
// 1. Update self state on blackboard
UpdateSelfState();
// 2. Run perception
_perception.UpdatePerception(_blackboard);
// 3. Update team state
UpdateTeamState();
// 4. Score and select action
AIAction bestAction = _scorer.Evaluate(this, _blackboard, _teamBlackboard);
// 5. Execute action
if (bestAction != null)
{
_currentAction = bestAction;
_currentAction.Execute(this, _blackboard, _teamBlackboard);
}
}
// ─── State Updates ───────────────────────────────────────
private void UpdateSelfState()
{
if (_health != null)
{
_blackboard.currentHealth = _health.currentHealth;
_blackboard.maxHealth = _health.maxHealth;
_blackboard.healthPercent = _health.maxHealth > 0 ? _health.currentHealth / _health.maxHealth : 1f;
_blackboard.isDead = _health.isDead;
}
if (_carrier != null)
{
_blackboard.carriedBagCount = _carrier.BagCount;
_blackboard.totalCarriedValue = _carrier.TotalValue;
_blackboard.isCarrying = _carrier.IsCarrying;
}
}
private void UpdateTeamState()
{
if (_teamBlackboard != null)
{
_blackboard.myVaultUnderAttack = _teamBlackboard.vaultUnderAttack;
_blackboard.myTeamScore = _teamBlackboard.teamScore;
}
// Get enemy team score
string enemyTag = _blackboard.teamTag == "Player" ? "Enemy" : "Player";
TeamBlackboard enemyBoard = TeamBlackboard.Get(enemyTag);
if (enemyBoard != null)
{
_blackboard.enemyTeamScore = enemyBoard.teamScore;
}
_blackboard.scoreDifference = _blackboard.myTeamScore - _blackboard.enemyTeamScore;
}
private void UpdateAILOD()
{
if (_mainCamera == null)
{
_currentTick = baseTick;
return;
}
float distToCamera = Vector3.Distance(transform.position, _mainCamera.position);
if (distToCamera > lodFarDistance)
_currentTick = farTick;
else if (distToCamera > lodMidDistance)
_currentTick = midTick;
else
_currentTick = baseTick;
}
private void FindVaults()
{
var vaults = EntityRegistry<TeamVault>.All;
for (int i = 0; i < vaults.Count; i++)
{
if (vaults[i] == null) continue;
if (vaults[i].TeamTag == _blackboard.teamTag)
_blackboard.myVault = vaults[i];
else
_blackboard.enemyVault = vaults[i];
}
// Fallback to FindObjectsOfType (only at initialization)
if (_blackboard.myVault == null || _blackboard.enemyVault == null)
{
TeamVault[] allVaults = FindObjectsOfType<TeamVault>();
foreach (var vault in allVaults)
{
if (vault.TeamTag == _blackboard.teamTag)
_blackboard.myVault = vault;
else
_blackboard.enemyVault = vault;
}
}
}
private void AssignRandomRole()
{
float rand = Random.value;
if (rand < 0.35f) role = UtilityScorer.AIRole.Balanced;
else if (rand < 0.60f) role = UtilityScorer.AIRole.Collector;
else if (rand < 0.80f) role = UtilityScorer.AIRole.Defender;
else role = UtilityScorer.AIRole.Aggressor;
}
// ─── Public API (called by AIActions) ────────────────────
/// <summary>Navigate to a world position.</summary>
public void NavigateTo(Vector3 position)
{
_navigation.SetDestination(position);
}
/// <summary>Stop all navigation.</summary>
public void StopNavigation()
{
_navigation.Stop();
}
/// <summary>Has the AI reached its current navigation destination?</summary>
public bool HasReachedDestination()
{
return _navigation.HasReachedDestination();
}
/// <summary>Engage a target in combat (delegates to CharacterAIController).</summary>
public void EngageTarget(Transform target)
{
if (_aiController != null)
{
_aiController.SetTarget(target.gameObject);
_aiController.NavigateTo(target.position);
_aiController.attack = true;
}
else
{
// Fallback: just navigate toward
NavigateTo(target.position);
}
}
/// <summary>Disengage from combat.</summary>
public void DisengageTarget()
{
if (_aiController != null)
{
_aiController.Stop();
}
}
// ─── Event Handlers ──────────────────────────────────────
private void OnCombatEnabled(bool enabled)
{
_combatEnabled = enabled;
}
private void OnVaultAttacked(TeamVault vault)
{
if (vault.TeamTag == _blackboard.teamTag)
{
_blackboard.myVaultUnderAttack = true;
if (_teamBlackboard != null)
_teamBlackboard.vaultUnderAttack = true;
// Force immediate decision (bypass tick timer)
_lastTickTime = 0;
}
}
private void OnMatchEnd(string winnerTag)
{
_combatEnabled = false;
StopNavigation();
}
// ─── Debug ───────────────────────────────────────────────
#if UNITY_EDITOR
private void OnDrawGizmosSelected()
{
if (_blackboard == null) return;
// Show current action
UnityEditor.Handles.Label(transform.position + Vector3.up * 2.5f,
$"AI: {CurrentActionName}\n" +
$"Role: {role}\n" +
$"HP: {_blackboard.healthPercent:P0}\n" +
$"Bags: {_blackboard.carriedBagCount}\n" +
$"Enemies: {_blackboard.nearbyEnemyCount}\n" +
$"Tick: {_currentTick:F2}s");
// Show target
if (_blackboard.currentTarget != null)
{
Gizmos.color = Color.red;
Gizmos.DrawLine(transform.position + Vector3.up, _blackboard.currentTarget.position + Vector3.up);
}
// Show target bag
if (_blackboard.targetBag != null)
{
Gizmos.color = Color.yellow;
Gizmos.DrawLine(transform.position + Vector3.up, _blackboard.targetBag.transform.position);
}
}
#endif
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5557a60f10e7a97e79823d421b552c2d

View File

@@ -0,0 +1,241 @@
using UnityEngine;
using System.Collections.Generic;
/// <summary>
/// AIPerceptionSystem — Unified vision/detection system for AI.
/// Uses Physics.OverlapSphereNonAlloc for zero-allocation detection.
/// Populates the AIBlackboard with perception results.
///
/// Call UpdatePerception() on the AI tick (not every frame).
/// </summary>
public class AIPerceptionSystem : MonoBehaviour
{
[Header("Vision Settings")]
[SerializeField] private float detectionRadius = 18f;
[SerializeField] private float fovAngle = 160f; // Forward cone
[SerializeField] private float closeRangeRadius = 5f; // Always detect within this range (no FOV check)
[Header("Bag Detection")]
[SerializeField] private float bagDetectionRadius = 25f;
[Header("Line of Sight")]
[SerializeField] private bool requireLineOfSight = true;
[SerializeField] private LayerMask obstructionLayers;
[SerializeField] private float losHeightOffset = 1.2f; // Eye height
[Header("Memory")]
[SerializeField] private float memoryDuration = 5f; // Remember entities for this long after losing sight
// ─── Detection Buffer (zero-alloc) ───────────────────────
private static readonly Collider[] _overlapBuffer = new Collider[32];
// ─── Threat Memory ───────────────────────────────────────
private readonly Dictionary<Transform, float> _lastSeenTime = new Dictionary<Transform, float>(16);
private readonly Dictionary<Transform, Vector3> _lastKnownPositions = new Dictionary<Transform, Vector3>(16);
// ─── Component Cache ─────────────────────────────────────
private Transform _transform;
private string _teamTag;
private void Awake()
{
_transform = transform;
}
public void Initialize(string teamTag)
{
_teamTag = teamTag;
}
/// <summary>
/// Update perception and populate blackboard.
/// Call this on the AI decision tick, NOT every frame.
/// </summary>
public void UpdatePerception(AIBlackboard blackboard)
{
blackboard.ClearPerception();
Vector3 myPos = _transform.position;
Vector3 eyePos = myPos + Vector3.up * losHeightOffset;
// ─── Detect Characters ───────────────────────────────
var allMembers = TeamMember.AllMembers;
for (int i = 0; i < allMembers.Count; i++)
{
TeamMember member = allMembers[i];
if (member == null || member.gameObject == gameObject) continue;
Transform target = member.transform;
float sqrDist = (target.position - myPos).sqrMagnitude;
float dist = Mathf.Sqrt(sqrDist);
// Range check
if (dist > detectionRadius) continue;
// FOV check (skip for close range)
bool inFOV = dist <= closeRangeRadius || IsInFOV(target.position);
// LOS check
bool hasLOS = !requireLineOfSight || dist <= closeRangeRadius || CheckLineOfSight(eyePos, target.position + Vector3.up * losHeightOffset);
bool isVisible = inFOV && hasLOS;
// Update memory
if (isVisible)
{
_lastSeenTime[target] = Time.time;
_lastKnownPositions[target] = target.position;
}
// Check memory (even if not currently visible)
bool isRemembered = false;
float timeSinceLastSeen = float.MaxValue;
Vector3 lastKnownPos = target.position;
if (_lastSeenTime.TryGetValue(target, out float lastSeen))
{
timeSinceLastSeen = Time.time - lastSeen;
isRemembered = timeSinceLastSeen < memoryDuration;
if (_lastKnownPositions.TryGetValue(target, out Vector3 lkp))
lastKnownPos = lkp;
}
if (!isVisible && !isRemembered) continue;
// Build perceived entity
HealthNew health = member.GetComponent<HealthNew>();
CashCarrier carrier = member.GetComponent<CashCarrier>();
PerceivedEntity entity = new PerceivedEntity
{
transform = target,
gameObject = member.gameObject,
distance = dist,
sqrDistance = sqrDist,
healthPercent = health != null ? (health.currentHealth / health.maxHealth) : 1f,
isCarryingBag = carrier != null && carrier.IsCarrying,
carriedBagCount = carrier != null ? carrier.BagCount : 0,
isVisible = isVisible,
lastKnownPosition = lastKnownPos,
timeSinceLastSeen = timeSinceLastSeen
};
bool isEnemy = member.gameObject.tag != _teamTag;
if (isEnemy)
{
blackboard.perceivedEnemies.Add(entity);
blackboard.nearbyEnemyCount++;
if (dist < blackboard.nearestEnemyDistance)
blackboard.nearestEnemyDistance = dist;
// Track nearest enemy carrier
if (entity.isCarryingBag && carrier != null)
{
if (blackboard.nearestEnemyCarrier == null ||
dist < Vector3.Distance(myPos, blackboard.nearestEnemyCarrier.transform.position))
{
blackboard.nearestEnemyCarrier = carrier;
}
}
}
else
{
blackboard.perceivedAllies.Add(entity);
blackboard.nearbyAllyCount++;
// Track nearest ally carrier (for escort)
if (entity.isCarryingBag && carrier != null)
{
if (blackboard.nearestAllyCarrier == null ||
dist < Vector3.Distance(myPos, blackboard.nearestAllyCarrier.transform.position))
{
blackboard.nearestAllyCarrier = carrier;
}
}
}
}
// ─── Detect Cash Bags ─────────────────────────────────
var allBags = EntityRegistry<CashBag>.All;
for (int i = 0; i < allBags.Count; i++)
{
CashBag bag = allBags[i];
if (bag == null || bag.IsCarried) continue;
float dist = Vector3.Distance(myPos, bag.transform.position);
if (dist > bagDetectionRadius) continue;
if (dist < blackboard.nearestBagDistance)
blackboard.nearestBagDistance = dist;
if (bag.IsAvailable)
blackboard.nearbyAvailableBags.Add(bag);
else if (bag.IsDropped)
blackboard.nearbyDroppedBags.Add(bag);
}
}
// ─── Vision Helpers ──────────────────────────────────────
private bool IsInFOV(Vector3 targetPos)
{
Vector3 dirToTarget = (targetPos - _transform.position).normalized;
dirToTarget.y = 0; // Horizontal only
float angle = Vector3.Angle(_transform.forward, dirToTarget);
return angle <= fovAngle * 0.5f;
}
private bool CheckLineOfSight(Vector3 from, Vector3 to)
{
Vector3 dir = to - from;
float dist = dir.magnitude;
if (dist < 0.1f) return true;
return !Physics.Raycast(from, dir.normalized, dist, obstructionLayers);
}
// ─── Memory Management ───────────────────────────────────
/// <summary>Clear all threat memory (call on state reset).</summary>
public void ClearMemory()
{
_lastSeenTime.Clear();
_lastKnownPositions.Clear();
}
/// <summary>Get last known position of a target (for pursuit).</summary>
public bool TryGetLastKnownPosition(Transform target, out Vector3 position)
{
return _lastKnownPositions.TryGetValue(target, out position);
}
// ─── Debug ───────────────────────────────────────────────
#if UNITY_EDITOR
private void OnDrawGizmosSelected()
{
if (_transform == null) _transform = transform;
// Detection radius
Gizmos.color = new Color(1f, 1f, 0f, 0.1f);
Gizmos.DrawWireSphere(_transform.position, detectionRadius);
// Close range
Gizmos.color = new Color(1f, 0.5f, 0f, 0.15f);
Gizmos.DrawWireSphere(_transform.position, closeRangeRadius);
// FOV cone
Gizmos.color = new Color(0f, 1f, 0f, 0.3f);
Vector3 fovLeft = Quaternion.Euler(0, -fovAngle * 0.5f, 0) * _transform.forward * detectionRadius;
Vector3 fovRight = Quaternion.Euler(0, fovAngle * 0.5f, 0) * _transform.forward * detectionRadius;
Gizmos.DrawLine(_transform.position, _transform.position + fovLeft);
Gizmos.DrawLine(_transform.position, _transform.position + fovRight);
// Bag detection radius
Gizmos.color = new Color(1f, 0.84f, 0f, 0.05f);
Gizmos.DrawWireSphere(_transform.position, bagDetectionRadius);
}
#endif
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 09984091d0b1426349f45490e2b656e7

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1513669be5125c8c2bee7148a001ff8d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,145 @@
using UnityEngine;
using UnityEngine.AI;
/// <summary>
/// AINavigationHelper — NavMeshAgent wrapper with path caching and mobile optimization.
/// Prevents unnecessary path recalculations and provides clean navigation API.
///
/// Only recalculates path when target moves >2m or path becomes invalid.
/// </summary>
public class AINavigationHelper : MonoBehaviour
{
[Header("Navigation Settings")]
[SerializeField] private float pathRecalcThreshold = 2f; // Minimum target movement to recalc
[SerializeField] private float stoppingDistance = 1.5f;
[SerializeField] private float reachThreshold = 2f;
[Header("Avoidance")]
[SerializeField] private int avoidancePriority = 50; // 0=highest priority, 99=lowest
// ─── Components ──────────────────────────────────────────
private NavMeshAgent _agent;
private CharacterAIController _aiController;
// ─── Path Cache ──────────────────────────────────────────
private Vector3 _lastTargetPosition;
private bool _hasDestination;
// ─── Properties ──────────────────────────────────────────
public bool HasArrived => _hasDestination && _agent != null && _agent.enabled &&
!_agent.pathPending &&
_agent.remainingDistance <= reachThreshold;
public bool IsNavigating => _hasDestination && _agent != null && _agent.enabled &&
_agent.hasPath && _agent.remainingDistance > reachThreshold;
public float RemainingDistance => _agent != null && _agent.enabled ? _agent.remainingDistance : float.MaxValue;
private void Awake()
{
_agent = GetComponent<NavMeshAgent>();
_aiController = GetComponent<CharacterAIController>();
}
private void Start()
{
if (_agent != null)
{
_agent.stoppingDistance = stoppingDistance;
_agent.avoidancePriority = avoidancePriority;
}
}
/// <summary>
/// Navigate to a position. Only recalculates path if target moved significantly.
/// </summary>
public void SetDestination(Vector3 target)
{
// Use CharacterAIController if available (it handles NavMesh warping)
if (_aiController != null)
{
float sqrDist = (target - _lastTargetPosition).sqrMagnitude;
if (_hasDestination && sqrDist < pathRecalcThreshold * pathRecalcThreshold)
return; // Target hasn't moved enough, keep current path
_aiController.NavigateTo(target);
_lastTargetPosition = target;
_hasDestination = true;
return;
}
// Direct NavMeshAgent fallback
if (_agent == null || !_agent.enabled) return;
float sqrDistDirect = (target - _lastTargetPosition).sqrMagnitude;
if (_hasDestination && sqrDistDirect < pathRecalcThreshold * pathRecalcThreshold)
return;
_agent.SetDestination(target);
_lastTargetPosition = target;
_hasDestination = true;
}
/// <summary>Stop navigation immediately.</summary>
public void Stop()
{
if (_aiController != null)
{
_aiController.Stop();
}
else if (_agent != null && _agent.enabled)
{
_agent.ResetPath();
}
_hasDestination = false;
}
/// <summary>Check if destination is approximately reached.</summary>
public bool HasReachedDestination()
{
if (!_hasDestination) return true;
if (_agent != null && _agent.enabled)
{
return !_agent.pathPending && _agent.remainingDistance <= reachThreshold;
}
// Fallback distance check
return Vector3.SqrMagnitude(transform.position - _lastTargetPosition) < reachThreshold * reachThreshold;
}
/// <summary>Set avoidance priority (carriers get higher priority = lower number).</summary>
public void SetAvoidancePriority(int priority)
{
avoidancePriority = priority;
if (_agent != null) _agent.avoidancePriority = priority;
}
/// <summary>Set movement speed.</summary>
public void SetSpeed(float speed)
{
if (_agent != null) _agent.speed = speed;
}
/// <summary>Warp to a position on the NavMesh.</summary>
public bool WarpTo(Vector3 position)
{
if (_agent == null) return false;
NavMeshHit hit;
if (NavMesh.SamplePosition(position, out hit, 5f, NavMesh.AllAreas))
{
_agent.Warp(hit.position);
return true;
}
return false;
}
/// <summary>Force path recalculation on next SetDestination call.</summary>
public void InvalidatePath()
{
_lastTargetPosition = Vector3.zero;
_hasDestination = false;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dfe5b90516a45b1bb9448ea8c0a67baa

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f4bc8a91e3ceecf97816ea2d740ae97f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,216 @@
using UnityEngine;
/// <summary>
/// UtilityScorer — Evaluates all AI actions and returns the highest-scoring one.
/// Stateless, zero-allocation. Called by AIBrain on each decision tick.
///
/// Uses Response Curves for nuanced decisions rather than hard thresholds.
/// Each AIAction.Score() returns 0-1 utility; UtilityScorer scales by role weights.
/// </summary>
public class UtilityScorer
{
// ─── Role Weight Profiles ────────────────────────────────
public enum AIRole
{
Balanced,
Collector,
Defender,
Aggressor
}
[System.Serializable]
public struct RoleWeights
{
public float collectWeight;
public float depositWeight;
public float attackWeight;
public float defendWeight;
public float retreatWeight;
public float escortWeight;
public float interceptWeight;
public float patrolWeight;
public static RoleWeights Balanced => new RoleWeights
{
collectWeight = 1.0f,
depositWeight = 1.0f,
attackWeight = 1.0f,
defendWeight = 1.2f,
retreatWeight = 1.0f,
escortWeight = 0.8f,
interceptWeight = 1.0f,
patrolWeight = 1.0f
};
public static RoleWeights Collector => new RoleWeights
{
collectWeight = 1.5f,
depositWeight = 1.3f,
attackWeight = 0.6f,
defendWeight = 0.8f,
retreatWeight = 1.2f,
escortWeight = 0.5f,
interceptWeight = 0.7f,
patrolWeight = 1.0f
};
public static RoleWeights Defender => new RoleWeights
{
collectWeight = 0.6f,
depositWeight = 0.8f,
attackWeight = 1.2f,
defendWeight = 2.0f,
retreatWeight = 0.8f,
escortWeight = 1.5f,
interceptWeight = 1.3f,
patrolWeight = 0.6f
};
public static RoleWeights Aggressor => new RoleWeights
{
collectWeight = 0.5f,
depositWeight = 0.7f,
attackWeight = 1.8f,
defendWeight = 0.8f,
retreatWeight = 0.6f,
escortWeight = 0.4f,
interceptWeight = 1.6f,
patrolWeight = 0.5f
};
public static RoleWeights ForRole(AIRole role)
{
switch (role)
{
case AIRole.Collector: return Collector;
case AIRole.Defender: return Defender;
case AIRole.Aggressor: return Aggressor;
default: return Balanced;
}
}
}
// ─── Pre-allocated action instances (no allocation per tick) ──
private readonly CollectBagAction _collectAction = new CollectBagAction();
private readonly DepositBagAction _depositAction = new DepositBagAction();
private readonly AttackEnemyAction _attackAction = new AttackEnemyAction();
private readonly DefendVaultAction _defendAction = new DefendVaultAction();
private readonly PatrolAction _patrolAction = new PatrolAction();
private readonly RetreatAction _retreatAction = new RetreatAction();
private readonly EscortCarrierAction _escortAction = new EscortCarrierAction();
private readonly InterceptCarrierAction _interceptAction = new InterceptCarrierAction();
private readonly AIAction[] _allActions;
private RoleWeights _weights;
// ─── Decision State ──────────────────────────────────────
private AIAction _currentAction;
private float _currentActionScore;
private float _minSwitchTime = 1.5f;
private float _actionStartTime;
// ─── Debug ───────────────────────────────────────────────
public AIAction CurrentAction => _currentAction;
public float CurrentActionScore => _currentActionScore;
public string CurrentActionName => _currentAction?.Name ?? "None";
// Expose for debug display
private readonly float[] _lastScores = new float[8];
public float[] LastScores => _lastScores;
public UtilityScorer(AIRole role = AIRole.Balanced)
{
_weights = RoleWeights.ForRole(role);
_allActions = new AIAction[]
{
_collectAction, // 0
_depositAction, // 1
_attackAction, // 2
_defendAction, // 3
_patrolAction, // 4
_retreatAction, // 5
_escortAction, // 6
_interceptAction // 7
};
}
/// <summary>
/// Evaluate all actions and return the best one.
/// Zero allocation — all actions are pre-allocated.
/// </summary>
public AIAction Evaluate(AIBrain brain, AIBlackboard bb, TeamBlackboard tb)
{
float bestScore = float.MinValue;
AIAction bestAction = null;
for (int i = 0; i < _allActions.Length; i++)
{
float rawScore = _allActions[i].Score(bb, tb);
float weight = GetWeight(i);
float weightedScore = rawScore * weight;
_lastScores[i] = weightedScore; // Store for debug
if (weightedScore > bestScore)
{
bestScore = weightedScore;
bestAction = _allActions[i];
}
}
// Hysteresis: don't switch unless new action is significantly better
// (prevents oscillation between similar-scoring actions)
if (_currentAction != null && bestAction != _currentAction)
{
bool timeLocked = (Time.time - _actionStartTime) < _minSwitchTime;
bool canInterrupt = bestAction.CanInterrupt;
bool significantlyBetter = bestScore > _currentActionScore * 1.2f;
if (timeLocked && !canInterrupt && !significantlyBetter)
{
return _currentAction; // Stay with current action
}
}
// Switch action
if (bestAction != _currentAction)
{
_currentAction?.OnExit(brain, bb, tb);
_currentAction = bestAction;
_currentActionScore = bestScore;
_actionStartTime = Time.time;
_currentAction?.OnEnter(brain, bb, tb);
}
return _currentAction;
}
/// <summary>Force reset (e.g., on death/respawn).</summary>
public void Reset()
{
_currentAction = null;
_currentActionScore = 0f;
}
private float GetWeight(int actionIndex)
{
switch (actionIndex)
{
case 0: return _weights.collectWeight;
case 1: return _weights.depositWeight;
case 2: return _weights.attackWeight;
case 3: return _weights.defendWeight;
case 4: return _weights.patrolWeight;
case 5: return _weights.retreatWeight;
case 6: return _weights.escortWeight;
case 7: return _weights.interceptWeight;
default: return 1f;
}
}
public void SetRole(AIRole role)
{
_weights = RoleWeights.ForRole(role);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 84ff338ac6954704cbaa771ed42b9e3e