DYNAMIC EXPLAINER / ARTICLE FLOW

把章节变成可单步观察的过程

01 / 15
STEP 01 / CODE / LOGIC

先说结论

行为树节点每次 Tick 只返回三种结果:Success、Failure 或 Running。组合节点根据子节点返回值决定停在哪、继续谁;中断则必须沿当前运行路径执行清理,不能只把根节点状态改掉。

Sequence:[靠近目标, 攻击]
第 1 帧:靠近目标 -> Running,Sequence -> Running
第 2 帧:靠近目标 -> Success;攻击 -> Running;Sequence -> Running
第 3 帧:攻击 -> Success;Sequence -> Success

分类:游戏 AI 与逻辑建模 / 决策执行

先说结论

行为树节点每次 Tick 只返回三种结果:Success、Failure 或 Running。组合节点根据子节点返回值决定停在哪、继续谁;中断则必须沿当前运行路径执行清理,不能只把根节点状态改掉。

连续三帧的返回值

Sequence:[靠近目标, 攻击]
第 1 帧:靠近目标 -> Running,Sequence -> Running
第 2 帧:靠近目标 -> Success;攻击 -> Running;Sequence -> Running
第 3 帧:攻击 -> Success;Sequence -> Success

若第 2 帧目标死亡,攻击分支被中断时要取消移动、动画或异步请求。Memory Sequence 会从正在运行的攻击继续;Reactive Sequence 可能从第一个条件重新检查,两者不能混为同一种执行语义。

先把行为树想成值班流程

想象一家小店开门后的值班流程:

如果有顾客:
  先确认需求
  再准备商品
  最后完成交付

否则:
  整理货架

这里同时存在三类问题:

决策:现在应该接待顾客,还是整理货架?
执行:准备商品可能要持续一段时间。
打断:整理到一半突然来了顾客,要不要立刻切换?

行为树不是单纯的条件树。它是一套反复求值的层级执行模型:

父节点决定怎样解释子节点的结果。
叶节点执行条件检查或具体动作。
树在后续 Tick 中继续推进尚未完成的工作。
高优先级条件变化时,可能中止正在运行的低优先级分支。

理解行为树时,最关键的三个概念是:

节点返回值
运行中的执行路径
中断与清理

三种基本返回值

行为树节点最常见的返回值是:

Success:本节点本次目标已经完成。
Failure:本节点本次目标无法完成。
Running:本节点还没有结束,后续需要继续执行。

它们描述的是“节点执行状态”,不是角色的情绪,也不是整个 AI 的永久状态。

例如“走到门口”:

已经到门口       -> Success
路径不可达       -> Failure
仍在路上         -> Running

同一个节点在不同 Tick 中可以依次返回:

Running -> Running -> Running -> Success

也可能是:

Running -> Failure

例如角色走到一半,门被锁上,路径重新计算后判定不可达。

返回值不等于异常

Failure 通常是可预期的决策结果:

目标不在范围内
资源不足
条件不成立
路径不可达

程序异常则表示实现本身出现了未按模型处理的问题:

依赖对象已经销毁
数组越界
节点配置缺失
内部状态违反不变量

是否把异常转换成 Failure 不是行为树算法的固定要求,而是具体系统的错误策略。默认吞掉异常会掩盖原始故障,因此应让错误保持可观察,或按明确的错误协议处理。

组合节点怎样传播结果

Sequence:顺序完成

Sequence 类似逻辑“并且”:

检查门是否可用
走到门前
打开门

典型规则:

子节点 Success -> 继续下一个子节点
子节点 Failure -> Sequence 立即 Failure
子节点 Running -> Sequence 立即 Running
全部子节点 Success -> Sequence Success

伪代码:

for child in children:
    result = Tick(child)

    if result == Failure:
        return Failure

    if result == Running:
        return Running

return Success

Selector:找到一个可行方案

Selector 类似逻辑“或者”:

尝试从正门进入
否则尝试从侧门进入
否则等待

典型规则:

