Required Components 与 Bundle
第 2 章 spawn(Camera2d) 时说过:表里的列比你写的多——Camera、投影方式等一整套组件被自动补了上来。机制的名字当时就报过:required components(必需组件)。现在拆开看它怎么运作。
声明“有我必有它”
required components 让一个组件类型在定义处声明:凡是挂上我的实体,必须同时有某几个组件;spawn 时缺了哪个,引擎就地补上。声明用 #[require(...)] 属性:
#[derive(Component)]
struct Health(i32);
impl Default for Health {
fn default() -> Self {
Health(30)
}
}
#[derive(Component, Default)]
#[require(Health)]
struct Monster;
#[derive(Component)]
#[require(Monster, Health(120))]
struct Golem;Listing 3-5(节选):用 #[require] 声明必需组件
这十几行声明了两条规则:
Monster要求Health。只写类型名,缺失时用该类型的Default构造——所以我们给Health实现了Default,默认 30 点血。Golem要求Monster和Health(120)。后者是内联构造器:石巨人的默认血量不是 30,而是它自己声明的 120。除了类型名(值),还有更自由的类型名 = 任意表达式形式,以及枚举变体形式(#[require(D::One)])。
而且要求是递归的:Golem 要求 Monster,Monster 又要求 Health——挂上 Golem 的实体最终三者齐备。三种情形各 spawn 一个验证:
fn spawn_creatures(mut commands: Commands) {
// 只给出 Monster:缺的 Health 由引擎用 Default 值补齐
commands.spawn((Name::new("史莱姆"), Monster));
// 手动给出的组件优先,required 构造器让位
commands.spawn((Name::new("史莱姆王"), Monster, Health(99)));
// Golem:递归补上 Monster,Health 用它声明的 120
commands.spawn((Name::new("石巨人"), Golem));
}fn print_roster(monsters: Query<(&Name, &Health), With<Monster>>) {
println!("=== 怪物清单 ===");
for (name, health) in &monsters {
println!("{name} HP {}", health.0);
}
}Listing 3-5(节选):三种 spawn 与按 Monster 筛选的清单
cargo run -p ch03-entities-components --example listing-03-05=== 怪物清单 ===
石巨人 HP 120
史莱姆 HP 30
史莱姆王 HP 99三行输出对应三条规则(顺序又一次不按生成顺序来,意料之中):
- 缺失补齐:史莱姆只 spawn 了
Monster,Health自动补上,值是Default的 30; - 手动优先:史莱姆王手动给了
Health(99),required 构造器让位——你写的永远赢; - 递归生效:石巨人只 spawn 了
Golem,却出现在With<Monster>的清单里,说明Monster被递归补上了,血量用的是Golem声明的 120,而不是Monster链上的默认 30——直接声明比间接声明优先级高。
现在回头看第 2 章就一目了然了。翻开 Bevy 源码:Camera2d 的定义上挂着 #[require(Camera, Projection::Orthographic(...), Frustum = ...)]——三种构造语法全用上了;Sprite 则要求 Transform、Visibility 等。所谓“自动冒出来的列”,全是各类型自己声明的标配。
这个机制的价值在于把“这类东西必须有什么”写在类型定义处,而不是散落在每个 spawn 调用里:调用方少写样板,更重要的是忘不了——你不可能 spawn 出一个没有 Health 的 Monster,就像不可能 spawn 出一个没法渲染的 Sprite。它给了组合式的 ECS 一点“继承”的便利,却没有继承树的僵硬:required 关系是按组件声明的图,不是单根的树。
Bundle:打包一组组件
从第 2 章用到现在的元组,该给个正式名分了。Bundle 是“一组静态确定的组件”的抽象:spawn 和 insert 接受的、remove 摘除的,都是 Bundle。单个组件是 Bundle;至多 15 个成员的元组是 Bundle;元组套元组也是。
元组之外,Bundle 还有具名形式——派生宏 #[derive(Bundle)]:
#[derive(Bundle)]
struct MonsterBundle {
name: Name,
marker: Monster,
health: Health,
}fn spawn_wave(mut commands: Commands) {
commands.spawn(MonsterBundle {
name: Name::new("史莱姆"),
marker: Monster,
health: Health(30),
});
}Listing 3-6(节选):derive(Bundle) 的定义与使用
字段必须全是组件(或嵌套的 Bundle),spawn 时按字段填值。它适合“同一形状反复生成”的场合:出怪表、子弹工厂——把形状定义一次,调用处只填数据。
但要听一句源码文档里的告诫:Bundle 只是组件的集合,不是行为单元。它在 spawn 那一刻就解散成一个个组件,World 里不留任何“这行来自 MonsterBundle”的痕迹——所以也不存在“查询某个 Bundle”的语法,System 永远按组件组合筛选。不要把 Bundle 当成类或抽象边界来设计。
Bundle 和 required components 怎么分工?看表达的内容:
- 类型天生需要什么——用 required components。“怪物必有血条”是
Monster这个类型的内在事实,写在类型上,谁 spawn 都生效。 - 这次调用想打包什么——用元组或
derive(Bundle)。“这一波出怪长这样”是调用点的便利。
Bevy 自己的取舍很能说明问题:0.15 之前引擎 API 充斥着 SpriteBundle、Camera2dBundle 这类打包类型,required components 落地后已全部移除——如今 spawn(Camera2d) 一个组件就够,标配由类型自己声明。
拼起来:地下城的一个回合
本章全部内容合成一个程序:
use bevy::prelude::*;
// —— 组件定义 ——
/// 生命值;Monster 的 required component,默认 30 点
#[derive(Component)]
struct Health(i32);
impl Default for Health {
fn default() -> Self {
Health(30)
}
}
/// 怪物标记:凡有 Monster,必有 Health
#[derive(Component, Default)]
#[require(Health)]
struct Monster;
/// 石巨人:天生是怪物,且自带 120 点生命
#[derive(Component)]
#[require(Monster, Health(120))]
struct Golem;
/// 玩家标记
#[derive(Component)]
struct Player;
/// 负伤标记
#[derive(Component)]
struct Wounded;
fn main() {
App::new()
.add_systems(Startup, spawn_dungeon)
.add_systems(
Update,
(print_roster, sweep, triage, print_survivors).chain(),
)
.run();
}
// —— Startup:搭建地下城 ——
fn spawn_dungeon(mut commands: Commands) {
commands.spawn((Name::new("罗兰"), Player, Health(100)));
commands.spawn((Name::new("史莱姆"), Monster));
commands.spawn((Name::new("史莱姆王"), Monster, Health(99)));
commands.spawn((Name::new("石巨人"), Golem));
commands.spawn_batch(
(1..=3).map(|i| (Name::new(format!("蝙蝠 {i} 号")), Monster, Health(10))),
);
}
// —— Update:一个回合 ——
fn print_roster(creatures: Query<(Entity, &Name, &Health)>) {
println!("=== 开场清单 ===");
for (entity, name, health) in &creatures {
println!("{entity} {name} HP {}", health.0);
}
}
/// 罗兰横扫:每只怪物受 50 点伤害;死者销毁,伤者插上 Wounded
fn sweep(
mut commands: Commands,
mut monsters: Query<(Entity, &Name, &mut Health), With<Monster>>,
) {
for (entity, name, mut health) in &mut monsters {
health.0 -= 50;
if health.0 <= 0 {
println!("{name} 倒下了");
commands.entity(entity).despawn();
} else {
println!("{name} 负伤,剩 {} 点生命", health.0);
commands.entity(entity).insert(Wounded);
}
}
}
/// 牧师治疗:摘除 Wounded 标记
fn triage(mut commands: Commands, wounded: Query<(Entity, &Name), With<Wounded>>) {
for (entity, name) in &wounded {
println!("牧师治疗了 {name}");
commands.entity(entity).remove::<Wounded>();
}
}
fn print_survivors(survivors: Query<(Entity, &Name, &Health)>) {
println!("=== 战后清点 ===");
for (entity, name, health) in &survivors {
println!("{entity} {name} HP {}", health.0);
}
}Listing 3-7:完整示例——组件定义、required components、批量生成与一个战斗回合(src/main.rs)
cargo run -p ch03-entities-components=== 开场清单 ===
3v0 石巨人 HP 120
1v0 史莱姆 HP 30
2v0 史莱姆王 HP 99
4v0 蝙蝠 1 号 HP 10
5v0 蝙蝠 2 号 HP 10
6v0 蝙蝠 3 号 HP 10
0v0 罗兰 HP 100
石巨人 负伤,剩 70 点生命
史莱姆 倒下了
史莱姆王 负伤,剩 49 点生命
蝙蝠 1 号 倒下了
蝙蝠 2 号 倒下了
蝙蝠 3 号 倒下了
牧师治疗了 史莱姆王
牧师治疗了 石巨人
=== 战后清点 ===
3v0 石巨人 HP 70
2v0 史莱姆王 HP 49
0v0 罗兰 HP 100逐段读下来应该没有任何陌生面孔:组件定义带着 #[require],地下城用元组和 spawn_batch 搭建,回合里值修改直接写、结构修改走 Commands,四个系统用 .chain() 串成固定顺序。With<Monster> 让罗兰免于被自己横扫——标记组件在干筛选的本职。
小结
- 定义组件:
#[derive(Component)],任何自有类型皆可;空结构体是标记组件,“有没有”本身就是信息 - Entity = 行号 + 世代号:轻量、可复制的 ID;行号会复用,世代号防止旧 ID 错认新实体;查询遍历顺序不可依赖
- Commands 是指令队列:结构修改(spawn/despawn/insert/remove)排队到同步点统一应用;组件值修改经
&mut查询当场生效;调度结束必清队 - required components:
#[require(...)]把“类型必备的组件”声明在定义处——缺失补齐、手动优先、递归生效 - Bundle 是组件包:元组或
derive(Bundle);只是集合、不是行为单元,World 里不留打包痕迹
练习
- 新列:给罗兰加一个
Mana(i32)组件(法力值),并让开场清单多打印一列法力。 - 标记:定义
Flying标记组件挂到三只蝙蝠身上,再写一个只清点飞行单位的系统加进链里(提示:照着print_roster改,过滤器换成With<Flying>)。 - require 链:定义
Boss组件,要求Golem且血量 500;只 spawn 一个Boss,验证清单里它的Monster、Health是否齐备、血量是多少;再 spawn 一个手动带Health(1)的Boss,确认手动值胜出。
下一章给 Query 正名。本章它一直在打杂——元组取多列、With 筛行、&mut 改值、连 Entity 都能要——但这只是它能力的零头:过滤器全家、变更检测、单实体快捷方式、借用冲突的化解,全在第 4 章。