chunk 1: core gameplay scripts scenes runtime assets
This commit is contained in:
8
Assets/Scripts/AI/Actions.meta
Normal file
8
Assets/Scripts/AI/Actions.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bbb831ed5741e5286af289e75349418b
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
72
Assets/Scripts/AI/Actions/AIAction.cs
Normal file
72
Assets/Scripts/AI/Actions/AIAction.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/AI/Actions/AIAction.cs.meta
Normal file
2
Assets/Scripts/AI/Actions/AIAction.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fde981af550c7988ebf29c73e80a06b2
|
||||
471
Assets/Scripts/AI/Actions/AIActions.cs
Normal file
471
Assets/Scripts/AI/Actions/AIActions.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/AI/Actions/AIActions.cs.meta
Normal file
2
Assets/Scripts/AI/Actions/AIActions.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ba24431079dff36f3822b97125261be6
|
||||
107
Assets/Scripts/AI/Actions/CollectBagAction.cs
Normal file
107
Assets/Scripts/AI/Actions/CollectBagAction.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/AI/Actions/CollectBagAction.cs.meta
Normal file
2
Assets/Scripts/AI/Actions/CollectBagAction.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 12d6d7471a2b608a38e68d2073778452
|
||||
8
Assets/Scripts/AI/Core.meta
Normal file
8
Assets/Scripts/AI/Core.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bf57712f1987ec7d7b6160acc2e1fa7f
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
274
Assets/Scripts/AI/Core/AIBlackboard.cs
Normal file
274
Assets/Scripts/AI/Core/AIBlackboard.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/AI/Core/AIBlackboard.cs.meta
Normal file
2
Assets/Scripts/AI/Core/AIBlackboard.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f851e5eb528f0fa12a0efb0e7ecde6f6
|
||||
352
Assets/Scripts/AI/Core/AIBrain.cs
Normal file
352
Assets/Scripts/AI/Core/AIBrain.cs
Normal 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
|
||||
}
|
||||
2
Assets/Scripts/AI/Core/AIBrain.cs.meta
Normal file
2
Assets/Scripts/AI/Core/AIBrain.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5557a60f10e7a97e79823d421b552c2d
|
||||
241
Assets/Scripts/AI/Core/AIPerceptionSystem.cs
Normal file
241
Assets/Scripts/AI/Core/AIPerceptionSystem.cs
Normal 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
|
||||
}
|
||||
2
Assets/Scripts/AI/Core/AIPerceptionSystem.cs.meta
Normal file
2
Assets/Scripts/AI/Core/AIPerceptionSystem.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 09984091d0b1426349f45490e2b656e7
|
||||
8
Assets/Scripts/AI/Navigation.meta
Normal file
8
Assets/Scripts/AI/Navigation.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1513669be5125c8c2bee7148a001ff8d
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
145
Assets/Scripts/AI/Navigation/AINavigationHelper.cs
Normal file
145
Assets/Scripts/AI/Navigation/AINavigationHelper.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/AI/Navigation/AINavigationHelper.cs.meta
Normal file
2
Assets/Scripts/AI/Navigation/AINavigationHelper.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dfe5b90516a45b1bb9448ea8c0a67baa
|
||||
8
Assets/Scripts/AI/Scoring.meta
Normal file
8
Assets/Scripts/AI/Scoring.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f4bc8a91e3ceecf97816ea2d740ae97f
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
216
Assets/Scripts/AI/Scoring/UtilityScorer.cs
Normal file
216
Assets/Scripts/AI/Scoring/UtilityScorer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/AI/Scoring/UtilityScorer.cs.meta
Normal file
2
Assets/Scripts/AI/Scoring/UtilityScorer.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 84ff338ac6954704cbaa771ed42b9e3e
|
||||
Reference in New Issue
Block a user