【游戏程序设计】游戏智能角色研究——决策结构之行为树
游戏智能角色研究——决策结构之行为树
摘要
本文从游戏中常见的NPC交互以及Stendhal中的有限状态机开始思考,对“游戏NPC交互”中重点关注的“行为决策”问题进行了研究,深入探讨了负责决策的行为树构建原理,并结合开源的行为树构建框架BehaviorTree.CPP进行了实战操作,编写了一颗基础的行为树,实现了节点之间的通信。
关键词:游戏AI、NPC、决策系统、交互系统、FSM、行为树
正文
1. 引言
开放世界游戏,或者说RP冒险类游戏已然成为当今游戏界的主流之一。无论是享负盛名的“塞尔达—英雄传说”、闻名遐迩的“赛博朋克2077”还是近来大火的“原神”,都为无数玩家带来了新奇刺激的游戏体验。
在这些开放世界游戏中,我们可以与游戏中的角色进行各种各样的互动:野猪一旦发现你就会迅速逃离,NPC会因为情况不同而与你进行不同的对话,当你在开放世界里晕头转向时,可以让小仙灵带着你走出迷雾。这些交互功能都可以称为“游戏AI”。那游戏中的AI是如何实现的呢?
我们先来看看开源游戏Stendhal是如何完成NPC交互的。
在stendhal/src/games/stendhal/server/entity/npc/
目录下,存放着决定游戏NPC行为的代码。
.../entity/npc/
├── action/ # 具体处理行为请求的操作
│ └── ...
├── behaviour/ # 定义行为请求并做出解析
│ └── ...
├── condition/ # 用于条件判断
│ └── Engine.java
│ └── ...
├── fsm/ # 有限自动机(finite state machine)驱动
│ └── ...
├── interaction/ # NPC聊天/跟随控制
│ └── ...
├── NPC.java # NPC抽象父类
├── ...
易知,Stendhal使用FSM构造游戏内的AI,若想了解Stendhal中的FSM具体如何运转,可以先从Engine.java
类入手,它是游戏FSM的核心:
/**
* 一个有限自动机
*/
public class Engine {
private final SpeakerNPC speakerNPC;
// 有限自动机状态转移表
private final List<Transition> stateTransitionTable = new LinkedList<Transition>();
// 当前FSM状态
private ConversationStates currentState = ConversationStates.IDLE;
// 下面提供FSM基本方法,除了FSM自身构造方法以外,还有:
// 为FSM新增一个转移 add()
// 为FSM移除一个转移 remove()
// 获得当前状态下已经注册的转移 get()
// 寻找句子对应的当前状态的转移 matchTransition()
// 获得/设置当前状态
// FSM做一次转移 step()
// ......
//详情还请参照源码
}
再看stendhal/tests/games/stendhal/server/entity/npc/NPCTest.java
,这里的测试代码从宏观角度展示了NPC如何使用FSM进行交互,尽管省略了很多细节,但足以体现FSM的使用。
/*
* 一段用于测试NPC的代码
*/
public class NPCTest extends ZonePlayerAndNPCTestImpl {
private static final String ZONE_NAME = "int_ados_felinas_house";
// 做好测试前的准备工作
@BeforeClass
public static void setUpBeforeClass() throws Exception {
CatTestHelper.generateRPClasses();
QuestHelper.setUpBeforeClass();
setupZone(ZONE_NAME);
}
public NPCTest() {
setNpcNames("Felina");
setZoneForPlayer(ZONE_NAME);
addZoneConfigurator(new CatSellerNPC(), ZONE_NAME);
}
/**
* NPC打招呼测试
*/
@Test
public void testHiAndBye() {
final SpeakerNPC npc = getNPC("Felina");
final Engine en = npc.getEngine();
// 玩家发言:hi Felina,状态机接受句子,进行一次转移
assertTrue(en.step(player, "hi Felina"));
// NPC将回复:"Greetings! How may I help you?"
assertEquals("Greetings! How may I help you?", getReply(npc));
assertTrue(en.step(player, "bye"));
assertEquals("Bye.", getReply(npc));
}
......
}
关于Stendhal的FSM具体实现以及FSM的特点我在这里不再做详细介绍,因为这篇论文不是Stendhal的源码阅读笔记,而且已经有一篇博客详细地讲述了状态模式、FSM的知识。
下面我将把重点放在更为激动人心的复杂游戏AI构建结构——行为树上。因为行为树相较于FSM,不仅可维护性更强,且更为流行,现在已经成为了现代游戏AI的主流决策架构。
2. 决策结构——行为树
2.1 行为树简介
顾名思义,行为树是一种用于控制AI决策行为的、包含层级节点的树状结构。
树的叶子节点,是AI具体行为的指令,而连接树叶的非叶节点,决定了AI如何根据不同的情况从树的顶端,沿着某条路径,来到最终的叶子这一过程。
行为树的遍历:行为树会逐层对节点进行依次检查,每一层都将花费一个 tick 的时间,若树的层数较多,花费的时间也会较大。
行为树由多种不同类型的节点构成,一般规定所有节点都有一个共同的核心函数execute()
或tick()
,它会返回三种状态中的一个为结果,分别为:
- 成功-Success
- 失败-Failure
- 运行中-Running
Success与Failure的结果很好理解,需要强调的是Running节点。它代表“还在运行中、结果未决定,需要在下一个tick继续检查运行结果的节点”。有很多场景需要这个节点,比如:“寻路”是一个持续的动作,一个负责“行走”的节点会在寻路的过程中持续返回“Running”,如果寻路过程中被障碍物阻挡,这个节点就会返回“Failure”,如果角色到达了目的地,则返回“Success”以表示寻路的指令已经完成。
一个节点的基类可抽象如下:
//节点类(基类)
class Node{
//...
public:
virtual String excute(); //执行函数,返还 成功/失败/运行中
//...
};
主流的行为树实现,将节点主要分为了三大类型,分别为:
-
组合节点(Combination Node)
非叶节点,用于控制遍历路径的走向
组合节点类型众多,稍后本文将选取关键类型进行介绍
-
修饰节点(Decorator Node)
非叶节点,负责修饰(辅助)其他节点,修饰的节点为其子节点
-
叶节点
条件节点(Condition Node),提供条件的判断结果
行为节点(Action Node),执行智能体的行为
三大类型关系如下图:
借助这些节点,我们能构建出复杂的行为树:
2.2 组合节点
组合节点又称“控制节点”,负责管理子节点的执行。作为非叶节点,控制节点即能控制行为节点,也能控制控制节点。
作为非叶节点,拥有基类定义如下:
class NonLeafNode : public Node {
std::vector<Node*> children; //子节点群
public:
void addChild(Node*); //添加子节点
virtual String excute();
};
(1).选择节点(Selector)
你可以将选择节点理解为一个“或门”,选择节点会在任一个子节点返回Success时返回Success,并且不在运行后续的子节点。相应的,只有所有节点返回Failure时,选择节点才会返回Failure。
class SelectorNode : public NonLeafNode{
public:
virtual String excute()override{
for(auto child : children){
//如果有一个子节点执行成功,则跳出
if(child->excute() == "Success"){break;}
}
return "Failure";
}
};
(2).顺序节点(Sequence)
选择节点与顺序节点相反,你可以将其理解为“与门”,顺序节点会按照顺序访问所有子节点,如果所有子节点都返回Success,则顺序节点返回Success;与之相对,如果遍序期间任何一个子节点返回Failure,则顺序节点返回Failure。
顺序节点最显而易见的用法就是执行一段有前后依存关系的行为,执行过程中任意一个行为的失败必然导致后续的动作没有进行的意义。
例如:开门—>进入房间—>拿取房间内物品 。则三个动作时需要保证绝对连贯。
class SequenceNode : public NonLeafNode{
public:
virtual bool excute()override{
for(auto child : children){
//如果有一个子节点执行失败,则跳出
if(child->excute() == false){break;}
}
return true;
}
};
(3).并行节点(Parallel)
如果希望我们的游戏AI同时执行多个动作(比如一边说话,一边走路)时就会使用到这个控制节点。
class ParallelNode : public NonLeafNode{
public:
virtual bool excute()override{
//执行所有子节点
for(auto child : children){
child->excute();
}
return true;
}
};
除了上面三种较常用节点以外,组合节点还有诸多变种,如
-
随机节点:随机执行一个子节点,用于模仿人类的随机行为
-
随机顺序节点:按照随机顺序执行若干子节点
-
权值选择节点:执行权值最高的节点
2.3 修饰节点
修饰节点,顾名思义是用来修饰子节点的节点。下面的两个例子,分别做了取反、重复执行子节点若干次的操作,均为修饰节点。
//取反节点-Inverter 示例class InvertNode : public OneChildNonLeafNode{public: virtual String excute()override{ return child->excute()=="Success?"?"Failure":"Success"; }};
//重复执行节点-Repeater 示例class CountNode : public OneChildNonLeafNode{ int count;public: virtual String excute()override{ while(--count){ if(child->excute() == "Failure")return "Failure"; } return true; }};
除了上面提到的Inverter
和Repeater
,还有其他修饰节点,如:
-
成功节点-Succeeder:不管其子节点返回结果如何,它总是返回Success。成功节点往往与顺序节点组合使用。
-
重复直至失败节点-Repeat Until Fail:重复执行子节点,并在子节点Failure的时候返回Failure。
你也许会好奇:有没有与成功节点相对的“失败节点”?事实上,一个取反节点加上成功节点就可以达到这一效果。由此可以看出,修饰节点是可以相互叠加,达到增强效果的。
2.4 叶节点
(1).行为节点
行为节点代表游戏AI行为的叶节点,常见的行为如:站立、射击等,都由行为节点定义。
行为细分下去又分为“瞬时行为”与“持续行为”。对于持续行为,还需额外考虑将运行持续行为的行为树挂起,去运行其他代码,以避免出现CPU阻塞现象。在Unity中,会使用“协程”达到异步执行,避免阻塞的效果。
//行为节点类(基类)
class BehaviorNode : public Node{public: virtual String excute(); //执行节点};
//举例:移动行为节点
class MoveTo : public BehaviorNode{
public: virtual String excute()override{
...
//让智能体启动移动行为 ...
//协程暂时挂起直到持续时间结束
return "Success";
}};
(2).条件节点
“前提条件”指的是:执行行为前必须满足的条件。用于判断当前环境状态是否被满足。
但是每个节点的“前提“各有不同,若要为每个行为节点加前提条件并降低耦合,一个可行的做法是让行为节点带上一个专门用于判断环境条件的函数接口(您可以参考策略模式)。
但有一个更加成熟的做法,就是把前提条件抽象分离成行动节点类型,称为“条件节点”,并将其当作叶节点混入行为树,条件判断的结果将交给控制节点处理。
示意代码如下
// 这里的顺序节点(se)能够让其所有子节点依次运行,若运行到其中一个子节点失败则不继续往下运行。这样可以实现出不满足条件则失败的效果。class ConditionNode : public Node { std::function<String()> condition; //前提条件public: virtual String excute()override { return condition(); }};
2.5 黑板模式
无论是FSM还是行为树,都避免不了讨论黑板模式。黑板模式的存在意义是为了解决信息交流问题,而行为树的节点之间正好存在着巨大的通信需求,特别是序列节点。
比如我们有一个简单的顺序控制节点,它控制着一套“攻击行为”。第一个子行为节点做了“选择目标”这个动作,第二个节点负责“攻击”,那么第二个节点怎么知道该攻击哪个对象呢?
想象我们有一块黑板,节点一选定目标后,便将目标写在黑板上,节点二只需要阅读黑板上的内容,就可以向目标发动一次无敌的攻击了!
行为树中黑板模式的实现方法就像这样,通常是在两个节点中分别创建一个指向共享对象的引用。(在Unity Behavior Design这个行为树插件里,实现信息交流的方式更加形象——你只需要创建一个变量,然后把它拖到第一个和第二个节点里)
提供一个黑板模式的简单例子以方便理解。
//BlackBoard.h//黑板类class BlackBoard {private: //黑板计时器 struct BlackBoardTimer { float timer; std::string key; std::any value; };protected: std::map<std::string, std::any> mDatas; std::list<BlackBoardTimer> mTimers;public: BlackBoard(); ~BlackBoard(); //写入数据 void setValue(std::string key, int value); //写入数据并设置数据时间 void setValue(std::string key, int value, float expiredTime, int expiredValue); void setValue(std::string key, std::string value); //访问数据 int getInt(std::string key); std::string getString(std::string key); //更新时间 void update(float dt);};
#include "BlackBoard.h"BlackBoard::BlackBoard(){}BlackBoard::~BlackBoard(){}// 写入数据void BlackBoard::setValue(std::string key, int value){ mDatas.emplace(key, value); }void BlackBoard::setValue(std::string key, int value, float expiredTime, int expiredValue){ setValue(key, value); mTimers.emplace_back(BlackBoardTimer{ expiredTime,key,expiredValue });}void BlackBoard::setValue(std::string key, std::string value){ mDatas.emplace(key, value);}//-----------------------// 访问数据int BlackBoard::getInt(std::string key){ auto & value = mDatas.at(key); return std::any_cast<int>(value);}std::string BlackBoard::getString(std::string key){ auto& value = mDatas.at(key); return std::any_cast<std::string>(value);}void BlackBoard::update(float dt){ auto itr = mTimers.begin(); while(itr != mTimers.end()) { itr->timer -= dt; if (itr->timer <= 0.0f) { mDatas[itr->key] = itr->value; itr = mTimers.erase(itr); } else { ++itr; } }}
3. 开源框架BehaviorTree.CPP
3.1 框架简介
BehaviorTree.CPP是一个使用C++14库构建的、用于创造行为树的框架,它灵活、反应快速且便于使用。您可以用这个库来构建游戏AI,或者替换应用程序中的FSM。
BehaviorTree.CPP有以下核心优点
-
使用异步Action,不会出现阻塞
-
树使用XML表示,并在代码运行时动态创建(简单来说就是树不是硬编码的)
-
提供一种安全且灵活的机制以保证节点之间的数据交互
-
拥有日志/分析功能,允许用户可视化、记录、重放、分析状态转换过程
BehaviorTree.CPP所包含的节点类型在上文中已经有过介绍,若您想了解得更加详细,可以阅读BehaviorTree.CPP官方文档中行为树的介绍部分。下面我将介绍BehaviorTree.CPP的使用方法。
3.2 安装
BehaviorTree.CPP依赖boost
和zmq3
,所以需要先进行安装
sudo apt-get install libboost-all-dev #可直接使用该命令安装所有套件sudo apt install libzmq3-dev#或者sudo apt install libboost-devsudo apt install libboost-coroutine-dev # 需要用到协程sudo apt install libzmq3-dev
在BehaviorTree.CPP文件夹下执行以下命令以编译和安装库。
mkdir build; cd build cmake .. make sudo make install
安装成功后,在/usr/local/include/
目录下将出现buhaviortree_cpp_v3
。
3.3 XML行为树构建语法
已经知道了BehaviorTree.CPP借助XML构建行为树,为例方便介绍语法,给出XML示例如下:
<!-- my_tree.xml --> <root main_tree_to_execute = "MainTree" > <BehaviorTree ID="MainTree"> <Sequence name="root_sequence"> <SaySomething name="action_hello" message="Hello"/> <OpenGripper name="open_gripper"/> <ApproachObject name="approach_object"/> <CloseGripper name="close_gripper"/> </Sequence> </BehaviorTree> </root>
介绍语法如下:
-
树的第一个标签是
<root>
。它包含一个或多个<BehaviorTree>
标签。 -
<BehaviorTree>
应具有属性[ID]
,它是<BehaviorTree>
的唯一标识。 -
标签
<root>
应包含属性[main_tree_to_execute]
,代表该ID的行为树将作为执行入口。若<root>
下有多个树,则该属性必填。 -
每个TreeNode由单个标签表示:
- 标签的名称是用于在工厂中注册
TreeNode
的ID
。 - 属性
[name]
为引用实例的名称,可选。 - 节点使用属性配置端口。在这个例子中,行为
SaySomething
需要输入端口message
。
- 标签的名称是用于在工厂中注册
-
在子节点的数量方面:
ControlNodes
容纳1到N个子节点。DecoratorNodes
和子树仅包含1
个子项。ActionNodes
和ConditionNodes
有没有子项。
3.4 实际使用
让我们结合代码实战,看看如何使用这个框架构建一颗行为树。
构建普通行为树
在构建行为树之前,先定义节点,官方对于创建TreeNode
的推荐方式是通过继承。
// dummy_nodes.cpp(part1)class ApproachObject : public BT::SyncActionNode{ public: // TreeNode 的任何实例都有一个名字,目的是提高可读性,但它不需要唯一。 ApproachObject(const std::string& name) : BT::SyncActionNode(name, {}) { } // 必须重写虚函数tick() // tick()是实际操作发生的地方。它必须始终返回一个节点状态,即运行、成功或失败。 BT::NodeStatus tick() override { std::cout << "ApproachObject: " << this->name() << std::endl; return BT::NodeStatus::SUCCESS; }};
除了继承以外,还可以使用依赖注入来创建一个给定函数指针的TreeNode
,称为functor
。
// dummy_nodes.cpp(part2)using namespace BT; // 返回节点状态的简单函数BT::NodeStatus CheckBattery(){ std::cout << "[ Battery: OK ]" << std::endl; return BT::NodeStatus::SUCCESS;}
框架提供的第三种方法是使用类的方法创建SimpleActionNodes
,原理其实还是依赖注入
// dummy_nodes.cpp(part3)// 把open()和close()方法封装到ActionNode中class GripperInterface{public: GripperInterface(): _open(true) {} NodeStatus open() { _open = true; std::cout << "GripperInterface::open" << std::endl; return NodeStatus::SUCCESS; } NodeStatus close() { _open = false; std::cout << "GripperInterface::close" << std::endl; return NodeStatus::SUCCESS; } private: bool _open; // 共享信息};
方法二和方法三创建出来的节点统称为SimpleActionNode
,现在我们可以从这些函数中把它们创建出来,具体方法请看下文的主函数。
CheckBattery()GripperInterface::open()GripperInterface::close()
节点有了,还差树结构。从下文的XML看来,这是一颗简单的行为树,一个顺序节点下四个自定义行为节点。
<root main_tree_to_execute = "MainTree" > <BehaviorTree ID="MainTree"> <Sequence name="root_sequence"> <CheckBattery name="check_battery"/> <OpenGripper name="open_gripper"/> <ApproachObject name="approach_object"/> <CloseGripper name="close_gripper"/> </Sequence> </BehaviorTree> </root>
下面是主函数,你能看到节点对象是如何以不同的方式被注册到BehaviorTreeFactory
中,以及行为树是如何在运行中被构建出来并执行的。
// hello_BT.cpp#include "behaviortree_cpp_v3/bt_factory.h"// 包含自定义节点定义的文件#include "dummy_nodes.h"int main(){ // 我们使用BehaviorTreeFactory注册我们的自定义节点 BT::BehaviorTreeFactory factory; // 注意:用于注册Node的名称应该与XML中使用的名称相同 using namespace DummyNodes; //------下面开始创建Node------------ // 方法1,推荐的创建Node的方法是通过继承。 factory.registerNodeType<ApproachObject>("ApproachObject"); // 方法2,使用函数指针注册SimpleActionNode。 // 你也可以使用c++ 11 lambdas来代替std::bind factory.registerSimpleCondition("CheckBattery", std::bind(CheckBattery)); // 方法3,使用类的方法创建SimpleActionNodes GripperInterface gripper; factory.registerSimpleAction("OpenGripper", std::bind(&GripperInterface::open, &gripper)); factory.registerSimpleAction("CloseGripper", std::bind(&GripperInterface::close, &gripper)); //------下面开始建树------------ // 树是在运行时创建的,树的结构由XML文件决定 // 当对象“tree”超出范围时,所有的TreeNodes将被销毁 auto tree = factory.createTreeFromFile("./my_tree.xml"); //------下面开始执行行为树------------ // 要“execute”一棵树,你需要“tick”每个节点。 // “tick”会随着树向下传播 // 在这个例子里,所有的子节点都会顺序执行,因为它们的父节点是顺序节点,且它们均返回SUCCESS tree.tickRoot(); return 0;}
编写CmakeLists.txt,以便编译运行。
cmake_minimum_required(VERSION 3.5)project(hello_BT)set(CMAKE_CXX_STANDARD 14)set(CMAKE_CXX_STANDARD_REQUIRED ON)find_package(BehaviorTreeV3)add_executable(${PROJECT_NAME} "hello_BT.cpp")target_sources(${PROJECT_NAME} PUBLIC "dummy_nodes.cpp")target_link_libraries(${PROJECT_NAME} BT::behaviortree_cpp_v3)
运行结果如下,按照顺序,每个行为节点都做出了其相应行为。
实现节点之间的通信
下面一个例子展示了节点之间如何通过黑板来交流信息。
// 定义两个Action节点,SaySomething使用继承,SaySomethingSimple使用依赖注入// 它们两有相同的行为,就是根据键“message”从黑板上读出信息并输出// 但因为SimpleActionNode使用黑板的方式有所不同,需要在此示例namespace DummyNodes{BT::NodeStatus SaySomething::tick(){ auto msg = getInput<std::string>("message"); if (!msg) { throw BT::RuntimeError( "missing required input [message]: ", msg.error() ); } std::cout << "Robot says: " << msg.value() << std::endl; return BT::NodeStatus::SUCCESS;}BT::NodeStatus SaySomethingSimple(BT::TreeNode &self){ auto msg = self.getInput<std::string>("message"); if (!msg) { throw BT::RuntimeError( "missing required input [message]: ", msg.error() ); } std::cout << "Robot says: " << msg.value() << std::endl; return BT::NodeStatus::SUCCESS;}}
// 在定义一个Action节点,此节点负责在端口“text”中写入一个值class ThinkWhatToSay : public BT::SyncActionNode{ public: ThinkWhatToSay(const std::string& name, const BT::NodeConfiguration& config): BT::SyncActionNode(name, config){} BT::NodeStatus tick() override { setOutput("text", "The answer is 42" ); return BT::NodeStatus::SUCCESS; } // 具有端口的节点必须实现这个static方法 static BT::PortsList providedPorts() { return { BT::OutputPort<std::string>("text") };}};
下面是主函数。注意XML写法,[message]
的输入可以是字符串
例如:<SaySomething message="start thinking..." />
输入也可以是个“指针”,指向在黑板上被擦除的类型的条目,语法为:{name_of_entry} 。
例如:<SaySomething message="{the_answer}" />
#include "behaviortree_cpp_v3/bt_factory.h"... using namespace BT;// 在行为树制定的时候,就已经规定好了黑板上的输入输出对象,以及用于联系的key(或者称之为port)static const char* xml_text = R"( <root main_tree_to_execute = "MainTree" > <BehaviorTree ID="MainTree"> <Sequence name="root"> <SaySomething message="start thinking..." /> <ThinkWhatToSay text="{the_answer}"/> <SaySomething message="{the_answer}" /> <SaySomething2 message="SaySomething2 works too..." /> <SaySomething2 message="{the_answer}" /> </Sequence> </BehaviorTree> </root> )";int main(){ using namespace DummyNodes; BT::BehaviorTreeFactory factory; // 类SaySomething有一个名为provideports()的方法,该方法定义了输入。 factory.registerNodeType<SaySomething>("SaySomething"); // 与SaySomething类似,ThinkWhatToSay有一个属性称为“text”,它负责指定输出端口 // 这两个端口指向相同,因此它们可以相互连接 factory.registerNodeType<ThinkWhatToSay>("ThinkWhatToSay"); //simpleactionnode不能定义自己的方法providdports(),因此如果想让行为节点使用getInput()或setOutput(),必须显式地传递PortsList; PortsList say_something_ports = { InputPort<std::string>("message") }; factory.registerSimpleAction("SaySomething2", SaySomethingSimple, say_something_ports ); auto tree = factory.createTreeFromText(xml_text); /* * ThinkSomething将信息写入带有“the_answer”键的条目; * SaySomething和SaySomething2将根据端口key读取来自同一条目的消息。 * */ tree.tickRoot(); return 0;}
结果如下,节点之间成功传递信息。
4. 结语
行为树的优点在于,它专注于“行为”,并借助非叶节点来进行逻辑控制,这使它逻辑与实现之间的耦合极低,且复用方便。
行为树优秀的扩展性也使它前景光明,现已经有基于机器学习Q-Learning算法或模糊逻辑构建的行为树,游戏也AI朝着“拟人”方向更近了一步。
(不要选陈波,会变得痛苦!!!!!!!!!!!!)
参考文献
[1] KillerAery . 游戏AI之决策结构—行为树[EB/OL].(2018-12-04) [2021-07-07].https://www.cnblogs.com/KillerAery/p/10007887.html.(精读)
[2] KillerAery . 游戏AI之初步介绍.(2018-11-23)[2021-07-07].https://www.cnblogs.com/KillerAery/p/10003678.html
[3] Faconti , D(2021) . BehaviorTree/BehaviorTree.CPP (Version 3.5.6) [Source code].https://github.com/BehaviorTree/BehaviorTree.CPP
[4] Chris Simpson . Behavior trees for AI: How they work[EB/OL].(2014-07-17)[2021-07-07].https://www.gamasutra.com/blogs/ChrisSimpson/20140717/221339/Behavior_trees_for_AI_How_they_work.php
[5]丁治强 .基于Q--learning行为树的人群组行为建模与仿真[D].中国科学技术大学,2019:19-21