盘点日
全章零件组装。分工照旧是本章的总纲:日常归柜台,特权归钥匙——巡逻和预检是普通系统(一个普通查询,一个 &World 只读),剧本用 Commands 排队制造变故,盘点本身是独占系统:SystemState 开柜台点名,resource_scope 写总册,Allow<Disabled> 全员点到,inspect_entity 逐户列家当,最后 world.spawn 当场立牌,同一帧里喇叭宣读。
rust
//! 第 11 章综合示例:灰岩镇的盘点日
//! 普通系统打理日常(只读预检、巡逻),独占系统接管全镇(点名、存档、立牌),
//! 存档用 EntityRef + inspect_entity 逐户列出组件清单——一个最小的检查器
use bevy::app::ScheduleRunnerPlugin;
use bevy::ecs::entity_disabling::Disabled;
use bevy::ecs::system::SystemState;
use bevy::prelude::*;
use std::time::Duration;
/// 常住户
#[derive(Component)]
struct Resident;
/// 借宿者
#[derive(Component)]
struct Lodger;
/// 铺面
#[derive(Component)]
struct Shop;
/// 存粮(袋)
#[derive(Component)]
struct Stock(u32);
/// 公告牌
#[derive(Component)]
struct Notice;
/// 镇公所总册:存档正文
#[derive(Resource, Default)]
struct TownLedger {
lines: Vec<String>,
}
fn main() {
App::new()
.add_plugins(MinimalPlugins.set(ScheduleRunnerPlugin::run_loop(
Duration::from_millis(100),
)))
.init_resource::<TownLedger>()
.add_systems(Startup, settle_in)
.add_systems(
Update,
(banner, patrol, precheck, script, census_day, town_crier).chain(),
)
.run();
println!("(run() 返回,盘点日结束了)");
}
fn settle_in(mut commands: Commands) {
commands.spawn((Resident, Name::new("杂货铺老板"), Stock(40), Shop));
commands.spawn((Resident, Name::new("老蔫儿"), Stock(7)));
commands.spawn((Shop, Name::new("铁匠铺"), Stock(2)));
}
fn banner(mut frame: Local<u32>) {
*frame += 1;
println!("—— 第 {} 帧 ——", *frame);
}
/// 巡逻队:普通查询,挂了 Disabled 的铺子自动从眼皮底下消失
fn patrol(folks: Query<&Name, Or<(With<Resident>, With<Lodger>, With<Shop>)>>) {
let roll: Vec<&str> = folks.iter().map(Name::as_str).collect();
println!(" 巡逻队({} 处亮灯):{}", roll.len(), roll.join("、"));
}
/// 助手的预检:&World 只读看全局——Disabled 只瞒得过查询,瞒不过直接清点
fn precheck(world: &World, mut frame: Local<u32>) {
*frame += 1;
if *frame == 1 || *frame == 3 {
println!(
" 助手:预检——全镇实体 {} 个,待盘点核对。",
world.entities().count_spawned()
);
}
}
/// 剧本:第 2 帧,罗兰投宿、铁匠铺冬歇
fn script(
shops: Query<(Entity, &Name), With<Shop>>,
mut commands: Commands,
mut frame: Local<u32>,
) {
*frame += 1;
if *frame != 2 {
return;
}
println!(" 罗兰背着行李进镇:在杂货铺后屋搭个铺。(spawn)");
commands.spawn((Lodger, Name::new("罗兰"), Stock(3)));
let (smithy, _) = shops.iter().find(|(_, n)| n.as_str() == "铁匠铺").unwrap();
println!(" 铁匠铺:入冬封炉,明春再会。(挂上 Disabled)");
commands.entity(smithy).insert(Disabled);
}
/// 盘点日:独占系统接管全镇——点名、逐户验看、写总册、立公告
fn census_day(
world: &mut World,
desk: &mut SystemState<Query<&Name, With<Resident>>>,
mut frame: Local<u32>,
) {
*frame += 1;
if *frame != 3 {
return;
}
println!(" 艾达:盘点日,全镇静止!(接管 World)");
// 柜台姿势没丢:SystemState 借出常住名册
let residents = desk.get(world).unwrap();
let roll: Vec<&str> = residents.iter().map(Name::as_str).collect();
println!(" 艾达点名(常住):{}。", roll.join("、"));
// 写总册要 &mut 资源,逐户验看要查询——resource_scope 两不耽误
world.resource_scope(|world, mut ledger: Mut<TownLedger>| {
ledger.lines.clear();
// Allow<Disabled>:冬歇的铺子也得入册
let mut everyone =
world.query_filtered::<(Entity, EntityRef), (With<Name>, Allow<Disabled>)>();
for (id, house) in everyone.iter(world) {
let name = house.get::<Name>().unwrap();
let stock = house.get::<Stock>().map(|s| s.0).unwrap_or(0);
// 检查器的核心一招:不知道有什么组件?问档案
let parts: Vec<String> = world
.inspect_entity(id)
.unwrap()
.map(|info| format!("[{}]", info.name().shortname()))
.collect();
let dormant = if house.contains::<Disabled>() { "(冬歇)" } else { "" };
ledger
.lines
.push(format!("{id} {name}{dormant}:存粮 {stock} 袋 {}", parts.join("")));
}
});
// 立公告——独占系统里当场生效
world.spawn((Notice, Name::new("年度盘点完毕")));
let archived = world.resource::<TownLedger>().lines.len();
println!(" 艾达:归档 {archived} 户,公告牌立讫。");
}
/// 喇叭:同一帧里宣读总册——独占系统写下的东西立等可读
fn town_crier(
notices: Query<(), With<Notice>>,
ledger: Res<TownLedger>,
mut exit: MessageWriter<AppExit>,
) {
if notices.is_empty() {
return;
}
println!(" 喇叭:宣读总册——");
for line in &ledger.lines {
println!(" {line}");
}
exit.write(AppExit::Success);
}Listing 11-14:完整示例——灰岩镇的盘点日(src/main.rs)
console
cargo run -p ch11-deep-ecstext
—— 第 1 帧 ——
巡逻队(3 处亮灯):铁匠铺、老蔫儿、杂货铺老板
助手:预检——全镇实体 3 个,待盘点核对。
—— 第 2 帧 ——
巡逻队(3 处亮灯):铁匠铺、老蔫儿、杂货铺老板
罗兰背着行李进镇:在杂货铺后屋搭个铺。(spawn)
铁匠铺:入冬封炉,明春再会。(挂上 Disabled)
—— 第 3 帧 ——
巡逻队(3 处亮灯):老蔫儿、杂货铺老板、罗兰
助手:预检——全镇实体 4 个,待盘点核对。
艾达:盘点日,全镇静止!(接管 World)
艾达点名(常住):老蔫儿、杂货铺老板。
艾达:归档 4 户,公告牌立讫。
喇叭:宣读总册——
0v0 杂货铺老板:存粮 40 袋 [Resident][Name][Stock][Shop]
1v0 老蔫儿:存粮 7 袋 [Resident][Name][Stock]
3v0 罗兰:存粮 3 袋 [Name][Stock][Lodger]
2v0 铁匠铺(冬歇):存粮 2 袋 [Disabled][Name][Stock][Shop]
(run() 返回,盘点日结束了)三处值得回头多看一眼:
- 第 3 帧的三个数字是三种视角。巡逻队报“3 处亮灯”——名单换了血:罗兰进来了、铁匠铺隐身了,可总数碰巧没变,光看它你不会知道镇上发生过什么;助手报“4 个实体”——
count_spawned不走查询,Disabled瞒不过它;总册归档 4 户——Allow<Disabled>让查询也看见全部,还能逐户标注谁在冬歇。给游戏写统计或调试面板时,先想清楚要哪种视角。 - 总册那四行就是一个检查器。事先没人声明过“要读
Lodger”或“要读Shop”,是inspect_entity临场翻档案翻出来的——连[Disabled]都自报家门。把这些行写进文件就是存档的雏形,接上 UI 就是检查器;生态里的 inspector 类插件,地基与这几行无异。剧本第 2 帧排队的两条命令(投宿、挂牌),在第 3 帧帧首的清算里落地,恰好赶上盘点——而艾达自己的公告牌不排队,立讫即见,喇叭同帧宣读。 - 罗兰那行没有
[Resident]。所以艾达点名常住时只有两人应到,总册上他照样入册——“点名”用窄查询,“归档”用全访问,一窄一宽各司其职。另外看门牌:罗兰是3v0,比铁匠铺的2v0大——他是第 2 帧才 spawn 的,行号顺位领的。
钥匙的份量
本章的 API 在日常游戏逻辑里出场不多,这是设计使然:柜台参数能并行、能被调度器排布,钥匙不能。经验法则——每帧跑的逻辑用 Query/Commands,低频特权活(存档、初始化、调试工具、测试)用 World。但从读代码的角度,本章是道分水岭:官方仓库的 examples、生态插件的源码、乃至 Bevy 自身的实现,SystemState、EntityWorldMut、QueryData derive 俯拾皆是,现在它们对你不再是天书。
小结
- 独占系统:参数为
&mut World的系统,运行期间无任何并行;World 方法当场生效,没有命令队列。同伴只能是ExclusiveSystemParam(Local、&mut SystemState等),混入普通参数是编译错误 - World 直接访问:
resource/resource_mut/insert_resource、query/query_filtered(给QueryState,iter(&world)取数据)、write_message、trigger(当场触发);“资源加世界”的借用冲突用resource_scope解 - 实体句柄三档:
EntityRef只读、EntityMut读写数据、EntityWorldMut全权(insert/remove/take/despawn);多户同借自动降档——结构变更会搬家,搬家会作废邻座的句柄;entity()panic,get_entity()给Result - 存储:组件组合相同的实体同住一个 Archetype,数据默认在 Table 里列式连续——查询快的根源;insert/remove 即搬家;
#[component(storage = "SparseSet")]换“插拔快、遍历慢”;遍历顺序永远不可依赖 &World与Query<EntityRef>:普通系统里的只读全访问;前者连资源也算读,后者只罩组件——同系统的写参数会撞出 B0002- 打包三件套:
SystemState在独占系统里借出参数(Commands要手动apply);#[derive(SystemParam)]打包参数;#[derive(QueryData)]打包查询行(mutable才许&'static mut,&'static是占位写法) - 变更检测:全局
Tick每系统运行加一;组件带added/changed两印,&mut解引用即盖章;Changed= 印落在(last_run, this_run]窗口;Ref<T>看印不过滤;set_if_neq防误报,bypass_change_detection绕过盖章(慎用),changed_by()报案发位置(需track_location) - Disabled:挂上即对“没提到它”的查询隐身(
DefaultQueryFilters自动补Without);With/Has/Allow三种提法;World 直接访问不受影响;自定义隐身组件要在 App 启动前注册
练习
- 优雅的钉子户:Listing 11-4 里艾达拆棚屋用的是
world.entity_mut(shed).despawn()——名单过期时这行会 panic。改用get_entity_mut重写拆房流程,对已注销的门牌打印一句“查无此户,跳过”,并故意拆两次验证两条分支都走到。 - 档案室寻路:在 Listing 11-7 末尾给罗兰
remove::<Registered>()再remove::<Flagged>(),先预测他会落回哪一册(新开一册,还是回到ArchetypeId(1)?archetypes().len()会不会减少?),运行验证。再想想:这对“频繁插拔组件”的成本意味着什么。 - 错位的窗口:把 Listing 11-11 的
audit挪到script前面((banner, audit, script).chain()),先预测每一帧账房的台词会怎么变——第 2 帧的误报还在吗,第 5 帧的入库哪一帧才被听见?运行对答案,并用“窗口(last_run, this_run]”解释。 - 自家的隐身牌:定义
Hibernating组件并register_disabling_component(提示:App阶段可以拿app.world_mut()调 World 方法),给面包房挂上。验证巡逻队同样失明,再验证Has<Hibernating>的查询能不能看见铁匠铺(它挂的是Disabled)——由此体会“每个隐身组件各自独立”的含义。
第二部分到此收官。从第 3 章的第一个 #[derive(Component)] 到今天的 &mut World,ECS 的地图已经画完整:数据怎么放、逻辑怎么跑、消息怎么传、世界怎么直接上手。下一章进入第三部分——给这个世界一个看得见的样子。先从最基本的问题开始:一个东西在哪儿、朝向哪儿、多大个儿——Transform 与坐标系统。