diff --git a/Assets/Tests/EditMode/AI/ApproachAttackEngagementTests.cs b/Assets/Tests/EditMode/AI/ApproachAttackEngagementTests.cs
new file mode 100644
index 00000000..214e2605
--- /dev/null
+++ b/Assets/Tests/EditMode/AI/ApproachAttackEngagementTests.cs
@@ -0,0 +1,89 @@
+using NUnit.Framework;
+using BaseGames.AI;
+using BaseGames.Enemies;
+
+namespace BaseGames.Tests.EditMode.AI
+{
+ public class ApproachAttackEngagementTests
+ {
+ const string Rest = "Rest";
+
+ static AiRuntime RunAlone(FakeAiContext ctx)
+ {
+ var m = new ApproachAttackEngagement();
+ var b = new BrainBuilder();
+ AiStateFragments.Locomotion(b, Rest, LocomotionMode.Patrol);
+ m.Build(b, Rest);
+ b.Entry(m.EntryState);
+ return new AiRuntime(b.Build(), ctx);
+ }
+
+ [Test]
+ public void Approach_PursuesLastKnown()
+ {
+ var ctx = new FakeAiContext();
+ ctx.S.Chase = true;
+ ctx.S.Last = new UnityEngine.Vector2(5f, 1f);
+ RunAlone(ctx);
+ Assert.AreEqual(new UnityEngine.Vector2(5f, 1f), ctx.L.PursuedTo);
+ }
+
+ [Test]
+ public void Approach_ToAttack_WhenEligibleAttackExists()
+ {
+ var ctx = new FakeAiContext();
+ ctx.S.Chase = true;
+ var rt = RunAlone(ctx);
+ Assert.AreEqual(ApproachAttackEngagement.Approach, rt.CurrentStateName);
+ ctx.C.Eligible = true;
+ rt.Tick(0.1f);
+ Assert.AreEqual(ApproachAttackEngagement.Attack, rt.CurrentStateName);
+ CollectionAssert.Contains(ctx.C.Used, "best");
+ }
+
+ [Test]
+ public void Attack_BackToApproach_WhenAbilityDone()
+ {
+ var ctx = new FakeAiContext();
+ ctx.S.Chase = true; ctx.C.Eligible = true;
+ var rt = RunAlone(ctx);
+ rt.Tick(0.1f);
+ Assert.AreEqual(ApproachAttackEngagement.Attack, rt.CurrentStateName);
+ ctx.C.Running = null; // 招式打完
+ rt.Tick(0.1f);
+ Assert.AreEqual(ApproachAttackEngagement.Approach, rt.CurrentStateName);
+ }
+
+ [Test]
+ public void Approach_ExitsToRest_WhenAllZonesLost()
+ {
+ var ctx = new FakeAiContext();
+ ctx.S.Chase = true;
+ var rt = RunAlone(ctx);
+ ctx.S.Chase = false; ctx.S.Vision = false;
+ rt.Tick(0.1f);
+ Assert.AreEqual(Rest, rt.CurrentStateName);
+ }
+
+ // 有意的行为变更:攻击起手后打完,不中途收招
+ [Test]
+ public void Attack_CompletesEvenWhenPlayerLeavesAllZones()
+ {
+ var ctx = new FakeAiContext();
+ ctx.S.Chase = true; ctx.C.Eligible = true;
+ var rt = RunAlone(ctx);
+ rt.Tick(0.1f);
+ Assert.AreEqual(ApproachAttackEngagement.Attack, rt.CurrentStateName);
+
+ ctx.S.Chase = false; ctx.S.Vision = false; // 前摇中玩家跑掉
+ rt.Tick(0.1f);
+ Assert.AreEqual(ApproachAttackEngagement.Attack, rt.CurrentStateName); // 不中途收招
+
+ ctx.C.Running = null; // 招式打完
+ rt.Tick(0.1f);
+ Assert.AreEqual(ApproachAttackEngagement.Approach, rt.CurrentStateName);
+ rt.Tick(0.1f);
+ Assert.AreEqual(Rest, rt.CurrentStateName); // 再经 Approach 自然脱战
+ }
+ }
+}
diff --git a/Assets/Tests/EditMode/AI/ApproachAttackEngagementTests.cs.meta b/Assets/Tests/EditMode/AI/ApproachAttackEngagementTests.cs.meta
new file mode 100644
index 00000000..d9bc15fd
--- /dev/null
+++ b/Assets/Tests/EditMode/AI/ApproachAttackEngagementTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 3fb4b91d0983bf14cbc12e6a509381c9
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/ApproachAttackEngagement.cs b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/ApproachAttackEngagement.cs
new file mode 100644
index 00000000..8472fc5a
--- /dev/null
+++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/ApproachAttackEngagement.cs
@@ -0,0 +1,42 @@
+using System;
+using BaseGames.AI;
+
+namespace BaseGames.Enemies
+{
+ ///
+ /// 寻路逼近 ↔ 到射程选招攻击。招式的射程 / 冷却 / 权重由 EnemyAttackSelector 负责,
+ /// 本模块只决定"什么时候该逼近、什么时候该出手"。
+ ///
+ [Serializable]
+ public sealed class ApproachAttackEngagement : IEngagementModule
+ {
+ public const string Approach = "Approach";
+ public const string Attack = "Attack";
+
+ static readonly Func InChaseZone = x => x.Sensor.InChaseZone();
+
+ public string EntryState => Approach;
+ public Func CanEngage => InChaseZone;
+ public string EngageLabel => "InChaseZone";
+
+ public void Build(BrainBuilder b, string rest)
+ {
+ b.DeclareState(Approach)
+ .OnEnter(x => x.Locomotion.Pursue(x.Sensor.LastKnown))
+ .Tick (x => x.Locomotion.Pursue(x.Sensor.LastKnown))
+ .OnExit(x => x.Locomotion.Stop())
+ // 脱战边放在前面:脱战判定优先于是否有合格攻击,避免"已经脱离全部感知区,
+ // 却因为攻击选择器还没来得及清掉合格标记"而卡在交战里出不去。
+ .To(rest) .When(AiStateFragments.LostAllZones, "leftAllZones")
+ .To(Attack).When(x => x.Combat.HasEligibleAttack(), "attackInRange");
+
+ // 攻击一旦起手就打完:刻意不挂 leftAllZones 边。
+ // 玩家在前摇中跑出感知区时招式仍完整打完,之后经 Approach 自然脱战——
+ // 只多一帧,且消除了"起手到一半凭空收招"的观感缺陷。
+ b.DeclareState(Attack)
+ .OnEnter(x => { x.Locomotion.Stop(); x.Combat.UseBestAttack(); })
+ .OnExit (x => x.Combat.InterruptAbilities())
+ .To(Approach).When(x => !x.Combat.IsAbilityRunning(), "attackDone");
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/ApproachAttackEngagement.cs.meta b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/ApproachAttackEngagement.cs.meta
new file mode 100644
index 00000000..a10e1906
--- /dev/null
+++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/ApproachAttackEngagement.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 59bb25c040c4705419fdd7516909acba
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant: