行为树学习 BehaviorTree.cpp官方文档翻译

零,要点总结

  1. 基本思路:拆分行为,模块化,组合小行为成为大行为,分层设计
  2. 编辑方法:可视化编辑使用Groot2
  3. 语言:XML和C++
  4. 运行特点:多并发,少并行
  5. 基本思路:相同条目在不同树中使用同一个名字方便进行自动重定向,私有条目前面加"_"避免被重定向
  6. 节点信号:可以返回SUCCESS、FAILURE或者RUNNING
  7. 运行方向:一个tick()信号从根部开始逐级触发,触发顺序依照内部节点逻辑,子节点返回的SUCCESS或者FAILURE的处理逻辑遵照父节点内部逻辑,但子节点的RUNNING会反馈到父节点将其置为RUNNING
  8. 所以设计方向上首先是进行逻辑分层,确定节点输入和输出,其次是确定每个节点的类型,设计时要检查可以复用的子树进行复用,以及哪些操作可以用前置/后置脚本简化,以及分配错误码
 

一,什么是Behavior Tree

基础信号:tick
tick传播路径:从根节点进入,按照各个普通节点的分发规则进行传播
tick进入各节点之后:均产生返回值,返回值仅包含三种情况——成功(SUCCESS)、失败(FAILURE)、运行中(RUNNING)
树节点(TreeNode)分类:中间节点(用于至少一个子节点的节点,决定如何传递tick信号),叶节点(LeafNodes,没有任何子节点的节点,包含实际动作指令)
 
中间节点分类:控制节点(包含至少一个子节点,可以根据子节点的反馈或某些子节点的状态来决定是否要tick某些子节点);装饰节点(只有一个子节点,可以改变子节点的输出或重复触发子节点)
 
叶节点分类:条件节点(不改变系统,不返回RUNNING);动作节点(用于执行某些动作)
 
动作节点分类:同步(原子性地执行,被触发后阻碍整个树的进程直到改节点返回SUCCESS或FAILURE),异步(可以返回RUNNING表示还在执行中,需要持续tick直到其返回了SUCCESS或FAILURE)
 
示例:
顺序节点
子节点总是有序的吗,并且触发顺序是从左到右,前一个返回成功则触发后一个,任意一个返回失败则不进行后续触发,并且本节点返回FAILURE,只有所有子节点返回SUCCESS才会返回SUCCESS
 
装饰节点的用法常见的可以有:改变子节点的返回的结果;停止子节点的执行;重复触发子节点
下图中的Inverter会反转IsDoorOpen,故该节点的返回值相当于“IsDoorClosed”
下图中的RetryUntilSuccessful,会重复执行tick直到超过5次或者返回SUCCESS
 
下图中的Fallback节点提供了一种FAILURE返回的处理策略

二,Behavior Trees的优点

  1. 有明确的层级结构,可以通过对行为进行解构来得到不同的具有通用性的行为树,再通过组合这些行为树来实现不一样的功能
  2. 图表化易于理解
  3. 具有更强的实现能力
 

三,简单要素说明

  1. 通过继承来制作自定义节点
  2. 黑板(BlackBoard)是所有树节点的公共存储,以键值对行驶存储
  3. 端口是节点间信息交换的机制
  4. 一个节点的端口的数量、名称和节点类型在编译前就决定了,而节点之间的连接在部署时建立
  5. 任何C++类型可以被储存为值

四,XML

示例如下
可以看到以下特点:
  1. 树的第一个节点是<root>,它应当包含1个及以上的标签<BehaviorTree>
  2. 标签<BehaviorTree>应当包含属性[ID]
  3. 标签<root>应当包含属性[BTCPP_format]
  4. 每个树节点都由一个标签表示,特别地:标签的名字是其用于注册的ID;属性[name]指的是实例的名字,是选填的;端口通过属性进行设置,在上例中,动作节点“SaySomething”需要输入端口“message”
 

端口再映射以及指向黑板条目的指针

输入/输出端口可以使用黑板的一个条目进行再映射,黑板的条目换一种说法是黑板的一个键值对中的“键”
一个黑板“键”使用如下语法描述:{key_name}
在如下示例中:Sequence的第一个子节点打印“Hello”;第二个子节点读取并打印了包含在名为"my_message"的黑板条目中的值

紧凑(Compact)格式和显式(Explicit)格式

以下两种语法都是可行的
我们称前一种为紧凑语法,后一种为显式语法。将第一个样例用显式语法表示如下
尽管紧凑语法很方便而易于编写,它提供的树节点的信息太少了。类似Groot的工具需要显式语法或者额外的信息来进行判断,这种额外信息可以通过标签<TreeNodeModel>来添加。
为了让紧凑语法版本的树可以与Groot兼容,需要将XML修改为如下形式
 

子树

一个子树可以避免通过单纯的复制粘贴来应用于其他的树中。
如下格式就可以在“SaySomething”之后执行“GraspObject”
 

引入外部文件

自版本2.4之后,可以使用类似C++中 '#include \<file>' 的形式方便快捷地引入外部文件。如下
例如在上面的例子中,可以把两个行为树分到两个文件内:
 
注意:对于ROS开发者来说,如果想要找到ROS包中的一个文件,可以使用如下语法
<include ros_pkg="name_package" path="path_relative_to_pkg/grasp.xml"/>
 

五,Hello Behavior Tree

如何创建自己的动作节点

默认(且推荐的)创建树节点的方法是继承
//一个自制同步动作节点的例子(无端口)
class ApproachObject : public BT::SyncActionNode
{
public:
    ApproachObject(const std::string& name):
        BT::SyncActionNode(name,{})
    {}
    BT::NodeStatus tick() override
    {
        std::cout<<"ApproachObject:"<<this->name()<<std::endl;
        return BT::NodeStatus::SUCCESS;
    }
}
可见:
  1. 一个树节点的任何实例都有一个名字。这个区分标志是阅读友好的而且不必是独特的
  2. tick()方法是真正的动作发生的地方,它必须从事返回节点状态:RUNNING,SUCCESS或FAILURE
 
另一种选择,我们可以使用依赖注入来用一个函数指针(“函子”)创建一个树节点
 
函子必须具有如下签名:
BT::NodeStatus myFunction(BT::TreeNode& self)
 
例如:
using namespace BT;
//返回节点状态的简单函数
BT::NodeStatus CheckBattery()
{
    std::cout<<"[Battery:OK]"<<std::endl;
    return BT::NodeStatus::SUCCESS;
}

//我们想把open()和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;
}
 
我们可以使用以下函子的任意一个来建立一个简单的动作节点:
CheckBattery();
GripperInterface::open();
GripperInterface::close()
 

用XML动态创建一个树

以如下my_tree.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>
首先我们要在BehaviorTreeFactory中注册我们的自制树节点,然后从文件或者文本中导入XML
XML中使用的识别标志必须与树节点中注册的一致
属性"name"表示实例的名字,是选填的
#include "behaviortree_cppp/bt_factory.h"
#include "dummy_nodes.h"//包含自制节点的定义的文件

using namespace DummyNodes;

