diff --git a/Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs b/Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs
index 34b2bf33..a0d037bf 100644
--- a/Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs
+++ b/Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs
@@ -25,6 +25,13 @@ namespace BaseGames.Tests.EditMode.AI
public bool NoAbilityRunning => Running == null;
public bool CanUseAbility(string id) => CanUse;
public void InterruptAbilities() => Running = null;
+ public bool Eligible; // 测试控制:是否有合格攻击
+ public bool HasEligibleAttack() => Eligible;
+ public bool UseBestAttack()
+ {
+ if (!Eligible) return false;
+ Used.Add("best"); Running = "best"; return true;
+ }
}
public sealed class FakeVitals : IActorVitals
diff --git a/Assets/_Game/Scripts/AI/ICombatant.cs b/Assets/_Game/Scripts/AI/ICombatant.cs
index fc4dda9b..62f3560f 100644
--- a/Assets/_Game/Scripts/AI/ICombatant.cs
+++ b/Assets/_Game/Scripts/AI/ICombatant.cs
@@ -8,5 +8,11 @@ namespace BaseGames.AI
bool NoAbilityRunning { get; }
bool CanUseAbility(string abilityId);
void InterruptAbilities(); // 中断当前所有能力(如退出追击时停追击能力)
+
+ /// 攻击选择器中是否有"够得着且未冷却"的招(供 Approach→Attack 转换)。
+ bool HasEligibleAttack();
+
+ /// 按敌人配置的选招模式选一个攻击并触发;返回是否成功触发。
+ bool UseBestAttack();
}
}
diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs b/Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs
index 50ea5111..24a4e7f8 100644
--- a/Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs
+++ b/Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs
@@ -82,6 +82,25 @@ namespace BaseGames.Enemies
public void InterruptAbilities()
=> _enemy.Abilities?.InterruptAll(Abilities.InterruptReason.ExternalRequest);
+ public bool HasEligibleAttack()
+ {
+ var sel = _enemy.AttackSelector;
+ if (sel == null) return false;
+ return sel.HasEligible(_enemy.IsPlayerVisible(), _enemy.Movement != null && _enemy.Movement.IsGrounded);
+ }
+
+ public bool UseBestAttack()
+ {
+ var sel = _enemy.AttackSelector;
+ if (sel == null) return false;
+ var mode = _enemy.StatsSO != null
+ ? _enemy.StatsSO.attackSelectionMode
+ : Abilities.AttackSelectionMode.WeightedRandom;
+ bool grounded = _enemy.Movement != null && _enemy.Movement.IsGrounded;
+ var pick = sel.Select(_enemy.IsPlayerVisible(), grounded, mode);
+ return pick != null && pick.Execute();
+ }
+
// ---- IActorVitals ----
public bool IsAlive => _enemy.IsAlive;
public bool IsControllable => _enemy.CurrentState == EnemyStateType.Controlled;
diff --git a/Assets/_Game/Scripts/Enemies/EnemyBase.cs b/Assets/_Game/Scripts/Enemies/EnemyBase.cs
index 85f5b86f..993a1dd3 100644
--- a/Assets/_Game/Scripts/Enemies/EnemyBase.cs
+++ b/Assets/_Game/Scripts/Enemies/EnemyBase.cs
@@ -221,6 +221,9 @@ namespace BaseGames.Enemies
/// 能力注册表(架构 §8.3)。Awake 时自动收集所有 EnemyAbilityBase 组件。
public EnemyAbilityRegistry Abilities => _abilities;
private readonly EnemyAbilityRegistry _abilities = new EnemyAbilityRegistry();
+ /// 攻击选择器(从已注册能力里筛出 category==Attack 的候选,Awake 时构建)。
+ public Abilities.EnemyAttackSelector AttackSelector => _attackSelector;
+ private Abilities.EnemyAttackSelector _attackSelector;
/// 由 _onPlayerSpawned 事件缓存的玩家 Transform,供 BD 任务读取。
public Transform PlayerTransform => _playerTransform;
/// 感知 Hub;供 BD 任务及 QuotaManager 暂停/恢复感知使用。
@@ -537,6 +540,7 @@ namespace BaseGames.Enemies
_pooledObject = GetComponent();
_brain = GetComponent();
_abilities.CollectFrom(gameObject);
+ BuildAttackSelector();
_colliders = GetComponentsInChildren(true);
// 收集配置型行为模块(零代码扩展点)
@@ -552,6 +556,30 @@ namespace BaseGames.Enemies
// 订阅在 OnEnable 中处理
}
+ ///
+ /// 从已注册能力里收集 category==Attack 的候选,构建攻击选择器。
+ /// 根因校验:攻击招 rangeRadius<=0(永远够不着)显式报错,不静默兜底。
+ ///
+ private void BuildAttackSelector()
+ {
+ var candidates = new System.Collections.Generic.List();
+ var all = _abilities?.All;
+ if (all != null)
+ {
+ for (int i = 0; i < all.Count; i++)
+ {
+ var ab = all[i];
+ if (ab == null || ab.Config == null) continue;
+ if (ab.Config.category != Abilities.AbilityCategory.Attack) continue;
+ if (ab.Config.rangeRadius <= 0f)
+ Debug.LogError($"[EnemyBase] 攻击招 '{ab.Config.abilityId}' 的 rangeRadius<=0," +
+ "永远够不着玩家。请在其 EnemyAbilitySO 上配置攻击触发半径。", ab);
+ candidates.Add(ab);
+ }
+ }
+ _attackSelector = new Abilities.EnemyAttackSelector(candidates);
+ }
+
protected virtual void Update()
{
_stats?.TickAttackCooldown(Time.deltaTime);