商队启程
全章人马上路:父子树管“谁在哪辆车上”,配发装备走 EquippedBy,认主之物走带 linked_spawn 的 SoulboundTo。六帧走完组队、配装、换乘、塌方、拾遗、散队的全程:
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_callListing 9-10:完整示例——商队启程(src/main.rs)
console
cargo run -p ch09-relationshipstext
—— 第 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的级联销毁正是这个开关- 引擎对坏关系的态度是自愈:自指、指向不存在实体的关系会被警告并拆除
练习
- 把灯笼也变成认主之物:把 Listing 9-10 里
Equipment的 derive 改成带linked_spawn,预测六帧输出会有哪几处变化(提示:第 4 帧的“地上”和第 6 帧的“在册实体”),再运行对答案。 - 数叶子:给 Listing 9-7 加一个系统,用
iter_leaves列出商队大旗下的全部叶子实体。先在纸上圈出哪些算叶子(提示:老姜算,小芙不算——为什么?),再运行验证。 - 亲手造一个环:把 Listing 9-6 的换乘改成
commands.entity(qing_peng_che).insert(ChildOf(xiaofu))——让青篷车挂到自己乘客的名下,然后在换乘后的清点里用iter_descendants从青篷车出发点名。先预测程序的命运再运行。程序会卡死不退出,这正是 9-3 节警告的环——确认现象后 Ctrl-C 收场即可。
下一章离开“实体之间”,处理“整个游戏的阶段”:主菜单、游戏中、暂停——State 把这些全局阶段做成类型安全的状态机,进出状态时自动启停系统、清理实体。