Skip to content

自定义关系:装备槽

上一节末尾的别扭,说破了是个硬约束:一个实体每种组件至多一份,所以一个实体在每种关系里至多有一个目标。长戟要是用 ChildOf 表示“在小芙手里”,那它就没法同时表达别的从属了;更糟的是语义被搅浑——“车上坐几个人”的遍历会把长戟也数进去,小芙下车时(remove::<ChildOf>)武器反倒留在了车上。

“拿在手里”和“坐在车上”就是两种关系,应该用两种组件。好消息是:ChildOf/Children 没有任何引擎特权,它们就是用公开的 Relationship 机制定义出来的。同一套 derive,你也可以用:

rust
/// 关系源:这件物品装备在谁身上(戴在物品实体上)
#[derive(Component)]
#[relationship(relationship_target = Equipment)]
struct EquippedBy(Entity);

/// 关系目标:这个人身上的装备清单(引擎自动维护,字段不公开)
#[derive(Component)]
#[relationship_target(relationship = EquippedBy)]
struct Equipment(Vec<Entity>);

Listing 9-8(节选):一对关系组件——EquippedBy 是事实源,Equipment 是镜像(examples/listing-09-08.rs)

两个 derive 属性互相指认,把两个普通组件配成一对:

  • #[relationship(...)] 标记事实源(对应 ChildOf 的角色):单字段元组结构体,装着目标实体;
  • #[relationship_target(...)] 标记镜像(对应 Children 的角色):装着一个 Vec<Entity>,由引擎维护。镜像组件的字段必须保持私有——这正是“别手工改名单”的纪律在类型层面的体现。

配好之后,第 9-1、9-2 节学的每一招都原样适用,连语法糖都有对应物(children! 宏只是 related! 的缩写):

rust
fn setup(mut commands: Commands) {
    let wagon = commands.spawn((Name::new("青篷车"), Wagon)).id();

    // 小芙:人在车上(ChildOf),装备在身上(Equipment)——两种关系互不相干
    commands.spawn((
        Name::new("小芙"),
        ChildOf(wagon),
        related!(Equipment[Name::new("长戟"), Name::new("护身符")]),
    ));
    commands.spawn((Name::new("罗兰"), ChildOf(wagon)));
}

Listing 9-8(续):小芙同时身处两种关系——ChildOf 管乘坐,Equipment 管装备

转手装备?跟换乘是同一个动作——insert 新的事实源:

rust
/// 转手:给长戟换一个新的 EquippedBy——跟换车是同一个动作
fn handover(everything: Query<(Entity, &Name)>, mut commands: Commands) {
    let spear = find(&everything, "长戟");
    let roland = find(&everything, "罗兰");
    println!("【小芙把长戟交给罗兰】");
    commands.entity(spear).insert(EquippedBy(roland));
}

Listing 9-8(续):reparent 的装备版

console
cargo run -p ch09-relationships --example listing-09-08
text
小芙 │ 在青篷车上 │ 装备:长戟、护身符
罗兰 │ 在青篷车上 │ 装备:(空手)

【小芙把长戟交给罗兰】
小芙 │ 在青篷车上 │ 装备:护身符
罗兰 │ 在青篷车上 │ 装备:长戟

【山路颠簸,护身符摔得粉碎】
罗兰 │ 在青篷车上 │ 装备:长戟
小芙 │ 在青篷车上 │ 装备:(空手)

最后一幕值得多看一眼:护身符被 despawn,小芙的清单自动除名,而且因为清单空了,Equipment 组件整个消失(所以清点系统查的是 Option<&Equipment>)。从插入、转手到销毁清账,自定义关系拿到的是和父子树完全相同的全套服务。

linked_spawn:陪葬还是掉落

只有一件事,上面的 Equipment 没有照搬 Children级联销毁。把主人 despawn 掉,装备会怎样?答案取决于镜像组件 derive 时的一个开关。这次给护卫配两件东西——商队配发的灯笼走普通关系,祖传的腰刀走带 linked_spawn 的关系:

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

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

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

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

Listing 9-9(节选):同一套机制,一个开关的差别(examples/listing-09-09.rs)

rust
fn setup(mut commands: Commands) {
    let guard = commands.spawn(Name::new("护卫老蔫儿")).id();
    commands.spawn((Name::new("公家灯笼"), EquippedBy(guard)));
    commands.spawn((Name::new("祖传腰刀"), SoulboundTo(guard)));
}

/// 合同到期:注销护卫实体
fn dismiss(guards: Query<Entity, With<SoulboundGear>>, mut commands: Commands) {
    println!("【这趟跑完,老蔫儿领钱走人】");
    for guard in &guards {
        commands.entity(guard).despawn();
    }
}

Listing 9-9(续):配发与认主,然后注销主人

text
== 清点 ==
护卫老蔫儿
祖传腰刀(认主)→ 绑定 护卫老蔫儿
公家灯笼(配发)→ 在 护卫老蔫儿 手里

【这趟跑完,老蔫儿领钱走人】
== 清点 ==
公家灯笼

老蔫儿注销后,两件装备命运分岔:

  • 祖传腰刀linked_spawn):随主人一起注销——目标实体销毁时,镜像名单上的源实体全部陪同销毁;
  • 公家灯笼(默认):只是被解除关系,作为无主实体留在世界里,等下一任护卫来领。

现在回头看第 9-2 节就全通了:Children 的级联销毁不是父子树的特权,只是它 derive 时开了 linked_spawn。坠崖的货车带走全车人,和老蔫儿带走祖传腰刀,是同一行代码生效的结果。你设计自己的关系时按语义选边:部件、子弹、特效这类“皮之不存毛将焉附”的关系开它;装备、目标、阵营这类“关系断了各自活”的关系别开。

关系工具箱

自定义关系的配套 API 与父子版一一对应,规律是把 child 换成 related 并补上类型参数:

父子版通用版
children![...]related!(Equipment[...])
with_children(|c| ...)with_related_entities::<EquippedBy>(|c| ...)
with_child(bundle)with_related::<EquippedBy>(bundle)
add_child(e) / add_children(&[e])add_one_related::<EquippedBy>(e) / add_related::<EquippedBy>(&[e])
detach_child(e)remove_related::<EquippedBy>(&[e])
despawn_children()despawn_related::<Equipment>()
iter_descendants(e) 等遍历同名方法,换用你的关系组件做查询数据即可

两个进阶开关,知道存在就好:

  • 镜像的集合不是只能用 Vec<Entity>——EntityHashSet(不关心顺序、频繁增删)、BTreeSet<Entity> 都实现了所需的 trait;甚至可以直接用单个 Entity,得到一对一关系:新源头插入时,旧源头的关系组件会被自动移除(想想“决斗”——同一时刻每人只有一个对手)。
  • 事实源上除了目标实体还可以带别的字段(须实现 Default),用 #[relationship] 属性标出哪个字段是目标。

下一节把全章人马拉上同一条山道,让两种关系的级联首尾相接。