feat(enemy): PerceptionSkeleton——两层可插拔的感知骨架,自声明死亡终态
This commit is contained in:
@@ -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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e25626c96226d9a489b332e79cd933d6
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using BaseGames.AI;
|
||||||
|
|
||||||
|
namespace BaseGames.Enemies
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 感知骨架:把「未发现 → 警觉 → 交战」的升降级规则,挂到两个模块声明的态上。
|
||||||
|
/// 骨架永远只做这一件事——未发现态长什么样、交战怎么打,全部归模块。
|
||||||
|
///
|
||||||
|
/// 规则:
|
||||||
|
/// 未发现:在追逐区 → 交战(优先);否则 有警觉态且在视野 → 警觉;否则保持。
|
||||||
|
/// 警觉: 在追逐区 → 交战;否则 脱离视野 → Rest;否则保持(朝向最后已知位置)。
|
||||||
|
/// 交战: 脱战条件由交战模块自定,脱战一律回 Rest —— 永不回警觉。
|
||||||
|
/// 死亡: 全局事件边,优先级最高,且不受 IsControllable 门阻挡。
|
||||||
|
///
|
||||||
|
/// 死亡态由骨架自己声明为无行为终态,不做成可插拔层——死亡在本项目里完全归物理层
|
||||||
|
/// (EnemyBase.PerformDeath 切终态/关碰撞/播死亡动画/归还池;EnemyDeathSequence 做
|
||||||
|
/// 死亡前摇演出并可经动画事件生成小怪)。且 PerformDeath 先 ForceState(Dead) 再发
|
||||||
|
/// Died 信号,此后 IsControllable 永久为假、AiRuntime 不再求值任何条件边,
|
||||||
|
/// 所以死亡链里放条件转换必然卡住。AI 图需要这个态只是为了让全局边有去处、决策停止。
|
||||||
|
///
|
||||||
|
/// 入口默认取 unaware.Entry;需要前置态(掉落链 / 出场链)的敌人在调用本方法后
|
||||||
|
/// 再调 b.Entry("自己的态") 覆盖即可。
|
||||||
|
/// </summary>
|
||||||
|
public static class PerceptionSkeleton
|
||||||
|
{
|
||||||
|
public const string Alert = "Alert";
|
||||||
|
public const string Death = "Death";
|
||||||
|
|
||||||
|
static readonly Func<IAiContext, bool> HasAlertAndInVision =
|
||||||
|
x => x is IEnemyActor a && a.HasAlertState && x.Sensor.InVisionZone();
|
||||||
|
|
||||||
|
static readonly Func<IAiContext, bool> LeftVision = x => !x.Sensor.InVisionZone();
|
||||||
|
|
||||||
|
public static void Add(BrainBuilder b,
|
||||||
|
IUnawareModule unaware,
|
||||||
|
IEngagementModule engagement)
|
||||||
|
{
|
||||||
|
if (b == null) throw new ArgumentNullException(nameof(b));
|
||||||
|
if (unaware == null) throw new ArgumentNullException(nameof(unaware));
|
||||||
|
if (engagement == null) throw new ArgumentNullException(nameof(engagement));
|
||||||
|
|
||||||
|
// 1) 各模块先声明自己的态(未发现层此阶段只声明态,不挂内部边)
|
||||||
|
unaware.Declare(b);
|
||||||
|
engagement.Build(b, unaware.Rest);
|
||||||
|
AiStateFragments.Terminal(b, Death);
|
||||||
|
|
||||||
|
var states = unaware.States;
|
||||||
|
if (states == null || states.Count == 0)
|
||||||
|
throw new InvalidOperationException($"{unaware.GetType().Name}: States 不能为空。");
|
||||||
|
for (int i = 0; i < states.Count; i++)
|
||||||
|
b.RequireState(states[i]); // Declare 漏声明的态在此暴露
|
||||||
|
if (!Contains(states, unaware.Rest))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"{unaware.GetType().Name}: Rest='{unaware.Rest}' 必须属于 States。");
|
||||||
|
|
||||||
|
b.Entry(unaware.Entry);
|
||||||
|
b.Global().To(Death).OnEvent(AiSignal.Died);
|
||||||
|
|
||||||
|
// 2) 升级边先挂——AiRuntime 按声明顺序评估条件边,升级必须优先于
|
||||||
|
// 未发现层的内部边(如巡逻计时),否则玩家进追逐区那一帧可能被计时边抢走。
|
||||||
|
for (int i = 0; i < states.Count; i++)
|
||||||
|
b.State(states[i])
|
||||||
|
.To(engagement.EntryState).When(engagement.CanEngage, engagement.EngageLabel)
|
||||||
|
.To(Alert).When(HasAlertAndInVision, "InVision+HasAlert");
|
||||||
|
|
||||||
|
AiStateFragments.Locomotion(b, Alert, LocomotionMode.Face)
|
||||||
|
.To(engagement.EntryState).When(engagement.CanEngage, engagement.EngageLabel)
|
||||||
|
.To(unaware.Rest).When(LeftVision, "leftVision");
|
||||||
|
|
||||||
|
// 3) 未发现层内部边最后挂,优先级低于升级边
|
||||||
|
unaware.Link(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool Contains(IReadOnlyList<string> list, string value)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < list.Count; i++)
|
||||||
|
if (list[i] == value) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 105259e9e0091134fb5afb8c0648ef45
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
Reference in New Issue
Block a user