Skip to content

借用冲突与 ParamSet

本章开头说过,调度器靠访问声明保证系统之间互不踩脚。现在回答最后的问题:一个系统自己的两个参数撞上了怎么办。

场景是真实的:羊圈里要给伤员加餐,也要给幼崽加餐——两条规则,两个查询,都要写 Hunger

rust
/// 伤员和幼崽各加一餐——能编译,但一启动就 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 在系统初始化时替借用检查器补上这一刀:

console
cargo run -p ch04-systems-queries --example listing-04-06
text
error[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 的 debug feature;不开的话所有名字都显示为占位符,排错体验天差地别:

toml
[dependencies]
# debug feature 让运行期报错(如 B0001 借用冲突)带上系统与组件的真实类型名
bevy = { workspace = true, features = ["debug"] }

化解冲突的三板斧

报错信息自己就给了两条出路,加上一条更朴素的,按优先级排:

  1. 拆成两个系统。一个系统管伤员加餐,一个管幼崽加餐,各自只有一个查询,冲突无从谈起。多数“一个系统里塞了两个 &mut 查询”的场面,根源是这个系统揽了两件事——拆开往往是更好的设计,这是首选。
  2. Without 证明不相交。如果两个查询在业务上确实互斥,把互斥写进过滤器:(With<Wounded>, Without<Young>)With<Young>,引擎一看便知两边不可能撞车,直接放行。但在本例行不通——受伤的幼崽真实存在,加上 Without 会改变语义:小不点会少吃一餐。
  3. ParamSet。访问真的需要重叠、又不想拆系统时的正解。

ParamSet 把一组互相冲突的参数打包,约束你同一时刻只能用其中一个

rust
/// 伤员和幼崽各加一餐——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。运行:

console
cargo run -p ch04-systems-queries --example listing-04-07
text
小不点(伤员)加餐
老灰(伤员)加餐
小不点(幼崽)加餐
=== 晚间清点 ===
卷卷  饥饿 3
小不点  饥饿 6
老灰  饥饿 4

小不点吃到了两餐(8 → 6)——重叠的实体被两条规则各处理一次,语义分毫不差。这正是 Without 给不了的结果。

拼起来:牧场三日

本章全部内容合成一个程序。羊群吃草,狼挑最弱的下口,牧羊犬护场,伤员静养,羊羔出生——三天的剧本全由查询驱动:

rust
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)

console
cargo run -p ch04-systems-queries
text
—— 第 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

对着输出清点本章的工具,每一件都在岗位上:

  • sunriselambing 各揣一份 Local——天数计数器和“羊羔只生一次”的开关,互不相干;
  • wolf_attack 是“先侦察后开刀”的标准现场:iter() 只读扫描配 min_by_key 选出最弱的羊,再 get_mut 精确咬下去;它的查询用 Without<Wounded> 放过伤员,狼被赶走后查询为空,循环零次,系统安静待命——空查询不需要任何判空代码;
  • sheepdog_guardSingle 锁定唯一的牧羊犬,用 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 分时复用

练习

  1. 数据形态:给夜幕点名加一列:用 Has<Sheepdog> 在阿黄那行末尾标出“〔犬〕”。再给灰背补一个 Hunger(9),看看它在第 1 天点名里的样子(提示:它当晚还在不在场?)。
  2. 变更检测:给 Listing 4-8 加一个 health_watch 系统(排在 nightfall 之前),用 Changed<Health> 报告当天生命值有变动的动物。先预测三天各报告谁,再运行验证——第 1 天和第 3 天的名单都值得想清楚。
  3. 化解冲突:把 Listing 4-7 的 extra_rations 拆成 feed_woundedfeed_young 两个系统(用 .chain() 保持顺序),确认输出与 ParamSet 版完全一致。体会一下为什么“拆系统”排在三板斧的第一位。

下一章解决本章 Local 留下的缺口:多个系统要共享的全局数据放哪里?比分、难度设置、随机数种子——它们不属于任何实体,World 里为它们留了另一种住所:Resource。第 2 章那个 Res<Time> 也将正式归队。