feat(audio): centralize sound management with proper enum, improved iterator protocols, introduce new sound files

This commit is contained in:
Ryan Walters
2025-09-11 00:38:02 -05:00
parent 08c964c32e
commit 43532dac56
9 changed files with 124 additions and 42 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+56 -10
View File
@@ -2,21 +2,62 @@
//! On desktop, assets are embedded using include_bytes!; on Emscripten, assets are loaded from the filesystem. //! On desktop, assets are embedded using include_bytes!; on Emscripten, assets are loaded from the filesystem.
use std::borrow::Cow; use std::borrow::Cow;
use strum_macros::EnumIter; use std::iter;
use crate::audio::Sound;
/// Enumeration of all game assets with cross-platform loading support. /// Enumeration of all game assets with cross-platform loading support.
/// ///
/// Each variant corresponds to a specific file that can be loaded either from /// Each variant corresponds to a specific file that can be loaded either from
/// binary-embedded data or embedded filesystem (Emscripten). /// binary-embedded data or embedded filesystem (Emscripten).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumIter)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Asset { pub enum Asset {
Waka(u8),
/// Main sprite atlas containing all game graphics (atlas.png) /// Main sprite atlas containing all game graphics (atlas.png)
AtlasImage, AtlasImage,
/// Terminal Vector font for text rendering (TerminalVector.ttf) /// Terminal Vector font for text rendering (TerminalVector.ttf)
Font, Font,
/// Sound effect for Pac-Man's death /// Sound file assets
DeathSound, SoundFile(Sound),
}
use strum::IntoEnumIterator;
impl Asset {
#[allow(dead_code)]
pub fn into_iter() -> AssetIter {
AssetIter {
sound_iter: None,
state: 0,
}
}
}
#[allow(clippy::type_complexity)]
pub struct AssetIter {
sound_iter: Option<iter::Map<<Sound as IntoEnumIterator>::Iterator, fn(Sound) -> Asset>>,
state: u8,
}
impl Iterator for AssetIter {
type Item = Asset;
fn next(&mut self) -> Option<Self::Item> {
match self.state {
0 => {
self.state = 1;
Some(Asset::AtlasImage)
}
1 => {
self.state = 2;
Some(Asset::Font)
}
2 => self
.sound_iter
.get_or_insert_with(|| Sound::iter().map(Asset::SoundFile))
.next(),
_ => None,
}
}
} }
impl Asset { impl Asset {
@@ -29,11 +70,16 @@ impl Asset {
pub fn path(&self) -> &str { pub fn path(&self) -> &str {
use Asset::*; use Asset::*;
match self { match self {
Waka(0) => "sound/pacman/waka/1.ogg", SoundFile(Sound::Waka(0)) => "sound/pacman/waka/1.ogg",
Waka(1) => "sound/pacman/waka/2.ogg", SoundFile(Sound::Waka(1)) => "sound/pacman/waka/2.ogg",
Waka(2) => "sound/pacman/waka/3.ogg", SoundFile(Sound::Waka(2)) => "sound/pacman/waka/3.ogg",
Waka(3..=u8::MAX) => "sound/pacman/waka/4.ogg", SoundFile(Sound::Waka(3..=u8::MAX)) => "sound/pacman/waka/4.ogg",
DeathSound => "sound/pacman/death.ogg", SoundFile(Sound::PacmanDeath) => "sound/pacman/death.ogg",
SoundFile(Sound::ExtraLife) => "sound/pacman/extra_life.ogg",
SoundFile(Sound::Fruit) => "sound/pacman/fruit.ogg",
SoundFile(Sound::Ghost) => "sound/pacman/ghost.ogg",
SoundFile(Sound::Beginning) => "sound/begin.ogg",
SoundFile(Sound::Intermission) => "sound/intermission.ogg",
AtlasImage => "atlas.png", AtlasImage => "atlas.png",
Font => "TerminalVector.ttf", Font => "TerminalVector.ttf",
} }
+54 -25
View File
@@ -1,11 +1,43 @@
//! This module handles the audio playback for the game. //! This module handles the audio playback for the game.
use std::collections::HashMap;
use crate::asset::{get_asset_bytes, Asset}; use crate::asset::{get_asset_bytes, Asset};
use sdl2::{ use sdl2::{
mixer::{self, Chunk, InitFlag, LoaderRWops, AUDIO_S16LSB, DEFAULT_CHANNELS}, mixer::{self, Chunk, InitFlag, LoaderRWops, AUDIO_S16LSB, DEFAULT_CHANNELS},
rwops::RWops, rwops::RWops,
}; };
use strum::IntoEnumIterator;
const SOUND_ASSETS: [Asset; 4] = [Asset::Waka(0), Asset::Waka(1), Asset::Waka(2), Asset::Waka(3)]; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Sound {
Waka(u8),
PacmanDeath,
ExtraLife,
Fruit,
Ghost,
Beginning,
Intermission,
}
impl IntoEnumIterator for Sound {
type Iterator = std::vec::IntoIter<Sound>;
fn iter() -> Self::Iterator {
vec![
Sound::Waka(0),
Sound::Waka(1),
Sound::Waka(2),
Sound::Waka(3),
Sound::PacmanDeath,
Sound::ExtraLife,
Sound::Fruit,
Sound::Ghost,
Sound::Beginning,
Sound::Intermission,
]
.into_iter()
}
}
/// The audio system for the game. /// The audio system for the game.
/// ///
@@ -14,9 +46,8 @@ const SOUND_ASSETS: [Asset; 4] = [Asset::Waka(0), Asset::Waka(1), Asset::Waka(2)
/// functions will silently do nothing. /// functions will silently do nothing.
pub struct Audio { pub struct Audio {
_mixer_context: Option<mixer::Sdl2MixerContext>, _mixer_context: Option<mixer::Sdl2MixerContext>,
sounds: Vec<Chunk>, sounds: HashMap<Sound, Chunk>,
death_sound: Option<Chunk>, next_waka_index: u8,
next_sound_index: usize,
muted: bool, muted: bool,
disabled: bool, disabled: bool,
} }
@@ -54,9 +85,8 @@ impl Audio {
tracing::warn!("Failed to open audio: {}. Audio will be disabled.", e); tracing::warn!("Failed to open audio: {}. Audio will be disabled.", e);
return Self { return Self {
_mixer_context: None, _mixer_context: None,
sounds: Vec::new(), sounds: HashMap::new(),
death_sound: None, next_waka_index: 0u8,
next_sound_index: 0,
muted: false, muted: false,
disabled: true, disabled: true,
}; };
@@ -77,9 +107,8 @@ impl Audio {
tracing::warn!("Failed to initialize SDL2_mixer: {}. Audio will be disabled.", e); tracing::warn!("Failed to initialize SDL2_mixer: {}. Audio will be disabled.", e);
return Self { return Self {
_mixer_context: None, _mixer_context: None,
sounds: Vec::new(), sounds: HashMap::new(),
death_sound: None, next_waka_index: 0u8,
next_sound_index: 0,
muted: false, muted: false,
disabled: true, disabled: true,
}; };
@@ -87,12 +116,14 @@ impl Audio {
}; };
// Try to load sounds, but don't panic if any fail // Try to load sounds, but don't panic if any fail
let mut sounds = Vec::new(); let mut sounds = HashMap::new();
for (i, asset) in SOUND_ASSETS.iter().enumerate() { for (i, asset) in Sound::iter().enumerate() {
match get_asset_bytes(*asset) { match get_asset_bytes(Asset::SoundFile(asset)) {
Ok(data) => match RWops::from_bytes(&data) { Ok(data) => match RWops::from_bytes(&data) {
Ok(rwops) => match rwops.load_wav() { Ok(rwops) => match rwops.load_wav() {
Ok(chunk) => sounds.push(chunk), Ok(chunk) => {
sounds.insert(asset, chunk);
}
Err(e) => { Err(e) => {
tracing::warn!("Failed to load sound {} from asset API: {}", i + 1, e); tracing::warn!("Failed to load sound {} from asset API: {}", i + 1, e);
} }
@@ -107,7 +138,7 @@ impl Audio {
} }
} }
let death_sound = match get_asset_bytes(Asset::DeathSound) { let death_sound = match get_asset_bytes(Asset::SoundFile(Sound::PacmanDeath)) {
Ok(data) => match RWops::from_bytes(&data) { Ok(data) => match RWops::from_bytes(&data) {
Ok(rwops) => match rwops.load_wav() { Ok(rwops) => match rwops.load_wav() {
Ok(chunk) => Some(chunk), Ok(chunk) => Some(chunk),
@@ -132,9 +163,8 @@ impl Audio {
tracing::warn!("No sounds loaded successfully. Audio will be disabled."); tracing::warn!("No sounds loaded successfully. Audio will be disabled.");
return Self { return Self {
_mixer_context: Some(mixer_context), _mixer_context: Some(mixer_context),
sounds: Vec::new(), sounds: HashMap::new(),
death_sound: None, next_waka_index: 0u8,
next_sound_index: 0,
muted: false, muted: false,
disabled: true, disabled: true,
}; };
@@ -143,8 +173,7 @@ impl Audio {
Audio { Audio {
_mixer_context: Some(mixer_context), _mixer_context: Some(mixer_context),
sounds, sounds,
death_sound, next_waka_index: 0u8,
next_sound_index: 0,
muted: false, muted: false,
disabled: false, disabled: false,
} }
@@ -160,17 +189,17 @@ impl Audio {
return; return;
} }
if let Some(chunk) = self.sounds.get(self.next_sound_index) { if let Some(chunk) = self.sounds.get(&Sound::Waka(self.next_waka_index)) {
match mixer::Channel(0).play(chunk, 0) { match mixer::Channel(0).play(chunk, 0) {
Ok(channel) => { Ok(channel) => {
tracing::trace!("Playing sound #{} on channel {:?}", self.next_sound_index + 1, channel); tracing::trace!("Playing sound #{} on channel {:?}", self.next_waka_index + 1, channel);
} }
Err(e) => { Err(e) => {
tracing::warn!("Could not play sound #{}: {}", self.next_sound_index + 1, e); tracing::warn!("Could not play sound #{}: {}", self.next_waka_index + 1, e);
} }
} }
} }
self.next_sound_index = (self.next_sound_index + 1) % self.sounds.len(); self.next_waka_index = (self.next_waka_index + 1) & 3;
} }
/// Plays the death sound effect. /// Plays the death sound effect.
@@ -179,7 +208,7 @@ impl Audio {
return; return;
} }
if let Some(chunk) = &self.death_sound { if let Some(chunk) = self.sounds.get(&Sound::PacmanDeath) {
mixer::Channel::all().play(chunk, 0).ok(); mixer::Channel::all().play(chunk, 0).ok();
} }
} }
+13 -5
View File
@@ -6,6 +6,7 @@ use std::time::Duration;
use rand::rngs::ThreadRng; use rand::rngs::ThreadRng;
use crate::asset::Asset; use crate::asset::Asset;
use crate::audio::Sound;
use crate::error::{AssetError, PlatformError}; use crate::error::{AssetError, PlatformError};
/// Desktop platform implementation. /// Desktop platform implementation.
@@ -59,13 +60,20 @@ pub fn init_console() -> Result<(), PlatformError> {
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> { pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
match asset { match asset {
Asset::Waka(0) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/1.ogg"))), Asset::SoundFile(sound) => match sound {
Asset::Waka(1) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/2.ogg"))), Sound::Waka(0) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/1.ogg"))),
Asset::Waka(2) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/3.ogg"))), Sound::Waka(1) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/2.ogg"))),
Asset::Waka(3..=u8::MAX) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/4.ogg"))), Sound::Waka(2) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/3.ogg"))),
Sound::Waka(3..=u8::MAX) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/4.ogg"))),
Sound::PacmanDeath => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/death.ogg"))),
Sound::ExtraLife => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/extra_life.ogg"))),
Sound::Fruit => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/fruit.ogg"))),
Sound::Ghost => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/ghost.ogg"))),
Sound::Beginning => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/begin.ogg"))),
Sound::Intermission => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/intermission.ogg"))),
},
Asset::AtlasImage => Ok(Cow::Borrowed(include_bytes!("../../assets/game/atlas.png"))), Asset::AtlasImage => Ok(Cow::Borrowed(include_bytes!("../../assets/game/atlas.png"))),
Asset::Font => Ok(Cow::Borrowed(include_bytes!("../../assets/game/TerminalVector.ttf"))), Asset::Font => Ok(Cow::Borrowed(include_bytes!("../../assets/game/TerminalVector.ttf"))),
Asset::DeathSound => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/death.ogg"))),
} }
} }
+1 -2
View File
@@ -1,10 +1,9 @@
use pacman::asset::Asset; use pacman::asset::Asset;
use speculoos::prelude::*; use speculoos::prelude::*;
use strum::IntoEnumIterator;
#[test] #[test]
fn all_asset_paths_exist() { fn all_asset_paths_exist() {
for asset in Asset::iter() { for asset in Asset::into_iter() {
let path = asset.path(); let path = asset.path();
let full_path = format!("assets/game/{}", path); let full_path = format!("assets/game/{}", path);