Skip to content

Wiki

脚本与前后置条件

About 8 min read

Existing English translations were reused. 39 current source blocks remain in Simplified Chinese, without triggering paid translation.

1.用 XML 表达数据更新与节点条件

官方资料:Scripting、Pre and Post Conditions。执行时机按 4.10.0 tree_node.cpp 和 tree_node.h 核对。

前面通过 C++ 节点读写 Blackboard。 本篇把简单的数据赋值与条件判断写到 XML,并沿用同一个计数导航,观察任务结束、中止和跳过的区别。 导航仍不连接机器人,回调计数达到三次才报告成功。

2.Script、ScriptCondition 与条件属性各管一层

<Script code="battery:=80; ready:=true; location:='loading_area'"/>
<ScriptCondition code="battery >= 30"/>

Script 是内置同步动作,code 是脚本文本。正常赋值执行完返回成功;脚本解析或类型错误会产生异常。ScriptCondition 是内置条件,表达式为真返回成功,为假返回失败。它们已由工厂注册,不需要再写同名节点类。

写法 含义
finished:=false 创建或赋值 Blackboard 变量
finished=true 对已有变量赋值;未声明的名称会报错
battery >= 30 比较表达式,返回布尔结果
ready && battery >= 30 两个条件都满足;写在 XML 属性中需把 && 编码为 &amp;&amp;
location:='loading_area' 保存字符串,脚本字符串使用单引号,XML 外层使用双引号

脚本中的名称通常是当前 Blackboard 的键;节点 XML 属性如 goal="{mission_goal}" 中的花括号则用于端口映射,两种语法不要混用。脚本适合简短的数据和策略表达,发送导航、文件读写等业务操作继续由 C++ 实现。

3.前置条件何时检查

属性 条件满足/不满足时的行为 RUNNING 期间是否继续检查
_failureIf="expr" 表达式为真时,跳过业务回调,报告 FAILURE 否
_successIf="expr" 表达式为真时,跳过业务回调,报告 SUCCESS 否
_skipIf="expr" 表达式为真时,不执行业务回调,报告 SKIPPED 否
_while="expr" 表达式为假时,报告 SKIPPED;已经运行的节点先被 halt 是,但仍须执行流 tick 到该节点

前三项在节点处于 IDLE 或 SKIPPED 时检查,不能仅靠 _failureIf 持续监控正在导航的动作。4.10.0 按 _failureIf、_successIf、_skipIf、_while 的顺序处理,因此同一节点上多项同时成立时,前面的结果可能先返回。开始时只给一个节点设置一种主要前置策略,更容易理解。

_while 不会创建监控线程。如果外部不再 tick,或者父节点不再执行到该节点,就没有机会检查新条件。

4.后置脚本与 C++ 中止回调

属性 触发位置
_onSuccess 节点被执行框架判定为成功时
_onFailure 节点被执行框架判定为失败时
_post 成功或失败后的公共脚本;不能当作每次 tick 或所有退出路径的 finally
_onHalted 框架 halt 该节点时

前置条件直接返回成功或失败时,也可能触发对应的后置脚本;因此 _onSuccess 不证明业务回调确实执行过。SKIPPED 不等于成功或失败,不能假定它触发这两项脚本。

_onHalted 是框架层脚本,StatefulActionNode::onHalted() 是 C++ 业务回调。后者负责取消外部操作;把 cancelled=true 写进 Blackboard 只更新一条数据,不会替你取消真实导航。脚本执行错误也不能成为依赖脚本清理外部资源的理由。

5.复制持续任务工程

本篇复用本系列 持续任务与中止 中建立的 bt4_running。先完成该篇的运行练习,再建立脚本练习工程:

  1. 在文件管理器中复制整个 bt4_running 文件夹,将副本重命名为 bt4_script。
  2. 删除副本中的 build、install 文件夹(如果存在),清理原工程的构建缓存和结果。
  3. 用 VSCode 新窗口打开 bt4_script,在顶层 CMakeLists.txt 修改项目名。依赖模块、预设和 BT_XML_PATH 配置继续使用副本中的文件:
project(bt4_script VERSION 1.0.0 LANGUAGES C CXX)

