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

BIN
assets/game/sound/begin.ogg Normal file
View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

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",
} }

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();
} }
} }

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"))),
} }
} }

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);