行为树可视化编辑器的探索与实现|社区征文

前言

  • 前段时间,大魔王花了些时间将与 EffectExporter Shader编辑器配套的社区网站部署到了服务器,并将示例上传、加载以及发布功能集成到该插件中。在这个过程中,也断断续续探索了在 CocosCreator v3.x 中自实现一个行为树可视化编辑器的可行性。广州线下 StarMeetings 结束之后,这件事情又被大魔王安排了起来。

行为树基础

  • 关于行为树大家或多或少都有接触过,本文不作详细介绍,下面引用一些与本文密切相关的概念。

    以下概念均来自虚幻引擎官方文档,文字描述略有调整

    https://docs.unrealengine.com/4.27/zh-CN/InteractiveExperiences/ArtificialIntelligence/BehaviorTrees/

    上图为虚幻引擎官方行为树介绍中的敌方AI树

    该AI看到玩家后会做出反应并展开追逐。当玩家离开视线后,AI将在几秒钟后放弃追逐,并在场景中随机移动,再次看到玩家时便会继续追逐。

  • 如图所示,行为树是一种以可视化方式创建,将一系列特定功能的节点添加并连接起来构成的树图。执行逻辑时行为树会使用一种名为 **黑板(Blackboard) **的数据结构来存储它需要知道的信息,从而做出有根据的决策。

行为树节点类型

  • 行为树节点执行行为树的主要工作,包括任务、逻辑流控制和数据更新

  • 充当行为树起始点的节点即 根节点。它是整个行为树内的一个独特节点,因此拥有一些特殊规则。

    它只能有一个子连接,且不支持附接 装饰器节点服务节点

    除根节点外,还有以下四种类型的行为树节点:

  • 合成(Composite) 节点定义分支的根,以及执行该分支的基本规则。您可以对其应用 装饰器(Decorators)节点,从而修改进入它们分支的条目,甚至取消执行中的条目。此外,它们还可以连接服务(Services)节点,这些服务节点只有在合成节点的子节点正在被执行时才会激活。

    只有 合成节点 可以被连接至行为树的根节点。是行为树是分支节点,一定需要包含子节点。

    合成节点主要包含以下三种类型

    • 选择器(Selector) 节点按从左到右的顺序执行其子节点。当其中一个子节点执行成功时,选择器节点将停止执行。如果选择器的一个子节点成功运行,则选择器运行成功。如果选择器的所有子节点运行失败,则选择器运行失败

      顾名思义,就是选择返回最先成功运行的那个。相当于 ‘||’ 或逻辑

    • 序列(Sequence) 节点按从左到右的顺序执行其子节点。当其中一个子节点失败时,序列节点也将停止执行。如果有子节点失败,那么序列就会失败。如果该序列的所有子节点运行都成功执行,则序列节点成功。

      相当于 ‘&&’ 与逻辑

    • 平行(Parallel) 节点允许一个主节点平行(同时)执行多个子节点。

      子节点运行结果相互不影响

  • 装饰器(Decorator) 节点(在其他行为树系统中也称为条件语句)连接到合成(Composite)任务(Task)节点,并定义树中的分支,甚至单个节点是否可以执行。

    装饰器节点有时也称为 Condition 条件节点。

  • 服务(Service) 节点通常连接至合成(Composite)节点,只要其分支被执行,它们就会以定义的频率执行。这些节点常用于检查和更新黑板。

  • 任务(Task) 节点的功能是实现具体的操作,例如移动AI或调整黑板值。

    任务节点有时也称为 Action 节点。是行为树的叶子节点,不含子节点。

有了以上概念基础之后,下面来看看 CocosCreator v3.x 中行为树可视化编辑器的集成与使用。

