《Cocos 游戏开发入门》13.1 有限状态机与行为树

上一章的 StateMachine 管的是游戏阶段,这一章要管的是敌人。本节先给《方块冲刺》的追击怪手写 FSM:状态、转移、条件三者分离;再写一棵行为树,用选择/顺序/装饰/叶子四类节点把行为拆成可组合的积木,最后给出两套方案在同一只敌人身上的取舍对照。

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* 与导航网格 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画