首先给大家推荐两篇个人认为最好的,讲行为树的文章。行为树原理
还有Unreal引擎的这篇,应该算是进阶版行为树进阶
然后自己在github上面找了一个相对比较受欢迎的行为树框架BehaviorTree.js
README.md
中有两种方法
- 方法一:一个个建立节点
BehaviorTree.register()
- 用
BehaviorTreeImporter
一次性导入json格式的行为树
第一种方法
有三种状态,actionA
, actionB
, actionC
,并且给与它们一个共同的selector
节点,此树从左向右执行,其中一种状态返回结果为SUCCESS
的话就直接返回整个树的结果 SUCCESS
,如果状态返回的是FAILURE
,那么继续执行右边的节点, 如果所有叶子节点都是FAILURE
,那么整个树返回 FAILURE
。
用一个对象来表示下:
let board = {
actionA: () => console.log("actionA"),
actionB: () => console.log("actionB"),
actionC: () => console.log("actionC"),
};
这里的board
其实就是以后要传到行为树中的blackboard
。
blackboard
是行为树中很重要的概念。我一开始也不太理解,后来想到其实就是黑板的原意。
我们把这个对象的一切行为都写在黑板上向大家展示。然后再输入到行为树的框架中,通过逻辑运算来控制,告知大家黑板上的当前行为在当前状态下如何运行。 我们先注册下每个行为:
const {
BehaviorTree,
BehaviorTreeImporter,
Sequence,
Selector,
Random,
Task,
SUCCESS,
FAILURE,
RUNNING,
} = require("behaviortree"); //npm下载以后先按需require一下
BehaviorTree.register(
"actionA", // 此行为,或者叫任务的名字
new Task({ //行为树会根据条件执行的构造函数
run: (blackboard) => {
blackboard.actionA();
return SUCCESS;
},
})
);
BehaviorTree.register(
"actionB",
new Task({
run: (blackboard) => {
blackboard.actionB();
return SUCCESS;
},
})
);
BehaviorTree.register(
"actionC",
new Task({
run: (blackboard) => {
blackboard.actionB();
return SUCCESS;
},
})
);
我们不满足于简单执行,所以我希望给其中的某一个行为加上一个修饰节点,而我加上的是条件节点。 这个条件节点是我自己写的,目的是,如果为true
,就执行此节点以及此节点为根部的所有叶子节点,如果为false
, 则直接返回 FAILURE
,而不执行这一支的节点。
const { FAILURE, Decorator } = require("behaviortree");
module.exports = class ConditionDecorator extends Decorator {
constructor(props) {
super(props);
this.nodeType = "ConditionDecorator ";
}
setConfig({ isMatch}) {
this.config = {
isMatch
};
}
decorate(run) {
if (this.config.isMatch) return run();
return FAILURE;
}
};
行为树基本上可以取代状态机,并且比状态机有更好的扩展性,以及易读性。
但是Unreal提出一个问题, 行为树真正执行任务的节点都在叶子部位,也就是最底层。而中间的根茎部位都是进行逻辑判断。
如果我在某一时刻,已经知道某个支路是不需要执行的,希望立马跳出,但是不可以,因为一直要执行到最底层的叶子节点才能判断。无论从效率上还是从结构的易读性上都不好。
所以我们需要给这个支路加上一个修饰节点,立马返回结果给上层节点,从而跳过此支路的执行。
const ConditionDecorator = require("./conditionDecorator");
let conditionTask = new ConditionDecorator({
config: { isMatch: true},
node: "actionA",
});//此条件节点叠加在actionA之上
BehaviorTree.register("condition", conditionTask);
再向上叠加一个selector
节点
const mySelector = new Selector({
nodes: ["condition", "actionB", "actionC"],
});
BehaviorTree.register("the root", mySelector);
那这个行为树的结构就变成
把这颗树画好以后,我们就去生成行为树的实例
let bTree = new BehaviorTree({
tree: mySelector, //根节点,已经连接上整个树
blackboard: board,
});
目前conditionTask
的config.isMatch
是 true
, 所以照常执行,如果我们在运行中让config.isMarch = false
那么actionA
将不会执行,直接返回FAILURE
, 因为顶层是 selector
节点,所以接下来会继续执行actionB
。
如果我们想执行这棵树,可以这么写
setInterval(() => {
bTree.step();
}, 1000);
用这种方法并不好,因为会消耗太多的不必要的计算资源。
Unreal 建议我们只有在事件驱动的时候才执行行为树,也就是说有必要才执行,没有必要不应该执行。
如果我们要在运行中不执行actionA
, 那么就要更改conditionTask
的config
setTimeout(() => { //这里用延时代替事件驱动
conditionTask.setConfig({ isMatch: false });
}, 3000);
这时候你会发现actionA
将不会被执行,直接执行actionB
, 这就是我们需要的效果。
这种一个一个task
注册的方法既在这里插入代码片有优点也有缺点。
- 优点:在行为树的运行过程中,你可以修改动态的修改修饰节点的配置,从而达到控制行为树的目的。
- 缺点:就是写起来太麻烦,而且你无法从代码很直观的看到整个行为树长什么样。
第二种方法
JSON
我们希望用一种更加直观的方法,直接给出一整颗行为树,增加易读性,同时减少代码量。
我们用json
格式来表示
let json = {
type: "selector",
name: "the root",
nodes: [
{
type: "condition",
name: "condition",
node: {
type: "actionA",
name: "actionA",
},
isMatch: true,
},
{
type: "actionB",
name: "actionB",
},
{
type: "actionC",
name: "actionC",
},
],
};
当然,我们还是要通过 BehaviorTree.register()
注册task
,不过只需要注册这些反应业务状况的叶子节点就好。不需要注册逻辑节点,诸如 selector
, sequendecoratorce
等,也不需要注册内置的修饰节点decorator
。
BehaviorTree.register(
"actionA",
new Task({
run: (blackboard) => {
blackboard.actionA();
return SUCCESS;
},
})
);
BehaviorTree.register(
"actionB",
new Task({
run: (blackboard) => {
blackboard.actionB();
return SUCCESS;
},
})
);
BehaviorTree.register(
"actionC",
new Task({
run: (blackboard) => {
blackboard.actionB();
return SUCCESS;
},
})
);
当然,如果是自定义的修饰节点,就需要定义一下。
const ConditionDecorator = require("./conditionDecorator");
const behaviorTreeImporter = new BehaviorTreeImporter();
behaviorTreeImporter.defineType('condition', ConditionDecorator);
这里的condition
, 对应于json
数据结构中的 node type
。
在json中,节点之间的关系,也就是逻辑节点和修饰节点的配置已经在数据结构中反应出来了,所以不需要重复注册。
接下来再把json
解析,以及生成行为树的实例对象。
let bTree = new BehaviorTree({
tree: behaviorTreeImporter.parse(json),
blackboard: board,
});
然后运行即可, 运行方法和第一种方法一样。
YAML
我们还可以做一些小小的改进。用YAML
文件来替代 json
。 这样可以进一步简化。把它作为一种独立的文件单独保存,当作游戏资源加载,提升性能和可配置性。
btree.yml
type: selector
name: the root
nodes:
- type: condition
name: condition
node:
type: actionA
name: actionA
isMatch: true
- type: actionB
name: actionB
- type: actionC
name: actionC
然后我们通过npm
,下载一个ymaljs
包,用异步方法来加载yaml
文件,并且转换成json
格式。 然后生成实例再运行行为树。
async function loadBTreeFile(file, blackboard) {
const json = await YAML.load(file);
return new BehaviorTree({
tree: behaviorTreeImporter.parse(json),
blackboard,
});
}
loadBTreeFile("./btree.yml", board).then((bTree) => exec(bTree));
let exec = (bTree) => {
setInterval(() => {
bTree.step();
}, 1000);
};
这样是不是更酷一点呢?
来说说这种方法的优缺点:
- 优点:直接加载一整颗行为树,易读性很好
- 缺点:初始加载的配置是什么样就是什么样。在
runtime
中,无法修改行为树的配置,比如给修饰节点传递参数。
setTimeout(()=>{
let conditionTask = new ConditionDecorator({
config: { isMatch: false }, //把true改成false
node: "actionA",
});
BehaviorTree.register("condition", conditionTask);
})
这里修饰节点的实例,不是当前运行中行为树里修饰节点的实例。因为 在创造behaviorTreeImporter
实例时, 会自行生成逻辑节点和修饰节点的实例。
我思考调试半天,只能用一种黑客方法,黑进行为树来修改。
let tree;
let exec = (bTree) => {
setInterval(() => {
tree = bTree.step();
}, 1000);
};
setTimeout(() => {
tree.blueprint.nodes[0].config.isMatch = false;
}, 3000);
结构非常不清晰,这种方法显然不好。
在github
上和作者沟通后,作者提供了一个不一样的修饰节点,解决了这个问题。
修饰节点:CanJumpDecorator
这个修饰节点之所以可行,是因为我们不必在运行时动态的改变修饰节点的配置项,而是通过直接传入参数 blackboard
,并且指定配置项 check
为blackboard
的某个属性。通过在runtime
改变blackboard
的目标属性, 来动态的改变修饰节点的配置。
我承认,作者比我更聪明,能想到用这种方法。我之前也隐约想到可以通过改变blackboard
的属性来改变行为树的配置,但是我却写不出代码。
很明显,这种方法更好。为你可以在blackboard
的初始化中显示自己的配置项。使得其和行为树一体,一目了然的看出来你想要做什么。如果runtime
中,去改修饰节点的内部配置的话,你就无法在初始状态下,看清楚整个行为树的逻辑是什么。
所以,我推荐各位用第二种方法,直接通过生成behaviorTreeImporter
实例来加载整个行为树,然后通过额外再blackboard
中设置修饰节点的配置项,以达到在runtime
中,动态更新行为树逻辑的目的。
至此之前我们提到的缺点 都已经被完美解决了。
希望各位都能创作出酷炫好玩的游戏哦!
共有条评论 网友评论