行为树编辑器概览

  • 经过一段时间的探索,行为树可视化编辑器已初步集成到CocosCreator v3.x 版本中。下面先看看编辑器中实现上述虚幻引擎示例中敌方AI行为树的效果:

    你可能还会注意到节点旁边的数字。

    它表示节点执行操作的顺序。行为树 会从左到右和自上而下执行,因此节点的排列很重要。对AI最重要的动作通常应该放在左边,而次要的动作(或退却行为)应该放在右边。子分支会以相同的方式执行,如果任何子分支失败,整个分支将会停止执行,导致失败并返回上级树。举例而言,如果 追逐玩家 节点失败,它将返回至上级 敌方AI,然后转变为 巡逻 节点。

    以上参考 https://docs.unrealengine.com/4.27/zh-CN/InteractiveExperiences/ArtificialIntelligence/BehaviorTrees/BehaviorTreeQuickStart/

节点分析

  1. 0 号节点 敌方AI 是整个行为树的根节点。它是一个 selector (选择类型)节点,包含 2 6 10 三个子节点 。当其中某个子节点执行成功时,本次循环结束。例如,当执行 2 追逐玩家失败时,会继续执行 6 巡逻。

  2. 2 号节点 追逐玩家 是一个 sequence (序列)节点,包含 3 4 5 三个子节点 。同时 2号身上附接一个 1敌方视野 装饰器,表示 2 号节点是有条件才执行,条件是装饰器本身定义的玩家在敌方视野中才开始追逐。

    2 号节点满足条件时,开始按顺序执行 3 4 5 子节点的逻辑,即 转向玩家->追逐玩家(切换动作,改变速度等)->移动到玩家,只有三个子节点都执行成功,2号节点才返回成功。

  3. 6 号节点 巡逻 也是一个 sequence (序列)节点,内部执行逻辑与 2 号相同。

  4. 追逐玩家巡逻 都失败时,开始执行 10等待

代码实现

为了让大家对上述涉及到的节点有更详细的理解,下面贴出具体的实现方式:

简单的行为树执行状态 BehaviorStatus,一般有三种取值(可能还有其它值,在此不讨论):

export enum BehaviorStatus {
    Failure = 0,
    Success = 1,
    Running = 2,
}

选择节点Selector

有一个子节点执行成功就可以返回成功

export class Selector extends Composite {
    update(delta: number) {
    	// 默认返回失败
        let res = BehaviorStatus.Failure
        
        // 从上次执行返回 running 状态的节点开始本次update;如果没有,则从0开始
        let i = this.lastRunning >= 0 ? this.lastRunning : 0
        this.lastRunning = -1

        for (; i < this.children.length; i++) {
            res = this.children[i].update(delta)
            if (res === BehaviorStatus.Failure) {
                continue
            } 
            // 有一个子节点执行成功就可以返回成功
            else if (res === BehaviorStatus.Success) {
                break
            } 
            // 如果有子节点是执行中状态,记录下来,下次从该节点开始执行
            else if (res === BehaviorStatus.Running) {
                this.lastRunning = i
                break
            }
        }
        return res
    }
}

序列节点Sequence

当有一个子节点执行失败,则本节点返回失败

export class Sequence extends Composite {
   update(delta: number) {
       let res = BehaviorStatus.Failure
       let i = this.lastRunning >= 0 ? this.lastRunning : 0
       this.lastRunning = -1
       for (i = this.lastRunning; i < this.children.length; i++) {
           res = this.children[i].update(delta)
           // 当有一个子节点执行失败,则本节点返回失败
           if (res === BehaviorStatus.Failure) {
               break
           } 
           else if (res === BehaviorStatus.Success) {
               continue
           } 
           else if (res === BehaviorStatus.Running) {
               this.lastRunning = i
               break
           }
       }
       return res
   }
}

当然实际实现起来会比上述代码复杂,包括本编辑器中实现的逻辑委托处理 Delegate

行为树编辑器集成

  • 这是本文的重点,主要分享在编辑器集成过程中的要点和难点。

  • 如图所示,编辑器除了行为树中间绘图部分,还包含左侧 Inspector 属性面板以及右侧 Node行为树节点列表面板、Blackboard黑板面板以及 Preference 选项设置面板。

