Compare commits

...

18 Commits

Author SHA1 Message Date
Ryan Walters
a887fae00f feat: separate player/ghost collider sizes, move fruit sprite up 1 pixel, add fruit TTL 2025-09-11 14:46:07 -05:00
Ryan Walters
273385dfe4 refactor: improve audio system states, add try_new(), organize constants, volume memory 2025-09-11 14:45:48 -05:00
Ryan Walters
82cedf7e4a fix: remove ConsoleInit condition, add ToggleFullscreen condition, helper 'push' just recipe 2025-09-11 13:49:44 -05:00
Ryan Walters
b58a7a8f63 chore: bump version, add 'dev-release' debug profile 2025-09-11 13:46:05 -05:00
Ryan Walters
f340de80f3 feat: subsystem toggling via feature, release mode console allocation with ANSI, desktop file subscriber 2025-09-11 13:45:01 -05:00
Ryan Walters
d9ea79db74 fix: only run most workflows against 'master' branch 2025-09-11 09:41:21 -05:00
Ryan Walters
126b6ff378 feat: fullscreen toggle key 2025-09-11 09:10:19 -05:00
Ryan Walters
36e9de1a1f chore: bump to v0.80.0, update ROADMAP.md 2025-09-11 02:26:39 -05:00
Ryan Walters
9ad1704806 feat(audio): setup intro jingle, use fruit & ghost sounds, improve AudioEvent 2025-09-11 02:24:15 -05:00
Ryan Walters
86331afd52 refactor(audio): rename eat() to waka(), use play(Sound) for death() instead 2025-09-11 02:11:57 -05:00
Ryan Walters
cca205fe95 chore: compress .ogg audio files 2025-09-11 02:01:44 -05:00
Ryan Walters
00a65954e6 refactor: unify cross-platform asset loading, avoid hard-coding with folder-based asset embedding for desktop 2025-09-11 01:11:00 -05:00
Ryan Walters
43532dac56 feat(audio): centralize sound management with proper enum, improved iterator protocols, introduce new sound files 2025-09-11 00:40:09 -05:00
Ryan Walters
08c964c32e feat: re-implement pausing mechanism with tick-perfect audio & state pauses 2025-09-11 00:03:14 -05:00
Ryan Walters
8b2d18b3da chore: add 'fix' just recipe, remove temp ignore lines 2025-09-10 23:10:27 -05:00
Ryan Walters
46a73c5ace fix: solve audio glitch/crackling on Emscripten via use higher buffer and AUDIO_S16LSB 2025-09-10 23:08:46 -05:00
Ryan Walters
a2783ae62d refactor: refine asset enum, move around audio files, use OGG for death sound 2025-09-10 22:53:19 -05:00
Ryan Walters
83e0d1d737 fix: FruitSprites resource for common tests, disable Exit command bindings on Emscripten, update ROADMAP.md 2025-09-10 22:08:32 -05:00
44 changed files with 799 additions and 395 deletions

View File

@@ -1,5 +1,13 @@
name: Builds
on: ["push", "pull_request"]
on:
push:
branches:
- master
pull_request:
branches:
- master
workflow_dispatch:
permissions:
contents: write

View File

@@ -1,6 +1,13 @@
name: Checks
on: ["push", "pull_request"]
on:
push:
branches:
- master
pull_request:
branches:
- master
workflow_dispatch:
env:
CARGO_TERM_COLOR: always

View File

@@ -1,6 +1,12 @@
name: Code Coverage
on: ["push", "pull_request"]
on:
push:
branches:
- master
pull_request:
branches:
- master
env:
CARGO_TERM_COLOR: always

View File

@@ -1,6 +1,13 @@
name: Tests
on: ["push", "pull_request"]
on:
push:
branches:
- master
pull_request:
branches:
- master
workflow_dispatch:
env:
CARGO_TERM_COLOR: always

5
.gitignore vendored
View File