int main()
{
    //使用BehaviorTreeFactory来注册自制节点
    BehaviorTreeFactory factory;
    //推荐的建立节点的方法是通过继承
    factory.registerNodeType<ApproachObject>("ApproachObject");
    
    //使用函数指针注册简单动作节点
    //可以使用C++11的lambdas表达式或者std::bind
    factory.registerSimpleCondition("CheckBattery",[&](TreeNode&){return CheckBattery();});
    //也可以使用类的方法来建立简单动作节点
    GripperInterface gripper;
    factory.registerSimpleAction("OpenGripper",[&](TreeNode&){return gripper.open();});
    factory.registerSimpleAction("CloseGripper",[&](TreeNode&){return gripper.close();});
    
    //树是在部署时间被创造的,也就是在运行时间的最开始
    
    //重点:当对象“树”超出了存活范围,则所有树节点被摧毁
    
    auto tree = factory.createTreeFromFile("./my_tree.xml");
    
    //为了运行一个树,需要tick它
    //tick依赖树自身的逻辑来传递给子节点
    //因此,序列内所有节点都会被执行,因为所有的子节点都返回SUCCESS
    tree.tickWhileRunning();
    
    return 0;
}
/* Expected output:
*
  [ Battery: OK ]
  GripperInterface::open
  ApproachObject: approach_object
  GripperInterface::close
*/

六,黑板和端口

自制树节点的目的是执行任意简单或复杂的软件,他们的目的就是提供一个跟更高级别的抽象进行交互的界面,因此,它们跟函数没有本质性区别
与函数类似,我们常常想要:
  1. 传递参数给节点(输入)
  2. 从节点中获得某些信息(输出)
  3. 某些节点的输出可以作为其他节点的输入
 
BehaviorTree.cpp提供一个数据流通过端口的基础机制,它是易用的、可变的、类型安全的
 
建立一个如下树
主要概念:
  1. 一个黑板就是简单的键-值储存方式,由树内的所有节点分享
  2. 黑板的一个“条目”就是一个键值对
  3. 一个输入端口可以读取黑板的条目,一个输出端口可以修改条目
 

输入端口

一个合法的输入可以是:
  1. 节点可以读取和分析的一个静态字符串
  2. 黑板的一个条目的指针,通过键来区分
 
假设我们像创建一个动作节点叫做SaySomething,它会用std::cout打印一个给定的字符串
为了传递这个字符串,我们要使用一个叫做message的输入端口
考虑以下相反的XML语法:
<SaySomething name="first" message="hello world" />
<SaySomething name="second" message="{greetings}" />
在第一个节点,端口接收到字符串“hello world”;
在第二个节点,相对的,它请求找到黑板中的值,使用条目“greetings”
注意:在运行时条目“greeting”的值是大概率会发生变化的
动作节点SaySomething可以通过如下方法实现:
class SaySomething:public SyncActionNode
{
public:
    //如果你的节点有端口,一定要使用这个构造函数签名
    SaySomething(const std::string& name,const NodeConfig& config)
    :SyncActionNode(name,config)
    {}
    //定义这个静态方法是必须得
    static PortList providePorts()
    {
        //这个动作有一个独立的输入端口叫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;
    }
};
如果自制节点存在输入或输出端口,这些节点必须在以下静态方法中被声明
static MyCustomNode::PortsList providedPorts();
节点message的输入可以使用模板方法TreeNode::getInput<T>(key)来读取
这个方法可能由于一些原因失败,这取决于用户如何检查返回值的有效性已经如何处理
  1. 返回NodeStatus::FAILURE?
  2. 抛出一个异常?
  3. 使用不同的默认值?
重点:总是建议在tick()内部调用getInput()方法,而不是在构造函数中。c++代码应该期望输入的实际值在运行时发生变化,因此,它应该定期更新。
 

输出端口

只有当另一个节点已经写入一些信息到黑板的一个条目上,指向该条目的输入端口才可用。
ThinkWhatToSay是一种使用输出端口来写入一个字符串到一个条目中的样例节点。
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
    {
        setOutput("text","The answer is 42");
        return NodeStatus::SUCCESS;
    } 
};
另外,在大多数情况下,出于调试目的,可以使用叫做Script的内置动作将静态值写入条目。
<Script code=" the_answer:='The answer is 42' " />
 

一个完整的例子

在本例子中,一个序列中的3个动作被执行
  1. 从一个静态字符串中获取输入信息message
  2. 将信息写入名为the_answer的黑板条目
  3. 从the_answer中获取输入的message
<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"
//包含自制节点定义的文件
#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
*/
 
我们使用同一个key“the_answer”连接了输入端口和输出端口,换言之,他们指向同一个黑板条目
这些端口可以相连因为他们的类型相同,如果试图连接不同类型的端口,那方法factory.createTreeFromFile会抛出一个异常
 

七,常见类型的端口

解析字符串

BehaviorTree.cpp支持字符串到常见类型入int/long/double/bool/NodeStaus的自动转换。使用自定义类型受支持方便地实现该功能。
 
例如:
struct Position2D 
{ 
  double x;
  double y; 
};
为了允许XML加载器从字符串实例化得到一个Position2D类型的数据,我们需要提供一个专门的模版叫做BT::convertFromString<Position2D>(StringView)
这个模版的制作取决于如何将Position2D排列在字符串中,此处我们假设它们被一个分号隔开
// 用于将string转换为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;
        }
    }
} // end namespace BT
  • StringView是一种c++11版本的std::string_view。可以接受一个std::string或一个const char*的传递
  • 库中提供了一个简单的splitString函数,同样也可以使用别的比如boost::algorithm::split
  • 我们可以使用专门化的converFromString<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;
    }
};
以下树中含有
  • 储存一个Position2D值到条目GoalPosition,使用动作CalculateGoal
  • 使用PrintTarget打印从黑板条目GoalPosition获得的值
  • 使用内置动作Sript来给OtherGoal赋值
  • 再次使用PrintTarget打印从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 ]
*/

八,响应式和异步行为

下一个例子展示了序列节点和响应式节点的区别
我们将实现一个异步节点,其特征为将花费一个较长时间来实现动作,并且在动作没有结束前会持续返回RUNNING
成为一个异步节点具有以下需求:
  1. 不应阻挡tick()太长时间。执行流应当尽快返回值。
  2. 如果调用了halt()方法,应当尽快中止它。
 

异步行为专题

在设计交互性行为树时,理解两个主要概念很重要
  1. 异步动作和同步动作的意思
  2. 并发和平行在BT.CPP的背景意义
 

并发vs平行

并发是两个及以上的任务可以在重叠的时间段开始、运行和完成。并发并不意味着同一时刻这些任务都在运行
平行是任务在同一时刻运行在不同线程中比如一个多核处理器
 
BT.CPP并发式地处理所有节点,换言之:
  1. 树的执行引擎是单线程的
  2. 所有tick()是顺序执行的
  3. 如果任意tick()阻滞了,整个执行流会被阻滞
我们通过“并发”和异步执行来实现响应性行为。
换言之,一个要花费很长时间执行的动作应当尽快返回RUNNING状态。
这告诉树执行器动作已经开始并且需要花费更多的时间来返回SUCCESS或FAILURE。我们需要之后再来tick这个节点以了解它的状态改变与否。
异步节点可以将此长时间执行委托给另一个进程(使用进程间通信)或另一个线程。
 

异步vs同步

总的来说,一个异步节点是一个:
  1. 当被tick时可能返回RUNNING而不是SUCCESS或FAILURE
  2. 当方法halt()被调用时会尽快停止
 
一般地,方法halt()由开发者实现
当你的树执行了一个返回RUNNING的异步动作,这个状态会反向传播让整个树被认为处于RUNNING状态。
下列例子中,“ActionE”是异步的并处于RUNNING,当一个节点处于RUNNING,它的父节点也返回RUNNING。
让我们考虑一个简单的“SleepNode”。一个好的模版用于开始学习状态动作节点(StatefulActionNode)。

using namespace std::chrono;

// Example of Asynchronous node that uses StatefulActionNode as base class
class SleepNode : public BT::StatefulActionNode
{
  public:
    SleepNode(const std::string& name, const BT::NodeConfig& config)
      : BT::StatefulActionNode(name, config)
    {}

    static BT::PortsList providedPorts()
    {
      // amount of milliseconds that we want to sleep
      return{ BT::InputPort<int>("msec") };
    }

    NodeStatus onStart() override
    {
      int msec = 0;
      getInput("msec", msec);

      if( msec <= 0 ) {
        // No need to go into the RUNNING state
        return NodeStatus::SUCCESS;
      }
      else {
        // once the deadline is reached, we will return SUCCESS.
        deadline_ = system_clock::now() + milliseconds(msec);
        return NodeStatus::RUNNING;
      }
    }

    /// method invoked by an action in the RUNNING state.
    NodeStatus onRunning() override
    {
      if ( system_clock::now() >= deadline_ ) {
        return NodeStatus::SUCCESS;
      }
      else {
        return NodeStatus::RUNNING;
      }
    }

    void onHalted() override
    {
      // nothing to do here...
      std::cout << "SleepNode interrupted" << std::endl;
    }

  private:
    system_clock::time_point deadline_;
};
在以上代码中:
  1. 当SleepNode第一次被tick,方法OnStart()被执行。当睡眠时间小于等于0时会返回SUCCESS,反之返回RUNNING。
  2. 我们应当继续循环执行树。它会反复调用onRunning直到返回了SUCCESS
  3. 别的节点可能触发halt()信号,此时onHalted方法会被调用
 

避免阻滞整个树的执行

一种错误的SleepNode的实现如下
// 同步版
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);
      // This blocking function will FREEZE the entire tree :(
      std::this_thread::sleep_for( milliseconds(msec) );
      return NodeStatus::SUCCESS;
     }

    void halt() override
    {
      // No one can invoke this method because I froze the tree.
      // Even if this method COULD be executed, there is no way I can
      // interrupt std::this_thread::sleep_for()
    }
};

多线程的问题

早期版本中,增加一个新线程看上去像建立异步节点的好办法。
但实际上,由于以下原因它并不是个好主意:
  1. 以线程安全的方法接入黑板是十分困难的
  2. 大概率没必要
  3. 人们认为这样处理就可以让动作异步化,但他们网课他们还要在halt()被调用时快速地以某种方法去停止这些线程
因此,使用这边并不被鼓励使用BT::ThreadAction作为基础类。让我们再看看多线程化的SleepNode
// This will spawn its own thread. But it still has problems when halted
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
    {  
      // This code runs in its own thread, therefore the Tree is still running.
      // This seems good but the thread still can't be aborted
      int msec = 0;
      getInput("msec", msec);
      std::this_thread::sleep_for( std::chrono::milliseconds(msec) );
      return NodeStatus::SUCCESS;
    }
    // The halt() method 正确版本应为
// I will create my own thread here, for no good reason
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
    {  
      // This code runs in its own thread, therefore the Tree is still running.
      int msec = 0;
      getInput("msec", msec);

      using namespace std::chrono;
      const auto deadline = system_clock::now() + milliseconds(msec);

      // Periodically check isHaltRequested() 
      // and sleep for a small amount of time only (1 millisecond)
      while( !isHaltRequested() && system_clock::now() < deadline )
      {
        std::this_thread::sleep_for( std::chrono::milliseconds(1) );
      }
      return NodeStatus::SUCCESS;
    }

    // The halt() method will set isHaltRequested() to true 
    // and stop the while loop in the spawned thread.
};
这个版本比我们之前实现的使用BT::StatefulActionNode的更复杂。这个模式当然也可以在某些情况下被使用,但必须记住多线程让事情变得更复杂并且应当默认不去使用它。

进阶例子:客户端/服务器通讯

常常地,人们使用BT.CPP在一个不同的进程上执行任务。
一个传统(并且是推荐的)方法是在ROS上使用ActionLib来做。
ActionLab提供我们正确实现异步行为的一系列API:
  1. 一个无阻滞函数用于开始动作
  2. 一个用于监视动作执行状态的方法
  3. 一个用于接受结果或者错误信息的方法
  4. 优先化/中断某个执行中的动作的能力
这些操作都是无阻滞的,所以我们也不需要增加我们的线程
更一般地说,我们可以假设开发人员有他们自己的进程间通信,在BT执行者和实际的服务提供者之间有一个客户机/服务器关系。
 

状态动作节点(StatefulActionNode)

StatefulActionNode是实现异步动作的首选方式。
当您的代码包含请求-应答模式时,即当动作向另一个进程发送异步请求时,并定期检查是否已接收到应答时,它特别有用。
基于该回复,它可能返回SUCCESS或FAILURE。
如果不是与外部进程通信,而是执行一些需要很长时间的计算,您可能希望将其分割成小的“块”,或者您可能希望将该计算移动到另一个线程(参见asyncthreaddactiontutorial)。
StatefulActionNode的派生类必须覆盖以下虚方法,而不是tick():
  1. NodeStatus onStart():当节点处于空闲状态时被调用。可以立即失败或成功,也可能返回RUNNING。在后一种情况下,下一次收到tick()后onRunning会被执行
  2. NodeStatus onRunning():当节点为RUNNING状态时,返回新的状态
  3. 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
    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
    // 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
  // 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响应式序列

以下例子应当使用一个简单的序列节点
 <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(),
  // 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 在前两次返回的是“正在运行”,而第三次最终返回的是“成功”
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()是被推荐的因为它可以被树上的一个节点中断当某些事发生改变时
Tree::sleep()会被中断当方法TreeNode::emitStateChanged()被调用

九,使用子树组合得到行为

我们可以通过将更小的自制行为插入到更大的行为中来得到大范围的行为
另一方面,我们希望创造分等级的行为树并使之可组合
这可以通过在XML中定义多个树并使用节点subtree将一个树包含到另一个树中来实现。

CrossDoor行为

这个例子启发于一个流行的关于行为树来表达。
它也是第一个使用decorator和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>
需要的行为是:
  1. 假如门是开着的,PassThroughDoor
  2. 如果门是关着的,尝试OpenDoor,或者尝试PickLock至多5次,最后SmashDoor
  3. 如果至少一个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 door
    BT::NodeStatus pickLock();

    // FAILURE if door locked
    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>
  // 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;
}

十,为一个子树再映射端口