Node 面板集成

  • 面板中列出的是当前实现的行为树可用的所有节点。包含4大类:组合节点、装饰器节点、服务节点、任务节点。

    节点类型具体参考上文虚幻引擎文档

    这里为方便理解,经处理只显示了所有基础通用节点

  • 如何将项目中定义的节点类导入到行为树编辑器中使用呢?

    遇到此问题时,首先想到的是,当我们在项目中定义了 Component 子类后,CocosCreator 编辑器是如何获取到该脚本的呢?

    @ccclass('NewComponent')
    export class NewComponent extends Component {
    }
    

    遇到解决不了的问题就上网查,查不到就先看源码。跟踪代码发现 ccclass 装饰器调用的过程为

    ccclass(xxx) --> 
    const res = CCClass(proto) --->
    const cls = define(name, base, mixins, options);
    

    定位到 class.ts 文件的 define 方法中

    if (EDITOR) {
    ...
    EditorExtends.emit('class-registered', cls, frame, className);
    }
    

    看来就是 EditorExtends.emit 将消息发送到了编辑器面板,熟悉插件开发的同学应该能想到,这其实就是不同渲染环境下面板间的数据通信方式。

    想到这里,我重写了一份 ccclass 到自己的行为树框架类装饰器 bt.ccclass 中 ,并将 EditorExtends.emit 改为熟悉的 Editor.Message.send(xxx) 方式调用:

    Editor.Message.send("oreo-behavior-tree", "btclass-registered", info)
    

    并在插件 package.json 中注册消息监听:

    "contributions": {
        "messages": {
            "btclass-registered": {
                "methods": [
                    "btclass_registered"
                ]
            }
        }
    }
    

    在插件主入口 main.ts 中实现接收函数

    export const methods: { [key: string]: (...any: any) => any } = {
        btclass_registered(info: TInfo){
            console.log("[LOG] onEditClass params: ", info);
        },
        ...
    }
    

    至此,自定义类的相关数据已拿到,剩下的就是数据组织和界面显示问题了。

    image-20220414235208324

Inspector 属性面板集成

  • 当我们选中行为树中的某个节点时,属性面板会列出当前节点可控的属性数据。例如选中 1敌方视野 装饰器节点:

    image-20220415001642067

  • 属性面板中 onUpdate 表示当装饰器被行为树 update 时实际执行的委托逻辑,在委托逻辑中自实现视野判断,返回 BehaviorStatus.Success 时表示玩家在敌方视野中,可以执行 2 号节点的追逐玩家行为。

  • 特别地,有时候在执行 Decorator 装饰器逻辑的过程中会引用到中间变量。比如在作视野判断时,需要指定判断哪个 player 玩家在视野中。这个时候就可能要自定义新的装饰器类,并可能会用到 Blackboard 黑板中定义的共享变量:

  • 如上图所示,敌方视野 节点是一个自定义装饰器 TestDecorator,定义了一个 bt.SharedNode 节点类型的共享属性 player :

    @bt.ccclass("TestDecorator")
    export class TestDecorator extends bt.Decorator {
        @bt.property({
            type: bt.SharedNode,
        })
        player: bt.SharedNode = null;
    }
    

    这个时候,我们可以在 Blackboard 黑板面板中增加一个 bt.SharedNode 类型的共享变量key值 targetPlayer

    image-20220415004654161

    并将其指定给 装饰器 TestDecorator中的 player 属性。

    其中 bt.SharedNode 类型定义如下:

    export class SharedNode extends SharedVariable<Node> {
    }
    

    共享变量泛型基类

    export class SharedVariable<T> {
        value: T ;
    }
    

    当运行时反序列化行为树时,会优先实例化 Blackboard 中的共享变量,当节点引用对应key的变量时,可通过key直接获取到该共享对象:

    player: bt.SharedNode = Blackboard.getValiable("targetPlayer");
    node: cc.Node = player.value;
    

