Compare commits

...

4 Commits

Author SHA1 Message Date
Ryan Walters
c8f389b163 feat: add pacman death sound 2025-09-06 12:15:08 -05:00
Ryan Walters
9c274de901 feat: setup dying sprites with sprite validation tests 2025-09-06 12:15:08 -05:00
Ryan Walters
9633611ae8 fix: downgrade to codecov-action v4, update escapes pattern, ignore codecov.json, slim codecov config 2025-09-06 12:15:07 -05:00
Ryan Walters
897b9b8621 fix: switch from lcov to codecov.json for Codecov reporting 2025-09-06 12:15:07 -05:00
11 changed files with 201 additions and 62 deletions

View File

@@ -46,11 +46,12 @@ jobs:
- name: Generate coverage report
run: |
just coverage
just coverage-codecov
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./lcov.info
files: ./codecov.json
disable_search: true
verbose: true

1
.gitignore vendored
View File

@@ -14,6 +14,7 @@ assets/site/build.css
# Coverage reports
lcov.info
codecov.json
coverage.html
# Profiling output

View File

@@ -2,8 +2,8 @@ set shell := ["bash", "-c"]
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
# Regex to exclude files from coverage report, double escapes for Justfile + CLI
# You can use src\\\\..., but the filename alone is acceptable too
coverage_exclude_pattern := "src\\\\app\\.rs|audio\\.rs|src\\\\error\\.rs|platform\\\\emscripten\\.rs|bin\\\\.+\\.rs|main\\.rs|platform\\\\desktop\\.rs|platform\\\\tracing_buffer\\.rs|platform\\\\buffered_writer\\.rs|systems\\\\debug\\.rs|systems\\\\profiling\\.rs"
# You can use src/..., but the filename alone is acceptable too
coverage_exclude_pattern := "src/app\\.rs|audio\\.rs|src/error\\.rs|platform/emscripten\\.rs|bin/.+\\.rs|main\\.rs|platform/desktop\\.rs|platform/tracing_buffer\\.rs|platform/buffered_writer\\.rs|systems/debug\\.rs|systems/profiling\\.rs"
binary_extension := if os() == "windows" { ".exe" } else { "" }
@@ -11,7 +11,7 @@ binary_extension := if os() == "windows" { ".exe" } else { "" }
# !!! --remap-path-prefix prevents the absolute path from being used in the generated report
# Generate HTML report (for humans, source line inspection)
html: coverage
html: coverage-lcov
cargo llvm-cov report \
--remap-path-prefix \
--ignore-filename-regex "{{ coverage_exclude_pattern }}" \
@@ -19,13 +19,13 @@ html: coverage
--open
# Display report (for humans)
report-coverage: coverage
report-coverage: coverage-lcov
cargo llvm-cov report \
--remap-path-prefix \
--ignore-filename-regex "{{ coverage_exclude_pattern }}"
# Run & generate report (for CI)
coverage:
# Run & generate LCOV report (as base report)
coverage-lcov:
cargo llvm-cov \
--lcov \
--remap-path-prefix \
@@ -34,6 +34,16 @@ coverage:
--profile coverage \
--no-fail-fast nextest
# Run & generate Codecov report (for CI)
coverage-codecov:
cargo llvm-cov \
--codecov \
--remap-path-prefix \
--ignore-filename-regex "{{ coverage_exclude_pattern }}" \
--output-path codecov.json \
--profile coverage \
--no-fail-fast nextest
# Profile the project using 'samply'
samply:
cargo build --profile profile

View File

Binary file not shown.

View File

@@ -1,17 +1,3 @@
coverage:
status:
project:
default:
target: 70%
patch:
default:
informational: true
ignore:
- "src/(?:bin|platform))/.+\\.rs"
- "src/(?:app|events|formatter)\\.rs"
comment:
layout: "reach,diff,flags,files,footer"
behavior: default
require_changes: false

View File

@@ -19,6 +19,8 @@ pub enum Asset {
AtlasImage,
/// Terminal Vector font for text rendering (TerminalVector.ttf)
Font,
/// Sound effect for Pac-Man's death
DeathSound,
}
impl Asset {
@@ -37,6 +39,7 @@ impl Asset {
Wav4 => "sound/waka/4.ogg",
AtlasImage => "atlas.png",
Font => "TerminalVector.ttf",
DeathSound => "sound/pacman_death.wav",
}
}
}

View File

