Skip to content

盘点日

全章零件组装。分工照旧是本章的总纲:日常归柜台,特权归钥匙——巡逻和预检是普通系统(一个普通查询,一个 &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-ecs
text
—— 第 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 自身的实现,SystemStateEntityWorldMutQueryData derive 俯拾皆是,现在它们对你不再是天书。

小结

  • 独占系统:参数为 &mut World 的系统,运行期间无任何并行;World 方法当场生效,没有命令队列。同伴只能是 ExclusiveSystemParamLocal&mut SystemState 等),混入普通参数是编译错误
  • World 直接访问resource/resource_mut/insert_resourcequery/query_filtered(给 QueryStateiter(&world) 取数据)、write_messagetrigger(当场触发);“资源加世界”的借用冲突用 resource_scope
  • 实体句柄三档EntityRef 只读、EntityMut 读写数据、EntityWorldMut 全权(insert/remove/take/despawn);多户同借自动降档——结构变更会搬家,搬家会作废邻座的句柄;entity() panic,get_entity()Result
  • 存储:组件组合相同的实体同住一个 Archetype,数据默认在 Table 里列式连续——查询快的根源;insert/remove 即搬家;#[component(storage = "SparseSet")] 换“插拔快、遍历慢”;遍历顺序永远不可依赖
  • &WorldQuery<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 启动前注册

练习

  1. 优雅的钉子户:Listing 11-4 里艾达拆棚屋用的是 world.entity_mut(shed).despawn()——名单过期时这行会 panic。改用 get_entity_mut 重写拆房流程,对已注销的门牌打印一句“查无此户,跳过”,并故意拆两次验证两条分支都走到。
  2. 档案室寻路:在 Listing 11-7 末尾给罗兰 remove::<Registered>()remove::<Flagged>(),先预测他会落回哪一册(新开一册,还是回到 ArchetypeId(1)archetypes().len() 会不会减少?),运行验证。再想想:这对“频繁插拔组件”的成本意味着什么。
  3. 错位的窗口:把 Listing 11-11 的 audit 挪到 script 前面((banner, audit, script).chain()),先预测每一帧账房的台词会怎么变——第 2 帧的误报还在吗,第 5 帧的入库哪一帧才被听见?运行对答案,并用“窗口 (last_run, this_run]”解释。
  4. 自家的隐身牌:定义 Hibernating 组件并 register_disabling_component(提示:App 阶段可以拿 app.world_mut() 调 World 方法),给面包房挂上。验证巡逻队同样失明,再验证 Has<Hibernating> 的查询能不能看见铁匠铺(它挂的是 Disabled)——由此体会“每个隐身组件各自独立”的含义。

第二部分到此收官。从第 3 章的第一个 #[derive(Component)] 到今天的 &mut World,ECS 的地图已经画完整:数据怎么放、逻辑怎么跑、消息怎么传、世界怎么直接上手。下一章进入第三部分——给这个世界一个看得见的样子。先从最基本的问题开始:一个东西在哪儿、朝向哪儿、多大个儿——Transform 与坐标系统。