Skip to content

公会的一天

全章人马到齐:登记规矩长在 Weapon 的钩子上,火焰附魔的装卸交给生命周期 observer,鉴定走 EntityEvent 指名道姓,收工锣是全局 Event——而老板这个 observer 听到锣声,写的是第 7 章的 AppExit 消息。事件与消息在最后一刻交棒:

rust
//! 第 8 章综合示例:公会的一天
//! Weapon 的登记规矩长在组件钩子上;火焰附魔的装卸由生命周期 observer 即时联动;
//! 鉴定走 EntityEvent 指名道姓;收工锣是全局 Event——老板听到就写 AppExit

use bevy::app::ScheduleRunnerPlugin;
use bevy::ecs::lifecycle::HookContext;
use bevy::ecs::world::DeferredWorld;
use bevy::prelude::*;
use std::time::Duration;

/// 武器:一上身就登记、一离身就销册
#[derive(Component)]
#[component(on_add = register_weapon, on_remove = deregister_weapon)]
struct Weapon {
    name: &'static str,
}

/// 火焰附魔
#[derive(Component)]
struct Flaming;

/// 实体事件:鉴定这件武器
#[derive(EntityEvent)]
struct Identified {
    entity: Entity,
}

/// 全局事件:收工锣
#[derive(Event)]
struct GongStruck;

