原文地址:https://www.behaviortree.dev/docs/intro
BehaviorTree.CPP行为树库地址:GitHub - BehaviorTree/BehaviorTree.CPP: Behavior Trees Library in C++. Batteries included.
一.关于
1.关于这个库
这个 C++库提供了一个创建行为树的框架。它设计得灵活、易用且快速。
即使我们的主要用例是机器人技术,您也可以使用此库来 为游戏构建人工智能,或者在您的应用程序中替换有限状态机。
与其他实现相比,BehaviorTree.CPP具有许多有趣的特性:
- 它使异步操作(即非阻塞例程)成为头等公民。
- 树是在运行时使用解释语言(基于 XML)创建的。
- 它包括一个日志/分析基础设施,允许用户可视化、记录、重放和分析状态转换。
- 您可以静态链接自定义的 TreeNode 或将它们转换为在运行时加载的插件。
2.什么是行为树?
行为树 ( BT ) 是一种构建自主智能体(例如机器人或计算机游戏中的虚拟实体)中不同任务之间切换的方式。
BT 是一种非常有效的创建复杂系统的方法,这些系统既模块化又具有响应性。这些特性在许多应用中都至关重要,这使得 BT 从计算机游戏编程扩展到人工智能和机器人技术的许多分支。
如果您已经熟悉有限状态机(FSM),那么您将很容易掌握大部分概念,但希望您会发现 BT 更具表现力且更容易推理。
把树的节点看作一组构建块。这些块用 C++ 实现,并且是“可组合的”:换句话说,它们可以“组装”以构建行为。
3.行为树的主要优点
-
它们本质上是分层的:我们可以组合 复杂的行为,包括将整个树作为更大树的子分支。例如,“取啤酒”行为可以重用“抓取对象”树。
-
它们的图形表示具有语义含义:更容易“阅读” BT 并理解相应的工作流程。相比之下,FSM 中的状态转换无论是在文本还是图形表示中都更难理解。
-
它们更具表现力:现成的 ControlNodes(控制节点) 和 DecoratorNodes(装饰节点) 可以表达更复杂的控制流。用户可以使用自己的自定义节点扩展“词汇表”。
4.为什么我们需要行为树(或 FSM)?
许多软件系统(机器人就是一个显著的例子)本质上都是复杂的。
管理复杂性、异构性和可扩展性的通常方法是使用基于组件的软件工程的概念 。
任何现有的机器人中间件都以非正式或正式的方式采用了这种方法,其中ROS、YARP和 SmartSoft就是一些著名的例子。
一个“好的”软件架构应该具备以下特点:
- 模块化。
- 组件的可重用性。
- 可组合性。
- 很好地分离关注点。
如果我们从一开始就不牢记这些概念,我们创建的软件就会紧密耦合、可重用性较差。
通常,软件系统的业务逻辑会“分散”到许多组件中,开发人员很难对其进行推理和调试错误。
为了实现强有力的关注点分离,最好将业务逻辑集中在一个位置。
有限状态机就是为了实现这个目标而创建的,但近年来, 行为树变得越来越流行,尤其是在游戏行业。
二.基本概念
1.简介
与有限状态机不同,行为树是 控制“任务”执行流程的分层节点树。
基本概念
-
一个称为“滴答”的信号被发送到树的根部,并在树中传播,直到到达叶节点。
-
任何收到tick信号的 TreeNode 都会执行其回调。此回调必须返回
- SUCCESS
- FAILURE
- RUNNING
-
RUNNING 表示该操作需要更多时间才能返回有效结果。
-
如果 TreeNode 具有一个或多个子节点,则它有责任传播tick;每种节点类型可能对于是否、何时以及tick子节点的次数有不同的规则。
-
叶节点(LeafNodes)是那些没有任何子节点的树节点,它们是实际的命令,即行为树与系统其余部分交互的节点。 动作节点(Actionnodes)是最常见的叶节点类型。
tick这个词经常用作动词(滴答/被滴答),其意思是:
"调用' TreeNode '的' tick() '回调"。
在面向服务的架构中,叶子节点包含与执行实际操作的“服务器”进行通信的“客户端”代码。
tick的工作原理
为了直观地了解勾选树的工作原理,请看下面的图片。
Sequence是最简单的ControlNode:它依次执行其子节点,如果所有子节点都成功,它也会返回 SUCCESS 。
- 第一个tick 将 Sequence 节点设置为 RUNNING(橙色)。
- Sequence tick第一个子项“OpenDoor”,最终返回 SUCCESS。
- 结果,第二个子项“Walk”和后来的“CloseDoor”都被tick了。
- 一旦最后一个子任务完成,整个序列就会从 RUNNING 切换到 SUCCESS。
节点类型
树节点的类型 | 子节点数量 | 注意事项 |
---|---|---|
ControlNode | 1...N | 通常,根据兄弟姐妹的结果或/和其自身的状态来tick一个子节点。 |
DecoratorNode | 1 | 除其他事情外,它可能会改变其子项的结果或多次tick它。 |
ConditionNode | 0 | 不应改变系统。不应返回 RUNNING。 |
ActionNode | 0 | 这是“做一些事情”的节点 |
在ActionNodes上下文中,我们可以进一步区分同步节点和异步节点。
前者以原子方式执行并阻止树直到返回 SUCCESS 或 FAILURE。
相反,异步操作可能会返回 RUNNING 来表明该操作仍在执行。
我们需要再次tick它们,直到最终返回SUCCESS或FAILURE。
示例
为了更好地理解行为树的工作原理,让我们关注一些实际示例。为了简单起见,我们不会考虑当动作返回 RUNNING 时会发生什么。
我们将假设每个动作都是原子且同步执行的。
第一个控制节点:Sequence
让我们使用最基本和最常用的 ControlNode(即SequenceNode)来说明 BT 的工作原理。
ControlNode 的子节点始终是有序的;在图形表示中,执行顺序是从左到右。
简而言之:
- 如果子节点回答“SUCCESS”,则tick下一个。
- 如果一个子项返回 FAILURE,则不再tick任何子项,并且序列返回 FAILURE。
- 如果所有子项都返回 SUCCESS,则 Sequence 也返回 SUCCESS。
找到BUG:
如果GrabBeer操作失败,由于跳过了最后一个CloseFridge操作,冰箱门将保持打开状态。
装饰器
根据DecoratorNode的类型,此节点的目标可以是:
- 来转化从子节点获得的结果。
- 停止对子节点的运行。
- 根据装饰器的类型来重复tick子项。
节点Inverter是一个装饰器,它反转其子节点返回的结果;因此,后面跟着 isDoorOpen节点的 Inverter相当于
"Is the door closed?".(门是否是关闭的?)
如果子节点返回 FAILURE,则Retry节点将重复对子节点进行tick,最多可达num_attempts次(在本例中为 5 次)。
显然,左侧的分支意味着:
If the door is closed, then try to open it.(如果门是关闭的,就尝试打开它)
Try up to 5 times, otherwise give up and return FAILURE.(尝试最多5次,否则就放弃且返回FAILURE)
但...
找到BUG:
如果isDoorOpen返回 FAILURE,则我们得到了所需的行为。但如果它返回 SUCCESS,则左侧分支失败,并且整个 Sequence 被中断。
第二个控制节点:Fallback
FallbackNodes,也称为“选择器”,是可以表达后备策略的节点,顾名思义,即如果子节点返回 FAILURE ,下一步该做什么。
它按顺序tick子项,并且:
- 如果子节点回答“FAILURE ”,则tick下一个。
- 如果子进程返回 SUCCESS,则不再tick其他子节点,并且 Fallback 返回 SUCCESS。
- 如果所有子项都返回 FAILURE,则 Fallback 也会返回 FAILURE。
在下一个示例中,您可以看到如何组合Sequences和Fallbacks:
Is the door open?门开着吗?
If not, try to open the door.如果没有,请尝试打开门。
Otherwise, if you have a key, unlock and open the door.否则,如果您有钥匙,请解锁并打开门。
Otherwise, smash the door.否则就砸门。
If any of these actions succeeded, then enter the room.如果任何这些操作成功,则进入房间。
复习"Fetch me a beer"例子
我们现在可以改进“给我拿一杯啤酒”的例子,如果啤酒不在冰箱里,冰箱门就会打开。
我们使用“绿色”表示返回 SUCCESS 的节点,使用“红色”表示返回 FAILURE 的节点。黑色节点表示未执行。
让我们创建一个替代树,即使GrabBeer 返回 FAILURE,它也会关闭门。
这两棵树最终都会关上冰箱门,但是:
- 无论我们是否真的拿到了啤酒, 左侧的树总会返回 SUCCESS。
- 如果有啤酒,右侧的树将返回 SUCCESS,否则将返回 FAILURE。
如果GrabBeer返回 SUCCESS,则一切都按预期工作。
2.主要概念
BehaviorTree.CPP是一个 C++ 库,可以轻松集成到您最喜欢的分布式中间件中,例如ROS或SmartSoft。
您可以将其静态链接到您的应用程序(例如游戏)。
这些是您首先需要了解的主要概念。
节点与树
用户必须创建自己的ActionNodes和ConditionNodes(LeafNodes);这个库可以帮助您轻松地将它们组合成树。
把 LeafNodes 视为构建复杂系统所需的构建块。如果 Nodes 是乐高积木,那么你的树就是乐高积木。
在运行时使用XML格式实例化树
尽管该库是用 C++ 编写的,但树本身可以在运行时(更具体地说,在部署时)使用基于 XML 的脚本语言创建和编写。
tick() callback
任何 TreeNode 都可以看作是一种调用callback的机制,即 运行一段代码。这个callback的作用由你决定。
在大多数以下教程中,我们的操作只会在控制台上打印消息或睡眠一段时间以模拟长时间计算。
在生产代码中,尤其是在模型驱动开发和基于组件的软件工程中,(Action/Condition)动作/条件可能会与系统的其他组件或服务进行通信。
//可以封装到BT Action中的最简单的回调
NodeStatus HelloTick()
{
std::cout << "Hello World\n";
return NodeStatus::SUCCESS;
}
//允许库创建调用HelloTick()的Actions(在教程中解释)
factory.registerSimpleAction("Hello", std::bind(HelloTick));
提示:
factory可能会创建节点Hello的多个实例。
使用继承创建自定义节点
在上面的例子中, 使用函数指针(依赖注入)HelloTick
创建了一种调用特定类型的 TreeNodes。
通常,要定义自定义 TreeNode,您应该从类继承TreeNode
,或者更具体地说,从其派生类继承:
ActionNodeBase
ConditionNode
DecoratorNode
数据流、端口和黑板(Dataflow, Ports and Blackboard)
目前,重要的是要知道:
-
黑板(Blackboard)是树的所有节点共享的键/值存储。
-
端口是节点之间可以用来交换信息的一种机制。
-
端口使用黑板上的同一个键进行“连接” 。
-
节点的端口数量、名称和类型必须在编译时(C++)知道;端口之间的连接在部署时完成(XML)。
-
您可以将任何 C++ 类型存储为值(我们使用类似于std::any 的_类型擦除技术)。
3.XML简介
下面是一颗简单的树
<root BTCPP_format="4">
<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>
。它应该包含1 个或多个标签<BehaviorTree>
。 -
该标签
<BehaviorTree>
应该具有属性[ID]
。 -
标签
<root>
应该包含属性[BTCPP_format]
。 -
每个 TreeNode 都由一个标签表示。具体来说:
- 标签的名称是用于在factory中注册TreeNode的ID 。
- 该属性
[name]
指的是实例的名称,是可选的。 - 端口使用属性进行配置。在上例中,操作
SaySomething
需要输入端口message
。
-
就子节点数量而言:
ControlNodes
包含1 到 N 个孩子。DecoratorNodes和
子树仅包含1 个子树。ActionNodes和
ConditionNodes
没有子树。
Ports重新映射和指向Blackboard条目的指针
可以使用 Blackboard 中条目的名称(换句话说,BB 的键/值对的键)重新映射输入/输出端口。
BB key 使用以下语法表示:{key_name}
。
在以下示例中:
- Sequence 的第一个子节点打印“Hello”,
- 第二个子节点读取并写入名为“my_message”的黑板条目中包含的值;
<root BTCPP_format="4" >
<BehaviorTree ID="MainTree">
<Sequence name="root_sequence">
<SaySomething message="Hello"/>
<SaySomething message="{my_message}"/>
</Sequence>
</BehaviorTree>
</root>
紧凑与显式表示
以下两种语法均有效:
<SaySomething name="action_hello" message="Hello World"/>
<Action ID="SaySomething" name="action_hello" message="Hello World"/>
我们将前一种语法称为“紧凑的”,将后一种语法称为“显示的”。使用显示语法表示的第一个示例将变为:
<root BTCPP_format="4" >
<BehaviorTree ID="MainTree">
<Sequence name="root_sequence">
<Action ID="SaySomething" name="action_hello" message="Hello"/>
<Action ID="OpenGripper" name="open_gripper"/>
<Action ID="ApproachObject" name="approach_object"/>
<Action ID="CloseGripper" name="close_gripper"/>
</Sequence>
</BehaviorTree>
</root>
即使紧凑语法更方便且更易于编写,但它提供的有关 TreeNode 模型的信息太少。Groot 等工具需要显式语法或附加信息。可以使用标签添加此信息<TreeNodeModel>
。
为了使我们的树的紧凑版本与 Groot 兼容,必须对 XML 进行如下修改:
<root BTCPP_format="4" >
<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>
<!-- 运行BT不需要这些, 但是Groot 需要-->
<TreeNodeModel>
<Action ID="SaySomething">
<input_port name="message" type="std::string" />
</Action>
<Action ID="OpenGripper"/>
<Action ID="ApproachObject"/>
<Action ID="CloseGripper"/>
</TreeNodeModel>
</root>
子树
我们可以将子树包含在另一棵树中,以避免在多个位置“复制和粘贴”同一棵树并降低复杂性。
假设我们想要将一些动作封装到行为树“ GraspObject ”中(可选,为简单起见省略了属性[name] )。
<root BTCPP_format="4" >
<BehaviorTree ID="MainTree">
<Sequence>
<Action ID="SaySomething" message="Hello World"/>
<SubTree ID="GraspObject"/>
</Sequence>
</BehaviorTree>
<BehaviorTree ID="GraspObject">
<Sequence>
<Action ID="OpenGripper"/>
<Action ID="ApproachObject"/>
<Action ID="CloseGripper"/>
</Sequence>
</BehaviorTree>
</root>
我们可能会注意到,整个树“GraspObject”是在“SaySomething”之后执行的。
包含外部文件
自 2.4 版起。
您可以使用类似于 C++ 中的“ #include \<file > ”的方式包含外部文件。我们可以使用以下标记轻松完成此操作:
<include path="relative_or_absolute_path_to_file">
使用前面的例子,我们可以将两个行为树分成两个文件:
<!-- 文件 maintree.xml -->
<root BTCPP_format="4" >
<include path="grasp.xml"/>
<BehaviorTree ID="MainTree">
<Sequence>
<Action ID="SaySomething" message="Hello World"/>
<SubTree ID="GraspObject"/>
</Sequence>
</BehaviorTree>
</root>
<!-- 文件 grasp.xml -->
<root BTCPP_format="4" >
<BehaviorTree ID="GraspObject">
<Sequence>
<Action ID="OpenGripper"/>
<Action ID="ApproachObject"/>
<Action ID="CloseGripper"/>
</Sequence>
</BehaviorTree>
</root>
三.教程(基础)
1.你的第一棵行为树
行为树与状态机类似,只不过是一种在正确的时间和正确的条件下调用callbacks的机制。这些回调中发生的事情由您决定。
我们将交替使用表达 “调用callback”和“tick”。
在本教程系列中,大多数时候我们的虚拟操作只会在控制台上打印一些信息,但请记住,真正的“生产”代码可能会做一些更复杂的事情。
接下来,我们将创建这棵简单的树:
如何创建自己的ActionNodes
创建 TreeNode 的默认(和推荐)方法是通过继承。
// Example of custom SyncActionNode (synchronous action) without ports.
// 使用实例自定义SyncActionNode(同步动作),不带端口。
class ApproachObject : public BT::SyncActionNode
{
public:
ApproachObject(const std::string& name) :
BT::SyncActionNode(name, {})
{}
// You must override the virtual function tick()
//你必须重写tick()这个虚函数
BT::NodeStatus tick() override
{
std::cout << "ApproachObject: " << this->name() << std::endl;
return BT::NodeStatus::SUCCESS;
}
};
如你看到的:
- TreeNode 的任何实例都有一个
name
。此标识符旨在便于人类阅读,并且无需唯一。 - 方法tick()是实际操作发生的地方。它必须始终返回
NodeStatus
,即 RUNNING、SUCCESS 或 FAILURE。
或者,我们可以使用依赖注入来通过给定函数指针(即“函数子”)来创建 TreeNode。
函子必须具有以下签名:
BT::NodeStatus myFunction(BT::TreeNode& self)
例如:
using namespace BT;
// Simple function that return a NodeStatus 简单的函数返回一个NodeStatus
BT::NodeStatus CheckBattery()
{
std::cout << "[ Battery: OK ]" << std::endl;
return BT::NodeStatus::SUCCESS;
}
//我们想把open()和close()方法包装到ActionNode中
// We want to wrap into an ActionNode the methods open() and close()
class GripperInterface
{
public:
GripperInterface(): _open(true) {}
NodeStatus open()
{
_open = true;
std::cout << "GripperInterface::open" << std::endl;
return NodeStatus::SUCCESS;
}
NodeStatus close()
{
std::cout << "GripperInterface::close" << std::endl;
_open = false;
return NodeStatus::SUCCESS;
}
private:
bool _open; // shared information 共享信息
};
我们可以从以下任意一个函子构建一个SimpleActionNode:
- CheckBattery()
- GripperInterface::open()
- GripperInterface::close()
用XML动态创建树
让我们考虑以下名为my_tree.xml的 XML 文件:
<root BTCPP_format="4" >
<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>
我们必须首先将自定义的 TreeNodes 注册到其中BehaviorTreeFactory
,然后从文件或文本中加载 XML。
XML 中使用的标识符必须与用于注册 TreeNode 的标识符一致。
属性“name”代表实例的名称;它是可选的。
#include "behaviortree_cpp/bt_factory.h"
// file that contains the custom nodes definitions 包含自定义节点定义的文件
#include "dummy_nodes.h"
using namespace DummyNodes;
int main()
{
// We use the BehaviorTreeFactory to register our custom nodes
//我们使用BehaviorTreeFactory来注册自定义节点
BehaviorTreeFactory factory;
// The recommended way to create a Node is through inheritance.
//创建Node的推荐方法是通过继承。
factory.registerNodeType<ApproachObject>("ApproachObject");
// Registering a SimpleActionNode using a function pointer.
//使用函数指针注册SimpleActionNode。 您可以使用c++ 11 lambdas或std::bind
// You can use C++11 lambdas or std::bind
factory.registerSimpleCondition("CheckBattery", [&](TreeNode&) { return CheckBattery(); });
//You can also create SimpleActionNodes using methods of a class
//您还可以使用类的方法创建SimpleActionNodes
GripperInterface gripper;
factory.registerSimpleAction("OpenGripper", [&](TreeNode&){ return gripper.open(); } );
factory.registerSimpleAction("CloseGripper", [&](TreeNode&){ return gripper.close(); } );
// Trees are created at deployment-time (i.e. at run-time, but only
// once at the beginning). 树是在部署时创建的(即在运行时,但只在开始时创建一次)。
// IMPORTANT: when the object "tree" goes out of scope, all the TreeNodes are destroyed
//重要提示:当对象“tree”超出作用域时,所有的treenode都将被销毁
auto tree = factory.createTreeFromFile("./my_tree.xml");
// To "execute" a Tree you need to "tick" it.要“执行”一棵树,你需要“tick”它。
//根据树的逻辑将tick传播给子节点。
// The tick is propagated to the children based on the logic of the tree.
// In this case, the entire sequence is executed, because all the children
// of the Sequence return SUCCESS.
//在这种情况下,整个序列被执行,因为序列的所有子序列返回SUCCESS。
tree.tickWhileRunning();
return 0;
}
/* Expected output:
*
[ Battery: OK ]
GripperInterface::open
ApproachObject: approach_object
GripperInterface::close
*/
2.黑板和端口(Blackboard and ports)
正如我们之前所解释的,自定义 TreeNode 可用于执行任意简单或复杂的软件。它们的目标是提供具有更高抽象级别的接口。
因此,它们在概念上与函数没有区别。
与函数类似,我们常常希望:
- 将参数传递给节点(输入)
- 从节点获取某种信息(输出)。
- 一个节点的输出可以是另一个节点的输入。
BehaviorTree.CPP 提供了通过端口进行数据流的基本机制,该机制使用简单,而且灵活且类型安全。
在本教程中,我们将创建以下树:
主要概念
- “Blackboard”是树的所有节点共享的简单key/value存储。
- Blackboard的一个“条目”是一个key/value对。
- Input port可以读取黑板中的条目,而Output port 可以写入条目。
输入端口(Input port)
有效输入可以是:
- Node 将读取并解析的静态字符串,或
- 指向Blackboard条目的“pointer”,由key标识。
假设我们要创建一个名为的ActionNode SaySomething
,它应该在上打印给定的字符串std::cout
。
为了传递这个字符串,我们将使用一个名为message 的输入端口。
考虑以下替代的 XML 语法:
<SaySomething name="first" message="hello world" />
<SaySomething name="second" message="{greetings}" />
- 在第一个节点,端口接收字符串“hello world”;
- 相反,第二个节点被要求使用条目“greetings”在黑板中查找值。
警告:
条目“greetings”的值可以在运行时改变(并且很可能会改变)。
ActionNodeSaySomething
可以按如下方式实现:
// SyncActionNode (synchronous action) with an input port.
//SyncActionNode(同步动作),带输入端口。
class SaySomething : public SyncActionNode
{
public:
// If your Node has ports, you must use this constructor signature
//如果您的Node有端口,则必须使用此构造函数签名
SaySomething(const std::string& name, const NodeConfig& config)
: SyncActionNode(name, config)
{ }
// It is mandatory to define this STATIC method. 这个方法必须定义成STATIC
static PortsList providedPorts()
{
// This action has a single input port called "message"
//该操作有一个名为“message”的输入端口。
return { InputPort<std::string>("message") };
}
// Override the virtual function tick()重写tick()虚函数
NodeStatus tick() override
{
Expected<std::string> msg = getInput<std::string>("message");
// Check if expected is valid. If not, throw its error
//检查expected是否有效。如果不是,抛出错误
if (!msg)
{
throw BT::RuntimeError("missing required input [message]: ",
msg.error() );
}
// use the method value() to extract the valid message.使用value()方法提取有效消息。
std::cout << "Robot says: " << msg.value() << std::endl;
return NodeStatus::SUCCESS;
}
};
当自定义 TreeNode 具有输入和/或输出端口时,必须在静态方法中声明这些端口:
static MyCustomNode::PortsList providedPorts();
message
可以使用模板方法读取 来自端口的输入TreeNode::getInput<T>(key)
。
此方法可能由于多种原因而失败。用户应检查返回值的有效性并决定如何处理:
- 返回
NodeStatus::FAILURE
? - 抛出异常?
- 使用不同的默认值?
重要的始终建议在
tick()
内部 调用该方法getInput()
,而不是在类的构造函数中调用该方法。C++ 代码应该预期输入的实际值在运行时会发生变化,因此应该定期更新它。
输出端口(Output ports)
仅当另一个节点已在同一条目中写入“某些内容”时,指向黑板条目的输入端口才会有效。
ThinkWhatToSay
是一个使用输出端口将字符串写入条目的 Node 示例。
class ThinkWhatToSay : public SyncActionNode
{
public:
ThinkWhatToSay(const std::string& name, const NodeConfig& config)
: SyncActionNode(name, config)
{ }
static PortsList providedPorts()
{
return { OutputPort<std::string>("text") };
}
// This Action writes a value into the port "text"这个操作将一个值写入端口“text”
NodeStatus tick() override
{
// the output may change at each tick(). Here we keep it simple.
//输出可能在每次tick()时发生变化。这里我们保持简单。
setOutput("text", "The answer is 42" );
return NodeStatus::SUCCESS;
}
};
或者,大多数时候出于调试目的,可以使用名为的内置操作将静态值写入条目Script
。
<Script code=" the_answer:='The answer is 42' " />
不建议这么使用
一个完整的例子
在此示例中,执行了 3 个操作的序列:
-
message
动作 1从静态字符串读取输入。 -
动作2在黑板的条目中写入一些内容,称为
the_answer
。 -
message
操作 3从黑板中名为 的条目读取输入the_answer
。
<root BTCPP_format="4" >
<BehaviorTree ID="MainTree">
<Sequence name="root_sequence">
<SaySomething message="hello" />
<ThinkWhatToSay text="{the_answer}"/>
<SaySomething message="{the_answer}" />
</Sequence>
</BehaviorTree>
</root>
注册并执行树的 C++ 代码:
#include "behaviortree_cpp/bt_factory.h"
// file that contains the custom nodes definitions包含自定义节点定义的文件
#include "dummy_nodes.h"
using namespace DummyNodes;
int main()
{
BehaviorTreeFactory factory;
factory.registerNodeType<SaySomething>("SaySomething");
factory.registerNodeType<ThinkWhatToSay>("ThinkWhatToSay");
auto tree = factory.createTreeFromFile("./my_tree.xml");
tree.tickWhileRunning();
return 0;
}
/* Expected output:预期的输出:
Robot says: hello
Robot says: The answer is 42
*/
我们使用相同的键(the_answer
)将输出端口“连接”到输入端口;换句话说,它们“指向”黑板上的同一个条目。
这些端口可以相互连接,因为它们的类型相同,即std::string.
如果您尝试连接不同类型的端口,该方法factory.createTreeFromFile
将引发异常。
3.具有泛型类型的端口
在前面的教程中,我们介绍了输入和输出端口,其中端口的类型是std::string
。
接下来,我们将展示如何为您的端口分配通用 C++ 类型。
解析字符串
BehaviorTree.CPP支持将字符串自动转换为常见类型,例如int
, long
, double
, bool
, NodeStatus
等。也可以轻松支持用户定义的类型
例如:
// We want to use this custom type 我们想使用这个自定义类型
struct Position2D
{
double x;
double y;
};
为了让 XML 加载器能够Position2D
从字符串实例化,我们需要提供 BT::convertFromString<Position2D>(StringView)
的模板特化
Position2D
如何将其序列化为字符串取决于您;在这种情况下,我们只需用分号分隔两个数字。
// Template specialization to converts a string to Position2D.
//模板专门化将字符串转换为Position2D。
namespace BT
{
template <> inline Position2D convertFromString(StringView str)
{
// We expect real numbers separated by semicolons
//我们期望用分号分隔真实的数字
auto parts = splitString(str, ';');
if (parts.size() != 2)
{
throw RuntimeError("invalid input)");
}
else
{
Position2D output;
output.x = convertFromString<double>(parts[0]);
output.y = convertFromString<double>(parts[1]);
return output;
}
}
} // end namespace BT
StringView
是std::string_view的 C++11 版本。您可以传递std::string
或const char*
。- 该库提供了一个简单的
splitString
函数。您可以随意使用其他函数,例如boost::algorithm::split。 - 我们可以使用
convertFromString<double>()
。
例子
正如我们在上一个教程中所做的那样,我们可以创建两个自定义操作,一个将写入端口,另一个将从端口读取。
class CalculateGoal: public SyncActionNode
{
public:
CalculateGoal(const std::string& name, const NodeConfig& config):
SyncActionNode(name,config)
{}
static PortsList providedPorts()
{
return { OutputPort<Position2D>("goal") };
}
NodeStatus tick() override
{
Position2D mygoal = {1.1, 2.3};
setOutput<Position2D>("goal", mygoal);
return NodeStatus::SUCCESS;
}
};
class PrintTarget: public SyncActionNode
{
public:
PrintTarget(const std::string& name, const NodeConfig& config):
SyncActionNode(name,config)
{}
static PortsList providedPorts()
{
// Optionally, a port can have a human readable description
//可选地,端口可以具有人类可读的描述
const char* description = "Simply print the goal on console...";
return { InputPort<Position2D>("target", description) };
}
NodeStatus tick() override
{
auto res = getInput<Position2D>("target");
if( !res )
{
throw RuntimeError("error reading port [target]:", res.error());
}
Position2D target = res.value();
printf("Target positions: [ %.1f, %.1f ]\n", target.x, target.y );
return NodeStatus::SUCCESS;
}
};
我们现在可以像往常一样连接输入/输出端口,指向黑板的相同条目。
下一个示例中的树是 4 个动作的序列:
-
使用
CalculateGoal
操作将Position2D
值存储在条目GoalPosition中。 -
调用
PrintTarget
。输入的“target”将从黑板条目GoalPosition中读取。 -
使用内置操作
Script
将字符串“-1;3”分配给键OtherGoal。从字符串到Position2D
的转换将自动完成。 -
再次调用
PrintTarget
。输入的“target”将从条目OtherGoal中读取。
static const char* xml_text = R"(
<root BTCPP_format="4" >
<BehaviorTree ID="MainTree">
<Sequence name="root">
<CalculateGoal goal="{GoalPosition}" />
<PrintTarget target="{GoalPosition}" />
<Script code=" OtherGoal:='-1;3' " />
<PrintTarget target="{OtherGoal}" />
</Sequence>
</BehaviorTree>
</root>
)";
int main()
{
BT::BehaviorTreeFactory factory;
factory.registerNodeType<CalculateGoal>("CalculateGoal");
factory.registerNodeType<PrintTarget>("PrintTarget");
auto tree = factory.createTreeFromText(xml_text);
tree.tickWhileRunning();
return 0;
}
/* Expected output:
Target positions: [ 1.1, 2.3 ]
Converting string: "-1;3"
Target positions: [ -1.0, 3.0 ]
*/
4.响应式行为
响应性和异步行为
下一个例子显示了 SequenceNode
和 ReactiveSequence
之间的区别。
我们将实现一个异步动作,即需要很长时间才能完成的动作,当完成标准不满足时将返回 RUNNING。
异步操作具有以下要求:
-
它不应该在方法中阻塞
tick()
太长时间。执行流程应该尽快返回。 -
如果调用
halt()
方法,则应尽快中止。
有状态动作节点
StatefulActionNode是实现异步Action的首选方式。
当您的代码包含请求-答复模式时它特别有用,即当操作向另一个进程发送异步请求并定期检查是否已收到答复时。
根据该答复,它可能会返回 SUCCESS 或 FAILURE。
如果您不是与外部进程通信,而是执行一些需要很长时间的计算,您可能需要将其分成小的“块”,或者您可能希望将该计算移动到另一个线程。
StatefulActionNode的派生类必须重写以下虚拟方法,而不是tick()
:
-
NodeStatus onStart()
:当 Node 处于 IDLE 状态时调用。它可能立即成功或失败,或返回 RUNNING。在后一种情况下,下次收到 tick 时onRunning
将执行该方法。 -
NodeStatus onRunning()
:当 Node 处于 RUNNING 状态时调用。返回新状态。 -
void onHalted()
:当此节点被树中的另一个节点中止时调用。
让我们创建一个名为MoveBaseAction的虚拟节点:
// Custom type自定义类型
struct Pose2D
{
double x, y, theta;
};
namespace chr = std::chrono;
class MoveBaseAction : public BT::StatefulActionNode
{
public:
// Any TreeNode with ports must have a constructor with this signature
//任何带有端口的TreeNode都必须有一个带有此签名的构造函数
MoveBaseAction(const std::string& name, const BT::NodeConfig& config)
: StatefulActionNode(name, config)
{}
// It is mandatory to define this static method.必须定义这个静态方法。
static BT::PortsList providedPorts()
{
return{ BT::InputPort<Pose2D>("goal") };
}
// this function is invoked once at the beginning.这个函数在开始时调用一次。
BT::NodeStatus onStart() override;
// If onStart() returned RUNNING, we will keep calling
//如果onStart()返回RUNNING,我们将继续调用此方法,直到它返回与RUNNING不同的结果
// this method until it return something different from RUNNING
BT::NodeStatus onRunning() override;
// callback to execute if the action was aborted by another node
//当操作被另一个节点中止时执行的回调
void onHalted() override;
private:
Pose2D _goal;
chr::system_clock::time_point _completion_time;
};
//-------------------------
BT::NodeStatus MoveBaseAction::onStart()
{
if ( !getInput<Pose2D>("goal", _goal))
{
throw BT::RuntimeError("missing required input [goal]");
}
printf("[ MoveBase: SEND REQUEST ]. goal: x=%f y=%f theta=%f\n",
_goal.x, _goal.y, _goal.theta);
// We use this counter to simulate an action that takes a certain
//我们使用这个计数器来模拟一个需要一定时间的动作需要完成的时间(200毫秒)
// amount of time to be completed (200 ms)
_completion_time = chr::system_clock::now() + chr::milliseconds(220);
return BT::NodeStatus::RUNNING;
}
BT::NodeStatus MoveBaseAction::onRunning()
{
// Pretend that we are checking if the reply has been received
//假装我们正在检查是否收到了回复.您不想在此函数内阻塞太长时间。
// you don't want to block inside this function too much time.
std::this_thread::sleep_for(chr::milliseconds(10));
// Pretend that, after a certain amount of time,
// we have completed the operation假定经过一段时间后,我们已经完成了操作
if(chr::system_clock::now() >= _completion_time)
{
std::cout << "[ MoveBase: FINISHED ]" << std::endl;
return BT::NodeStatus::SUCCESS;
}
return BT::NodeStatus::RUNNING;
}
void MoveBaseAction::onHalted()
{
printf("[ MoveBase: ABORTED ]");
}
序列VS响应序列
下面的示例应该使用一个简单的SequenceNode
。
<root BTCPP_format="4">
<BehaviorTree>
<Sequence>
<BatteryOK/>
<SaySomething message="mission started..." />
<MoveBase goal="1;2;3"/>
<SaySomething message="mission completed!" />
</Sequence>
</BehaviorTree>
</root>
int main()
{
BT::BehaviorTreeFactory factory;
factory.registerSimpleCondition("BatteryOK", std::bind(CheckBattery));
factory.registerNodeType<MoveBaseAction>("MoveBase");
factory.registerNodeType<SaySomething>("SaySomething");
auto tree = factory.createTreeFromText(xml_text);
// Here, instead of tree.tickWhileRunning(),这里,不是tree.tickwhilerrunning (),
// we prefer our own loop.我们更喜欢自己的循环
std::cout << "--- ticking\n";
auto status = tree.tickOnce();
std::cout << "--- status: " << toStr(status) << "\n\n";
while(status == NodeStatus::RUNNING)
{
// Sleep to avoid busy loops.睡觉以避免繁忙的循环。
// do NOT use other sleep functions!不要使用其他睡眠功能!
// Small sleep time is OK, here we use a large one only to
// have less messages on the console.
//较小的睡眠时间是可以的,这里我们使用较大的睡眠时间只是为了减少控制台上的消息。
tree.sleep(std::chrono::milliseconds(100));
std::cout << "--- ticking\n";
status = tree.tickOnce();
std::cout << "--- status: " << toStr(status) << "\n\n";
}
return 0;
}
预期输出:
--- ticking
[ Battery: OK ]
Robot says: mission started...
[ MoveBase: SEND REQUEST ]. goal: x=1.0 y=2.0 theta=3.0
--- status: RUNNING
--- ticking
--- status: RUNNING
--- ticking
[ MoveBase: FINISHED ]
Robot says: mission completed!
--- status: SUCCESS
您可能已经注意到,当executeTick()
调用时,第一次和第二次MoveBase
返回 RUNNING ,并最终在第三次返回 SUCCESS 。
BatteryOK
仅执行一次。
如果我们改用ReactiveSequence
,当子MoveBase
进程返回 RUNNING 时,序列将重新启动,并且条件将再次BatteryOK
执行。
如果在任何时候BatteryOK
返回FAILURE,则MoveBase
操作将被中断(具体来说是停止)。
<root>
<BehaviorTree>
<ReactiveSequence>
<BatteryOK/>
<Sequence>
<SaySomething message="mission started..." />
<MoveBase goal="1;2;3"/>
<SaySomething message="mission completed!" />
</Sequence>
</ReactiveSequence>
</BehaviorTree>
</root>
预期输出:
--- ticking
[ Battery: OK ]
Robot says: mission started...
[ MoveBase: SEND REQUEST ]. goal: x=1.0 y=2.0 theta=3.0
--- status: RUNNING
--- ticking
[ Battery: OK ]
--- status: RUNNING
--- ticking
[ Battery: OK ]
[ MoveBase: FINISHED ]
Robot says: mission completed!
--- status: SUCCESS
事件驱动树?
提示:
我们使用命令
tree.sleep()
而不是std::this_thread::sleep_for()
有原因的!!!
应该优先使用Tree::sleep()方法,因为当“某些东西发生变化”时,它可以被树中的节点中断。
当调用TreeNode::emitStateChanged()方法时,Tree::sleep()将被中断。
5.使用子树
使用子树组合行为
我们可以通过将较小且可重复使用的行为插入到较大的行为中来构建大规模行为。
换句话说,我们想要创建分层的行为树并使我们的树可组合。
这可以通过在 XML 中定义多棵树并使用节点SubTree将一棵树包含到另一棵树中来实现。
CrossDoor 行为
这也是第一个使用Decorators
和的实际例子Fallback
。
<root BTCPP_format="4">
<BehaviorTree ID="MainTree">
<Sequence>
<Fallback>
<Inverter>
<IsDoorClosed/>
</Inverter>
<SubTree ID="DoorClosed"/>
</Fallback>
<PassThroughDoor/>
</Sequence>
</BehaviorTree>
<BehaviorTree ID="DoorClosed">
<Fallback>
<OpenDoor/>
<RetryUntilSuccessful num_attempts="5">
<PickLock/>
</RetryUntilSuccessful>
<SmashDoor/>
</Fallback>
</BehaviorTree>
</root>
期望的行为是:
- 如果门是开着的,
PassThroughDoor
。 - 如果门关着,请尝试
OpenDoor
,或者PickLock
最多尝试5次,或者最后SmashDoor
。 - 如果子树中至少有一个操作
DoorClosed
成功,那么PassThroughDoor
。
CPP代码
我们不会在 CrossDoor
中展示虚拟动作的详细实现。
唯一有趣的代码片段可能就是registerNodes
。
class CrossDoor
{
public:
void registerNodes(BT::BehaviorTreeFactory& factory);
// SUCCESS if _door_open != true
BT::NodeStatus isDoorClosed();
// SUCCESS if _door_open == true
BT::NodeStatus passThroughDoor();
// After 3 attempts, will open a locked door3次尝试后,将打开一扇锁着的门
BT::NodeStatus pickLock();
// FAILURE if door locked 如果门锁上了返回FAILURE
BT::NodeStatus openDoor();
// WILL always open a door 总会打开一扇门
BT::NodeStatus smashDoor();
private:
bool _door_open = false;
bool _door_locked = true;
int _pick_attempts = 0;
};
// Helper method to make registering less painful for the user
//帮助器方法,使用户注册时不那么痛苦
void CrossDoor::registerNodes(BT::BehaviorTreeFactory &factory)
{
factory.registerSimpleCondition(
"IsDoorClosed", std::bind(&CrossDoor::isDoorClosed, this));
factory.registerSimpleAction(
"PassThroughDoor", std::bind(&CrossDoor::passThroughDoor, this));
factory.registerSimpleAction(
"OpenDoor", std::bind(&CrossDoor::openDoor, this));
factory.registerSimpleAction(
"PickLock", std::bind(&CrossDoor::pickLock, this));
factory.registerSimpleCondition(
"SmashDoor", std::bind(&CrossDoor::smashDoor, this));
}
int main()
{
BehaviorTreeFactory factory;
CrossDoor cross_door;
cross_door.registerNodes(factory);
// In this example a single XML contains multiple <BehaviorTree>
//在这个例子中,一个XML包含多个<BehaviorTree>,要确定哪一个是“主树”,我们应该首先注册XML,然后使//用它的ID分配一个特定的树
// To determine which one is the "main one", we should first register
// the XML and then allocate a specific tree, using its ID
factory.registerBehaviorTreeFromText(xml_text);
auto tree = factory.createTree("MainTree");
// helper function to print the tree打印树的辅助函数
printTreeRecursively(tree.rootNode());
tree.tickWhileRunning();
return 0;
}
6.端口重新映射
重新映射子树的端口
SubTree
在 CrossDoor 示例中,我们看到从其父树的角度来看,它看起来像一个单叶节点。
为了避免在非常大的树中发生名称冲突,任何树和子树都使用不同的 Blackboard 实例。
因此,我们需要明确地将树的端口与其子树的端口连接起来。
您不需要修改您的 C++ 实现,因为此重新映射完全在 XML 定义中完成。
让我们考虑一下这个行为树。
<root BTCPP_format="4">
<BehaviorTree ID="MainTree">
<Sequence>
<Script code=" move_goal='1;2;3' " />
<SubTree ID="MoveRobot" target="{move_goal}"
result="{move_result}" />
<SaySomething message="{move_result}"/>
</Sequence>
</BehaviorTree>
<BehaviorTree ID="MoveRobot">
<Fallback>
<Sequence>
<MoveBase goal="{target}"/>
<Script code=" result:='goal reached' " />
</Sequence>
<ForceFailure>
<Script code=" result:='error' " />
</ForceFailure>
</Fallback>
</BehaviorTree>
</root>
您可能会注意到:
- 我们有一个
MainTree
包含一个名为MoveRobot
的子树。 - 我们希望将
MoveRobot
子树内的端口与MainTree
中的其他端口“连接”(即“重新映射”) 。 - 这是通过上面示例中使用的语法完成的。
CPP 代码
这里没什么可做的。我们用这个debugMessage
方法来检查黑板的值。
int main()
{
BT::BehaviorTreeFactory factory;
factory.registerNodeType<SaySomething>("SaySomething");
factory.registerNodeType<MoveBaseAction>("MoveBase");
factory.registerBehaviorTreeFromText(xml_text);
auto tree = factory.createTree("MainTree");
// Keep ticking until the end 保持tick,直到结束
tree.tickWhileRunning();
// let's visualize some information about the current state of the blackboards.
//让我们想象一下黑板当前状态的一些信息。
std::cout << "\n------ First BB ------" << std::endl;
tree.subtrees[0]->blackboard->debugMessage();
std::cout << "\n------ Second BB------" << std::endl;
tree.subtrees[1]->blackboard->debugMessage();
return 0;
}
/* Expected output:
------ First BB ------
move_result (std::string)
move_goal (Pose2D)
------ Second BB------
[result] remapped to port of parent tree [move_result]
[target] remapped to port of parent tree [move_goal]
*/
7.使用多个XML文件
在我们提供的示例中,我们总是从单个 XML 文件创建整个树及其子树。
但随着子树数量的增长,使用多个文件会很方便。
我们的子树
文件subtree_A.xml:
<root>
<BehaviorTree ID="SubTreeA">
<SaySomething message="Executing Sub_A" />
</BehaviorTree>
</root>
文件subtree_B.xml:
<root>
<BehaviorTree ID="SubTreeB">
<SaySomething message="Executing Sub_B" />
</BehaviorTree>
</root>
手动加载多个文件(推荐)
让我们考虑一个应该包含其他 2 个文件的文件main_tree.xml :
<root>
<BehaviorTree ID="MainTree">
<Sequence>
<SaySomething message="starting MainTree" />
<SubTree ID="SubTreeA" />
<SubTree ID="SubTreeB" />
</Sequence>
</BehaviorTree>
</root>
要手动加载多个文件:
int main()
{
BT::BehaviorTreeFactory factory;
factory.registerNodeType<DummyNodes::SaySomething>("SaySomething");
// Find all the XML files in a folder and register all of them.
// We will use std::filesystem::directory_iterator
//在一个文件夹中找到所有的XML文件并注册它们。//使用std::filesystem::directory_iterator
std::string search_directory = "./";
using std::filesystem::directory_iterator;
for (auto const& entry : directory_iterator(search_directory))
{
if( entry.path().extension() == ".xml")
{
factory.registerBehaviorTreeFromFile(entry.path().string());
}
}
// This, in our specific case, would be equivalent to 这个,在这种情况下,就等于
// factory.registerBehaviorTreeFromFile("./main_tree.xml");
// factory.registerBehaviorTreeFromFile("./subtree_A.xml");
// factory.registerBehaviorTreeFromFile("./subtree_B.xml");
// You can create the MainTree and the subtrees will be added automatically.
//您可以创建主树,然后自动添加子树。
std::cout << "----- MainTree tick ----" << std::endl;
auto main_tree = factory.createTree("MainTree");
main_tree.tickWhileRunning();
// ... or you can create only one of the subtrees 或者你可以只创建一个子树
std::cout << "----- SubA tick ----" << std::endl;
auto subA_tree = factory.createTree("SubTreeA");
subA_tree.tickWhileRunning();
return 0;
}
/* Expected output:
Registered BehaviorTrees:
- MainTree
- SubTreeA
- SubTreeB
----- MainTree tick ----
Robot says: starting MainTree
Robot says: Executing Sub_A
Robot says: Executing Sub_B
----- SubA tick ----
Robot says: Executing Sub_A
使用“include"
如果您希望将树的信息包含到 XML 本身中,您可以修改main_tree.xml,如下所示:
<root BTCPP_format="4">
<include path="./subtree_A.xml" />
<include path="./subtree_B.xml" />
<BehaviorTree ID="MainTree">
<Sequence>
<SaySomething message="starting MainTree" />
<SubTree ID="SubTreeA" />
<SubTree ID="SubTreeB" />
</Sequence>
</BehaviorTree>
</root>
您可能注意到,我们在main_tree.xml中包含了两个相对路径 ,告诉BehaviorTreeFactory
在哪里找到所需的依赖项。
路径相对于main_tree.xml。
我们现在可以像平常一样创建树:
factory.createTreeFromFile("main_tree.xml")
8.传递附加参数
向节点传递附加参数
在我们迄今为止探索的每个示例中,我们都“被迫”提供具有以下签名的构造函数
MyCustomNode(const std::string& name, const NodeConfig& config);
在某些情况下,需要将额外的参数、参数、指针、引用等传递给我们的类的构造函数。
警告:有些人用黑板(blackboards)来做这件事。一定不要这样做!!!
在本教程的其余部分,我们将仅使用“参数”一词。
即使从理论上讲,这些参数可以通过输入端口传递,但如果出现以下情况,那么这样做就是错误的:
- 这些参数在部署时(构建树时)是已知的。
- 参数在运行时不会改变。
- 不需要从 XML 中设置参数。
如果满足所有这些条件,则强烈不建议使用端口(ports)或黑板(blackboard)。
向构造函数添加参数(推荐)
考虑以下名为Action_A的自定义节点。
我们想要传递两个附加参数;它们可以是任意复杂的对象,而不仅限于内置类型。
// Action_A has a different constructor than the default one.
//Action_A有一个不同于默认构造函数的构造函数。
class Action_A: public SyncActionNode
{
public:
// additional arguments passed to the constructor传递给构造函数的附加参数
Action_A(const std::string& name, const NodeConfig& config,
int arg_int, std::string arg_str):
SyncActionNode(name, config),
_arg1(arg_int),
_arg2(arg_str) {}
// this example doesn't require any port这个示例不需要任何端口
static PortsList providedPorts() { return {}; }
// tick() can access the private members//Tick()可以访问私有成员
NodeStatus tick() override;
private:
int _arg1;
std::string _arg2;
};
注册此节点并传递已知参数非常简单:
BT::BehaviorTreeFactory factory;
factory.registerNodeType<Action_A>("Action_A", 42, "hello world");
// If you prefer to specify the template parameters//如果您希望指定模板参数
// factory.registerNodeType<Action_A, int, std::string>("Action_A", 42, "hello world");
使用“initialize”方法
如果由于任何原因您需要将不同的值传递给 Node 类型的各个实例,您可能需要考虑其他模式:
class Action_B: public SyncActionNode
{
public:
// The constructor looks as usual.构造函数看起来和往常一样。
Action_B(const std::string& name, const NodeConfig& config):
SyncActionNode(name, config) {}
// We want this method to be called ONCE and BEFORE the first tick()
//我们希望在第一个tick()之前调用这个方法一次。
void initialize(int arg_int, const std::string& arg_str)
{
_arg1 = arg_int;
_arg2 = arg_str;
}
// this example doesn't require any port 这个示例不需要任何端口
static PortsList providedPorts() { return {}; }
// tick() can access the private members// tick()可以访问私有成员
NodeStatus tick() override;
private:
int _arg1;
std::string _arg2;
};
我们注册和初始化Action_B的方式有所不同:
BT::BehaviorTreeFactory factory;
// Register as usual, but we still need to initialize
//像往常一样注册,但我们仍然需要初始化
factory.registerNodeType<Action_B>("Action_B");
// Create the whole tree. Instances of Action_B are not initialized yet
//创建整个树。Action_B的实例还没有初始化
auto tree = factory.createTreeFromText(xml_text);
// visitor will initialize the instances of //visitor将初始化实例
auto visitor = [](TreeNode* node)
{
if (auto action_B_node = dynamic_cast<Action_B*>(node))
{
action_B_node->initialize(69, "interesting_value");
}
};
// Apply the visitor to ALL the nodes of the tree//将visitor应用于树的所有节点
tree.applyVisitor(visitor);
9.Script实例
脚本和前提条件节点
在我们的脚本语言中,变量是黑板中的条目。
在这个例子中,我们使用节点脚本来设置这些变量并观察我们可以在SaySomething中将它们作为输入端口访问。
支持的类型有数字(整数和实数)、字符串和已注册的 ENUMS。
警告
请注意,我们使用的magic_enums有一些已知的 限制。
值得注意的是,默认范围是[-128, 128] .
我们将使用这个 XML:
<root BTCPP_format="4">
<BehaviorTree>
<Sequence>
<Script code=" msg:='hello world' " />
<Script code=" A:=THE_ANSWER; B:=3.14; color:=RED " />
<Precondition if="A>B && color != BLUE" else="FAILURE">
<Sequence>
<SaySomething message="{A}"/>
<SaySomething message="{B}"/>
<SaySomething message="{msg}"/>
<SaySomething message="{color}"/>
</Sequence>
</Precondition>
</Sequence>
</BehaviorTree>
</root>
我们期望黑板条目包含以下内容:
- msg:字符串“hello world”
- A:与别名 THE_ANSWER 对应的整数值。
- B:真实值 3.14
- C:与枚举RED对应的整数值。
因此,预期输出为:
Robot says: 42.000000
Robot says: 3.140000
Robot says: hello world
Robot says: 1.000000
C++代码如下:
enum Color
{
RED = 1,
BLUE = 2,
GREEN = 3
};
int main()
{
BehaviorTreeFactory factory;
factory.registerNodeType<DummyNodes::SaySomething>("SaySomething");
// We can add these enums to the scripting language.//我们可以将这些枚举添加到脚本语言中。
// Check the limits of magic_enum //检查magic_enum的限制
factory.registerScriptingEnums<Color>();
// Or we can manually assign a number to the label "THE_ANSWER".
// This is not affected by any range limitation
//或者我们可以手动为标签THE_ANSWER分配一个数字。
//不受任何范围限制
factory.registerScriptingEnum("THE_ANSWER", 42);
auto tree = factory.createTreeFromText(xml_text);
tree.tickWhileRunning();
return 0;
}
10.记录员(logger)和观察员(Observer)
记录器接口
BT.CPP 提供了一种在运行时将记录器添加到树中的方法,通常是在树创建之后、开始tick它之前。
“记录器”是一个在 TreeNode 每次改变其状态时都会调用回调的类;它是所谓观察者模式的非侵入式实现。
更具体地说,将被调用的回调是:
virtual void callback(
BT::Duration timestamp, // When the transition happened//当转换发生时
const TreeNode& node, // the node that changed its status//改变状态的节点
NodeStatus prev_status, // the previous status//前一个状态
NodeStatus status); // the new status//新的状态
TreeObserver 类
有时,特别是在执行单元测试时,知道某个节点返回 SUCCESS 或 FAILURE 的次数会很方便。
例如,我们想要检查在某些条件下是否执行一个分支而另一个分支不被执行。
这TreeObserver
是一个简单的记录器实现,它收集树的每个节点的以下统计数据:
struct NodeStatistics
{
// Last valid result, either SUCCESS or FAILURE//最后一个有效结果,SUCCESS或FAILURE
NodeStatus last_result;
// Last status. Can be any status, including IDLE or SKIPPED
//最后状态。可以是任何状态,包括IDLE或skip
NodeStatus current_status;
// count status transitions, excluding transition to IDLE//计数状态转换,不包括转换到IDLE
unsigned transitions_count;
// count number of transitions to SUCCESS//转换到SUCCESS的次数
unsigned success_count;
// count number of transitions to FAILURE//转换到SUCCESS的次数
unsigned failure_count;
// count number of transitions to SKIPPED//转换到SKIPPED的次数
unsigned skip_count;
// timestamp of the last transition//最后一次转换的时间戳
Duration last_timestamp;
};
如何唯一标识一个节点
由于观察者允许我们收集特定节点的统计信息,因此我们需要一种方法来唯一地标识该节点:
可以使用两种机制:
-
这是与树的深度优先遍历
TreeNode::UID()
相对应的唯一数字。 -
TreeNode::fullPath()
旨在成为特定节点的唯一但人类可读的标识符。
我们使用术语“路径”,因为典型的字符串值可能看起来像这样:
first_subtree/nested_subtree/node_name
换句话说,路径包含有关子树层次结构中节点位置的信息。
“node_name” 是 XML 中指定的名称属性,或者是使用节点注册后跟“::”和 UID 自动分配的。
示例(XML)
考虑以下 XML,就子树而言,它具有非平凡的层次结构:
<root BTCPP_format="4">
<BehaviorTree ID="MainTree">
<Sequence>
<Fallback>
<AlwaysFailure name="failing_action"/>
<SubTree ID="SubTreeA" name="mysub"/>
</Fallback>
<AlwaysSuccess name="last_action"/>
</Sequence>
</BehaviorTree>
<BehaviorTree ID="SubTreeA">
<Sequence>
<AlwaysSuccess name="action_subA"/>
<SubTree ID="SubTreeB" name="sub_nested"/>
<SubTree ID="SubTreeB" />
</Sequence>
</BehaviorTree>
<BehaviorTree ID="SubTreeB">
<AlwaysSuccess name="action_subB"/>
</BehaviorTree>
</root>
您可能会注意到,一些节点具有 XML 属性“名称”,而其他节点没有。
UID -> fullPath对的对应列表是:
1 -> Sequence::1
2 -> Fallback::2
3 -> failing_action
4 -> mysub
5 -> mysub/Sequence::5
6 -> mysub/action_subA
7 -> mysub/sub_nested
8 -> mysub/sub_nested/action_subB
9 -> mysub/SubTreeB::9
10 -> mysub/SubTreeB::9/action_subB
11 -> last_action
示例(C++)
以下应用程序将:
- 递归地打印树的结构。
附加TreeObserver
到树上。- 打印
UID / fullPath
这些对。 - 收集名为“last_action”的特定节点的统计信息。
- 显示观察员收集的所有统计数据。
int main()
{
BT::BehaviorTreeFactory factory;
factory.registerBehaviorTreeFromText(xml_text);
auto tree = factory.createTree("MainTree");
// Helper function to print the tree.//打印树的辅助函数。
BT::printTreeRecursively(tree.rootNode());
// The purpose of the observer is to save some statistics about the number of times
// a certain node returns SUCCESS or FAILURE.
// This is particularly useful to create unit tests and to check if
// a certain set of transitions happened as expected
//观察者的目的是保存一些关于某个节点返回SUCCESS或FAILURE次数的统计数据。
//这对于创建单元测试和检查一组特定的转换是否按预期发生特别有用
BT::TreeObserver observer(tree);
// Print the unique ID and the corresponding human readable path
// Path is also expected to be unique.
//打印唯一的ID和相应的人类可读路径
//路径也是唯一的。
std::map<uint16_t, std::string> ordered_UID_to_path;
for(const auto& [name, uid]: observer.pathToUID()) {
ordered_UID_to_path[uid] = name;
}
for(const auto& [uid, name]: ordered_UID_to_path) {
std::cout << uid << " -> " << name << std::endl;
}
tree.tickWhileRunning();
// You can access a specific statistic, using is full path or the UID
//您可以访问一个特定的统计数据,使用它的全路径或UID
const auto& last_action_stats = observer.getStatistics("last_action");
assert(last_action_stats.transitions_count > 0);
std::cout << "----------------" << std::endl;
// print all the statistics //打印所有统计信息
for(const auto& [uid, name]: ordered_UID_to_path) {
const auto& stats = observer.getStatistics(uid);
std::cout << "[" << name
<< "] \tT/S/F: " << stats.transitions_count
<< "/" << stats.success_count
<< "/" << stats.failure_count
<< std::endl;
}
return 0;
}
11.链接到Groot2
Groot2是用于编辑、监控和与使用BT.CPP创建的行为树交互的官方 IDE 。
正如您将在本教程中看到的,将两者结合起来非常容易,但您应该首先了解一些简单的概念。
树节点模型(TreeNodesModel)
Groot 需要一个“TreeNodesModel”。
例如在上图中,Groot 需要知道用户定义的节点 ThinkWhatToSay
和SaySomething
存在。
此外,它还要求:
- Node 类型
- 端口的名称和类型(输入/输出)。
这些模型以 XML 表示。在本例中,它们将是:
<TreeNodesModel>
<Action ID="SaySomething">
<input_port name="message"/>
</Action>
<Action ID="ThinkWhatToSay">
<output_port name="text"/>
</Action>
</TreeNodesModel>
尽管如此,您不应该手动创建这些 XML 描述。
BT.CPP 有一个特定的函数可以为您生成这个 XML。
BT::BehaviorTreeFactory factory;
//
// register here your user-defined Nodes//在这里注册您的用户定义节点
//
std::string xml_models = BT::writeTreeNodesModelXML(factory);
// this xml_models should be saved to file and
// loaded in Groot2
//这个xml_models会被保存到文件并且在Groot2中加载
要将这些模型导入 UI,请执行以下操作之一:
- 将 XML 保存到文件中(例如称为
models.xml
)并单击Groot2 中的导入模型按钮。 - 或者手动将 XML 部分直接添加到您的
.xml
或.btproj
文件中。
向Groot添加实时可视化
笔记:目前,只有Groot2的PRO版本支持实时可视化。
将树连接到 Groot2 只需一行代码:
BT::Groot2Publisher publisher(tree);
这将在您的 BT.CPP 执行器和 Groot2 之间创建一个进程间通信服务:
- 将整个树结构发送给Groot2,包括上面提到的模型。
- 定期更新各个节点的状态(RUNNING, SUCCESS, FAILURE, IDLE)。
- 发送blackboard的值;开箱即用支持整数、实数和字符串等基本类型,其他类型需要手动添加。
- 允许 Groot2 插入断点、执行节点替换或故障注入。
完整示例:
<root BTCPP_format="4">
<BehaviorTree ID="MainTree">
<Sequence>
<Script code="door_open:=false" />
<Fallback>
<Inverter>
<IsDoorClosed/>
</Inverter>
<SubTree ID="DoorClosed" _autoremap="true" door_open="{door_open}"/>
</Fallback>
<PassThroughDoor/>
</Sequence>
</BehaviorTree>
<BehaviorTree ID="DoorClosed">
<Fallback name="tryOpen" _onSuccess="door_open:=true">
<OpenDoor/>
<RetryUntilSuccessful num_attempts="5">
<PickLock/>
</RetryUntilSuccessful>
<SmashDoor/>
</Fallback>
</BehaviorTree>
</root>
int main()
{
BT::BehaviorTreeFactory factory;
// Our set of simple Nodes, related to CrossDoor 我们的简单节点集,与CrossDoor相关
CrossDoor cross_door;
cross_door.registerNodes(factory);
// Groot2 editor requires a model of your registered Nodes.
// You don't need to write that by hand, it can be automatically
// generated using the following command.
// Groot2编辑器需要注册节点的模型。
//你不需要手工写,它可以自动生成
//使用以下命令生成。
std::string xml_models = BT::writeTreeNodesModelXML(factory);
factory.registerBehaviorTreeFromText(xml_text);
auto tree = factory.createTree("MainTree");
// Connect the Groot2Publisher. This will allow Groot2 to
// get the tree and poll status updates.
//连接Groot2Publisher。这将允许groot2获取树并轮询状态更新。
BT::Groot2Publisher publisher(tree);
// we want to run this indefinitely //我们想要无限期地运行它
while(1)
{
std::cout << "Start" << std::endl;
cross_door.reset();
tree.tickWhileRunning();
std::this_thread::sleep_for(std::chrono::milliseconds(3000));
}
return 0;
}
在Blackboard中可视化自定义类型
黑板里面的内容以JSON格式发送给Groot2。
要添加新类型并允许 Groot2 对其进行可视化,您应该按照此处的说明进行操作:
Arbitrary Type Conversions - JSON for Modern C++
例如,给定一个用户定义类型:
struct Pose2D {
double x;
double y;
double theta;
}
您将需要包含behaviortree_cpp/json_export.h并根据您的 BT.CPP 版本遵循这些说明。
“to_json”函数的实现可以有任何名称或命名空间,但必须符合函数签名void(nlohmann::json&, const T&)
。
例如:
void PoseToJson(nlohmann::json& dest, const Pose2D& pose) {
dest["x"] = pose.x;
dest["y"] = pose.y;
dest["theta"] = pose.theta;
}
注册该函数并将其添加到您的主函数中:
BT::RegisterJsonDefinition<Pose2D>(PoseToJson);