13.1 有限状态机与行为树
第 12 章我们给游戏整体写了 StateMachine,把加载、菜单、游戏、暂停、结算收进一条栈。那一节管的是游戏阶段,粒度粗、数量少、互斥清晰。这一节要面对的是另一类状态:敌人 AI。它的问题恰恰相反——粒度细、数量多、还会互相嵌套。一只追击怪就有巡逻、发现、追击、攻击、硬直、回巢这些行为,每个行为下还有「撞墙了怎么办」「目标丢了怎么办」的子判断。用第 12 章那种「一张转移表」的写法当然能跑,但很快会撞上维护的墙。本节把 FSM 和它的进化形态行为树都手写一遍,让读者看清它们各自适合什么场景。
13.1.1 敌人的状态比游戏状态碎得多
先看需求。给《方块冲刺》设计一只最基础的追击怪,行为描述是:平时在平台上左右巡逻;玩家进入视野就追;距离足够近就攻击;攻击后有硬直;玩家跑远就回巢;血量见底时优先逃跑。把它摊成一张转移图,边的数量会迅速膨胀:
// 反面教材:所有条件塞进一个 update,靠 if 堆叠
function updateEnemy(e: Enemy, dt: number): void {
if (e.hp <= 0) return;
if (e.hp < 20 && !e.fleeing) { e.fleeing = true; }
if (e.fleeing) { moveAway(e, dt); return; }
if (dist(e, player) < ATTACK_RANGE) { attack(e, dt); return; }
if (dist(e, player) < SIGHT_RANGE) { chase(e, dt); return; }
patrol(e, dt);
}
这段代码能跑,但三处隐患已经埋下。第一,return 的先后顺序就是优先级,调整一行会悄悄改变行为,改的人往往意识不到。第二,e.fleeing 这类标志会越加越多,与第 12 章开头批判的布尔量是同一个病。第三,硬直、攻击冷却这些有时限的子行为没有落脚点,只能再塞几个计时器。核心症结是:状态、转移条件、每帧行为被揉进了一个函数,无法单独替换任何一项。
13.1.2 手写 FSM:状态 / 转移 / 条件三者分离
FSM 的第一刀就是把上面三样东西拆开。状态只描述「处于该状态时每帧做什么」,条件只描述「什么时候该走」,转移表把它们串起来:
export type EnemyStateId = 'patrol' | 'chase' | 'attack' | 'stagger' | 'flee';
export interface EnemyContext {
x: number;
y: number;
hp: number;
targetX: number;
targetY: number;
sightRange: number;
attackRange: number;
timer: number;
}
export interface EnemyState {
readonly id: EnemyStateId;
onEnter?(ctx: EnemyContext): void;
onExit?(ctx: EnemyContext): void;
onUpdate?(ctx: EnemyContext, dt: number): void;
}
export type Guard = (ctx: EnemyContext) => boolean;
export interface Transition {
from: EnemyStateId;
to: EnemyStateId;
when: Guard;
/** 数字越小优先级越高;同一状态上多条转移同时成立时,取最小的 */
priority: number;
}
Guard 是一个纯函数:只读 EnemyContext,返回布尔值,不产生副作用。这条纪律非常重要——条件被反复求值(每帧都会遍历当前状态的出边),如果 when 里写了「播放音效」之类的东西,音效就会每帧响一次。判断和执行必须分开。
13.1.3 状态机核心:每帧只做三件事
有了上面三块,状态机本体不到三十行。它的每帧逻辑是:遍历当前状态的出边、按优先级找第一条成立的条件、执行带钩子的切换,最后调用新状态的 onUpdate:
export class EnemyFsm {
private states = new Map<EnemyStateId, EnemyState>();
private transitions: Transition[] = [];
private currentId: EnemyStateId | null = null;
register(state: EnemyState): void {
this.states.set(state.id, state);
}
addTransition(t: Transition): void {
this.transitions.push(t);
}
private pick(ctx: EnemyContext): Transition | null {
let best: Transition | null = null;
for (const t of this.transitions) {
if (t.from !== this.currentId) continue;
if (!t.when(ctx)) continue;
if (!best || t.priority < best.priority) best = t;
}
return best;
}
step(ctx: EnemyContext, dt: number): void {
const t = this.pick(ctx);
if (t && t.to !== this.currentId) {
const prev = this.currentId ? this.states.get(this.currentId) : undefined;
prev?.onExit?.(ctx);
this.currentId = t.to;
this.states.get(t.to)?.onEnter?.(ctx);
}
if (this.currentId) this.states.get(this.currentId)?.onUpdate?.(ctx, dt);
}
}
注意 pick 里「同一状态多条转移同时成立」的处理:用 priority 取最小的那条,而不是依赖数组顺序。这样一来,转移表就是纯粹的声明式数据,可以整段写在一个文件里,改优先级只改数字。
13.1.4 用转移表描述一只追击怪
把状态和转移都声明出来,行为就一目了然了:
const fsm = new EnemyFsm();
fsm.register({
id: 'patrol',
onEnter: (c) => { c.timer = 0; },
onUpdate: (c, dt) => { c.x += PATROL_SPEED * dt; },
});
fsm.register({
id: 'chase',
onEnter: () => { /* 播放警戒动画 */ },
onUpdate: (c, dt) => { moveToward(c, c.targetX, c.targetY, CHASE_SPEED * dt); },
});
fsm.register({
id: 'attack',
onEnter: (c) => { c.timer = ATTACK_DURATION; },
onUpdate: (c, dt) => { c.timer -= dt; },
onExit: () => { /* 结算一次伤害 */ },
});
fsm.register({
id: 'stagger',
onEnter: (c) => { c.timer = STAGGER_DURATION; },
onUpdate: (c, dt) => { c.timer -= dt; },
});
fsm.register({
id: 'flee',
onUpdate: (c, dt) => { moveAwayFrom(c, c.targetX, c.targetY, FLEE_SPEED * dt); },
});
转移表集中写成一组 addTransition,优先级用常量表达,可读性远好于 return 链:
const inRange = (r: number): Guard => (c) =>
Math.hypot(c.targetX - c.x, c.targetY - c.y) < r;
fsm.addTransition({ from: 'patrol', to: 'flee', when: (c) => c.hp < 20, priority: 0 });
fsm.addTransition({ from: 'patrol', to: 'chase', when: inRange(160), priority: 1 });
fsm.addTransition({ from: 'chase', to: 'flee', when: (c) => c.hp < 20, priority: 0 });
fsm.addTransition({ from: 'chase', to: 'attack', when: inRange(28), priority: 1 });
fsm.addTransition({ from: 'chase', to: 'patrol', when: (c) => !inRange(200)(c), priority: 2 });
fsm.addTransition({ from: 'attack', to: 'stagger', when: (c) => c.timer <= 0, priority: 0 });
fsm.addTransition({ from: 'stagger', to: 'chase', when: (c) => c.timer <= 0, priority: 0 });
每帧调用 fsm.step(ctx, dt) 即可。对照 13.1.1 的反面教材,这里的收益是三件事都能独立改:想加一个「回巢」状态,只加一个 register 和几条 addTransition,不碰其他状态;想调视野,只改 inRange(160);想改优先级,只改数字。
13.1.5 FSM 的墙:转移数量的平方增长
但 FSM 有一个绕不过去的成本。转移的数量随状态数增长得更快:如果每个状态都可能切换到其他状态,边数是 N * (N-1);即便有约束,实际项目里也会迅速逼近几十条。上面那只怪,光是「逃跑」这一个条件就复制到了 patrol 和 chase 两条边上——每加一个新状态,就得回头把「血量低就跑」这条规则再抄一遍。这种横切规则是 FSM 最痛的负担。
第二堵墙是组合爆炸。如果敌人同时具备「远程/近战」「主动/被动」两组属性,用状态枚举就要写 chase_ranged、chase_melee、patrol_active、patrol_passive 四份几乎一样的逻辑。第三堵墙是**无法表达「先做 A 再做 B」**这类顺序语义——状态机天生是「当前在哪」,不擅长描述「接下来依次尝试」。行为树正是为这三堵墙设计的。
13.1.6 行为树:四类节点拼出可组合行为
行为树把「状态」换成「一棵每帧从根节点重新求值的树」。每个节点被 tick 时返回三种状态之一:success(成功)、failure(失败)、running(还在进行)。节点只有四类,全部可以手写:
export type BtStatus = 'success' | 'failure' | 'running';
export interface BtNode {
tick(ctx: EnemyContext, dt: number): BtStatus;
}
/** 顺序节点:依次执行子节点,任一失败则整体失败 */
export class Sequence implements BtNode {
private children: BtNode[];
constructor(children: BtNode[]) { this.children = children; }
tick(ctx: EnemyContext, dt: number): BtStatus {
for (const child of this.children) {
const s = child.tick(ctx, dt);
if (s !== 'success') return s;
}
return 'success';
}
}
/** 选择节点:依次尝试,任一成功则整体成功 */
export class Selector implements BtNode {
private children: BtNode[];
constructor(children: BtNode[]) { this.children = children; }
tick(ctx: EnemyContext, dt: number): BtStatus {
for (const child of this.children) {
const s = child.tick(ctx, dt);
if (s !== 'failure') return s;
}
return 'failure';
}
}
剩下两类是装饰节点和叶子节点。装饰节点只包一个子节点,用来改写它的结果或施加额外约束;叶子节点则是真正的动作或条件:
/** 装饰:条件不成立直接失败,不 tick 子节点 */
export class Guarded implements BtNode {
private guard: Guard;
private child: BtNode;
constructor(guard: Guard, child: BtNode) { this.guard = guard; this.child = child; }
tick(ctx: EnemyContext, dt: number): BtStatus {
return this.guard(ctx) ? this.child.tick(ctx, dt) : 'failure';
}
}
/** 叶子:动作,返回 running 表示持续进行 */
export function action(fn: (ctx: EnemyContext, dt: number) => BtStatus): BtNode {
return { tick: (ctx, dt) => fn(ctx, dt) };
}
13.1.7 把敌人写成一棵树
有了四类节点,13.1.1 那只怪可以直接「读」成树。根是一个 Selector,从上到下依次尝试「逃跑」「攻击」「追击」「巡逻」,每层用 Guarded 挂上自己的前置条件:
const enemyTree = new Selector([
new Guarded((c) => c.hp < 20, action((c, dt) => {
moveAwayFrom(c, c.targetX, c.targetY, FLEE_SPEED * dt);
return 'running';
})),
new Guarded(inRange(28), action((c, dt) => {
c.timer -= dt;
if (c.timer <= 0) { dealDamage(c); return 'success'; }
return 'running';
})),
new Guarded(inRange(160), action((c, dt) => {
moveToward(c, c.targetX, c.targetY, CHASE_SPEED * dt);
return 'running';
})),
action((c, dt) => { c.x += PATROL_SPEED * dt; return 'running'; }),
]);
每帧只需 enemyTree.tick(ctx, dt)。三堵墙在这里同时被拆掉:横切规则不再复制——「血量低就跑」是根选择器的第一支,与后面几支无关;组合爆炸消失——「远程/近战」可以做成两个 action 叶子挂在不同分支上,共享同一棵树的骨架;顺序语义天然成立——Sequence 就是「先做 A 再做 B」。
13.1.8 两种方案的取舍
两套方案没有绝对的优劣,关键看敌人复杂度。下面这张表是《方块冲刺》里的选型依据:
| 维度 | FSM | 行为树 |
|---|---|---|
| 适用规模 | 状态 ≤ 6、规则简单 | 行为多、有层级与顺序 |
| 加一个行为 | 改 register + 若干转移 | 加一个子树分支 |
| 横切规则 | 每条边上重复 | 挂在高优先级分支,一处生效 |
| 调试 | 打印当前状态即可 | 需要可视化树与 running 路径 |
| 性能 | 每帧遍历出边,极快 | 每帧从根重求值,稍慢 |
| 学习成本 | 低,一张表 | 中,需理解三种返回状态 |
实践中的分界线通常是「规则能不能列成一张不超过两屏的表」。像《方块冲刺》里的巡逻小怪,用 FSM 更省事;而 Boss 那种「阶段 + 技能序列 + 打断」的行为,行为树的可组合性会救你一命。两者还可以混用:行为树的某个叶子内部就是一个 FSM。
13.1.9 Cocos 对照:组件承载状态,tween 表达过渡
在 Cocos Creator 3.8 里,上面两种结构都以 Component 子类的形式挂在 Node 上。cc.d.ts 里 Component 定义在 export class Component extends CCObject,它有 node: Node 属性(脚本挂在哪个节点上)、getComponent 与 addComponent(addComponent<T extends Component>(classConstructor: Constructor<T>): T | null)——这正是「状态对象持有节点引用」的天然落点:
import { _decorator, Component } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('EnemyBrain')
export class EnemyBrain extends Component {
@property
public sightRange = 160;
@property
public attackRange = 28;
private tree: BtNode | null = null;
start(): void {
// 行为树是纯逻辑、与渲染无关:13.1.7 里那棵树原样搬过来即可
this.tree = enemyTree;
}
update(dt: number): void {
const p = this.node.position;
this.tree?.tick({ x: p.x, y: p.y, hp: 100, targetX: 0, targetY: 0,
sightRange: this.sightRange, attackRange: this.attackRange, timer: 0 }, dt);
}
}
行为切换时的表现过渡,用 tween 比手写插值更省事。cc.d.ts 里 tween<T extends object>(target?: T): Tween<T>,Tween 的链式方法包括 to(duration, props, opts?)、by、delay、call、start(time?)、stop()。比如敌人进入硬直时「缩一下再弹回」,就是 tween(node).to(0.08, { scale: new Vec3(1.2, 0.8, 1) }).to(0.12, { scale: new Vec3(1, 1, 1) }).start()。
如果硬直同时要切一张帧动画,可以用 Animation 组件。cc.d.ts 里 Animation extends __private...Animation_base,对外有 clips: (AnimationClip | null)[]、defaultClip: AnimationClip | null,以及 play(name?: string): void 与 getState(name: string): AnimationState。所以「状态切换时播哪段动画」是一行 anim.play('stagger')。
对照下来:mini2d 里的 FSM / 行为树是纯逻辑,与渲染无关;Cocos 里的对应物是「逻辑放在 Component 的 update 里,表现交给 tween / Animation」。这条分工要守住——不要把移动逻辑写进 tween,也不要用 Animation 事件驱动 AI 决策,否则逻辑就绑死在表现层了。下一节解决敌人「怎么绕过墙追到玩家」的问题:寻路。
阅读导航:上一节:目录 · 下一节:13.2 寻路:A* 与导航网格 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。