父子树:ChildOf 与 Children
商队的第一辆车要发车了。“老姜、小芙、罗兰在青篷车上”这件事,在 Bevy 里只需要给每个乘员挂一个组件:
/// 发车前:乘员逐个上车——只写 ChildOf,不碰 Children
fn board(mut commands: Commands) {
let wagon = commands.spawn((Name::new("青篷车"), Wagon)).id();
commands.spawn((Name::new("老姜"), ChildOf(wagon)));
commands.spawn((Name::new("小芙"), ChildOf(wagon)));
commands.spawn((Name::new("罗兰"), ChildOf(wagon)));
}Listing 9-1(节选):上车——给乘员插入 ChildOf(examples/listing-09-01.rs)
ChildOf(父子关系组件)是个普通的单字段组件,里面装着父实体的 Entity。注意 board 里我们只写了 ChildOf,但查询时却能从两个方向看到这层关系:
/// 从车往下看:Children 是引擎替我们记好的名单
fn roster_from_wagon(wagons: Query<(&Name, &Children), With<Wagon>>, names: Query<&Name>) {
println!("== 从车往下看 ==");
for (wagon_name, children) in &wagons {
println!("{wagon_name} 载着 {} 人:", children.len());
for &crew in children {
println!(" - {}", names.get(crew).unwrap());
}
}
}/// 从人往上看:ChildOf 记着自己的父实体
fn whereami_from_crew(crew: Query<(&Name, &ChildOf)>, names: Query<&Name>) {
println!("== 从人往上看 ==");
for (crew_name, child_of) in &crew {
let wagon_name = names.get(child_of.parent()).unwrap();
println!("{crew_name} 在 {wagon_name} 上");
}
}Listing 9-1(续):同一层关系,两个方向各有一个组件可查
cargo run -p ch09-relationships --example listing-09-01== 从车往下看 ==
青篷车 载着 3 人:
- 老姜
- 小芙
- 罗兰
== 从人往上看 ==
老姜 在 青篷车 上
小芙 在 青篷车 上
罗兰 在 青篷车 上Children(子女名单组件)我们从头到尾没写过,它是哪来的?答案就是第 8 章的组件钩子:ChildOf 在 derive 时自带一个 on_insert 钩子——组件上身的瞬间,钩子跑到目标实体(车)上,没有 Children 就补一个,然后把新乘员登记进去。开篇抱怨的“两本账”问题,Bevy 的解法不是只记一本,而是两本都记、但只让你写其中一本:
ChildOf是事实源(source of truth):想改变父子关系,永远操作它;Children是引擎根据事实源自动维护的镜像:随便读(迭代、len()、按下标取),但不要手工增删——它对你唯一开放的写操作是重排顺序(sort_by一族),名单里有谁完全由引擎做主。
Children 里的顺序就是登记顺序,这一点后面做 UI 时有用(子节点的排列顺序就是它)。
还有一条规则是白送的:第 3 章说过,一个实体上每种组件至多一份。ChildOf 是组件,所以一个实体至多只有一个父亲——“树”这个结构不靠任何额外检查,光凭组件模型就立住了。
建树三式
一个个 spawn 再挂 ChildOf 当然可行,但车队天天组装,Bevy 给了三种更顺手的写法:
fn assemble(mut commands: Commands) {
// 写法一:children! 宏——树长什么样,代码就长什么样
commands.spawn((
Name::new("铁皮货车"),
Wagon,
children![
(Name::new("罗兰"), children![Name::new("铜灯")]),
Name::new("货物箱"),
],
));
// 写法二:with_children 闭包——适合循环生成,或继续拿子实体的 id
let wagon = commands
.spawn((Name::new("青篷车"), Wagon))
.with_children(|car| {
car.spawn(Name::new("老姜"));
for i in 1..=2 {
car.spawn(Name::new(format!("木桶 {i} 号")));
}
})
.id();
// 写法三:add_child——实体早已存在,事后认亲
let pole = commands.spawn(Name::new("扁担")).id();
commands.entity(wagon).add_child(pole);
}Listing 9-2(节选):children! 宏、with_children 闭包、add_child 后补(examples/listing-09-02.rs)
三种写法各有用武之地:
children![...]宏:声明式,树长什么样、代码就长什么样,支持任意嵌套(罗兰怀里还揣着铜灯)。代价是拿不到中途实体的Entity——整棵树是一个表达式,没有插手的空隙。with_children(|car| ...)闭包:闭包里的car.spawn(...)自动给孩子挂上ChildOf,适合循环生成;闭包外的.id()照常能拿到父实体的编号。add_child(entity):实体早已存在(路边捡的扁担),事后认亲。同族还有批量版add_children(&[...])。
用一个递归函数把两棵树打出来验货:
/// 从每辆车出发,递归打印整棵树
fn print_trees(
wagons: Query<Entity, With<Wagon>>,
names: Query<&Name>,
children_query: Query<&Children>,
) {
for wagon in &wagons {
print_subtree(wagon, 0, &names, &children_query);
}
}
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);
}
}
}Listing 9-2(续):沿 Children 递归下行,缩进打印整棵树
铁皮货车
罗兰
铜灯
货物箱
青篷车
老姜
木桶 1 号
木桶 2 号
扁担两桩闯祸
挂关系的时候手一抖,会发生什么?让一辆车自己拉自己,再让一位旅人登上早已报废的车:
fn mishaps(mut commands: Commands) {
// 闯祸一:让青篷车自己拉自己
let wagon = commands.spawn(Name::new("青篷车")).id();
commands.entity(wagon).insert(ChildOf(wagon));
// 闯祸二:登上一辆已经报废的马车
let scrapped = commands.spawn(Name::new("报废马车")).id();
commands.entity(scrapped).despawn();
commands.spawn((Name::new("倒霉旅人"), ChildOf(scrapped)));
}Listing 9-3(节选):自指的 ChildOf、指向已销毁实体的 ChildOf(examples/listing-09-03.rs)
这次运行多挂了一个 LogPlugin——引擎的警告走日志系统,不挂它就什么都看不见:
WARN bevy_ecs::relationship: The bevy_ecs::hierarchy::ChildOf(0v0) relationship on entity 0v0 points to itself. The invalid bevy_ecs::hierarchy::ChildOf relationship has been removed.
WARN bevy_ecs::relationship: The bevy_ecs::hierarchy::ChildOf(1v0) relationship on entity 2v0 relates to an entity that does not exist. The invalid bevy_ecs::hierarchy::ChildOf relationship has been removed.
== 事后清点 ==
青篷车:不挂在任何东西下面
倒霉旅人:不挂在任何东西下面两条 WARN 各对应一桩祸:指向自己、指向不存在的实体。处置方式一样——警告一声,把非法的 ChildOf 当场拆掉,程序继续跑。事后清点也证实:两位当事实体都还活着,只是身上的关系组件没了。这是 Bevy 关系机制的一贯姿态:宁可拆掉关系自愈,也不让一笔坏账留在世界里。
但引擎只拦这两种明牌错误。A 当 B 的父亲、B 再当 A 的父亲这种绕圈它不查——环上的遍历会无限循环,这是下下节的注意事项。