锣鼓与中场
第 19 章结尾立过三张欠条:碰撞消息驱动 DESPAWN 音效、暂停时两套开关一起拧、GlobalVolume 总闸只管新声的坑。本节连本带利,顺手把暂停也装上——它们必须一起装:暂停的另一半本来就是“声音也得停”。
中场:子状态,不是第四个状态
给 GameState 添一个平级的 Paused?第 10.4 节开场就审过这个方案的死刑理由:在三平级的世界里,Playing → Paused 和 Playing → Menu 都是“离开 Playing”——上一节刚给全场实体挂的 DespawnOnExit(GameState::Playing) 不会区分这两者,按一下暂停,整个台子被引擎当场拆光。暂停的真实形状是“只在局中才存在的小状态机”:
/// 中场:只在一局进行中才存在的小状态机(第 10 章的 SubStates)
#[derive(SubStates, Clone, PartialEq, Eq, Hash, Debug, Default)]
#[source(GameState = GameState::Playing)]
enum IsPaused {
#[default]
Running,
Paused,
}Listing 20-7(其一):IsPaused——随 Playing 而生灭的子状态(examples/listing-20-07.rs)
注册用 add_sub_state::<IsPaused>();随父而生、每局重置回 Running、随父而亡,三条契约第 10.4 节都验过货。钥匙与两道闸:
/// P:中场与开演来回切
fn pause_keys(
keyboard: Res<ButtonInput<KeyCode>>,
paused: Res<State<IsPaused>>,
mut next_paused: ResMut<NextState<IsPaused>>,
) {
if keyboard.just_pressed(KeyCode::KeyP) {
next_paused.set(match paused.get() {
IsPaused::Running => IsPaused::Paused,
IsPaused::Paused => IsPaused::Running,
});
}
}
/// 中场里按 Esc:弃局回后台
fn quit_from_pause(
keyboard: Res<ButtonInput<KeyCode>>,
mut next_state: ResMut<NextState<GameState>>,
) {
if keyboard.just_pressed(KeyCode::Escape) {
println!("场记:这局不打了,瓦留给明儿个。");
next_state.set(GameState::Menu);
}
}
/// 中场的另一半(18.2 的戏台钟):钟也得停
fn hold_clock(mut time: ResMut<Time<Virtual>>) {
time.pause();
}
fn release_clock(mut time: ResMut<Time<Virtual>>) {
time.unpause();
}Listing 20-7(其二):P 切中场,Esc 弃局,钟跟着停
中场要拧的闸不止一道,分工读仔细:
- 规则的闸:固定调度那串系统和
collect_intent的run_if从in_state(GameState::Playing)换成in_state(IsPaused::Running)——第 10.4 节的原话:“一个条件同时表达两层”,Running成立的前提就是Playing; - 时间的闸:
hold_clock按停Time<Virtual>(第 18.2 节的戏台钟)。规则已经被run_if拦了,钟为什么还要停?因为钟管着所有没被拦的时间用户——眼下没有,但哪天你加一只飘字定时器、一团粒子,它们都喝虚拟钟的水,这道闸替未来把门; - 声音的闸:马上就到。19.3 节的事故报告写得明白——戏台钟管不到声卡。
中场幕布照旧一棵 DespawnOnExit(IsPaused::Paused) 的文字树,进 Paused 搭、出 Paused 引擎拆,连“弃局退菜单时幕布谁收”都不用操心——子状态随父而亡时,OnExit(Paused) 与清场照样跑(第 10.4 节验过的“死得体面”)。
武场进驻
声音三步走,全是第 19 章的熟路。第一步,备家伙——音效的提货单收进一个资源,BGM 循环起播:
/// 武场的家伙什:全部音效的提货单
#[derive(Resource)]
struct SoundBank {
clack: Handle<AudioSource>,
shatter: Handle<AudioSource>,
drum: Handle<AudioSource>,
win: Handle<AudioSource>,
lose: Handle<AudioSource>,
}
/// 循环 BGM 的标记——总闸调音量时要找到它
#[derive(Component)]
struct Bgm;
/// 开张就位:备好提货单,序曲循环起播
fn setup_band(mut commands: Commands, asset_server: Res<AssetServer>) {
commands.insert_resource(SoundBank {
clack: asset_server.load("sfx/clack.wav"),
shatter: asset_server.load("sfx/shatter.wav"),
drum: asset_server.load("sfx/drum.wav"),
win: asset_server.load("sfx/win.wav"),
lose: asset_server.load("sfx/lose.wav"),
});
commands.spawn((
Bgm,
AudioPlayer::new(asset_server.load("music/changfeng-overture.wav")),
PlaybackSettings::LOOP.with_volume(Volume::Linear(BGM_LEVEL)),
));
}Listing 20-7(其三):SoundBank 与 BGM——《长风渡》序曲接着奏
第二步,听动静敲家伙。第一张欠条在此兑现——Knock 消息流早就在那里,武场只是第三个读者(记分员是第二个):
/// 武场只听 Knock:一声动静一个 DESPAWN 实体(19.2 的标准答案)。
/// clack 一份素材三种用场——速度即音高,凳高墙平瓦裂闷
fn play_knocks(mut knocks: MessageReader<Knock>, bank: Res<SoundBank>, mut commands: Commands) {
for knock in knocks.read() {
let (source, speed) = match knock {
Knock::Wall => (&bank.clack, 1.0),
Knock::Paddle => (&bank.clack, 1.3),
Knock::Crack => (&bank.clack, 0.6),
Knock::Shatter => (&bank.shatter, 1.0),
Knock::Gutter => (&bank.drum, 1.0),
};
commands.spawn((
AudioPlayer::new(source.clone()),
PlaybackSettings::DESPAWN.with_speed(speed),
));
}
}
/// 闭幕一声定音:满堂彩上行,绣球散尽下行
fn verdict_sting(outcome: Res<Outcome>, bank: Res<SoundBank>, mut commands: Commands) {
let source = match *outcome {
Outcome::Cleared => &bank.win,
Outcome::Spilled => &bank.lose,
};
commands.spawn((AudioPlayer::new(source.clone()), PlaybackSettings::DESPAWN));
}Listing 20-7(其四):play_knocks——一声动静一个 DESPAWN 实体
每声锣鼓都是 spawn 一个一次性实体,PlaybackSettings::DESPAWN 播完连壳带子实体自动拆台(19.2 节的标准答案)。再看 clack 那三行:墙、凳、裂瓦用的是同一份素材,只换 with_speed——19.2 节说过变速即变调,一块木头敲出三种音高:凳清脆(1.3)、墙平实(1.0)、筒瓦掉釉发闷(0.6)。verdict_sting 给闭幕定音:进 GameOver 的瞬间读 Outcome,满堂彩上行琶音,绣球散尽下行三叹。
第三步,两道欠条一起还——声卡侧的闸,和总闸的坑:
/// 中场协议的声卡侧(19.3 的教训):戏台钟管不到声卡,sink 的闸自己拧
fn hold_sinks(sinks: Query<&AudioSink>) {
sinks.iter().for_each(AudioSink::pause);
}
fn release_sinks(sinks: Query<&AudioSink>) {
sinks.iter().for_each(AudioSink::play);
}
/// 总闸:-/= 拧 GlobalVolume——新开播的声音自动吃到新值
fn master_dial(keyboard: Res<ButtonInput<KeyCode>>, mut master: ResMut<GlobalVolume>) {
let step = i32::from(keyboard.just_pressed(KeyCode::Equal))
- i32::from(keyboard.just_pressed(KeyCode::Minus));
if step != 0 {
let turned = (master.volume.to_linear() + step as f32 * 0.1).clamp(0.0, 1.0);
master.volume = Volume::Linear(turned);
println!("老雷:总闸拧到 {turned:.1}。");
}
}
/// 19.4 的坑:总闸不管已经在播的——BGM 这种长寿声音得自己补一遍
fn apply_master(master: Res<GlobalVolume>, mut bgm: Query<&mut AudioSink, With<Bgm>>) {
for mut sink in &mut bgm {
sink.set_volume(Volume::Linear(BGM_LEVEL) * master.volume);
}
}Listing 20-7(其五):hold_sinks/release_sinks 与 GlobalVolume 总闸
hold_sinks 挂在 OnEnter(IsPaused::Paused),把场上每只 AudioSink 逐个按停——这就是 19.3 节那句“暂停要两套开关一起拧”的落地:钟一道、声卡一道(本游戏没开空间音频,不然 SpatialAudioSink 还得再扫一遍,19.5 节的账)。总闸那两个系统则是 19.4 节的坑的标准填法:master_dial 拧 GlobalVolume,新开播的锣鼓自动吃到新值;但已经在播的 BGM 引擎不管,apply_master 挂着 run_if(resource_changed::<GlobalVolume>)(又是第 5 章的闸门)手动补一遍——基准音量乘总闸,Volume 实现了乘法,一行算完。
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "打瓦".into(),
..default()
}),
..default()
}))
.insert_resource(ClearColor(BACKDROP))
.init_state::<GameState>()
.add_sub_state::<IsPaused>()
.init_resource::<Intent>()
.init_resource::<Score>()
.init_resource::<Lives>()
.add_message::<Knock>()
.add_systems(Startup, (rig_camera, setup_band, greet))
.add_systems(OnEnter(GameState::Menu), show_menu)
.add_systems(OnEnter(GameState::Playing), (setup_court, rig_scoreboard))
.add_systems(OnEnter(GameState::GameOver), (show_curtain, verdict_sting))
.add_systems(
OnEnter(IsPaused::Paused),
(show_intermission, hold_clock, hold_sinks),
)
.add_systems(OnExit(IsPaused::Paused), (release_clock, release_sinks))
.add_systems(
RunFixedMainLoop,
collect_intent
.in_set(RunFixedMainLoopSystems::BeforeFixedMainLoop)
.run_if(in_state(IsPaused::Running)),
)
.add_systems(
FixedUpdate,
(
move_paddle,
follow_paddle,
serve_ball,
apply_velocity,
check_collisions,
watch_gutter,
check_cleared,
)
.chain()
.run_if(in_state(IsPaused::Running)),
)
.add_systems(
Update,
(
menu_keys.run_if(in_state(GameState::Menu)),
curtain_keys.run_if(in_state(GameState::GameOver)),
pause_keys.run_if(in_state(GameState::Playing)),
quit_from_pause.run_if(in_state(IsPaused::Paused)),
(
tally,
refresh_scoreboard.run_if(resource_changed::<Score>),
refresh_lives.run_if(resource_changed::<Lives>),
)
.chain(),
play_knocks,
master_dial,
apply_master.run_if(resource_changed::<GlobalVolume>),
),
)
.run();
}Listing 20-7(其六):注册全景——OnEnter(Paused) 三件套,OnExit 两件套
有声开张
cargo run -p ch20-breakout --example listing-20-07老雷:夜戏散了,伙计们后台耍一局《打瓦》——空格开局。
场记:开台——56 片瓦,3 只绣球。
场记:头一片,开张。
场记:一只绣球喂了沟——还剩 2 只。
老雷:总闸拧到 0.9。
老雷:总闸拧到 1.0。
场记:这局不打了,瓦留给明儿个。这回耳朵有事干了:序曲循环铺底,球每撞一下凳是清脆的“哒”、撞墙低半截,瓦碎是一声噼啦的脆响,筒瓦掉釉发出闷闷的一磕;球落沟,一记堂鼓。按 P——整个世界一起停:球钉在半空,曲子停在半拍,幕布压上画面;再按 P,从停下的那一拍接着演,曲子的进度一秒没丢(sink 的 pause 是暂停不是停止,19.3 节的细则)。中场里按 -/=,总闸的台词照走——Update 里没挂 run_if(Running) 的系统不归中场管,这是故意的:音量这种“元操作”就该随时能拧。

Figure 20-9:中场——画面定格、钟停声歇,三道闸各拧各的
游戏全须全尾了:菜单、计分、音效、暂停、胜负,一样不缺。代价也摆在眼前——wc -l 报告,这个单文件已经 817 行。改记分牌要从碰撞代码旁边路过,调音量得先翻过状态机,main() 里那张注册大清单快要一屏放不下。游戏写完了,工程才开始——下一节拆。