Skip to content

商队启程

全章人马上路:父子树管“谁在哪辆车上”,配发装备走 EquippedBy,认主之物走带 linked_spawnSoulboundTo。六帧走完组队、配装、换乘、塌方、拾遗、散队的全程:

rust
//! 第 9 章综合示例:商队启程
//! 父子树管"谁在哪辆车上",配发装备走 EquippedBy,认主之物走 SoulboundTo(linked_spawn);
//! 坠崖一幕是三级连锁:车带走乘员(ChildOf),乘员带走认主之物(SoulboundTo),
//! 配发的灯笼无人认领,掉落在地

use bevy::app::ScheduleRunnerPlugin;
use bevy::prelude::*;
use std::time::Duration;

/// 标记:商队大旗(整棵树的根)
#[derive(Component)]
struct Caravan;

/// 标记:这是一辆车
#[derive(Component)]
struct Wagon;

/// 标记:这是一件物品
#[derive(Component)]
struct Item;

/// 商队配发的装备:主人注销后留在原地
#[derive(Component)]
#[relationship(relationship_target = Equipment)]
struct EquippedBy(Entity);

#[derive(Component)]
#[relationship_target(relationship = EquippedBy)]
struct Equipment(Vec<Entity>);

/// 认主的随身物:主人注销,它也一起注销
#[derive(Component)]
#[relationship(relationship_target = SoulboundGear)]
struct SoulboundTo(Entity);

#[derive(Component)]
#[relationship_target(relationship = SoulboundTo, linked_spawn)]
struct SoulboundGear(Vec<Entity>);

fn main() {
    App::new()
        .add_plugins(MinimalPlugins.set(ScheduleRunnerPlugin::run_loop(
            Duration::from_millis(100),
        )))
        .add_systems(Update, (banner, script, roll_call).chain())
        .run();

    println!("(run() 返回,商队的故事讲完了)");
}

/// 报幕:让"帧"在输出里看得见
fn banner(mut frame: Local<u32>) {
    *frame += 1;
    println!("—— 第 {} 帧 ——", *frame);
}

// ANCHOR: script
/// 商队五幕剧,每帧推进一幕
fn script(
    everything: Query<(Entity, &Name)>,
    mut commands: Commands,
    mut exit: MessageWriter<AppExit>,
    mut frame: Local<u32>,
) {
    *frame += 1;
    let find = |target: &str| {
        everything
            .iter()
            .find(|(_, name)| name.as_str() == target)
            .map(|(entity, _)| entity)
            .unwrap()
    };
    match *frame {
        1 => {
            println!("队长老姜:人齐了,出发!");
            // 父子树:大旗 → 两辆车 → 乘员,一个表达式声明完
            commands.spawn((
                Name::new("商队大旗"),
                Caravan,
                children![
                    (
                        Name::new("青篷车"),
                        Wagon,
                        children![Name::new("老姜"), Name::new("小芙")],
                    ),
                    (
                        Name::new("铁皮货车"),
                        Wagon,
                        children![
                            Name::new("货物箱"),
                            Name::new("罗兰"),
                            Name::new("护卫老蔫儿"),
                        ],
                    ),
                ],
            ));
        }
        2 => {
            // children! 里拿不到乘员的 Entity,装备等下一帧按名字补发
            println!("账房:配发装备,认主之物自行登记。");
            commands.spawn((Name::new("长戟"), Item, EquippedBy(find("小芙"))));
            commands.spawn((
                Name::new("祖传腰刀"),
                Item,
                SoulboundTo(find("护卫老蔫儿")),
            ));
            commands.spawn((
                Name::new("公家灯笼"),
                Item,
                EquippedBy(find("护卫老蔫儿")),
            ));
        }
        3 => {
            println!("罗兰:货车颠得慌,我去青篷车给小芙讲掌灯人的故事。");
            commands.entity(find("罗兰")).insert(ChildOf(find("青篷车")));
        }
        4 => {
            println!("山道塌方!铁皮货车连人带货坠下山崖——");
            commands.entity(find("铁皮货车")).despawn();
        }
        5 => {
            println!("小芙:路边有盏灯笼……我捡走了。");
            commands.entity(find("公家灯笼")).insert(EquippedBy(find("小芙")));
        }
        6 => {
            println!("老姜:灰岩镇到了,收旗散队!");
            commands.entity(find("商队大旗")).despawn();
            exit.write(AppExit::Success);
        }
        _ => {}
    }
}
// ANCHOR_END: script

