上一篇验证的是关卡静态上能不能通 ------纯几何,跑都不用跑。这篇走到运行时,问一个不一样的问题:敌人的行为对不对。
需求一句话:
做一个敌人:平时在两点之间巡逻,发现玩家就追,玩家跑远了就放弃、返回巡逻。并且要能验证这套状态切换是对的。
"做一个会追人的敌人"不难,难的是后半句------怎么证明状态机没写错 。看它在编辑器里跑起来"感觉对"是不够的:状态机的 bug 往往藏在边界(玩家刚好在检测圈边缘反复横跳)、藏在某条少见的转换路径上。这篇延续前两篇的一贯做法:把"行为正确"变成机器能断言的东西。
状态机本体
三个状态:Patrol(巡逻)、Chase(追击)、Return(返回)。转换规则:
- Patrol:在两个航点间来回;玩家进入检测半径 → Chase
- Chase:朝玩家移动;玩家超出丢失半径 → Return
- Return:回最近的航点;到了 → Patrol;半路又撞见玩家 → Chase
一个关键细节:检测半径(5)< 丢失半径(8),故意不相等。这叫滞回(hysteresis)。如果只有一个半径,玩家站在圈边缘抖一下,敌人就会 Chase↔Patrol 疯狂横跳。两个半径拉开一段,进入和退出用不同阈值,状态就稳了。这是状态机的经典坑,值得专门处理。
csharp
public class EnemyFSM : MonoBehaviour
{
public enum State { Patrol, Chase, Return }
public Transform player;
public Transform[] waypoints;
public float moveSpeed = 3f;
public float detectRadius = 5f; // 进圈 → 追
public float loseRadius = 8f; // 出圈 → 放弃(滞回,比 detect 大)
public State Current { get; private set; } = State.Patrol;
public List<string> Transitions = new(); // 记录每次切换,给验证用
int _wp;
void SetState(State s)
{
if (s == Current) return;
Transitions.Add($"{Current}->{s}"); // 关键:把切换记下来
Current = s;
}
void Update()
{
float dp = PlanarDist(transform.position, player.position);
switch (Current)
{
case State.Patrol:
MoveTo(waypoints[_wp].position);
if (Arrived(waypoints[_wp])) _wp = (_wp + 1) % waypoints.Length;
if (dp <= detectRadius) SetState(State.Chase);
break;
case State.Chase:
MoveTo(player.position);
if (dp >= loseRadius) SetState(State.Return);
break;
case State.Return:
_wp = NearestWaypoint();
MoveTo(waypoints[_wp].position);
if (Arrived(waypoints[_wp])) SetState(State.Patrol);
if (dp <= detectRadius) SetState(State.Chase);
break;
}
}
}
注意 SetState 里每次切换都往 Transitions 里记一笔------这就是后面验证的抓手。
怎么验证它对:脚本化场景 + 序列比对
这是这篇的核心。验证不靠"看",靠一个脚本化的剧本:让玩家从远处靠近 → 停留 → 跑远 ,全程录下敌人实际发生的状态切换,最后和预期序列逐一比对。
预期很明确:
Patrol -> Chase (玩家靠近,进检测圈)
Chase -> Return (玩家跑远,出丢失圈)
Return -> Patrol (敌人回到航点)
测试器脚本化这个剧本:
csharp
public class EnemyFSMTest : MonoBehaviour
{
public EnemyFSM enemy;
public Transform player;
Vector3 farPos = new(20, 0.5f, 0), nearPos = new(0, 0.5f, 4);
float approachAt = 1.0f, retreatAt = 3.5f, endAt = 8.0f;
public string verdict = "not run";
static readonly string[] Expected =
{ "Patrol->Chase", "Chase->Return", "Return->Patrol" };
public void RunTest()
{
enemy.Transitions.Clear();
player.position = farPos; // 开局玩家在远处
_t0 = Time.time; _running = true;
}
void Update()
{
if (!_running) return;
float t = Time.time - _t0;
if (!_approached && t >= approachAt) { player.position = nearPos; _approached = true; }
if (!_retreated && t >= retreatAt) { player.position = farPos; _retreated = true; }
if (t >= endAt) { _running = false; verdict = Compare(enemy.Transitions, Expected); }
}
static string Compare(List<string> got, string[] exp)
{
if (got.Count != exp.Length)
return $"FAIL --- 期望{exp.Length}次, 实际{got.Count}次: [{string.Join(", ", got)}]";
for (int i = 0; i < exp.Length; i++)
if (got[i] != exp[i]) return $"FAIL --- 第{i}次 期望{exp[i]} 实际{got[i]}";
return $"PASS --- 状态序列正确: [{string.Join(", ", got)}]";
}
}
剧本只是"在固定时刻把玩家瞬移到远/近",敌人的状态切换是它自己根据规则产生的------测试只负责制造情境 和核对结果。
跑起来
PlayMode 里调 RunTest(),跑完读 verdict:
verdict = PASS --- 状态序列正确: [Patrol->Chase, Chase->Return, Return->Patrol]
实际录到的切换序列和预期一字不差。
为了让状态肉眼可见,敌人按状态变色(巡逻=青、追击=红、返回=橙),地面画出检测圈(黄,半径 5)和丢失圈(红,半径 8)。
巡逻态------敌人青色,在两个航点间来回,玩家在圈外(画面外):

