首演之夜
《长风渡》开演。这台戏把全章的零件装回一处,每个角色一个系统,分工与排期如下:
- 琴师:循环序曲压音量起播(19.1),+/- 键走 sink 拧音量(19.4);
- 武场:只听 cue 消息敲家伙——锣与鼓都是
DESPAWN的一次性实体(19.2); - 阿燕:巡台演员兼发声体,梆子开着空间档随她走(19.5);
- 老雷:掌中场——这回吸取 19.3 的教训,戏台钟与全场声音一起停。
武场的戏单先立起来。谁需要锣鼓点,往通道里投一条消息——这正是第 7 章碰碰车场的解耦:DJ 当年喊“砰!”的那个位置,今天换成真锣真鼓,而写读两端的代码形状一点没变:
/// 武场的戏单:谁要锣鼓,投一条消息——第 7 章的解耦,原样照搬
#[derive(Message)]
enum Cue {
/// 台口亮相:锣
Flourish,
/// 剑花:鼓
Sword,
}Listing 19-9(其一):cue 是消息——要什么家伙,类型自己说话(src/main.rs)
/// 写者一:阿燕的台步——走到台口亮相,向武场投一条 cue
fn patrol(
time: Res<Time>,
mut patrols: Query<(&mut Transform, &mut Patrol)>,
mut cues: MessageWriter<Cue>,
) {
for (mut transform, mut patrol) in &mut patrols {
transform.translation.x += patrol.velocity * time.delta_secs();
if transform.translation.x.abs() > LIMIT {
transform.translation.x = transform.translation.x.clamp(-LIMIT, LIMIT);
patrol.velocity = -patrol.velocity;
cues.write(Cue::Flourish);
}
}
}
/// 写者二:剑花的 cue 从键盘来——写者彼此不相识,读者只有一个
fn call_sword(keyboard: Res<ButtonInput<KeyCode>>, mut cues: MessageWriter<Cue>) {
if keyboard.just_pressed(KeyCode::KeyJ) {
cues.write(Cue::Sword);
}
}
/// 读者:武场。听见什么 cue,敲什么家伙——一声一个 DESPAWN 实体
fn play_percussion(
mut cues: MessageReader<Cue>,
percussion: Res<Percussion>,
mut commands: Commands,
) {
for cue in cues.read() {
let (sound, line) = match cue {
Cue::Flourish => (&percussion.gong, "武场:台口亮相——哐!"),
Cue::Sword => (&percussion.drum, "武场:剑花——咚!"),
};
commands.spawn((
AudioPlayer::new(sound.clone()),
PlaybackSettings::DESPAWN,
));
println!("{line}");
}
}Listing 19-9(其二):两个写者互不相识——台步折返发亮相 cue,键盘发剑花 cue;读者只有武场一个
阿燕的台步系统根本不知道世上有锣;键盘系统也不知道。武场收到什么敲什么,一声一个 DESPAWN 实体,散场后台干干净净——第 7 章欠的音效,连本带利还上了。
中场是第二处点题。19.3 节的事故报告写明:戏台钟管不到声卡,暂停要两套开关一起拧。AudioSink 和 SpatialAudioSink 是两个组件,各查各的:
/// 中场:戏台钟与台上所有声音一起停——两套开关在同一个系统里各拧各的
fn intermission(
keyboard: Res<ButtonInput<KeyCode>>,
mut time: ResMut<Time<Virtual>>,
plain_sinks: Query<&AudioSink>,
spatial_sinks: Query<&SpatialAudioSink>,
) {
if keyboard.just_pressed(KeyCode::Space) {
if time.is_paused() {
time.unpause();
plain_sinks.iter().for_each(AudioSink::play);
spatial_sinks.iter().for_each(SpatialAudioSink::play);
println!("老雷:开戏——钟、曲、更声一起回来。");
} else {
time.pause();
plain_sinks.iter().for_each(AudioSink::pause);
spatial_sinks.iter().for_each(SpatialAudioSink::pause);
println!("老雷:中场——这回连琴带梆子全歇。");
}
}
}Listing 19-9(其三):完整的中场——戏台钟、普通 sink、空间 sink,三把闸一起落
完整代码如下,余下的零件(巡台、动画、读数牌)都是前几节与前几章的旧识:
//! Listing 19-9:《长风渡》首演——文武场全编制
//! 琴师奏循环序曲,阿燕提梆子巡台(空间音频),台口亮相与剑花走消息敲锣打鼓;
//! 空格 = 中场/开戏(戏台钟与全部声音一起停),J = 剑花,+/- = 曲子音量。
use bevy::audio::{AudioPlugin, SpatialScale, Volume};
use bevy::prelude::*;
use bevy::sprite::Anchor;
const AUDIO_SCALE: f32 = 1.0 / 100.0;
const EAR_GAP: f32 = 400.0;
const LIMIT: f32 = 420.0;
// ANCHOR: cue
/// 武场的戏单:谁要锣鼓,投一条消息——第 7 章的解耦,原样照搬
#[derive(Message)]
enum Cue {
/// 台口亮相:锣
Flourish,
/// 剑花:鼓
Sword,
}
// ANCHOR_END: cue
#[derive(Component)]
struct Bgm;
#[derive(Component)]
struct Patrol {
velocity: f32,
}
#[derive(Component)]
struct FrameClock(Timer);
#[derive(Component)]
struct Hud;
#[derive(Resource)]
struct Percussion {
gong: Handle<AudioSource>,
drum: Handle<AudioSource>,
}
// ANCHOR: app
fn main() {
App::new()
.add_plugins(
DefaultPlugins
.set(AudioPlugin {
default_spatial_scale: SpatialScale::new_2d(AUDIO_SCALE),
..default()
})
.set(ImagePlugin::default_nearest()),
)
.insert_resource(ClearColor(Color::srgb(0.10, 0.10, 0.14)))
.add_message::<Cue>()
.add_systems(Startup, setup)
.add_systems(
Update,
(
(patrol, call_sword, play_percussion).chain(),
intermission,
tune_strings,
animate,
hud,
),
)
.run();
}
// ANCHOR_END: app
fn setup(
mut commands: Commands,
asset_server: Res<AssetServer>,
mut layouts: ResMut<Assets<TextureAtlasLayout>>,
) {
commands.spawn(Camera2d);
// 文场:琴师的循环序曲,音量先压到六成
commands.spawn((
Bgm,
AudioPlayer::new(asset_server.load("music/changfeng-overture.wav")),
PlaybackSettings::LOOP.with_volume(Volume::Linear(0.6)),
));
// 武场的家伙什:锣与鼓的提货单
commands.insert_resource(Percussion {
gong: asset_server.load("sfx/gong.wav"),
drum: asset_server.load("sfx/drum.wav"),
});
// 台板
commands.spawn((
Sprite {
image: asset_server.load("props/dock-plank.png"),
custom_size: Some(Vec2::new(1400.0, 56.0)),
image_mode: SpriteImageMode::Tiled {
tile_x: true,
tile_y: true,
stretch_value: 4.0,
},
..default()
},
Transform::from_xyz(0.0, -148.0, 0.5),
));
// 阿燕:巡台的发声体——梆子是开了 spatial 的循环声
let layout = layouts.add(TextureAtlasLayout::from_grid(
UVec2::new(32, 40),
6,
2,
None,
None,
));
commands.spawn((
Patrol { velocity: 120.0 },
FrameClock(Timer::from_seconds(0.15, TimerMode::Repeating)),
Sprite::from_atlas_image(
asset_server.load("actors/ayan-sheet.png"),
TextureAtlas { layout, index: 6 },
),
Anchor::BOTTOM_CENTER,
Transform::from_xyz(-LIMIT, -120.0, 2.0).with_scale(Vec3::splat(4.0)),
AudioPlayer::new(asset_server.load("sfx/bangzi-loop.wav")),
PlaybackSettings::LOOP.with_spatial(true),
));
// 观众席正中的一对耳朵
commands.spawn((
SpatialListener::new(EAR_GAP),
Transform::from_xyz(0.0, -260.0, 0.0),
Visibility::default(),
children![
(
Sprite::from_color(Color::srgb(0.86, 0.45, 0.40), Vec2::splat(30.0)),
Transform::from_xyz(-EAR_GAP / 2.0, 0.0, 1.0),
),
(
Sprite::from_color(Color::srgb(0.42, 0.76, 0.70), Vec2::splat(30.0)),
Transform::from_xyz(EAR_GAP / 2.0, 0.0, 1.0),
),
],
));
commands.spawn((
Hud,
Text2d::new(""),
TextFont {
font: FontSource::Handle(asset_server.load("fonts/book-sans-sc-bold.otf")),
font_size: FontSize::Px(28.0),
..default()
},
TextColor(Color::srgb(0.91, 0.88, 0.80)),
Transform::from_xyz(0.0, 300.0, 5.0),
));
commands.spawn((
Text2d::new("空格 中场/开戏 J 剑花 +/- 曲子音量"),
TextFont {
font: FontSource::Handle(asset_server.load("fonts/book-sans-sc-regular.otf")),
font_size: FontSize::Px(22.0),
..default()
},
TextColor(Color::srgb(0.55, 0.57, 0.62)),
Transform::from_xyz(0.0, 244.0, 5.0),
));
println!("老雷:《长风渡》首演。文场起曲,武场听 cue,阿燕巡台。");
}
// ANCHOR: writers
/// 写者一:阿燕的台步——走到台口亮相,向武场投一条 cue
fn patrol(
time: Res<Time>,
mut patrols: Query<(&mut Transform, &mut Patrol)>,
mut cues: MessageWriter<Cue>,
) {
for (mut transform, mut patrol) in &mut patrols {
transform.translation.x += patrol.velocity * time.delta_secs();
if transform.translation.x.abs() > LIMIT {
transform.translation.x = transform.translation.x.clamp(-LIMIT, LIMIT);
patrol.velocity = -patrol.velocity;
cues.write(Cue::Flourish);
}
}
}
/// 写者二:剑花的 cue 从键盘来——写者彼此不相识,读者只有一个
fn call_sword(keyboard: Res<ButtonInput<KeyCode>>, mut cues: MessageWriter<Cue>) {
if keyboard.just_pressed(KeyCode::KeyJ) {
cues.write(Cue::Sword);
}
}
/// 读者:武场。听见什么 cue,敲什么家伙——一声一个 DESPAWN 实体
fn play_percussion(
mut cues: MessageReader<Cue>,
percussion: Res<Percussion>,
mut commands: Commands,
) {
for cue in cues.read() {
let (sound, line) = match cue {
Cue::Flourish => (&percussion.gong, "武场:台口亮相——哐!"),
Cue::Sword => (&percussion.drum, "武场:剑花——咚!"),
};
commands.spawn((
AudioPlayer::new(sound.clone()),
PlaybackSettings::DESPAWN,
));
println!("{line}");
}
}
// ANCHOR_END: writers
// ANCHOR: intermission
/// 中场:戏台钟与台上所有声音一起停——两套开关在同一个系统里各拧各的
fn intermission(
keyboard: Res<ButtonInput<KeyCode>>,
mut time: ResMut<Time<Virtual>>,
plain_sinks: Query<&AudioSink>,
spatial_sinks: Query<&SpatialAudioSink>,
) {
if keyboard.just_pressed(KeyCode::Space) {
if time.is_paused() {
time.unpause();
plain_sinks.iter().for_each(AudioSink::play);
spatial_sinks.iter().for_each(SpatialAudioSink::play);
println!("老雷:开戏——钟、曲、更声一起回来。");
} else {
time.pause();
plain_sinks.iter().for_each(AudioSink::pause);
spatial_sinks.iter().for_each(SpatialAudioSink::pause);
println!("老雷:中场——这回连琴带梆子全歇。");
}
}
}
// ANCHOR_END: intermission
/// 琴师的旋钮(与 Listing 19-7 同款)
fn tune_strings(
keyboard: Res<ButtonInput<KeyCode>>,
mut strings: Query<&mut AudioSink, With<Bgm>>,
) {
let Ok(mut sink) = strings.single_mut() else {
return;
};
let step = i32::from(keyboard.just_pressed(KeyCode::Equal))
- i32::from(keyboard.just_pressed(KeyCode::Minus));
if step != 0 {
let turned = sink.volume().increase_by_percentage(step as f32 * 25.0);
sink.set_volume(turned);
println!("琴师:曲子拧到线性 {:.2}。", turned.to_linear());
}
}
fn animate(time: Res<Time>, mut patrols: Query<(&mut Sprite, &mut FrameClock, &Patrol)>) {
for (mut sprite, mut clock, patrol) in &mut patrols {
if clock.0.tick(time.delta()).just_finished()
&& let Some(atlas) = sprite.texture_atlas.as_mut()
{
atlas.index = if atlas.index >= 11 { 6 } else { atlas.index + 1 };
}
sprite.flip_x = patrol.velocity < 0.0;
}
}
fn hud(
stage: Res<Time<Virtual>>,
strings: Query<&AudioSink, With<Bgm>>,
patrols: Query<&Transform, With<Patrol>>,
mut text: Single<&mut Text2d, With<Hud>>,
) {
let (position, volume) = match strings.single() {
Ok(sink) => (sink.position().as_secs_f32(), sink.volume().to_linear()),
Err(_) => (0.0, 0.0),
};
let where_is_ayan = patrols
.single()
.map(|t| t.translation.x)
.unwrap_or_default();
text.0 = format!(
"{} 曲子 {position:5.1} 秒 / 音量 {volume:.2} 阿燕 x = {where_is_ayan:>4.0}",
if stage.is_paused() { "—— 中场 ——" } else { "—— 开演 ——" },
);
}Listing 19-9(其四):完整示例——《长风渡》首演(src/main.rs)
cargo run -p ch19-audio老雷:《长风渡》首演。文场起曲,武场听 cue,阿燕巡台。
武场:台口亮相——哐!
武场:剑花——咚!
老雷:中场——这回连琴带梆子全歇。
老雷:开戏——钟、曲、更声一起回来。
琴师:曲子拧到线性 0.75。
武场:台口亮相——哐!
Figure 19-7:《长风渡》首演——文场、武场、巡更、观众席的耳朵各就各位
耳机里这台戏是立体的:序曲铺底,阿燕每到台口锣声应声而起,梆子的更声从左舷滑到右舷、近重远轻;空格一按,连钟带声一片寂静,再按一下整台戏原地接着演——曲子的进度一秒没丢。
小结
- 声音是资产,发声是实体:
AudioPlayer(handle)spawn 即播,资产没到货就等到货那刻开播。格式按 feature 开闸——默认只有vorbis(.ogg),wav/mp3/flac自取;没开 feature 的格式能加载(loader 按资产类型选中、只搬字节),到解码才 panicUnrecognizedFormat PlaybackSettings是开播设定单:AudioPlayer的 required component,默认ONCE。四档PlaybackMode——Once播完留壳(不可复用,重播得拆装音频组件),Loop不完,Despawn连子实体拆台(一次性音效的标准答案),Remove卸件留体。speed快慢与音高绑定,一份素材多个音色- 播放中的控制走
AudioSink:引擎开播时在PostUpdate经Commands插上,最早下一帧可查——查询永远备好“还不在”的退路。面板:pause/play/toggle_playback、set_volume(要&mut)、set_speed、position、stop、empty、mute三件套 - 暂停游戏不等于暂停音频:声音的供给线在声卡侧线程,
Time<Virtual>拧不动它。中场 =time.pause()加遍历全部 sinkpause()——两个组件类型都要遍历 Volume有两把尺:Linear是振幅倍数(1.0 原样、SILENT静音),Decibels是对数刻度(0 原样、±6 dB 约一倍);等距的分贝就是等比的线性,滑条与淡入淡出用分贝尺听感才匀。GlobalVolume总闸只乘进新开播的声音,实时总闸要自己遍历 sink 补一遍- 空间音频 = 声像 + 衰减:声源
.with_spatial(true),场上立一个SpatialListener(一对耳朵,全场一个);每只耳朵的份额 = 声像系数 × min(1/d², 1),d 是SpatialScale换算后的“音频单位”——2D 像素世界必须自己定尺(如 1/100),否则全程衰没。位置追踪全自动,控制句柄是另一个组件SpatialAudioSink
练习
- 重播按钮:给 Listing 19-3 加一个 R 键——对最早 spawn 的那具“空壳”执行
commands.entity(e).remove::<AudioSink>(),验证 19.2 节的偏方:锣真的从头再响。再连按两下 R 体会它的别扭处(每次都从头来、同一实体只有一路声音),想想为什么一次性音效还是DESPAWN省心。 - 治本的总闸:给《首演》接上真正的主音量——V 键改
GlobalVolume,再写一个run_if(resource_changed::<GlobalVolume>)(第 5 章的零件)的系统遍历两种 sink 重设音量,验证这回在播的曲子也应声变轻。 - 淡入淡出:中场不要急刹——用一只 0.5 秒的
Timer(第 18 章)驱动Volume::fade_towards,让曲子在中场时滑进寂静、开戏时滑回来。提示:淡出走完再pause(),省得静音了还空转。 - 慢动作的配乐难题:把第 18 章的换挡(
set_relative_speed)接进《首演》,台上慢放时让 BGM 的set_speed跟着同步。听一耳朵就明白代价——变速即变调,半速的序曲低了八度不止。想想商业游戏慢动作时 BGM 为什么通常不跟着慢,或者只把音效(而非音乐)交给变速。 - 听者上路:参考官方
audio/spatial_audio_2d.rs,把《首演》的听者改成方向键可控。阿燕站定不动,你提着耳朵走向她——验证声像与衰减只关心相对几何,谁动都一样。
下一章
到这一章为止,做一个 2D 游戏需要的零件全部到齐:ECS 的世界观(3~11 章)、画面与坐标(12~16 章)、输入(17 章)、时间(18 章)、声音(本章)。第 20 章不再发新零件——从一个空目录开始,把它们组装成一个完整的游戏:带菜单、计分、音效的 Breakout(打砖块)。你在本章学的每一手都会在那里再过一遍实战:碰撞消息驱动 DESPAWN 音效、暂停菜单里两套开关一起拧、音量滑条背着 GlobalVolume 的坑。开工见。