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: