借用冲突与 ParamSet
本章开头说过,调度器靠访问声明保证系统之间互不踩脚。现在回答最后的问题:一个系统自己的两个参数撞上了怎么办。
场景是真实的:羊圈里要给伤员加餐,也要给幼崽加餐——两条规则,两个查询,都要写 Hunger:
/// 伤员和幼崽各加一餐——能编译,但一启动就 panic
fn extra_rations(
mut wounded: Query<(&Name, &mut Hunger), With<Wounded>>,
mut young: Query<(&Name, &mut Hunger), With<Young>>,
) {
for (name, mut hunger) in &mut wounded {
hunger.0 -= 1;
println!("{name}(伤员)加餐");
}
for (name, mut hunger) in &mut young {
hunger.0 -= 1;
println!("{name}(幼崽)加餐");
}
}Listing 4-6(节选):两个 &mut Hunger 查询——编译通过,运行 panic
Rust 的借用检查器对此无能为力:在它眼里这只是两个不同类型的参数值,看不出它们背后是 World 里的同一列。但危险是实打实的——羊圈里有只受伤的幼崽小不点,两个查询都匹配它,真让两个 &mut 同时指向它的 Hunger 就违反了别名规则。Bevy 在系统初始化时替借用检查器补上这一刀:
cargo run -p ch04-systems-queries --example listing-04-06error[B0001]: Query<(Name, &mut Hunger), With<Young>> in system
listing_04_06::extra_rations accesses component(s) Hunger in a way that
conflicts with a previous system parameter. Consider using `Without<T>` to
create disjoint Queries or merging conflicting Queries into a `ParamSet`.
See: https://bevy.org/learn/errors/b0001程序在第一帧直接 panic,错误编号 B0001——Bevy 给常见运行期错误编了号,官网能按号查到详解。注意引擎判定冲突的口径:With<Wounded> 和 With<Young> 不能证明两个查询不相交(一个实体完全可以两个标记都有),所以按“都可能访问 Hunger”从严处理。就算羊圈里恰好没有受伤的幼崽,这个系统也照样 panic——判的是声明,不是当前数据。
报错里能看到类型名,是因为本章的 crate 打开了 bevy 的
debugfeature;不开的话所有名字都显示为占位符,排错体验天差地别:toml[dependencies] # debug feature 让运行期报错(如 B0001 借用冲突)带上系统与组件的真实类型名 bevy = { workspace = true, features = ["debug"] }
化解冲突的三板斧
报错信息自己就给了两条出路,加上一条更朴素的,按优先级排:
- 拆成两个系统。一个系统管伤员加餐,一个管幼崽加餐,各自只有一个查询,冲突无从谈起。多数“一个系统里塞了两个
&mut查询”的场面,根源是这个系统揽了两件事——拆开往往是更好的设计,这是首选。 - 用
Without证明不相交。如果两个查询在业务上确实互斥,把互斥写进过滤器:(With<Wounded>, Without<Young>)配With<Young>,引擎一看便知两边不可能撞车,直接放行。但在本例行不通——受伤的幼崽真实存在,加上Without会改变语义:小不点会少吃一餐。 ParamSet。访问真的需要重叠、又不想拆系统时的正解。
ParamSet 把一组互相冲突的参数打包,约束你同一时刻只能用其中一个:
/// 伤员和幼崽各加一餐——ParamSet 化解了两个 &mut Hunger 查询的冲突
fn extra_rations(
mut rations: ParamSet<(
Query<(&Name, &mut Hunger), With<Wounded>>,
Query<(&Name, &mut Hunger), With<Young>>,
)>,
) {
for (name, mut hunger) in rations.p0().iter_mut() {
hunger.0 -= 1;
println!("{name}(伤员)加餐");
}
for (name, mut hunger) in rations.p1().iter_mut() {
hunger.0 -= 1;
println!("{name}(幼崽)加餐");
}
}Listing 4-7(节选):ParamSet 让两个冲突查询分时上岗
成员按位置取:p0()、p1()……最多 8 个。每个 pN() 都可变借用整个 ParamSet,所以第一个查询用完之前,借用检查器不许你碰第二个——冲突从“运行期 panic”变回了 Rust 自己能管的编译期规则。引擎也认这笔账:声明在 ParamSet 里的访问不再触发 B0001。运行:
cargo run -p ch04-systems-queries --example listing-04-07小不点(伤员)加餐
老灰(伤员)加餐
小不点(幼崽)加餐
=== 晚间清点 ===
卷卷 饥饿 3
小不点 饥饿 6
老灰 饥饿 4小不点吃到了两餐(8 → 6)——重叠的实体被两条规则各处理一次,语义分毫不差。这正是 Without 给不了的结果。
拼起来:牧场三日
本章全部内容合成一个程序。羊群吃草,狼挑最弱的下口,牧羊犬护场,伤员静养,羊羔出生——三天的剧本全由查询驱动:
use bevy::prelude::*;
// —— 组件定义 ——
/// 饥饿度;新生羊羔默认 6
#[derive(Component)]
struct Hunger(i32);
impl Default for Hunger {
fn default() -> Self {
Hunger(6)
}
}
/// 生命值;默认 50
#[derive(Component)]
struct Health(i32);
impl Default for Health {
fn default() -> Self {
Health(50)
}
}
/// 羊:天生有饥饿度和生命值
#[derive(Component, Default)]
#[require(Hunger, Health)]
struct Sheep;
/// 狼标记
#[derive(Component)]
struct Wolf;
/// 牧羊犬标记
#[derive(Component)]
struct Sheepdog;
/// 受伤标记
#[derive(Component)]
struct Wounded;
fn main() {
let mut app = App::new();
app.add_systems(Startup, spawn_farm).add_systems(
Update,
(
sunrise,
graze,
recovery,
wolf_attack,
sheepdog_guard,
lambing,
register,
nightfall,
)
.chain(),
);
app.update(); // 第 1 天
app.update(); // 第 2 天
app.update(); // 第 3 天
}
// —— Startup:牧场开张 ——
fn spawn_farm(mut commands: Commands) {
commands.spawn((Name::new("小白"), Sheep, Hunger(6), Health(60)));
commands.spawn((Name::new("小黑"), Sheep, Hunger(6), Health(55)));
commands.spawn((Name::new("卷卷"), Sheep, Hunger(4), Health(40)));
commands.spawn((Name::new("阿黄"), Sheepdog, Health(70)));
commands.spawn((Name::new("灰背"), Wolf, Health(80)));
}
// —— Update:牧场的一天 ——
/// 报晓:Local 记着今天是第几天
fn sunrise(mut day: Local<u32>) {
*day += 1;
println!("—— 第 {} 天 ——", *day);
}
/// 吃草:每只羊饥饿 -1
fn graze(mut flock: Query<&mut Hunger, With<Sheep>>) {
for mut hunger in &mut flock {
hunger.0 -= 1;
}
}
/// 静养:伤员每天恢复 15 点生命,痊愈后摘掉 Wounded
fn recovery(
mut commands: Commands,
mut wounded: Query<(Entity, &Name, &mut Health), With<Wounded>>,
) {
for (entity, name, mut health) in &mut wounded {
health.0 += 15;
if health.0 >= 40 {
println!("{name} 伤愈归队(生命 {})", health.0);
commands.entity(entity).remove::<Wounded>();
} else {
println!("{name} 还在羊圈静养(生命 {})", health.0);
}
}
}
/// 狼袭击:挑生命值最低的健康羊下口
fn wolf_attack(
mut commands: Commands,
wolves: Query<&Name, With<Wolf>>,
mut flock: Query<(Entity, &Name, &mut Health), (With<Sheep>, Without<Wounded>)>,
) {
for wolf_name in &wolves {
// iter() 给出只读视图,先找目标,再用 get_mut 精确下口
let Some((victim, _, _)) = flock.iter().min_by_key(|(_, _, health)| health.0) else {
return;
};
let Ok((_, sheep_name, mut health)) = flock.get_mut(victim) else {
return;
};
health.0 -= 25;
println!("{wolf_name} 咬伤了 {sheep_name}!(生命 {})", health.0);
commands.entity(victim).insert(Wounded);
}
}
/// 牧羊犬护场:发现新伤员就把狼赶出牧场
fn sheepdog_guard(
mut commands: Commands,
dog: Single<&Name, With<Sheepdog>>,
newly_wounded: Query<&Name, Added<Wounded>>,
wolves: Query<(Entity, &Name), With<Wolf>>,
) {
for sheep_name in &newly_wounded {
println!("{} 发现 {sheep_name} 受了伤!", *dog);
for (wolf, wolf_name) in &wolves {
println!("{} 冲出去,把 {wolf_name} 赶出了牧场", *dog);
commands.entity(wolf).despawn();
}
}
}
/// 添丁:全群吃饱(饥饿 ≤ 3)的那天,羊羔出生——只发生一次
fn lambing(mut commands: Commands, flock: Query<&Hunger, With<Sheep>>, mut born: Local<bool>) {
if !*born && flock.iter().all(|hunger| hunger.0 <= 3) {
commands.spawn((Name::new("羊羔"), Sheep));
*born = true;
}
}
/// 登记员:名册添上新来的羊
fn register(newcomers: Query<&Name, Added<Sheep>>) {
for name in &newcomers {
println!("名册新增:{name}");
}
}
/// 夜幕点名:羊、狼、牧羊犬一并清点
fn nightfall(
animals: Query<
(&Name, Option<&Hunger>, &Health, Has<Wounded>),
Or<(With<Sheep>, With<Wolf>, With<Sheepdog>)>,
>,
) {
println!("· 夜幕点名");
for (name, hunger, health, is_wounded) in &animals {
let hunger_text = match hunger {
Some(hunger) => format!("饥饿 {}", hunger.0),
None => "饥饿 —".to_string(),
};
let tag = if is_wounded { "(伤)" } else { "" };
println!(" {name} {hunger_text} 生命 {}{tag}", health.0);
}
}Listing 4-8:完整示例——牧场三日(src/main.rs)
cargo run -p ch04-systems-queries—— 第 1 天 ——
灰背 咬伤了 卷卷!(生命 15)
阿黄 发现 卷卷 受了伤!
阿黄 冲出去,把 灰背 赶出了牧场
名册新增:小白
名册新增:小黑
名册新增:卷卷
· 夜幕点名
阿黄 饥饿 — 生命 70
小白 饥饿 5 生命 60
小黑 饥饿 5 生命 55
卷卷 饥饿 3 生命 15(伤)
—— 第 2 天 ——
卷卷 还在羊圈静养(生命 30)
· 夜幕点名
阿黄 饥饿 — 生命 70
小白 饥饿 4 生命 60
小黑 饥饿 4 生命 55
卷卷 饥饿 2 生命 30(伤)
—— 第 3 天 ——
卷卷 伤愈归队(生命 45)
名册新增:羊羔
· 夜幕点名
阿黄 饥饿 — 生命 70
小白 饥饿 3 生命 60
小黑 饥饿 3 生命 55
卷卷 饥饿 1 生命 45
羊羔 饥饿 6 生命 50对着输出清点本章的工具,每一件都在岗位上:
sunrise和lambing各揣一份Local——天数计数器和“羊羔只生一次”的开关,互不相干;wolf_attack是“先侦察后开刀”的标准现场:iter()只读扫描配min_by_key选出最弱的羊,再get_mut精确咬下去;它的查询用Without<Wounded>放过伤员,狼被赶走后查询为空,循环零次,系统安静待命——空查询不需要任何判空代码;sheepdog_guard用Single锁定唯一的牧羊犬,用Added<Wounded>只对新伤员出动——第 3 章的Commands同步点语义在这里接力:狼咬人排进队列,下一个系统就能看见新标记;- 第 1 天的三条“名册新增”是首帧全新效应的现场重演:
register第一次运行,启动时生成的三只羊对它全是新面孔;第 3 天那条才是真正的新生儿; nightfall一条查询点名全场:Or圈定羊、狼、牧羊犬,Option<&Hunger>容下没有饥饿值的阿黄,Has<Wounded>给卷卷标上“(伤)”;Sheep上的#[require(Hunger, Health)]让羊羔一行spawn((Name, Sheep))就五脏俱全——第 3 章的功课。
还有一件值得一提的事:这个程序没有一处用到 ParamSet。八个系统、十几条查询,靠拆分职责和过滤器就让所有访问各行其道——这是常态。ParamSet 是化解冲突的最后一招,不是日常工具。
小结
- System = 参数全是系统参数的普通函数;签名即访问声明,调度器据此并行。参数顺序任意,最多 16 个
Local<T>:系统私有、跨帧存活的状态;初始值来自Default;不能共享——共享是 Resource 的活app.update()= 跑一帧;Startup只在第一帧运行。手动驱动是纯逻辑实验的利器- D 槽位:
&T必有、Option<&T>可选、Has<T>只问在场、Entity要行号;get/get_mut按 ID 直取(返回Result);single()自己处理“恰好一个”,Single参数失败时静默跳过系统 - F 槽位:元组是且、
Or是或、Without是非,任意嵌套;只筛行不读数据,能用With就别在 D 里写&T - 变更检测:
Added看新挂上,Changed看挂上或写过;窗口是本系统两次运行之间;首帧一切皆新;写访问即变更,不比较值(要比较用set_if_neq) - 两个参数写同一列 → B0001 panic;化解按序:拆系统 →
Without证明不相交 →ParamSet分时复用
练习
- 数据形态:给夜幕点名加一列:用
Has<Sheepdog>在阿黄那行末尾标出“〔犬〕”。再给灰背补一个Hunger(9),看看它在第 1 天点名里的样子(提示:它当晚还在不在场?)。 - 变更检测:给 Listing 4-8 加一个
health_watch系统(排在nightfall之前),用Changed<Health>报告当天生命值有变动的动物。先预测三天各报告谁,再运行验证——第 1 天和第 3 天的名单都值得想清楚。 - 化解冲突:把 Listing 4-7 的
extra_rations拆成feed_wounded和feed_young两个系统(用.chain()保持顺序),确认输出与ParamSet版完全一致。体会一下为什么“拆系统”排在三板斧的第一位。
下一章解决本章 Local 留下的缺口:多个系统要共享的全局数据放哪里?比分、难度设置、随机数种子——它们不属于任何实体,World 里为它们留了另一种住所:Resource。第 2 章那个 Res<Time> 也将正式归队。