feat(enemy): PerceptionSkeleton——两层可插拔的感知骨架,自声明死亡终态

This commit is contained in:
2026-07-29 14:10:46 +08:00
parent faa210c789
commit bf8101b3f5
4 changed files with 325 additions and 0 deletions
@@ -0,0 +1,220 @@
using System.Collections.Generic;
using NUnit.Framework;
using BaseGames.AI;
using BaseGames.Enemies;
namespace BaseGames.Tests.EditMode.AI
{
public class PerceptionSkeletonTests
{
sealed class Ctx : IAiContext, IEnemyActor
{
public FakeSensor S = new FakeSensor();
public FakeLocomotion L = new FakeLocomotion();
public FakeCombat C = new FakeCombat();
public FakeVitals V = new FakeVitals();
public Blackboard BB = new Blackboard();
public bool HasAlert = true;
public ISensor Sensor => S;
public IEnemyLocomotion Locomotion => L;
public ICombatant Combat => C;
public IActorVitals Vitals => V;
public Blackboard Blackboard => BB;
public bool HasAlertState => HasAlert;
}
static AiRuntime Build(Ctx ctx, IUnawareModule unaware = null, IEngagementModule engagement = null)
{
var b = new BrainBuilder();
PerceptionSkeleton.Add(b,
unaware ?? new SinglePost(LocomotionMode.Patrol),
engagement ?? new RushEngagement("rush", RushExit.OnLostTarget));
return new AiRuntime(b.Build(), ctx);
}
// ── 升级 ──────────────────────────────────────────────────────────
[Test]
public void Unaware_ToEngagement_WhenInChaseZone()
{
var ctx = new Ctx();
var rt = Build(ctx);
ctx.S.Chase = true;
rt.Tick(0.1f);
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
}
[Test]
public void Unaware_ToAlert_WhenInVision_AndHasAlert()
{
var ctx = new Ctx { HasAlert = true };
var rt = Build(ctx);
ctx.S.Vision = true;
rt.Tick(0.1f);
Assert.AreEqual(PerceptionSkeleton.Alert, rt.CurrentStateName);
}
[Test]
public void Unaware_StaysUnaware_WhenInVision_ButNoAlertState()
{
var ctx = new Ctx { HasAlert = false };
var rt = Build(ctx);
ctx.S.Vision = true;
rt.Tick(0.1f);
Assert.AreEqual(SinglePost.Post, rt.CurrentStateName);
}
[Test]
public void ChaseZone_BeatsAlert_WhenBothActive()
{
var ctx = new Ctx { HasAlert = true };
var rt = Build(ctx);
ctx.S.Chase = true; ctx.S.Vision = true;
rt.Tick(0.1f);
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
}
// 升级边必须优先于未发现层内部计时边
[Test]
public void UpgradeEdge_BeatsUnawareInternalDwellEdge()
{
var ctx = new Ctx();
var rt = Build(ctx, new AlternatingIdlePatrol(idleDwell: 1f, patrolDwell: 1f));
Assert.AreEqual(AlternatingIdlePatrol.Idle, rt.CurrentStateName);
ctx.S.Chase = true;
rt.Tick(5f); // 计时早已到点,但追击边优先
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
}
// ── 警觉 ──────────────────────────────────────────────────────────
[Test]
public void Alert_ToEngagement_WhenEnteringChaseZone()
{
var ctx = new Ctx();
var rt = Build(ctx);
ctx.S.Vision = true; rt.Tick(0.1f);
Assert.AreEqual(PerceptionSkeleton.Alert, rt.CurrentStateName);
ctx.S.Chase = true; rt.Tick(0.1f);
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
}
[Test]
public void Alert_ToRest_WhenLeavingVision()
{
var ctx = new Ctx();
var rt = Build(ctx);
ctx.S.Vision = true; rt.Tick(0.1f);
ctx.S.Vision = false; rt.Tick(0.1f);
Assert.AreEqual(SinglePost.Post, rt.CurrentStateName);
}
[Test]
public void Alert_FacesLastKnown()
{
var ctx = new Ctx();
ctx.S.Last = new UnityEngine.Vector2(7f, 2f);
var rt = Build(ctx);
ctx.S.Vision = true; rt.Tick(0.1f);
Assert.AreEqual(new UnityEngine.Vector2(7f, 2f), ctx.L.FacedAt);
}
// ── 降级:追击后永不回警觉 ─────────────────────────────────────────
[Test]
public void AfterEngagement_GoesToRest_NeverBackToAlert()
{
var ctx = new Ctx();
var rt = Build(ctx);
ctx.S.Chase = true; rt.Tick(0.1f);
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
ctx.S.Chase = false; ctx.S.Vision = false; rt.Tick(0.1f);
Assert.AreEqual(SinglePost.Post, rt.CurrentStateName); // 不是 Alert
}
// ── 死亡 ──────────────────────────────────────────────────────────
[Test]
public void Died_TransitionsToDeath_FromAnyState()
{
var ctx = new Ctx();
var rt = Build(ctx);
ctx.S.Chase = true; rt.Tick(0.1f);
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
rt.Send(AiSignal.Died);
rt.Tick(0.1f);
Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName);
}
// 真实死亡路径:PerformDeath 先 ForceState(Dead) 再发 Died
// 所以信号被处理时 IsControllable 已为假。事件边必须仍能通过。
[Test]
public void Died_WorksEvenWhenNotControllable()
{
var ctx = new Ctx();
var rt = Build(ctx);
ctx.V.Controllable = false; // 已是 Dead 物理态
rt.Send(AiSignal.Died);
rt.Tick(0.1f);
Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName);
}
[Test]
public void Death_IsTerminal_NoConditionEdgesEvaluatedAfterwards()
{
var ctx = new Ctx();
var rt = Build(ctx);
rt.Send(AiSignal.Died);
rt.Tick(0.1f);
ctx.V.Controllable = false; // 死后不可控
ctx.S.Chase = true; // 即便玩家又进追逐区
for (int i = 0; i < 10; i++) rt.Tick(0.1f);
Assert.AreEqual(PerceptionSkeleton.Death, rt.CurrentStateName); // 也不复活
}
// ── 校验 ──────────────────────────────────────────────────────────
sealed class BadUnaware : IUnawareModule
{
public string Entry => "X";
public string Rest => "NotInStates";
public IReadOnlyList<string> States => new[] { "X" };
public void Declare(BrainBuilder b) => AiStateFragments.Locomotion(b, "X", LocomotionMode.Idle);
public void Link(BrainBuilder b) { }
}
sealed class UndeclaredUnaware : IUnawareModule
{
public string Entry => "Y";
public string Rest => "Y";
public IReadOnlyList<string> States => new[] { "Y" };
public void Declare(BrainBuilder b) { } // 忘了声明行为
public void Link(BrainBuilder b) { }
}
[Test]
public void Throws_WhenRestNotInStates()
{
var b = new BrainBuilder();
Assert.Throws<System.InvalidOperationException>(() => PerceptionSkeleton.Add(
b, new BadUnaware(), new RushEngagement("rush")));
}
[Test]
public void Throws_WhenUnawareStateNotDeclared()
{
var b = new BrainBuilder();
Assert.Throws<System.InvalidOperationException>(() => PerceptionSkeleton.Add(
b, new UndeclaredUnaware(), new RushEngagement("rush")));
}
[Test]
public void Throws_WhenModuleIsNull()
{
var b = new BrainBuilder();
Assert.Throws<System.ArgumentNullException>(() => PerceptionSkeleton.Add(
b, null, new RushEngagement("rush")));
}
}
}