在CrossDoor示例中,我们看到从父树的角度来看,SubTree看起来像单个叶子节点。 为了避免在非常大的树中发生名称冲突,任何树和子树都使用Blackboard的不同实例。 出于这个原因,我们需要显式地将树的端口连接到它的子树的端口。 你不需要修改c++实现,因为这种重新映射完全是在XML定义中完成的。

例子

让我们考虑这个行为树
<root BTCPP_format="4">

    <BehaviorTree ID="MainTree">
        <Sequence>
            <Script code=" move_goal='1;2;3' " />
            
                                    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>
可以注意到:
  1. 我们有一个主树MainTree,其包含了一个子树叫做MoveRobot
  2. 我们希望连接(“再映射”)MoveRobot子树中的一些端口到MainTree中的一些端口
  3. 这通过以上例子中高亮的语法实现

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
  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]

*/

十一,如何使用多个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>

手动加载多个文件

一个文件main_tree.xml应当包含另外两个文件:
<root>
    <BehaviorTree ID="MainTree">
        <Sequence>
            <SaySomething message="starting MainTree" />
            

        </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
  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"来添加多个文件

<root BTCPP_format="4">
    


        <Sequence>
            <SaySomething message="starting MainTree" />
            <SubTree ID="SubTreeA" />
            <SubTree ID="SubTreeB" />
        </Sequence>
    </BehaviorTree>
</root>
这个方法可以直接添加子树所在的相对地址,这个地址是相对于main_tree.xml
然后就正常创建树:
factory.createTreeFromFile("main_tree.xml")

十二,向节点传递其他参数

在所有例子中,我们强制使用一个含有如下签名的构造器
MyCustomNode(const std::string& name, const NodeConfig& config);
在某些情况下,我们希望可以传递参数、指针等到我们的类的构造器,有些人用黑板来实现这个,不要这么干
尽管理论上这些参数可以通过输入端口来传递,但这可能是一个错误的行为如果这个参数满足以下条件:
  1. 这个参数在部署时才被知道
  2. 这个参数在运行时不改变
  3. 这个参数不需要再XML中被设置
如果所有条件都满足,那使用端口或者黑板是极度不推荐的

添加参数到构造器

考虑从以下自制节点Action_A
我们想传递两个额外的参数;它们可以是任意复杂的对象,并不局限于内置类型。
// Action_A has a different constructor than the default one.
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
    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");

使用初始化方法

如果出于某种原因,您需要将不同的值传递给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()
    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
    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
auto tree = factory.createTreeFromText(xml_text);

// visitor will initialize the instances of 
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
tree.applyVisitor(visitor);

十三,脚本语言介绍

脚本和前提节点

在我们的脚本语言中,变量是黑板上的条目。 在本例中,我们使用节点脚本设置这些变量,并观察我们是否可以将它们作为SaySomething中的输入端口访问。 支持的类型是数字(整数和实数)、字符串和已注册的enum。
注意,我们使用的magic_enum有一些已知的限制。 值得注意的是,默认范围是[-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>
我们希望包含以下黑板条目:
  1. msg:字符串“hello world”
  2. A:对应与THE_ANSWER的整数值
  3. B:真值3.14
  4. C:对应于enumRED的整数值
因此,期望输出是:
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
  factory.registerScriptingEnums<Color>();

  // Or we can manually assign a number to the label "THE_ANSWER".
  // This is not affected by any range limitation
  factory.registerScriptingEnum("THE_ANSWER", 42);

  auto tree = factory.createTreeFromText(xml_text);
  tree.tickWhileRunning();

  return 0;
}

十四,记录界面

BT.cpp提供了一种在运行时将记录器添加到树的方法,通常是在树创建之后和开始tick前
“logger”是一个类,每次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是一个简单的日志实现,它为树的每个节点收集以下统计信息:
struct NodeStatistics
  {
    // Last valid result, either SUCCESS or FAILURE
    NodeStatus last_result;
    // Last status. Can be any status, including IDLE or SKIPPED
    NodeStatus current_status;
    // count status transitions, excluding transition to IDLE
    unsigned transitions_count;
    // count number of transitions to SUCCESS
    unsigned success_count;
    // count number of transitions to FAILURE
    unsigned failure_count;
    // count number of transitions to SKIPPED
    unsigned skip_count;
    // timestamp of the last transition
    Duration last_timestamp;
  };

如何唯一标识一个节点

由于观察者允许我们收集特定节点的统计信息,我们需要一种唯一标识该节点的方法: 可以使用两种机制: •TreeNode::UID()是一个唯一的数字,对应于树的深度优先遍历。 •TreeNode::fullPath()旨在成为特定节点的唯一但人类可读的标识符。 我们使用术语“路径”,因为一个典型的字符串值可能是这样的:
 first_subtree/nested_subtree/node_name
换句话说,路径包含有关Subtree层次结构中Node位置的信息。 “node_name”要么是在XML中分配的名称属性,要么是自动分配的,使用Node注册后跟“::”和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属性“name”,而其他节点则没有。 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
  BT::TreeObserver observer(tree);

  // Print the unique ID and the corresponding human readable path
  // Path is also expected to be unique.
  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
  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;
}

十五,连接到groot2

grot2是官方的IDE,用于编辑,监控和与BT.CPP创建的行为树交互。 正如您将在本教程中看到的那样,将两者结合起来非常容易,但是您应该首先理解一些简单的概念。
 

树节点模型

Groot需要一个树节点模型。
例如,在上图中,Groot需要知道使用者定义的节点ThinkWhatToSay和SaySomething的存在。
另外,它需要:
  1. 节点类型
  2. 端口的名字和类型
这些模型用XML表达如下:
  <TreeNodesModel>
    <Action ID="SaySomething">
      <input_port name="message"/>
    </Action>
    <Action ID="ThinkWhatToSay">
      <output_port name="text"/>
    </Action>
  </TreeNodesModel>
然而,您不应该手工创建这些XML描述。 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
要将这些模型导入UI,可以: •将XML保存到一个文件中(称为instance Models . XML),然后单击Groot2中的Import Models按钮。 •或手动将XML部分直接添加到. XML或.btproj文件中。

添加实时可视化到Groot

注意,目前只有pro版本的Groot2支持该功能
 

可视化黑板中的自制类型

黑板中的内容使用JSON格式发送到Groot2。
要添加一个新类型,并允许groov2可视化它们,您应该遵循下面的说明:
https://json.nlohmann.me/features/arbitrary_types/
例如,给定一个用户定义的类型:
struct Pose2D {
    double x;
    double y;
    double theta;
}
您需要包含behaviortree_cpp/json_export.h,并根据您的BT.CPP版本遵循以下说明。

Version 4.3.5 or earlier

实现函数nlohmann::to_json()
namespace nlohmann {
  void to_json(nlohmann::json& dest, const Pose2D& pose) {
    dest["x"] = pose.x;
    dest["y"] = pose.y;
    dest["theta"] = pose.theta;
  }
}
然后,注册该函数到你的main()
BT::JsonExporter::get().addConverter<Pose2D>();

Version 4.3.6 or later

“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;
}
注册该函数到你的main
BT::RegisterJsonDefinition<Pose2D>(PoseToJson);

十六,端口的默认值

在定义端口时,添加一个默认值是方便的,比如添加一个端口在XML没有指定时应当有的值
注意,教程中的一些例子需要4.5.2版本以上

默认输入端口

考虑一个初始化多个端口的节点,我们使用了一个自制类型Point2D,但当然其他类型也是适用的,比如int,double,string
  static PortsList providedPorts()
  {
    return { 
      BT::InputPort<Point2D>("input"),
      BT::InputPort<Point2D>("pointA", Point2D{1, 2}, "default value is x=1, y=2"),
      BT::InputPort<Point2D>("pointB", "3,4",         "default value is x=3, y=4"),
      BT::InputPort<Point2D>("pointC", "{point}",     "point by default to BB entry {point}"),
      BT::InputPort<Point2D>("pointD", "{=}",         "point by default to BB entry {pointD}") 
    };
  }
第一个input没有默认值所以必须从XML或者黑板中为其提供值

默认值

BT::InputPort<Point2D>("pointA", Point2D{1, 2}, "...");
假如特定模版convertFromString<Point2D>()被实现了,我们也可以使用它
另一方面,以下语法应当是等效的,假如我们的convertFromString期望两个逗号分割的值:
BT::InputPort<Point2D>("pointB", "3,4", "...");
// should be equivalent to:
BT::InputPort<Point2D>("pointB", Point2D{3, 4}, "...");

默认黑板条目

或者,我们可以定义端口应该指向的默认黑板条目
BT::InputPort<Point2D>("pointC", "{point}", "...");
假如端口名和黑板条目时一致的,那就可以使用"{=}"
BT::InputPort<Point2D>("pointD", "{=}", "...");
// equivalent to:
BT::InputPort<Point2D>("pointD", "{pointD}", "...");

默认输出端口

输出端口在使用上是更加受限制的,只能指向到黑板条目。你依然可以使用"{=}"当两个名字是一样的时候
  static PortsList providedPorts()
  {
    return { 
      BT::OutputPort<Point2D>("result", "{target}", "point by default to BB entry {target}");
    };
  }

十七,零拷贝访问黑板

如果您遵循本教程,您应该已经知道Blackboard使用值语义,即方法getInput和setOutput将值从/复制到Blackboard。
在某些情况下,可能需要使用引用语义,即直接访问存储在Blackboard中的对象。当对象是: •复杂的数据结构 •复制成本高 •不可复制。 此时,建议使用引用语义的节点是LoopNodedecorator,它可以“就地”修改对象向量。

方法1:将黑板条目作为共享指针

为了简单起见,我们将考虑一个复制成本较高的对象,称为Pointcloud。
假设我们有如下行为树:
 <root BTCPP_format="4" >
    <BehaviorTree ID="SegmentCup">
       <Sequence>
           <AcquirePointCloud  cloud="{pointcloud}"/>
           <SegmentObject  obj_name="cup" cloud="{pointcloud}" obj_pose="{pose}"/>
       </Sequence>
    </BehaviorTree>
</root>
  • AcquirePointCloud会写入黑板条目pointcloud
  • SegmentObject会读取该条目
那么推荐的端口类型如下:
PortsList AcquirePointCloud::providedPorts()
{
    return { OutputPort<std::shared_ptr<Pointcloud>>("cloud") };
}

PortsList SegmentObject::providedPorts()
{
    return { InputPort<std::string>("obj_name"),
             InputPort<std::shared_ptr<Pointcloud>>("cloud"),
             OutputPort<Pose3D>("obj_pose") };
}
getInput和setOutput方法可以像往常一样使用,并且仍然具有值语义。但是由于被复制的对象是shared_ptr,我们实际上是通过引用访问点云实例。

方法2:线程安全的castPtr(从4.5.1版本开始推荐)

当使用shared_ptr方法时,最值得注意的问题是它不是线程安全的。
如果自定义异步Node有自己的线程,那么实际对象可能同时被其他线程访问。 为了防止这个问题,我们提供了一个包含锁定机制的不同API。 首先,在创建我们的端口时,我们可以使用一个普通的Pointcloud,不需要将它包装在std::shared_ptr中:
PortsList AcquirePointCloud::providedPorts()
{
    return { OutputPort<Pointcloud>("cloud") };
}

PortsList SegmentObject::providedPorts()
{
    return { InputPort<std::string>("obj_name"),
             InputPort<Pointcloud>("cloud"),
             OutputPort<Pose3D>("obj_pose") };
}
为了使用指针/引用来访问点云实例:
// inside the scope below, as long as "any_locked" exists, a mutex protecting 
// the instance of "cloud" will remain locked
if(auto any_locked = getLockedPortContent("cloud"))
{
  if(any_locked->empty())
  {
    // the entry in the blackboard hasn't been initialized yet.
    // You can initialize it doing:
    any_locked.assign(my_initial_pointcloud);
  }
  else if(Pointcloud* cloud_ptr = any_locked->castPtr<Pointcloud>())
  {
    // Succesful cast to Pointcloud* (original type).
    // Modify the pointcloud instance, using cloud_ptr
  }
}

十八,子树模型和自动再映射

不幸的是,当在多个位置使用相同的SubTree时,我们可能会发现自己复制和粘贴了相同的长XML标记
考虑如下情况:
<SubTree ID="MoveRobot" target="{move_goal}"  frame="world" result="{error_code}" />
我们不希望每次都复制粘贴三个XML属性target、frame和resulte4,除非它们的值不同。
为了避免这个,我们定了了他们的默认值在<TreeNodesModel>中
  <TreeNodesModel>
    <SubTree ID="MoveRobot">
      <input_port  name="target"  default="{move_goal}"/>
      <input_port  name="frame"   default="world"/>
      <output_port name="result"  default="{error_code}"/>
    </SubTree>
  </TreeNodesModel>
概念上,这跟默认端口有些相似。
如果特定到XML,这些再映射了的黑板条目会被覆写。在以上例子中,我们覆写了frame的值,但保留了默认的再映射
<SubTree ID="MoveRobot" frame="map" />

自动再映射

当子树的条目名字和亲树的相同,可以使用属性_autoremap
例如:
<SubTree ID="MoveRobot" target="{target}"  frame="{frame}" result="{result}" />
可以被如下替换:
<SubTree ID="MoveRobot" _autoremap="true" />
我们依然可以覆写特定的一个值,同时保留其他的自动重定向
<SubTree ID="MoveRobot" _autoremap="true" frame="world" />
属性_autoremap="true“将自动重新映射SubTree中的所有条目,除非它们的名称以下划线(字符”_")开头。 这可能是将SubTree中的条目标记为“私有”的方便方法。

十九,在BT.CPP中模拟测试

有时,特别是在实现集成和单元测试时,需要有一种机制,允许我们用“测试”版本(mock)快速替换特定的Node或整个Node类。
从版本4.1开始,我们引入了一种名为“替代规则”的新机制,使这个过程更容易。
它由BehaviorTreeFactory类中的其他方法组成,这些方法应该在节点注册之后和实际树实例化之前调用。
例如,有XML如下:
<SaySomething name="talk" message="hello world"/>
我们可能想用另一个节点替代TestMessage这个节点: 相应的替换是用如下命令完成的:
factory.addSubstitutionRule("talk", "TestMessage");
第一个参数包含将与TreeNode::fullPath匹配的通配符字符串。

测试节点

TestNode是一个Action,可以配置为:
•返回一个特定的状态,SUCCESS或FAILURE
•同步或异步;在后一种情况下,应该指定超时时间。
•后置条件脚本,通常用于模拟OutputPort。
这个简单的虚拟节点不会覆盖100%的情况,但可以作为许多替代规则的默认解决方案。

完整例子

在这个例子里我们可以看到:
  1. 我们可以使用替代规则来将一个节点替换为另一个
  2. 如何使用内置测试节点
  3. 通配符匹配的例子
  4. 如何使用JSON文件在运行时传递这些规则
我们使用如下XML:
<root BTCPP_format="4">
  <BehaviorTree ID="MainTree">
    <Sequence>
      <SaySomething name="talk" message="hello world"/>
        <Fallback>
          <AlwaysFailure name="failing_action"/>
          <SubTree ID="MySub" name="mysub"/>
        </Fallback>
        <SaySomething message="before last_action"/>
        <Script code="msg:='after last_action'"/>
        <AlwaysSuccess name="last_action"/>
        <SaySomething message="{msg}"/>
    </Sequence>
  </BehaviorTree>

  <BehaviorTree ID="MySub">
    <Sequence>
      <AlwaysSuccess name="action_subA"/>
      <AlwaysSuccess name="action_subB"/>
    </Sequence>
  </BehaviorTree>
</root>
C++代码如下:
int main(int argc, char** argv)
{
  BT::BehaviorTreeFactory factory;
  factory.registerNodeType<SaySomething>("SaySomething");

  // We use lambdas and registerSimpleAction, to create
  // a "dummy" node, that we want to substitute to a given one.

  // Simple node that just prints its name and return SUCCESS
  factory.registerSimpleAction("DummyAction", [](BT::TreeNode& self){
    std::cout << "DummyAction substituting: "<< self.name() << std::endl;
    return BT::NodeStatus::SUCCESS;
  });

  // Action that is meant to substitute SaySomething.
  // It will try to use the input port "message"
  factory.registerSimpleAction("TestSaySomething", [](BT::TreeNode& self){
    auto msg = self.getInput<std::string>("message");
    if (!msg)
    {
      throw BT::RuntimeError( "missing required input [message]: ", msg.error() );
    }
    std::cout << "TestSaySomething: " << msg.value() << std::endl;
    return BT::NodeStatus::SUCCESS;
  });

  //----------------------------
  // pass "no_sub" as first argument to 

JSON格式

JSON文件,相当于USE_JSON == false时执行的分支:
{
  "TestNodeConfigs": {
    "MyTest": {
      "async_delay": 2000,
      "return_status": "SUCCESS",
      "post_script": "msg ='message SUBSTITUED'"
    }
  },

  "SubstitutionRules": {
    "mysub/action_*": "TestAction",
    "talk": "TestSaySomething",
    "last_action": "MyTest"
  }
}
如你所见,主要有两小节:
  • TestNodeConfigs,其中设置一个或多个TestNode的参数和名称
  • SubtitutionRules,实际的规则在哪里指定

二十,为什么要使用“全局黑板”

正如在前面的教程中描述的,BT.CPP坚持拥有一个全域黑板的重要性,尽管在编程语言中要将每个子树作为独立的函数/例程对待,
尽管如此,在某些情况下,有一个真正的“全局”黑板是可取的,它可以从每个子树直接访问,而不需要重新映射。
对于以下情况,这是有意义的: •像教程8中描述的那样,单例和全局对象不能被共享 •机器人的全局状态。 •在行为树之外写入/读取的数据,即在执行tick的主循环中。
此外,由于黑板是一个通用的键/值存储,其中的值可以包含任何类型,因此它是一个完美的数据结构,可以实现文献中所说的“世界模型”,即环境、机器人和任务的状态可以与行为树共享的地方。

黑板的层级

考虑一个简单的有两个子树的树,如下
每个行为树有它自己的黑板;这些黑板间的亲/子关系和对应的这些树之间的关系一致,也就是说在此图中,BB1是BB2和BB3的亲代
这些独立黑板的生命周期与对应的子树的相耦合
我们可以用如下方法生成一个额外的全局黑板:
auto global_bb = BT::Blackboard::create();
auto maintree_bb = BT::Blackboard::create(global_bb);
auto tree = factory.createTree("MainTree", maintree_bb);
这样会得到如下的黑板分层:
实例global_bb的生命周期独立于子行为树,它会持续到整个“树”被摧毁为止。
另外,它可以轻易地使用set和get方法来接入。

如何从树上访问最高等级的黑板

所谓最高等级黑板,我们指的是位于根或者层级上的黑板。
在以上代码中,global_bb是最高等级的黑板
自4.6版本以来,一种新语法被用于在不再映射的情况下访问最高等级的黑板,通过增加前缀@在条目的名称之前。
例如
<PrintNumber val="{@value}" />
端口val会搜索最高等级黑板而不是本地黑板中的条目value

完整例子

考虑这个树
  <BehaviorTree ID="MainTree">
    <Sequence>
      <PrintNumber name="main_print" val="{@value}" />
      <SubTree ID="MySub"/>
    </Sequence>
  </BehaviorTree>

  <BehaviorTree ID="MySub">
    <Sequence>
      <PrintNumber name="sub_print" val="{@value}" />
      <Script code="@value_sqr := @value * @value" />
    </Sequence>
  </BehaviorTree>
C++代码:
class PrintNumber : public BT::SyncActionNode
{
public:
  PrintNumber(const std::string& name, const BT::NodeConfig& config)
    : BT::SyncActionNode(name, config)
  {}
  
  static BT::PortsList providedPorts()
  {
    return { BT::InputPort<int>("val") };
  }

  NodeStatus tick() override
  {
    const int val = getInput<int>("val").value();
    std::cout << "[" << name() << "] val: " << val << std::endl;
    return NodeStatus::SUCCESS;
  }
};

int main()
{
  BehaviorTreeFactory factory;
  factory.registerNodeType<PrintNumber>("PrintNumber");
  factory.registerBehaviorTreeFromText(xml_main);

  // No one will take the ownership of this blackboard
  auto global_bb = BT::Blackboard::create();
  // "MainTree" will own maintree_bb
  auto maintree_bb = BT::Blackboard::create(global_bb);
  auto tree = factory.createTree("MainTree", maintree_bb);

  // we can interact directly with global_bb
  for(int i = 1; i <= 3; i++)
  {
    // write the entry "value"
    global_bb->set("value", i);
    // tick the tree
    tree.tickOnce();
    // read the entry "value_sqr"
    auto value_sqr = global_bb->get<int>("value_sqr");
    // print 
    std::cout << "[While loop] value: " << i 
              << " value_sqr: " << value_sqr << "\n\n";
  }
  return 0;
}
期望输出:
[main_print] val: 1
[sub_print] val: 1
[While loop] value: 1 value_sqr: 1

[main_print] val: 2
[sub_print] val: 2
[While loop] value: 2 value_sqr: 4

[main_print] val: 3
[sub_print] val: 3
[While loop] value: 3 value_sqr: 9
注意:
  1. 前缀@在输入/输出节点或者在脚本语言中都起效
  2. 子树不需要再映射
  3. 当在主循环中直接访问黑板时,不需要前缀@

二十一,脚本语言介绍

行为树4.x介绍了一种简单但强大的新概念:一种脱离XML的脚本语言
脚本语言的实现有类似的符号;它允许使用者快速读取/写入黑板中的变量
更简单的学习脚本如何发挥作用的方法是使用内置动作脚本

赋值操作符、字符串和数字

例子:
param_A := 42
param_B = 3.14
message = 'hello world'
  • 第一行将数字42分配给黑板条目param_A
  • 第二行将数字3.14分配给条目param_B
  • 第三行将字符串“hello world”分配给条目message
 
算术运算符和括号
例子:
param_A := 7
param_B := 5
param_B *= 2
param_C := (param_A * 3) + param_B
param_B的结果值为10,param_C的结果值为31。 支持以下操作符:
注意加号是仅有的同样适用于字符串的运算符(用于连接两个字符串)

位运算符和十六进制数

只有当值可以被强制转换为整数时,这些操作符才有效。 将它们与字符串或实数一起使用将导致异常。
例子:
value:= 0x7F
val_A:= value & 0x0F
val_B:= value | 0xF0
val_A的值是0x0F(或15);val_B是0xFF(或255)

逻辑和比较运算符

返回逻辑值的运算符
例子:
val_A := true
val_B := 5 > 3
val_C := (val_A == val_B)
val_D := (val_A && val_B) || !val_C

三元运算符if-then-else

例子:
val_B = (val_A > 1) ? 42 : 24

C++例子

演示脚本语言,包括如何使用枚举来表示整数值。
XML:
<root >
    <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>
用于注册节点和enum的C++代码:
int main()
{
  // Simple tree: a sequence of two asynchronous actions,
  // but the second will be halted because of the timeout.

  BehaviorTreeFactory factory;
  factory.registerNodeType<SaySomething>("SaySomething");

  enum Color { RED=1, BLUE=2, GREEN=3 };
  // We can add these enums to the scripting language
  factory.registerScriptingEnums<Color>();

  // Or we can do it manually
  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
注意,在底层,ENUM总是被解释为它的数值。

二十二,前提条件和后续条件

利用上一篇教程里介绍的脚本语言的强大功能,BT.CPP 4.x引入了前提条件和后续条件的功能,也就是可以在tick节点前或后触发的脚本
所有节点都支持Pre和Post条件,不需要在c++代码中进行任何修改。
注意,脚本的目标不是编写复杂的代码,而只是提高树的可读性,并在非常简单的用例中减少对自定义c++节点的需求。 如果您的脚本太长,您可能需要重新考虑使用它们的决定。

前提条件

例子

在前面的教程中,我们看到了如何使用回退在树中构建if-then-else逻辑。 新的语法更加紧凑:
之前的方法:
<Fallback>
    <Inverter>
        <IsDoorClosed/>
    </Inverter>
    <OpenDoor/>
</Fallback>
如果不使用自定义的ConditionNode IsDoorOpen,我们可以在一个名为door_closed的条目中存储一个布尔值,那么XML可以重写为:
<OpenDoor _skipIf="!door_closed"/>

后续条件

例子

在关于子树的教程中,我们看到了如何根据MoveBase的结果编写特定的黑板变量。 在左侧,您可以看到如何在BT.cpp 3.x中实现此逻辑。X以及使用后置条件要简单得多。此外,新语法支持枚举。
之前的版本:
<Fallback>
    <Sequence>
        <MoveBase  goal="{target}"/>
        <SetBlackboard output_key="result" value="0" />
    </Sequence>
    <ForceFailure>
        <SetBlackboard output_key="result" value="-1" />
    </ForceFailure>
</Fallback>
新的实现:
<MoveBase goal="{target}" 
          _onSuccess="result:=OK"
          _onFailure="result:=ERROR"/>

设计模式:错误码

与状态机相比,行为树可能存在的问题之一是,在那些应该根据动作的结果执行不同策略的模式中。 由于bt仅限于成功和失败,这可能是不直观的。 一种解决方案是将结果/错误代码存储在黑板中,但这在3.X版本中很麻烦。 前提条件可以帮助我们实现更具可读性的代码,就像下面这样:
在上面的树中,我们添加了一个输出端口返回错误码到MoveBase,然后根据错误码执行第二或第三分支

设计模式:状态和声明树

即使行为树的承诺是将我们从状态的暴政中解放出来,但事实是,有时没有状态很难对我们的应用程序进行推理。 使用状态可以使我们的树更简单。例如,只有当机器人(或子系统)处于特定状态时,我们才能取树的某个分支。 考虑这个节点及其前置/后置条件:
只有当状态等于DO_LANDING时,这个节点才会被执行,一旦高度的值足够小,状态就会被更改为landing。 注意,DO_LANDING和landing是enum,而不是字符串
小贴士:
这种模式的一个令人惊讶的副作用是,我们使我们的节点更具声明性,也就是说,更容易将这个特定的节点/子树移动到树的不同部分。

二十三,端口vs黑板

BT.CPP是行为树的唯一实现(据我们所知),它引入了输入/输出端口的概念,作为黑板的替代方案
更具体地说,端口是一种接口,它为黑板添加了一层间接和额外的语义。
要理解为什么推荐使用Ports而不鼓励直接使用Blackboard,我们应该首先了解BehaviorTree.CPP的一些核心原则。

BT.CPP的目标

模型驱动的发展

本文的目的不是解释什么是模型驱动开发。但是,简而言之,我们想要构建节点的“模型”,即某种元信息,告诉我们节点如何与树的其余部分交互。
模型对于开发人员(自文档化)和外部工具都很重要,例如可视化编辑器,例如Groot2,或者静态分析器。
我们认为节点间数据流的描述必须是模型的一部分。此外,我们希望清楚地表示黑板上的条目是正在写(输出)、读(输入)还是两者兼而有之。
考虑以下例子:
在其他实现中(或者如果有人不恰当地使用这个库…),知道这两个节点是否相互通信和依赖的唯一方法是:
  1. 检查代码:这是我们想要避免的
  2. 阅读文档:但文档并不一定是更新到最新的
相反,如果输入/输出端口是模型的一部分,则节点的意图及其与其他端口的关系变得更加明确:

节点的可组合性和子树作用域

理想情况下,我们希望提供一个平台,允许行为设计师构建由不同供应商/用户实现的树(即“组合节点”)。 但是,当直接使用Blackboard时,名称冲突将立即成为一个问题。 想想常见的名字,比如目标、结果、目标、价值等等。 例如,节点GraspObject和MoveBase可能是由不同的人开发的,它们都是从黑板上读取输入目标。不幸的是,它们有不同的含义和类型本身是不同的:前者期望一个3D姿势,而后者是一个2D姿势。
端口提供了一个间接层次,也称为“重新映射”,如教程2中所述。 这意味着,无论在定义Port时使用哪个名称(该名称被“硬编码”到您的c++实现中),您都可以将其“重新映射”到XML中的不同黑板条目,而无需修改源代码。 在教程6中解释的Subtrees重新映射也是如此。由于Blackboard是一个美化的全局变量映射,它的可伸缩性非常差。这就是为什么全局变量在编程中是禁忌的原因! 端口重新映射为这个问题提供了一个解决方案。

总结:永远不要直接使用黑板

你应该这么做:
// example code in your tick()
getInput("goal", goal);
setOutput("result", result);
而避免这么做:
// example code in your tick()
config().blackboard->get("goal", goal);
config().blackboard->set("result", result);
这两种代码在技术上都可以工作,但后者(直接访问黑板)被认为是不好的做法,高度不鼓励:
第二个版本的问题,即直接访问黑板:
  1. 名称“目标”和“结果”是硬编码的。要更改它们,必须重新编译应用程序。Port可以在运行时重新映射,只需修改XML即可。
  2. 了解Node是否读取或写入黑板上的一个或多个条目的唯一方法是检查代码。最新的文档是一种解决方案,但是Port模型是自文档化的。
  3. BehaviorTreeFactory不知道那些黑板条目正在被访问。相反,在使用端口时,我们能够检查端口是如何互相通信的,也可以检查连接的部署时间、如果连接正确地完成了
  4. 在使用SubTrees时,它可能不会像预期的那样工作。
  5. 专用模板convertFromString()将无法正常工作。

二十四,装饰器

一个装饰器有且仅有一个子节点
装饰器节点决定了子节点在什么时候被tick几次

翻转者(Inverter)

tick子节点一次并且返回SUCCESS当子节点失败,反之反馈FAILURE
假如子节点返回RUNNING,该节点也返回RUNNING

强制成功(ForceSuccess)

假如子节点返回RUNNING,它也返回RUNNING,否则返回SUCCESS

强制失败(ForceFailure)

假如子节点返回RUNNING,它也返回RUNNING,否则返回FAILURE

重复(Repeat)

只要子进程返回SUCCESS,子进程最多被tickN次(在它的一次节拍内),其中N作为输入端口num_cycles传递。如果子进程总是返回SUCCESS,则在N次重复之后返回SUCCESS。 如果子进程返回FAILURE,则中断循环,在这种情况下,返回FAILURE。 如果子节点返回RUNNING,则该节点也返回RUNNING,重复将继续进行,而不会进入Repeat节点的下一个节拍。

重试直到成功(RetryUntilSuccessful)

只要子节点返回FAILURE,就持续tick子节点最多N次,其中N作为输入端口num_attempts传递,。如果子进程总是返回FAILURE,则在N次尝试后返回FAILURE。 如果子进程返回SUCCESS,则中断循环,在这种情况下,返回SUCCESS。 如果子节点返回RUNNING,则此节点也返回RUNNING,并且尝试将继续,而不会在retryuntilsuccess节点的下一个tick上增加。

持续运行到失败(KeepRunningUntilFailure)

KeepRunningUntilFailure节点总是返回FAILURE(子节点FAILURE)或者RUNNING(子节点SUCCESS或RUNNING)

延迟(Delay)

在指定的时间过去后tick子节点。延迟由输入端口delay_msec指定。如果子节点返回RUNNING,则该节点也返回RUNNING,并将在Delay节点的下一个标记上标记子节点。否则,返回子节点的状态。

单次触发(RunOnce)

当您只想执行子进程一次时,使用RunOnce节点。如果子线程是异步的,它将一直tick,直到返回SUCCESS或FAILURE。 在第一次执行之后,你可以设置输入端口then_skip的值为: •TRUE(默认值),该节点将在将来被跳过。 •FALSE,永远同步返回子进程返回的状态。

前提条件

子树

其他需要在C++中注册的装饰器

消费队列(ConsumeQueue)

只要队列不为空,就执行子节点。在每次迭代中,从“队列”中弹出一个类型为T的项并插入到“popped_item”中。 空队列将返回SUCCESS

简单装饰器(SimpleDecoratorNode)

用void BehaviorTreeFactory::registerSimpleDecorator("MyDecorator", tick_function, ports)注册一个简单的decorator节点,它在内部使用SimpleDecoratorNode,其中tick_function是一个带有std::function<NodeStatus(NodeStatus, TreeNode&)>签名的函数,ports是一个类型为PortsList的变量。

二十五,回退(Fallback)

这类节点在其他框架中称为“选择器”或“优先级”。
他们的目的是尝试不同的策略,直到我们找到一个“有效”的。
目前框架提供了两种节点:
•回退
•有响应式回退
它们共享以下规则:
•在tick第一个子节点之前,节点状态变为RUNNING。
•如果一个子节点返回FAILURE,则回退tick下一个子节点。
•如果最后一个子进程也返回FAILURE,所有的子进程都被暂停,回退进程返回FAILURE。
•如果子进程返回SUCCESS,则停止并返回SUCCESS。所有的子进程都停下来了。
两种控制节点的区别如下
重启意味着整个回退节点从第一个子节点重新开始
重新tick意味着下一次该回退节点被tick时,同样的子节点被tick。先前已经返回FAILURE的兄弟节点不再被tick。

回退

在这个例子里,我们尝试了不同策略来开门,首先检查门是否开启

响应式回退(ReactiveFallback)

当我们想要在某个异步子节点的前提条件从FAILURE变为SUCCESS时中断它,我们可以使用这个控制节点。 在下面的示例中,角色将睡眠长达8小时。如果他/她已经完全休息了,那么节点areyourested ?将返回SUCCESS,异步节点Timeout(8小时)和Sleep将被中断。

二十六,序列

只有所有子节点反馈SUCCESS,一个序列才会返回SUCCESS。如果任何子线程返回FAILURE,则终止该序列。 目前该框架提供了三种类型的节点: •序列 •带记忆序列 •交互序列
它们共享以下规则: •在勾选第一个子节点之前,节点状态变为RUNNING。 •如果一个子节点返回SUCCESS,它会勾起下一个子节点。 •如果最后一个子进程也返回SUCCESS,那么所有的子进程都被暂停,序列返回SUCCESS。
三种节点区别如下:
重启意味着整个回退节点从第一个子节点重新开始
重新tick意味着下一次该回退节点被tick时,同样的子节点被tick。先前已经返回FAILURE的兄弟节点不再被tick。

序列(Sequence)

这个树表示了游戏中一个狙击手的行为

响应式序列(ReactiveSequence)

这个节点对于连续检查条件特别有用;但是用户在使用异步子线程时也应该小心,确保它们不会比预期的更频繁地被选中。 让我们来看另一个例子:
ApproachEnemy是一个异步动作,它返回RUNNING,直到最终完成。 条件isEnemyVisible将被调用多次,如果它变为false(例如,“FAILURE”),则停止ApproachEnemy。

带记忆序列(SequenceWithMemory)

当您不想再次勾选已经返回SUCCESS的子节点时,使用此ControlNode。
例子:
这是一个巡逻代理/机器人,它必须只访问a、B和C地点一次。如果动作GoTo(B)失败,GoTo(A)将不会再次打勾。 另一方面,isBatteryOK必须在每次tick时检查,因此它的父类必须是一个ReactiveSequence。
 
posted @ 2025-08-28 10:03  虚在君  阅读(22)  评论(0)    收藏  举报