子节点 Failure -> 尝试下一个子节点
子节点 Success -> Selector 立即 Success
子节点 Running -> Selector 立即 Running
全部子节点 Failure -> Selector Failure

伪代码:

for child in children:
    result = Tick(child)

    if result == Success:
        return Success

    if result == Running:
        return Running

return Failure

Decorator:改写或限制一个子节点

装饰节点包裹一个子节点,常见用途包括:

取反:Success 与 Failure 互换,但 Running 保持 Running。
重复:满足次数或终止条件前反复执行。
超时:超过时间预算后终止子节点。
冷却:在一段时间内阻止再次进入。

“装饰节点存在”是行为树的通用结构;具体有哪些装饰器、超时多久、重复几次,都是实现与内容配置选择。

运行中的执行路径

当一个叶节点返回 Running 时,树不能把它当成已经结束。系统至少要知道:

哪个节点正在运行
它的父节点是谁
父节点下次应从哪个孩子继续
节点内部保存了什么执行状态

可以把当前运行路径想成调用栈:

Root Selector
└─ Combat Sequence
   ├─ HasTarget 条件:Success
   └─ MoveToTarget 动作:Running

此时概念上的活动栈是:

Root Selector
Combat Sequence
MoveToTarget

其中 HasTarget 已完成本轮求值,不一定需要保留活动帧。

执行帧里通常有什么

一个组合节点的运行帧常保存:

当前子节点索引
节点是否已经进入
局部执行数据
上一次返回状态

叶节点可能保存:

异步请求句柄
当前路径或目标快照
已经播放到的阶段
开始时间

哪些数据属于节点实例、黑板或外部系统,是实现选择;但运行状态必须有明确所有者,否则多个角色共享同一树资源时容易互相污染。

Memory 与 Reactive 两种推进方式

Sequence 和 Selector 常见两种语义。

Memory:记住运行位置

如果 Sequence 的第二个孩子正在运行,下次从第二个孩子继续:

第一次:
  条件 A -> Success
  动作 B -> Running

下一次:
  直接继续动作 B

优点:

重复检查少
执行过程稳定
适合不会频繁失效的前置流程

风险:

先前成立的条件后来失效,树可能不会立即发现。

Reactive:从高优先级处重新求值

每次从第一个孩子重新检查:

第一次:
  条件 A -> Success
  动作 B -> Running

下一次:
  重新检查条件 A
  再决定是否继续动作 B

优点:

能及时响应条件变化
适合安全条件和高优先级决策

代价:

检查开销更高
条件抖动可能造成频繁切换
必须定义旧动作如何退出

Memory 与 Reactive 不是所有行为树库统一规定的唯一语义。使用哪一种必须从节点定义、编辑器标识或框架文档中确认。

一次完整执行怎样推进

考虑这棵树:

Selector
├─ Sequence:紧急避险
│  ├─ 条件:危险临近
│  └─ 动作:移动到安全点
└─ Sequence:日常巡查
   ├─ 条件:存在巡查点
   └─ 动作:移动到巡查点

第一次 Tick

当前没有危险,但有巡查点:

紧急避险 Sequence
  危险临近 -> Failure
  Sequence -> Failure

日常巡查 Sequence
  存在巡查点 -> Success
  移动到巡查点 -> Running
  Sequence -> Running

Root Selector -> Running

活动路径:

Root Selector -> 日常巡查 Sequence -> 移动到巡查点

第二次 Tick

如果采用带高优先级重检的 Reactive Selector:

重新检查危险临近
仍为 Failure
继续移动到巡查点

第三次 Tick:高优先级条件变化

危险临近变成 Success

旧分支“移动到巡查点”被中断并退出
进入紧急避险分支
移动到安全点 -> Running

关键不是“选择器改选了第一个分支”,而是中间必须完成:

识别旧活动路径与新活动路径的分叉点
从旧路径叶子向上执行中止或退出
释放旧动作持有的资源
再进入新路径

否则旧的移动请求、动画请求或异步回调仍可能继续生效。

