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.
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.
///
/// Each variant corresponds to a specific file that can be loaded either from
/// binary-embedded data or embedded filesystem (Emscripten).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumIter)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Asset {
Waka(u8),
/// Main sprite atlas containing all game graphics (atlas.png)
AtlasImage,
/// Terminal Vector font for text rendering (TerminalVector.ttf)
Font,
/// Sound effect for Pac-Man's death
DeathSound,
/// Sound file assets
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 {
@@ -29,11 +70,16 @@ impl Asset {
pub fn path(&self) -> &str {
use Asset::*;
match self {
Waka(0) => "sound/pacman/waka/1.ogg",
Waka(1) => "sound/pacman/waka/2.ogg",
Waka(2) => "sound/pacman/waka/3.ogg",
Waka(3..=u8::MAX) => "sound/pacman/waka/4.ogg",
DeathSound => "sound/pacman/death.ogg",
SoundFile(Sound::Waka(0)) => "sound/pacman/waka/1.ogg",
SoundFile(Sound::Waka(1)) => "sound/pacman/waka/2.ogg",
SoundFile(Sound::Waka(2)) => "sound/pacman/waka/3.ogg",
SoundFile(Sound::Waka(3..=u8::MAX)) => "sound/pacman/waka/4.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",
Font => "TerminalVector.ttf",
}

View File

@@ -1,11 +1,43 @@
//! This module handles the audio playback for the game.
use std::collections::HashMap;
use crate::asset::{get_asset_bytes, Asset};
use sdl2::{
mixer::{self, Chunk, InitFlag, LoaderRWops, AUDIO_S16LSB, DEFAULT_CHANNELS},
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.
///
@@ -14,9 +46,8 @@ const SOUND_ASSETS: [Asset; 4] = [Asset::Waka(0), Asset::Waka(1), Asset::Waka(2)
/// functions will silently do nothing.
pub struct Audio {
_mixer_context: Option<mixer::Sdl2MixerContext>,
sounds: Vec<Chunk>,
death_sound: Option<Chunk>,
next_sound_index: usize,
sounds: HashMap<Sound, Chunk>,
next_waka_index: u8,
muted: bool,
disabled: bool,
}
@@ -54,9 +85,8 @@ impl Audio {
tracing::warn!("Failed to open audio: {}. Audio will be disabled.", e);
return Self {
_mixer_context: None,
sounds: Vec::new(),
death_sound: None,
next_sound_index: 0,
sounds: HashMap::new(),
next_waka_index: 0u8,
muted: false,
disabled: true,
};
@@ -77,9 +107,8 @@ impl Audio {
tracing::warn!("Failed to initialize SDL2_mixer: {}. Audio will be disabled.", e);
return Self {
_mixer_context: None,
sounds: Vec::new(),
death_sound: None,
next_sound_index: 0,
sounds: HashMap::new(),
next_waka_index: 0u8,
muted: false,
disabled: true,
};
@@ -87,12 +116,14 @@ impl Audio {
};
// Try to load sounds, but don't panic if any fail
let mut sounds = Vec::new();
for (i, asset) in SOUND_ASSETS.iter().enumerate() {
match get_asset_bytes(*asset) {
let mut sounds = HashMap::new();
for (i, asset) in Sound::iter().enumerate() {
match get_asset_bytes(Asset::SoundFile(asset)) {
Ok(data) => match RWops::from_bytes(&data) {
Ok(rwops) => match rwops.load_wav() {
Ok(chunk) => sounds.push(chunk),
Ok(chunk) => {
sounds.insert(asset, chunk);
}
Err(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(rwops) => match rwops.load_wav() {
Ok(chunk) => Some(chunk),
@@ -132,9 +163,8 @@ impl Audio {
tracing::warn!("No sounds loaded successfully. Audio will be disabled.");
return Self {
_mixer_context: Some(mixer_context),
sounds: Vec::new(),
death_sound: None,
next_sound_index: 0,
sounds: HashMap::new(),
next_waka_index: 0u8,
muted: false,
disabled: true,
};
@@ -143,8 +173,7 @@ impl Audio {
Audio {
_mixer_context: Some(mixer_context),
sounds,
death_sound,
next_sound_index: 0,
next_waka_index: 0u8,
muted: false,
disabled: false,
}
@@ -160,17 +189,17 @@ impl Audio {
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) {
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) => {
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.
@@ -179,7 +208,7 @@ impl Audio {
return;
}
if let Some(chunk) = &self.death_sound {
if let Some(chunk) = self.sounds.get(&Sound::PacmanDeath) {
mixer::Channel::all().play(chunk, 0).ok();
}
}

View File

@@ -6,6 +6,7 @@ use std::time::Duration;
use rand::rngs::ThreadRng;
use crate::asset::Asset;
use crate::audio::Sound;
use crate::error::{AssetError, PlatformError};
/// 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> {
match asset {
Asset::Waka(0) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/1.ogg"))),
Asset::Waka(1) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/2.ogg"))),
Asset::Waka(2) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/3.ogg"))),
Asset::Waka(3..=u8::MAX) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/4.ogg"))),
Asset::SoundFile(sound) => match sound {
Sound::Waka(0) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/1.ogg"))),
Sound::Waka(1) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/2.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::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 speculoos::prelude::*;
use strum::IntoEnumIterator;
#[test]
fn all_asset_paths_exist() {
for asset in Asset::iter() {
for asset in Asset::into_iter() {
let path = asset.path();
let full_path = format!("assets/game/{}", path);