把章节变成可单步观察的过程
分类:游戏 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 重检可能让高优先级分支重新获得控制权。
中断必须沿旧活动路径完成撤销和清理。
真正决定行为树是否可靠的,不只是节点数量,而是:
返回值语义是否一致
运行状态归属是否清楚
中断协议是否完整
异步结果是否仍受当前执行代次约束