Compare commits

...

12 Commits

Author SHA1 Message Date
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
34 changed files with 522 additions and 178 deletions

4
.gitignore vendored
View File

@@ -20,7 +20,3 @@ coverage.html
# Profiling output
flamegraph.svg
/profile.*
# temporary
assets/game/sound/*.wav
/*.py

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.1"
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.1"
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

View File

@@ -37,3 +37,8 @@ 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

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

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

@@ -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,43 @@
//! 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::{
mixer::{self, Chunk, InitFlag, LoaderRWops, DEFAULT_FORMAT},
mixer::{self, Chunk, InitFlag, LoaderRWops, AUDIO_S16LSB, DEFAULT_CHANNELS},
rwops::RWops,
};
use strum::IntoEnumIterator;
const SOUND_ASSETS: [Asset; 4] = [Asset::Wav1, Asset::Wav2, Asset::Wav3, Asset::Wav4];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Sound {
Waka(u8),
PacmanDeath,
ExtraLife,
Fruit,
Ghost,
Beginning,
Intermission,
}
impl IntoEnumIterator for Sound {
type Iterator = std::vec::IntoIter<Sound>;
fn iter() -> Self::Iterator {
vec![
Sound::Waka(0),
Sound::Waka(1),
Sound::Waka(2),
Sound::Waka(3),
Sound::PacmanDeath,
Sound::ExtraLife,
Sound::Fruit,
Sound::Ghost,
Sound::Beginning,
Sound::Intermission,
]
.into_iter()
}
}
/// The audio system for the game.
///
@@ -14,9 +46,8 @@ const SOUND_ASSETS: [Asset; 4] = [Asset::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,
sounds: HashMap<Sound, Chunk>,
next_waka_index: u8,
muted: bool,
disabled: bool,
}
@@ -33,25 +64,36 @@ 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
let frequency = 16_000;
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) {
if let Err(e) = mixer::open_audio(frequency, format, DEFAULT_CHANNELS, 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,
sounds: HashMap::new(),
next_waka_index: 0u8,
muted: false,
disabled: true,
};
}
mixer::allocate_channels(channels);
let channels = 4;
mixer::allocate_channels(4);
// set channel volume
for i in 0..channels {
@@ -65,9 +107,8 @@ impl Audio {
tracing::warn!("Failed to initialize SDL2_mixer: {}. Audio will be disabled.", e);
return Self {
_mixer_context: None,
sounds: Vec::new(),
death_sound: None,
next_sound_index: 0,
sounds: HashMap::new(),
next_waka_index: 0u8,
muted: false,
disabled: true,
};
@@ -75,12 +116,15 @@ impl Audio {
};
// Try to load sounds, but don't panic if any fail
let mut sounds = Vec::new();
for (i, asset) in SOUND_ASSETS.iter().enumerate() {
match get_asset_bytes(*asset) {
let mut sounds = HashMap::new();
for (i, sound_type) in Sound::iter().enumerate() {
let asset = Asset::SoundFile(sound_type);
match asset.get_bytes() {
Ok(data) => match RWops::from_bytes(&data) {
Ok(rwops) => match rwops.load_wav() {
Ok(chunk) => sounds.push(chunk),
Ok(chunk) => {
sounds.insert(sound_type, chunk);
}
Err(e) => {
tracing::warn!("Failed to load sound {} from asset API: {}", i + 1, e);
}
@@ -95,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(rwops) => match rwops.load_wav() {
Ok(chunk) => Some(chunk),
@@ -120,9 +164,8 @@ impl Audio {
tracing::warn!("No sounds loaded successfully. Audio will be disabled.");
return Self {
_mixer_context: Some(mixer_context),
sounds: Vec::new(),
death_sound: None,
next_sound_index: 0,
sounds: HashMap::new(),
next_waka_index: 0u8,
muted: false,
disabled: true,
};
@@ -131,8 +174,7 @@ impl Audio {
Audio {
_mixer_context: Some(mixer_context),
sounds,
death_sound,
next_sound_index: 0,
next_waka_index: 0u8,
muted: false,
disabled: false,
}
@@ -143,32 +185,32 @@ 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) {
pub fn waka(&mut self) {
if self.disabled || self.muted || self.sounds.is_empty() {
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) & 3;
}
/// Plays the death sound effect.
pub fn death(&mut self) {
/// Plays the provided sound effect once.
pub fn play(&mut self, sound: Sound) {
if self.disabled || self.muted {
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);
}
}
@@ -179,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.
///
/// 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}")]
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),
}

View File

@@ -19,7 +19,10 @@ 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)
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!(
@@ -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,
));

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) {
@@ -57,18 +60,6 @@ pub fn init_console() -> Result<(), PlatformError> {
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()
}

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
@@ -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

@@ -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

@@ -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

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