/// 武器登记簿
#[derive(Resource, Default)]
struct Ledger(Vec<&'static str>);

fn main() {
    App::new()
        // 真正的主循环:每 100 毫秒跑一帧,直到读到 AppExit
        .add_plugins(MinimalPlugins.set(ScheduleRunnerPlugin::run_loop(
            Duration::from_millis(100),
        )))
        .init_resource::<Ledger>()
        .add_observer(ignite)
        .add_observer(extinguish)
        .add_observer(appraise)
        .add_observer(close_guild)
        .add_systems(Update, (banner, guild_script).chain())
        .run();

    println!("(run() 返回,公会熄灯)");
}

/// 报幕:让"同一帧"在输出里看得见
fn banner(mut frame: Local<u32>) {
    *frame += 1;
    println!("—— 第 {} 帧 ——", *frame);
}

/// 公会一天的日程,每帧推进一格
fn guild_script(
    weapons: Query<(Entity, &Weapon)>,
    ledger: Res<Ledger>,
    mut commands: Commands,
    mut frame: Local<u32>,
) {
    *frame += 1;
    let find = |name: &str| weapons.iter().find(|(_, w)| w.name == name).map(|(e, _)| e);
    match *frame {
        1 => {
            println!("老锤:开炉!打一把长戟、一把铁剑。");
            commands.spawn(Weapon { name: "长戟" });
            commands.spawn(Weapon { name: "铁剑" });
        }
        2 => {
            println!("附魔师:给长戟上火焰附魔。");
            commands.entity(find("长戟").unwrap()).insert(Flaming);
        }
        3 => {
            println!("委托人:这把长戟,麻烦鉴定一下。");
            commands
                .entity(find("长戟").unwrap())
                .trigger(|entity| Identified { entity });
        }
        4 => {
            println!("附魔师:附魔到期,拆下来。");
            commands.entity(find("长戟").unwrap()).remove::<Flaming>();
        }
        5 => {
            println!("老锤:铁剑卖出去了。出货前在册:{:?}", ledger.0);
            commands.entity(find("铁剑").unwrap()).despawn();
        }
        6 => {
            println!("司仪:当——收工锣响。");
            commands.trigger(GongStruck);
        }
        _ => {}
    }
}

/// 钩子:Weapon 上身即登记
fn register_weapon(mut world: DeferredWorld, ctx: HookContext) {
    let name = world.get::<Weapon>(ctx.entity).unwrap().name;
    world.resource_mut::<Ledger>().0.push(name);
    println!("  账房(钩子):{name} 登记入册。");
}

/// 钩子:Weapon 离身(含销毁)即销册
fn deregister_weapon(mut world: DeferredWorld, ctx: HookContext) {
    let name = world.get::<Weapon>(ctx.entity).unwrap().name;
    world.resource_mut::<Ledger>().0.retain(|n| *n != name);
    println!("  账房(钩子):{name} 销册。");
}

/// 生命周期 observer:火焰附魔装上即点火
fn ignite(add: On<Add, Flaming>, weapons: Query<&Weapon>) {
    println!("  {} 轰地烧了起来!", weapons.get(add.entity).unwrap().name);
}

/// 生命周期 observer:火焰附魔卸下即熄灭
fn extinguish(remove: On<Remove, Flaming>, weapons: Query<&Weapon>) {
    println!("  {} 上的火光熄灭了。", weapons.get(remove.entity).unwrap().name);
}

/// EntityEvent observer:鉴定指名的那件武器
fn appraise(identified: On<Identified>, weapons: Query<&Weapon>, flames: Query<&Flaming>) {
    let Ok(weapon) = weapons.get(identified.entity) else {
        return;
    };
    let verdict = if flames.get(identified.entity).is_ok() {
        "火焰附魔货真价实"
    } else {
        "并无附魔"
    };
    println!("  鉴定师:{}——{}。", weapon.name, verdict);
}

/// 全局 Event observer:听到收工锣就写 AppExit,事件与消息在此交棒
fn close_guild(_gong: On<GongStruck>, mut exit: MessageWriter<AppExit>) {
    println!("  老板:听到锣了,今天到此为止!");
    exit.write(AppExit::Success);
}

Listing 8-10:完整示例——公会的一天(src/main.rs)

console
cargo run -p ch08-events-observers
text
—— 第 1 帧 ——
老锤:开炉!打一把长戟、一把铁剑。
  账房(钩子):长戟 登记入册。
  账房(钩子):铁剑 登记入册。
—— 第 2 帧 ——
附魔师:给长戟上火焰附魔。
  长戟 轰地烧了起来!
—— 第 3 帧 ——
委托人:这把长戟,麻烦鉴定一下。
  鉴定师:长戟——火焰附魔货真价实。
—— 第 4 帧 ——
附魔师:附魔到期,拆下来。
  长戟 上的火光熄灭了。
—— 第 5 帧 ——
老锤:铁剑卖出去了。出货前在册:["长戟", "铁剑"]
  账房(钩子):铁剑 销册。
—— 第 6 帧 ——
司仪:当——收工锣响。
  老板:听到锣了,今天到此为止!
(run() 返回,公会熄灯)

值得回头多看一眼的细节:

  • 全场只有一个“写剧本”的系统guild_script 自始至终只做三类事:spawn/insert/remove/despawn 组件、trigger 事件——它不认识账房、附魔特效、鉴定师、老板中的任何一位。六位反应者全部是被动挂上去的,这正是第 7 章解耦承诺的推模式版本。
  • 第 3 帧的便捷写法commands.entity(目标).trigger(|entity| Identified { entity })——先锁定实体再补全事件,省去手里攥着 Entity 来回传的麻烦。
  • 第 5 帧只有钩子出声。销册是 Weapon 的家务事(钩子),没有任何 observer 监听 Weapon 的去留——上一节的分工表落了地。
  • 第 6 帧的交棒:observer 的参数表里写着 MessageWriter<AppExit>。事件负责“即刻反应”,反应的内容却是投一条消息给 runner 慢慢读——两套机制不是二选一,而是各管一段。

怎么选:通知家族全家福

到此为止,“在变化发生时运行逻辑”这件事,你手里已经有五件工具。选型只看两个问题——多久发生一次,以及需要多快反应

工具模式适合
Message + MessageReader(第 7 章)拉:缓冲两帧,读者各自批量消费高频、可攒批的广播:碰撞、计分、输入流
Event + add_observer推:触发即执行低频、要立即反应的全局时刻:存档完成、关卡解锁
EntityEvent + observe/add_observer推:瞄准单个实体指名道姓的交互:点击、伤害结算、鉴定
生命周期事件 On<Add/Remove/.., T>推:引擎自动触发组件来去引发的联动:装备特效、缓存失效
组件钩子 #[component(on_add = ..)]推:组件固有行为组件自身的不变式:索引维护、配对登记

一条经验性的分界线:每帧都在大量发生的事,用 Message 攒着批处理;偶尔发生、发生了就要立刻办的事,用 Event 和 Observer。拿不准时先想想第 7 章 DJ 的台词——“有没有”比“有几条”重要的场合,往往说明你要的其实是缓冲。

小结

  • Event 是即时事件#[derive(Event)] 定义、无需注册,commands.trigger 走命令队列,应用的那一刻所有匹配 observer 当场同步跑完;没有缓冲、没有游标,错过即不存在
  • Observer 是头号参数为 On<E> 的系统:其余参数随意声明;同一事件可挂任意多个,但相互顺序无保证;observer 的真身是挂着 Observer 组件的实体
  • EntityEvent 带准星entity 字段(或 #[event_target])锁定目标,全局 observer 与 .observe() 挂的专属 observer 都会运行;目标可能已销毁,get + let-else 防御
  • 生命周期事件五件套Add/Insert 管上身(首次/每次),Replace/Remove 管清退(值/有无),Despawn 管销毁;移除类事件运行在数据消失之前;纯改值不触发任何一个
  • observer 触发链在同一个同步点内递归跑完:联动零延迟,但引擎不查环,无限连锁会栈溢出
  • 组件钩子是组件的构造/析构函数fn(DeferredWorld, HookContext) 固定签名,每组件每种至多一个;添加时钩子先于 observer,移除时 observer 先于钩子

练习

  1. 零改动加戏:给 Listing 8-3 的诅咒之剑再挂第二个专属 observer(比如“诅咒之剑:还有完没完!”),同时给铁剑也配一句专属台词。运行验证:同一实体可以叠多个专属 observer,且原有代码一行不动。
  2. 顺序侦探:把 Listing 8-6 剧本里的第 3 帧(拆附魔)整段删掉,先在纸上预测第 4、5 帧各会打出哪几行、顺序如何,再运行对答案。提示:第 4 帧的 Add 还响吗?
  3. 环为什么没爆:在 Listing 8-7 的 report_glow 里加一行 commands.entity(add.entity).insert(Flaming);——表面看这构成了 Flaming → Glowing → Flaming 的死循环。先预测程序的命运,再运行验证,然后解释它为什么活了下来。想清楚之后再回答追问:如果 attach_glow 监听的不是 On<Add, Flaming> 而是 On<Insert, Flaming>,结局如何?(不必真跑——答案是无限递归直到栈溢出,引擎不会替你检测环路。)

下一章把目光从单个实体放大到实体之间:长戟属于小芙,小芙和坐骑同进退——ChildOf/Children 父子树、自定义关系、级联销毁。你在本章见过的钩子和生命周期事件,正是那套机制的地基。