@@ -16,6 +16,7 @@ const SOUND_ASSETS: [Asset; 4] = [Asset::Wav1, Asset::Wav2, Asset::Wav3, Asset::
pub struct Audio {
_mixer_context: Option<mixer::Sdl2MixerContext>,
sounds: Vec<Chunk>,
death_sound: Option<Chunk>,
next_sound_index: usize,
muted: bool,
disabled: bool,
@@ -44,6 +45,7 @@ impl Audio {
return Self {
_mixer_context: None,
sounds: Vec::new(),
death_sound: None,
next_sound_index: 0,
muted: false,
disabled: true,
@@ -65,6 +67,7 @@ impl Audio {
return Self {
_mixer_context: None,
sounds: Vec::new(),
death_sound: None,
next_sound_index: 0,
muted: false,
disabled: true,
@@ -93,12 +96,33 @@ impl Audio {
}
}
let death_sound = match get_asset_bytes(Asset::DeathSound) {
Ok(data) => match RWops::from_bytes(&data) {
Ok(rwops) => match rwops.load_wav() {
Ok(chunk) => Some(chunk),
Err(e) => {
tracing::warn!("Failed to load death sound from asset API: {}", e);
None
}
},
Err(e) => {
tracing::warn!("Failed to create RWops for death sound: {}", e);
None
}
},
Err(e) => {
tracing::warn!("Failed to load death sound asset: {}", e);
None
}
};
// If no sounds loaded successfully, disable audio
if sounds.is_empty() {
if sounds.is_empty() && death_sound.is_none() {
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,
muted: false,
disabled: true,
@@ -108,6 +132,7 @@ impl Audio {
Audio {
_mixer_context: Some(mixer_context),
sounds,
death_sound,
next_sound_index: 0,
muted: false,
disabled: false,
@@ -138,6 +163,24 @@ impl Audio {
self.next_sound_index = (self.next_sound_index + 1) % self.sounds.len();
}
/// Plays the death sound effect.
pub fn death(&mut self) {
if self.disabled || self.muted {
return;
}
if let Some(chunk) = &self.death_sound {
mixer::Channel::all().play(chunk, 0).ok();
}
}
/// Halts all currently playing audio channels.
pub fn stop_all(&mut self) {
if !self.disabled {
mixer::Channel::all().halt();
}
}
/// Instantly mutes or unmutes all audio channels by adjusting their volume.
///
/// Sets all 4 mixer channels to zero volume when muting, or restores them to

View File

@@ -65,6 +65,7 @@ pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
Asset::Wav4 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/4.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.wav"))),
}
}

View File

@@ -26,6 +26,10 @@ pub struct AudioState {
pub enum AudioEvent {
/// Play the "eat" sound when Pac-Man consumes a pellet
PlayEat,
/// Play the death sound
PlayDeath,
/// Stop all currently playing sounds
StopAll,
}
/// Non-send resource wrapper for SDL2 audio system
@@ -59,6 +63,16 @@ pub fn audio_system(
// 4 eat sounds available
}
}
AudioEvent::PlayDeath => {
if !audio.0.is_disabled() && !audio_state.muted {
audio.0.death();
}
}
AudioEvent::StopAll => {
if !audio.0.is_disabled() {
audio.0.stop_all();
}
}
}
}
}

View File

@@ -15,6 +15,8 @@ pub enum PacmanSprite {
Moving(Direction, u8),
/// The full, closed-mouth Pac-Man sprite.
Full,
/// A single frame of the dying animation.
Dying(u8),
}
/// Represents the color of a frightened ghost.
@@ -60,45 +62,50 @@ impl GameSprite {
/// This path corresponds to the filename in the texture atlas JSON file.
pub fn to_path(self) -> String {
match self {
GameSprite::Pacman(sprite) => match sprite {
PacmanSprite::Moving(dir, frame) => {
let frame_char = match frame {
0 => 'a',
1 => 'b',
_ => panic!("Invalid animation frame"),
};
format!("pacman/{}_{}.png", dir.as_ref().to_lowercase(), frame_char)
GameSprite::Pacman(PacmanSprite::Moving(dir, frame)) => format!(
"pacman/{}_{}.png",
dir.as_ref(),
match frame {
0 => "a",
1 => "b",
_ => panic!("Invalid animation frame"),
}
PacmanSprite::Full => "pacman/full.png".to_string(),
},
GameSprite::Ghost(sprite) => match sprite {
GhostSprite::Normal(ghost, dir, frame) => {
let frame_char = match frame {
0 => 'a',
1 => 'b',
_ => panic!("Invalid animation frame"),
};
format!("ghost/{}/{}_{}.png", ghost.as_str(), dir.as_ref().to_lowercase(), frame_char)
}
GhostSprite::Frightened(color, frame) => {
let frame_char = match frame {
0 => 'a',
1 => 'b',
_ => panic!("Invalid animation frame"),
};
let color_str = match color {
FrightenedColor::Blue => "blue",
FrightenedColor::White => "white",
};
format!("ghost/frightened/{}_{}.png", color_str, frame_char)
}
GhostSprite::Eyes(dir) => format!("ghost/eyes/{}.png", dir.as_ref().to_lowercase()),
},
GameSprite::Maze(sprite) => match sprite {
MazeSprite::Tile(index) => format!("maze/tiles/{}.png", index),
MazeSprite::Pellet => "maze/pellet.png".to_string(),
MazeSprite::Energizer => "maze/energizer.png".to_string(),
},
),
GameSprite::Pacman(PacmanSprite::Full) => "pacman/full.png".to_string(),
GameSprite::Pacman(PacmanSprite::Dying(frame)) => format!("pacman/death/{}.png", frame),
// Ghost sprites
GameSprite::Ghost(GhostSprite::Normal(ghost_type, dir, frame)) => {
let frame_char = match frame {
0 => 'a',
1 => 'b',
_ => panic!("Invalid animation frame"),
};
format!(
"ghost/{}/{}_{}.png",
ghost_type.as_str(),
dir.as_ref().to_lowercase(),
frame_char
)
}
GameSprite::Ghost(GhostSprite::Frightened(color, frame)) => {
let frame_char = match frame {
0 => 'a',
1 => 'b',
_ => panic!("Invalid animation frame"),
};
let color_str = match color {
FrightenedColor::Blue => "blue",
FrightenedColor::White => "white",
};
format!("ghost/frightened/{}_{}.png", color_str, frame_char)
}
GameSprite::Ghost(GhostSprite::Eyes(dir)) => format!("ghost/eyes/{}.png", dir.as_ref().to_lowercase()),
// Maze sprites
GameSprite::Maze(MazeSprite::Tile(index)) => format!("maze/tiles/{}.png", index),
GameSprite::Maze(MazeSprite::Pellet) => "maze/pellet.png".to_string(),
GameSprite::Maze(MazeSprite::Energizer) => "maze/energizer.png".to_string(),
}
}
}

73
tests/sprites.rs Normal file
View File

@@ -0,0 +1,73 @@
//! Tests for the sprite path generation.
use pacman::{
game::ATLAS_FRAMES,
map::direction::Direction,
systems::components::Ghost,
texture::sprites::{FrightenedColor, GameSprite, GhostSprite, MazeSprite, PacmanSprite},
};
#[test]
fn test_all_sprite_paths_exist() {
let mut sprites_to_test = Vec::new();
// Pac-Man sprites
for &dir in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] {
for frame in 0..2 {
sprites_to_test.push(GameSprite::Pacman(PacmanSprite::Moving(dir, frame)));
}
}
sprites_to_test.push(GameSprite::Pacman(PacmanSprite::Full));
for frame in 0..=10 {
sprites_to_test.push(GameSprite::Pacman(PacmanSprite::Dying(frame)));
}
// Ghost sprites
for &ghost in &[Ghost::Blinky, Ghost::Pinky, Ghost::Inky, Ghost::Clyde] {
for &dir in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] {
for frame in 0..2 {
sprites_to_test.push(GameSprite::Ghost(GhostSprite::Normal(ghost, dir, frame)));
}
sprites_to_test.push(GameSprite::Ghost(GhostSprite::Eyes(dir)));
}
}
for &color in &[FrightenedColor::Blue, FrightenedColor::White] {
for frame in 0..2 {
sprites_to_test.push(GameSprite::Ghost(GhostSprite::Frightened(color, frame)));
}
}
// Maze sprites
for i in 0..=34 {
sprites_to_test.push(GameSprite::Maze(MazeSprite::Tile(i)));
}
sprites_to_test.push(GameSprite::Maze(MazeSprite::Pellet));
sprites_to_test.push(GameSprite::Maze(MazeSprite::Energizer));
for sprite in sprites_to_test {
let path = sprite.to_path();
assert!(
ATLAS_FRAMES.contains_key(&path),
"Sprite path '{}' does not exist in the atlas.",
path
);
}
}
#[test]
fn test_invalid_sprite_paths_do_not_exist() {
let invalid_sprites = vec![
// An invalid Pac-Man dying frame
GameSprite::Pacman(PacmanSprite::Dying(99)),
// An invalid maze tile
GameSprite::Maze(MazeSprite::Tile(99)),
];
for sprite in invalid_sprites {
let path = sprite.to_path();
assert!(
!ATLAS_FRAMES.contains_key(&path),
"Invalid sprite path '{}' was found in the atlas, but it should not exist.",
path
);
}
}