bit-behaviortree:写游戏 AI 别再堆 if-else,用行为树组装逻辑

它解决什么问题

怪物 AI 的逻辑最初可能就是”看到玩家就追,追上了就打”,两三个 if-else 就写完了。但需求总是越加越多:追击过程中要躲避障碍、血量低于 30% 要撤退、撤退中被拦截又要反击……每加一条新规则,就要在已经很长的 if-else 链里找地方插进去,改到后面没人敢再碰这段代码,改一个分支可能牵连到看似无关的另一个分支。

行为树把 AI 逻辑拆成一个个可复用的节点(”追击”、”撤退”、”检查血量”),用组合节点(选择、序列、并行)把它们拼装成树。改需求变成”调整树的结构或者加个新节点”,而不是在一段线性代码里插队。bit-behaviortree 是这套模式的零依赖 TypeScript 实现。

安装

npm install @gongxh/bit-behaviortree

核心概念

树的每次 tick 都会返回三种状态之一:

  • SUCCESS —— 这个节点做完了,成功
  • FAILURE —— 做不了,失败
  • RUNNING —— 还在做,下一帧继续

组合节点:怎么串起子节点

最常用的两个是 SelectorSequence

import { Selector, Sequence } from '@gongxh/bit-behaviortree';

// Selector:依次尝试子节点,只要有一个成功就整体成功("打不过就跑"的逻辑常用这个)
const combat = new Selector([attackNode, fleeNode]);

// Sequence:依次执行子节点,全部成功才算成功,中途失败就整体失败("先靠近再攻击"这种有先后依赖的逻辑)
const chaseAndAttack = new Sequence([moveToTargetNode, attackNode]);

还有 Parallel(逻辑上并行,实际按顺序执行完所有子节点)、RandomSelector(随机选一个执行,做行为多样性)、ParallelAnySuccess(任一子节点成功就整体成功)。

装饰节点:给子节点加条件或限制

import { LimitTime, Inverter, RepeatUntilSuccess } from '@gongxh/bit-behaviortree';

const timedAttack = new LimitTime(attackNode, 5); // 超过 5 秒还没成功,判定为失败
const notFleeing = new Inverter(fleeNode);         // 反转结果:撤退成功变成"失败",常用来做反向条件
const keepTryingToOpen = new RepeatUntilSuccess(openDoorNode, 3); // 最多重试 3 次

叶子节点:真正干活的地方

import { LeafNode, Status } from '@gongxh/bit-behaviortree';

class AttackNode extends LeafNode {
    tick(dt: number): Status {
        if (!target.isInRange()) return Status.FAILURE;
        target.takeDamage(damage);
        return Status.SUCCESS;
    }
}

黑板系统:三层数据隔离,别把所有东西都塞进全局

行为树的节点之间需要共享数据(比如”当前目标是谁”),但不是所有数据都该是全局可见的。黑板系统分了三层:

  • 全局层——所有行为树实例都能看到,适合全局配置、跨树共享的状态
  • 树级层——只在这一棵树内可见,随树创建销毁,适合”当前目标”、”任务进度”这类树内共享状态
  • 节点层——只在当前节点内可见,适合节点自己的临时变量
// 树级数据:追击目标,整棵树的节点都能读到
this.setRoot('target', enemy);
const target = this.getRoot<Enemy>('target');

// 全局数据:比如游戏难度配置,所有怪物的行为树都读同一份
globalBlackboard.set('difficulty', 2);

分层的意义在于避免数据污染——如果所有数据都塞进全局黑板,A 怪物的行为树写坏了一个变量名,可能悄悄影响到 B 怪物的判断逻辑,这种跨树的数据污染排查起来非常麻烦。

避坑提醒

  • SelectorSequence 的语义容易搞反:Selector 是”任一成功即成功”(类似逻辑或),Sequence 是”全部成功才成功”(类似逻辑与),命名和常见编程语言里的 if/switch 直觉不完全一致,团队里最好统一一下理解。
  • 树内共享的状态优先用树级黑板(setRoot/getRoot),别图方便都往全局黑板里塞,多个怪物实例共用同一份全局数据的话,字段名冲突或者互相覆盖的问题很难在测试阶段暴露出来,容易线上才发现。
  • RUNNING 状态的节点下一帧会从上次的状态继续,不是重新从头执行——写自定义叶子节点时如果需要”进行中”的行为(比如移动到某个点,没到目标点之前一直返回 RUNNING),要自己维护好这个跨帧的进度状态。

项目信息

零依赖,纯 TypeScript 实现,不限定 Cocos Creator,机器人控制、自动化脚本这类需要组织复杂决策逻辑的场景也能用。