追击态------玩家(橙)进了黄色检测圈,敌人立刻转红、朝玩家移动:

整个过程:AI 写了状态机、写了带滞回的转换规则、写了一个脚本化场景把行为正确性变成可断言的序列比对,然后自己跑出 PASS。和前两篇是同一套方法论------别让"感觉对"蒙混过关,用客观信号断言。
一脉相承的方法
回头看这个系列的验证思路,三篇是同一个模子:
- 调跳跃手感:采样轨迹 → 断言峰值 / 上升下降比
- 程序化关卡:用玩家物理 → 断言每个 gap 可达
- 敌人状态机:脚本化情境 → 断言状态转换序列
共同点是:把"对不对"翻译成一个机器能跑、能比对的信号。AI 擅长这个------它能同时写出"被测的东西"和"验证它的东西",还能保证两者用同一套规则。手感、可达、行为,被测对象变了,方法没变。
诚实地讲:验证了什么,没验证什么
这套验证有明确边界:
- 只测了一个理想剧本。玩家"直线靠近→停留→直线跑远"是我喂给它的情境。真实玩家的走位千奇百怪,要更有信心得喂更多剧本(贴边横跳、绕圈、瞬间反复进出------尤其要测滞回到底防没防住抖动)。
- 没有视线遮挡(LOS)。现在是纯距离检测,隔着墙也能"看见"玩家。真实敌人 AI 要加 Raycast 视线判定,那又是一组新的转换和新的验证。
- 移动是直线 MoveTowards,不绕障碍。真要能用得接导航网格(NavMesh)寻路。
- 行为"对" ≠ AI "聪明 / 好玩"。状态序列正确只是不出 bug 的底线。敌人追得笨不笨、有没有威胁感、节奏好不好------那是设计和调校,验证器管不了。
- 没测多敌人 / 性能。一个敌人的状态机是玩具规模;几十个敌人同时跑、还要寻路,是另一个量级的问题。
复杂的敌人 AI 最终会走向行为树(Behavior Tree)、GOAP、效用 AI 这些更强的结构。但有限状态机 + 可断言的转换测试,是把"敌人行为"从"跑起来看着像那么回事"变成"有客观验证"的第一步,也是 AI 最容易帮你快速立起来的那一层。
完整产物
- 两个脚本:
EnemyFSM(状态机 + 滞回 + 变色/检测圈可视化)、EnemyFSMTest(脚本化场景 + 序列比对) - PlayMode 里
RunTest自动跑出 PASS - 仓库:https://github.com/FunplayAI/funplay-unity-mcp
- 上一篇:AI 生成关卡,还用游戏物理证明它能通关
下一篇换个方向:当一个 execute_code 的套路反复用,怎么把它沉淀成自己的 ToolProvider 正式工具 + 美术资源批处理------从"一次性脚本"走到"工程化工具"。