這篇的目標要把 bgm、巫師 Boss 的魔法球音效,以及選單點擊音全部整理進事件驅動的 Audio 系統,並且讓音量會隨 GamePhase
自動切換。主要會說明音效資產如何落到 ECS 架構裡,包含常數、資源、系統與插件串接的細節。
這回完成的重點:
assets/sounds/bgm/Space-Cadet.ogg
、enemy/explosion3.ogg
、ui/click.ogg
,集中載入並掛到 SoundEffects
資源。MainThemeMusic
標記與 BackgroundMusicState
,在 GamePhase
之間自動調整 bgm 音量:主選單 0.8、戰鬥 0.45、暫停完全靜音。MenuClickEvent
,UI 音效透過事件轉給 Audio 系統播放。BossWizardSpellCastEvent
,同樣走事件讓音效模組觸發。SoundEffects
原本只存玩家攻擊/撿拾等音效,會擴充成標準的資產容器 (new_demo/src/resources/sound_effects.rs:5
):
#[derive(Resource)]
pub struct SoundEffects {
pub player_attack: Handle<AudioSource>,
pub player_pickup: Handle<AudioSource>,
pub player_upgrade: Handle<AudioSource>,
pub player_hurt: Handle<AudioSource>,
pub enemy_attack: Handle<AudioSource>,
pub door_open: Handle<AudioSource>,
pub door_close: Handle<AudioSource>,
pub boss_wizard_spell: Handle<AudioSource>,
pub ui_click: Handle<AudioSource>,
}
#[derive(Resource, Default)]
pub struct BackgroundMusicState {
pub current_phase: Option<GamePhase>,
}
bgm 的音量數值集中在常數檔 (new_demo/src/constants.rs:43
):
pub const MENU_MUSIC_VOLUME: f32 = 0.8;
pub const GAMEPLAY_MUSIC_VOLUME: f32 = 0.45;
要調整音量或換曲目時,只要修改常數或音效資源即可,避免在系統裡散落魔法數字。
initialize_audio
(new_demo/src/systems/audio.rs:11
)會在 Startup
階段一次載入所有音效,包含 bgm 的音樂檔案 Space-Cadet.ogg
。它也會 spawn 一個帶有 MainThemeMusic
標記的實體來管理背景音樂:
commands.insert_resource(SoundEffects { /* handles... */ });
commands.insert_resource(BackgroundMusicState {
current_phase: Some(session.phase()),
});
let mut settings = PlaybackSettings::LOOP.with_volume(Volume::Linear(match session.phase() {
GamePhase::MainMenu => MENU_MUSIC_VOLUME,
GamePhase::Playing | GamePhase::Paused => GAMEPLAY_MUSIC_VOLUME,
}));
if matches!(session.phase(), GamePhase::Paused) {
settings.paused = true;
}
commands.spawn((MainThemeMusic, AudioPlayer::new(background_music), settings));
真正的「音量調整」由 update_background_music_volume
負責 (new_demo/src/systems/audio.rs:154
)。它會監聽 GameSession
變化,並透過 AudioSink
對同一個播放實體調整音量或暫停:
match session.phase() {
GamePhase::MainMenu => {
sink.unmute();
sink.set_volume(Volume::Linear(MENU_MUSIC_VOLUME));
if sink.is_paused() {
sink.play();
}
}
GamePhase::Playing => {
sink.unmute();
sink.set_volume(Volume::Linear(GAMEPLAY_MUSIC_VOLUME));
if sink.is_paused() {
sink.play();
}
}
GamePhase::Paused => {
sink.set_volume(Volume::Linear(GAMEPLAY_MUSIC_VOLUME));
if !sink.is_paused() {
sink.pause();
}
}
}
BackgroundMusicState::current_phase
紀錄上一次的狀態,避免每幀都重複設定音量。這樣 bgm 進入主選單會恢復正常音量、遊戲中降低、暫停時完全停播,整體效果比單純停掉音效實體來得平順。
主選單與暫停選單的 Button 在被按下時會廣播 MenuClickEvent
(new_demo/src/systems/game_session.rs:38
):
Interaction::Pressed => {
click_events.write(MenuClickEvent);
match button.action {
MainMenuAction::NewGame => start_events.write(StartNewGameEvent),
MainMenuAction::LoadGame => load_events.write(RequestLoadGameEvent { from_main_menu }),
}
}
AudioPlugin
將 play_menu_click_sound
掛在 Update
排程 (new_demo/src/plugins/audio.rs:11
)。系統只要看到事件就播放 sounds/ui/click.ogg
(new_demo/src/systems/audio.rs:134
):
if events.read().next().is_some() {
spawn_one_shot(&mut commands, &sounds.ui_click);
}
好處是 UI 系統與音效完全解耦:未來新增別的選單或設定頁,也只要同樣寫入 MenuClickEvent
就能自動取得音效回饋。
敵人模組新增 BossWizardSpellCastEvent
(new_demo/src/systems/enemy.rs:20
)。boss_wizard_ai_system
在施放魔法球時會同步寫入事件 (new_demo/src/systems/enemy.rs:483
):
if distance <= WIZARD_BOSS_ATTACK_RADIUS && attack.cooldown.finished() {
spawn_wizard_projectile(
&mut commands,
transform.translation,
direction,
attack_stat.value(),
);
spell_events.write(BossWizardSpellCastEvent);
attack.cooldown.reset();
}
EnemyPlugin
負責註冊事件 (new_demo/src/plugins/enemy.rs:10
),音效系統透過 play_boss_wizard_spell_sound
播放 enemy/explosion3.ogg
(new_demo/src/systems/audio.rs:144
)。因為事件只會在真正施法時觸發,避免了冷卻中還會重複播放的問題。
目前有關新增的聲音部分已經完整納入事件流程,任何互動想要導入音效,只要寫入事件並在 Audio 模組註冊對應的 handler 就好。之後如果要新增關卡音樂或戰鬥 transition,可以沿用同一套 MainThemeMusic
標記與 BackgroundMusicState
的模式快速擴充。
因為這篇跟之前音效的部分比較難呈現出來,所以如果想實際體驗的話,建議可以到下方專案連結實際運作。
今日程式碼同步至 repo