中断到底是什么

中断不是把节点状态直接改成 Failure

更准确的模型是:

正在运行的节点因为外部决策变化,被要求提前结束。

节点应有独立的生命周期通知,例如:

OnEnter:第一次进入。
OnTick:推进工作并返回状态。
OnExit:自然结束后的统一退出。
OnAbort:被中断时撤销未完成工作。

具体 API 名称是实现选择,但“提前结束必须清理”是通用工程要求。

自身中断与低优先级中断

一些行为树系统会把条件观察范围分为:

Self:当前分支自己的条件失效时,中断当前分支。
LowerPriority:更高优先级分支重新变得可行时,中断低优先级分支。
Both:同时具备以上两种行为。
None:不主动观察,中途不因条件变化切换。

这些名称和精确定义不是所有实现都相同,必须以具体框架为准。

生活化理解:

Self:
  正在“有预约时准备餐桌”,预约被取消,于是停止准备。

LowerPriority:
  正在“没有顾客时整理货架”,顾客到来,于是停止整理去接待。

中断时的清理顺序

假设活动路径为:

Root
└─ Patrol Sequence
   └─ MoveToPoint

新路径切换到:

Root
└─ Escape Sequence
   └─ MoveToSafePoint

共同祖先是 Root。旧分支通常从叶到根退出:

Abort MoveToPoint
Exit Patrol Sequence
Enter Escape Sequence
Enter MoveToSafePoint

叶到根清理能先撤销最具体的工作,再关闭上层作用域。

需要清理的内容可能包括:

取消寻路请求
撤销移动意图
停止只属于该动作的计时器
注销事件订阅
使异步回调失效
释放节点独占的资源

是否立即停止动画、是否允许过渡动作完成,是表现与玩法规则选择,不能由“发生了中断”自动推导。

最小 C# 执行骨架

下面只展示返回值、运行索引与中止传播,不绑定具体引擎:

public enum NodeStatus
{
    Success,
    Failure,
    Running
}

public abstract class BehaviorNode
{
    public abstract NodeStatus Tick();

    public virtual void Abort()
    {
        // 默认节点没有待撤销工作;异步动作应覆写此方法。
    }
}

public sealed class SequenceNode : BehaviorNode
{
    private readonly IReadOnlyList<BehaviorNode> children;
    private int runningIndex;

    public SequenceNode(IReadOnlyList<BehaviorNode> children)
    {
        this.children = children;
    }

    public override NodeStatus Tick()
    {
        while (runningIndex < children.Count)
        {
            NodeStatus status = children[runningIndex].Tick();

            if (status == NodeStatus.Running)
            {
                return NodeStatus.Running;
            }

            if (status == NodeStatus.Failure)
            {
                runningIndex = 0;
                return NodeStatus.Failure;
            }

            runningIndex++;
        }

        runningIndex = 0;
        return NodeStatus.Success;
    }

    public override void Abort()
    {
        if (runningIndex < children.Count)
        {
            // 先撤销当前仍在运行的子节点。
            children[runningIndex].Abort();
        }

        runningIndex = 0;
    }
}

这段代码确定表达了:

Sequence 的标准结果传播
Memory Sequence 的当前子节点索引
中断向活动子节点传播
结束后重置运行位置

它没有规定:

节点是否可被多个角色共享
Tick 频率
异常处理
Reactive 重检
异步请求取消协议

这些都需要由实际运行时明确设计。

正常路径、边界路径与失败路径

正常路径:动作自然完成

MoveTo -> Running
MoveTo -> Running
MoveTo -> Success
父 Sequence 继续下一个孩子

检查点:

动作只进入一次
Running 期间状态持续存在
成功后资源被正常释放
父节点只推进一次

正常路径:候选方案回退

正门条件 -> Failure
侧门条件 -> Success
侧门移动 -> Running

这属于 Selector 的正常选择过程,不是系统错误。

边界路径:条件在动作运行中失效

有目标 -> Success
追踪目标 -> Running
目标消失

