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/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/Tests/EditMode/AI/PerceptionStateMachineTests.cs b/Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs index 38bc4259..ca004669 100644 --- a/Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs +++ b/Assets/Tests/EditMode/AI/PerceptionStateMachineTests.cs @@ -275,6 +275,115 @@ 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); + } + + // 进 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/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 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/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/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/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/AIBrain/EnemyBrainContext.cs b/Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs index 50ea5111..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; } /// 对象池复用时清空临时态。 @@ -82,6 +81,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/AIBrain/PerceptionStateMachine.cs b/Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs index ac49a131..dde04530 100644 --- a/Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs +++ b/Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs @@ -11,9 +11,15 @@ namespace BaseGames.Enemies /// 警觉: 在追逐区→追击; 否则 脱离视野→R; 否则保持警觉(朝向玩家)。 /// 追击: 脱离全部感知(追逐区且视野区都不在)→R(永不回警觉)。 /// 缺省自动降级:无警觉(HasAlert=false)→警觉分支不触发。 + /// 交战风格(Engagement):默认 ChaseAbility=单一冲锋能力节点; + /// ApproachAttack=把追击换成 Approach(寻路逼近)↔Attack(到射程选招攻击、攻击时停逼近) 两态子机, + /// 打完回逼近重判、脱离全部感知区→R。 /// public static class PerceptionStateMachine { + /// 交战风格:单一冲锋能力,或"寻路逼近 + 选招攻击"两态子机。 + public enum EngagementStyle { ChaseAbility, ApproachAttack } + public sealed class Config { public string Idle = "Idle"; @@ -34,6 +40,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 +53,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); 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/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 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/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/EnemyAbilityBase.cs b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs index 5751efd6..42d1a474 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(); @@ -164,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 6f8a599c..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\")")] @@ -42,5 +44,25 @@ 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; + + /// SOValidationRunner 自动扫描调用:校验 Attack 类招式必须配置有效射程。 + public IEnumerable Validate() + { + if (category == AbilityCategory.Attack && rangeRadius <= 0f) + yield return ValidationResult.Error( + $"能力 \"{abilityId}\" category=Attack 但 rangeRadius<=0,攻击选择器永远选不中该招式,请补齐 rangeRadius。"); + } } } diff --git a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs new file mode 100644 index 00000000..d10ee61c --- /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 → 退化为按 Priority 选(并列取首个) + 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; // 理论不达(浮点边界兜底) + } + } +} 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 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(); // 触发本招 + } +} 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: 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; diff --git a/Assets/_Game/Scripts/Enemies/EnemyBase.cs b/Assets/_Game/Scripts/Enemies/EnemyBase.cs index 85f5b86f..19c14e93 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 != BaseGames.Enemies.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); 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; 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;