Compare commits

...

9 Commits

22 changed files with 367 additions and 97 deletions

View File

@@ -3,3 +3,10 @@ fail-fast = false
[profile.coverage] [profile.coverage]
status-level = "none" status-level = "none"
[[profile.default.overrides]]
filter = 'test(pacman::game::)'
test-group = 'serial'
[test-groups]
serial = { max-threads = 1 }

View File

@@ -4,13 +4,11 @@ on: ["push", "pull_request"]
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
RUST_TOOLCHAIN: 1.86.0 RUST_TOOLCHAIN: nightly
jobs: jobs:
coverage: coverage:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v5
@@ -48,35 +46,11 @@ jobs:
- name: Generate coverage report - name: Generate coverage report
run: | run: |
just coverage just coverage-lcov
- name: Download Coveralls CLI - name: Coveralls upload
if: ${{ env.COVERALLS_REPO_TOKEN != '' }} uses: coverallsapp/github-action@v2
run: | with:
# use GitHub Releases URL instead of coveralls.io because they can't maintain their own files; it 404s github-token: ${{ secrets.COVERALLS_REPO_TOKEN }}
curl -L https://github.com/coverallsapp/coverage-reporter/releases/download/v0.6.15/coveralls-linux-x86_64.tar.gz | tar -xz -C /usr/local/bin path-to-lcov: lcov.info
debug: true
- name: Upload coverage to Coveralls
if: ${{ env.COVERALLS_REPO_TOKEN != '' }}
run: |
if [ ! -f "lcov.info" ]; then
echo "Error: lcov.info file not found. Coverage generation may have failed."
exit 1
fi
for i in {1..10}; do
echo "Attempt $i: Uploading coverage to Coveralls..."
if coveralls -n report lcov.info; then
echo "Successfully uploaded coverage report."
exit 0
fi
if [ $i -lt 10 ]; then
delay=$((2**i))
echo "Attempt $i failed. Retrying in $delay seconds..."
sleep $delay
fi
done
echo "Failed to upload coverage report after 10 attempts."
exit 1

5
.gitignore vendored
View File

