feat(enemy): 嘲风 AI 图(BrainGraph 定制路径)
Wait →(Engaged) Intro → Ground⇄GroundAttack →(hp<50%) PhaseTx → Air⇄AirAttack。 Boss 不脱战(rest 指回自身)、阶段单向推进、起手就打完。 死亡不在图上:全局 Died 边进 Death 终态,演出归物理层。 8 条 EditMode 断言覆盖状态序列、过渡只发起一次、过渡结束必须转出、 脱离感知区不脱战、血量回升不回退阶段。
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
using NUnit.Framework;
|
||||
using BaseGames.AI;
|
||||
using BaseGames.Enemies;
|
||||
|
||||
namespace BaseGames.Tests.EditMode.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// 嘲风定制图的状态序列断言。
|
||||
///
|
||||
/// 注意 <see cref="AiRuntime.Send"/> 只入队信号,事件转换在下一次 Tick 的第一步出队处理,
|
||||
/// 因此每个 Send 之后都必须跟一次 Tick 才会真正换态(不是"发了就到")。
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>推进到地面阶段:开战 → 入场演出播完 → 地面逼近态。</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>再推进到空中阶段:血量过半 → 阶段过渡 → 过渡结束。</summary>
|
||||
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, "不应再次发起阶段过渡");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ca3207eb8b4a0ca4f9e57bf3c12aa95c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user