// ANCHOR: roll_call
/// 点名:树形打印整支商队,再报装备归属与地上的遗落物
fn roll_call(
    roots: Query<Entity, With<Caravan>>,
    names: Query<&Name>,
    children_query: Query<&Children>,
    items: Query<(&Name, Option<&EquippedBy>, Option<&SoulboundTo>), With<Item>>,
    ground: Query<&Name, (With<Item>, Without<EquippedBy>, Without<SoulboundTo>)>,
    in_roster: Query<Entity, With<Name>>,
) {
    for root in &roots {
        print_subtree(root, 1, &names, &children_query);
    }
    for (item_name, equipped, soulbound) in &items {
        match (equipped, soulbound) {
            (Some(equipped), _) => println!(
                "  随身:{item_name} → {}(配发)",
                names.get(equipped.0).unwrap()
            ),
            (_, Some(soulbound)) => println!(
                "  随身:{item_name} → {}(认主)",
                names.get(soulbound.0).unwrap()
            ),
            _ => {}
        }
    }
    for item_name in &ground {
        println!("  地上:{item_name}");
    }
    if roots.is_empty() {
        println!("  (在册实体:{} 个)", in_roster.iter().count());
    }
}

fn print_subtree(
    entity: Entity,
    depth: usize,
    names: &Query<&Name>,
    children_query: &Query<&Children>,
) {
    println!("{}{}", "  ".repeat(depth), names.get(entity).unwrap());
    if let Ok(children) = children_query.get(entity) {
        for &child in children {
            print_subtree(child, depth + 1, names, children_query);
        }
    }
}
// ANCHOR_END: roll_call

Listing 9-10:完整示例——商队启程(src/main.rs)

console
cargo run -p ch09-relationships
text
—— 第 1 帧 ——
队长老姜:人齐了,出发!
  商队大旗
    青篷车
      老姜
      小芙
    铁皮货车
      货物箱
      罗兰
      护卫老蔫儿
—— 第 2 帧 ——
账房:配发装备,认主之物自行登记。
  商队大旗
    青篷车
      老姜
      小芙
    铁皮货车
      货物箱
      罗兰
      护卫老蔫儿
  随身:祖传腰刀 → 护卫老蔫儿(认主)
  随身:长戟 → 小芙(配发)
  随身:公家灯笼 → 护卫老蔫儿(配发)
—— 第 3 帧 ——
罗兰:货车颠得慌,我去青篷车给小芙讲掌灯人的故事。
  商队大旗
    青篷车
      老姜
      小芙
      罗兰
    铁皮货车
      货物箱
      护卫老蔫儿
  随身:祖传腰刀 → 护卫老蔫儿(认主)
  随身:长戟 → 小芙(配发)
  随身:公家灯笼 → 护卫老蔫儿(配发)
—— 第 4 帧 ——
山道塌方!铁皮货车连人带货坠下山崖——
  商队大旗
    青篷车
      老姜
      小芙
      罗兰
  随身:长戟 → 小芙(配发)
  地上:公家灯笼
—— 第 5 帧 ——
小芙:路边有盏灯笼……我捡走了。
  商队大旗
    青篷车
      老姜
      小芙
      罗兰
  随身:长戟 → 小芙(配发)
  随身:公家灯笼 → 小芙(配发)
—— 第 6 帧 ——
老姜:灰岩镇到了,收旗散队!
  地上:长戟
  地上:公家灯笼
  (在册实体:2 个)
(run() 返回,商队的故事讲完了)