Blackboard 黑板

  • 关于黑板的使用,本编辑器参考了 Unity Behavior Designer 的设计理念,具体请参考其官方文档,现摘录部分描述如下:

    翻译过来大意是:

    行为树其中一个优秀的特性是:低耦合,这样每个任务都不需要依赖其他任务去运行。但是低耦合的缺点就是任务节点之间很难进行信息传递,例如,有一个条件任务判断目标对象是否处于可视区域,如果目标处于可视区域就运行动作任务,让主角靠近目标对象,条件任务和动作任务这个时候需要共享一个变量:目标对象。在传统的行为树中是编写一个黑板模块来解决这个问题,在行为树设计师插件中有更加简单的方法来解决,就是在行为树设计师插件提供的变量面板中直接编写变量。

行为树的反序列化

行为树反序列化的意思是,将行为树编辑器导出的 json 数据在运行时解析成一个个行为树子类的过程。这其中有几个问题需要注意:

  • 类的反序列化

    在行为树导出数据时,会把脚本uuid一起导出,在运行时通过uuid反序列化

    const cls = js._getClassById(config.uuid);
    let instance: BehaviorNode = new cls(...);
    
  • 属性的反序列化

    在行为树编辑器 Inspector 面板配置的属性,也需要在运行时反序列化

    let properties = config.properties || {}
    for (const key in properties) {
    	const property = propertys[key];
    	...
    	instance[key] = property.default;
    	...
    }
            
    

    以上属性反序列化只能针对非对象类型的属性有效(boolean、number、string),对于 cc.Node 以及 bt.SharedValiable 类型需要特殊处理

  • Delegate 委托反序列化

    委托对象实例化参考 cc.EventHandler 事件对像的实例化

    let handler = new BehaviorEventHandler();
    handler.target = target;
    handler.component = event.component.name;
    handler.handler = event.method;
    handler.customEventData = event.data;
    handler.invert = event.invert;
    return handler;
    
  • Blackboard 黑板共享变量 bt.SharedValiable 反序列化

    因黑板变量序列化数据中保存的是字符串类型名,因此通过以下方式获取实际的类

    let SharedClass = js.getClassByName(property.TYPE);
    
    let shared: SharedVariable<any> = new SharedClass() as unknown as any;
    

    存储键值映射

    Blackboard.setValiable(property.name, shared);
    

    在使用时可直接获取

    Blackboard.getValiable(key)
    
  • cc.Node 反序列化

    在 Delegate 或共享变量类型为 bt.SharedNode 的数据中,存储的是 node 相应的 uuid 字符串,在运行时需要遍历场景节点树获取,这一点暂时只是通过广度遍历的算法期待节点层级没那么深可以尽快找到对应uuid的节点,目前还没想到其它高效的办法。

结语

  • 关于编辑器集成的分享暂且告一段落,其中还有一些问题需要优化解决。希望大家都能有所收获,有兴趣的同学也可以一起学习交流,共同进步。
5赞

:smiley: 请问一下您说的行为树,
是目前已经集成到您那个 3.x 插件里面了吗?
如果集成了的话,请问是在什么地方看呢?

对,行为树框架的实现是集成到插件里的,不过目前还没发布

1赞

:smile: :smile:
开心, 期待大佬的更新

行为树编辑器 Behavior Creator 终于在商店提审了!

1赞

说话在2.x的插件怎么能做出3.x才有的组件,比如: ui-node-graph,ui-scale-plate,其实在2.x也很需要这种组件来写插件。

这个编辑器分两部分集成
左侧 Inspector 面板和右侧 Blackboard 因为要涉及到场景和脚本,所以在 CocosCreator 插件系统里实现,然后挂载到 dom 节点就可以了;
其它部分是纯 vue 项目,比如右侧面板是在 vue 项目里用的 UI 界面库 iview
要使用 ui-node-graph 的话,除了看看官方有没有办法集成 (ui-kit不开源,自己摸索不到),另一个就是基于上面的思路,找找看有没有类似的 vue 组件自己集成,ui里不涉及到场景里的东西就没问题。
这里 找到个类似的 Vnodes

1赞