using UnityEngine; /// /// 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. /// 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 }; } /// /// Evaluate all actions and return the best one. /// Zero allocation — all actions are pre-allocated. /// 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; } /// Force reset (e.g., on death/respawn). 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); } }