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

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