Compare commits

..

12 Commits

36 changed files with 691 additions and 291 deletions

View File

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

View File

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

View File

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

View File

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

5
.gitignore vendored
View File

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

136
Cargo.lock generated
View File

@@ -228,6 +228,15 @@ dependencies = [
"serde", "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]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.19.0" version = "3.19.0"
@@ -268,6 +277,15 @@ dependencies = [
"portable-atomic", "portable-atomic",
] ]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "critical-section" name = "critical-section"
version = "1.2.0" version = "1.2.0"
@@ -289,6 +307,16 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 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]] [[package]]
name = "deprecate-until" name = "deprecate-until"
version = "0.1.1" version = "0.1.1"
@@ -337,6 +365,16 @@ version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" 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]] [[package]]
name = "disqualified" name = "disqualified"
version = "1.0.0" version = "1.0.0"
@@ -408,6 +446,16 @@ dependencies = [
"pin-project-lite", "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]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.3.3" version = "0.3.3"
@@ -663,7 +711,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]] [[package]]
name = "pacman" name = "pacman"
version = "0.79.2" version = "0.80.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bevy_ecs", "bevy_ecs",
@@ -678,6 +726,7 @@ dependencies = [
"phf", "phf",
"pretty_assertions", "pretty_assertions",
"rand", "rand",
"rust-embed",
"sdl2", "sdl2",
"serde", "serde",
"serde_json", "serde_json",
@@ -907,6 +956,40 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 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]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.1" version = "2.1.1"
@@ -925,6 +1008,15 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 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]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
@@ -994,6 +1086,17 @@ dependencies = [
"serde", "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]] [[package]]
name = "sharded-slab" name = "sharded-slab"
version = "0.1.4" version = "0.1.4"
@@ -1253,6 +1356,12 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.11" version = "1.0.11"
@@ -1306,6 +1415,22 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "579a42fc0b8e0c63b76519a339be31bed574929511fa53c1a3acae26eb258f29" 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]] [[package]]
name = "wasi" name = "wasi"
version = "0.14.2+wasi-0.2.4" version = "0.14.2+wasi-0.2.4"
@@ -1396,6 +1521,15 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "windows" name = "windows"
version = "0.62.0" version = "0.62.0"

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "pacman" name = "pacman"
version = "0.79.2" version = "0.80.2"
authors = ["Xevion"] authors = ["Xevion"]
edition = "2021" edition = "2021"
rust-version = "1.86.0" 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 # 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"] } 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"] } rand = { version = "0.9.2", default-features = false, features = ["thread_rng"] }
rust-embed = "8.7.2"
spin_sleep = "1.3.3" spin_sleep = "1.3.3"
# Browser-specific dependencies # Browser-specific dependencies
@@ -88,6 +89,12 @@ opt-level = "z"
lto = true lto = true
panic = "abort" 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] [package.metadata.vcpkg]
dependencies = ["sdl2", "sdl2-image", "sdl2-ttf", "sdl2-gfx", "sdl2-mixer"] dependencies = ["sdl2", "sdl2-image", "sdl2-ttf", "sdl2-gfx", "sdl2-mixer"]
git = "https://github.com/microsoft/vcpkg" 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" } x86_64-apple-darwin = { triplet = "x64-osx" }
aarch64-apple-darwin = { triplet = "arm64-osx" } aarch64-apple-darwin = { triplet = "arm64-osx" }
[features]
# Windows-specific features
force-console = []
default = []
[lints.rust] [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,8 @@ samply:
web *args: web *args:
bun run web.build.ts {{args}}; bun run web.build.ts {{args}};
caddy file-server --root dist caddy file-server --root dist
# Run cargo fix
fix:
cargo fix --workspace --lib --allow-dirty
cargo fmt --all

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 - [x] Classic maze navigation with tunnels and dot collection
- [ ] Four ghosts with their unique AI behaviors (Blinky, Pinky, Inky, and Clyde) - [ ] Four ghosts with their unique AI behaviors (Blinky, Pinky, Inky, and Clyde)
- [x] Power pellets that allow Pac-Man to eat ghosts - [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 - [ ] Progressive difficulty with faster ghosts and shorter power pellet duration
- [x] Authentic sound effects and sprites - [x] Authentic sound effects and sprites

View File

@@ -50,6 +50,7 @@ A comprehensive list of features needed to complete the Pac-Man emulation, organ
- [x] Sound effect playback - [x] Sound effect playback
- [x] Audio muting controls - [x] Audio muting controls
- [ ] Background Music - [ ] Background Music
- [x] Intro jingle
- [ ] Continuous gameplay music - [ ] Continuous gameplay music
- [ ] Escalating siren based on remaining pellets - [ ] Escalating siren based on remaining pellets
- [ ] Power pellet mode music - [ ] 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] Sound Effects
- [x] Pellet eating sounds - [x] Pellet eating sounds
- [x] Fruit collection sounds - [x] Fruit collection sounds
- [x] Ghost eaten sounds
- [x] Pac-Man Death
- [ ] Ghost movement sounds - [ ] Ghost movement sounds
- [ ] Level completion fanfare - [ ] Level completion fanfare
@@ -78,8 +81,8 @@ A comprehensive list of features needed to complete the Pac-Man emulation, organ
- [x] Keyboard controls - [x] Keyboard controls
- [x] Direction buffering for responsive controls - [x] Direction buffering for responsive controls
- [x] Touch controls for mobile - [x] Touch controls for mobile
- [ ] Pause System - [x] Pause System
- [ ] Pause/unpause functionality - [x] Pause/unpause functionality
- [ ] Pause menu with options - [ ] Pause menu with options
- [ ] Input System - [ ] Input System
- [ ] Input remapping - [ ] Input remapping
@@ -129,7 +132,7 @@ A comprehensive list of features needed to complete the Pac-Man emulation, organ
- [x] Animation system - [x] Animation system
- [x] HUD rendering - [x] HUD rendering
- [ ] Display Options - [ ] Display Options
- [ ] Fullscreen support - [x] Fullscreen support
- [x] Window resizing - [x] Window resizing
- [ ] Pause while resizing (SDL2 limitation mitigation) - [ ] Pause while resizing (SDL2 limitation mitigation)
- [ ] Multiple resolution support - [ ] 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

@@ -51,4 +51,11 @@ fn main() {
writeln!(&mut file, "}};").unwrap(); writeln!(&mut file, "}};").unwrap();
println!("cargo:rerun-if-changed=assets/game/atlas.json"); 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,21 +2,63 @@
//! On desktop, assets are embedded using include_bytes!; on Emscripten, assets are loaded from the filesystem. //! On desktop, assets are embedded using include_bytes!; on Emscripten, assets are loaded from the filesystem.
use std::borrow::Cow; 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. /// Enumeration of all game assets with cross-platform loading support.
/// ///
/// Each variant corresponds to a specific file that can be loaded either from /// Each variant corresponds to a specific file that can be loaded either from
/// binary-embedded data or embedded filesystem (Emscripten). /// binary-embedded data or embedded filesystem (Emscripten).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumIter)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Asset { pub enum Asset {
Waka(u8),
/// Main sprite atlas containing all game graphics (atlas.png) /// Main sprite atlas containing all game graphics (atlas.png)
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 /// Sound file assets
DeathSound, 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 { impl Asset {
@@ -25,47 +67,71 @@ impl Asset {
/// Paths are consistent across platforms and used by the Emscripten backend /// Paths are consistent across platforms and used by the Emscripten backend
/// for filesystem loading. Desktop builds embed assets directly and don't /// for filesystem loading. Desktop builds embed assets directly and don't
/// use these paths at runtime. /// use these paths at runtime.
#[allow(dead_code)]
pub fn path(&self) -> &str { pub fn path(&self) -> &str {
use Asset::*; use Asset::*;
match self { match self {
Waka(0) => "sound/pacman/waka/1.ogg", SoundFile(Sound::Waka(0)) => "sound/pacman/waka/1.ogg",
Waka(1) => "sound/pacman/waka/2.ogg", SoundFile(Sound::Waka(1)) => "sound/pacman/waka/2.ogg",
Waka(2) => "sound/pacman/waka/3.ogg", SoundFile(Sound::Waka(2)) => "sound/pacman/waka/3.ogg",
Waka(3..=u8::MAX) => "sound/pacman/waka/4.ogg", SoundFile(Sound::Waka(3..=u8::MAX)) => "sound/pacman/waka/4.ogg",
DeathSound => "sound/pacman/death.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", AtlasImage => "atlas.png",
Font => "TerminalVector.ttf", Font => "TerminalVector.ttf",
} }
} }
}
mod imp {
use super::*;
use crate::error::AssetError;
use crate::platform;
use tracing::trace;
/// Loads asset bytes using the appropriate platform-specific method. /// 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 /// 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 /// `Cow` allows zero-copy access to embedded data while supporting owned data
/// when loaded from disk. /// when loaded from disk.
/// ///
/// # Errors /// # 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. /// or `AssetError::Io` for filesystem I/O failures.
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> { pub fn get_bytes(&self) -> Result<Cow<'static, [u8]>, AssetError> {
trace!(asset = ?asset, "Loading game asset"); use tracing::trace;
let result = platform::get_asset_bytes(asset); trace!(asset = ?self, "Loading game asset");
let result = self.get_bytes_platform();
match &result { match &result {
Ok(bytes) => trace!(asset = ?asset, size_bytes = bytes.len(), "Asset loaded successfully"), Ok(bytes) => trace!(asset = ?self, size_bytes = bytes.len(), "Asset loaded successfully"),
Err(e) => trace!(asset = ?asset, error = ?e, "Asset loading failed"), Err(e) => trace!(asset = ?self, error = ?e, "Asset loading failed"),
} }
result 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,43 @@
//! This module handles the audio playback for the game. //! 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 sdl2::{ use sdl2::{
mixer::{self, Chunk, InitFlag, LoaderRWops, AUDIO_S16LSB, DEFAULT_CHANNELS}, mixer::{self, Chunk, InitFlag, LoaderRWops, AUDIO_S16LSB, DEFAULT_CHANNELS},
rwops::RWops, rwops::RWops,
}; };
use strum::IntoEnumIterator;
const SOUND_ASSETS: [Asset; 4] = [Asset::Waka(0), Asset::Waka(1), Asset::Waka(2), Asset::Waka(3)]; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Sound {
Waka(u8),
PacmanDeath,
ExtraLife,
Fruit,
Ghost,
Beginning,
Intermission,
}
impl IntoEnumIterator for Sound {
type Iterator = std::vec::IntoIter<Sound>;
fn iter() -> Self::Iterator {
vec![
Sound::Waka(0),
Sound::Waka(1),
Sound::Waka(2),
Sound::Waka(3),
Sound::PacmanDeath,
Sound::ExtraLife,
Sound::Fruit,
Sound::Ghost,
Sound::Beginning,
Sound::Intermission,
]
.into_iter()
}
}
/// The audio system for the game. /// The audio system for the game.
/// ///
@@ -14,9 +46,8 @@ const SOUND_ASSETS: [Asset; 4] = [Asset::Waka(0), Asset::Waka(1), Asset::Waka(2)
/// functions will silently do nothing. /// functions will silently do nothing.
pub struct Audio { pub struct Audio {
_mixer_context: Option<mixer::Sdl2MixerContext>, _mixer_context: Option<mixer::Sdl2MixerContext>,
sounds: Vec<Chunk>, sounds: HashMap<Sound, Chunk>,
death_sound: Option<Chunk>, next_waka_index: u8,
next_sound_index: usize,
muted: bool, muted: bool,
disabled: bool, disabled: bool,
} }
@@ -33,7 +64,7 @@ impl Audio {
/// If audio fails to initialize, the audio system will be disabled and /// If audio fails to initialize, the audio system will be disabled and
/// all functions will silently do nothing. /// all functions will silently do nothing.
pub fn new() -> Self { pub fn new() -> Self {
let frequency = 44_100; let frequency = 16_000;
let format = AUDIO_S16LSB; let format = AUDIO_S16LSB;
let chunk_size = { let chunk_size = {
// 256 is the minimum for Emscripten, but in practice 1024 is much more reliable // 256 is the minimum for Emscripten, but in practice 1024 is much more reliable
@@ -54,9 +85,8 @@ impl Audio {
tracing::warn!("Failed to open audio: {}. Audio will be disabled.", e); tracing::warn!("Failed to open audio: {}. Audio will be disabled.", e);
return Self { return Self {
_mixer_context: None, _mixer_context: None,
sounds: Vec::new(), sounds: HashMap::new(),
death_sound: None, next_waka_index: 0u8,
next_sound_index: 0,
muted: false, muted: false,
disabled: true, disabled: true,
}; };
@@ -77,9 +107,8 @@ impl Audio {
tracing::warn!("Failed to initialize SDL2_mixer: {}. Audio will be disabled.", e); tracing::warn!("Failed to initialize SDL2_mixer: {}. Audio will be disabled.", e);
return Self { return Self {
_mixer_context: None, _mixer_context: None,
sounds: Vec::new(), sounds: HashMap::new(),
death_sound: None, next_waka_index: 0u8,
next_sound_index: 0,
muted: false, muted: false,
disabled: true, disabled: true,
}; };
@@ -87,12 +116,15 @@ impl Audio {
}; };
// Try to load sounds, but don't panic if any fail // Try to load sounds, but don't panic if any fail
let mut sounds = Vec::new(); let mut sounds = HashMap::new();
for (i, asset) in SOUND_ASSETS.iter().enumerate() { for (i, sound_type) in Sound::iter().enumerate() {
match get_asset_bytes(*asset) { let asset = Asset::SoundFile(sound_type);
match asset.get_bytes() {
Ok(data) => match RWops::from_bytes(&data) { Ok(data) => match RWops::from_bytes(&data) {
Ok(rwops) => match rwops.load_wav() { Ok(rwops) => match rwops.load_wav() {
Ok(chunk) => sounds.push(chunk), Ok(chunk) => {
sounds.insert(sound_type, chunk);
}
Err(e) => { Err(e) => {
tracing::warn!("Failed to load sound {} from asset API: {}", i + 1, e); tracing::warn!("Failed to load sound {} from asset API: {}", i + 1, e);
} }
@@ -107,7 +139,7 @@ impl Audio {
} }
} }
let death_sound = match get_asset_bytes(Asset::DeathSound) { let death_sound = match Asset::SoundFile(Sound::PacmanDeath).get_bytes() {
Ok(data) => match RWops::from_bytes(&data) { Ok(data) => match RWops::from_bytes(&data) {
Ok(rwops) => match rwops.load_wav() { Ok(rwops) => match rwops.load_wav() {
Ok(chunk) => Some(chunk), Ok(chunk) => Some(chunk),
@@ -132,9 +164,8 @@ impl Audio {
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: HashMap::new(),
death_sound: None, next_waka_index: 0u8,
next_sound_index: 0,
muted: false, muted: false,
disabled: true, disabled: true,
}; };
@@ -143,8 +174,7 @@ impl Audio {
Audio { Audio {
_mixer_context: Some(mixer_context), _mixer_context: Some(mixer_context),
sounds, sounds,
death_sound, next_waka_index: 0u8,
next_sound_index: 0,
muted: false, muted: false,
disabled: false, disabled: false,
} }
@@ -155,32 +185,32 @@ impl Audio {
/// Automatically rotates through the four eating sound assets. The sound plays on channel 0 and the internal sound index /// 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, /// advances to the next variant. Silently returns if audio is disabled, muted,
/// or no sounds were loaded successfully. /// or no sounds were loaded successfully.
pub fn eat(&mut self) { pub fn waka(&mut self) {
if self.disabled || self.muted || self.sounds.is_empty() { if self.disabled || self.muted || self.sounds.is_empty() {
return; return;
} }
if let Some(chunk) = self.sounds.get(self.next_sound_index) { if let Some(chunk) = self.sounds.get(&Sound::Waka(self.next_waka_index)) {
match mixer::Channel(0).play(chunk, 0) { match mixer::Channel::all().play(chunk, 0) {
Ok(channel) => { 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) => { Err(e) => {
tracing::warn!("Could not play sound #{}: {}", self.next_sound_index + 1, e); tracing::warn!("Could not play sound #{}: {}", self.next_waka_index + 1, e);
} }
} }
} }
self.next_sound_index = (self.next_sound_index + 1) % self.sounds.len(); self.next_waka_index = (self.next_waka_index + 1) & 3;
} }
/// Plays the death sound effect. /// Plays the provided sound effect once.
pub fn death(&mut self) { pub fn play(&mut self, sound: Sound) {
if self.disabled || self.muted { if self.disabled || self.muted {
return; return;
} }
if let Some(chunk) = &self.death_sound { if let Some(chunk) = self.sounds.get(&sound) {
mixer::Channel::all().play(chunk, 0).ok(); let _ = mixer::Channel::all().play(chunk, 0);
} }
} }
@@ -191,6 +221,20 @@ impl Audio {
} }
} }
/// Pauses all currently playing audio channels.
pub fn pause_all(&mut self) {
if !self.disabled {
mixer::Channel::all().pause();
}
}
/// Resumes all currently playing audio channels.
pub fn resume_all(&mut self) {
if !self.disabled {
mixer::Channel::all().resume();
}
}
/// 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

@@ -46,8 +46,6 @@ pub enum AssetError {
#[error("IO error: {0}")] #[error("IO error: {0}")]
Io(#[from] io::Error), 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}")] #[error("Asset not found: {0}")]
NotFound(String), NotFound(String),
} }

View File

@@ -19,7 +19,10 @@ pub enum GameCommand {
/// Restart the current level with fresh entity positions and items /// Restart the current level with fresh entity positions and items
ResetLevel, ResetLevel,
/// Pause or resume game ticking logic /// Pause or resume game ticking logic
/// TODO: Display pause state, fix debug rendering pause distress
TogglePause, TogglePause,
/// Toggle fullscreen mode (desktop only)
ToggleFullscreen,
} }
/// Global events that flow through the ECS event system to coordinate game behavior. /// 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::events::{CollisionTrigger, GameEvent, StageTransition};
use crate::map::builder::Map; use crate::map::builder::Map;
use crate::map::direction::Direction; use crate::map::direction::Direction;
use crate::systems::item::PelletCount;
use crate::systems::state::IntroPlayed;
use crate::systems::{ use crate::systems::{
self, audio_system, blinking_system, collision_system, combined_render_system, directional_render_system, 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, 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, BackbufferResource, Blinking, BufferedDirection, Collider, DebugState, DebugTextureResource, DeltaTime, DirectionalAnimation,
EntityType, Frozen, FruitSprites, GameStage, Ghost, GhostAnimation, GhostAnimations, GhostBundle, GhostCollider, GhostState, EntityType, Frozen, FruitSprites, GameStage, Ghost, GhostAnimation, GhostAnimations, GhostBundle, GhostCollider, GhostState,
GlobalState, ItemBundle, ItemCollider, LastAnimationState, LinearAnimation, MapTextureResource, MovementModifiers, NodeId, GlobalState, ItemBundle, ItemCollider, LastAnimationState, LinearAnimation, MapTextureResource, MovementModifiers, NodeId,
PacmanCollider, PlayerAnimation, PlayerBundle, PlayerControlled, PlayerDeathAnimation, PlayerLives, Position, RenderDirty, PacmanCollider, Paused, PlayerAnimation, PlayerBundle, PlayerControlled, PlayerDeathAnimation, PlayerLives, Position,
Renderable, ScoreResource, StartupSequence, SystemId, SystemTimings, Timing, TouchState, Velocity, Visibility, RenderDirty, Renderable, ScoreResource, StartupSequence, SystemId, SystemTimings, Timing, TouchState, Velocity, Visibility,
}; };
use crate::texture::animated::{DirectionalTiles, TileSequence}; use crate::texture::animated::{DirectionalTiles, TileSequence};
@@ -40,7 +42,7 @@ use sdl2::video::{Window, WindowContext};
use sdl2::EventPump; use sdl2::EventPump;
use crate::{ use crate::{
asset::{get_asset_bytes, Asset}, asset::Asset,
events::GameCommand, events::GameCommand,
map::render::MapRenderer, map::render::MapRenderer,
systems::{BatchedLinesResource, Bindings, CursorPosition, TtfAtlasResource}, systems::{BatchedLinesResource, Bindings, CursorPosition, TtfAtlasResource},
@@ -247,7 +249,7 @@ impl Game {
debug_texture.set_blend_mode(BlendMode::Blend); debug_texture.set_blend_mode(BlendMode::Blend);
debug_texture.set_scale_mode(ScaleMode::Nearest); 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 font_asset = RWops::from_bytes(font_data).map_err(|_| GameError::Sdl("Failed to load font".to_string()))?;
let debug_font = ttf_context let debug_font = ttf_context
.load_font_from_rwops(font_asset, constants::ui::DEBUG_FONT_SIZE) .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>)> { fn load_atlas_and_map_tiles(texture_creator: &TextureCreator<WindowContext>) -> GameResult<(SpriteAtlas, Vec<AtlasTile>)> {
trace!("Loading atlas image from embedded assets"); 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| { let atlas_texture = texture_creator.load_texture_bytes(&atlas_bytes).map_err(|e| {
if e.to_string().contains("format") || e.to_string().contains("unsupported") { if e.to_string().contains("format") || e.to_string().contains("unsupported") {
GameError::Texture(crate::error::TextureError::InvalidFormat(format!( GameError::Texture(crate::error::TextureError::InvalidFormat(format!(
@@ -430,7 +432,7 @@ impl Game {
world.insert_resource(GlobalState { exit: false }); world.insert_resource(GlobalState { exit: false });
world.insert_resource(PlayerLives::default()); world.insert_resource(PlayerLives::default());
world.insert_resource(ScoreResource(0)); 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(SystemTimings::default());
world.insert_resource(Timing::default()); world.insert_resource(Timing::default());
world.insert_resource(Bindings::default()); world.insert_resource(Bindings::default());
@@ -438,11 +440,13 @@ impl Game {
world.insert_resource(RenderDirty::default()); world.insert_resource(RenderDirty::default());
world.insert_resource(DebugState::default()); world.insert_resource(DebugState::default());
world.insert_resource(AudioState::default()); world.insert_resource(AudioState::default());
world.insert_resource(IntroPlayed::default());
world.insert_resource(CursorPosition::default()); world.insert_resource(CursorPosition::default());
world.insert_resource(TouchState::default()); world.insert_resource(TouchState::default());
world.insert_resource(GameStage::Starting(StartupSequence::TextOnly { world.insert_resource(GameStage::Starting(StartupSequence::TextOnly {
remaining_ticks: constants::startup::STARTUP_FRAMES, remaining_ticks: constants::startup::STARTUP_FRAMES,
})); }));
world.insert_resource(Paused(false));
world.insert_non_send_resource(event_pump); world.insert_non_send_resource(event_pump);
world.insert_non_send_resource::<&mut Canvas<Window>>(Box::leak(Box::new(canvas))); 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) { fn configure_schedule(schedule: &mut Schedule) {
let stage_system = profile(SystemId::Stage, systems::stage_system); let stage_system = profile(SystemId::Stage, systems::stage_system);
let input_system = profile(SystemId::Input, systems::input::input_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_control_system = profile(SystemId::PlayerControls, systems::player_control_system);
let player_movement_system = profile(SystemId::PlayerMovement, systems::player_movement_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); let player_tunnel_slowdown_system = profile(SystemId::PlayerMovement, systems::player::player_tunnel_slowdown_system);
@@ -483,6 +488,9 @@ impl Game {
*local % 2 == 0 *local % 2 == 0
}), }),
player_control_system, player_control_system,
pause_system,
#[cfg(not(target_os = "emscripten"))]
profile(SystemId::Input, systems::handle_fullscreen_command),
) )
.chain(); .chain();
@@ -525,9 +533,9 @@ impl Game {
)) ))
.configure_sets(( .configure_sets((
GameplaySet::Input, GameplaySet::Input,
GameplaySet::Update, GameplaySet::Update.run_if(|paused: Res<Paused>| !paused.0),
GameplaySet::Respond, GameplaySet::Respond.run_if(|paused: Res<Paused>| !paused.0),
RenderSet::Animation, RenderSet::Animation.run_if(|paused: Res<Paused>| !paused.0),
RenderSet::Draw, RenderSet::Draw,
RenderSet::Present, RenderSet::Present,
)); ));

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. #![cfg_attr(all(not(use_console), target_os = "windows"), windows_subsystem = "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, feature(coverage_attribute))]
#![cfg_attr(coverage_nightly, coverage(off))] #![cfg_attr(coverage_nightly, coverage(off))]
use std::env;
use crate::{app::App, constants::LOOP_TIME}; use crate::{app::App, constants::LOOP_TIME};
use tracing::info; use tracing::info;
@@ -32,9 +34,12 @@ 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.
pub fn main() { 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 // 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"); let mut app = App::new().expect("Could not create app");

View File

@@ -1,12 +1,15 @@
//! Desktop platform implementation. //! Desktop platform implementation.
use std::borrow::Cow;
use std::time::Duration; use std::time::Duration;
use rand::rngs::ThreadRng; use rand::rngs::ThreadRng;
use rust_embed::Embed;
use crate::asset::Asset; use crate::error::PlatformError;
use crate::error::{AssetError, PlatformError};
#[derive(Embed)]
#[folder = "assets/game/"]
struct EmbeddedAssets;
/// Desktop platform implementation. /// Desktop platform implementation.
pub fn sleep(duration: Duration, focused: bool) { pub fn sleep(duration: Duration, focused: bool) {
@@ -17,129 +20,152 @@ pub fn sleep(duration: Duration, focused: bool) {
} }
} }
pub fn init_console() -> Result<(), PlatformError> { 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)] #[cfg(windows)]
{ {
use crate::platform::tracing_buffer::setup_switchable_subscriber; // If using windows subsystem, and force_console is true, allocate a new console window
use tracing::{debug, info, trace}; if force_console && cfg!(not(use_console)) {
use windows::Win32::System::Console::GetConsoleWindow; use crate::platform::tracing_buffer::{SwitchableMakeWriter, SwitchableWriter};
// Setup buffered tracing subscriber that will buffer logs until console is ready // Setup deferred tracing subscriber that will buffer logs until console is ready
let switchable_writer = setup_switchable_subscriber(); 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 tracing_subscriber::registry()
if unsafe { !GetConsoleWindow().0.is_null() } { .with(console_layer)
debug!("Already have a console window"); .with(file_layer)
return Ok(()); .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 { } 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()? { #[cfg(not(windows))]
trace!(r#type = file_type, "Existing output detected"); {
} else { // Set up tracing subscriber with ANSI colors enabled
trace!("No existing output detected"); 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 tracing_subscriber::registry()
attach_to_parent_console()?; .with(console_layer)
info!("Successfully attached to parent console"); .with(file_layer)
} .with(ErrorLayer::default())
.init();
// 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:?}");
}
} }
Ok(()) Ok(())
} }
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
match asset {
Asset::Waka(0) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/1.ogg"))),
Asset::Waka(1) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/2.ogg"))),
Asset::Waka(2) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/3.ogg"))),
Asset::Waka(3..=u8::MAX) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/4.ogg"))),
Asset::AtlasImage => Ok(Cow::Borrowed(include_bytes!("../../assets/game/atlas.png"))),
Asset::Font => Ok(Cow::Borrowed(include_bytes!("../../assets/game/TerminalVector.ttf"))),
Asset::DeathSound => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/death.ogg"))),
}
}
pub fn rng() -> ThreadRng { pub fn rng() -> ThreadRng {
rand::rng() rand::rng()
} }
/* Internal functions */ /// Enable ANSI escape sequence support in the Windows console
/// Check if the output stream has been setup by a parent process
/// Windows-only /// Windows-only
#[cfg(windows)] #[cfg(windows)]
fn is_output_setup() -> Result<Option<&'static str>, PlatformError> { fn enable_ansi_support() -> Result<(), PlatformError> {
use tracing::{trace, warn}; use windows::Win32::System::Console::{
GetConsoleMode, GetStdHandle, SetConsoleMode, CONSOLE_MODE, ENABLE_VIRTUAL_TERMINAL_PROCESSING, STD_ERROR_HANDLE,
use windows::Win32::Storage::FileSystem::{ STD_OUTPUT_HANDLE,
GetFileType, FILE_TYPE_CHAR, FILE_TYPE_DISK, FILE_TYPE_PIPE, FILE_TYPE_REMOTE, FILE_TYPE_UNKNOWN,
}; };
use windows_sys::Win32::{ // Enable ANSI processing for stdout
Foundation::INVALID_HANDLE_VALUE, unsafe {
System::Console::{GetStdHandle, STD_OUTPUT_HANDLE}, 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 mut console_mode = CONSOLE_MODE(0);
let handle = match unsafe { GetStdHandle(STD_OUTPUT_HANDLE) } { GetConsoleMode(stdout_handle, &mut console_mode)
INVALID_HANDLE_VALUE => { .map_err(|e| PlatformError::ConsoleInit(format!("Failed to get console mode: {:?}", e)))?;
return Err(PlatformError::ConsoleInit("Invalid handle".to_string()));
}
handle => handle,
};
// Identify the file type of the handle and whether it's 'well known' (i.e. we trust it to be a reasonable output destination) console_mode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING;
let (well_known, file_type) = match unsafe { SetConsoleMode(stdout_handle, console_mode)
use windows::Win32::Foundation::HANDLE; .map_err(|e| PlatformError::ConsoleInit(format!("Failed to enable ANSI for stdout: {:?}", e)))?;
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")
}
};
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 let mut console_mode = CONSOLE_MODE(0);
Ok(well_known.then_some(file_type)) 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 /// Windows-only
#[cfg(windows)] #[cfg(windows)]
fn attach_to_parent_console() -> Result<(), PlatformError> { fn allocate_console() -> Result<(), PlatformError> {
use windows::{ use windows::{
core::PCSTR, core::PCSTR,
Win32::{ Win32::{
Foundation::{GENERIC_READ, GENERIC_WRITE}, Foundation::{GENERIC_READ, GENERIC_WRITE},
Storage::FileSystem::{CreateFileA, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING}, Storage::FileSystem::{CreateFileA, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING},
System::Console::{ System::Console::{AllocConsole, SetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE},
AttachConsole, FreeConsole, SetStdHandle, ATTACH_PARENT_PROCESS, STD_ERROR_HANDLE, STD_OUTPUT_HANDLE,
},
}, },
}; };
// Attach the process to the parent's console // Allocate a new console for this process
unsafe { AttachConsole(ATTACH_PARENT_PROCESS) } unsafe { AllocConsole() }.map_err(|e| PlatformError::ConsoleInit(format!("Failed to allocate console: {:?}", e)))?;
.map_err(|e| PlatformError::ConsoleInit(format!("Failed to attach to parent 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); let pcstr = PCSTR::from_raw(c"CONOUT$".as_ptr() as *const u8);
CreateFileA::<PCSTR>( CreateFileA::<PCSTR>(
pcstr, pcstr,
@@ -151,28 +177,32 @@ fn attach_to_parent_console() -> Result<(), PlatformError> {
None, 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 // Redirect stdin
if let Some(handle_error) = unsafe { SetStdHandle(STD_OUTPUT_HANDLE, handle) } let stdin_handle = unsafe {
.map_err(|e| PlatformError::ConsoleInit(format!("Failed to set console output handle: {:?}", e))) let pcstr = PCSTR::from_raw(c"CONIN$".as_ptr() as *const u8);
.and_then(|_| { CreateFileA::<PCSTR>(
unsafe { SetStdHandle(STD_ERROR_HANDLE, handle) } pcstr,
.map_err(|e| PlatformError::ConsoleInit(format!("Failed to set console error handle: {:?}", e))) (GENERIC_READ | GENERIC_WRITE).0,
}) FILE_SHARE_READ | FILE_SHARE_WRITE,
.err() None,
{ OPEN_EXISTING,
// If either set handle call fails, free the console FILE_FLAGS_AND_ATTRIBUTES(0),
unsafe { FreeConsole() } None,
// 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))?;
} }
.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(()) Ok(())
} }

View File

@@ -1,13 +1,10 @@
//! Emscripten platform implementation. //! Emscripten platform implementation.
use crate::asset::Asset; use crate::error::PlatformError;
use crate::error::{AssetError, PlatformError};
use crate::formatter::CustomFormatter; use crate::formatter::CustomFormatter;
use rand::{rngs::SmallRng, SeedableRng}; use rand::{rngs::SmallRng, SeedableRng};
use sdl2::rwops::RWops;
use std::borrow::Cow;
use std::ffi::CString; use std::ffi::CString;
use std::io::{self, Read, Write}; use std::io::{self, Write};
use std::time::Duration; use std::time::Duration;
// Emscripten FFI functions // 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}; use tracing_subscriber::{fmt, layer::SubscriberExt, EnvFilter};
// Set up a custom tracing subscriber that writes directly to emscripten console // 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 { pub fn rng() -> SmallRng {
SmallRng::from_os_rng() SmallRng::from_os_rng()
} }

View File

@@ -1,14 +1,11 @@
//! Buffered tracing setup for handling logs before console attachment. //! Buffered tracing setup for handling logs before console attachment.
use crate::formatter::CustomFormatter;
use parking_lot::Mutex; use parking_lot::Mutex;
use std::io; use std::io;
use std::io::Write; use std::io::Write;
use std::sync::Arc; use std::sync::Arc;
use tracing::{debug, Level}; use tracing::debug;
use tracing_error::ErrorLayer;
use tracing_subscriber::fmt::MakeWriter; use tracing_subscriber::fmt::MakeWriter;
use tracing_subscriber::layer::SubscriberExt;
/// A thread-safe buffered writer that stores logs in memory until flushed. /// A thread-safe buffered writer that stores logs in memory until flushed.
#[derive(Clone)] #[derive(Clone)]
@@ -76,7 +73,7 @@ impl SwitchableWriter {
// Get buffer size before flushing for debug logging // Get buffer size before flushing for debug logging
let buffer_size = self.buffered_writer.buffer_size(); 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())?; self.buffered_writer.flush_to(io::stdout())?;
// Switch to direct mode (and drop the lock) // Switch to direct mode (and drop the lock)
@@ -130,23 +127,3 @@ impl<'a> MakeWriter<'a> for SwitchableMakeWriter {
self.writer.clone() 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 tracing::{debug, trace};
use crate::{audio::Audio, error::GameError}; use crate::{audio::Audio, audio::Sound, error::GameError};
/// Resource for tracking audio state /// Resource for tracking audio state
#[derive(Resource, Debug, Clone, Default)] #[derive(Resource, Debug, Clone, Default)]
@@ -25,12 +25,16 @@ pub struct AudioState {
/// Events for triggering audio playback /// Events for triggering audio playback
#[derive(Event, Debug, Clone, Copy, PartialEq, Eq)] #[derive(Event, Debug, Clone, Copy, PartialEq, Eq)]
pub enum AudioEvent { pub enum AudioEvent {
/// Play the "eat" sound when Pac-Man consumes a pellet /// Play a specific sound effect
PlayEat, PlaySound(Sound),
/// Play the death sound /// Play the cycling waka sound variant
PlayDeath, Waka,
/// Stop all currently playing sounds /// Stop all currently playing sounds
StopAll, StopAll,
/// Pause all sounds
Pause,
/// Resume all sounds
Resume,
} }
/// Non-send resource wrapper for SDL2 audio system /// Non-send resource wrapper for SDL2 audio system
@@ -57,10 +61,10 @@ pub fn audio_system(
// Process audio events // Process audio events
for event in audio_events.read() { for event in audio_events.read() {
match event { match event {
AudioEvent::PlayEat => { AudioEvent::Waka => {
if !audio.0.is_disabled() && !audio_state.muted { if !audio.0.is_disabled() && !audio_state.muted {
trace!(sound_index = audio_state.sound_index, "Playing eat sound"); trace!(sound_index = audio_state.sound_index, "Playing eat sound");
audio.0.eat(); audio.0.waka();
// Update the sound index for cycling through sounds // Update the sound index for cycling through sounds
audio_state.sound_index = (audio_state.sound_index + 1) % 4; audio_state.sound_index = (audio_state.sound_index + 1) % 4;
// 4 eat sounds available // 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 { if !audio.0.is_disabled() && !audio_state.muted {
trace!("Playing death sound"); trace!(?sound, "Playing sound");
audio.0.death(); audio.0.play(*sound);
} else { } else {
debug!( debug!(
disabled = audio.0.is_disabled(), disabled = audio.0.is_disabled(),
muted = audio_state.muted, 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"); 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 tracing::{debug, trace, warn};
use crate::audio::Sound;
use crate::{ use crate::{
constants, constants,
systems::{movement::Position, AudioEvent, DyingSequence, FruitSprites, GameStage, Ghost, ScoreResource, SpawnTrigger}, systems::{movement::Position, AudioEvent, DyingSequence, FruitSprites, GameStage, Ghost, ScoreResource, SpawnTrigger},
@@ -165,8 +166,8 @@ pub fn ghost_collision_observer(
ghost_type, ghost_type,
}); });
// Play eat sound // Play ghost eaten sound
events.write(AudioEvent::PlayEat); events.write(AudioEvent::PlaySound(Sound::Ghost));
} else if matches!(*ghost_state, GhostState::Normal) { } else if matches!(*ghost_state, GhostState::Normal) {
// Pac-Man dies // Pac-Man dies
warn!(ghost = ?ghost_type, "Pacman hit by normal ghost, player 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 // Trigger audio if appropriate
if entity_type.is_collectible() { 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 // Make non-eaten ghosts frightened when power pellet is collected

View File

@@ -90,6 +90,8 @@ impl Default for Bindings {
{ {
key_bindings.insert(Keycode::Escape, GameCommand::Exit); key_bindings.insert(Keycode::Escape, GameCommand::Exit);
key_bindings.insert(Keycode::Q, 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([ let movement_keys = HashSet::from([

View File

@@ -1,5 +1,5 @@
use std::mem::discriminant; use std::mem::discriminant;
use tracing::{debug, info, warn}; use tracing::{debug, info};
use crate::constants; use crate::constants;
use crate::events::StageTransition; use crate::events::StageTransition;
@@ -20,12 +20,24 @@ use bevy_ecs::{
system::{Commands, Query, Res, ResMut, Single}, 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)] #[derive(Resource, Clone)]
pub struct PlayerAnimation(pub DirectionalAnimation); pub struct PlayerAnimation(pub DirectionalAnimation);
#[derive(Resource, Clone)] #[derive(Resource, Clone)]
pub struct PlayerDeathAnimation(pub LinearAnimation); 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. /// A resource to track the overall stage of the game from a high-level perspective.
#[derive(Resource, Debug, PartialEq, Eq, Clone, Copy)] #[derive(Resource, Debug, PartialEq, Eq, Clone, Copy)]
pub enum GameStage { pub enum GameStage {
@@ -45,6 +57,49 @@ pub enum GameStage {
GameOver, 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 { pub trait TooSimilar {
fn too_similar(&self, other: &Self) -> bool; fn too_similar(&self, other: &Self) -> bool;
} }
@@ -159,9 +214,10 @@ pub fn stage_system(
player: Single<(Entity, &mut Position), With<PlayerControlled>>, player: Single<(Entity, &mut Position), With<PlayerControlled>>,
mut item_query: Query<(Entity, &EntityType), With<ItemCollider>>, mut item_query: Query<(Entity, &EntityType), With<ItemCollider>>,
mut ghost_query: Query<(Entity, &Ghost, &mut Position, &mut GhostState), (With<GhostCollider>, Without<PlayerControlled>)>, 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 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 // Handle stage transition requests before normal ticking
for event in stage_event_reader.read() { for event in stage_event_reader.read() {
@@ -172,7 +228,7 @@ pub fn stage_system(
let pac_node = player.1.current_node(); let pac_node = player.1.current_node();
debug!(ghost = ?ghost_type, node = pac_node, "Ghost eaten, entering pause state"); 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, remaining_ticks: 30,
ghost_entity, ghost_entity,
ghost_type, ghost_type,
@@ -180,29 +236,11 @@ pub fn stage_system(
}); });
} }
let new_state: GameStage = match new_state.unwrap_or(*game_state) { let new_state: GameStage = new_state_opt.unwrap_or_else(|| match *game_state {
GameStage::Starting(startup) => match startup { GameStage::Playing => {
StartupSequence::TextOnly { remaining_ticks } => { // This is the default state, do nothing
if remaining_ticks > 0 { *game_state
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,
GameStage::GhostEatenPause { GameStage::GhostEatenPause {
remaining_ticks, remaining_ticks,
ghost_entity, ghost_entity,
@@ -221,11 +259,37 @@ pub fn stage_system(
GameStage::Playing 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 } => { DyingSequence::Frozen { remaining_ticks } => {
if remaining_ticks > 0 { if remaining_ticks > 0 {
GameStage::PlayerDying(DyingSequence::Frozen { GameStage::PlayerDying(DyingSequence::Frozen {
remaining_ticks: remaining_ticks - 1, remaining_ticks: remaining_ticks.saturating_sub(1),
}) })
} else { } else {
let death_animation = &player_death_animation.0; let death_animation = &player_death_animation.0;
@@ -237,7 +301,7 @@ pub fn stage_system(
DyingSequence::Animating { remaining_ticks } => { DyingSequence::Animating { remaining_ticks } => {
if remaining_ticks > 0 { if remaining_ticks > 0 {
GameStage::PlayerDying(DyingSequence::Animating { GameStage::PlayerDying(DyingSequence::Animating {
remaining_ticks: remaining_ticks - 1, remaining_ticks: remaining_ticks.saturating_sub(1),
}) })
} else { } else {
GameStage::PlayerDying(DyingSequence::Hidden { remaining_ticks: 60 }) GameStage::PlayerDying(DyingSequence::Hidden { remaining_ticks: 60 })
@@ -246,7 +310,7 @@ pub fn stage_system(
DyingSequence::Hidden { remaining_ticks } => { DyingSequence::Hidden { remaining_ticks } => {
if remaining_ticks > 0 { if remaining_ticks > 0 {
GameStage::PlayerDying(DyingSequence::Hidden { GameStage::PlayerDying(DyingSequence::Hidden {
remaining_ticks: remaining_ticks - 1, remaining_ticks: remaining_ticks.saturating_sub(1),
}) })
} else { } else {
player_lives.0 = player_lives.0.saturating_sub(1); 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"); info!(remaining_lives = player_lives.0, "Player died, returning to startup sequence");
GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 }) GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 })
} else { } else {
warn!("All lives lost, game over"); info!("All lives lost, game over");
GameStage::GameOver GameStage::GameOver
} }
} }
} }
}, },
GameStage::GameOver => GameStage::GameOver, GameStage::GameOver => *game_state,
}; });
if old_state == new_state { if old_state == new_state {
return; return;
@@ -327,7 +391,7 @@ pub fn stage_system(
.insert((Dying, player_death_animation.0.clone())); .insert((Dying, player_death_animation.0.clone()));
// Play the death sound // Play the death sound
audio_events.write(AudioEvent::PlayDeath); audio_events.write(AudioEvent::PlaySound(crate::audio::Sound::PacmanDeath));
} }
(_, GameStage::PlayerDying(DyingSequence::Hidden { .. })) => { (_, GameStage::PlayerDying(DyingSequence::Hidden { .. })) => {
// Pac-Man's death animation is complete, so he should be hidden just like the ghosts. // 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() { for entity in blinking_query.iter_mut() {
commands.entity(entity).remove::<Frozen>(); commands.entity(entity).remove::<Frozen>();
} }
// Reset intro flag for the next round
intro_played.0 = false;
} }
(_, GameStage::GameOver) => { (_, GameStage::GameOver) => {
// Freeze blinking // Freeze blinking

View File

@@ -1,10 +1,9 @@
use pacman::asset::Asset; use pacman::asset::Asset;
use speculoos::prelude::*; use speculoos::prelude::*;
use strum::IntoEnumIterator;
#[test] #[test]
fn all_asset_paths_exist() { fn all_asset_paths_exist() {
for asset in Asset::iter() { for asset in Asset::into_iter() {
let path = asset.path(); let path = asset.path();
let full_path = format!("assets/game/{}", 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 bevy_ecs::{entity::Entity, event::Events, schedule::Schedule, world::World};
use glam::{U16Vec2, Vec2}; use glam::{U16Vec2, Vec2};
use pacman::{ use pacman::{
asset::{get_asset_bytes, Asset}, asset::Asset,
constants::RAW_BOARD, constants::RAW_BOARD,
events::{CollisionTrigger, GameEvent}, events::{CollisionTrigger, GameEvent},
game::ATLAS_FRAMES, game::ATLAS_FRAMES,
@@ -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 { pub fn create_atlas(canvas: &mut sdl2::render::Canvas<sdl2::video::Window>) -> SpriteAtlas {
let texture_creator = canvas.texture_creator(); 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(); let texture = texture_creator.load_texture_bytes(&atlas_bytes).unwrap();