本例不再需要 XML 中独立的 NavigationAllowed,由 _while 检查允许导航的值。下面完整程序保留导航节点的定义,并用脚本条件替代原来的独立条件节点。

5.1.src/main.cpp

#include "behaviortree_cpp/bt_factory.h"

#include <chrono>
#include <iostream>
#include <stdexcept>
#include <string>

class NavigateToGoal : public BT::StatefulActionNode
{
public:
    NavigateToGoal(const std::string& name, const BT::NodeConfig& config)
        : BT::StatefulActionNode(name, config)
    {}

    static BT::PortsList providedPorts()
    {
        return {BT::InputPort<std::string>("goal"),
                BT::InputPort<int>("ticks", 3, "完成导航所需的 onRunning 调用次数")};
    }

    BT::NodeStatus onStart() override
    {
        auto goal = getInput<std::string>("goal");
        auto ticks = getInput<int>("ticks");
        if (!goal)
        {
            throw BT::RuntimeError("Cannot read input [goal]: ", goal.error());
        }
        if (!ticks)
        {
            throw BT::RuntimeError("Cannot read input [ticks]: ", ticks.error());
        }
        if (goal.value().empty() || ticks.value() < 1)
        {
            throw std::runtime_error("goal must not be empty and ticks must be positive");
        }

        goal_ = goal.value();             // 保存本次目标,供后续回调使用
        required_ticks_ = ticks.value();  // 保存模拟完成所需的检查次数,本例为 3
        completed_ticks_ = 0;             // 每次开始新任务,都从零次检查开始
        // 真实导航通常在这里发送一次请求,本例只保存模拟任务状态。
        std::cout << "[NavigateToGoal] start goal=" << goal_ << '\n';
        // 结束本次回调并报告“尚未完成”,后续 tick 再进入 onRunning。
        return BT::NodeStatus::RUNNING;
    }

    BT::NodeStatus onRunning() override
    {
        // 每次进入这个回调,模拟检查次数就加一;函数返回后成员计数仍会保留。
        ++completed_ticks_;
        std::cout << "[NavigateToGoal] progress=" << completed_ticks_
                  << '/' << required_ticks_ << '\n';
        // 本例把“检查次数达到要求”当作完成条件,没有查询机器人的真实位置。
        if (completed_ticks_ >= required_ticks_)
        {
            std::cout << "[NavigateToGoal] arrived goal=" << goal_ << '\n';
            // 已满足模拟完成条件,向父节点报告成功,本次函数到这里结束。
            return BT::NodeStatus::SUCCESS;
        }
        // 尚未达到要求,结束本次检查,等待后续 tick 再进入这个回调。
        return BT::NodeStatus::RUNNING;
    }

    void onHalted() override
    {
        // 运行中的任务收到中止要求时,基类才会调用这个回调。
        // 真实导航在这里取消请求、停止当前任务并清理资源。
        std::cout << "[NavigateToGoal] halted goal=" << goal_
                  << ", progress=" << completed_ticks_ << '\n';
        completed_ticks_ = 0;
    }

private:
    std::string goal_;          // 本次目标
    int required_ticks_ = 0;    // 模拟完成所需的运行检查次数,由 onStart 读取
    int completed_ticks_ = 0;   // 已完成的运行检查次数,可跨多次回调保存
};

int main()
{
    try
    {
        BT::BehaviorTreeFactory factory;
        factory.registerNodeType<NavigateToGoal>("NavigateToGoal");
        factory.registerSimpleAction("ReportArrival", [](BT::TreeNode&) {
            std::cout << "[ReportArrival] mission completed\n";
            return BT::NodeStatus::SUCCESS;
        });
        auto blackboard = BT::Blackboard::create();
        blackboard->set("navigation_allowed", true);
        const bool cancel_after_two_ticks = false;
        auto tree = factory.createTreeFromFile(BT_XML_PATH, blackboard);

        BT::NodeStatus result = BT::NodeStatus::RUNNING;
        int tick_count = 0;
        while (result == BT::NodeStatus::RUNNING)
        {
            ++tick_count;
            std::cout << "--- tick " << tick_count << '\n';
            result = tree.tickExactlyOnce();
            if (cancel_after_two_ticks && tick_count == 2)
            {
                blackboard->set("navigation_allowed", false);
            }
            if (result == BT::NodeStatus::RUNNING)
            {
                tree.sleep(std::chrono::milliseconds(100));
            }
        }
        std::cout << "Tree result: " << BT::toStr(result)
                  << ", finished=" << std::boolalpha << blackboard->get<bool>("finished")
                  << ", cancelled=" << blackboard->get<bool>("cancelled") << '\n';
        return result == BT::NodeStatus::SUCCESS ? 0 : 1;
    }
    catch (const std::exception& error)
    {
        std::cerr << "Error: " << error.what() << '\n';
        return 2;
    }
}

