资源的变更检测
第 4 章用 Changed<Health> 只挑“动过的”行。资源也有同款能力,但形式不同:资源不在实体表里,没有行可筛,所以变更检测不走查询过滤器,而是 Res/ResMut 自带的两个方法——
is_changed():自本系统上次运行以来,这份资源被写过(或刚插入)吗?is_added():自本系统上次运行以来,这份资源是新插入的吗?
判定口径与组件版完全一致:写访问即变更,不比较值。ResMut 一被可变解引用就记账,哪怕写进去的是原值。对策也是第 4 章那位老朋友:
rust
/// 射手:第 2 枪脱靶(+0 分);set_if_neq 让"没变"不留变更记录
fn shoot(mut score: ResMut<Score>, mut round: Local<u32>) {
*round += 1;
let hit = if *round == 2 { 0 } else { 10 };
println!("第 {} 枪:{}", *round, if hit > 0 { "命中" } else { "脱靶" });
let new_total = score.0 + hit;
score.set_if_neq(Score(new_total));
}Listing 5-7(节选):set_if_neq——值没变就不留变更记录
set_if_neq 先用 PartialEq 比较新旧值(所以 Score 这次 derive 了 PartialEq),不同才写入并记账,相同则什么都不发生。记分牌据此偷懒:
rust
/// 记分牌:开机打一次招呼,之后只在分数真的变了时刷新
fn scoreboard(score: Res<Score>) {
if score.is_added() {
println!("记分牌通电,开始计分");
}
if score.is_changed() {
println!("记分牌刷新 → {} 分", score.0);
}
}Listing 5-7(节选):is_changed 只在分数真的变了时刷新
console
cargo run -p ch05-resources --example listing-05-07text
第 1 枪:命中
记分牌通电,开始计分
记分牌刷新 → 10 分
第 2 枪:脱靶
第 3 枪:命中
记分牌刷新 → 20 分三处看点:
- 首帧一切皆新,资源版同样成立:
Score是开赛前insert_resource进来的,第一帧is_added()和is_changed()双双为真——“通电”只此一声; - 第 2 枪脱靶,
set_if_neq(Score(10))发现 10 == 10,不记账——记分牌整帧沉默; - 若把
shoot最后两行换成朴素的score.0 += hit,第 2 枪也会触发“记分牌刷新 → 10 分”——加零也是写,写了就算变。真实游戏里记分牌刷新可能牵扯 UI 重绘,这笔账不省白不省。
变更检测还能再省一步:第 6 章的
run_if(resource_changed::<Score>)可以让scoreboard在分数没变的帧根本不运行,连if都不用进。
拼起来:打靶场四枪
本章全部内容合成一个程序:靶子是实体(每环一个基础分),难度、规则、计分板、双倍卡是资源——每实体数据与全局数据同台分工:
rust
use bevy::prelude::*;
// —— 组件定义 ——
/// 靶环的基础分值——每实体一份
#[derive(Component)]
struct Points(u32);
// —— 资源定义 ——
/// 场地难度——职业场记分翻倍
#[derive(Resource)]
struct Difficulty {
pro: bool,
}
/// 记分规则:全场倍率,开场时由难度算出
#[derive(Resource)]
struct ScoreRules {
multiplier: u32,
}
impl FromWorld for ScoreRules {
fn from_world(world: &mut World) -> Self {
let multiplier = if world.resource::<Difficulty>().pro { 2 } else { 1 };
ScoreRules { multiplier }
}
}
/// 计分板
#[derive(Resource, Default, PartialEq)]
struct Score(u32);
/// 双倍卡:在场即生效
#[derive(Resource)]
struct DoubleCard;
fn main() {
let mut app = App::new();
app.insert_resource(Difficulty { pro: true })
.init_resource::<ScoreRules>() // 依赖 Difficulty,必须排在它之后
.init_resource::<Score>() // Default:0 分开局
.add_systems(Startup, setup_range)
.add_systems(Update, (shoot, stall_keeper, scoreboard).chain());
app.update(); // 第 1 枪
app.update(); // 第 2 枪
app.update(); // 第 3 枪
app.update(); // 第 4 枪
}
// —— Startup:靶场开张 ——
fn setup_range(mut commands: Commands, rules: Res<ScoreRules>) {
commands.spawn_batch([
(Name::new("外环"), Points(2)),
(Name::new("内环"), Points(5)),
(Name::new("红心"), Points(10)),
]);
println!("打靶场开张:职业场,全场 {} 倍记分", rules.multiplier);
}
// —— Update:一轮一枪 ——
/// 射手:命中得分 = 靶环基础分 × 全场倍率 ×(双倍卡在场再 ×2)
fn shoot(
mut score: ResMut<Score>,
rules: Res<ScoreRules>,
card: Option<Res<DoubleCard>>,
targets: Query<(&Name, &Points)>,
mut round: Local<u32>,
) {
*round += 1;
// 剧本:前三枪依次瞄准三个靶环,第 4 枪脱靶
let aim = ["外环", "红心", "内环"].get(*round as usize - 1).copied();
let hit = aim.and_then(|aim| targets.iter().find(|(name, _)| name.as_str() == aim));
let gained = match hit {
Some((name, points)) => {
let double = if card.is_some() { 2 } else { 1 };
let gained = points.0 * rules.multiplier * double;
let tag = if card.is_some() { "(双倍卡生效)" } else { "" };
println!("第 {} 枪:命中 {name},+{gained} 分{tag}", *round);
gained
}
None => {
println!("第 {} 枪:脱靶", *round);
0
}
};
let new_total = score.0 + gained;
score.set_if_neq(Score(new_total));
}
/// 摊主:第 2 枪打中红心后递出双倍卡,用过一枪就收回
fn stall_keeper(mut commands: Commands, mut round: Local<u32>) {
*round += 1;
if *round == 2 {
println!("摊主:红心都让你打中了,这张双倍卡送你!");
commands.insert_resource(DoubleCard);
}
if *round == 3 {
println!("摊主:双倍卡到期,收回了。");
commands.remove_resource::<DoubleCard>();
}
}
/// 记分牌:只在分数真的变了时刷新
fn scoreboard(score: Res<Score>) {
if score.is_changed() {
println!("记分牌 → {} 分", score.0);
}
}Listing 5-8:完整示例——打靶场四枪(src/main.rs)
console
cargo run -p ch05-resourcestext
打靶场开张:职业场,全场 2 倍记分
第 1 枪:命中 外环,+4 分
记分牌 → 4 分
第 2 枪:命中 红心,+20 分
摊主:红心都让你打中了,这张双倍卡送你!
记分牌 → 24 分
第 3 枪:命中 内环,+20 分(双倍卡生效)
摊主:双倍卡到期,收回了。
记分牌 → 44 分
第 4 枪:脱靶对着输出清点本章的工具:
- 构建期的初始化顺序:
Difficulty用insert_resource先就位,ScoreRules的FromWorld紧随其后按难度算出 2 倍率,Score由init_resource按Default给 0 分开局——三行的先后不是巧合,是依赖; setup_range在Startup里既spawn靶子实体又读Res<ScoreRules>——组件归实体,规则归全局,一个系统两头取用;shoot的得分公式同时取三处数据:靶环基础分(组件Points)、全场倍率(资源ScoreRules)、双倍卡行情(Option<Res<DoubleCard>>);- 摊主第 2 枪后发卡、第 3 枪后收卡,命令帧末落地,所以双倍只罩住第 3 枪——运行期插拔资源的现场;
- 第 4 枪脱靶,
set_if_neq加零不记账,is_changed让记分牌全场静默——四枪只刷新三次。
小结
- Resource = World 里按类型存放、全局唯一的数据;
#[derive(Resource)]即可,要求Send + Sync + 'static。每实体一份的数据用 Component,全场一份的用 Resource,只有自己用的记忆用Local Res<T>读、ResMut<T>写;访问声明照常驱动并行调度。同一系统对同一资源一读一写 → B0002 panic,删掉冗余的Res即可- 缺失即 panic:
Res把“资源不存在”当 bug;时有时无是设计时,用Option<Res<T>>分支处理,或If<Res<T>>跳过系统 - 三条注册路径:
App::insert_resource/init_resource构建期立即生效;Plugin 的build里同名调用随add_plugins执行;Commands版本运行期排队、同步点落地。增删资源属于结构修改,“改值直接写、改结构走 Commands”对资源同样成立 insert覆盖,init让位;init_resource的初始值来自FromWorld(实现Default的类型自动获得),from_world能读整个 World——初始化顺序就是书写顺序,谁依赖谁,谁写在后面- 变更检测是方法不是过滤器:
is_changed()/is_added();写访问即变更、首帧一切皆新的口径与组件一致;值没变就别记账,用set_if_neq(要求PartialEq)
练习
- 共享数据:给 Listing 5-1 加一个弹药资源
Bullets(u32),初始 3 发:shoot每枪 -1,没子弹时只打印“没子弹了”不再加分;announce同时播报剩余弹药。跑 5 帧,确认后两帧分数不再涨。体会同一份资源被两个系统一写一读的分工。 - 初始化顺序:不看书,先预测——把 Listing 5-6 职业场那段的
insert_resource与init_resource两行对调会发生什么?什么时机发生?然后运行验证。再试第三种写法:在init_resource::<ScoreRules>()之前手动insert_resource(ScoreRules { bullseye: 99 }),解释输出为什么是 99。 - 变更检测:把 Listing 5-8
shoot末尾的set_if_neq换回score.0 += gained,先预测第 4 枪后记分牌的行为,运行验证;再想想scoreboard里若加一段is_added的欢迎语,它会在第几枪出现,为什么只出现一次。
下一章直面那个被我们反复绕开的问题:系统到底什么时候跑?Startup 和 Update 之外还有哪些调度、.chain() 之外还有什么排序手段、同步点究竟插在哪——Bevy 程序行为的根源,尽在 Schedule。