公会的一天
全章人马到齐:登记规矩长在 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-observerstext
—— 第 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 先于钩子
练习
- 零改动加戏:给 Listing 8-3 的诅咒之剑再挂第二个专属 observer(比如“诅咒之剑:还有完没完!”),同时给铁剑也配一句专属台词。运行验证:同一实体可以叠多个专属 observer,且原有代码一行不动。
- 顺序侦探:把 Listing 8-6 剧本里的第 3 帧(拆附魔)整段删掉,先在纸上预测第 4、5 帧各会打出哪几行、顺序如何,再运行对答案。提示:第 4 帧的
Add还响吗? - 环为什么没爆:在 Listing 8-7 的
report_glow里加一行commands.entity(add.entity).insert(Flaming);——表面看这构成了Flaming → Glowing → Flaming的死循环。先预测程序的命运,再运行验证,然后解释它为什么活了下来。想清楚之后再回答追问:如果attach_glow监听的不是On<Add, Flaming>而是On<Insert, Flaming>,结局如何?(不必真跑——答案是无限递归直到栈溢出,引擎不会替你检测环路。)
下一章把目光从单个实体放大到实体之间:长戟属于小芙,小芙和坐骑同进退——ChildOf/Children 父子树、自定义关系、级联销毁。你在本章见过的钩子和生命周期事件,正是那套机制的地基。