@@ -14,8 +14,13 @@ assets/site/build.css
# Coverage reports # Coverage reports
lcov.info lcov.info
codecov.json
coverage.html coverage.html
# Profiling output # Profiling output
flamegraph.svg flamegraph.svg
/profile.* /profile.*
# temporary
assets/game/sound/*.wav
/*.py

2
Cargo.lock generated
View File

@@ -663,7 +663,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]] [[package]]
name = "pacman" name = "pacman"
version = "0.2.0" version = "0.76.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bevy_ecs", "bevy_ecs",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "pacman" name = "pacman"
version = "0.2.0" version = "0.76.1"
authors = ["Xevion"] authors = ["Xevion"]
edition = "2021" edition = "2021"
rust-version = "1.86.0" rust-version = "1.86.0"
@@ -98,3 +98,6 @@ x86_64-pc-windows-msvc = { triplet = "x64-windows-static-md" }
x86_64-unknown-linux-gnu = { triplet = "x64-linux" } x86_64-unknown-linux-gnu = { triplet = "x64-linux" }
x86_64-apple-darwin = { triplet = "x64-osx" } x86_64-apple-darwin = { triplet = "x64-osx" }
aarch64-apple-darwin = { triplet = "arm64-osx" } aarch64-apple-darwin = { triplet = "arm64-osx" }
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage,coverage_nightly)'] }

View File

@@ -1,9 +1,6 @@
set shell := ["bash", "-c"] set shell := ["bash", "-c"]
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] 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"
binary_extension := if os() == "windows" { ".exe" } else { "" } binary_extension := if os() == "windows" { ".exe" } else { "" }
@@ -14,22 +11,19 @@ binary_extension := if os() == "windows" { ".exe" } else { "" }
html: coverage html: coverage
cargo llvm-cov report \ cargo llvm-cov report \
--remap-path-prefix \ --remap-path-prefix \
--ignore-filename-regex "{{ coverage_exclude_pattern }}" \
--html \ --html \
--open --open
# Display report (for humans) # Display report (for humans)
report-coverage: coverage report-coverage: coverage
cargo llvm-cov report \ cargo llvm-cov report --remap-path-prefix
--remap-path-prefix \
--ignore-filename-regex "{{ coverage_exclude_pattern }}"
# Run & generate report (for CI) # Run & generate LCOV report (as base report)
coverage: coverage:
cargo llvm-cov \ cargo +nightly llvm-cov \
--lcov \ --lcov \
--remap-path-prefix \ --remap-path-prefix \
--ignore-filename-regex "{{ coverage_exclude_pattern }}" \ --workspace \
--output-path lcov.info \ --output-path lcov.info \
--profile coverage \ --profile coverage \
--no-fail-fast nextest --no-fail-fast nextest

View File

@@ -1,16 +1,16 @@
# Pac-Man # Pac-Man
[![Tests Status][badge-test]][test] [![Build Status][badge-build]][build] [![If you're seeing this, Coveralls.io is broken again and it's not my fault.][badge-coverage]][coverage] [![Online Demo][badge-online-demo]][demo] [![Last Commit][badge-last-commit]][commits] [![Tests Status][badge-test]][test] [![Build Status][badge-build]][build] [![Code Coverage][badge-coverage]][coverage] [![Online Demo][badge-online-demo]][demo] [![Last Commit][badge-last-commit]][commits]
[badge-test]: https://github.com/Xevion/Pac-Man/actions/workflows/tests.yaml/badge.svg [badge-test]: https://github.com/Xevion/Pac-Man/actions/workflows/tests.yaml/badge.svg
[badge-build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml/badge.svg [badge-build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml/badge.svg
[badge-coverage]: https://coveralls.io/repos/github/Xevion/Pac-Man/badge.svg?branch=master [badge-coverage]: https://codecov.io/github/Xevion/Pac-Man/branch/master/graph/badge.svg?token=R2RBYUQK3I
[badge-demo]: https://img.shields.io/github/deployments/Xevion/Pac-Man/github-pages?label=GitHub%20Pages [badge-demo]: https://img.shields.io/github/deployments/Xevion/Pac-Man/github-pages?label=GitHub%20Pages
[badge-online-demo]: https://img.shields.io/badge/GitHub%20Pages-Demo-brightgreen [badge-online-demo]: https://img.shields.io/badge/GitHub%20Pages-Demo-brightgreen
[badge-last-commit]: https://img.shields.io/github/last-commit/Xevion/Pac-Man [badge-last-commit]: https://img.shields.io/github/last-commit/Xevion/Pac-Man
[build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml [build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml
[test]: https://github.com/Xevion/Pac-Man/actions/workflows/tests.yaml [test]: https://github.com/Xevion/Pac-Man/actions/workflows/tests.yaml
[coverage]: https://coveralls.io/github/Xevion/Pac-Man?branch=master [coverage]: https://codecov.io/github/Xevion/Pac-Man
[demo]: https://xevion.github.io/Pac-Man/ [demo]: https://xevion.github.io/Pac-Man/
[commits]: https://github.com/Xevion/Pac-Man/commits/master [commits]: https://github.com/Xevion/Pac-Man/commits/master

View File

Binary file not shown.

3
codecov.yml Normal file
View File

@@ -0,0 +1,3 @@
ignore:
- "src/(?:bin|platform))/.+\\.rs"
- "src/(?:app|events|formatter)\\.rs"

View File

@@ -19,6 +19,8 @@ pub enum Asset {
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
DeathSound,
} }
impl Asset { impl Asset {
@@ -37,6 +39,7 @@ impl Asset {
Wav4 => "sound/waka/4.ogg", Wav4 => "sound/waka/4.ogg",
AtlasImage => "atlas.png", AtlasImage => "atlas.png",
Font => "TerminalVector.ttf", 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 { pub struct Audio {
_mixer_context: Option<mixer::Sdl2MixerContext>, _mixer_context: Option<mixer::Sdl2MixerContext>,
sounds: Vec<Chunk>, sounds: Vec<Chunk>,
death_sound: Option<Chunk>,
next_sound_index: usize, next_sound_index: usize,
muted: bool, muted: bool,
disabled: bool, disabled: bool,
@@ -44,6 +45,7 @@ impl Audio {
return Self { return Self {
_mixer_context: None, _mixer_context: None,
sounds: Vec::new(), sounds: Vec::new(),
death_sound: None,
next_sound_index: 0, next_sound_index: 0,
muted: false, muted: false,
disabled: true, disabled: true,
@@ -65,6 +67,7 @@ impl Audio {
return Self { return Self {
_mixer_context: None, _mixer_context: None,
sounds: Vec::new(), sounds: Vec::new(),
death_sound: None,
next_sound_index: 0, next_sound_index: 0,
muted: false, muted: false,
disabled: true, 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 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."); 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: Vec::new(),
death_sound: None,
next_sound_index: 0, next_sound_index: 0,
muted: false, muted: false,
disabled: true, disabled: true,
@@ -108,6 +132,7 @@ impl Audio {
Audio { Audio {
_mixer_context: Some(mixer_context), _mixer_context: Some(mixer_context),
sounds, sounds,
death_sound,
next_sound_index: 0, next_sound_index: 0,
muted: false, muted: false,
disabled: false, disabled: false,
@@ -138,6 +163,24 @@ impl Audio {
self.next_sound_index = (self.next_sound_index + 1) % self.sounds.len(); 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. /// 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 /// Sets all 4 mixer channels to zero volume when muting, or restores them to

View File

@@ -1,3 +1,6 @@
#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
#![cfg_attr(coverage_nightly, coverage(off))]
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use sdl2::event::Event; use sdl2::event::Event;

View File

@@ -1,3 +1,6 @@
#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
#![cfg_attr(coverage_nightly, coverage(off))]
use circular_buffer::CircularBuffer; use circular_buffer::CircularBuffer;
use pacman::constants::CANVAS_SIZE; use pacman::constants::CANVAS_SIZE;
use sdl2::event::Event; use sdl2::event::Event;

View File

@@ -1,14 +1,22 @@
//! Pac-Man game library crate. //! Pac-Man game library crate.
#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
#[cfg_attr(coverage_nightly, coverage(off))]
pub mod app; pub mod app;
pub mod asset; #[cfg_attr(coverage_nightly, coverage(off))]
pub mod audio; pub mod audio;
pub mod constants; #[cfg_attr(coverage_nightly, coverage(off))]
pub mod error; pub mod error;
#[cfg_attr(coverage_nightly, coverage(off))]
pub mod events; pub mod events;
#[cfg_attr(coverage_nightly, coverage(off))]
pub mod formatter; pub mod formatter;
#[cfg_attr(coverage_nightly, coverage(off))]
pub mod platform;
pub mod asset;
pub mod constants;
pub mod game; pub mod game;
pub mod map; pub mod map;
pub mod platform;
pub mod systems; pub mod systems;
pub mod texture; pub mod texture;

View File

@@ -1,20 +1,27 @@
// Note: This disables the console window on Windows. We manually re-attach to the parent terminal or process later on. // Note: This disables the console window on Windows. We manually re-attach to the parent terminal or process later on.
#![windows_subsystem = "windows"] #![windows_subsystem = "windows"]
#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
use crate::{app::App, constants::LOOP_TIME}; use crate::{app::App, constants::LOOP_TIME};
use tracing::info; use tracing::info;
#[cfg_attr(coverage_nightly, coverage(off))]
mod app; mod app;
mod asset; #[cfg_attr(coverage_nightly, coverage(off))]
mod audio; mod audio;
mod constants; #[cfg_attr(coverage_nightly, coverage(off))]
mod error; mod error;
#[cfg_attr(coverage_nightly, coverage(off))]
mod events; mod events;
#[cfg_attr(coverage_nightly, coverage(off))]
mod formatter; mod formatter;
#[cfg_attr(coverage_nightly, coverage(off))]
mod platform;
mod asset;
mod constants;
mod game; mod game;
mod map; mod map;
mod platform;
mod systems; mod systems;
mod texture; mod texture;
@@ -22,6 +29,7 @@ mod texture;
/// ///
/// This function initializes SDL, the window, the game state, and then enters /// This function initializes SDL, the window, the game state, and then enters
/// the main game loop. /// the main game loop.
#[cfg_attr(coverage_nightly, coverage(off))]
pub fn main() { pub fn main() {
// On Windows, this connects output streams to the console dynamically // On Windows, this connects output streams to the console dynamically
// On Emscripten, this connects the subscriber to the browser console // On Emscripten, this connects the subscriber to the browser console

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::Wav4 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/4.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.wav"))),
} }
} }

View File

@@ -26,6 +26,10 @@ pub struct AudioState {
pub enum AudioEvent { pub enum AudioEvent {
/// Play the "eat" sound when Pac-Man consumes a pellet /// Play the "eat" sound when Pac-Man consumes a pellet
PlayEat, PlayEat,
/// Play the death sound
PlayDeath,
/// Stop all currently playing sounds
StopAll,
} }
/// Non-send resource wrapper for SDL2 audio system /// Non-send resource wrapper for SDL2 audio system
@@ -59,6 +63,16 @@ pub fn audio_system(
// 4 eat sounds available // 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

@@ -1,6 +1,5 @@
//! Debug rendering system //! Debug rendering system
use std::cmp::Ordering; #[cfg_attr(coverage_nightly, feature(coverage_attribute))]
use crate::constants::{self, BOARD_PIXEL_OFFSET}; use crate::constants::{self, BOARD_PIXEL_OFFSET};
use crate::map::builder::Map; use crate::map::builder::Map;
use crate::systems::{Collider, CursorPosition, NodeId, Position, SystemTimings}; use crate::systems::{Collider, CursorPosition, NodeId, Position, SystemTimings};
@@ -13,6 +12,7 @@ use sdl2::rect::{Point, Rect};
use sdl2::render::{Canvas, Texture}; use sdl2::render::{Canvas, Texture};
use sdl2::video::Window; use sdl2::video::Window;
use smallvec::SmallVec; use smallvec::SmallVec;
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use tracing::warn; use tracing::warn;
@@ -149,6 +149,7 @@ fn transform_position_with_offset(pos: Vec2, scale: f32) -> IVec2 {
} }
/// Renders timing information in the top-left corner of the screen using the debug text atlas /// Renders timing information in the top-left corner of the screen using the debug text atlas
#[cfg_attr(coverage_nightly, coverage(off))]
fn render_timing_display( fn render_timing_display(
canvas: &mut Canvas<Window>, canvas: &mut Canvas<Window>,
timings: &SystemTimings, timings: &SystemTimings,
@@ -203,6 +204,7 @@ fn render_timing_display(
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
#[cfg_attr(coverage_nightly, coverage(off))]
pub fn debug_render_system( pub fn debug_render_system(
canvas: &mut Canvas<Window>, canvas: &mut Canvas<Window>,
ttf_atlas: &mut TtfAtlasResource, ttf_atlas: &mut TtfAtlasResource,

View File

@@ -3,17 +3,21 @@
//! This module contains all the ECS-related logic, including components, systems, //! This module contains all the ECS-related logic, including components, systems,
//! and resources. //! and resources.
#[cfg_attr(coverage_nightly, coverage(off))]
pub mod audio; pub mod audio;
pub mod blinking; pub mod blinking;
pub mod collision; pub mod collision;
pub mod components; pub mod components;
#[cfg_attr(coverage_nightly, coverage(off))]
pub mod debug; pub mod debug;
pub mod ghost; pub mod ghost;
pub mod input; pub mod input;
pub mod item; pub mod item;
pub mod movement; pub mod movement;
pub mod player; pub mod player;
#[cfg_attr(coverage_nightly, coverage(off))]
pub mod profiling; pub mod profiling;
#[cfg_attr(coverage_nightly, coverage(off))]
pub mod render; pub mod render;
pub mod stage; pub mod stage;

View File

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

115
tests/ttf.rs Normal file
View File

@@ -0,0 +1,115 @@
use pacman::texture::ttf::{TtfAtlas, TtfRenderer};
use sdl2::pixels::Color;
mod common;
#[test]
fn text_width_calculates_correctly_for_empty_string() {
let (mut canvas, texture_creator, _sdl) = common::setup_sdl().unwrap();
let _ttf_context = sdl2::ttf::init().unwrap();
let font = _ttf_context.load_font("assets/game/TerminalVector.ttf", 16).unwrap();
let mut atlas = TtfAtlas::new(&texture_creator, &font).unwrap();
atlas.populate_atlas(&mut canvas, &texture_creator, &font).unwrap();
let renderer = TtfRenderer::new(1.0);
let width = renderer.text_width(&atlas, "");
assert_eq!(width, 0);
}
#[test]
fn text_width_calculates_correctly_for_single_character() {
let (mut canvas, texture_creator, _sdl) = common::setup_sdl().unwrap();
let _ttf_context = sdl2::ttf::init().unwrap();
let font = _ttf_context.load_font("assets/game/TerminalVector.ttf", 16).unwrap();
let mut atlas = TtfAtlas::new(&texture_creator, &font).unwrap();
atlas.populate_atlas(&mut canvas, &texture_creator, &font).unwrap();
let renderer = TtfRenderer::new(1.0);
let width = renderer.text_width(&atlas, "A");
assert!(width > 0);
}
#[test]
fn text_width_scales_correctly() {
let (mut canvas, texture_creator, _sdl) = common::setup_sdl().unwrap();
let _ttf_context = sdl2::ttf::init().unwrap();
let font = _ttf_context.load_font("assets/game/TerminalVector.ttf", 16).unwrap();
let mut atlas = TtfAtlas::new(&texture_creator, &font).unwrap();
atlas.populate_atlas(&mut canvas, &texture_creator, &font).unwrap();
let renderer1 = TtfRenderer::new(1.0);
let renderer2 = TtfRenderer::new(2.0);
let width1 = renderer1.text_width(&atlas, "Test");
let width2 = renderer2.text_width(&atlas, "Test");
assert_eq!(width2, width1 * 2);
}
#[test]
fn text_height_returns_non_zero_for_valid_atlas() {
let (mut canvas, texture_creator, _sdl) = common::setup_sdl().unwrap();
let _ttf_context = sdl2::ttf::init().unwrap();
let font = _ttf_context.load_font("assets/game/TerminalVector.ttf", 16).unwrap();
let mut atlas = TtfAtlas::new(&texture_creator, &font).unwrap();
atlas.populate_atlas(&mut canvas, &texture_creator, &font).unwrap();
let renderer = TtfRenderer::new(1.0);
let height = renderer.text_height(&atlas);
assert!(height > 0);
}
#[test]
fn text_height_scales_correctly() {
let (mut canvas, texture_creator, _sdl) = common::setup_sdl().unwrap();
let _ttf_context = sdl2::ttf::init().unwrap();
let font = _ttf_context.load_font("assets/game/TerminalVector.ttf", 16).unwrap();
let mut atlas = TtfAtlas::new(&texture_creator, &font).unwrap();
atlas.populate_atlas(&mut canvas, &texture_creator, &font).unwrap();
let renderer1 = TtfRenderer::new(1.0);
let renderer2 = TtfRenderer::new(2.0);
let height1 = renderer1.text_height(&atlas);
let height2 = renderer2.text_height(&atlas);
assert_eq!(height2, height1 * 2);
}
#[test]
fn render_text_handles_empty_string() {
let (mut canvas, texture_creator, _sdl) = common::setup_sdl().unwrap();
let _ttf_context = sdl2::ttf::init().unwrap();
let font = _ttf_context.load_font("assets/game/TerminalVector.ttf", 16).unwrap();
let mut atlas = TtfAtlas::new(&texture_creator, &font).unwrap();
atlas.populate_atlas(&mut canvas, &texture_creator, &font).unwrap();
let renderer = TtfRenderer::new(1.0);
let result = renderer.render_text(&mut canvas, &mut atlas, "", glam::Vec2::new(0.0, 0.0), Color::WHITE);
assert!(result.is_ok());
}
#[test]
fn render_text_handles_single_character() {
let (mut canvas, texture_creator, _sdl) = common::setup_sdl().unwrap();
let _ttf_context = sdl2::ttf::init().unwrap();
let font = _ttf_context.load_font("assets/game/TerminalVector.ttf", 16).unwrap();
let mut atlas = TtfAtlas::new(&texture_creator, &font).unwrap();
atlas.populate_atlas(&mut canvas, &texture_creator, &font).unwrap();
let renderer = TtfRenderer::new(1.0);
let result = renderer.render_text(&mut canvas, &mut atlas, "A", glam::Vec2::new(10.0, 10.0), Color::RED);
assert!(result.is_ok());
}