可能的合法策略包括:

Reactive 重检并中断追踪
让动作自己检测目标失效并返回 Failure
将目标快照固定到动作结束

选择哪种语义取决于行为定义,不能只看树形结构猜测。

边界路径:同一 Tick 连续完成多个节点

如果多个条件和瞬时动作都立即 Success,一次 Tick 可能穿过多个节点。

需要明确:

是否允许单 Tick 无限制推进
是否设置节点步数预算
重复装饰器会不会形成死循环

步数预算是运行时防护选择,不是行为树理论固定常数。

失败路径:中断后旧回调仍写状态

动作 A 发起异步请求
动作 A 被中断
动作 B 开始
动作 A 的旧回调晚到并覆盖动作 B 的结果

可验证的解决思路是建立所有权或版本校验:

中断时取消请求
或者让回调携带执行代次,只接受当前代次

具体采用取消令牌、请求 ID 还是节点版本号,是实现选择。

失败路径:共享节点保存角色状态

如果多个角色共享同一个树资源,而节点对象内部直接保存 runningIndex

角色 A 的执行位置可能污染角色 B。

解决方式可以是:

每个角色实例化独立节点树
或把运行帧放入每个角色独立的执行上下文

两者都是合法架构,必须明确资源与运行态的边界。

代价与适用边界

行为树的优势:

层级结构适合表达优先级与组合逻辑
叶节点可复用
运行路径可视化后容易观察当前决策
Sequence 和 Selector 能组织大量小行为

主要代价:

Reactive 重检会增加条件求值成本
中断与异步清理容易形成隐藏复杂度
黑板键过多时会产生隐式依赖
树很深时,逻辑意图可能被节点细节淹没

行为树适合:

分支优先级相对明确
动作具有持续执行过程
需要组合可复用条件与行为
需要观察“当前正在执行哪条路径”

不应强行用行为树解决:

需要跨很多步骤搜索方案的规划问题
需要在大量方案之间连续优化评分的问题
严格的事务流程或协议状态机
大量单位的极简同质逻辑且 Tick 成本非常敏感

这些场景可能更适合 HTN、GOAP、效用系统、状态机或批处理数据模型。

进阶:通用算法与实现选择

可验证的通用机制

节点返回 Success、Failure 或 Running。
组合节点按自身规则解释并传播子节点结果。
运行中的节点需要跨 Tick 保存执行状态。
活动分支切换时,旧运行节点必须结束并清理。
Sequence 与 Selector 分别表达顺序完成与候选选择。

必须由具体实现确定

Tick 频率与每 Tick 步数预算
Memory 或 Reactive 语义
中断观察范围
异常怎样暴露
黑板数据的类型和生命周期
异步请求取消协议
节点实例是否共享
并行节点的成功与失败阈值

调试时应该记录什么

只打印“当前节点名称”通常不够。更有用的执行记录包括:

Tick 序号或时间
进入和退出的节点
节点返回值
活动路径
触发中断的条件
旧分支与新分支
异步请求的所有者或代次
关键黑板值的变更来源

最小事件流可以写成:

Tick 41  Enter Patrol/MoveTo
Tick 41  MoveTo -> Running
Tick 42  DangerNearby: false -> true
Tick 42  Abort Patrol/MoveTo
Tick 42  Enter Escape/MoveToSafePoint
Tick 42  MoveToSafePoint -> Running

这样才能区分:

节点自己失败
父节点改选分支
高优先级条件触发中断
旧异步结果污染当前状态

最后总结

理解行为树不能只看树长什么样,还要跟踪执行过程:

返回值决定父节点下一步怎么走。
Running 让执行状态跨 Tick 延续。
活动路径相当于当前调用栈。
Reactive 重检可能让高优先级分支重新获得控制权。
中断必须沿旧活动路径完成撤销和清理。

真正决定行为树是否可靠的,不只是节点数量,而是:

返回值语义是否一致
运行状态归属是否清楚
中断协议是否完整
异步结果是否仍受当前执行代次约束