@@ -21,6 +21,5 @@ coverage.html
flamegraph.svg
/profile.*
# temporary
assets/game/sound/*.wav
/*.py
# Logs
*.log

136
Cargo.lock generated
View File

@@ -228,6 +228,15 @@ dependencies = [
"serde",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.19.0"
@@ -268,6 +277,15 @@ dependencies = [
"portable-atomic",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "critical-section"
version = "1.2.0"
@@ -289,6 +307,16 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crypto-common"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "deprecate-until"
version = "0.1.1"
@@ -337,6 +365,16 @@ version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "disqualified"
version = "1.0.0"
@@ -408,6 +446,16 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.3.3"
@@ -663,7 +711,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "pacman"
version = "0.79.1"
version = "0.80.3"
dependencies = [
"anyhow",
"bevy_ecs",
@@ -678,6 +726,7 @@ dependencies = [
"phf",
"pretty_assertions",
"rand",
"rust-embed",
"sdl2",
"serde",
"serde_json",
@@ -907,6 +956,40 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "rust-embed"
version = "8.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "025908b8682a26ba8d12f6f2d66b987584a4a87bc024abc5bbc12553a8cd178a"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "8.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6065f1a4392b71819ec1ea1df1120673418bf386f50de1d6f54204d836d4349c"
dependencies = [
"proc-macro2",
"quote",
"rust-embed-utils",
"syn",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "8.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6cc0c81648b20b70c491ff8cce00c1c3b223bb8ed2b5d41f0e54c6c4c0a3594"
dependencies = [
"sha2",
"walkdir",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
@@ -925,6 +1008,15 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -994,6 +1086,17 @@ dependencies = [
"serde",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.4"
@@ -1253,6 +1356,12 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
[[package]]
name = "unicode-ident"
version = "1.0.11"
@@ -1306,6 +1415,22 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "579a42fc0b8e0c63b76519a339be31bed574929511fa53c1a3acae26eb258f29"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.14.2+wasi-0.2.4"
@@ -1396,6 +1521,15 @@ dependencies = [
"web-sys",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.0",
]
[[package]]
name = "windows"
version = "0.62.0"

View File

@@ -1,6 +1,6 @@
[package]
name = "pacman"
version = "0.79.1"
version = "0.80.3"
authors = ["Xevion"]
edition = "2021"
rust-version = "1.86.0"
@@ -50,6 +50,7 @@ windows-sys = { version = "0.61.0", features = ["Win32_System_Console"] }
# On desktop platforms, build SDL2 with cargo-vcpkg
sdl2 = { version = "0.38", default-features = false, features = ["image", "ttf", "gfx", "mixer", "unsafe_textures", "static-link", "use-vcpkg"] }
rand = { version = "0.9.2", default-features = false, features = ["thread_rng"] }
rust-embed = "8.7.2"
spin_sleep = "1.3.3"
# Browser-specific dependencies
@@ -88,6 +89,12 @@ opt-level = "z"
lto = true
panic = "abort"
# This profile is intended to appear as a 'release' profile to the build system due to`debug_assertions = false`,
# but it will compile faster without optimizations. Useful for rapid testing of release-mode logic.
[profile.dev-release]
inherits = "dev"
debug-assertions = false
[package.metadata.vcpkg]
dependencies = ["sdl2", "sdl2-image", "sdl2-ttf", "sdl2-gfx", "sdl2-mixer"]
git = "https://github.com/microsoft/vcpkg"
@@ -99,5 +106,10 @@ x86_64-unknown-linux-gnu = { triplet = "x64-linux" }
x86_64-apple-darwin = { triplet = "x64-osx" }
aarch64-apple-darwin = { triplet = "arm64-osx" }
[features]
# Windows-specific features
force-console = []
default = []
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage,coverage_nightly)'] }
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage,coverage_nightly)', 'cfg(use_console)'] }

View File

@@ -37,3 +37,11 @@ samply:
web *args:
bun run web.build.ts {{args}};
caddy file-server --root dist
# Run cargo fix
fix:
cargo fix --workspace --lib --allow-dirty
cargo fmt --all
push:
git push origin --tags && git push

View File

@@ -32,7 +32,7 @@ The game includes all the original features you'd expect from Pac-Man:
- [x] Classic maze navigation with tunnels and dot collection
- [ ] Four ghosts with their unique AI behaviors (Blinky, Pinky, Inky, and Clyde)
- [x] Power pellets that allow Pac-Man to eat ghosts
- [ ] Fruit bonuses that appear periodically
- [x] Fruit bonuses that appear periodically
- [ ] Progressive difficulty with faster ghosts and shorter power pellet duration
- [x] Authentic sound effects and sprites

View File

@@ -28,7 +28,7 @@ A comprehensive list of features needed to complete the Pac-Man emulation, organ
- [x] Fruit Spawning Mechanics
- [x] Spawn at pellet counts 70 and 170
- [ ] Fruit display in bottom-right corner
- [x] Fruit display in bottom-right corner
- [x] Fruit collection and scoring
- [x] Bonus point display system
@@ -50,6 +50,7 @@ A comprehensive list of features needed to complete the Pac-Man emulation, organ
- [x] Sound effect playback
- [x] Audio muting controls
- [ ] Background Music
- [x] Intro jingle
- [ ] Continuous gameplay music
- [ ] Escalating siren based on remaining pellets
- [ ] Power pellet mode music
@@ -57,6 +58,8 @@ A comprehensive list of features needed to complete the Pac-Man emulation, organ
- [x] Sound Effects
- [x] Pellet eating sounds
- [x] Fruit collection sounds
- [x] Ghost eaten sounds
- [x] Pac-Man Death
- [ ] Ghost movement sounds
- [ ] Level completion fanfare
@@ -78,8 +81,8 @@ A comprehensive list of features needed to complete the Pac-Man emulation, organ
- [x] Keyboard controls
- [x] Direction buffering for responsive controls
- [x] Touch controls for mobile
- [ ] Pause System
- [ ] Pause/unpause functionality
- [x] Pause System
- [x] Pause/unpause functionality
- [ ] Pause menu with options
- [ ] Input System
- [ ] Input remapping
@@ -129,7 +132,7 @@ A comprehensive list of features needed to complete the Pac-Man emulation, organ
- [x] Animation system
- [x] HUD rendering
- [ ] Display Options
- [ ] Fullscreen support
- [x] Fullscreen support
- [x] Window resizing
- [ ] Pause while resizing (SDL2 limitation mitigation)
- [ ] Multiple resolution support

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

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

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

@@ -51,4 +51,11 @@ fn main() {
writeln!(&mut file, "}};").unwrap();
println!("cargo:rerun-if-changed=assets/game/atlas.json");
#[cfg(target_os = "windows")]
{
if cfg!(any(feature = "force-console", debug_assertions)) {
println!("cargo:rustc-cfg=use_console");
}
}
}

View File

@@ -2,24 +2,63 @@
//! 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;
use crate::error::AssetError;
/// 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 {
Wav1,
Wav2,
Wav3,
Wav4,
/// 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 {
@@ -28,47 +67,71 @@ impl Asset {
/// Paths are consistent across platforms and used by the Emscripten backend
/// for filesystem loading. Desktop builds embed assets directly and don't
/// use these paths at runtime.
#[allow(dead_code)]
pub fn path(&self) -> &str {
use Asset::*;
match self {
Wav1 => "sound/waka/1.ogg",
Wav2 => "sound/waka/2.ogg",
Wav3 => "sound/waka/3.ogg",
Wav4 => "sound/waka/4.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",
DeathSound => "sound/pacman_death.wav",
}
}
}
mod imp {
use super::*;
use crate::error::AssetError;
use crate::platform;
use tracing::trace;
/// Loads asset bytes using the appropriate platform-specific method.
///
/// On desktop platforms, returns embedded compile-time data via `include_bytes!`.
/// On desktop platforms, returns embedded compile-time data via `rust-embed`.
/// On Emscripten, loads from the filesystem using the asset's path. The returned
/// `Cow` allows zero-copy access to embedded data while supporting owned data
/// when loaded from disk.
///
/// # Errors
///
/// Returns `AssetError::NotFound` if the asset file cannot be located (Emscripten only),
/// Returns `AssetError::NotFound` if the asset file cannot be located,
/// or `AssetError::Io` for filesystem I/O failures.
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
trace!(asset = ?asset, "Loading game asset");
let result = platform::get_asset_bytes(asset);
pub fn get_bytes(&self) -> Result<Cow<'static, [u8]>, AssetError> {
use tracing::trace;
trace!(asset = ?self, "Loading game asset");
let result = self.get_bytes_platform();
match &result {
Ok(bytes) => trace!(asset = ?asset, size_bytes = bytes.len(), "Asset loaded successfully"),
Err(e) => trace!(asset = ?asset, error = ?e, "Asset loading failed"),
Ok(bytes) => trace!(asset = ?self, size_bytes = bytes.len(), "Asset loaded successfully"),
Err(e) => trace!(asset = ?self, error = ?e, "Asset loading failed"),
}
result
}
}
pub use imp::get_asset_bytes;
#[cfg(not(target_os = "emscripten"))]
fn get_bytes_platform(&self) -> Result<Cow<'static, [u8]>, AssetError> {
#[derive(rust_embed::Embed)]
#[folder = "assets/game/"]
struct EmbeddedAssets;
let path = self.path();
EmbeddedAssets::get(path)
.map(|file| file.data)
.ok_or_else(|| AssetError::NotFound(path.to_string()))
}
#[cfg(target_os = "emscripten")]
fn get_bytes_platform(&self) -> Result<Cow<'static, [u8]>, AssetError> {
use sdl2::rwops::RWops;
use std::io::{self, Read};
let path = format!("assets/game/{}", self.path());
let mut rwops = RWops::from_file(&path, "rb").map_err(|_| AssetError::NotFound(self.path().to_string()))?;
let len = rwops.len().ok_or_else(|| AssetError::NotFound(self.path().to_string()))?;
let mut buf = vec![0u8; len];
rwops.read_exact(&mut buf).map_err(|e| AssetError::Io(io::Error::other(e)))?;
Ok(Cow::Owned(buf))
}
}

View File

@@ -1,11 +1,46 @@
//! This module handles the audio playback for the game.
use crate::asset::{get_asset_bytes, Asset};
use std::collections::HashMap;
use crate::asset::Asset;
use anyhow::{anyhow, Result};
use sdl2::{
mixer::{self, Chunk, InitFlag, LoaderRWops, DEFAULT_FORMAT},
mixer::{self, Chunk, InitFlag, LoaderRWops, AUDIO_S16LSB},
rwops::RWops,
};
use strum::IntoEnumIterator;
const SOUND_ASSETS: [Asset; 4] = [Asset::Wav1, Asset::Wav2, Asset::Wav3, Asset::Wav4];
const AUDIO_FREQUENCY: i32 = 16_000;
const AUDIO_CHANNELS: i32 = 4;
const DEFAULT_VOLUME: u8 = 32;
const WAKA_SOUND_COUNT: u8 = 4;
#[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 {
let mut sounds = vec![
Sound::PacmanDeath,
Sound::ExtraLife,
Sound::Fruit,
Sound::Ghost,
Sound::Beginning,
Sound::Intermission,
];
sounds.extend((0..WAKA_SOUND_COUNT).map(Sound::Waka));
sounds.into_iter()
}
}
/// The audio system for the game.
///
@@ -14,11 +49,16 @@ const SOUND_ASSETS: [Asset; 4] = [Asset::Wav1, Asset::Wav2, Asset::Wav3, Asset::
/// functions will silently do nothing.
pub struct Audio {
_mixer_context: Option<mixer::Sdl2MixerContext>,
sounds: Vec<Chunk>,
death_sound: Option<Chunk>,
next_sound_index: usize,
muted: bool,
disabled: bool,
sounds: HashMap<Sound, Chunk>,
next_waka_index: u8,
state: AudioState,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AudioState {
Enabled { volume: u8 },
Muted { previous_volume: u8 },
Disabled,
}
impl Default for Audio {
@@ -33,109 +73,83 @@ impl Audio {
/// If audio fails to initialize, the audio system will be disabled and
/// all functions will silently do nothing.
pub fn new() -> Self {
let frequency = 44100;
let format = DEFAULT_FORMAT;
let channels = 4;
let chunk_size = 256; // 256 is minimum for emscripten
match Self::try_new() {
Ok(audio) => audio,
Err(e) => {
tracing::warn!("Failed to initialize audio: {}. Audio will be disabled.", e);
Self {
_mixer_context: None,
sounds: HashMap::new(),
next_waka_index: 0,
state: AudioState::Disabled,
}
}
}
}
fn try_new() -> Result<Self> {
let format = AUDIO_S16LSB;
let chunk_size = {
// 256 is the minimum for Emscripten, but in practice 1024 is much more reliable
#[cfg(target_os = "emscripten")]
{
1024
}
// Otherwise, 256 is plenty safe.
#[cfg(not(target_os = "emscripten"))]
{
256
}
};
// Try to open audio, but don't panic if it fails
if let Err(e) = mixer::open_audio(frequency, format, 1, chunk_size) {
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,
muted: false,
disabled: true,
};
}
mixer::open_audio(AUDIO_FREQUENCY, format, AUDIO_CHANNELS, chunk_size)
.map_err(|e| anyhow!("Failed to open audio: {}", e))?;
mixer::allocate_channels(channels);
mixer::allocate_channels(AUDIO_CHANNELS);
// set channel volume
for i in 0..channels {
mixer::Channel(i).set_volume(32);
for i in 0..AUDIO_CHANNELS {
mixer::Channel(i).set_volume(DEFAULT_VOLUME as i32);
}
// Try to initialize mixer, but don't panic if it fails
let mixer_context = match mixer::init(InitFlag::OGG) {
Ok(ctx) => ctx,
Err(e) => {
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,
muted: false,
disabled: true,
};
}
};
let mixer_context = mixer::init(InitFlag::OGG).map_err(|e| anyhow!("Failed to initialize SDL2_mixer: {}", e))?;
// 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) {
Ok(data) => match RWops::from_bytes(&data) {
Ok(rwops) => match rwops.load_wav() {
Ok(chunk) => sounds.push(chunk),
Err(e) => {
tracing::warn!("Failed to load sound {} from asset API: {}", i + 1, e);
}
},
Err(e) => {
tracing::warn!("Failed to create RWops for sound {}: {}", i + 1, e);
}
},
let sounds: HashMap<Sound, Chunk> = Sound::iter()
.filter_map(|sound_type| match Self::load_sound(sound_type) {
Ok(chunk) => Some((sound_type, chunk)),
Err(e) => {
tracing::warn!("Failed to load sound asset {}: {}", i + 1, e);
}
}
}
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);
tracing::warn!("Failed to load sound {:?}: {}", sound_type, e);
None
}
},
Err(e) => {
tracing::warn!("Failed to load death sound asset: {}", e);
None
}
};
})
.collect();
// If no sounds loaded successfully, disable audio
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,
};
if sounds.is_empty() {
return Err(anyhow!("No sounds loaded successfully"));
}
Audio {
Ok(Audio {
_mixer_context: Some(mixer_context),
sounds,
death_sound,
next_sound_index: 0,
muted: false,
disabled: false,
}
next_waka_index: 0u8,
state: AudioState::Enabled { volume: DEFAULT_VOLUME },
})
}
fn load_sound(sound_type: Sound) -> Result<Chunk> {
let asset = Asset::SoundFile(sound_type);
let data = asset
.get_bytes()
.map_err(|e| anyhow!("Failed to get bytes for {:?}: {}", sound_type, e))?;
let rwops = RWops::from_bytes(&data).map_err(|e| anyhow!("Failed to create RWops for {:?}: {}", sound_type, e))?;
rwops
.load_wav()
.map_err(|e| anyhow!("Failed to load wav for {:?}: {}", sound_type, e))
}
/// Plays the next waka eating sound in the cycle of four variants.
@@ -143,57 +157,79 @@ impl Audio {
/// Automatically rotates through the four eating sound assets. The sound plays on channel 0 and the internal sound index
/// advances to the next variant. Silently returns if audio is disabled, muted,
/// or no sounds were loaded successfully.
pub fn eat(&mut self) {
if self.disabled || self.muted || self.sounds.is_empty() {
pub fn waka(&mut self) {
if !matches!(self.state, AudioState::Enabled { .. }) {
return;
}
if let Some(chunk) = self.sounds.get(self.next_sound_index) {
match mixer::Channel(0).play(chunk, 0) {
if let Some(chunk) = self.sounds.get(&Sound::Waka(self.next_waka_index)) {
match mixer::Channel::all().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) % WAKA_SOUND_COUNT;
}
/// Plays the death sound effect.
pub fn death(&mut self) {
if self.disabled || self.muted {
/// Plays the provided sound effect once.
pub fn play(&mut self, sound: Sound) {
if !matches!(self.state, AudioState::Enabled { .. }) {
return;
}
if let Some(chunk) = &self.death_sound {
mixer::Channel::all().play(chunk, 0).ok();
if let Some(chunk) = self.sounds.get(&sound) {
let _ = mixer::Channel::all().play(chunk, 0);
}
}
/// Halts all currently playing audio channels.
pub fn stop_all(&mut self) {
if !self.disabled {
if self.state != AudioState::Disabled {
mixer::Channel::all().halt();
}
}
/// Pauses all currently playing audio channels.
pub fn pause_all(&mut self) {
if self.state != AudioState::Disabled {
mixer::Channel::all().pause();
}
}
/// Resumes all currently playing audio channels.
pub fn resume_all(&mut self) {
if self.state != AudioState::Disabled {
mixer::Channel::all().resume();
}
}
/// 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
/// their default volume (32) when unmuting. The mute state is tracked internally
/// regardless of whether audio is disabled, allowing the state to be preserved.
pub fn set_mute(&mut self, mute: bool) {
if !self.disabled {
let channels = 4;
let volume = if mute { 0 } else { 32 };
for i in 0..channels {
mixer::Channel(i).set_volume(volume);
match (mute, self.state) {
// Mute
(true, AudioState::Enabled { volume }) => {
self.state = AudioState::Muted { previous_volume: volume };
for i in 0..AUDIO_CHANNELS {
mixer::Channel(i).set_volume(0);
}
}
// Unmute
(false, AudioState::Muted { previous_volume }) => {
self.state = AudioState::Enabled { volume: previous_volume };
for i in 0..AUDIO_CHANNELS {
mixer::Channel(i).set_volume(previous_volume as i32);
}
}
_ => {}
}
self.muted = mute;
}
/// Returns the current mute state regardless of whether audio is functional.
@@ -201,7 +237,7 @@ impl Audio {
/// This tracks the user's mute preference and will return `true` if muted
/// even when the audio system is disabled due to initialization failures.
pub fn is_muted(&self) -> bool {
self.muted
matches!(self.state, AudioState::Muted { .. })
}
/// Returns whether the audio system failed to initialize and is non-functional.
@@ -210,6 +246,6 @@ impl Audio {
/// audio device, or failure to load any sound assets. When disabled, all
/// audio operations become no-ops.
pub fn is_disabled(&self) -> bool {
self.disabled
matches!(self.state, AudioState::Disabled)
}
}

View File

@@ -53,9 +53,9 @@ pub mod animation {
/// Frightened ghost animation speed (ticks per frame at 60 ticks/sec)
pub const GHOST_FRIGHTENED_SPEED: u16 = 12;
/// Time in ticks for frightened ghosts to return to normal
pub const GHOST_FRIGHTENED_TICKS: u32 = 300;
pub const GHOST_FRIGHTENED_TICKS: u32 = 5 * 60;
/// Time in ticks when frightened ghosts start flashing
pub const GHOST_FRIGHTENED_FLASH_START_TICKS: u32 = GHOST_FRIGHTENED_TICKS - 120;
pub const GHOST_FRIGHTENED_FLASH_START_TICKS: u32 = GHOST_FRIGHTENED_TICKS - 2 * 60;
}
/// The size of the canvas, in pixels.
@@ -75,13 +75,15 @@ pub const LARGE_CANVAS_SIZE: UVec2 = UVec2::new(
pub mod collider {
use super::CELL_SIZE;
/// Collider size for player and ghosts (1.375x cell size)
pub const PLAYER_GHOST_SIZE: f32 = CELL_SIZE as f32 * 1.375;
/// Collider size for pellets (0.4x cell size)
/// Collider size for player and ghosts
pub const PLAYER_SIZE: f32 = CELL_SIZE as f32 * 1.385;
/// Collider size for ghosts
pub const GHOST_SIZE: f32 = CELL_SIZE as f32 * 1.55;
/// Collider size for pellets
pub const PELLET_SIZE: f32 = CELL_SIZE as f32 * 0.4;
/// Collider size for power pellets/energizers (0.95x cell size)
/// Collider size for power pellets/energizers
pub const POWER_PELLET_SIZE: f32 = CELL_SIZE as f32 * 0.95;
/// Collider size for fruits (0.8x cell size)
/// Collider size for fruits
pub const FRUIT_SIZE: f32 = CELL_SIZE as f32 * 1.375;
}
@@ -148,7 +150,7 @@ pub const RAW_BOARD: [&str; BOARD_CELL_SIZE.y as usize] = [
/// Game initialization constants
pub mod startup {
/// Number of frames for the startup sequence (3 seconds at 60 FPS)
pub const STARTUP_FRAMES: u32 = 60 * 3;
pub const STARTUP_FRAMES: u32 = 60 * 4;
}
/// Game mechanics constants

View File

@@ -46,8 +46,6 @@ pub enum AssetError {
#[error("IO error: {0}")]
Io(#[from] io::Error),
// This error is only possible on Emscripten, as the assets are loaded from a 'filesystem' of sorts (while on Desktop, they are included in the binary at compile time)
#[allow(dead_code)]
#[error("Asset not found: {0}")]
NotFound(String),
}
@@ -56,7 +54,6 @@ pub enum AssetError {
#[derive(thiserror::Error, Debug)]
pub enum PlatformError {
#[error("Console initialization failed: {0}")]
#[cfg(any(windows, target_os = "emscripten"))]
ConsoleInit(String),
}

View File

@@ -19,7 +19,11 @@ pub enum GameCommand {
/// Restart the current level with fresh entity positions and items
ResetLevel,
/// Pause or resume game ticking logic
/// TODO: Display pause state, fix debug rendering pause distress
TogglePause,
/// Toggle fullscreen mode (desktop only)
#[cfg(not(target_os = "emscripten"))]
ToggleFullscreen,
}
/// Global events that flow through the ECS event system to coordinate game behavior.

View File

@@ -11,6 +11,8 @@ use crate::error::{GameError, GameResult};
use crate::events::{CollisionTrigger, GameEvent, StageTransition};
use crate::map::builder::Map;
use crate::map::direction::Direction;
use crate::systems::item::PelletCount;
use crate::systems::state::IntroPlayed;
use crate::systems::{
self, audio_system, blinking_system, collision_system, combined_render_system, directional_render_system,
dirty_render_system, eaten_ghost_system, fruit_sprite_system, ghost_collision_observer, ghost_movement_system,
@@ -19,8 +21,8 @@ use crate::systems::{
BackbufferResource, Blinking, BufferedDirection, Collider, DebugState, DebugTextureResource, DeltaTime, DirectionalAnimation,
EntityType, Frozen, FruitSprites, GameStage, Ghost, GhostAnimation, GhostAnimations, GhostBundle, GhostCollider, GhostState,
GlobalState, ItemBundle, ItemCollider, LastAnimationState, LinearAnimation, MapTextureResource, MovementModifiers, NodeId,
PacmanCollider, PlayerAnimation, PlayerBundle, PlayerControlled, PlayerDeathAnimation, PlayerLives, Position, RenderDirty,
Renderable, ScoreResource, StartupSequence, SystemId, SystemTimings, Timing, TouchState, Velocity, Visibility,
PacmanCollider, Paused, PlayerAnimation, PlayerBundle, PlayerControlled, PlayerDeathAnimation, PlayerLives, Position,
RenderDirty, Renderable, ScoreResource, StartupSequence, SystemId, SystemTimings, Timing, TouchState, Velocity, Visibility,
};
use crate::texture::animated::{DirectionalTiles, TileSequence};
@@ -40,7 +42,7 @@ use sdl2::video::{Window, WindowContext};
use sdl2::EventPump;
use crate::{
asset::{get_asset_bytes, Asset},
asset::Asset,
events::GameCommand,
map::render::MapRenderer,
systems::{BatchedLinesResource, Bindings, CursorPosition, TtfAtlasResource},
@@ -247,7 +249,7 @@ impl Game {
debug_texture.set_blend_mode(BlendMode::Blend);
debug_texture.set_scale_mode(ScaleMode::Nearest);
let font_data: &'static [u8] = get_asset_bytes(Asset::Font)?.to_vec().leak();
let font_data: &'static [u8] = Asset::Font.get_bytes()?.to_vec().leak();
let font_asset = RWops::from_bytes(font_data).map_err(|_| GameError::Sdl("Failed to load font".to_string()))?;
let debug_font = ttf_context
.load_font_from_rwops(font_asset, constants::ui::DEBUG_FONT_SIZE)
@@ -261,7 +263,7 @@ impl Game {
fn load_atlas_and_map_tiles(texture_creator: &TextureCreator<WindowContext>) -> GameResult<(SpriteAtlas, Vec<AtlasTile>)> {
trace!("Loading atlas image from embedded assets");
let atlas_bytes = get_asset_bytes(Asset::AtlasImage)?;
let atlas_bytes = Asset::AtlasImage.get_bytes()?;
let atlas_texture = texture_creator.load_texture_bytes(&atlas_bytes).map_err(|e| {
if e.to_string().contains("format") || e.to_string().contains("unsupported") {
GameError::Texture(crate::error::TextureError::InvalidFormat(format!(
@@ -379,7 +381,7 @@ impl Game {
directional_animation: player_animation,
entity_type: EntityType::Player,
collider: Collider {
size: constants::collider::PLAYER_GHOST_SIZE,
size: constants::collider::PLAYER_SIZE,
},
pacman_collider: PacmanCollider,
}
@@ -430,7 +432,7 @@ impl Game {
world.insert_resource(GlobalState { exit: false });
world.insert_resource(PlayerLives::default());
world.insert_resource(ScoreResource(0));
world.insert_resource(crate::systems::item::PelletCount(0));
world.insert_resource(PelletCount(0));
world.insert_resource(SystemTimings::default());
world.insert_resource(Timing::default());
world.insert_resource(Bindings::default());
@@ -438,11 +440,13 @@ impl Game {
world.insert_resource(RenderDirty::default());
world.insert_resource(DebugState::default());
world.insert_resource(AudioState::default());
world.insert_resource(IntroPlayed::default());
world.insert_resource(CursorPosition::default());
world.insert_resource(TouchState::default());
world.insert_resource(GameStage::Starting(StartupSequence::TextOnly {
remaining_ticks: constants::startup::STARTUP_FRAMES,
}));
world.insert_resource(Paused(false));
world.insert_non_send_resource(event_pump);
world.insert_non_send_resource::<&mut Canvas<Window>>(Box::leak(Box::new(canvas)));
@@ -457,6 +461,7 @@ impl Game {
fn configure_schedule(schedule: &mut Schedule) {
let stage_system = profile(SystemId::Stage, systems::stage_system);
let input_system = profile(SystemId::Input, systems::input::input_system);
let pause_system = profile(SystemId::Input, systems::handle_pause_command);
let player_control_system = profile(SystemId::PlayerControls, systems::player_control_system);
let player_movement_system = profile(SystemId::PlayerMovement, systems::player_movement_system);
let player_tunnel_slowdown_system = profile(SystemId::PlayerMovement, systems::player::player_tunnel_slowdown_system);
@@ -483,6 +488,9 @@ impl Game {
*local % 2 == 0
}),
player_control_system,
pause_system,
#[cfg(not(target_os = "emscripten"))]
profile(SystemId::Input, systems::handle_fullscreen_command),
)
.chain();
@@ -525,9 +533,9 @@ impl Game {
))
.configure_sets((
GameplaySet::Input,
GameplaySet::Update,
GameplaySet::Respond,
RenderSet::Animation,
GameplaySet::Update.run_if(|paused: Res<Paused>| !paused.0),
GameplaySet::Respond.run_if(|paused: Res<Paused>| !paused.0),
RenderSet::Animation.run_if(|paused: Res<Paused>| !paused.0),
RenderSet::Draw,
RenderSet::Present,
));
@@ -621,7 +629,7 @@ impl Game {
directional_animation: animations,
entity_type: EntityType::Ghost,
collider: Collider {
size: constants::collider::PLAYER_GHOST_SIZE,
size: constants::collider::GHOST_SIZE,
},
ghost_collider: GhostCollider,
ghost_state: GhostState::Normal,

View File

@@ -1,8 +1,10 @@
// Note: This disables the console window on Windows. We manually re-attach to the parent terminal or process later on.
#![windows_subsystem = "windows"]
#![cfg_attr(all(not(use_console), target_os = "windows"), windows_subsystem = "windows")]
#![cfg_attr(all(use_console, target_os = "windows"), windows_subsystem = "console")]
#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
#![cfg_attr(coverage_nightly, coverage(off))]
use std::env;
use crate::{app::App, constants::LOOP_TIME};
use tracing::info;
@@ -32,9 +34,12 @@ mod texture;
/// This function initializes SDL, the window, the game state, and then enters
/// the main game loop.
pub fn main() {
// On Windows, this connects output streams to the console dynamically
// Parse command line arguments
let args: Vec<String> = env::args().collect();
let force_console = args.iter().any(|arg| arg == "--console" || arg == "-c");
// On Emscripten, this connects the subscriber to the browser console
platform::init_console().expect("Could not initialize console");
platform::init_console(force_console).expect("Could not initialize console");
let mut app = App::new().expect("Could not create app");

View File

@@ -1,12 +1,15 @@
//! Desktop platform implementation.
use std::borrow::Cow;
use std::time::Duration;
use rand::rngs::ThreadRng;
use rust_embed::Embed;
use crate::asset::Asset;
use crate::error::{AssetError, PlatformError};
use crate::error::PlatformError;
#[derive(Embed)]
#[folder = "assets/game/"]
struct EmbeddedAssets;
/// Desktop platform implementation.
pub fn sleep(duration: Duration, focused: bool) {
@@ -17,129 +20,153 @@ pub fn sleep(duration: Duration, focused: bool) {
}
}
pub fn init_console() -> Result<(), PlatformError> {
#[allow(unused_variables)]
pub fn init_console(force_console: bool) -> Result<(), PlatformError> {
use crate::formatter::CustomFormatter;
use tracing::Level;
use tracing_error::ErrorLayer;
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, Layer};
// Create a file layer
let log_file = std::fs::File::create("pacman.log")
.map_err(|e| PlatformError::ConsoleInit(format!("Failed to create log file: {}", e)))?;
let file_layer = fmt::layer()
.with_ansi(false)
.with_writer(log_file)
.event_format(CustomFormatter)
.with_filter(tracing_subscriber::filter::LevelFilter::from_level(Level::DEBUG))
.boxed();
#[cfg(windows)]
{
use crate::platform::tracing_buffer::setup_switchable_subscriber;
use tracing::{debug, info, trace};
use windows::Win32::System::Console::GetConsoleWindow;
// If using windows subsystem, and force_console is true, allocate a new console window
if force_console && cfg!(not(use_console)) {
use crate::platform::tracing_buffer::{SwitchableMakeWriter, SwitchableWriter};
// Setup buffered tracing subscriber that will buffer logs until console is ready
let switchable_writer = setup_switchable_subscriber();
// Setup deferred tracing subscriber that will buffer logs until console is ready
let switchable_writer = SwitchableWriter::default();
let make_writer = SwitchableMakeWriter::new(switchable_writer.clone());
let console_layer = fmt::layer()
.with_ansi(true)
.with_writer(make_writer)
.event_format(CustomFormatter)
.with_filter(tracing_subscriber::filter::LevelFilter::from_level(Level::DEBUG))
.boxed();
// Check if we already have a console window
if unsafe { !GetConsoleWindow().0.is_null() } {
debug!("Already have a console window");
return Ok(());
tracing_subscriber::registry()
.with(console_layer)
.with(file_layer)
.with(ErrorLayer::default())
.init();
// Enable virtual terminal processing for ANSI colors
allocate_console()?;
enable_ansi_support()?;
switchable_writer
.switch_to_direct_mode()
.map_err(|e| PlatformError::ConsoleInit(format!("Failed to switch to direct mode: {}", e)))?;
} else {
trace!("No existing console window found");
// Set up tracing subscriber with ANSI colors enabled
let console_layer = fmt::layer()
.with_ansi(true)
.with_writer(std::io::stdout)
.event_format(CustomFormatter)
.with_filter(tracing_subscriber::filter::LevelFilter::from_level(Level::DEBUG))
.boxed();
tracing_subscriber::registry()
.with(console_layer)
.with(file_layer)
.with(ErrorLayer::default())
.init();
}
}
if let Some(file_type) = is_output_setup()? {
trace!(r#type = file_type, "Existing output detected");
} else {
trace!("No existing output detected");
#[cfg(not(windows))]
{
// Set up tracing subscriber with ANSI colors enabled
let console_layer = fmt::layer()
.with_ansi(true)
.with_writer(std::io::stdout)
.event_format(CustomFormatter)
.with_filter(tracing_subscriber::filter::LevelFilter::from_level(Level::DEBUG))
.boxed();
// Try to attach to parent console for direct cargo run
attach_to_parent_console()?;
info!("Successfully attached to parent console");
}
// Now that console is initialized, flush buffered logs and switch to direct output
trace!("Switching to direct logging mode and flushing buffer...");
if let Err(error) = switchable_writer.switch_to_direct_mode() {
use tracing::warn;
warn!("Failed to flush buffered logs to console: {error:?}");
}
tracing_subscriber::registry()
.with(console_layer)
.with(file_layer)
.with(ErrorLayer::default())
.init();
}
Ok(())
}
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
match asset {
Asset::Wav1 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/1.ogg"))),
Asset::Wav2 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/2.ogg"))),
Asset::Wav3 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/3.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::Font => Ok(Cow::Borrowed(include_bytes!("../../assets/game/TerminalVector.ttf"))),
Asset::DeathSound => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman_death.wav"))),
}
}
pub fn rng() -> ThreadRng {
rand::rng()
}
/* Internal functions */
/// Check if the output stream has been setup by a parent process
/// Enable ANSI escape sequence support in the Windows console
/// Windows-only
#[cfg(windows)]
fn is_output_setup() -> Result<Option<&'static str>, PlatformError> {
use tracing::{trace, warn};
use windows::Win32::Storage::FileSystem::{
GetFileType, FILE_TYPE_CHAR, FILE_TYPE_DISK, FILE_TYPE_PIPE, FILE_TYPE_REMOTE, FILE_TYPE_UNKNOWN,
fn enable_ansi_support() -> Result<(), PlatformError> {
use windows::Win32::System::Console::{
GetConsoleMode, GetStdHandle, SetConsoleMode, CONSOLE_MODE, ENABLE_VIRTUAL_TERMINAL_PROCESSING, STD_ERROR_HANDLE,
STD_OUTPUT_HANDLE,
};
use windows_sys::Win32::{
Foundation::INVALID_HANDLE_VALUE,
System::Console::{GetStdHandle, STD_OUTPUT_HANDLE},
};
// Enable ANSI processing for stdout
unsafe {
let stdout_handle = GetStdHandle(STD_OUTPUT_HANDLE)
.map_err(|e| PlatformError::ConsoleInit(format!("Failed to get stdout handle: {:?}", e)))?;
// Get the process's standard output handle, check if it's invalid
let handle = match unsafe { GetStdHandle(STD_OUTPUT_HANDLE) } {
INVALID_HANDLE_VALUE => {
return Err(PlatformError::ConsoleInit("Invalid handle".to_string()));
}
handle => handle,
};
let mut console_mode = CONSOLE_MODE(0);
GetConsoleMode(stdout_handle, &mut console_mode)
.map_err(|e| PlatformError::ConsoleInit(format!("Failed to get console mode: {:?}", e)))?;
// Identify the file type of the handle and whether it's 'well known' (i.e. we trust it to be a reasonable output destination)
let (well_known, file_type) = match unsafe {
use windows::Win32::Foundation::HANDLE;
GetFileType(HANDLE(handle))
} {
FILE_TYPE_PIPE => (true, "pipe"),
FILE_TYPE_CHAR => (true, "char"),
FILE_TYPE_DISK => (true, "disk"),
FILE_TYPE_UNKNOWN => (false, "unknown"),
FILE_TYPE_REMOTE => (false, "remote"),
unexpected => {
warn!("Unexpected file type: {unexpected:?}");
(false, "unknown")
}
};
console_mode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING;
SetConsoleMode(stdout_handle, console_mode)
.map_err(|e| PlatformError::ConsoleInit(format!("Failed to enable ANSI for stdout: {:?}", e)))?;
}
trace!("File type: {file_type:?}, well known: {well_known}");
// Enable ANSI processing for stderr
unsafe {
let stderr_handle = GetStdHandle(STD_ERROR_HANDLE)
.map_err(|e| PlatformError::ConsoleInit(format!("Failed to get stderr handle: {:?}", e)))?;
// If it's anything recognizable and valid, assume that a parent process has setup an output stream
Ok(well_known.then_some(file_type))
let mut console_mode = CONSOLE_MODE(0);
GetConsoleMode(stderr_handle, &mut console_mode)
.map_err(|e| PlatformError::ConsoleInit(format!("Failed to get console mode: {:?}", e)))?;
console_mode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING;
SetConsoleMode(stderr_handle, console_mode)
.map_err(|e| PlatformError::ConsoleInit(format!("Failed to enable ANSI for stderr: {:?}", e)))?;
}
Ok(())
}
/// Try to attach to parent console
/// Allocate a new console window for the process
/// Windows-only
#[cfg(windows)]
fn attach_to_parent_console() -> Result<(), PlatformError> {
fn allocate_console() -> Result<(), PlatformError> {
use windows::{
core::PCSTR,
Win32::{
Foundation::{GENERIC_READ, GENERIC_WRITE},
Storage::FileSystem::{CreateFileA, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING},
System::Console::{
AttachConsole, FreeConsole, SetStdHandle, ATTACH_PARENT_PROCESS, STD_ERROR_HANDLE, STD_OUTPUT_HANDLE,
},
System::Console::{AllocConsole, SetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE},
},
};
// Attach the process to the parent's console
unsafe { AttachConsole(ATTACH_PARENT_PROCESS) }
.map_err(|e| PlatformError::ConsoleInit(format!("Failed to attach to parent console: {:?}", e)))?;
// Allocate a new console for this process
unsafe { AllocConsole() }.map_err(|e| PlatformError::ConsoleInit(format!("Failed to allocate console: {:?}", e)))?;
let handle = unsafe {
// Note: SetConsoleTitle is not available in the imported modules, skipping title setting
// Redirect stdout
let stdout_handle = unsafe {
let pcstr = PCSTR::from_raw(c"CONOUT$".as_ptr() as *const u8);
CreateFileA::<PCSTR>(
pcstr,
@@ -151,28 +178,32 @@ fn attach_to_parent_console() -> Result<(), PlatformError> {
None,
)
}
.map_err(|e| PlatformError::ConsoleInit(format!("Failed to create console handle: {:?}", e)))?;
.map_err(|e| PlatformError::ConsoleInit(format!("Failed to create stdout handle: {:?}", e)))?;
// Set the console's output and then error handles
if let Some(handle_error) = unsafe { SetStdHandle(STD_OUTPUT_HANDLE, handle) }
.map_err(|e| PlatformError::ConsoleInit(format!("Failed to set console output handle: {:?}", e)))
.and_then(|_| {
unsafe { SetStdHandle(STD_ERROR_HANDLE, handle) }
.map_err(|e| PlatformError::ConsoleInit(format!("Failed to set console error handle: {:?}", e)))
})
.err()
{
// If either set handle call fails, free the console
unsafe { FreeConsole() }
// Free the console if the SetStdHandle calls fail
.map_err(|free_error| {
PlatformError::ConsoleInit(format!(
"Failed to free console after SetStdHandle failed: {free_error:?} ({handle_error:?})"
))
})
// And then return the original error if the FreeConsole call succeeds
.and(Err(handle_error))?;
// Redirect stdin
let stdin_handle = unsafe {
let pcstr = PCSTR::from_raw(c"CONIN$".as_ptr() as *const u8);
CreateFileA::<PCSTR>(
pcstr,
(GENERIC_READ | GENERIC_WRITE).0,
FILE_SHARE_READ | FILE_SHARE_WRITE,
None,
OPEN_EXISTING,
FILE_FLAGS_AND_ATTRIBUTES(0),
None,
)
}
.map_err(|e| PlatformError::ConsoleInit(format!("Failed to create stdin handle: {:?}", e)))?;
// Set the standard handles
unsafe { SetStdHandle(STD_OUTPUT_HANDLE, stdout_handle) }
.map_err(|e| PlatformError::ConsoleInit(format!("Failed to set stdout handle: {:?}", e)))?;
unsafe { SetStdHandle(STD_ERROR_HANDLE, stdout_handle) }
.map_err(|e| PlatformError::ConsoleInit(format!("Failed to set stderr handle: {:?}", e)))?;
unsafe { SetStdHandle(STD_INPUT_HANDLE, stdin_handle) }
.map_err(|e| PlatformError::ConsoleInit(format!("Failed to set stdin handle: {:?}", e)))?;
Ok(())
}

View File

@@ -1,13 +1,10 @@
//! Emscripten platform implementation.
use crate::asset::Asset;
use crate::error::{AssetError, PlatformError};
use crate::error::PlatformError;
use crate::formatter::CustomFormatter;
use rand::{rngs::SmallRng, SeedableRng};
use sdl2::rwops::RWops;
use std::borrow::Cow;
use std::ffi::CString;
use std::io::{self, Read, Write};
use std::io::{self, Write};
use std::time::Duration;
// Emscripten FFI functions
@@ -22,7 +19,7 @@ pub fn sleep(duration: Duration, _focused: bool) {
}
}
pub fn init_console() -> Result<(), PlatformError> {
pub fn init_console(_force_console: bool) -> Result<(), PlatformError> {
use tracing_subscriber::{fmt, layer::SubscriberExt, EnvFilter};
// Set up a custom tracing subscriber that writes directly to emscripten console
@@ -62,18 +59,6 @@ impl Write for EmscriptenConsoleWriter {
}
}
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
let path = format!("assets/game/{}", asset.path());
let mut rwops = RWops::from_file(&path, "rb").map_err(|_| AssetError::NotFound(asset.path().to_string()))?;
let len = rwops.len().ok_or_else(|| AssetError::NotFound(asset.path().to_string()))?;
let mut buf = vec![0u8; len];
rwops.read_exact(&mut buf).map_err(|e| AssetError::Io(io::Error::other(e)))?;
Ok(Cow::Owned(buf))
}
pub fn rng() -> SmallRng {
SmallRng::from_os_rng()
}

View File

@@ -1,14 +1,11 @@
//! Buffered tracing setup for handling logs before console attachment.
use crate::formatter::CustomFormatter;
use parking_lot::Mutex;
use std::io;
use std::io::Write;
use std::sync::Arc;
use tracing::{debug, Level};
use tracing_error::ErrorLayer;
use tracing::debug;
use tracing_subscriber::fmt::MakeWriter;
use tracing_subscriber::layer::SubscriberExt;
/// A thread-safe buffered writer that stores logs in memory until flushed.
#[derive(Clone)]
@@ -76,7 +73,7 @@ impl SwitchableWriter {
// Get buffer size before flushing for debug logging
let buffer_size = self.buffered_writer.buffer_size();
// Flush any buffered content
// Flush any buffered content to stdout only
self.buffered_writer.flush_to(io::stdout())?;
// Switch to direct mode (and drop the lock)
@@ -130,23 +127,3 @@ impl<'a> MakeWriter<'a> for SwitchableMakeWriter {
self.writer.clone()
}
}
/// Sets up a switchable tracing subscriber that can transition from buffered to direct output.
///
/// Returns the switchable writer that can be used to control the behavior.
pub fn setup_switchable_subscriber() -> SwitchableWriter {
let switchable_writer = SwitchableWriter::default();
let make_writer = SwitchableMakeWriter::new(switchable_writer.clone());
let _subscriber = tracing_subscriber::fmt()
.with_ansi(cfg!(not(target_os = "emscripten")))
.with_max_level(Level::DEBUG)
.event_format(CustomFormatter)
.with_writer(make_writer)
.finish()
.with(ErrorLayer::default());
tracing::subscriber::set_global_default(_subscriber).expect("Could not set global default switchable subscriber");
switchable_writer
}

View File

@@ -11,7 +11,7 @@ use bevy_ecs::{
};
use tracing::{debug, trace};
use crate::{audio::Audio, error::GameError};
use crate::{audio::Audio, audio::Sound, error::GameError};
/// Resource for tracking audio state
#[derive(Resource, Debug, Clone, Default)]
@@ -25,12 +25,16 @@ pub struct AudioState {
/// Events for triggering audio playback
#[derive(Event, Debug, Clone, Copy, PartialEq, Eq)]
pub enum AudioEvent {
/// Play the "eat" sound when Pac-Man consumes a pellet
PlayEat,
/// Play the death sound
PlayDeath,
/// Play a specific sound effect
PlaySound(Sound),
/// Play the cycling waka sound variant
Waka,
/// Stop all currently playing sounds
StopAll,
/// Pause all sounds
Pause,
/// Resume all sounds
Resume,
}
/// Non-send resource wrapper for SDL2 audio system
@@ -57,10 +61,10 @@ pub fn audio_system(
// Process audio events
for event in audio_events.read() {
match event {
AudioEvent::PlayEat => {
AudioEvent::Waka => {
if !audio.0.is_disabled() && !audio_state.muted {
trace!(sound_index = audio_state.sound_index, "Playing eat sound");
audio.0.eat();
audio.0.waka();
// Update the sound index for cycling through sounds
audio_state.sound_index = (audio_state.sound_index + 1) % 4;
// 4 eat sounds available
@@ -72,15 +76,15 @@ pub fn audio_system(
);
}
}
AudioEvent::PlayDeath => {
AudioEvent::PlaySound(sound) => {
if !audio.0.is_disabled() && !audio_state.muted {
trace!("Playing death sound");
audio.0.death();
trace!(?sound, "Playing sound");
audio.0.play(*sound);
} else {
debug!(
disabled = audio.0.is_disabled(),
muted = audio_state.muted,
"Skipping death sound due to audio state"
"Skipping sound due to audio state"
);
}
}
@@ -92,6 +96,22 @@ pub fn audio_system(
debug!("Audio disabled, ignoring stop all request");
}
}
AudioEvent::Pause => {
if !audio.0.is_disabled() {
debug!("Pausing all audio");
audio.0.pause_all();
} else {
debug!("Audio disabled, ignoring pause all request");
}
}
AudioEvent::Resume => {
if !audio.0.is_disabled() {
debug!("Resuming all audio");
audio.0.resume_all();
} else {
debug!("Audio disabled, ignoring resume all request");
}
}
}
}
}

View File

@@ -8,6 +8,7 @@ use bevy_ecs::{
};
use tracing::{debug, trace, warn};
use crate::audio::Sound;
use crate::{
constants,
systems::{movement::Position, AudioEvent, DyingSequence, FruitSprites, GameStage, Ghost, ScoreResource, SpawnTrigger},
@@ -165,8 +166,8 @@ pub fn ghost_collision_observer(
ghost_type,
});
// Play eat sound
events.write(AudioEvent::PlayEat);
// Play ghost eaten sound
events.write(AudioEvent::PlaySound(Sound::Ghost));
} else if matches!(*ghost_state, GhostState::Normal) {
// Pac-Man dies
warn!(ghost = ?ghost_type, "Pacman hit by normal ghost, player dies");
@@ -226,7 +227,15 @@ pub fn item_collision_observer(
// Trigger audio if appropriate
if entity_type.is_collectible() {
events.write(AudioEvent::PlayEat);
match *entity_type {
EntityType::Fruit(_) => {
events.write(AudioEvent::PlaySound(Sound::Fruit));
}
EntityType::Pellet | EntityType::PowerPellet => {
events.write(AudioEvent::Waka);
}
_ => {}
}
}
// Make non-eaten ghosts frightened when power pellet is collected

View File

@@ -27,7 +27,7 @@ fn calculate_fruit_sprite_position(index: u32) -> Vec2 {
let sprite_spacing = CELL_SIZE + CELL_SIZE / 2; // 1.5 cells between sprites
let x = start_x - ((index as f32) * (sprite_spacing as f32 * 1.5)).round() as u32;
let y = start_y - CELL_SIZE / 2;
let y = start_y - (1 + CELL_SIZE / 2);
Vec2::new((x - CELL_SIZE) as f32, (y + CELL_SIZE) as f32)
}

View File

@@ -85,8 +85,14 @@ impl Default for Bindings {
key_bindings.insert(Keycode::Space, GameCommand::ToggleDebug);
key_bindings.insert(Keycode::M, GameCommand::MuteAudio);
key_bindings.insert(Keycode::R, GameCommand::ResetLevel);
key_bindings.insert(Keycode::Escape, GameCommand::Exit);
key_bindings.insert(Keycode::Q, GameCommand::Exit);
#[cfg(not(target_os = "emscripten"))]
{
key_bindings.insert(Keycode::Escape, GameCommand::Exit);
key_bindings.insert(Keycode::Q, GameCommand::Exit);
// Desktop-only fullscreen toggle
key_bindings.insert(Keycode::F, GameCommand::ToggleFullscreen);
}
let movement_keys = HashSet::from([
Keycode::W,

View File

@@ -3,12 +3,14 @@ use bevy_ecs::{
observer::Trigger,
system::{Commands, NonSendMut, Res},
};
use rand::Rng;
use strum_macros::IntoStaticStr;
use tracing::debug;
use crate::{
constants,
map::builder::Map,
platform::rng,
systems::{common::bundles::ItemBundle, Collider, Position, Renderable, TimeToLive},
texture::{
sprite::SpriteAtlas,
@@ -112,7 +114,9 @@ pub fn spawn_fruit_observer(
item_collider: ItemCollider,
};
commands.spawn(bundle)
let lifetime_ticks = (rng().random_range(9f32..10f32) * 60f32).round() as u32;
commands.spawn((bundle, TimeToLive::new(lifetime_ticks)))
}
SpawnTrigger::Bonus { position, value, ttl } => {
let sprite = &atlas

View File

@@ -1,5 +1,5 @@
use std::mem::discriminant;
use tracing::{debug, info, warn};
use tracing::{debug, info};
use crate::constants;
use crate::events::StageTransition;
@@ -20,12 +20,24 @@ use bevy_ecs::{
system::{Commands, Query, Res, ResMut, Single},
};
use crate::events::{GameCommand, GameEvent};
#[cfg(not(target_os = "emscripten"))]
use bevy_ecs::system::NonSendMut;
#[cfg(not(target_os = "emscripten"))]
use sdl2::render::Canvas;
#[cfg(not(target_os = "emscripten"))]
use sdl2::video::{FullscreenType, Window};
#[derive(Resource, Clone)]
pub struct PlayerAnimation(pub DirectionalAnimation);
#[derive(Resource, Clone)]
pub struct PlayerDeathAnimation(pub LinearAnimation);
/// Tracks whether the beginning sound has been played for the current startup sequence
#[derive(Resource, Debug, Default, Clone, Copy)]
pub struct IntroPlayed(pub bool);
/// A resource to track the overall stage of the game from a high-level perspective.
#[derive(Resource, Debug, PartialEq, Eq, Clone, Copy)]
pub enum GameStage {
@@ -45,6 +57,49 @@ pub enum GameStage {
GameOver,
}
#[derive(Resource, Debug, Default)]
pub struct Paused(pub bool);
pub fn handle_pause_command(
mut events: EventReader<GameEvent>,
mut paused: ResMut<Paused>,
mut audio_events: EventWriter<AudioEvent>,
) {
for event in events.read() {
if let GameEvent::Command(GameCommand::TogglePause) = event {
paused.0 = !paused.0;
if paused.0 {
info!("Game paused");
audio_events.write(AudioEvent::Pause);
} else {
info!("Game resumed");
audio_events.write(AudioEvent::Resume);
}
}
}
}
#[cfg(not(target_os = "emscripten"))]
pub fn handle_fullscreen_command(mut events: EventReader<GameEvent>, mut canvas: NonSendMut<&mut Canvas<Window>>) {
for event in events.read() {
if let GameEvent::Command(GameCommand::ToggleFullscreen) = event {
let window = canvas.window_mut();
let current = window.fullscreen_state();
let target = match current {
FullscreenType::Off => FullscreenType::Desktop,
_ => FullscreenType::Off,
};
if let Err(e) = window.set_fullscreen(target) {
tracing::warn!(error = ?e, "Failed to toggle fullscreen");
} else {
let on = matches!(target, FullscreenType::Desktop | FullscreenType::True);
info!(fullscreen = on, "Toggled fullscreen");
}
}
}
}
pub trait TooSimilar {
fn too_similar(&self, other: &Self) -> bool;
}
@@ -159,9 +214,10 @@ pub fn stage_system(
player: Single<(Entity, &mut Position), With<PlayerControlled>>,
mut item_query: Query<(Entity, &EntityType), With<ItemCollider>>,
mut ghost_query: Query<(Entity, &Ghost, &mut Position, &mut GhostState), (With<GhostCollider>, Without<PlayerControlled>)>,
mut intro_played: ResMut<IntroPlayed>,
) {
let old_state = *game_state;
let mut new_state: Option<GameStage> = None;
let mut new_state_opt: Option<GameStage> = None;
// Handle stage transition requests before normal ticking
for event in stage_event_reader.read() {
@@ -172,7 +228,7 @@ pub fn stage_system(
let pac_node = player.1.current_node();
debug!(ghost = ?ghost_type, node = pac_node, "Ghost eaten, entering pause state");
new_state = Some(GameStage::GhostEatenPause {
new_state_opt = Some(GameStage::GhostEatenPause {
remaining_ticks: 30,
ghost_entity,
ghost_type,
@@ -180,29 +236,11 @@ pub fn stage_system(
});
}
let new_state: GameStage = match new_state.unwrap_or(*game_state) {
GameStage::Starting(startup) => match startup {
StartupSequence::TextOnly { remaining_ticks } => {
if remaining_ticks > 0 {
GameStage::Starting(StartupSequence::TextOnly {
remaining_ticks: remaining_ticks - 1,
})
} else {
GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 })
}
}
StartupSequence::CharactersVisible { remaining_ticks } => {
if remaining_ticks > 0 {
GameStage::Starting(StartupSequence::CharactersVisible {
remaining_ticks: remaining_ticks - 1,
})
} else {
info!("Startup sequence completed, beginning gameplay");
GameStage::Playing
}
}
},
GameStage::Playing => GameStage::Playing,
let new_state: GameStage = new_state_opt.unwrap_or_else(|| match *game_state {
GameStage::Playing => {
// This is the default state, do nothing
*game_state
}
GameStage::GhostEatenPause {
remaining_ticks,
ghost_entity,
@@ -221,11 +259,37 @@ pub fn stage_system(
GameStage::Playing
}
}
GameStage::PlayerDying(dying) => match dying {
GameStage::Starting(sequence) => match sequence {
StartupSequence::TextOnly { remaining_ticks } => {
// Play the beginning sound once at the start of TextOnly stage
if !intro_played.0 {
audio_events.write(AudioEvent::PlaySound(crate::audio::Sound::Beginning));
intro_played.0 = true;
}
if remaining_ticks > 0 {
GameStage::Starting(StartupSequence::TextOnly {
remaining_ticks: remaining_ticks.saturating_sub(1),
})
} else {
GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 })
}
}
StartupSequence::CharactersVisible { remaining_ticks } => {
if remaining_ticks > 0 {
GameStage::Starting(StartupSequence::CharactersVisible {
remaining_ticks: remaining_ticks.saturating_sub(1),
})
} else {
info!("Startup sequence completed, beginning gameplay");
GameStage::Playing
}
}
},
GameStage::PlayerDying(sequence) => match sequence {
DyingSequence::Frozen { remaining_ticks } => {
if remaining_ticks > 0 {
GameStage::PlayerDying(DyingSequence::Frozen {
remaining_ticks: remaining_ticks - 1,
remaining_ticks: remaining_ticks.saturating_sub(1),
})
} else {
let death_animation = &player_death_animation.0;
@@ -237,7 +301,7 @@ pub fn stage_system(
DyingSequence::Animating { remaining_ticks } => {
if remaining_ticks > 0 {
GameStage::PlayerDying(DyingSequence::Animating {
remaining_ticks: remaining_ticks - 1,
remaining_ticks: remaining_ticks.saturating_sub(1),
})
} else {
GameStage::PlayerDying(DyingSequence::Hidden { remaining_ticks: 60 })
@@ -246,7 +310,7 @@ pub fn stage_system(
DyingSequence::Hidden { remaining_ticks } => {
if remaining_ticks > 0 {
GameStage::PlayerDying(DyingSequence::Hidden {
remaining_ticks: remaining_ticks - 1,
remaining_ticks: remaining_ticks.saturating_sub(1),
})
} else {
player_lives.0 = player_lives.0.saturating_sub(1);
@@ -255,14 +319,14 @@ pub fn stage_system(
info!(remaining_lives = player_lives.0, "Player died, returning to startup sequence");
GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 })
} else {
warn!("All lives lost, game over");
info!("All lives lost, game over");
GameStage::GameOver
}
}
}
},
GameStage::GameOver => GameStage::GameOver,
};
GameStage::GameOver => *game_state,
});
if old_state == new_state {
return;
@@ -327,7 +391,7 @@ pub fn stage_system(
.insert((Dying, player_death_animation.0.clone()));
// Play the death sound
audio_events.write(AudioEvent::PlayDeath);
audio_events.write(AudioEvent::PlaySound(crate::audio::Sound::PacmanDeath));
}
(_, GameStage::PlayerDying(DyingSequence::Hidden { .. })) => {
// Pac-Man's death animation is complete, so he should be hidden just like the ghosts.
@@ -396,6 +460,8 @@ pub fn stage_system(
for entity in blinking_query.iter_mut() {
commands.entity(entity).remove::<Frozen>();
}
// Reset intro flag for the next round
intro_played.0 = false;
}
(_, GameStage::GameOver) => {
// Freeze blinking

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

View File

@@ -3,7 +3,7 @@
use bevy_ecs::{entity::Entity, event::Events, schedule::Schedule, world::World};
use glam::{U16Vec2, Vec2};
use pacman::{
asset::{get_asset_bytes, Asset},
asset::Asset,
constants::RAW_BOARD,
events::{CollisionTrigger, GameEvent},
game::ATLAS_FRAMES,
@@ -13,9 +13,9 @@ use pacman::{
graph::{Graph, Node},
},
systems::{
item_collision_observer, AudioEvent, AudioState, BufferedDirection, Collider, DebugState, DeltaTime, EntityType, Ghost,
GhostCollider, GhostState, GlobalState, ItemCollider, MovementModifiers, PacmanCollider, PelletCount, PlayerControlled,
Position, ScoreResource, Velocity,
item_collision_observer, AudioEvent, AudioState, BufferedDirection, Collider, DebugState, DeltaTime, EntityType,
FruitSprites, Ghost, GhostCollider, GhostState, GlobalState, ItemCollider, MovementModifiers, PacmanCollider,
PelletCount, PlayerControlled, Position, ScoreResource, Velocity,
},
texture::sprite::{AtlasMapper, AtlasTile, SpriteAtlas},
};
@@ -43,7 +43,7 @@ pub fn setup_sdl() -> Result<(Canvas<Window>, TextureCreator<WindowContext>, Sdl
pub fn create_atlas(canvas: &mut sdl2::render::Canvas<sdl2::video::Window>) -> SpriteAtlas {
let texture_creator = canvas.texture_creator();
let atlas_bytes = get_asset_bytes(Asset::AtlasImage).unwrap();
let atlas_bytes = Asset::AtlasImage.get_bytes().unwrap();
let texture = texture_creator.load_texture_bytes(&atlas_bytes).unwrap();
@@ -83,6 +83,7 @@ pub fn create_test_world() -> (World, Schedule) {
world.insert_resource(Events::<pacman::error::GameError>::default());
world.insert_resource(Events::<AudioEvent>::default());
world.insert_resource(ScoreResource(0));
world.insert_resource(FruitSprites::default());
world.insert_resource(AudioState::default());
world.insert_resource(GlobalState { exit: false });
world.insert_resource(DebugState::default());