引言
当敌人只有「巡逻 / 追击 / 攻击」三个状态时,一个 if-elseif 状态机就够了。但当它要「先找掩体、血量低于 30% 撤退、被包围时呼叫支援、队友阵亡时改变队形」时,状态机会迅速退化成一张互相纠缠的转移网——每加一个行为就要改所有转移条件。行为树(Behavior Tree, BT)解决的就是这个问题:它把 AI 逻辑组织成一棵树,每个节点只回答「成功还是失败」,组合节点负责编排,行为天然可复用、可组合、可视化。
本文讲在 Defold 里怎么落地行为树:先讲清楚为什么状态机会失控而行为树不会,再讲四类节点的语义与执行规则,然后给一份可直接用的 Lua 行为树运行时,接着是黑板(Blackboard)与数据共享、与 Defold 感知/寻路的结合,最后是调试与性能实践。
前置:寻路与 AI 、复杂逻辑与状态管理 。行为树的通用设计见 游戏 AI 行为树 、行为树 vs 状态机的取舍 。
1. 为什么需要行为树
状态机的困境:转移边爆炸。
3 个状态 → 最多 6 条转移边
5 个状态 → 最多 20 条转移边
8 个状态 → 最多 56 条转移边
每加一个状态,都要重审「它能从哪些状态进入、能转到哪些状态」
—— 复杂度是 O(n²),改一处要验证一片
行为树把「转移」换成「层级」:
状态机:A 能到 B、C、D……(边是显式的,n² 条)
行为树:A 是 B 的父节点(结构是显式的,n 条)
| 维度 | 状态机 | 行为树 |
|---|---|---|
| 组织方式 | 平铺 + 转移边 | 树形层级 |
| 加新行为 | 改多条转移 | 加一个节点 |
| 复用 | 复制转移逻辑 | 子树可直接搬 |
| 可视化 | 需要画状态图 | 树本身即图 |
| 适合规模 | 3~6 个状态 | 任意规模 |
选择判据:
状态少、转移清晰(如 UI 流程) → 状态机更简单
行为多、有层级、需复用(如敌人 AI)→ 行为树更划算
两者不是互斥:行为树的叶子节点内部可以是小状态机
心智:状态机的复杂度在「转移边」(O(n²)),行为树的复杂度在「树深」(O(n))——行为一多,树就赢。
2. 四类节点
行为树只有四类节点,理解它们就理解了整棵树:
① 组合节点(Composite):有多个子节点,决定「按什么顺序跑」
Sequence 依次执行,全成功才成功(and 语义)
Selector 依次执行,一个成功即成功(or 语义)
Parallel 并行执行,按策略决定结果
② 装饰节点(Decorator):只有一个子节点,修饰其行为
Inverter 成功↔失败 取反
Succeeder 永远返回成功
Repeater 重复 N 次
Cooldown 冷却期内直接失败
UntilFail 子节点失败才成功
③ 条件节点(Condition):判断,返回成功/失败(不改变世界)
HasTarget? / InAttackRange? / HpBelow30%?
④ 行为节点(Action / Leaf):真正做事
MoveTo / Attack / PlayAnim / Wait
树的一个实例(敌人 AI):
Selector(根:按优先级尝试)
├── Sequence(撤退:血少就撤)
│ ├── HpBelow(0.3) ← 条件
│ └── MoveTo(base) ← 行为
├── Sequence(攻击:有目标且在范围内)
│ ├── HasTarget ← 条件
│ ├── InAttackRange ← 条件
│ └── Attack ← 行为
├── Sequence(追击:有目标但太远)
│ ├── HasTarget
│ └── MoveTo(target)
└── Patrol(巡逻:兜底行为)
读法:Selector 从上到下试,第一个「整条链成功」的分支胜出
—— 撤退优先级最高、攻击次之、追击再次、巡逻兜底
心智:Selector = 「按优先级找第一个能用的方案」,Sequence = 「一条方案里的前置条件与步骤」——一棵树读下来就是一段 if/elseif 的自然语言。
3. 执行语义:Tick 与三态
行为树靠「Tick」驱动:每帧(或固定间隔)从根节点 tick 一次,返回值三选一:
Success 该节点已完成任务
Failure 该节点无法完成
Running 正在进行中(需要后续 tick 继续)
三种返回值的传播规则:
Sequence:
子节点返回 Failure → 立刻返回 Failure(后续子节点不执行)
子节点返回 Running → 返回 Running(下次 tick 从该子节点继续)
全部 Success → 返回 Success
Selector:
子节点返回 Success → 立刻返回 Success
子节点返回 Failure → 试下一个
子节点返回 Running → 返回 Running
全部 Failure → 返回 Failure
「Running」是行为树的关键设计——它让长耗时行为(走到目标、播完动画)能跨帧继续,而不是每帧从头开始:
-- 一个会返回 Running 的行为节点:走到目标点
local function MoveTo(ctx, target)
if ctx.reached(target) then
return "success"
end
ctx.step_toward(target) -- 本帧走一步
return "running" -- 还没到,下次继续
end
Running 的两种实现风格:
| 风格 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 无状态(推荐) | 每帧重算,靠条件重判 | 简单、无隐藏状态 | 需要幂等的前置检查 |
| 有状态 | 节点记住「上次做到哪」 | 精细控制 | 状态易泄漏 |
无状态风格的核心:把「进行中」表达为「条件仍满足」
走到一半目标死了 → 下次 tick 时 HasTarget 失败 → Sequence 直接失败
—— 不需要显式的「打断」逻辑,条件自己会打断
心智:三态 Success/Failure/Running 是行为树的心跳——Running 让长行为跨帧续跑,无状态风格让「打断」变成「条件不成立」,比显式打断干净得多。
4. 一份可用的 Lua 运行时
节点通用接口:每个节点是一个 table,含 tick(ctx) 方法。
-- bt.lua:行为树运行时
local BT = {}
-- 组合:Sequence
function BT.sequence(children)
return {
tick = function(ctx)
for _, child in ipairs(children) do
local r = child.tick(ctx)
if r ~= "success" then return r end -- failure 或 running 都直接上抛
end
return "success"
end
}
end
-- 组合:Selector
function BT.selector(children)
return {
tick = function(ctx)
for _, child in ipairs(children) do
local r = child.tick(ctx)
if r ~= "failure" then return r end -- success 或 running 都直接上抛
end
return "failure"
end
}
end
return BT
装饰节点:
-- 取反
function BT.inverter(child)
return {
tick = function(ctx)
local r = child.tick(ctx)
if r == "success" then return "failure" end
if r == "failure" then return "success" end
return "running"
end
}
end
-- 冷却(同一行为在 N 秒内不重复触发)
function BT.cooldown(child, seconds)
local node = { last = -math.huge }
node.tick = function(ctx)
if ctx.now - node.last < seconds then return "failure" end
local r = child.tick(ctx)
if r ~= "running" then node.last = ctx.now end
return r
end
return node
end
条件与行为节点:
-- 条件:封装一个返回 bool 的判定
function BT.condition(fn)
return {
tick = function(ctx)
return fn(ctx) and "success" or "failure"
end
}
end
-- 行为:执行一段逻辑,返回三态
function BT.action(fn)
return { tick = fn }
end
组装并使用:
local BT = require("bt")
local function build_enemy_bt()
return BT.selector({
-- 撤退
BT.sequence({
BT.condition(function(ctx) return ctx.hp < 0.3 end),
BT.action(function(ctx) return ctx:move_to(ctx.base) end),
}),
-- 攻击
BT.sequence({
BT.condition(function(ctx) return ctx.target ~= nil end),
BT.condition(function(ctx) return ctx:in_range(ctx.target, 60) end),
BT.action(function(ctx) return ctx:attack(ctx.target) end),
}),
-- 追击
BT.sequence({
BT.condition(function(ctx) return ctx.target ~= nil end),
BT.action(function(ctx) return ctx:move_to(ctx.target) end),
}),
-- 巡逻
BT.action(function(ctx) return ctx:patrol() end),
})
end
-- 敌人脚本里每帧 tick
function init(self)
self.ctx = { hp = 1.0, now = 0, target = nil, base = vmath.vector3(0, 0, 0) }
self.bt = build_enemy_bt()
end
function update(self, dt)
self.ctx.now = self.ctx.now + dt
local result = self.bt.tick(self.ctx) -- 三态之一
self.last_result = result -- 存起来便于调试
end
心智:行为树运行时不到 100 行——组合节点管「顺序」、装饰节点管「修饰」、条件/行为节点是叶子;节点就是「一个含 tick 的 table」,简单到可以直接嵌进脚本。
5. 黑板与数据共享
黑板(Blackboard)是节点之间的共享数据区——避免节点之间互相持有引用:
-- 黑板:一个普通 table + 读写约定
local function new_blackboard()
return { data = {} }
end
function blackboard_set(bb, key, value) bb.data[key] = value end
function blackboard_get(bb, key) return bb.data[key] end
黑板的典型键:
target 当前目标对象 URL
last_seen 最后一次看到玩家的位置
alert_level 警戒等级(0 平静 / 1 怀疑 / 2 战斗)
home 出生点(撤退用)
squad_id 小队编号
用黑板改写节点(节点不再持有状态,全从黑板读):
BT.action(function(ctx)
local bb = ctx.bb
local target = blackboard_get(bb, "target")
if not target then return "failure" end
return ctx:move_to(target)
end)
感知写入黑板(感知与决策分离):
-- 感知脚本:定期扫描,把结果写进黑板
function update(self, dt)
self.timer = self.timer + dt
if self.timer < 0.2 then return end -- 5Hz 感知,不必每帧
self.timer = 0
local seen = self:can_see_player() -- 视野 + 射线遮挡检测
if seen then
blackboard_set(self.bb, "target", "/player")
blackboard_set(self.bb, "last_seen", go.get_position("/player"))
else
blackboard_set(self.bb, "target", nil) -- 丢失目标
end
end
分离的好处:
感知 5Hz(贵:射线检测),决策 60Hz(便宜:读黑板)
—— 把昂贵操作从每帧路径里挪出去
心智:黑板是节点之间的唯一共享区——感知写、决策读,节点本身无状态;把昂贵感知降到 5Hz、决策保持每帧,是 AI 性能的第一条优化。
6. 与 Defold 的结合
AI 代理脚本的完整骨架:
local BT = require("bt")
local bb = require("blackboard")
function init(self)
self.bb = bb.new()
self.ctx = {
bb = self.bb,
now = 0,
hp = 1.0,
base = go.get_position(),
move_to = function(ctx, target) return self:move_to(target) end,
attack = function(ctx, target) return self:attack(target) end,
patrol = function() return self:patrol() end,
}
self.bt = build_enemy_bt()
end
move_to 返回 Running(走一段就返回,下帧继续):
function move_to(self, target)
local my_pos = go.get_position()
local target_pos = (type(target) == "string") and go.get_position(target) or target
local delta = target_pos - my_pos
if vmath.length(delta) < 8 then
return "success" -- 到了
end
local dir = vmath.normalize(delta)
go.set_position(my_pos + dir * self.speed * self.dt)
return "running" -- 还没到
end
与寻路结合(走路径而非直线):把路径点队列当作 move_to 的目标序列,每个路点到达后弹出下一个,队列空时返回 success;每帧只走一步并返回 running。
感知与攻击:
function attack(self, target)
if not self.attack_cd or self.attack_cd <= 0 then
msg.post(target, "apply_damage", { amount = 10 })
sprite.play_flipbook("#sprite", hash("attack"))
self.attack_cd = 0.8 -- 攻击间隔
return "success"
end
self.attack_cd = self.attack_cd - self.dt
return "running" -- 冷却中
end
与 Defold 的三个衔接点:
1. 消息:AI 决策 → msg.post 驱动表现(动画/音效)
2. 组件:go.get_position / go.set_position 直接动对象
3. 集合:AI 在子集合里,跨集合通信走 Proxy
心智:行为树的叶子最终落到 Defold 的三样东西上——go 的读写、msg.post 的通信、sprite 的表现;把长行为写成返回 Running 的幂等函数,整棵树就活了。
7. 调试与性能
可视化调试(把「当前走的哪条分支」画出来):
调试三件套:
1. 打印「本帧选中了哪个分支」(给节点加 id,tick 后记录 trace)
2. 在场景里画「当前目标」「路径点」(用 debug 绘制)
3. 把黑板内容打到屏幕上(实时看 target / alert_level)
性能纪律:
1. 感知降频:射线检测/视野扫描 5~10Hz,别每帧
2. 决策降频:简单 AI 可 10Hz 决策、60Hz 移动(决策结果缓存)
3. 距离早退:Selector 第一层就是「玩家是否在附近」,远距离 AI 直接走巡逻分支
4. 分帧错峰:同一批敌人在不同帧 tick(按 index 取模),避免同帧尖峰
5. 数量裁剪:屏幕外/远处的 AI 降级为「统计模拟」而非完整行为树
分帧错峰:按敌人索引分配 offset,只有 (frame % 3 == offset) 的那帧才 tick
抖动(Oscillation)的根治:进入阈值与退出阈值分开(迟滞,Hysteresis)
进入攻击距离 = 60,退出攻击距离 = 80
—— 在 60~80 之间不会来回切,抖动消失
常见坑:
| 现象 | 原因 |
|---|---|
| AI 原地抖动 | 条件与行为每帧反复切换(加冷却或滞后阈值) |
| AI 卡在 Running | 行为节点没处理「目标消失」的情况 |
| 同帧卡顿 | 所有 AI 同一帧 tick + 昂贵感知 |
| 行为不打断 | 用了有状态节点,条件变化无法中断 |
心智:调试靠「打印分支 + 画目标 + 黑板上屏」;性能靠「感知降频、决策降频、距离早退、分帧错峰、数量裁剪」;抖动靠迟滞阈值根治。
速查表
| 需求 | 做法 |
|---|---|
| 按优先级选方案 | Selector |
| 一条方案的步骤 | Sequence |
| 取反条件 | Inverter |
| 限制触发频率 | Cooldown |
| 条件节点 | BT.condition(fn) 返回 bool |
| 行为节点 | BT.action(fn) 返回三态 |
| 长行为跨帧 | 返回 "running" |
| 共享数据 | 黑板(table + get/set) |
| 感知降频 | 5~10Hz 扫描写黑板 |
| 防抖动 | 进入/退出阈值分离(迟滞) |
| 防同帧尖峰 | 按索引分帧 tick |
| 调试 | 打印分支 + 画目标 + 黑板上屏 |
一句话记忆:行为树 = Selector(按优先级找方案)+ Sequence(方案内的步骤与前置)+ 条件/行为叶子——三态 Success/Failure/Running 是心跳,Running 让长行为跨帧续跑、无状态风格让打断变成「条件不成立」;黑板做感知与决策的唯一共享区;性能五招(感知降频、决策降频、距离早退、分帧错峰、数量裁剪)加迟滞防抖——树一搭好,AI 行为就是加节点的事了。
小结
行为树不是「更高级的状态机」,而是把 AI 逻辑从「转移边」重新组织成「层级」,让「加一个行为」从「改一片转移」变成「加一个节点」。在 Defold 里落地它只需三样东西:一份不到 100 行的 Lua 运行时(组合/装饰/条件/行为四类节点)、一个黑板 table(感知写、决策读)、以及把叶子节点接到 go 的读写与 msg.post 的通信上。工程上最值得先做的三件事是:把感知从每帧降到 5Hz 写黑板、把长行为写成返回 Running 的幂等函数、用迟滞阈值消灭抖动。这三件事做完,即使敌人数量翻十倍,AI 也依然稳。
延伸阅读
- 寻路与 AI 移动
- 复杂逻辑与状态管理
- 游戏 AI 行为树 — 行为树的通用设计与节点语义
- 行为树 vs 状态机的取舍 — 什么规模该换架构
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。