behavior-dog 行为树编辑器
本篇文章首发自 Cocos 中文社区,作者:lamdev
今天想向大家介绍一款行为树编辑器插件 behavior-dog。
使用 behavior-dog,你可以在 Cocos Creator 上快速设计并搭建出游戏 AI,助力游戏开发!
behavior-dog 分 2 部分:编辑器和运行时框架。
编辑器
编辑器是一个运行在 Cocos Creator 插件系统上的可视化行为树编辑工具。简单易上手,只需通过简单的鼠标操作,开发者就可以轻松地搭建和编辑行为树。
目前内置了超过 100 个任务(后续会加入更多的内置任务)。
开发者可以自定义任务,打造自己的任务库。编辑器的错误反馈系统可以帮助开发者在运行游戏之前就能发现定位问题,减少调试时间。
编辑器的预览模式可以帮助开发者实时调试游戏的行为。
在编辑器内,还可以通过时间胶囊面板来管理行为树,支持导入、导出、复制、保存时间戳等操作。
状态机编辑模式将于近期上线,敬请期待。
数据格式
behavior-dog 使用 JSON 文件格式来保存和加载行为树数据。
运行时
插件安装成功后,Cocos Creator 的资源管理器会自动挂载名为 behavior-dog-runtime 的文件夹。
该文件夹下是 behavior-dog 的运行时脚本。其中:
BehaviorTree.js 是 Cocos Creator 组件,可被挂载到 cc.Node 节点上。
bt.js 是 behavior-dog 的运行时框架。
代码
behavior-dog 的运行时脚本是 commonjs 模块,他定义了很多实用的 API。
behavior-dog 还自带了运行时脚本的 TypeScript 声明文件 ➠ VSCode 代码提示无压力。
开发者可以通过编写 TypeScript 代码,轻松实现行为树组件、任务和共享变量的扩展。
更新
v1.1.0
[ADD] 工具栏添加 窗口置顶按钮 。
[ADD] 支持 声明 cc.Asset 属性 。
[ADD] 增加 子树功能 。
在线文档
[在线文档]
插件商店
[插件商店]
下文摘自 behavior-dog 文档的快速入门章节。
下面我们将从零开始,引导大家使用 behavior-dog 来搭建一个简单的行为树,实现点击按钮时,按钮自动旋转的功能。
希望能通过这个例子,帮助大家初步了解到 behavior-dog 的一些相关知识。
创建行为树
编辑行为树
注册任务
添加属性
添加方法
完整任务脚本
解决错误
生命周期
真・添加方法
打开编辑器
打开行为树
第一个任务
第二个任务:捕获按钮的点击事件
第三个任务:旋转节点
预览
第一次预览
第二次预览
运行时调试
继续编辑行为树
创建共享变量
改造旋转任务
完善行为树
完善属性设置,解决错误
终极预览
创建行为树
先创建一个行为树。
假设在 Canvas 节点下有一个名为 Demo 的节点。
添加 BehaviorTree 组件到 Demo 节点上。
然后新建一个空的 JSON 文件。命名为 test.json :
// test.json
{}
将 test.json 拖到 BehaviorTree 组件的 asset 属性上。
按 ctrl-s 保存场景。
这样就创建好了一个空的行为树。
编辑行为树
接下来,开始搭建行为树。
打开编辑器
选择 Cocos Creator 主菜单的 扩展 ➠ behavior-dog ➠ open 打开编辑器。
打开行为树
不出意外的话,你应该能在编辑器的欢迎界面看到刚刚新建的行为树。
欢迎界面会列出当前场景里的所有行为树实例。在本例中,该列表里只有一个名字为 Behavior Tree 的行为树,被挂载在 Canvas/Demo 这个节点上。
点击列表上行为树的名字,就可进入编辑模式了。
下图就是我们这次要编辑的行为树啦。初始状态下,他只有一个入口任务 Entry。这个任务比较特殊,它在属性检查器上是不可见的,你不能修改它的属性,也不能移动它的位置。
第一个任务
开始创建任务了。
点击 Factory 选项卡,出来一个任务列表,里面包含了所有可被创建的行为树任务。
目前 behavior-dog 内置了 100 个任务(后续会有更多任务加入进来)。
他们被分成 5 个组:
Action 行动任务分组
Condition 条件任务分组
Composite 组合任务分组
Decorator 装饰任务分组
Intercepter 拦截任务分组
默认都为折叠状态。
拖拽任务到绘图区即可生成对应的任务卡(一个任务卡表示一个任务实例)。
比如我们接下来要创建的第一个任务 Sequence 。
他在 Composite 分组里,找到他并把他拖到绘图区。
此时 Sequence 任务是一个模糊化的状态,因为他还没与行为树连接。
模糊化是一个可选项,勾掉 behavior-dog 工具栏菜单的 Blur In-Active Tasks 可以取消模糊化。
接下来,连接 Sequence 任务与 Entry 任务,使其成为 Entry 任务的子任务。
点击 Entry 任务的父锚点,拉出连接线,拖到 Sequence 的子锚点即可。
温馨提示
连接线只能从父锚点处拉出,从子锚点处断开。
第二个任务:捕获按钮的点击事件
在 behavior-dog 中,有 2 种方法可以捕获按钮的点击事件:
1. 通过 Cocos Creator 的属性检查器向 cc.Button 组件添加事件回调
当按钮被点击时,行为树会向内部广播一个事件,使用诸如 OnClick 或 OnEvent 的任务可以捕获到他。
2. 使用 OnClick 任务监听指定 cc.Node 节点上的 "click" 事件。
OnClick 任务在 Condition/UI 分组里。
接下来,就有必要简单说说行为树的事件系统了。
在行为树中,从事件的传递方式上可以分为 3 种不同类型的事件:
任务事件。此事件只在指定任务上触发,不传递给其他任务。
冒泡事件。此事件在指定任务上触发后,会继续向上传递给父任务。
广播事件。此事件在指定任务上触发后,会继续向下传递给子任务。
刚刚我们提到的第 1 种方法所产生的事件,就是一个广播事件。他从行为树的顶端向下传递,因此所有任务都可以收到该事件。
那么在行为树中是如何捕获事件的呢?
通过在任务脚本里实现事件处理函数 onEvent 来捕获事件。
此为行为树任务的通用事件处理函数,可以中断事件传递。
通过 on 方法向一个任务注册事件回调。
注册事件回调可以用于监听指定任务上触发的事件,但不能中断事件的传递。
使用内置条件任务 OnEvent 。
此任务是通用的事件响应任务。他可以捕获任何你想要捕获的事件。
使用内置条件任务 OnCCEvent 。
此任务是通用的 cc.Node 事件监听任务。
使用内置条件任务 OnClick 。
继承自 OnCCEvent ,专门捕获点击事件。
使用 OnClick 任务
其实,不管是上面提到第 1 种方法还是第 2 种,都推荐使用 OnClick 任务来捕获事件。
区别是:
第 1 种方法捕获到的是标准的行为树广播事件。
第 2 种方法捕获到的是 cc.Node 事件(需要为任务指定 cc.Node 节点)。
这里我们使用第 2 种方法。
如图,选中 OnClick 任务和 Task 属性检查器 。
这时我们发现, OnClick 任务收到编辑器的。
一条错误消息:connection error
一条警告消息:prop "target" not set 。
他们的意思分别是,连接错误 和 属性 target 的值无效 。
关于错误和警告消息的说明,请参考文档的调试章节。
为什么会收到这条错误呢?当然是因为还没将 OnClick 连接到行为树啦.....
为什么会收到这条警告呢?当然是因为我们还没赋值给属性 target 啦......
接下来解决错误:
连接 OnClick 与 Sequence,使其成为 Sequence 的子任务。
然后解决警告,将按钮节点拖到 target 属性上。
之所以是警告,不是错误,是因为对于 OnClick 来说, cc.Node 节点不是必需的,因为他不仅捕获 cc.Node 事件,也捕获行为树事件。
这样,当 Sequence 任务执行到 OnClick 任务时,如果 OnClick 任务捕获到了点击事件,他就会返回成功,告知 Sequence 任务可以执行下一个子任务了。
第三个任务:旋转节点
内置任务可以极大地帮助开发者搭建行为树,但还不够。
一般来说,开发者还是需要通过自己自定义任务来执行特定操作的。
在 behavior-dog 的框架中,行为树任务分为 5 大类:
ActionTask :行动任务基类,属于叶子节点,通常用来做具体的行为。
ConditionTask :条件任务基类,属于叶子节点,通常用来检查某个状态。
CompositeTask :组合任务基类,属于分支节点,以某种规则执行自己的子任务。
DecoratorTask :装饰任务基类,属于分支节点,只有一个子任务。在子任务执行之后,根据子任务的执行结果决定自身的状态。
IntercepterTask :拦截任务基类,属于分支节点,只有一个子任务。在子任务执行之前,对子任务的执行施加影响,可中断子任务或改变子任务的行为。
所有行为树任务都继承自 5 大基类中的一个,比如上一节中的 OnClick 任务就派生自条件任务 ConditionTask 。
而这一节我们要实现的旋转节点任务,就属于行动任务 ActionTask 的范畴。
下面我们来动手实现它。
注册任务
新建一个名为 Rotate.ts 的文件,并定义类 Rotate 如下:
// Rotate.ts
import { btclass, ActionTask } from 'bt';
@btclass('Rotate', 'Action/Custom', {
description: '旋转一个 cc.Node 节点。'
})
export class Rotate extends ActionTask {
//
};
通过类装饰器 @btclass,我们可以注册一个行为树任务。
@btclass 接受 3 个参数。
类名。第一个参数是必填项,该名字会显示在 Factory 选项卡和 Task 属性检查器上。
分组名。第二个参数是可选的,支持子分组,父子分组用斜杆 / 隔开。
选项。第三个参数也是可选的。本例中, Rotate 任务定义了 description 选项来描述他的行为。
本例中,为 Rotate 任务定义了分组 Action/Custom ,其中 Custom 是 Action 的子分组。
如果不提供分组名,将继承父类的分组。比如 Rotate 任务,如果类装饰器的第二个参数为空,则自动继承父类 ActionTask 的分组,因此 Rotate 任务的分组将是 Action 。
注册完任务,你就可以在 Factory 选项卡的对应分组里找到他了。
添加属性
接下来,向 Rotate 类添加属性。
// Rotate.ts
import { btclass, btprop, ActionTask } from 'bt';
@btclass('Rotate', 'Action/Custom', {
description: '旋转一个 cc.Node 节点。'
})
export class Rotate extends ActionTask {
@btprop({
type: cc.Node,
required: true
})
public target: cc.Node;
@btprop()
public step: number = 5;
};
属性装饰器 @btprop 用来注册一个可被序列化的属性。该属性可以在 Task 属性检查器上查看和修改。
与 Cocos Creator 自带的组件属性装饰器 @property 一样,@btprop 装饰器也接受一系列选项对属性进行装饰。为了贴合 Cocos Creator 的使用习惯,有些选项的名字和功能和 @property 一样,比如,type 和 min。同时,@btprop 也引入了一些 @property 不支持的选项,比如 required。
本例中,我们注册了 2 个属性:target 和 step。
其中,target 的数据类型是 cc.Node,同时也是一个 required 属性。他要求 target 的值不能为空,如果为空,编辑器会有错误提示。
关于错误和警告的说明,请看文档的调试章节。
step 的数据类型没有通过 type 定义,将从默认值自动推断为 number。
添加方法
生命周期
说到方法,就不得不提生命周期了。
其中有 4 个方法跟行为树的生命周期有关:
onLoad 在整棵行为树加载完成后调用,且只被调用一次。
onReady 在行为树第一次执行前调用,且只被调用一次。
onDestroy 在行为树要被销毁之前调用,且只被调用一次。
onBehaviorComplete 在行为树执行结束时调用。
另外 4 个方法则为任务本身的生命周期函数:
onEnter 在任务开始时调用。他有一个布尔返回值,当返回 false 时,任务失败。
onUpdate 在任务开始后调用。他有一个 Status 返回值,表示任务的执行结果。(下面单独讲)
onAbort 在任务运行被打断时调用。
onExit 在任务结束时调用。
现在重点讲 onUpdate 函数。
每个任务都可以实现 onUpdate 函数,他的主要作用是执行任务的核心逻辑,返回执行结果。
执行结果有以下 3 种:
// bt.js
enum Status {
/**
* 失败。
*/
FAILURE = 0,
/**
* 成功。
*/
SUCCESS,
/**
* 运行中。
* 条件任务不支持此状态。
*/
RUNNING
};
对于叶子节点来说,除非特殊情况,一般都需要实现 onUpdate 函数,如果不实现,默认返回失败。
而分支节点都默认实现了 onUpdate 函数。
当 onUpdate 的返回值是 RUNNING 时,表明任务处于运行状态。行为树会在下一帧执行此任务时跳过 onEnter 直接调用 onUpdate。
真・添加方法
了解完生命周期函数,我们可以开始为 Rotate 任务添加方法了。
比如我们想让节点不停地旋转,只要在 onUpdate 处修改节点的 angle 属性,然后返回 RUNNING 就可以了。
// Rotate.ts
protected onUpdate(): Status {
if (this.target) {
//
// 每帧旋转 step 角度。
//
this.target.angle += this.step;
//
// 持续旋转,返回 RUNNING 。
//
return Status.RUNNING;
} else {
this.log.e('未指定 cc.Node 节点');
//
// 旋转失败,返回 FAILURE 。
//
return Status.FAILURE;
}
}
完整任务脚本
到这一步,旋转任务的脚本就算完成了。下面是完整代码:
// Rotate.ts
import { btclass, btprop, Status, ActionTask } from 'bt';
@btclass('Rotate', 'Action/Custom', {
description: '旋转一个 cc.Node 节点。'
})
export class Rotate extends ActionTask {
@btprop({
type: cc.Node,
required: true
})
public target: cc.Node;
@btprop()
public step: number = 5;
/**
* 任务的生命周期函数 onUpdate 。
*/
protected onUpdate(): Status {
if (this.target) {
//
// 每帧旋转 step 角度。
//
this.target.angle += this.step;
//
// 持续旋转,返回 RUNNING 。
//
return Status.RUNNING;
} else {
this.log.e('未指定 cc.Node 节点');
//
// 旋转失败,返回 FAILURE 。
//
return Status.FAILURE;
}
}
};
解决错误
Rotate 任务出现的错误跟 OnClick 任务差不多。
先解决掉 connection error。
然后是 prop "target" not set。
到此一个简单的行为树就搭建好了。
预览
运行看看效果。
behavior-dog 内置了一个预览按钮,在工具栏上可以找到。
点击预览按钮会在浏览器中运行预览游戏,这点和 Cocos Creator 工具栏上的那个预览功能一样。
不同的是,通过 behavior-dog 工具栏上的预览按钮运行游戏时,将进入预览模式。
预览模式下,除了属性检查器外,编辑器禁止任何修改行为树的操作。
此时在浏览器中运行的行为树任务,会将执行结果实时反馈到 behavior-dog 编辑器上。
具体看任务卡右下角的图标,从中可以判断出当前任务的执行状态。
➠ 任务成功。 ➠ 任务失败。 ➠ 任务正在运行,所在分支为运行分支。 ➠ 当前任务所在分支不是运行分支,但任务被执行了,且返回成功。 ➠ 当前任务所在分支不是运行分支,但任务被执行了,且返回失败。
第一次预览
点击预览按钮。顺利的话,你将看到下图这样。
从 OnClick 右下角的图标可以得知,他失败了,因为没有捕获到点击事件。
接下来, 我们点击一下按钮看看效果。
What?! OnClick 没有反应。
这是为什么?
原来,行为树第一次评估 OnClick 任务时得到了失败的结果,然后行为树就结束了。默认情况下,行为树结束之后并不会重新执行,之后产生的点击事件也就不会被捕获到了,因为 OnClick 只在开始时被执行了一次。
原因知道了,那怎么解决呢?方案有很多,这里我们介绍一个让行为树结束之后重新执行的方法。
点击 Behavior 属性检查器,找到属性 restartWhenComplete,打上勾。
编辑器会自动保存修改。如果此时是预览模式,编辑器会将修改同步到游戏中。
补充
在本例中,除了让行为树重新执行之外,我们还需要修改 OnClick 任务的一个属性,那就是捕获策略 catchPolicy(他告诉 OnClick 在什么情况下才捕获点击事件)。
他的默认值是 BEHAVIOR_RUNNING ,表示行为树状态为 RUNNING 时才捕获点击事件。
而此时,行为树的状态并不是 RUNNING ,而是 START ➠ COMPLETE ➠ RESTART ➠ COMPLETE ......
因此 OnClick 是捕获不到点击事件的。
我们需要将 catchPolicy 设置成 ANYTIME(表示随时捕获点击事件),才能顺利捕获他。
第二次预览
重新开启预览,点击按钮,相信你会看到不一样的结果。
运行时调试
上一节我们提到,在预览模式中,编辑器会将对属性的修改同步到游戏中。
我们来测试一下。假设你已经点击了按钮,此时他正在旋转中。
选中 Rotate 任务,将属性 step 的值改为 20,看看按钮的转速是不是变快了。
继续编辑行为树
我们来修改一下上面这个行为树,让按钮每隔一段时间自动切换转速 。
大体思路是这样的, 在执行 Rotate 任务的同时,执行另一个任务,这个任务负责每隔一段时间修改 Rotate 任务的属性 step,以改变他的转速。
这里涉及到的一个问题是,一个任务怎么修改另一个任务的属性?答案有很多,比如可以通过引用一个任务来达到这个目的,也可以通过共享数据的方式来实现。
在 behavior-dog 中,支持使用 黑板 和 共享变量 这两种方式来共享数据。
这里着重介绍一下共享变量的使用。
创建共享变量
点击 Share 选项卡,出来 2 个共享变量列表。在这里你可以创建、删除和修改共享变量。
2 个列表分别存放不同作用域下的共享变量。而作用域决定了共享变量对哪些行为树可见。
目前支持 2 个作用域:
tree 作用域下,共享变量只对在当前行为树可见。
scene 作用域下,共享变量对当前场景下的所有行为树可见。
本例中,我们需要创建的是作用域 tree 的共享变量,因为他将只被当前行为树里的任务引用。
按照下图里的标示,点击 Create 菜单选项,呼叫出共享变量的创建框。
选择类型 SharedNumber ,因为我们需要储存一个 number 类型的变量。
然后是取名字,就叫他 Rotation Step 吧。在创建共享变量时,名字是必填项。
点击 Create 按钮完成创建。
共享变量列表将自动刷新。
改造旋转任务
要使用共享变量,我们需要稍微修改下 Rotate 脚本,将 step 的数据类型从 number 改为 SharedNumber 。直接上改造后的代码吧。
// Rotate.ts
import { btclass, btprop, Status, ActionTask, SharedNumber } from 'bt';
@btclass('Rotate', 'Action/Custom', {
description: '旋转一个 cc.Node 节点。'
})
export class Rotate extends ActionTask {
@btprop({
type: cc.Node,
required: true
})
public target: cc.Node;
@btprop({
type: SharedNumber,
required: true
})
public step: SharedNumber;
/**
* 行为树任务的生命周期函数 onUpdate 。
*/
protected onUpdate(): Status {
if (this.target && this.step) {
//
// 1) 从共享变量 step 读取角度值。
// 2) 旋转 cc.Node 节点。
//
this.target.angle += this.step.value;
//
// 持续旋转,返回 RUNNING 。
//
return Status.RUNNING;
} else {
this.log.e('未指定 cc.Node 节点');
//
// 旋转失败,返回 FAILURE 。
//
return Status.FAILURE;
}
}
};
此时再看编辑器上的属性检查器,属性 step 对应的 UI 控件由数字输入控件变为了共享变量选择控件。
点击共享变量选择器,弹出一个下拉列表。出现在该列表里的都是 SharedNumber 或继承自 SharedNumber 的共享变量实例。比如,名为 Rotation Step 的共享变量实例。
点击共享变量选择器右边的眼睛,弹出一个下拉框。这个下拉框是该共享变量的属性检查器,我们可以通过他修改共享变量的属性。
比如,给 Rotation Step 设置一个初始值 5。
共享变量选择器最右边的数字是他的引用计数,乃调试的一大助手,在这里就不展开讲了,感兴趣的同学可以看文档的调试章节。
完善行为树
剩下的部分通过引入内置任务就可以完成,不用自定义脚本。
引入 7 个任务:
1 个 Composite 分组下的 Parallel 任务,用于并行执行子任务。
1 个 Decorator 分组下的 Repeater 任务,用于循环执行子任务。
1 个 Intercepter 分组下的 Delay 任务,用于延迟执行子任务。
1 个 Composite 分组下的 RandomTicker 任务,用于随机执行且只执行一个子任务。
3 个 Action/Operator 分组下的 SetNum 任务,用于赋值操作。
找到他们,然后像下面这样完成最终行为树的搭建。
完善属性设置,解决错误
双击 Delay 任务,修改 delayTimeDft 的值为 1,表示延迟 1 秒执行 RandomTicker 这个子任务。
此时不用理 delayTime 这个共享变量属性,因为我们不需要动态改变延迟时间。而当 delayTime 的值为空时,Delay 任务会默认使用 delayTimeDft 作为固定的延迟时间。
细心的同学应该还发现了一个 prop "delayTimeDft" not set 的警告消息。这是因为,当 delayTimeDft 为 0 时,表示不延迟,直接执行子任务。而这么做的话,相当于 Delay 任务成了一个摆设,所以警告一下意思意思。
接下来,依次点击 3 个 SetNum 任务, lhs 属性都选择 Rotation Step , rhsDft 属性分别修改为 20 、 0 、 5。
SetNum 其实是一个计算任务,除了赋值外,还支持 +=、 -=、 *=、 /= 等计算操作。
这里我们只使用他的赋值操作。
他接受一个共享变量 lhs 作为计算表达式左边的值。而表达式右边的值可以是另一个共享变量 rhs,也可以是一个在编辑阶段就指定好的值 rhsDft。
计算结果会赋值给 lhs。也就是说 lhs 共享变量里储存的值会被 SetNum 任务改变。
本例中, SetNum 任务的 lhs 属性与 Rotate 任务的 step 属性都引用同一个共享变量 Rotation Step,因此当 SetNum 完成计算任务后,Rotate 任务的旋转角度也会跟着改变。
终极预览
最后来个动图吧。
最后
感谢 Cocos Creator 提供平台。
参考链接
[在线文档]
http://lamdev.gitee.io/behavior-dog/
[插件商店]
https://store.cocos.com/#/resources/detail/2357
以上是由 Cocos 开发者 lamdev 分享的技术教程,欢迎各位开发者点击【阅读原文】查看原文,与作者进行交流学习!
如果您在使用 Cocos 引擎的过程中,获得了独到的开发心得、见解或是方法,并且乐于分享出来,帮助更多开发者解决技术问题,加速游戏开发效率,期待您与我们联系!