三处值得回头多看一眼:

  • 第 4 帧是一场三级连锁,但代码只 despawn 了一个实体。货车销毁→Children 的级联带走货物箱和老蔫儿(linked_spawn)→老蔫儿销毁又沿 SoulboundTo 带走祖传腰刀;而公家灯笼的关系不带 linked_spawn,只被解绑,掉在地上。两种关系的级联首尾相接,每一环都是引擎按 derive 时的声明自动执行的。罗兰在第 3 帧换了车,捡回一条命——reparent 改变的不只是名单,还有“跟谁共存亡”。
  • 第 6 帧收旗,世界却没清空despawn 大旗确实带走了整棵树——车、人都没了,可在册实体还剩 2 个:长戟和灯笼。它们挂在小芙身上用的是不带 linked_spawn 的关系,所以小芙被级联销毁时它们只是被解绑。“销毁根实体=清空整个场景”这个直觉,只对纯 linked_spawn 的关系链成立——清场前用 despawn_related::<Equipment>() 补刀,或者干脆把这类“场景物品”也并进父子树,都是常见的修法。
  • 第 2 帧为什么不并进第 1 帧children! 是声明式的,拿不到树里乘员的 Entity,所以装备只能等下一帧按名字查到人再补发。要在同一帧办完,就得换 with_children 闭包逐个 .id()——声明的简洁和过程的灵活,按需取舍。

这棵树还有第二重身份

本章的父子树管的全是“账目”:谁挂在谁名下、谁陪谁销毁。等第 12 章进入渲染世界,你会看到它的另一重身份——子实体的 Transform 是相对父亲的局部坐标,引擎每帧沿着这棵树把局部坐标累乘成世界坐标(GlobalTransform),可见性也沿树继承。“车一动,全车人跟着动”到时候不再是比喻,而是字面意义的坐标传播。地基已经打好,就是本章这棵树。

小结

  • Relationship 用一对组件表达“谁连着谁”:事实源组件(如 ChildOf)装着目标实体,写它;镜像组件(如 Children)由引擎经组件钩子自动维护,只读它。两本账永不失同步
  • 一个实体每种关系至多一个目标——这是“组件每实体一份”的直接推论;父子树因此天然是树
  • 建树children![] 声明式嵌套、with_children 闭包、add_child 后补;自定义关系把 child 换成 related
  • 拆树despawn 级联整棵子树;despawn_children 只灭孩子;detach/remove::<ChildOf> 只断关系;镜像名单空了组件自动消失
  • 关系组件不可变&mut ChildOf 过不了编译;改关系=insert 新组件(reparent 与转手装备是同一个动作)
  • 遍历:向下 iter_descendants(广度)/iter_descendants_depth_first(深度),向上 iter_ancestors/root_ancestor;引擎不查环,reparent 进自己的子树会让遍历死循环
  • linked_spawn 决定级联:开了陪葬,不开掉落;Children 的级联销毁正是这个开关
  • 引擎对坏关系的态度是自愈:自指、指向不存在实体的关系会被警告并拆除

练习

  1. 把灯笼也变成认主之物:把 Listing 9-10 里 Equipment 的 derive 改成带 linked_spawn,预测六帧输出会有哪几处变化(提示:第 4 帧的“地上”和第 6 帧的“在册实体”),再运行对答案。
  2. 数叶子:给 Listing 9-7 加一个系统,用 iter_leaves 列出商队大旗下的全部叶子实体。先在纸上圈出哪些算叶子(提示:老姜算,小芙不算——为什么?),再运行验证。
  3. 亲手造一个环:把 Listing 9-6 的换乘改成 commands.entity(qing_peng_che).insert(ChildOf(xiaofu))——让青篷车挂到自己乘客的名下,然后在换乘后的清点里用 iter_descendants 从青篷车出发点名。先预测程序的命运再运行。程序会卡死不退出,这正是 9-3 节警告的环——确认现象后 Ctrl-C 收场即可。

下一章离开“实体之间”,处理“整个游戏的阶段”:主菜单、游戏中、暂停——State 把这些全局阶段做成类型安全的状态机,进出状态时自动启停系统、清理实体。