5.2.trees/mission.xml

<root BTCPP_format="4" main_tree_to_execute="MainTree">
    <BehaviorTree ID="MainTree">
        <Sequence>
            <Script code="finished:=false; cancelled:=false"/>
            <NavigateToGoal goal="loading_area" ticks="3"
                            _while="navigation_allowed"
                            _onSuccess="finished=true"
                            _onHalted="cancelled=true"/>
            <ScriptCondition code="finished"/>
            <ReportArrival/>
        </Sequence>
    </BehaviorTree>
</root>

外层普通 Sequence 在导航 RUNNING 时直接继续当前节点,导航自己的 _while 仍会被检查。本例的条件写在动作上,不依赖外层 ReactiveSequence 重查一个前置叶节点。

6.正常完成与中途取消

在 CMake Tools 选择 linux-debug,Configure、Build 后选择 bt4_script 为 Launch 目标,再运行。 cancel_after_two_ticks=false 时,导航经过一次启动和三次运行检查,最后输出:

[ReportArrival] mission completed
Tree result: SUCCESS, finished=true, cancelled=false

_onSuccess 把 finished 改为真;后面的 ScriptCondition 才允许执行报告。

截图预留:脚本标记正常完成。 展示 XML 的 _onSuccess、四次 tick 和最终的两个布尔值。

把取消设置改为 true,保存并 Build、Launch。第二次根 tick 之后,主程序修改允许导航的值。第三次根 tick,_while 检查失败,调用导航的 C++ 中止回调和框架的中止后置脚本。结果为:

[NavigateToGoal] halted goal=loading_area, progress=1
Tree result: FAILURE, finished=false, cancelled=true

这里导航向父节点交回的是 SKIPPED。普通 Sequence 会继续后面的 ScriptCondition;由于 finished 仍为假,条件返回失败,树才最终失败。ReportArrival 不执行。

截图预留:条件变假后的中止。 在 onHalted() 设置断点,展示计数仍为 1,以及最终取消标记。

7.SKIPPED 不能直接当成任务失败

如果删掉 <ScriptCondition code="finished"/>,取消路径会跳过导航并继续 ReportArrival,整个 Sequence 可能报告成功。这是树组合规则产生的结果,不是导航已经到达。

对照修改 应观察的行为
主示例,取消关闭 导航成功,执行到达报告
主示例,取消开启 中止导航,完成条件失败,不报告到达
删掉完成条件,取消开启 中止后仍执行报告,暴露不正确的业务组合
恢复完成条件,将 _while 改成 _failureIf="!navigation_allowed" 开始时允许,运行中变假后不会靠该属性中止,导航仍可成功

每次只修改一项。只改 XML 时保存后重新 Launch;改 C++ 时 Build 后 Launch。观察完恢复主示例。

截图预留:跳过与失败对照。 展示删除完成条件前后的结果,解释为什么不能把 SKIPPED 当作 FAILURE。

本篇的 ScriptCondition 检查的是模拟导航的完成标记。实际系统可选择返回失败、取消结果或切换到取消处理分支,但必须明确哪一条路径允许对外报告完成。

8.当前学习进度

本篇完成了 Script、条件属性、后置脚本与 SKIPPED 的组合实践。接下来把外部数据放到全局 Blackboard,并区分“同一个值”“同一次更新”和“仍然足够新”。


v4 学习路线 · 上一篇:组合实际任务 · 下一篇:全局Blackboard与数据更新