diff --git a/Assets/Tests/EditMode/AI/ChaoFengAiTests.cs b/Assets/Tests/EditMode/AI/ChaoFengAiTests.cs
new file mode 100644
index 00000000..c4ac848e
--- /dev/null
+++ b/Assets/Tests/EditMode/AI/ChaoFengAiTests.cs
@@ -0,0 +1,171 @@
+using NUnit.Framework;
+using BaseGames.AI;
+using BaseGames.Enemies;
+
+namespace BaseGames.Tests.EditMode.AI
+{
+ ///
+ /// 嘲风定制图的状态序列断言。
+ ///
+ /// 注意 只入队信号,事件转换在下一次 Tick 的第一步出队处理,
+ /// 因此每个 Send 之后都必须跟一次 Tick 才会真正换态(不是"发了就到")。
+ ///
+ public class ChaoFengAiTests
+ {
+ static AiRuntime Run(FakeAiContext ctx)
+ => new AiRuntime(new ChaoFengAi().GetOrBuildGraph(), ctx);
+
+ static FakeAiContext Ctx()
+ {
+ var c = new FakeAiContext();
+ c.S.Chase = true; // 竞技场内玩家恒在追逐区
+ c.S.Vision = true;
+ return c;
+ }
+
+ /// 推进到地面阶段:开战 → 入场演出播完 → 地面逼近态。
+ static AiRuntime RunToGround(FakeAiContext ctx)
+ {
+ var rt = Run(ctx);
+ rt.Send(AiSignal.Engaged);
+ rt.Tick(0.1f); // 事件出队 → Intro
+ ctx.C.Running = null; // 入场演出播完
+ rt.Tick(0.1f); // introDone → Ground
+ return rt;
+ }
+
+ /// 再推进到空中阶段:血量过半 → 阶段过渡 → 过渡结束。
+ static void AdvanceToAirPhase(FakeAiContext ctx, AiRuntime rt)
+ {
+ ctx.V.Hp = 0.4f;
+ rt.Tick(0.1f); // hp<50% → PhaseTx
+ ctx.B.Transitioning = false; // 过渡结束
+ rt.Tick(0.1f); // txDone → Air
+ }
+
+ [Test]
+ public void StartsInWait_UntilEngaged()
+ {
+ var ctx = Ctx();
+ var rt = Run(ctx);
+
+ Assert.AreEqual(ChaoFengAi.Wait, rt.CurrentStateName);
+ rt.Tick(0.1f);
+ Assert.AreEqual(ChaoFengAi.Wait, rt.CurrentStateName, "未开战前不应自行进入战斗");
+ }
+
+ [Test]
+ public void Engaged_EntersIntro_ThenGround()
+ {
+ var ctx = Ctx();
+ var rt = Run(ctx);
+
+ rt.Send(AiSignal.Engaged);
+ rt.Tick(0.1f);
+ Assert.AreEqual(ChaoFengAi.Intro, rt.CurrentStateName);
+ CollectionAssert.Contains(ctx.C.Used, ChaoFengAi.IntroAbility);
+
+ rt.Tick(0.1f);
+ Assert.AreEqual(ChaoFengAi.Intro, rt.CurrentStateName, "入场演出未播完不应进战");
+
+ ctx.C.Running = null; // 入场演出播完
+ rt.Tick(0.1f);
+ Assert.AreEqual(ChaoFengAi.Ground, rt.CurrentStateName);
+ }
+
+ [Test]
+ public void Ground_ApproachAttackLoop()
+ {
+ var ctx = Ctx();
+ var rt = RunToGround(ctx);
+ Assert.AreEqual(ChaoFengAi.Ground, rt.CurrentStateName);
+
+ ctx.C.Eligible = true;
+ rt.Tick(0.1f);
+ Assert.AreEqual(ChaoFengAi.GroundAttack, rt.CurrentStateName);
+ CollectionAssert.Contains(ctx.C.Used, "best");
+
+ ctx.C.Running = null; // 招式打完
+ rt.Tick(0.1f);
+ Assert.AreEqual(ChaoFengAi.Ground, rt.CurrentStateName);
+ }
+
+ [Test]
+ public void Ground_DoesNotDisengage_WhenPlayerLeavesAllZones()
+ {
+ // Boss 不脱战:逼近态的 rest 指回自身,脱离感知区也留在地面阶段。
+ var ctx = Ctx();
+ var rt = RunToGround(ctx);
+
+ ctx.S.Chase = false; ctx.S.Vision = false;
+ for (int i = 0; i < 5; i++) rt.Tick(0.1f);
+ Assert.AreEqual(ChaoFengAi.Ground, rt.CurrentStateName, "Boss 不应脱战");
+ }
+
+ [Test]
+ public void HpBelowHalf_TransitionsToAirPhase()
+ {
+ var ctx = Ctx();
+ var rt = RunToGround(ctx);
+
+ ctx.V.Hp = 0.4f;
+ rt.Tick(0.1f);
+ Assert.AreEqual(ChaoFengAi.PhaseTx, rt.CurrentStateName);
+ Assert.AreEqual(1, ctx.B.BeginCallCount, "阶段过渡只应发起一次");
+ Assert.AreEqual(1, ctx.B.LastTargetPhase);
+ Assert.Greater(ctx.B.LastDuration, 0f, "过渡期必须有正的无敌时长");
+
+ rt.Tick(0.1f);
+ Assert.AreEqual(ChaoFengAi.PhaseTx, rt.CurrentStateName, "过渡期间不得提前转出");
+ Assert.AreEqual(1, ctx.B.BeginCallCount, "过渡期间不得重复发起");
+
+ ctx.B.Transitioning = false; // 过渡结束
+ rt.Tick(0.1f);
+ Assert.AreEqual(ChaoFengAi.Air, rt.CurrentStateName,
+ "过渡结束必须有出边转出——过渡态无出边会把 Boss 永久锁死且零报错");
+ }
+
+ [Test]
+ public void Air_HoverAttackLoop()
+ {
+ var ctx = Ctx();
+ var rt = RunToGround(ctx);
+ AdvanceToAirPhase(ctx, rt);
+ Assert.AreEqual(ChaoFengAi.Air, rt.CurrentStateName);
+
+ ctx.C.Eligible = true;
+ rt.Tick(0.1f);
+ Assert.AreEqual(ChaoFengAi.AirAttack, rt.CurrentStateName);
+ CollectionAssert.Contains(ctx.C.Used, "best");
+
+ ctx.C.Running = null;
+ rt.Tick(0.1f);
+ Assert.AreEqual(ChaoFengAi.Air, rt.CurrentStateName);
+ }
+
+ [Test]
+ public void Died_GoesToDeathFromAnyState()
+ {
+ var ctx = Ctx();
+ var rt = RunToGround(ctx);
+
+ rt.Send(AiSignal.Died);
+ rt.Tick(0.1f);
+ Assert.AreEqual(ChaoFengAi.Death, rt.CurrentStateName);
+ }
+
+ [Test]
+ public void AirPhase_DoesNotFallBackToGround()
+ {
+ // 阶段单向推进:进了空中阶段就不再回地面阶段(招池已换)
+ var ctx = Ctx();
+ var rt = RunToGround(ctx);
+ AdvanceToAirPhase(ctx, rt);
+
+ ctx.V.Hp = 0.9f; // 即使血量回升
+ for (int i = 0; i < 5; i++) rt.Tick(0.1f);
+ Assert.AreEqual(ChaoFengAi.Air, rt.CurrentStateName);
+ Assert.AreEqual(1, ctx.B.BeginCallCount, "不应再次发起阶段过渡");
+ }
+ }
+}
diff --git a/Assets/Tests/EditMode/AI/ChaoFengAiTests.cs.meta b/Assets/Tests/EditMode/AI/ChaoFengAiTests.cs.meta
new file mode 100644
index 00000000..fe7d973e
--- /dev/null
+++ b/Assets/Tests/EditMode/AI/ChaoFengAiTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: ca3207eb8b4a0ca4f9e57bf3c12aa95c
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Ai.meta b/Assets/_Game/Scripts/Enemies/AIBrain/Ai.meta
new file mode 100644
index 00000000..93649e8b
--- /dev/null
+++ b/Assets/_Game/Scripts/Enemies/AIBrain/Ai.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: d0934d78d9f1fe34aa6c0429a0822df8
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Ai/ChaoFengAi.cs b/Assets/_Game/Scripts/Enemies/AIBrain/Ai/ChaoFengAi.cs
new file mode 100644
index 00000000..9436e89a
--- /dev/null
+++ b/Assets/_Game/Scripts/Enemies/AIBrain/Ai/ChaoFengAi.cs
@@ -0,0 +1,80 @@
+using BaseGames.AI;
+
+namespace BaseGames.Enemies
+{
+ ///
+ /// 嘲风的 AI 图(定制路径:Boss 数量少、彼此差异大,手写图而非配方)。
+ ///
+ /// 阶段 0(地面):逼近 ↔ 选招。地面招由 BossPhaseAbilityGate 在阶段 0 放行。
+ /// 阶段 1(空中):悬停 ↔ 选招。空中招由阶段门在阶段 1 放行,地面招被禁用。
+ ///
+ /// 招式的射程 / 冷却 / 权重全归各能力自己的 EnemyAbilitySO;本图只决定
+ /// 「什么时候该逼近、什么时候该出手、什么时候该换阶段」。
+ /// 死亡不在本图:EnemyBase 的死亡流程发 Died 事件,全局边把决策停在 Death 终态。
+ ///
+ /// 三条刻意的形状,改图时别顺手抹掉:
+ /// 1. Boss 不脱战——逼近态的 rest 指回自身,脱离感知区也不回静置态。
+ /// 2. 阶段单向推进——空中阶段没有回地面阶段的边,血量回升也不退阶段(招池已换)。
+ /// 3. 起手就打完——攻击态不挂脱战边,前摇中玩家跑开仍完整打完。
+ ///
+ [AiDefinition("ChaoFeng")]
+ public sealed class ChaoFengAi : AiScript
+ {
+ public const string Wait = "Wait";
+ public const string Intro = "Intro";
+ public const string Ground = "Ground";
+ public const string GroundAttack = "GroundAttack";
+ public const string PhaseTx = "PhaseTransition";
+ public const string Air = "Air";
+ public const string AirAttack = "AirAttack";
+ public const string Death = "Death";
+
+ /// 入场演出能力 id(对应入场能力资产的 abilityId)。
+ public const string IntroAbility = "chaofeng_intro";
+
+ /// 空中阶段的血量阈值。
+ private const float AirPhaseHpRatio = 0.5f;
+
+ /// 阶段过渡的目标阶段索引。
+ private const int AirPhaseIndex = 1;
+
+ /// 阶段过渡无敌时长,须 ≥ 浮空上升时长 + 缓冲。
+ private const float PhaseTxInvincible = 2f;
+
+ protected override void Build(BrainBuilder b)
+ {
+ b.Entry(Wait);
+
+ // 竞技场触发前静置:不巡逻、不警觉(Boss 不搜敌,等玩家进场)
+ AiStateFragments.Locomotion(b, Wait, LocomotionMode.Idle)
+ .To(Intro).OnEvent(AiSignal.Engaged);
+
+ // 入场演出:一次性能力,播完进战
+ AiStateFragments.AbilityOnce(b, Intro, IntroAbility)
+ .To(Ground).When(x => !x.Combat.IsAbilityRunning(IntroAbility), "introDone");
+
+ // 阶段 0(地面):逼近 ↔ 选招。rest 指回自身——Boss 不脱战。
+ BossFragments.ApproachAttack(b, Ground, GroundAttack, rest: Ground)
+ .To(PhaseTx).When(x => x.Vitals.HpBelow(AirPhaseHpRatio), "hp<50%");
+
+ // 阶段过渡:无敌 + 浮空演出由 Boss 侧的阶段过渡回调承担。
+ // PhaseTransition 片段刻意不带出边——这条 txDone 边就是它的出口,删了会把 Boss 永久锁死。
+ BossFragments.PhaseTransition(b, PhaseTx, AirPhaseIndex, PhaseTxInvincible)
+ .To(Air).When(x => !x.Boss.IsPhaseTransitioning, "txDone");
+
+ // 阶段 1(空中):悬停朝向玩家 ↔ 选招。刻意不挂回地面阶段的边(阶段单向推进)。
+ AiStateFragments.Locomotion(b, Air, LocomotionMode.Face)
+ .To(AirAttack).When(x => x.Combat.HasEligibleAttack(), "attackInRange");
+
+ // 与地面攻击态同语义:起手就打完,不挂脱战边
+ b.DeclareState(AirAttack)
+ .OnEnter(x => { x.Locomotion.Stop(); x.Combat.UseBestAttack(); })
+ .OnExit (x => x.Combat.InterruptAbilities())
+ .To(Air).When(x => !x.Combat.IsAbilityRunning(), "attackDone");
+
+ // 死亡终态:演出走物理层的击败序列,图上只需停止决策
+ AiStateFragments.Terminal(b, Death);
+ b.Global().To(Death).OnEvent(AiSignal.Died);
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Ai/ChaoFengAi.cs.meta b/Assets/_Game/Scripts/Enemies/AIBrain/Ai/ChaoFengAi.cs.meta
new file mode 100644
index 00000000..94c05b8d
--- /dev/null
+++ b/Assets/_Game/Scripts/Enemies/AIBrain/Ai/ChaoFengAi.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 9df917389cefced4692c7e56ed9bd6e3
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant: