Defold 行为树与游戏 AI 决策

系统讲解在 Defold 中实现行为树(Behavior Tree):从状态机到行为树的演进动机、组合/装饰/叶/条件四类节点的语义、Tick 执行与 Success/Failure/Running 三态、一份可直接使用的 Lua 行为树运行时、黑板(Blackboard)数据共享、与 Defold 感知/寻路的结合,以及调试与性能实践。

引言

当敌人只有「巡逻 / 追击 / 攻击」三个状态时,一个 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 也依然稳。

延伸阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「defold」更多文章

  1. Defold 材质与着色器语言详解
  2. Defold HTML5 导出与 Web 性能优化
  3. Defold GUI 响应式布局与多分辨率适配