前言
本人在准备RoboMaster比赛时负责编写哨兵机器人的决策代码,在查询资料后可知需要进行关于BehaviorTree(以下简称BT树)的学习,不过BT树的官方教程过于简单并且并无过多言语描述并且网上我暂时没有搜索到系统性BehaviorTree_cpp的学习路线,更多的只是与虚幻引擎当中的行为树蓝图有关的教程。
本着没有教程就创造教程以及作为自己的备忘录的初衷,本人决定开启本文的编写。由于本人对于端口、xml文件编写的了解程度可算作为0,所以当中的表述会有些出入甚至是完全错误,也请各位在发现本人表述上有错误时可以及时指正,本文持续更新。
那么让我们开始关于BT树的学习路程吧!
一、何为BT树
1.概念:
与有限状态机不同,行为树是控制“任务”执行流的分层节点树。(有限状态机(缩写:FSM)又称有限状态自动机(英语:finite-state automaton,缩写:FSA),简称状态机,是表示有限个状态以及在这些状态之间的转移和动作等行为的数学计算模型。——来自维基百科)
2.节点(建议先了解数据结构中树的概念):
2.1 TreeNode(树节点)
孩子数量:1...N
树节点包含一下提到的所有节点,类似于复数对于整数、虚数、实数等等的概念。
2.2 LeafNode(叶子节点)
LeafNodes,那些没有任何子节点的 TreeNodes, 是实际的命令,即行为树的节点 与系统的其余部分交互。操作节点是最常见的 LeafNode 类型。
2.3 ControlNode(控制节点)
官方解释:通常,根据其兄弟姐妹或/和其自身状态的结果来勾选子项。
2.4 DecoratorNode(装饰节点)
孩子数量:1
官方解释:除其他外,它可能会改变其子项的结果或多次勾选它。
人话:这小子俩作用,其一:原本返回的结果是SUCCESS,经过它就变为FIELD了
其二:类比for函数,也就是多次执行某操作节点(ActionNode)
2.5 ConditionNode(条件节点)
孩子数量:0
官方解释:不应更改系统,不能返回RUNNING(毕竟都叫条件节点了,当然只能非对即错咯)
2.6 ActionNode(操作节点)
孩子数量:0
官方解释:就是“做某事”的节点
注:该节点分为同步节点以及异步节点。前者以原子方式执行并阻塞树,直到返回 SUCCESS 或 FAILURE。相反,异步操作可能会返回 RUNNING 来传达 该操作仍在执行中。我们需要再次访问它们,直到最终返回 SUCCESS 或 FAILURE。(想具体了解异步操作的可以看四)
2.7 NodeStatus(节点状态)
节点状态分为三种:SUCCESS(成功) FIELD(失败) RUNNING(运行)
不同的节点状态集成在NodeStatus并由NodeStatus进行return,return回来的不同状态会很大程度的决定了BT树的下一步操作到底是结束还是继续还是其它怎么样。
2.8不同节点间的包含关系
3.Blackboard(黑板)与端口
- “Blackboard”是所有节点共享的简单键/值存储 树。
- Blackboard 的“条目”是键/值对。
- 输入端口可以读取 Blackboard 中的条目,而输出端口可以写入条目。
输入端口有效输入:1.Node读取和解析的静态字符串 2.指向BlackBoard条目的指针,由键表示
输出端口有效输入:仅当另一个节点已在同一条目内写入“某些内容”时,指向黑板条目的输入端口才有效。
在我看来,黑板的作用类似于一个仓库,InputPort端口是放东西,OutputPort端口是拿东西。
4.关键函数
4.1tick()及其工作原理
我们可以看作为ROS中的spin()函数,其用法就是通过override(复写)tick函数来完成我们想要进行的操作,将想写的代码写入其中即可。
工作原理:一个树中tick函数会照从左到右的顺序单线程执行,如果执行当中有任意一方法阻塞(也就是不反悔三种状态中的一种时),则整个执行的流均会阻塞,尤其是在sequence中,当前一个操作节点执行失败,则在该节点之后的所有节点均不会被执行。
二、理论联系实际(初级)
0.执行流程的综述(个人见解)
以官方流程代码为例子,想要创建以上的BT树大体需要经历如下几个阶段:
创建CheckBattery和其余四个操作节点----->在main函数中创建BehaviorTreeFactory实例---->用factory.registerNodeType<T>("name")创建名为name的端口?(反正这步的意义就是让你写的操作节点不在xml文件中报错)---->编写xml文件---->在main函数中写auto tree=factory.createTreeFromFile("xml文件路径")来构造树---->tree.tickWhileRunning();
注:随着ActionNode类型的不同,是否有端口,在main函数用factory注册的时候所用的子函数都不同。
1.1创建ActionNode(无端口)
官方推荐的方法时继承,继承的东西以及接下来书写的代码形式我没有进行过多了解,直接背就完事了。
//在没有端口的情况下自定义SyncActionNode示例(同步操作) 。
class ApproachObject : public BT::SyncActionNode
{
public:
ApproachObject(const std::string& name) :
BT::SyncActionNode(name, {})
{}
// 你必须复写tick()函数
BT::NodeStatus tick() override
{
std::cout << "ApproachObject: " << this->name() << std::endl;
return BT::NodeStatus::SUCCESS;
}
};
构造函数的形式到2024.2.19日我都是直接背,日后可能会有进一步解释
tick函数复写进自己想要的操作。
所有这种操作节点的return函数返回的只能是NodeStatus中的RUNNING、SUCCESS 或 FAILURE
另外,我们也可以使用依赖注入来创建一个给定函数指针(即 "functor")的 TreeNode。(原文为Alternatively, we can use dependency injection to create a TreeNode given a function pointer (i.e. "functor").我一直没看懂)
有关依赖注入(DI)可以参考本连接理解依赖注入(DI – Dependency Injection) - 知乎 (zhihu.com)https://zhuanlan.zhihu.com/p/67032669
1.2创建ActionNode(有端口)
输入端口:
// SyncActionNode(同步动作)带有输入端口。
class SaySomething : public SyncActionNode
{
public:
//如果您的节点有端口,则必须使用此构造函数签名
SaySomething(const std::string& name, const NodeConfig& config)
: SyncActionNode(name, config)
{ }
//必须定义此 STATIC 方法。
static PortsList providedPorts()
{
// 此操作有一个名为“message”的输入端口
return { InputPort<std::string>("message") };
}
//复写纯虚函数 tick()
NodeStatus tick() override
{
Expected<std::string> msg = getInput<std::string>("message");
//检查预期是否有效。如果没有,抛出它的错误
if (!msg)
{
throw BT::RuntimeError("missing required input [message]: ",
msg.error() );
}
// 使用 value() 方法提取有效消息。
std::cout << "Robot says: " << msg.value() << std::endl;
return NodeStatus::SUCCESS;
}
};
其中getInput可以用模板类型TreeNode::getInput<T>(key),不过失败率高,有以下三种解决方案:1.返回NodeStatus::FAILURE 2.抛出异常 3.使用其它默认值
注:1.getInput中的值可能会随着运行的时间进行更改,所以应当对其进行周期性更新。
2.始终建议在tick中调用getInput,而不是在构造函数中。
输出端口:
class ThinkWhatToSay : public SyncActionNode
{
public:
ThinkWhatToSay(const std::string& name, const NodeConfig& config)
: SyncActionNode(name, config)
{ }
static PortsList providedPorts()
{
return { OutputPort<std::string>("text") };
}
// 该操作将向端口 "text "写入一个值
NodeStatus tick() override
{
// 每次 tick() 时,输出结果都可能发生变化。在本例子中,我们将从简设为简短的一句话。
setOutput("text", "The answer is 42" );
return NodeStatus::SUCCESS;
}
};
1.3关于ActionNode的拓展
Sync同步:串行执行(按顺序,一个等一个),阻塞模式。
Async异步:并行执行(没有顺序,同时做),非阻塞模式。
-
BT::ActionNodeBase
: 基类 -
BT::AsyncActionNode
:当节点处于运行状态时,默认返回running,当使用Sequence时,再次被tick时,会再次调用running节点。 -
BT::CoroActionNode
:CoroActionNode 类是需要使用异步请求/回复接口与服务提供商通信的异步操作的理想候选对象(例如 ROS 中的 ActionLib、MoveIt 客户端或 move_base 客户端)。 -
BT::SimpleActionNode
:SimpleActionNode 提供了一种易于使用的 ActionNode。用户只需提供具有此签名的回调即可。 -
BT::SyncActionNode
:该节点不存在running状态,执行会一直等待直到返回状态。
2.编写xml文件
以上述0的图片为例子,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 BTCPP_format="4"代表的意思是有四个子节点
BehaviorTree ID为本xml文件所编写的树的姓名
sequence表示的是以下节点按照顺序执行
3.注册树&&执行树
#include "behaviortree_cpp/bt_factory.h"
// 包含自定义节点定义的文件
#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
*/
以上的代码注册的节点均有端口
以上便是掌握行为树的基本体系架构了,接下来想要阅读就需要对上述流程以及代码有一定的熟练度了。
三、端口进阶——具有泛型类型的端口
端口只能传递string类型的消息,若我想传递int...NodeStatus时BehaviorTree.CPP支持自动将字符串转换为常用字符串 类型,但如果我想传入结构体时便需要解析字符串操作。
解析字符串
在此我们定义一个结构体包含xy坐标作为例子
struct Position2D
{
double x;
double y;
};
为了允许 XML 加载器从字符串实例化 a,我们需要提供 .Position2D 的模板特化
BT::convertFromString<Position2D>(StringView)
如何序列化为字符串由您决定;在本例中, 我们只需用分号分隔两个数字。其中StringBiew是 std::string_view 的 C++11 版本。
//将字符串转换为 Position2D 的模板专业化
namespace BT
{
template <> inline Position2D convertFromString(StringView str)
{
// 我们期望用分号分隔的实数
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;
}
}
}
该库提供了一个简单的函数。随意使用另一个 一个,比如 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()
{
// 可选择对端口进行人可读的描述
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;
}
};
static const char* xml_text = R"(
<root BTCPP_format="4" >
<BehaviorTree ID="MainTree">
<Sequence name="root">
<CalculateGoal goal="{GoalPosition}" />
<PrintTarget target="{GoalPosition}" />
##使用内置操作分配字符串“-1;3“ 到键 OtherGoal。 从字符串到的转换将自动完成
<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 ]
*/
四、异步操作
1.并发性与并行性
并发是指两个或多个任务可以在重叠的时间段内启动、运行和完成。 这并不一定意味着它们会在同一时刻运行。
并行是指任务在不同的线程中同时运行,例如,在多核处理器上。
BT.CPP同时执行所有节点。换言之:
- 树执行引擎是单线程的。
- 所有方法都是按顺序执行的。
tick()
- 如果任何方法阻塞,则整个执行流将被阻塞。
tick()
我们通过“并发”和异步执行来实现反应式行为。
换句话说,需要很长时间才能执行的 Action 应该 尽快返回状态 RUNNING。这样才能让执行流保持通畅。这会告诉树执行程序操作已启动,需要更多时间才能返回 状态 SUCCESS 或 FAILURE。 我们需要再次勾选该节点以了解状态是否更改(轮询)。
异步节点可以将此长执行委托给另一个进程 (使用进程间通信)或其他线程。
2.异步与同步
通常,异步节点是:
- 勾选时,可能会返回 RUNNING 而不是 SUCCESS 或 FAILURE。
- 调用
halt()
方法时,可以尽快停止。
通常,方法halt()必须由开发人员实现。
当树执行返回 RUNNING 的异步操作时, 该状态通常向后传播,使整个树都处于 RUNNING 状态。
在下面的示例中,“ActionE”是异步的和 RUNNING;当子节点返回RUNNING时通常它的父节点也返回 RUNNING。
3.StatefulActionNode——异步操作首选方法
3.0官方概念以及应用场景:
当您的代码包含请求-回复模式时,它特别有用, 即,当操作向另一个进程发送异步请求时, 并定期检查是否已收到回复。根据该回复,它可能会返回 SUCCESS 或 FAILURE。如果您不是与外部进程通信,而是执行一些 需要很长时间的计算,您可能希望将其拆分为小的“块” 或者您可能希望将该计算移动到另一个线程 (请参阅 AsyncThreadedAction 教程)。
3.1书写规范:
StatefulActionNode 的派生类必须重写以下虚拟方法: 而不是tick()
3.1.1 NodeStatus onStart():当节点处于空闲状态时调用。 它可能会立即成功或失败,或者返回 RUNNING。在后一种情况下, 下次收到即时报价时,将执行该方法。onRunning
3.1.2 NodeStatus onRunning():当 Node 处于 RUNNING 状态时调用。 返回新状态。
3.1.3 void onHalted():当此节点被另一个节点中止时调用 在树上。
3.2例子
让我们创建一个名为 MoveBaseAction 的虚拟节点:
// 自定义类型Pose2D
struct Pose2D
{
double x, y, theta;
};
namespace chr = std::chrono;
class MoveBaseAction : public BT::StatefulActionNode
{
public:
// 任何具有端口的 TreeNode 都必须具有具有此签名的构造函数
MoveBaseAction(const std::string& name, const BT::NodeConfig& config)
: StatefulActionNode(name, config)
{}
//必须定义此静态方法。
static BT::PortsList providedPorts()
{
return{ BT::InputPort<Pose2D>("goal") };
}
// 该函数在开始时被调用一次。
BT::NodeStatus onStart() override;
//若onStart()返回RUNNING, 我们将继续调用
// 这个方法,直到它返回与 RUNNING 不同的结果为止。
BT::NodeStatus onRunning() override;
// 如果其他节点中止了操作,则执行回调
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);
//我们使用这个计数器来模拟需要一定时间的动作
// 要完成的时间量(200 毫秒)
_completion_time = chr::system_clock::now() + chr::milliseconds(220);
return BT::NodeStatus::RUNNING;
}
BT::NodeStatus MoveBaseAction::onRunning()
{
// 假装我们正在检查是否已收到回复
// 你不想在这个函数中阻塞太多时间。
std::this_thread::sleep_for(chr::milliseconds(10));
// 假装经过一定时间后,
// 我们已经完成了操作
if(chr::system_clock::now() >= _completion_time)
{
std::cout << "[ MoveBase: 完成 ]" << std::endl;
return BT::NodeStatus::SUCCESS;
}
return BT::NodeStatus::RUNNING;
}
void MoveBaseAction::onHalted()
{
printf("[ MoveBase: 已终止 ]");
}
在上面的代码中:
- 当第一次调用 SleepNode 时,将执行
onStart()
方法。 如果将睡眠时间调为 0,这可能会立即返回 SUCCESS,否则将返回 RUNNING。 - 我们应该继续在循环中调用树。这将调用可能再次返回 RUNNING 或最终返回 SUCCESS 的
onRunning()
方法。 - 另一个节点的
halt()函数
可能会触发信号。在这种情况下,本节点将调用onHalted()
方法。
4.避免阻塞树的执行
想制造一个阻塞树的实现方法是:SleepNode()
// 这是Node的同步版本。可能不是我们想要的。
class BadSleepNode : public BT::ActionNodeBase
{
public:
BadSleepNode(const std::string& name, const BT::NodeConfig& config)
: BT::ActionNodeBase(name, config)
{}
static BT::PortsList providedPorts()
{
return{ BT::InputPort<int>("msec") };
}
NodeStatus tick() override
{
int msec = 0;
getInput("msec", msec);
// 该阻塞函数将冻结整个树
std::this_thread::sleep_for( milliseconds(msec) );
return NodeStatus::SUCCESS;
}
void halt() override
{
// 没有人可以调用这个方法,因为我冻结了树。
// 即使这个方法可以执行,我也没有办法
// 中断 std::this_thread::sleep_for()
}
};
5.多线程的问题
在这个库的早期(版本 1.x),生成一个新线程 看起来是构建异步操作的好解决方案。
这是一个坏主意,原因有很多:
- 以线程安全的方式访问黑板更难(稍后会详细介绍)。
- 你可能不需要。
- 人们认为这会神奇地使 Action 变得“异步”,但他们 忘记他们仍然有责任“以某种方式”快速停止该线程 调用
halt()
方法。
出于这个原因,通常不鼓励用户使用 BT::ThreadedAction
基类。让我们再看一下 SleepNode。
// 这将产生它自己的线程。但停止时仍然存在问题
class BadSleepNode : public BT::ThreadedAction
{
public:
BadSleepNode(const std::string& name, const BT::NodeConfig& config)
: BT::ActionNodeBase(name, config)
{}
static BT::PortsList providedPorts()
{
return{ BT::InputPort<int>("msec") };
}
NodeStatus tick() override
{
// 该代码在其自己的线程中运行,因此树仍在运行。
// 这看起来不错,但线程仍然无法中止
int msec = 0;
getInput("msec", msec);
std::this_thread::sleep_for( std::chrono::milliseconds(msec) );
return NodeStatus::SUCCESS;
}
// halt() 方法无法杀死生成的线程:(
};
正确的版本是:
//我将在这里创建我自己的主题,没有任何理由
class ThreadedSleepNode : public BT::ThreadedAction
{
public:
ThreadedSleepNode(const std::string& name, const BT::NodeConfig& config)
: BT::ActionNodeBase(name, config)
{}
static BT::PortsList providedPorts()
{
return{ BT::InputPort<int>("msec") };
}
NodeStatus tick() override
{
// 该代码在自己的线程中运行,因此 "树 "仍在运行。
int msec = 0;
getInput("msec", msec);
using namespace std::chrono;
const auto deadline = system_clock::now() + milliseconds(msec);
//定期检查 isHaltRequested()
// 并且仅睡眠一小段时间(1 毫秒)
while( !isHaltRequested() && system_clock::now() < deadline )
{
std::this_thread::sleep_for( std::chrono::milliseconds(1) );
}
return NodeStatus::SUCCESS;
}
// halt() 方法会将 isHaltRequested() 设置为 true
// 并停止生成线程中的 while 循环。
};
正如你所看到的,这看起来比我们实现的版本更复杂 首先,使用 . 在某些情况下,此模式仍然有用,但您必须记住,引入 多线程会使事情变得更加复杂,默认情况下应避免使用。BT::StatefulActionNode
6.高级示例:客户端/服务器的通信
通常,人们使用BT。CPP 在不同的流程中执行实际任务。
在 ROS 中执行此操作的典型(也是推荐的)方法是使用 ActionLib。
ActionLib 提供了正确实现异步行为所需的 API:
- 用于启动操作的非阻塞函数。
- 一种监视操作执行当前状态的方法。
- 检索结果或错误消息的方法。
- 抢占/中止正在执行的操作的能力。
这些操作都不是“阻塞”的,因此我们不需要生成自己的线程。
更一般地说,我们可以假设开发人员有自己的进程间通信, 具有 BT 执行器和实际服务提供商之间的客户端/服务器关系。
(高级实例照搬官方教程,因为我暂时不会)
五、序列与反应序列
以下示例应使用简单的 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);
// 在这里,而不是tree.ticKWhileRunning(),
// 我们更喜欢自己的循环。
std::cout << "--- ticking\n";
status = tree.tickOnce();
std::cout << "--- status: " << toStr(status) << "\n\n";
while(status == NodeStatus::RUNNING)
{
//睡眠以避免繁忙的循环。
// 不要使用其他睡眠函数!
// 小的睡眠时间是可以的,这里我们使用大的睡眠时间只是为了
// 控制台上的消息较少。
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
六、使用子树
0.前言:
我们可以通过将较小且可重用的行为插入到较大的行为中来构建大规模行为。换句话说,我们想要创建分层行为树并使我们的树可组合。这可以通过在 XML 中定义多棵树并使用节点 SubTree 将一棵树包含到另一棵树中来实现。
1.CrossDoor behavior 跨门实例
这个例子的灵感来自一篇关于行为树的流行文章。这也是第一个使用装饰器(Decorators)和回退(Fallback
.)的实际示例。
注:下图中起到装饰器作用的节点为Inverter(因为只有IsDoorClosed返回FAILED时才会让Fallback调用DoorClosed)
Behavior trees for AI: How they work (gamedeveloper.com)
<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代码:
class CrossDoor
{
public:
void registerNodes(BT::BehaviorTreeFactory& factory);
// 如果 _door_open != true 则成功
BT::NodeStatus isDoorClosed();
// 如果 _door_open == true 则成功
BT::NodeStatus passThroughDoor();
// 3次尝试后,将打开上锁的门
BT::NodeStatus pickLock();
// 如果门锁上则失败
BT::NodeStatus openDoor();
//总是会开门
BT::NodeStatus smashDoor();
private:
bool _door_open = false;
bool _door_locked = true;
int _pick_attempts = 0;
};
// 帮助用户减少注册痛苦的方法
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);
// 在此示例中,单个 XML 包含多个 <BehaviorTree>
// 要确定哪个是“主要的”,我们应该首先注册
// XML,然后使用其 ID 分配特定的树
factory.registerBehaviorTreeFromText(xml_text);
auto tree = factory.createTree("MainTree");
// 打印树的辅助函数
printTreeRecursively(tree.rootNode());
tree.tickWhileRunning();
return 0;
}
七、端口重映射
0.前言
在 CrossDoor 示例中,我们看到子树从其父树的角度来看就像单个叶节点。
为了避免在非常大的树中发生名称冲突,任何树和子树都使用不同的 Blackboard 实例。
因此,我们需要将树的端口显式连接到其子树的端口。
您无需修改 C++ 实现,因为此重新映射完全在 XML 定义中完成。
1.实例
以此行为树为例子
<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");
// 持续访问直到结束
tree.tickWhileRunning();
// 让我们可视化有关黑板当前状态的一些信息。
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;
}
/* 预期输出:
------ 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]
*/
八、多XML文件
0.前言:
在我们提供的示例中,我们始终从单个 XML 文件创建整个树及其子树。但随着子树数量的增长,使用多个文件就很方便了。
1.实例:
文件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>
手动加载多个文件(推荐):
让我们考虑一个文件 main_tree.xml,它应该包含其他 2 个文件:
<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");
// 查找文件夹中的所有 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());
}
}
// 在我们的具体案例中,这相当于
// factory.registerBehaviorTreeFromFile("./main_tree.xml");
// factory.registerBehaviorTreeFromFile("./subtree_A.xml");
// factory.registerBehaviorTreeFromFile("./subtree_B.xml");
// 您可以创建 MainTree,子树将自动添加。
std::cout << "----- MainTree tick ----" << std::endl;
auto main_tree = factory.createTree("MainTree");
main_tree.tickWhileRunning();
//...或者您可以只创建一个子树
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 在哪里可以找到所需的依赖项。
我们现在可以像往常一样创建树:
factory.createTreeFromFile("main_tree.xml")
九、传递附加参数
0.前言:
到目前为止,在我们探索的每个示例中,我们“被迫”提供具有以下签名的构造函数
MyCustomNode(const std::string& name, const NodeConfig& config);
在某些情况下,需要将额外的参数、参数、指针、引用等传递给我们类的构造函数。
即使从理论上讲,这些参数可以使用输入端口传递,但如果出现以下情况,这将是错误的方法:
- 这些参数在部署时(构建树时)是已知的。
- 参数在运行时不会改变。
- 不需要从 XML 设置参数。
如果满足所有这些条件,则强烈建议不要使用端口或黑板。
1.向构造函数添加参数(推荐):
考虑以下名为 Action_A 的自定义节点。
我们想传递两个额外的参数;它们可以是任意复杂的对象,不限于内置类型。
// Action_A 具有与默认构造函数不同的构造函数。
class Action_A: public SyncActionNode
{
public:
// 传递给构造函数的附加参数
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) {}
// 此示例不需要任何端口
static PortsList providedPorts() { return {}; }
// tick() 可以访问私有成员
NodeStatus tick() override;
private:
int _arg1;
std::string _arg2;
};
注册该节点并传递已知参数非常简单:
BT::BehaviorTreeFactory factory;
factory.registerNodeType<Action_A>("Action_A", 42, "hello world");
// 如果您希望指定模板参数
//factory.registerNodeType<Action_A, int, std::string>("Action_A", 42, "hello world");
2.使用“初始化”方法:
如果出于任何原因,您需要将不同的值传递给 Node 类型的各个实例,您可能需要考虑其他模式:
class Action_B: public SyncActionNode
{
public:
// 构造函数看起来和平常一样。
Action_B(const std::string& name, const NodeConfig& config):
SyncActionNode(name, config) {}
//我们希望这个方法在第一个tick()之前被调用一次
void initialize(int arg_int, const std::string& arg_str)
{
_arg1 = arg_int;
_arg2 = arg_str;
}
// 此示例不需要任何端口
static PortsList providedPorts() { return {}; }
// tick() 可以访问私有成员
NodeStatus tick() override;
private:
int _arg1;
std::string _arg2;
};
我们注册和初始化Action_B的方式是不同的:
BT::BehaviorTreeFactory factory;
//像往常一样注册,但我们仍然需要初始化
factory.registerNodeType<Action_B>("Action_B");
// 创建整棵树。 Action_B 的实例尚未初始化
auto tree = factory.createTreeFromText(xml_text);
// 访问者将初始化以下实例
auto visitor = [](TreeNode* node)
{
if (auto action_B_node = dynamic_cast<Action_B*>(node))
{
action_B_node->initialize(69, "interesting_value");
}
};
// 将访问者应用到树的所有节点
tree.applyVisitor(visitor);
十、脚本语言简介
1.脚本和前提条件节点
1.0 前言:
在我们的脚本语言中,变量是黑板上的条目。在此示例中,我们使用节点脚本来设置这些变量并进行观察,因为我们可以将它们作为 SaySomething 中的输入端口进行访问。支持的类型是数字(整数和实数)、字符串和注册的 ENUMS。
1.1示例:
注:我们使用的 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:字符串“你好世界”
- A:别名THE_ANSWER对应的整数值。
- B:真实值3.14
- C:与枚举 RED 对应的整数值
C++代码是:
enum Color
{
RED = 1,
BLUE = 2,
GREEN = 3
};
int main()
{
BehaviorTreeFactory factory;
factory.registerNodeType<DummyNodes::SaySomething>("SaySomething");
// 我们可以将这些枚举添加到脚本语言中。
// 检查 magic_enum 的限制
factory.registerScriptingEnums<Color>();
// 或者我们可以手动为标签“THE_ANSWER”分配一个数字。
// 这不受任何范围限制的影响
factory.registerScriptingEnum("THE_ANSWER", 42);
auto tree = factory.createTreeFromText(xml_text);
tree.tickWhileRunning();
return 0;
}
预期输出为:
Robot says: 42.000000
Robot says: 3.140000
Robot says: hello world
Robot says: 1.000000
十一、记录器和观察器
更新日志:
2.19—— 内容一至三的编写
2.20—— 内容四的编写 || 更改文章题目
2.21—— 内容五至十的编写 || 二.1.3的编写 || 更改文章题目