StateTree 浅析
简单来说,StateTree 是一个增强版的状态机 (State Machine)。它的核心思想是:一个对象(比如一个 AI 角色)在任何时刻都只处于一个明确的状态 (State) 中。它通过定义一系列状态以及状态之间切换的条件 (Transition) 来驱动对象的行为。
它与行为树 (Behavior Tree) 的主要区别在于:
行为树:每一帧(或固定的时间间隔)都会从根节点开始遍历,寻找并执行合适的行为分支。它的逻辑是“每时每刻都在决策要做什么”。
StateTree:一旦进入一个状态,它会持续停留在这个状态,只执行该状态内定义的任务 (Task)。它会不断检查“转换条件 (Transitions)”,只有当某个条件满足时,才会切换到新的状态。这种方式更加高效,因为不需要每一帧都评估整个逻辑树。
StateTree 不仅仅用于 AI,它可以用于管理任何需要清晰状态逻辑的对象,例如一个交互式门的开关状态、一个任务的流程状态等。
My Understanding
默认状态模式
默认状态(选择)模式,也是就是Try Select Children In Order
模式, 该模式是状态树最本质的设计哲学。文档里提到状态树的执行行为但没有指明特定模式的话,就是特指Try Select Children In Order
模式。
执行法则
状态树的执行,就是状态的选择过程,一旦进入了该状态,就会持续停留在这个状态,只执行该状态内定义的任务 (Task)。 默认状态树的选择过程类似于深度优先搜索(Depth-First Search),StateTree 会一直探索到第一个有效的叶子状态(leaf state)
Tasks的搜集过程,是从Root 到 当前激活的Leaf, 按顺序添加。执行时,也是按加入的顺序执行。伪代码如下:
void Tick(float DeltaTime)
{
for (auto& task : tasks)
{
task->Tick(DeltaTime);
}
}
Tree.Tick(DeltaTime);
可以看到,Task是按顺序并排执行,既不是并发也不是串行。
状态切换
状态都可以配置Transitions
规则, 没有配置的,默认返回Root状态。
通常由两种情况可以触发状态切换:
- Task 内部调用
FinishTask
主动触发状态切换 - 外部通过
SendEvent
被动触发状态切换
理解 first shared state
:
树只会退出到之前运行状态和正在转换到的状态之间的第一个共享状态(first shared state)
用文档中的例子说明:
- Root (根)
- Peaceful (和平)
- Wander (漫游)
- Graze (吃草)
- Idle (空闲)
- Danger (危险)
- Flee (逃跑)
- Assess Situation (评估情况)
场景一:在同一个分支内转换 (Transition within the same branch)
假设 AI 当前正在 Wander
状态,现在它完成漫游,准备转换到 Graze
状态。
之前运行的状态路径 (Previously running state path):
Root -> Peaceful -> Wander
将要转换到的状态路径 (New state path):
Root -> Peaceful -> Graze
共享状态 (Shared States)? 这两个路径都经过了
Root
和Peaceful
。所以Root
和Peaceful
都是它们的共享状态。“第一个”共享状态 (First Shared State)? “第一个”指的是从下往上(从叶子节点往根节点)看,遇到的第一个共同的父状态。在这里,
Peaceful
是Wander
和Graze
的直接父状态,所以Peaceful
就是它们的“第一个共享状态”。执行流程:
- 退出 (Exit): 规则是“退出到第一个共享状态为止”。这意味着,只有在第一个共享状态
Peaceful
下方的状态才需要退出。所以,只有Wander
状态会执行ExitState
。 - 进入 (Enter): 从第一个共享状态
Peaceful
开始,往下进入新的路径。所以,只有Graze
状态会执行EnterState
。
结果:
Wander
状态退出。Peaceful
状态保持激活,它的任务(如果有的话)继续运行,不会被中断。Graze
状态进入。
Peaceful
状态的上下文被完整保留了。- 退出 (Exit): 规则是“退出到第一个共享状态为止”。这意味着,只有在第一个共享状态
场景二:在不同分支之间转换 (Transition between different branches)
假设当前正在 Wander
状态,但突然感知到了玩家,需要立即转换到 Flee
状态。
之前运行的状态路径:
Root -> Peaceful -> Wander
将要转换到的状态路径:
Root -> Danger -> Flee
“第一个”共享状态? 从
Wander
和Flee
往上找,它们唯一的共同父状态是Root
。所以,Root
就是这里的“第一个共享状态”。执行流程:
- 退出 (Exit): 规则是“退出到第一个共享状态
Root
为止”。所以,Wander
需要退出,然后它的父状态Peaceful
也需要退出。 - 进入 (Enter): 从第一个共享状态
Root
开始,往下进入新的路径。所以,Danger
需要进入,然后Flee
需要进入。
结果:
Wander
状态退出。Peaceful
状态退出。Root
状态保持激活。Danger
状态进入。Flee
状态进入。
- 退出 (Exit): 规则是“退出到第一个共享状态
中间态模式
既Try Enter
, 这种选择模式,允许状态不去到叶子节点,而是停留在"中间态", 子状态被忽略 。
默认模式下,一定要有一个可用的叶子状态,但实际开发过程中,经常会遇到没有可用的叶子状态,比如子状态的进入条件全部要依赖外部Event。此时Try Enter
模式就很有用。
我们可以让中间态来运行对外部Event的监听,让中间态来决定如何切换到相应的子状态。
状态的类型
除了选择模式,状态还可以配置不同的类型:
- State:树中使用的基本状态。
- Group:不能有任何Task,但可以有子状态、进入条件和转换。主要用于组织和逻辑分组。
- Linked:链接到 相同 StateTree 资产内的子树状态(Subtree state),但执行会保留当前分支(branch)在层级结构中的状态。
- Linked Asset:允许在当前树中运行另一个 StateTree 资产。这实现了 StateTree 的模块化(modularity)。
- Subtree:一个可以从
Linked
状态链接到的状态,并且仍然可以有任务、子状态、条件和转换。
StateTree 使用感悟
Try Select Children In Order
必须要有可用的叶子状态,当只有一个状态时,Root既是根,也是叶子,所以能够正常工作。但如果存在子状态,且没有可用的叶子状态(条件不满足),就会出错,无法进入任何状态。自带的
DelayTask
, 理解成延迟并不合适,理解成Timeout
更加准确;可以给状态配置这个Timeout
,时间一到不管如何,都会切换到下一个状态。叶子状态可以理解成“动作”,因为通常都很具体,而父状态(中间态)才是真正的“状态”
如官方图,Danger 和 Peaceful 都是状态,Wander,Flee 和 Graze 等 都是动作。
“中间态”负责维护上下文,如监听Event、管理该状态下的变量等,而“动作”则负责执行具体的行为。
当状态配置了Event相关Tag和Payload的前置需求时,你无法通过
Transition
来切换状态,因为Transition
无法传递Event的Tag和Payload,这种状态只能依赖SendEvent
来切换。Transition使用
OnTick
定义规则时,不要切换到自身的子状态,否则会一直触发OnTick
;最好是切换到兄弟状态。修改Task 不要使用replace,改错了也不要使用撤回,非常容易触发不稳定的错误。直接删除新建一个Task是最好的选择。
StateTree 问题记录
叶子节点不可用时,状态树无法执行 (UE 5.5.4)
这个问题有一半不算bug,而是设计上的限制。
如图,唯一可用的子状态配置了进入条件,既只能通过外部Event来触发。这种情况下,父状态的模式理论上应该使用
Try Enter
,也就是中间态,但这个模式会触发Bug:
如果父状态的模式是Try Select Children In Order
, 这种模式必须要能够去到叶子状态,否则整个状态树都无法工作。 很尴尬的地方是:此时没有其他叶子状态可以选了。
解决方法:
- 父状态改成
Try Select Children In Order
- 添加一个什么都不做的状态,让父状态有可用的子状态,这样父状态的Task就能正常执行了。
带Reuired Event Tag的中间态,无法进入自身的子状态(UE 5.5.4)
如图,MoveToPrediction状态配置了Required Event Tag
和 Payload
,该状态的模式是Try Enter
,也就是中间态。
此时中间态是能够正常进入,该中间态的任务是移动到预测位置,当任务完成时,也就是预测位置到达时,开始进行状态切换,问题就出现在这里:无法切换到自身的子状态。Could not trigger completion transition, jump back to root state.
即使子状态没有任何前置条件,和要求。
我调试了很久都没法解决,这是个暂时无解的bug,如果尝试取消勾选Check Prerequisites when Activating Child Directly
,则会它内部的SharedEvent空指针断言,并且会导致UE编辑器死循环,卡死。
一种Workaround是: 这种情况不使用嵌套状态,虽然表达力下降,但至少不会出错。
慎用空状态,某些情况下空状态会阻碍状态切换(UE 5.5.4)
开发过程中,使用空状态进行占位留个坑位是很常见的操作, 但这里面有坑需要小心。 如图,Searching是
Try Select Children In Order
模式,而NextCheckPoint 是一个空状态,没有任何Task,没有任何子状态,只有一个转换到Next State
,最后一个状态是RandomMove,
这个流程是没问题的,当进入Searching状态后,空状态也会被激活,并最终成功跳转到有任务的RandomMove状态。
诡异的地方是,一旦Searching状态配置了任何Task,比如DelayTask
或者DebugText
这些无关紧要的Task, NextCheckPoint 就会成为阻碍,无法跳转到RandomMove状态。即使这些无关紧要的Task 被设置成了disabled
, 也会有同样的问题。