From 25fae97065d5c06f26c2bd73579ce87736f3e7ab Mon Sep 17 00:00:00 2001 From: Joywayer Date: Fri, 24 Jul 2026 11:56:07 +0800 Subject: [PATCH 01/12] =?UTF-8?q?feat(enemy):=20=E6=94=BB=E5=87=BB?= =?UTF-8?q?=E9=80=89=E6=8B=9B=E5=85=83=E6=95=B0=E6=8D=AE(category/weight/?= =?UTF-8?q?=E5=9C=86=E5=BD=A2=E5=B0=84=E7=A8=8B)=E4=B8=8E=E9=80=89?= =?UTF-8?q?=E6=8B=9B=E6=A8=A1=E5=BC=8F=E5=85=A5=20SO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Scripts/Enemies/Abilities/AbilityCategory.cs | 11 +++++++++++ .../Scripts/Enemies/Abilities/AttackSelectionMode.cs | 9 +++++++++ .../Scripts/Enemies/Abilities/EnemyAbilitySO.cs | 12 ++++++++++++ Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs | 5 +++++ 4 files changed, 37 insertions(+) create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/AbilityCategory.cs create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/AttackSelectionMode.cs diff --git a/Assets/_Game/Scripts/Enemies/Abilities/AbilityCategory.cs b/Assets/_Game/Scripts/Enemies/Abilities/AbilityCategory.cs new file mode 100644 index 00000000..93374f68 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/AbilityCategory.cs @@ -0,0 +1,11 @@ +namespace BaseGames.Enemies.Abilities +{ + /// 能力调度分类:攻击选择器只从 类里选招。 + public enum AbilityCategory + { + None, // 未分类(默认,不参与选招) + Attack, // 攻击招式(选择器候选) + Movement, // 位移/机动 + Utility // 辅助/增益 + } +} diff --git a/Assets/_Game/Scripts/Enemies/Abilities/AttackSelectionMode.cs b/Assets/_Game/Scripts/Enemies/Abilities/AttackSelectionMode.cs new file mode 100644 index 00000000..5f704bf0 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/AttackSelectionMode.cs @@ -0,0 +1,9 @@ +namespace BaseGames.Enemies.Abilities +{ + /// 多攻击选招策略。 + public enum AttackSelectionMode + { + WeightedRandom, // 合格集内按 weight 加权随机 + Priority // 合格集内取 priority 最高(并列取首个) + } +} diff --git a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs index 6f8a599c..856c200e 100644 --- a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs +++ b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs @@ -42,5 +42,17 @@ namespace BaseGames.Enemies.Abilities public string exclusionGroup = ""; [Tooltip("AI 调度优先级(值越高越优先被选择,冷却就绪时对比)")] [Min(0)] public int priority = 0; + + [Header("选招(攻击选择器用)")] + [Tooltip("能力调度分类;攻击选择器只从 Attack 类里选招")] + public AbilityCategory category = AbilityCategory.None; + [Tooltip("WeightedRandom 模式下的相对权重(越大越易被选)")] + [Min(0f)] public float weight = 1f; + + [Header("攻击触发范围(招式自管,圆形)")] + [Tooltip("触发半径(m);category==Attack 时必须 > 0,否则永远够不着(Awake 报错)")] + [Min(0f)] public float rangeRadius = 0f; + [Tooltip("范围圆心相对敌人的偏移(m),X 随朝向翻转")] + public Vector2 rangeOffset = Vector2.zero; } } diff --git a/Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs b/Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs index 48ff63d3..c8761222 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs @@ -39,6 +39,11 @@ namespace BaseGames.Enemies [Header("战斗")] [Min(0f)] public float AttackCooldown = 1f; + [Header("攻击选招策略")] + [Tooltip("到攻击范围内如何从多个候选招里选一个")] + public BaseGames.Enemies.Abilities.AttackSelectionMode attackSelectionMode + = BaseGames.Enemies.Abilities.AttackSelectionMode.WeightedRandom; + [Header("追击 & AI 阶段")] [Tooltip("是否有警觉状态:勾选=未发现态进入视野先进警觉(朝向+警觉动画)再追击;不勾选=进入追逐感知直接追击")] public bool HasAlertState = true; From 2cf0d6b1a9e23f998ade03691facd8c6c9b4e46d Mon Sep 17 00:00:00 2001 From: Joywayer Date: Fri, 24 Jul 2026 11:58:22 +0800 Subject: [PATCH 02/12] =?UTF-8?q?feat(enemy):=20EnemyAbilityBase=20?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20IAttackCandidate=20=E5=A5=91=E7=BA=A6(?= =?UTF-8?q?=E8=87=AA=E7=AE=A1=E5=9C=86=E5=BD=A2=E5=B0=84=E7=A8=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Enemies/Abilities/EnemyAbilityBase.cs | 20 ++++++++++++++++++- .../Enemies/Abilities/IAttackCandidate.cs | 17 ++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/IAttackCandidate.cs diff --git a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs index 5751efd6..98b7ec86 100644 --- a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs +++ b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs @@ -16,7 +16,7 @@ namespace BaseGames.Enemies.Abilities /// - 受击/死亡时由 调用 。 /// [DisallowMultipleComponent] - public abstract class EnemyAbilityBase : MonoBehaviour + public abstract class EnemyAbilityBase : MonoBehaviour, IAttackCandidate { [Header("配置 SO")] [SerializeField] protected EnemyAbilitySO _config; @@ -43,6 +43,24 @@ namespace BaseGames.Enemies.Abilities /// BD 任务统一查询入口:当前是否可用(冷却完毕且未执行中)。 public virtual bool CanUse => !_isRunning && !IsOnCooldown && _enemy != null && _enemy.IsAlive; + // ── IAttackCandidate(供 EnemyAttackSelector 选招)────────────────── + public bool RequiresLineOfSight => _config != null && _config.requiresLineOfSight; + public bool RequiresGrounded => _config != null && _config.requiresGrounded; + public float Weight => _config != null ? _config.weight : 0f; + public int Priority => _config != null ? _config.priority : 0; + + /// 本招圆形攻击范围内是否有玩家(招式自管其射程;LOS 复用敌人级 IsPlayerVisible)。 + public virtual bool InAttackRange() + { + if (_config == null || _config.rangeRadius <= 0f + || _enemy == null || _enemy.PlayerTransform == null) return false; + float sign = _transform.localScale.x < 0f ? -1f : 1f; + Vector2 origin = (Vector2)_transform.position + + new Vector2(_config.rangeOffset.x * sign, _config.rangeOffset.y); + float r = _config.rangeRadius; + return ((Vector2)_enemy.PlayerTransform.position - origin).sqrMagnitude <= r * r; + } + protected virtual void Awake() { _enemy = GetComponentInParent(); diff --git a/Assets/_Game/Scripts/Enemies/Abilities/IAttackCandidate.cs b/Assets/_Game/Scripts/Enemies/Abilities/IAttackCandidate.cs new file mode 100644 index 00000000..2cb61610 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/IAttackCandidate.cs @@ -0,0 +1,17 @@ +namespace BaseGames.Enemies.Abilities +{ + /// + /// 攻击选择器的候选契约。攻击类 实现之; + /// 抽象出来使 可脱离 MonoBehaviour 单测。 + /// + public interface IAttackCandidate + { + bool CanUse { get; } // 未冷却 + 未运行 + 存活 + bool RequiresLineOfSight { get; } // 是否需要视线(由敌人级 LOS 提供) + bool RequiresGrounded { get; } // 是否需要着地 + float Weight { get; } // WeightedRandom 权重 + int Priority { get; } // Priority 优先级 + bool InAttackRange(); // 招式自管圆形射程内是否有玩家 + bool Execute(); // 触发本招 + } +} From 04a267b69f4b6211a80f7c0b528fff22b69ce460 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Fri, 24 Jul 2026 12:15:41 +0800 Subject: [PATCH 03/12] =?UTF-8?q?feat(enemy):=20EnemyAttackSelector=20?= =?UTF-8?q?=E9=80=89=E6=8B=9B=E5=99=A8(=E5=B0=84=E7=A8=8B+=E5=86=B7?= =?UTF-8?q?=E5=8D=B4+LOS+=E7=9D=80=E5=9C=B0=E8=BF=87=E6=BB=A4,=E4=B8=A4?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E9=80=89=E6=8B=9B)+=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Enemies/EnemyAttackSelectorTests.cs | 108 ++++++++++++++++++ .../Enemies/Abilities/EnemyAttackSelector.cs | 68 +++++++++++ 2 files changed, 176 insertions(+) create mode 100644 Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs diff --git a/Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs b/Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs new file mode 100644 index 00000000..ee4b13e6 --- /dev/null +++ b/Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs @@ -0,0 +1,108 @@ +using System.Collections.Generic; +using NUnit.Framework; +using BaseGames.Enemies.Abilities; + +namespace BaseGames.Tests.EditMode.Enemies +{ + public class EnemyAttackSelectorTests + { + sealed class FakeCandidate : IAttackCandidate + { + public bool CanUseV = true, InRange = true, ReqLOS = false, ReqGround = false, Executed; + public float WeightV = 1f; + public int PriorityV = 0; + public bool CanUse => CanUseV; + public bool RequiresLineOfSight => ReqLOS; + public bool RequiresGrounded => ReqGround; + public float Weight => WeightV; + public int Priority => PriorityV; + public bool InAttackRange() => InRange; + public bool Execute() { Executed = true; return true; } + } + + static EnemyAttackSelector Sel(params IAttackCandidate[] cs) + => new EnemyAttackSelector(new List(cs)); + + [Test] + public void Empty_NoEligible() + { + var s = Sel(); + Assert.IsFalse(s.HasEligible(true, true)); + Assert.IsNull(s.Select(true, true, AttackSelectionMode.Priority)); + } + + [Test] + public void ExcludesOnCooldown() + { + var c = new FakeCandidate { CanUseV = false }; + Assert.IsFalse(Sel(c).HasEligible(true, true)); + } + + [Test] + public void ExcludesOutOfRange() + { + var c = new FakeCandidate { InRange = false }; + Assert.IsFalse(Sel(c).HasEligible(true, true)); + } + + [Test] + public void ExcludesWhenNeedsLOS_ButNoLOS() + { + var c = new FakeCandidate { ReqLOS = true }; + Assert.IsFalse(Sel(c).HasEligible(false, true)); + Assert.IsTrue (Sel(c).HasEligible(true, true)); + } + + [Test] + public void ExcludesWhenNeedsGrounded_ButAirborne() + { + var c = new FakeCandidate { ReqGround = true }; + Assert.IsFalse(Sel(c).HasEligible(true, false)); + Assert.IsTrue (Sel(c).HasEligible(true, true)); + } + + [Test] + public void Priority_PicksHighest() + { + var lo = new FakeCandidate { PriorityV = 1 }; + var hi = new FakeCandidate { PriorityV = 5 }; + var pick = Sel(lo, hi).Select(true, true, AttackSelectionMode.Priority); + Assert.AreSame(hi, pick); + } + + [Test] + public void Priority_TieTakesFirstInList() + { + var a = new FakeCandidate { PriorityV = 3 }; + var b = new FakeCandidate { PriorityV = 3 }; + var pick = Sel(a, b).Select(true, true, AttackSelectionMode.Priority); + Assert.AreSame(a, pick); + } + + [Test] + public void Priority_SkipsIneligible() + { + var blocked = new FakeCandidate { PriorityV = 9, InRange = false }; + var ok = new FakeCandidate { PriorityV = 1 }; + var pick = Sel(blocked, ok).Select(true, true, AttackSelectionMode.Priority); + Assert.AreSame(ok, pick); + } + + [Test] + public void Weighted_SingleEligible_AlwaysThatOne() + { + var only = new FakeCandidate { WeightV = 2f }; + for (int i = 0; i < 20; i++) + Assert.AreSame(only, Sel(only).Select(true, true, AttackSelectionMode.WeightedRandom)); + } + + [Test] + public void Weighted_NeverPicksIneligible() + { + var bad = new FakeCandidate { WeightV = 100f, InRange = false }; + var ok = new FakeCandidate { WeightV = 1f }; + for (int i = 0; i < 50; i++) + Assert.AreSame(ok, Sel(bad, ok).Select(true, true, AttackSelectionMode.WeightedRandom)); + } + } +} diff --git a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs new file mode 100644 index 00000000..310bff99 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace BaseGames.Enemies.Abilities +{ + /// + /// 多攻击选招器:从候选(category==Attack)里按"射程 + 冷却 + LOS + 着地"过滤, + /// 再按 选一个。纯逻辑、无 MonoBehaviour 依赖,可单测。 + /// + public sealed class EnemyAttackSelector + { + private readonly List _candidates; + + public EnemyAttackSelector(IEnumerable candidates) + => _candidates = new List(candidates); + + public int Count => _candidates.Count; + + private static bool Eligible(IAttackCandidate c, bool hasLOS, bool grounded) + => c != null && c.CanUse && c.InAttackRange() + && (!c.RequiresLineOfSight || hasLOS) + && (!c.RequiresGrounded || grounded); + + public bool HasEligible(bool hasLOS, bool grounded) + { + for (int i = 0; i < _candidates.Count; i++) + if (Eligible(_candidates[i], hasLOS, grounded)) return true; + return false; + } + + public IAttackCandidate Select(bool hasLOS, bool grounded, AttackSelectionMode mode) + => mode == AttackSelectionMode.Priority + ? SelectByPriority(hasLOS, grounded) + : SelectByWeight(hasLOS, grounded); + + private IAttackCandidate SelectByPriority(bool hasLOS, bool grounded) + { + IAttackCandidate best = null; + for (int i = 0; i < _candidates.Count; i++) + { + var c = _candidates[i]; + if (!Eligible(c, hasLOS, grounded)) continue; + if (best == null || c.Priority > best.Priority) best = c; // 并列取首个 + } + return best; + } + + private IAttackCandidate SelectByWeight(bool hasLOS, bool grounded) + { + float total = 0f; + for (int i = 0; i < _candidates.Count; i++) + { + var c = _candidates[i]; + if (Eligible(c, hasLOS, grounded)) total += Mathf.Max(0f, c.Weight); + } + if (total <= 0f) return SelectByPriority(hasLOS, grounded); // 权重全 0 → 退化为确定性取首个合格 + float roll = Random.value * total; + for (int i = 0; i < _candidates.Count; i++) + { + var c = _candidates[i]; + if (!Eligible(c, hasLOS, grounded)) continue; + roll -= Mathf.Max(0f, c.Weight); + if (roll <= 0f) return c; + } + return null; // 理论不达(浮点边界兜底) + } + } +} From 702df35efd3e4f39f6b9a418e755c6f18e92e488 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Fri, 24 Jul 2026 12:20:54 +0800 Subject: [PATCH 04/12] =?UTF-8?q?feat(enemy):=20ICombatant=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=20HasEligibleAttack/UseBestAttack=20+=20EnemyBase=20?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E9=80=89=E6=8B=A9=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Tests/EditMode/AI/Fakes/FakeAiContext.cs | 7 +++++ Assets/_Game/Scripts/AI/ICombatant.cs | 6 ++++ .../Enemies/AIBrain/EnemyBrainContext.cs | 19 +++++++++++++ Assets/_Game/Scripts/Enemies/EnemyBase.cs | 28 +++++++++++++++++++ 4 files changed, 60 insertions(+) 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); From 6fd80cfec91bb994692274a3ec136475c343f095 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Fri, 24 Jul 2026 12:22:35 +0800 Subject: [PATCH 05/12] =?UTF-8?q?fix(enemy):=20BuildAttackSelector=20?= =?UTF-8?q?=E8=A1=A8=E8=BE=BE=E5=BC=8F=E4=B8=8A=E4=B8=8B=E6=96=87=E5=85=A8?= =?UTF-8?q?=E9=99=90=E5=AE=9A=20AbilityCategory(=E6=B6=88=E6=AD=A7?= =?UTF-8?q?=E4=B9=89=E4=BA=8E=20Abilities=20=E5=B1=9E=E6=80=A7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/_Game/Scripts/Enemies/EnemyBase.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/_Game/Scripts/Enemies/EnemyBase.cs b/Assets/_Game/Scripts/Enemies/EnemyBase.cs index 993a1dd3..19c14e93 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyBase.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyBase.cs @@ -570,7 +570,7 @@ namespace BaseGames.Enemies { var ab = all[i]; if (ab == null || ab.Config == null) continue; - if (ab.Config.category != Abilities.AbilityCategory.Attack) continue; + if (ab.Config.category != BaseGames.Enemies.Abilities.AbilityCategory.Attack) continue; if (ab.Config.rangeRadius <= 0f) Debug.LogError($"[EnemyBase] 攻击招 '{ab.Config.abilityId}' 的 rangeRadius<=0," + "永远够不着玩家。请在其 EnemyAbilitySO 上配置攻击触发半径。", ab); From 297f9d2968575337fc3286df7c477d6bcef077df Mon Sep 17 00:00:00 2001 From: Joywayer Date: Fri, 24 Jul 2026 12:23:43 +0800 Subject: [PATCH 06/12] =?UTF-8?q?feat(enemy):=20IEnemyLocomotion=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20Pursue=20=E5=AF=BB=E8=B7=AF=E9=80=BC?= =?UTF-8?q?=E8=BF=91=E5=8E=9F=E8=AF=AD(RunSpeed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/Tests/EditMode/AI/Fakes/FakeLocomotion.cs | 1 + Assets/_Game/Scripts/AI/IEnemyLocomotion.cs | 1 + .../Scripts/Enemies/Navigation/EnemyLocomotion.cs | 12 ++++++++++++ 3 files changed, 14 insertions(+) diff --git a/Assets/Tests/EditMode/AI/Fakes/FakeLocomotion.cs b/Assets/Tests/EditMode/AI/Fakes/FakeLocomotion.cs index 1bd8e57e..d736a110 100644 --- a/Assets/Tests/EditMode/AI/Fakes/FakeLocomotion.cs +++ b/Assets/Tests/EditMode/AI/Fakes/FakeLocomotion.cs @@ -11,6 +11,7 @@ namespace BaseGames.Tests.EditMode.AI public bool IsMoving { get; set; } public void SetMode(LocomotionMode mode) { CurrentMode = mode; Calls.Add("SetMode:" + mode); } public void Approach(Transform t) { CurrentMode = LocomotionMode.Approach; Calls.Add("Approach"); } + public void Pursue(Vector2 p) { CurrentMode = LocomotionMode.Approach; Calls.Add("Pursue"); } public void MoveTo(Vector2 p) { Calls.Add("MoveTo"); } public void Face(Vector2 p) { CurrentMode = LocomotionMode.Face; Calls.Add("Face"); } public void Stop() { CurrentMode = LocomotionMode.Idle; Calls.Add("Stop"); } diff --git a/Assets/_Game/Scripts/AI/IEnemyLocomotion.cs b/Assets/_Game/Scripts/AI/IEnemyLocomotion.cs index f68c4c7a..833d7ebf 100644 --- a/Assets/_Game/Scripts/AI/IEnemyLocomotion.cs +++ b/Assets/_Game/Scripts/AI/IEnemyLocomotion.cs @@ -16,6 +16,7 @@ namespace BaseGames.AI { void SetMode(LocomotionMode mode); // Idle(停) / Patrol(按配置策略游走) void Approach(Transform target); // 持续跟随,派生 RunSpeed + void Pursue(Vector2 target); // 寻路逼近(RunSpeed):每帧朝 target 重寻路(底层防抖) void MoveTo(Vector2 point); // 一次性目标点 void Face(Vector2 lookAt); // 停 + 朝向 void Stop(); diff --git a/Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs b/Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs index 7b15f806..1a59ff27 100644 --- a/Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs +++ b/Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs @@ -84,6 +84,18 @@ namespace BaseGames.Enemies _enemy?.MoveTo(point); } + /// + /// 寻路逼近:设 Approach 模式 + RunSpeed,每帧朝 target 发一次寻路请求(底层 Nav 自带防抖/受阻/NavLink)。 + /// 与一次性 的区别:设跑速、语义为"持续追向移动目标"。供 AI 追击态每帧调用。 + /// + public void Pursue(Vector2 target) + { + _mode = LocomotionMode.Approach; + _approachTarget = null; + if (_enemy?.Stats != null) _enemy.Nav?.SetSpeed(_enemy.Stats.RunSpeed); + _enemy?.MoveTo(target); + } + public void Face(Vector2 lookAt) { _mode = LocomotionMode.Face; From 6cdd4ef513215b13eaf131610ed93424594d503e Mon Sep 17 00:00:00 2001 From: Joywayer Date: Fri, 24 Jul 2026 12:27:59 +0800 Subject: [PATCH 07/12] =?UTF-8?q?feat(enemy):=20PerceptionStateMachine=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20ApproachAttack=20=E9=A3=8E=E6=A0=BC(?= =?UTF-8?q?=E5=AF=BB=E8=B7=AF=E9=80=BC=E8=BF=91=E2=86=94=E9=80=89=E6=8B=9B?= =?UTF-8?q?=E6=94=BB=E5=87=BB)+=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AI/PerceptionStateMachineTests.cs | 82 +++++++++++++++++++ .../Enemies/AIBrain/PerceptionStateMachine.cs | 71 ++++++++++++---- 2 files changed, 137 insertions(+), 16 deletions(-) diff --git a/Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs b/Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs index 38bc4259..b1a7b572 100644 --- a/Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs +++ b/Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs @@ -275,6 +275,88 @@ namespace BaseGames.Tests.EditMode.AI Assert.AreEqual(LocomotionMode.Patrol, ctx.L.CurrentMode); // 落地即巡逻移动意图 } + // ── ApproachAttack 交战风格 ─────────────────────────────────────── + static AiGraph GraphApproach(string entry = "Idle") + { + var b = new BrainBuilder(); + PerceptionStateMachine.Add(b, new PerceptionStateMachine.Config + { + Idle = "Idle", Patrol = "Patrol", Alert = "Alert", Death = "Death", + Entry = entry, Rest = "Patrol", + IdleMode = LocomotionMode.Idle, PatrolMode = LocomotionMode.Patrol, AlertMode = LocomotionMode.Face, + Engagement = PerceptionStateMachine.EngagementStyle.ApproachAttack, + ApproachState = "Approach", AttackState = "Attack", + }); + return b.Build(); + } + + [Test] + public void Approach_EnteredFromChaseZone_UsesPursue() + { + var ctx = new Ctx(); + var rt = new AiRuntime(GraphApproach(), ctx); + ctx.S.Chase = true; + rt.Tick(0.1f); + Assert.AreEqual("Approach", rt.CurrentStateName); + CollectionAssert.Contains(ctx.L.Calls, "Pursue"); + } + + [Test] + public void Approach_ToAttack_WhenEligibleAttack() + { + var ctx = new Ctx(); + var rt = new AiRuntime(GraphApproach(), ctx); + ctx.S.Chase = true; rt.Tick(0.1f); + ctx.C.Eligible = true; + rt.Tick(0.1f); + Assert.AreEqual("Attack", rt.CurrentStateName); + CollectionAssert.Contains(ctx.C.Used, "best"); + } + + [Test] + public void Attack_StopsLocomotion_OnEnter() + { + var ctx = new Ctx(); + var rt = new AiRuntime(GraphApproach(), ctx); + ctx.S.Chase = true; rt.Tick(0.1f); + ctx.C.Eligible = true; rt.Tick(0.1f); + CollectionAssert.Contains(ctx.L.Calls, "Stop"); + } + + [Test] + public void Attack_ToApproach_WhenAttackDone() + { + var ctx = new Ctx(); + var rt = new AiRuntime(GraphApproach(), ctx); + ctx.S.Chase = true; rt.Tick(0.1f); + ctx.C.Eligible = true; rt.Tick(0.1f); + Assert.AreEqual("Attack", rt.CurrentStateName); + ctx.C.Running = null; + rt.Tick(0.1f); + Assert.AreEqual("Approach", rt.CurrentStateName); + } + + [Test] + public void Approach_ToRest_WhenLeftAllZones() + { + var ctx = new Ctx(); + var rt = new AiRuntime(GraphApproach(), ctx); + ctx.S.Chase = true; rt.Tick(0.1f); + ctx.S.Chase = false; ctx.S.Vision = false; + rt.Tick(0.1f); + Assert.AreEqual("Patrol", rt.CurrentStateName); + } + + [Test] + public void ApproachAttack_Death_IsGlobal() + { + var ctx = new Ctx(); + var rt = new AiRuntime(GraphApproach(), ctx); + ctx.S.Chase = true; rt.Tick(0.1f); + rt.Send(AiSignal.Died); rt.Tick(0.1f); + Assert.AreEqual("Death", rt.CurrentStateName); + } + // ── 死亡(全局事件,任意态可达)─────────────────────────────────── [Test] diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs b/Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs index ac49a131..4865b2b9 100644 --- a/Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs +++ b/Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs @@ -14,6 +14,9 @@ namespace BaseGames.Enemies /// public static class PerceptionStateMachine { + /// 交战风格:单一冲锋能力,或"寻路逼近 + 选招攻击"两态子机。 + public enum EngagementStyle { ChaseAbility, ApproachAttack } + public sealed class Config { public string Idle = "Idle"; @@ -34,6 +37,11 @@ namespace BaseGames.Enemies // 追击=带冷却的冲刺能力时开启(opt-in):冲刺打完→巡逻(CD 期),冷却结束且仍在追逐区→再冲刺。 // 由冷却门控 Patrol→Chase,避免"CD 期在追逐区每帧 Chase↔Patrol 来回抖"。 public bool PatrolBetweenChases = false; + + // 交战风格(默认冲锋能力,保持现状);ApproachAttack 时启用 Approach↔Attack 两态子机。 + public EngagementStyle Engagement = EngagementStyle.ChaseAbility; + public string ApproachState = "Approach"; + public string AttackState = "Attack"; } public static void Add(BrainBuilder b, Config c) @@ -42,35 +50,66 @@ namespace BaseGames.Enemies b.Global().To(c.Death).OnEvent(AiSignal.Died); // 受击致死 → 死亡(全局最高优先) // 进追击的条件:默认"在追逐区"。PatrolBetweenChases 时加冷却门——CD 中不回追击, - // 使 Chase(冲刺)→Patrol(CD 巡逻)→Chase 循环不抖动。 + // 使 Chase(冲刺)→Patrol(CD 巡逻)→Chase 循环不抖动。冷却门仅在冲锋能力风格下生效。 string chaseId = c.ChaseAbilityId; - System.Func canChase = c.PatrolBetweenChases - ? (x => x.Sensor.InChaseZone() && x.Combat.CanUseAbility(chaseId)) - : (x => x.Sensor.InChaseZone()); - string chaseLabel = c.PatrolBetweenChases ? "InChaseZone+offCD" : "InChaseZone"; + System.Func canChase; + string chaseLabel; + if (c.Engagement == EngagementStyle.ChaseAbility && c.PatrolBetweenChases) + { + canChase = x => x.Sensor.InChaseZone() && x.Combat.CanUseAbility(chaseId); + chaseLabel = "InChaseZone+offCD"; + } + else + { + canChase = x => x.Sensor.InChaseZone(); + chaseLabel = "InChaseZone"; + } + + // 交战入口态:冲锋风格=Chase;逼近风格=Approach + string combatEntry = c.Engagement == EngagementStyle.ApproachAttack ? c.ApproachState : c.Chase; // 待机 / 巡逻:未发现态,升级转换相同(追逐优先、其次警觉) AddLocomotionState(b, c.Idle, c.IdleMode) - .To(c.Chase).When(canChase, chaseLabel) + .To(combatEntry).When(canChase, chaseLabel) .To(c.Alert).When(x => HasAlert(x) && x.Sensor.InVisionZone(), "InVision+HasAlert"); AddLocomotionState(b, c.Patrol, c.PatrolMode) - .To(c.Chase).When(canChase, chaseLabel) + .To(combatEntry).When(canChase, chaseLabel) .To(c.Alert).When(x => HasAlert(x) && x.Sensor.InVisionZone(), "InVision+HasAlert"); // 警觉(仅升级路径出现):进追逐区→追击;脱离视野→R;在视野内保持(朝向玩家) AddLocomotionState(b, c.Alert, c.AlertMode) - .To(c.Chase).When(canChase, chaseLabel) + .To(combatEntry).When(canChase, chaseLabel) .To(c.Rest).When(x => !x.Sensor.InVisionZone(), "leftVision"); - // 追击退出: - // PatrolBetweenChases: 冲刺打完(能力结束)→R(CD 期巡逻);committed 冲锋不被感知丢失中途打断, - // 是否再冲刺交由 Patrol 侧冷却门控。 - // 默认: 脱离全部感知(追逐区且视野区都不在)→R(永不回警觉)。 - var chase = AddAbilityState(b, c.Chase, chaseId); - if (c.PatrolBetweenChases) - chase.To(c.Rest).When(x => !x.Combat.IsAbilityRunning(chaseId), "dashDone→CD"); + if (c.Engagement == EngagementStyle.ApproachAttack) + { + // Approach:寻路逼近;有招够得着→Attack;脱离全部感知→R + b.State(c.ApproachState) + .OnEnter(x => x.Locomotion.Pursue(x.Sensor.LastKnown)) + .Tick(x => x.Locomotion.Pursue(x.Sensor.LastKnown)) + .OnExit(x => x.Locomotion.Stop()) + .To(c.AttackState).When(x => x.Combat.HasEligibleAttack(), "attackInRange") + .To(c.Rest).When(x => !x.Sensor.InChaseZone() && !x.Sensor.InVisionZone(), "leftAllZones"); + + // Attack:停逼近 + 选招攻击;打完回 Approach;脱离感知→R + b.State(c.AttackState) + .OnEnter(x => { x.Locomotion.Stop(); x.Combat.UseBestAttack(); }) + .OnExit(x => x.Combat.InterruptAbilities()) + .To(c.ApproachState).When(x => !x.Combat.IsAbilityRunning(), "attackDone") + .To(c.Rest).When(x => !x.Sensor.InChaseZone() && !x.Sensor.InVisionZone(), "leftAllZones"); + } else - chase.To(c.Rest).When(x => !x.Sensor.InChaseZone() && !x.Sensor.InVisionZone(), "leftAllZones"); + { + // 追击退出: + // PatrolBetweenChases: 冲刺打完(能力结束)→R(CD 期巡逻);committed 冲锋不被感知丢失中途打断, + // 是否再冲刺交由 Patrol 侧冷却门控。 + // 默认: 脱离全部感知(追逐区且视野区都不在)→R(永不回警觉)。 + var chase = AddAbilityState(b, c.Chase, chaseId); + if (c.PatrolBetweenChases) + chase.To(c.Rest).When(x => !x.Combat.IsAbilityRunning(chaseId), "dashDone→CD"); + else + chase.To(c.Rest).When(x => !x.Sensor.InChaseZone() && !x.Sensor.InVisionZone(), "leftAllZones"); + } // 死亡:终态(无转出);死亡演出走物理状态机 AddAbilityState(b, c.Death, c.DeathAbilityId); From 980fe35ae42a12bd4b53dfc268e147351d91e2d6 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Fri, 24 Jul 2026 12:31:31 +0800 Subject: [PATCH 08/12] =?UTF-8?q?docs(enemy):=20PerceptionStateMachine=20?= =?UTF-8?q?=E7=B1=BB=E6=B3=A8=E9=87=8A=E8=A1=A5=E5=85=85=20ApproachAttack?= =?UTF-8?q?=20=E4=BA=A4=E6=88=98=E9=A3=8E=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs b/Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs index 4865b2b9..dde04530 100644 --- a/Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs +++ b/Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs @@ -11,6 +11,9 @@ namespace BaseGames.Enemies /// 警觉: 在追逐区→追击; 否则 脱离视野→R; 否则保持警觉(朝向玩家)。 /// 追击: 脱离全部感知(追逐区且视野区都不在)→R(永不回警觉)。 /// 缺省自动降级:无警觉(HasAlert=false)→警觉分支不触发。 + /// 交战风格(Engagement):默认 ChaseAbility=单一冲锋能力节点; + /// ApproachAttack=把追击换成 Approach(寻路逼近)↔Attack(到射程选招攻击、攻击时停逼近) 两态子机, + /// 打完回逼近重判、脱离全部感知区→R。 /// public static class PerceptionStateMachine { From f77ab71555602b6e5eab0c2cd3e70da6cbe7e281 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Fri, 24 Jul 2026 12:33:16 +0800 Subject: [PATCH 09/12] =?UTF-8?q?feat(enemy):=20=E6=94=BB=E5=87=BB?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E5=B0=84=E7=A8=8B=20Gizmo=EF=BC=88=E9=80=89?= =?UTF-8?q?=E4=B8=AD=E5=8F=AF=E8=A7=81=EF=BC=89+=20category=3D=3DAttack=20?= =?UTF-8?q?=E7=9A=84=20rangeRadius=20=E8=87=AA=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Scripts/Enemies/Abilities/EnemyAbilityBase.cs | 14 ++++++++++++++ .../Scripts/Enemies/Abilities/EnemyAbilitySO.cs | 12 +++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs index 98b7ec86..42d1a474 100644 --- a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs +++ b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs @@ -182,6 +182,20 @@ namespace BaseGames.Enemies.Abilities $"实际 = {(_config != null ? _config.GetType().Name : "null")}。请重建为正确的子类 SO。", this); return null; } + +#if UNITY_EDITOR + /// 选中时绘制攻击射程范围(编辑器可视化辅助,不影响运行时)。 + private void OnDrawGizmosSelected() + { + if (_config == null || _config.category != AbilityCategory.Attack || _config.rangeRadius <= 0f) return; + float sign = transform.localScale.x < 0f ? -1f : 1f; + Vector3 origin = transform.position + + new Vector3(_config.rangeOffset.x * sign, _config.rangeOffset.y, 0f); + UnityEditor.Handles.color = new Color(1f, 0.4f, 0.2f, 0.9f); + UnityEditor.Handles.DrawWireDisc(origin, Vector3.forward, _config.rangeRadius); + UnityEditor.Handles.Label(origin, $"{_config.abilityId} range"); + } +#endif } /// WaitForSeconds 池(架构 §10 GC 优化)。能力协程统一通过此获取等待指令。 diff --git a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs index 856c200e..a6382088 100644 --- a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs +++ b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs @@ -1,4 +1,6 @@ +using System.Collections.Generic; using UnityEngine; +using BaseGames.Core; namespace BaseGames.Enemies.Abilities { @@ -8,7 +10,7 @@ namespace BaseGames.Enemies.Abilities /// 由对应 EnemyAbilityBase 子类组件读取并执行。 /// [CreateAssetMenu(menuName = "BaseGames/Enemies/Enemy Ability", fileName = "EAB_")] - public class EnemyAbilitySO : ScriptableObject + public class EnemyAbilitySO : ScriptableObject, IValidatable { [Header("标识")] [Tooltip("BD 任务通过此 Id 调用能力(如 \"melee_combo\" / \"blink_strike\")")] @@ -54,5 +56,13 @@ namespace BaseGames.Enemies.Abilities [Min(0f)] public float rangeRadius = 0f; [Tooltip("范围圆心相对敌人的偏移(m),X 随朝向翻转")] public Vector2 rangeOffset = Vector2.zero; + + /// SOValidationRunner 自动扫描调用:校验 Attack 类招式必须配置有效射程。 + public IEnumerable Validate() + { + if (category == AbilityCategory.Attack && rangeRadius <= 0f) + yield return ValidationResult.Error( + $"能力 \"{abilityId}\" category=Attack 但 rangeRadius<=0,攻击选择器永远选不中该招式,请补齐 rangeRadius。"); + } } } From 022e4f18d200d160086af85bb743a110587ae586 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Fri, 24 Jul 2026 12:47:48 +0800 Subject: [PATCH 10/12] =?UTF-8?q?fix(enemy):=20Approach=20=E9=80=BC?= =?UTF-8?q?=E8=BF=91=E6=94=B9=E7=94=A8=20aggro/=E8=A7=86=E9=87=8E=E5=8F=8C?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E7=9A=84=20LastKnown(=E4=BF=AE=20aggro=20?= =?UTF-8?q?=E5=85=88=E5=91=BD=E4=B8=AD=E6=89=91=E5=90=91=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=82=B9)+=E8=A1=A5=20Attack=20=E9=80=80=E5=87=BA=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AI/PerceptionStateMachineTests.cs | 27 +++++++++++++++++++ .../Enemies/AIBrain/EnemyBrainContext.cs | 15 +++++------ .../Enemies/Abilities/EnemyAttackSelector.cs | 2 +- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs b/Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs index b1a7b572..ca004669 100644 --- a/Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs +++ b/Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs @@ -357,6 +357,33 @@ namespace BaseGames.Tests.EditMode.AI Assert.AreEqual("Death", rt.CurrentStateName); } + // 进 Attack 后玩家脱离全部感知区:攻击仍在跑 → 不回 Approach,改判 leftAllZones → Rest(Patrol)。 + [Test] + public void Attack_ToRest_WhenLeftAllZones() + { + var ctx = new Ctx(); + var rt = new AiRuntime(GraphApproach(), ctx); + ctx.S.Chase = true; rt.Tick(0.1f); // → Approach + ctx.C.Eligible = true; rt.Tick(0.1f); // → Attack, Running="best" + Assert.AreEqual("Attack", rt.CurrentStateName); + ctx.S.Chase = false; ctx.S.Vision = false; // 攻击仍 running,但脱离全部感知 + rt.Tick(0.1f); + Assert.AreEqual("Patrol", rt.CurrentStateName); + } + + // 死亡是全局事件,从 Attack 态也能达 Death。 + [Test] + public void ApproachAttack_Death_FromAttack() + { + var ctx = new Ctx(); + var rt = new AiRuntime(GraphApproach(), ctx); + ctx.S.Chase = true; rt.Tick(0.1f); // → Approach + ctx.C.Eligible = true; rt.Tick(0.1f); // → Attack + Assert.AreEqual("Attack", rt.CurrentStateName); + rt.Send(AiSignal.Died); rt.Tick(0.1f); + Assert.AreEqual("Death", rt.CurrentStateName); + } + // ── 死亡(全局事件,任意态可达)─────────────────────────────────── [Test] diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs b/Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs index 24a4e7f8..30dc8e08 100644 --- a/Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs +++ b/Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs @@ -26,15 +26,14 @@ namespace BaseGames.Enemies /// 每帧由 EnemyAiBrain 在 Tick 之前调用,维护最后已知位置与丢失计时(供需要的敌人用)。 public void Refresh(float dt) { - if (InVisionZone() && _enemy.PlayerTransform != null) - { + bool vision = InVisionZone(); + // 追逐区(aggro)或视野内都刷新最后已知位置:Approach 逼近态仅由 aggro 门控进入, + // 若只在视野时刷新,aggro 先于视野命中时 Pursue 会拿到 zero/过期点而扑向错误位置。 + if ((vision || InChaseZone()) && _enemy.PlayerTransform != null) _lastKnown = _enemy.PlayerTransform.position; - _lostTimer = 0f; - } - else - { - _lostTimer += dt; - } + // 丢失计时仍以视野为准(LostFor 语义=脱离视野的时长,不受 aggro 影响)。 + if (vision) _lostTimer = 0f; + else _lostTimer += dt; } /// 对象池复用时清空临时态。 diff --git a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs index 310bff99..d10ee61c 100644 --- a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs +++ b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs @@ -53,7 +53,7 @@ namespace BaseGames.Enemies.Abilities var c = _candidates[i]; if (Eligible(c, hasLOS, grounded)) total += Mathf.Max(0f, c.Weight); } - if (total <= 0f) return SelectByPriority(hasLOS, grounded); // 权重全 0 → 退化为确定性取首个合格 + if (total <= 0f) return SelectByPriority(hasLOS, grounded); // 权重全 0 → 退化为按 Priority 选(并列取首个) float roll = Random.value * total; for (int i = 0; i < _candidates.Count; i++) { From 568b6e46c08925ece80130ebce215610d8523fef Mon Sep 17 00:00:00 2001 From: Joywayer Date: Fri, 24 Jul 2026 14:55:17 +0800 Subject: [PATCH 11/12] =?UTF-8?q?chore(enemy):=20=E8=A1=A5=E4=BA=A4?= =?UTF-8?q?=E6=96=B0=E8=84=9A=E6=9C=AC=E7=9A=84=20.meta(AbilityCategory/At?= =?UTF-8?q?tackSelectionMode/IAttackCandidate/EnemyAttackSelector+?= =?UTF-8?q?=E6=B5=8B=E8=AF=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/Tests/EditMode/Enemies.meta | 8 ++++++++ .../EditMode/Enemies/EnemyAttackSelectorTests.cs.meta | 11 +++++++++++ .../Scripts/Enemies/Abilities/AbilityCategory.cs.meta | 11 +++++++++++ .../Enemies/Abilities/AttackSelectionMode.cs.meta | 11 +++++++++++ .../Enemies/Abilities/EnemyAttackSelector.cs.meta | 11 +++++++++++ .../Enemies/Abilities/IAttackCandidate.cs.meta | 11 +++++++++++ 6 files changed, 63 insertions(+) create mode 100644 Assets/Tests/EditMode/Enemies.meta create mode 100644 Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/AbilityCategory.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/AttackSelectionMode.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/Abilities/IAttackCandidate.cs.meta diff --git a/Assets/Tests/EditMode/Enemies.meta b/Assets/Tests/EditMode/Enemies.meta new file mode 100644 index 00000000..02f1cfba --- /dev/null +++ b/Assets/Tests/EditMode/Enemies.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8ce8f4da759211e4b9e06dfe498585a9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs.meta b/Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs.meta new file mode 100644 index 00000000..9f282b30 --- /dev/null +++ b/Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6f22b047123bb324a9e3f6cf0e71178f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Abilities/AbilityCategory.cs.meta b/Assets/_Game/Scripts/Enemies/Abilities/AbilityCategory.cs.meta new file mode 100644 index 00000000..8fce0eeb --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/AbilityCategory.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f3568dcdc4a3bdd4084ed27fe9369dcc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Abilities/AttackSelectionMode.cs.meta b/Assets/_Game/Scripts/Enemies/Abilities/AttackSelectionMode.cs.meta new file mode 100644 index 00000000..d7b26fd3 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/AttackSelectionMode.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 42fd6b0dbc1f9404baede42a158b4cb0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs.meta b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs.meta new file mode 100644 index 00000000..db012d74 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9ee7c2be22a1ff548b0bb0e7e8e7a3ca +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Abilities/IAttackCandidate.cs.meta b/Assets/_Game/Scripts/Enemies/Abilities/IAttackCandidate.cs.meta new file mode 100644 index 00000000..20f529bd --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Abilities/IAttackCandidate.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8627938e5d8ca4440b1c8e52700b1b0b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 23de5adf752aa22d9bc320a19a6c9fed84da4613 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Fri, 24 Jul 2026 15:12:41 +0800 Subject: [PATCH 12/12] =?UTF-8?q?fix(enemy):=20MeleeAttackAbility=20?= =?UTF-8?q?=E7=A9=BA=20clip=20=E8=B5=B0=20fallbackDuration(=E5=88=A4=20cli?= =?UTF-8?q?p.Clip=20=E8=80=8C=E9=9D=9E=20clip),=E5=85=91=E7=8E=B0=20EnemyA?= =?UTF-8?q?ttackSO=20=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/_Game/Scripts/Enemies/Abilities/MeleeAttackAbility.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Assets/_Game/Scripts/Enemies/Abilities/MeleeAttackAbility.cs b/Assets/_Game/Scripts/Enemies/Abilities/MeleeAttackAbility.cs index 06c1540c..605d9b8b 100644 --- a/Assets/_Game/Scripts/Enemies/Abilities/MeleeAttackAbility.cs +++ b/Assets/_Game/Scripts/Enemies/Abilities/MeleeAttackAbility.cs @@ -68,7 +68,9 @@ namespace BaseGames.Enemies.Abilities Phase = AbilityRunState.Active; float duration = atk.fallbackDuration; - if (atk.clip != null && _animancer != null) + // clip 是 ClipTransition(包装对象,恒非 null);须判其内部 .Clip 是否为空—— + // 为空表示本段无动画,按 EnemyAttackSO 契约走 fallbackDuration,不可 Play(否则 Animancer 抛 Clip is null)。 + if (atk.clip != null && atk.clip.Clip != null && _animancer != null) { var state = _animancer.Play(atk.clip); if (state != null && state.Length > 0f) duration = state.Length;