Compare commits

..

24 Commits

Author SHA1 Message Date
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
Ryan Walters
d86864b6a3 feat: fruit display hud 2025-09-10 22:00:11 -05:00
Ryan Walters
d7a6ee7684 fix: flush world after switching to observer-based item collection 2025-09-10 21:45:10 -05:00
Ryan Walters
d84f0c831e feat: proper scheduling via SystemSet, non-conditional game systems, better collision handling 2025-09-10 21:36:51 -05:00
Ryan Walters
ae19ca1795 feat: rewrite ghost/item collision eventing into trigger-based observer 2025-09-10 17:15:15 -05:00
Ryan Walters
abf341d753 fix: avoid constant recalculation of max character height in TtfAtlas 2025-09-10 14:09:07 -05:00
Ryan Walters
7b6dad0c74 refactor: remove unused component, simplify visibility check defaulting behavior, reformat STORY.md 2025-09-10 11:17:12 -05:00
Ryan Walters
5563b64044 refactor: replace immutable Hidden component with mutable Visibility component 2025-09-10 00:45:16 -05:00
Ryan Walters
cb691b0907 refactor: move animation components into new systems/animation submodule 2025-09-10 00:26:49 -05:00
Ryan Walters
ce8ea347e1 refactor: reorganize hud-related elements into systems/hud submodule 2025-09-09 17:00:32 -05:00
Ryan Walters
afae3c5e7b fix: restore target_os for linux linker arg, add documentation detail 2025-09-09 16:49:01 -05:00
Ryan Walters
4f7902fc50 fix: cfg on ConsoleInit for windows/emscripten, use simplified cfg for windows/linux 2025-09-09 16:41:59 -05:00
Ryan Walters
2a2cca675a fix: cfg limit tracing_buffer to windows only 2025-09-09 16:27:35 -05:00
Ryan Walters
f3a6b72931 chore: remove unused tests, fixup README & disable bad markdown lints 2025-09-09 14:22:06 -05:00
Ryan Walters
ca006b5073 refactor: remove dead code, tune lints, remove useless tests 2025-09-09 14:22:06 -05:00
Ryan Walters
139afb2d40 chore: update ROADMAP.md with latest progress, detail core feature targets & mechanics 2025-09-09 11:42:26 -05:00
Ryan Walters
5d56b31353 feat: fruit spawning mechanism, sprites, pellet counting, fruit trigger observer 2025-09-09 11:26:05 -05:00
Ryan Walters
b4990af109 chore: fix clippy lints part 972 2025-09-08 23:53:30 -05:00
Ryan Walters
088c496ad9 refactor: store common components & bundles in 'common' submodule, move others directly into relevant files, create 'animation' submodule 2025-09-08 23:53:30 -05:00
Ryan Walters
5bdf11dfb6 feat: enhance slow frame timing warning 2025-09-08 19:19:23 -05:00
Ryan Walters
c163171304 refactor: use Single<> for player queries 2025-09-08 16:50:28 -05:00
Ryan Walters
63e1059df8 feat: implement entity-based sprite system for HUD display (lives)
- Spawn HUD elements as Renderables with simple change-based entity updates
- Updated rendering systems to accommodate new precise pixel positioning for life sprites.
2025-09-08 16:22:40 -05:00
67 changed files with 1959 additions and 1887 deletions

View File

@@ -7,6 +7,8 @@ rustflags = [
]
runner = "node"
# despite being semantically identical to `target_os = "linux"`, the `cfg(linux)` syntax is not supported here. Who knows why...
# https://github.com/Xevion/Pac-Man/actions/runs/17596477856
[target.'cfg(target_os = "linux")']
rustflags = [
# Manually link zlib.

View File

@@ -41,17 +41,3 @@ repos:
language: system
types_or: [rust, cargo, cargo-lock]
pass_filenames: false
- id: bump-version
name: bump version based on commit message
entry: python scripts/bump-version.py
language: system
stages: [commit-msg]
always_run: true
- id: tag-version
name: tag version based on commit message
entry: python scripts/tag-version.py
language: system
stages: [post-commit]
always_run: true

2
Cargo.lock generated
View File

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

View File

@@ -1,6 +1,6 @@
[package]
name = "pacman"
version = "0.78.1"
version = "0.79.2"
authors = ["Xevion"]
edition = "2021"
rust-version = "1.86.0"
@@ -40,7 +40,7 @@ num-width = "0.1.0"
phf = { version = "0.13.1", features = ["macros"] }
# Windows-specific dependencies
[target.'cfg(target_os = "windows")'.dependencies]
[target.'cfg(windows)'.dependencies]
# Used for customizing console output on Windows; both are required due to the `windows` crate having poor Result handling with `GetStdHandle`.
windows = { version = "0.62.0", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Console"] }
windows-sys = { version = "0.61.0", features = ["Win32_System_Console"] }

View File

@@ -1,3 +1,6 @@
<!-- markdownlint-disable MD033 -->
<!-- markdownlint-disable MD041 -->
<div align="center">
<img src="assets/repo/banner.png" alt="Pac-Man Banner Screenshot">
</div>
@@ -13,7 +16,6 @@
[badge-build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml/badge.svg
[badge-coverage]: https://coveralls.io/repos/github/Xevion/Pac-Man/badge.svg?branch=master
[badge-online-demo]: https://img.shields.io/badge/Online%20Demo-Click%20Me!-brightgreen
[banner-image]: assets/repo/banner.png
[justforfunnoreally]: https://justforfunnoreally.dev
[build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml
[test]: https://github.com/Xevion/Pac-Man/actions/workflows/tests.yaml

View File

@@ -1,30 +1,161 @@
# Roadmap
A list of ideas and features that I might implement in the future.
A comprehensive list of features needed to complete the Pac-Man emulation, organized by priority and implementation complexity.
## Debug Tooling
## Core Game Features
### Ghost AI & Behavior
- [x] Core Ghost System Architecture
- [x] Ghost entity types (Blinky, Pinky, Inky, Clyde)
- [x] Ghost state management (Normal, Frightened, Eyes)
- [x] Ghost movement and pathfinding systems
- [ ] Authentic Ghost AI Personalities
- [ ] Blinky (Red): Direct chase behavior
- [ ] Pinky (Pink): Target 4 tiles ahead of Pac-Man
- [ ] Inky (Cyan): Complex behavior based on Blinky's position
- [ ] Clyde (Orange): Chase when far, flee when close
- [x] Mode Switching System
- [ ] Scatter/Chase pattern with proper timing
- [x] Frightened mode transitions
- [ ] Ghost house entry/exit mechanics
- [x] Ghost House Behavior
- [x] Proper spawning sequence
- [ ] Exit timing and patterns
- [ ] House-specific movement rules
### Fruit Bonus System
- [x] Fruit Spawning Mechanics
- [x] Spawn at pellet counts 70 and 170
- [x] Fruit display in bottom-right corner
- [x] Fruit collection and scoring
- [x] Bonus point display system
### Level Progression
- [ ] Multiple Levels
- [ ] Level completion detection
- [ ] Progressive difficulty scaling
- [ ] Ghost speed increases per level
- [ ] Power pellet duration decreases
- [ ] Intermission Screens
- [ ] Between-level cutscenes
- [ ] Proper graphics and timing
### Audio System Completion
- [x] Core Audio Infrastructure
- [x] Audio event system
- [x] Sound effect playback
- [x] Audio muting controls
- [ ] Background Music
- [ ] Continuous gameplay music
- [ ] Escalating siren based on remaining pellets
- [ ] Power pellet mode music
- [ ] Intermission music
- [x] Sound Effects
- [x] Pellet eating sounds
- [x] Fruit collection sounds
- [ ] Ghost movement sounds
- [ ] Level completion fanfare
### Game Mechanics
- [ ] Bonus Lives
- [ ] Extra life at 10,000 points
- [x] Life counter display
- [ ] High Score System
- [ ] High score tracking
- [x] High score display
- [ ] Score persistence
## Secondary Features (Medium Priority)
### Game Polish
- [x] Core Input System
- [x] Keyboard controls
- [x] Direction buffering for responsive controls
- [x] Touch controls for mobile
- [ ] Pause System
- [ ] Pause/unpause functionality
- [ ] Pause menu with options
- [ ] Input System
- [ ] Input remapping
- [ ] Multiple input methods
## Advanced Features (Lower Priority)
### Difficulty Options
- [ ] Easy/Normal/Hard modes
- [ ] Customizable ghost speeds
### Data Persistence
- [ ] High Score Persistence
- [ ] Save high scores to file
- [ ] High score table display
- [ ] Settings Storage
- [ ] Save user preferences
- [ ] Audio/visual settings
- [ ] Statistics Tracking
- [ ] Game statistics
- [ ] Achievement system
### Debug & Development Tools
- [ ] Game state visualization
- [ ] Game speed controls + pausing
- [ ] Log tracing
- [x] Performance details
- [x] Core Debug Infrastructure
- [x] Debug mode toggle
- [x] Comprehensive game event logging
- [x] Performance profiling tools
- [ ] Game State Visualization
- [ ] Ghost AI state display
- [ ] Pathfinding visualization
- [ ] Collision detection display
- [ ] Game Speed Controls
- [ ] Variable game speed for testing
- [ ] Frame-by-frame stepping
## Customization
## Customization & Extensions
- [ ] Themes & Colors
- Color-blind friendly options
- [ ] Perfected ghost AI algorithms
- [ ] Support for >4 ghosts
- [ ] Custom level generation with multi-map tunneling
### Visual Customization
## Online Features
- [x] Core Rendering System
- [x] Sprite-based rendering
- [x] Layered rendering system
- [x] Animation system
- [x] HUD rendering
- [ ] Display Options
- [ ] Fullscreen support
- [x] Window resizing
- [ ] Pause while resizing (SDL2 limitation mitigation)
- [ ] Multiple resolution support
- [ ] Scoreboard system
- Axum server with database and OAuth2 auth
- Authentication via GitHub/Discord/Google
- Profile features:
- [ ] Optional avatars (downscaled to match 8-bit aesthetic)
- Custom names (3-14 chars, filtered for abuse)
- Zero-config client implementation
- Uses default API endpoint
- Manual override available
### Gameplay Extensions
- [ ] Advanced Ghost AI
- [ ] Support for >4 ghosts
- [ ] Custom ghost behaviors
- [ ] Level Generation
- [ ] Custom level creation
- [ ] Multi-map tunneling
- [ ] Level editor
## Online Features (Future)
### Scoreboard System
- [ ] Backend Infrastructure
- [ ] Axum server with database
- [ ] OAuth2 authentication
- [ ] GitHub/Discord/Google auth
- [ ] Profile Features
- [ ] Optional avatars (8-bit aesthetic)
- [ ] Custom names (3-14 chars, filtered)
- [ ] Client Implementation
- [ ] Zero-config client
- [ ] Default API endpoint
- [ ] Manual override available

View File

@@ -412,8 +412,8 @@ The bigger downside was that I had to toss out almost all the existing code for
This ended up being okay though, as I was able to clean up a lot of gross code, and the system ended up being easier to work with by comparison.
[code-review-video]: https://www.youtube.com/watch?v=OKs_JewEeOo
[code-review-thumbnail]: https://img.youtube.com/vi/OKs_JewEeOo/hqdefault.jpg
[code-review-video]: https://www.youtube.com/watch?v=OKs_JewEeOo
[fighting-lifetimes-1]: https://devcry.heiho.net/html/2022/20220709-rust-and-sdl2-fighting-with-lifetimes.html
[fighting-lifetimes-2]: https://devcry.heiho.net/html/2022/20220716-rust-and-sdl2-fighting-with-lifetimes-2.html
[fighting-lifetimes-3]: https://devcry.heiho.net/html/2022/20220724-rust-and-sdl2-fighting-with-lifetimes-3.html

View File

Binary file not shown.

View File

@@ -1,143 +0,0 @@
#!/usr/bin/env python3
"""
Pre-commit hook script to automatically bump Cargo.toml version based on commit message.
This script parses the commit message for version bump keywords and uses cargo set-version
to update the version in Cargo.toml accordingly.
Supported keywords:
- "major" or "breaking": Bump major version (1.0.0 -> 2.0.0)
- "minor" or "feature": Bump minor version (1.0.0 -> 1.1.0)
- "patch" or "fix" or "bugfix": Bump patch version (1.0.0 -> 1.0.1)
Usage: python scripts/bump-version.py <commit_message_file>
"""
import sys
import re
import subprocess
import os
from pathlib import Path
def get_current_version():
"""Get the current version from Cargo.toml."""
try:
result = subprocess.run(
["cargo", "metadata", "--format-version", "1", "--no-deps"],
capture_output=True,
text=True,
check=True
)
# Parse the JSON output to get version
import json
metadata = json.loads(result.stdout)
return metadata["packages"][0]["version"]
except (subprocess.CalledProcessError, json.JSONDecodeError, KeyError) as e:
print(f"Error getting current version: {e}", file=sys.stderr)
return None
def bump_version(current_version, bump_type):
"""Calculate the new version based on bump type."""
try:
major, minor, patch = map(int, current_version.split('.'))
if bump_type == "major":
return f"{major + 1}.0.0"
elif bump_type == "minor":
return f"{major}.{minor + 1}.0"
elif bump_type == "patch":
return f"{major}.{minor}.{patch + 1}"
else:
return None
except ValueError:
print(f"Invalid version format: {current_version}", file=sys.stderr)
return None
def set_version(new_version):
"""Set the new version using cargo set-version."""
try:
result = subprocess.run(
["cargo", "set-version", new_version],
capture_output=True,
text=True,
check=True
)
print(f"Successfully bumped version to {new_version}")
return True
except subprocess.CalledProcessError as e:
print(f"Error setting version: {e}", file=sys.stderr)
print(f"stdout: {e.stdout}", file=sys.stderr)
print(f"stderr: {e.stderr}", file=sys.stderr)
return False
def parse_commit_message(commit_message_file):
"""Parse the commit message file for version bump keywords."""
try:
with open(commit_message_file, 'r', encoding='utf-8') as f:
message = f.read().lower()
except FileNotFoundError:
print(f"Commit message file not found: {commit_message_file}", file=sys.stderr)
return None
except Exception as e:
print(f"Error reading commit message: {e}", file=sys.stderr)
return None
# Check for version bump keywords
if re.search(r'\b(major|breaking)\b', message):
return "major"
elif re.search(r'\b(minor|feature)\b', message):
return "minor"
elif re.search(r'\b(patch|fix|bugfix)\b', message):
return "patch"
return None
def main():
if len(sys.argv) != 2:
print("Usage: python scripts/bump-version.py <commit_message_file>", file=sys.stderr)
sys.exit(1)
commit_message_file = sys.argv[1]
# Parse commit message for version bump type
bump_type = parse_commit_message(commit_message_file)
if not bump_type:
print("No version bump keywords found in commit message")
sys.exit(0)
print(f"Found version bump type: {bump_type}")
# Get current version
current_version = get_current_version()
if not current_version:
print("Failed to get current version", file=sys.stderr)
sys.exit(1)
print(f"Current version: {current_version}")
# Calculate new version
new_version = bump_version(current_version, bump_type)
if not new_version:
print("Failed to calculate new version", file=sys.stderr)
sys.exit(1)
print(f"New version: {new_version}")
# Set the new version
if set_version(new_version):
print("Version bump completed successfully")
sys.exit(0)
else:
print("Version bump failed", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -1,125 +0,0 @@
#!/usr/bin/env python3
"""
Post-commit hook script to automatically create git tags based on the version in Cargo.toml.
This script reads the current version from Cargo.toml and creates a git tag with that version.
It's designed to run after the version has been bumped by the bump-version.py script.
Usage: python scripts/tag-version.py
"""
import sys
import subprocess
import re
from pathlib import Path
def get_version_from_cargo_toml():
"""Get the current version from Cargo.toml."""
cargo_toml_path = Path("Cargo.toml")
if not cargo_toml_path.exists():
print("Cargo.toml not found", file=sys.stderr)
return None
try:
with open(cargo_toml_path, 'r', encoding='utf-8') as f:
content = f.read()
# Look for version = "x.y.z" pattern
version_match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
if version_match:
return version_match.group(1)
else:
print("Could not find version in Cargo.toml", file=sys.stderr)
return None
except Exception as e:
print(f"Error reading Cargo.toml: {e}", file=sys.stderr)
return None
def get_existing_tags():
"""Get list of existing git tags."""
try:
result = subprocess.run(
["git", "tag", "--list"],
capture_output=True,
text=True,
check=True
)
return result.stdout.strip().split('\n') if result.stdout.strip() else []
except subprocess.CalledProcessError as e:
print(f"Error getting git tags: {e}", file=sys.stderr)
return []
def create_git_tag(version):
"""Create a git tag with the specified version."""
tag_name = f"v{version}"
try:
# Check if tag already exists
existing_tags = get_existing_tags()
if tag_name in existing_tags:
print(f"Tag {tag_name} already exists, skipping")
return True
# Create the tag
result = subprocess.run(
["git", "tag", tag_name],
capture_output=True,
text=True,
check=True
)
print(f"Successfully created tag: {tag_name}")
return True
except subprocess.CalledProcessError as e:
print(f"Error creating git tag: {e}", file=sys.stderr)
print(f"stdout: {e.stdout}", file=sys.stderr)
print(f"stderr: {e.stderr}", file=sys.stderr)
return False
def is_git_repository():
"""Check if we're in a git repository."""
try:
subprocess.run(
["git", "rev-parse", "--git-dir"],
capture_output=True,
check=True
)
return True
except subprocess.CalledProcessError:
return False
def main():
# Check if we're in a git repository
if not is_git_repository():
print("Not in a git repository, skipping tag creation")
sys.exit(0)
# Get the current version from Cargo.toml
version = get_version_from_cargo_toml()
if not version:
print("Could not determine version, skipping tag creation")
sys.exit(0)
print(f"Current version: {version}")
# Create the git tag
if create_git_tag(version):
print("Tag creation completed successfully")
sys.exit(0)
else:
print("Tag creation failed", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -1,4 +1,3 @@
#![allow(dead_code)]
//! Cross-platform asset loading abstraction.
//! On desktop, assets are embedded using include_bytes!; on Emscripten, assets are loaded from the filesystem.
@@ -11,10 +10,7 @@ use strum_macros::EnumIter;
/// binary-embedded data or embedded filesystem (Emscripten).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumIter)]
pub enum Asset {
Wav1,
Wav2,
Wav3,
Wav4,
Waka(u8),
/// Main sprite atlas containing all game graphics (atlas.png)
AtlasImage,
/// Terminal Vector font for text rendering (TerminalVector.ttf)
@@ -33,13 +29,13 @@ impl Asset {
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",
Waka(0) => "sound/pacman/waka/1.ogg",
Waka(1) => "sound/pacman/waka/2.ogg",
Waka(2) => "sound/pacman/waka/3.ogg",
Waka(3..=u8::MAX) => "sound/pacman/waka/4.ogg",
DeathSound => "sound/pacman/death.ogg",
AtlasImage => "atlas.png",
Font => "TerminalVector.ttf",
DeathSound => "sound/pacman_death.wav",
}
}
}
@@ -62,7 +58,7 @@ mod imp {
/// Returns `AssetError::NotFound` if the asset file cannot be located (Emscripten only),
/// or `AssetError::Io` for filesystem I/O failures.
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
trace!(asset = ?asset, path = asset.path(), "Loading game asset");
trace!(asset = ?asset, "Loading game asset");
let result = platform::get_asset_bytes(asset);
match &result {
Ok(bytes) => trace!(asset = ?asset, size_bytes = bytes.len(), "Asset loaded successfully"),

View File

@@ -1,18 +1,17 @@
//! This module handles the audio playback for the game.
use crate::asset::{get_asset_bytes, Asset};
use sdl2::{
mixer::{self, Chunk, InitFlag, LoaderRWops, DEFAULT_FORMAT},
mixer::{self, Chunk, InitFlag, LoaderRWops, AUDIO_S16LSB, DEFAULT_CHANNELS},
rwops::RWops,
};
const SOUND_ASSETS: [Asset; 4] = [Asset::Wav1, Asset::Wav2, Asset::Wav3, Asset::Wav4];
const SOUND_ASSETS: [Asset; 4] = [Asset::Waka(0), Asset::Waka(1), Asset::Waka(2), Asset::Waka(3)];
/// The audio system for the game.
///
/// This struct is responsible for initializing the audio device, loading sounds,
/// and playing them. If audio fails to initialize, it will be disabled and all
/// functions will silently do nothing.
#[allow(dead_code)]
pub struct Audio {
_mixer_context: Option<mixer::Sdl2MixerContext>,
sounds: Vec<Chunk>,
@@ -34,13 +33,24 @@ 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 = 44_100;
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,
@@ -52,7 +62,8 @@ impl Audio {
};
}
mixer::allocate_channels(channels);
let channels = 4;
mixer::allocate_channels(4);
// set channel volume
for i in 0..channels {
@@ -144,7 +155,6 @@ 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.
#[allow(dead_code)]
pub fn eat(&mut self) {
if self.disabled || self.muted || self.sounds.is_empty() {
return;
@@ -211,7 +221,6 @@ impl Audio {
/// Audio can be disabled due to SDL2_mixer initialization failures, missing
/// audio device, or failure to load any sound assets. When disabled, all
/// audio operations become no-ops.
#[allow(dead_code)]
pub fn is_disabled(&self) -> bool {
self.disabled
}

View File

@@ -52,10 +52,12 @@ pub mod animation {
pub const GHOST_EATEN_SPEED: u16 = 6;
/// Frightened ghost animation speed (ticks per frame at 60 ticks/sec)
pub const GHOST_FRIGHTENED_SPEED: u16 = 12;
/// Time in ticks when frightened ghosts start flashing (2 seconds at 60 FPS)
pub const FRIGHTENED_FLASH_START_TICKS: u32 = 120;
/// Time in ticks for frightened ghosts to return to normal
pub const GHOST_FRIGHTENED_TICKS: u32 = 300;
/// Time in ticks when frightened ghosts start flashing
pub const GHOST_FRIGHTENED_FLASH_START_TICKS: u32 = GHOST_FRIGHTENED_TICKS - 120;
}
/// The size of the canvas, in pixels.
pub const CANVAS_SIZE: UVec2 = UVec2::new(
(BOARD_CELL_SIZE.x + BOARD_CELL_OFFSET.x + BOARD_BOTTOM_CELL_OFFSET.x) * CELL_SIZE,
@@ -79,6 +81,8 @@ pub mod collider {
pub const PELLET_SIZE: f32 = CELL_SIZE as f32 * 0.4;
/// Collider size for power pellets/energizers (0.95x cell size)
pub const POWER_PELLET_SIZE: f32 = CELL_SIZE as f32 * 0.95;
/// Collider size for fruits (0.8x cell size)
pub const FRUIT_SIZE: f32 = CELL_SIZE as f32 * 1.375;
}
/// UI and rendering constants

View File

@@ -46,6 +46,7 @@ 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),
@@ -53,12 +54,10 @@ pub enum AssetError {
/// Platform-specific errors.
#[derive(thiserror::Error, Debug)]
#[allow(dead_code)]
pub enum PlatformError {
#[error("Console initialization failed: {0}")]
#[cfg(any(windows, target_os = "emscripten"))]
ConsoleInit(String),
#[error("Platform-specific error: {0}")]
Other(String),
}
/// Error type for map parsing operations.
@@ -110,55 +109,3 @@ pub enum MapError {
/// Result type for game operations.
pub type GameResult<T> = Result<T, GameError>;
/// Helper trait for converting other error types to GameError.
pub trait IntoGameError<T> {
#[allow(dead_code)]
fn into_game_error(self) -> GameResult<T>;
}
impl<T, E> IntoGameError<T> for Result<T, E>
where
E: std::error::Error + Send + Sync + 'static,
{
fn into_game_error(self) -> GameResult<T> {
self.map_err(|e| GameError::InvalidState(e.to_string()))
}
}
/// Helper trait for converting Option to GameResult with a custom error.
pub trait OptionExt<T> {
#[allow(dead_code)]
fn ok_or_game_error<F>(self, f: F) -> GameResult<T>
where
F: FnOnce() -> GameError;
}
impl<T> OptionExt<T> for Option<T> {
fn ok_or_game_error<F>(self, f: F) -> GameResult<T>
where
F: FnOnce() -> GameError,
{
self.ok_or_else(f)
}
}
/// Helper trait for converting Result to GameResult with context.
pub trait ResultExt<T, E> {
#[allow(dead_code)]
fn with_context<F>(self, f: F) -> GameResult<T>
where
F: FnOnce(&E) -> GameError;
}
impl<T, E> ResultExt<T, E> for Result<T, E>
where
E: std::error::Error + Send + Sync + 'static,
{
fn with_context<F>(self, f: F) -> GameResult<T>
where
F: FnOnce(&E) -> GameError,
{
self.map_err(|e| f(&e))
}
}

View File

@@ -1,6 +1,6 @@
use bevy_ecs::{entity::Entity, event::Event};
use crate::map::direction::Direction;
use crate::{map::direction::Direction, systems::Ghost};
/// Player input commands that trigger specific game actions.
///
@@ -24,15 +24,12 @@ pub enum GameCommand {
/// Global events that flow through the ECS event system to coordinate game behavior.
///
/// Events enable loose coupling between systems - input generates commands, collision
/// detection reports overlaps, and various systems respond appropriately without
/// direct dependencies.
/// Events enable loose coupling between systems - input generates commands and
/// various systems respond appropriately without direct dependencies.
#[derive(Event, Clone, Copy, Debug, PartialEq, Eq)]
pub enum GameEvent {
/// Player input command to be processed by relevant game systems
Command(GameCommand),
/// Physical overlap detected between two entities requiring gameplay response
Collision(Entity, Entity),
}
impl From<GameCommand> for GameEvent {
@@ -44,5 +41,18 @@ impl From<GameCommand> for GameEvent {
/// Data for requesting stage transitions; processed centrally in stage_system
#[derive(Event, Clone, Copy, Debug, PartialEq, Eq)]
pub enum StageTransition {
GhostEatenPause { ghost_entity: Entity },
GhostEatenPause { ghost_entity: Entity, ghost_type: Ghost },
}
/// Collision triggers for immediate collision handling via observers
#[derive(Event, Clone, Copy, Debug, PartialEq, Eq)]
pub enum CollisionTrigger {
/// Pac-Man collided with a ghost
GhostCollision {
pacman: Entity,
ghost: Entity,
ghost_type: Ghost,
},
/// Pac-Man collided with an item
ItemCollision { item: Entity },
}

View File

@@ -150,11 +150,3 @@ pub fn increment_tick() {
pub fn get_tick_count() -> u64 {
TICK_COUNTER.load(Ordering::Relaxed)
}
/// Reset the tick counter to 0
///
/// This can be used for testing or when restarting the game
#[allow(dead_code)]
pub fn reset_tick_counter() {
TICK_COUNTER.store(0, Ordering::Relaxed);
}

View File

@@ -3,23 +3,24 @@
include!(concat!(env!("OUT_DIR"), "/atlas_data.rs"));
use std::collections::HashMap;
use std::ops::Not;
use tracing::{debug, info, trace, warn};
use crate::constants::{self, animation, MapTile, CANVAS_SIZE};
use crate::error::{GameError, GameResult};
use crate::events::{GameEvent, StageTransition};
use crate::events::{CollisionTrigger, GameEvent, StageTransition};
use crate::map::builder::Map;
use crate::map::direction::Direction;
use crate::systems::{
self, audio_system, blinking_system, collision_system, combined_render_system, directional_render_system,
dirty_render_system, eaten_ghost_system, ghost_collision_system, ghost_movement_system, ghost_state_system,
hud_render_system, item_system, linear_render_system, present_system, profile, time_to_live_system, touch_ui_render_system,
AudioEvent, AudioResource, AudioState, BackbufferResource, Blinking, BufferedDirection, Collider, DebugState,
DebugTextureResource, DeltaTime, DirectionalAnimation, EntityType, Frozen, GameStage, Ghost, GhostAnimation, GhostAnimations,
GhostBundle, GhostCollider, GhostState, GlobalState, Hidden, ItemBundle, ItemCollider, LastAnimationState, LinearAnimation,
MapTextureResource, MovementModifiers, NodeId, PacmanCollider, PlayerAnimation, PlayerBundle, PlayerControlled,
PlayerDeathAnimation, PlayerLives, Position, RenderDirty, Renderable, ScoreResource, StartupSequence, SystemId,
SystemTimings, Timing, TouchState, Velocity,
dirty_render_system, eaten_ghost_system, fruit_sprite_system, ghost_collision_observer, ghost_movement_system,
ghost_state_system, hud_render_system, item_collision_observer, linear_render_system, player_life_sprite_system,
present_system, profile, time_to_live_system, touch_ui_render_system, AudioEvent, AudioResource, AudioState,
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,
};
use crate::texture::animated::{DirectionalTiles, TileSequence};
@@ -42,15 +43,27 @@ use crate::{
asset::{get_asset_bytes, Asset},
events::GameCommand,
map::render::MapRenderer,
systems::debug::{BatchedLinesResource, TtfAtlasResource},
systems::input::{Bindings, CursorPosition},
systems::{BatchedLinesResource, Bindings, CursorPosition, TtfAtlasResource},
texture::sprite::{AtlasMapper, SpriteAtlas},
};
/// System set for all gameplay systems to ensure they run after input processing
#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
enum GameplaySet {
/// Gameplay systems that process inputs
Input,
/// Gameplay systems that update the game state
Update,
/// Gameplay systems that respond to events
Respond,
}
/// System set for all rendering systems to ensure they run after gameplay logic
#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
enum RenderSet {
Animation,
Draw,
Present,
}
/// Core game state manager built on the Bevy ECS architecture.
@@ -128,6 +141,8 @@ impl Game {
debug!("Setting up ECS event registry and observers");
Self::setup_ecs(&mut world);
world.add_observer(systems::spawn_fruit_observer);
debug!("Inserting resources into ECS world");
Self::insert_resources(
&mut world,
@@ -147,7 +162,7 @@ impl Game {
Self::configure_schedule(&mut schedule);
debug!("Spawning player entity");
world.spawn(player_bundle).insert((Frozen, Hidden));
world.spawn(player_bundle).insert((Frozen, Visibility::hidden()));
info!("Spawning game entities");
Self::spawn_ghosts(&mut world)?;
@@ -375,6 +390,7 @@ impl Game {
EventRegistry::register_event::<GameEvent>(world);
EventRegistry::register_event::<AudioEvent>(world);
EventRegistry::register_event::<StageTransition>(world);
EventRegistry::register_event::<CollisionTrigger>(world);
world.add_observer(
|event: Trigger<GameEvent>, mut state: ResMut<GlobalState>, _score: ResMut<ScoreResource>| {
@@ -383,6 +399,9 @@ impl Game {
}
},
);
world.add_observer(ghost_collision_observer);
world.add_observer(item_collision_observer);
}
#[allow(clippy::too_many_arguments)]
@@ -405,11 +424,13 @@ impl Game {
world.insert_resource(PlayerAnimation(player_animation));
world.insert_resource(PlayerDeathAnimation(death_animation));
world.insert_resource(FruitSprites::default());
world.insert_resource(BatchedLinesResource::new(&map, constants::LARGE_SCALE));
world.insert_resource(map);
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(SystemTimings::default());
world.insert_resource(Timing::default());
world.insert_resource(Bindings::default());
@@ -441,30 +462,19 @@ impl Game {
let player_tunnel_slowdown_system = profile(SystemId::PlayerMovement, systems::player::player_tunnel_slowdown_system);
let ghost_movement_system = profile(SystemId::Ghost, ghost_movement_system);
let collision_system = profile(SystemId::Collision, collision_system);
let ghost_collision_system = profile(SystemId::GhostCollision, ghost_collision_system);
let item_system = profile(SystemId::Item, item_system);
let audio_system = profile(SystemId::Audio, audio_system);
let blinking_system = profile(SystemId::Blinking, blinking_system);
let directional_render_system = profile(SystemId::DirectionalRender, directional_render_system);
let linear_render_system = profile(SystemId::LinearRender, linear_render_system);
let dirty_render_system = profile(SystemId::DirtyRender, dirty_render_system);
let hud_render_system = profile(SystemId::HudRender, hud_render_system);
let player_life_sprite_system = profile(SystemId::HudRender, player_life_sprite_system);
let fruit_sprite_system = profile(SystemId::HudRender, fruit_sprite_system);
let present_system = profile(SystemId::Present, present_system);
let unified_ghost_state_system = profile(SystemId::GhostStateAnimation, ghost_state_system);
// let death_sequence_system = profile(SystemId::DeathSequence, death_sequence_system);
// let game_over_system = profile(SystemId::GameOver, systems::game_over_system);
let eaten_ghost_system = profile(SystemId::EatenGhost, eaten_ghost_system);
let time_to_live_system = profile(SystemId::TimeToLive, time_to_live_system);
let forced_dirty_system = |mut dirty: ResMut<RenderDirty>| {
dirty.0 = true;
};
schedule.add_systems(
forced_dirty_system
.run_if(|score: Res<ScoreResource>, stage: Res<GameStage>| score.is_changed() || stage.is_changed()),
);
// Input system should always run to prevent SDL event pump from blocking
let input_systems = (
input_system.run_if(|mut local: Local<u8>| {
@@ -476,33 +486,51 @@ impl Game {
)
.chain();
let gameplay_systems = (
(player_movement_system, player_tunnel_slowdown_system, ghost_movement_system).chain(),
eaten_ghost_system,
(collision_system, ghost_collision_system, item_system).chain(),
unified_ghost_state_system,
)
.chain()
.run_if(|game_state: Res<GameStage>| matches!(*game_state, GameStage::Playing));
// .run_if(|game_state: Res<GameStage>| matches!(*game_state, GameStage::Playing));
schedule.add_systems((blinking_system, directional_render_system, linear_render_system).in_set(RenderSet::Animation));
schedule.add_systems((
time_to_live_system,
stage_system,
input_systems,
gameplay_systems,
(
dirty_render_system,
combined_render_system,
hud_render_system,
touch_ui_render_system,
present_system,
)
.chain()
.after(RenderSet::Animation),
audio_system,
));
schedule
.add_systems((
input_systems.in_set(GameplaySet::Input),
time_to_live_system.before(GameplaySet::Update),
(
player_movement_system,
player_tunnel_slowdown_system,
ghost_movement_system,
eaten_ghost_system,
collision_system,
unified_ghost_state_system,
)
.in_set(GameplaySet::Update),
(
blinking_system,
directional_render_system,
linear_render_system,
player_life_sprite_system,
fruit_sprite_system,
)
.in_set(RenderSet::Animation),
stage_system.in_set(GameplaySet::Respond),
(
(|mut dirty: ResMut<RenderDirty>, score: Res<ScoreResource>, stage: Res<GameStage>| {
dirty.0 = score.is_changed() || stage.is_changed();
}),
dirty_render_system.run_if(|dirty: Res<RenderDirty>| dirty.0.not()),
combined_render_system,
hud_render_system,
touch_ui_render_system,
)
.chain()
.in_set(RenderSet::Draw),
(present_system, audio_system).chain().in_set(RenderSet::Present),
))
.configure_sets((
GameplaySet::Input,
GameplaySet::Update,
GameplaySet::Respond,
RenderSet::Animation,
RenderSet::Draw,
RenderSet::Present,
));
}
fn spawn_items(world: &mut World) -> GameResult<()> {
@@ -601,7 +629,7 @@ impl Game {
}
};
let entity = world.spawn(ghost).insert((Frozen, Hidden)).id();
let entity = world.spawn(ghost).insert((Frozen, Visibility::hidden())).id();
trace!(ghost = ?ghost_type, entity = ?entity, start_node, "Spawned ghost entity");
}
@@ -716,12 +744,23 @@ impl Game {
timings.add_total_timing(total_duration, new_tick);
// Log performance warnings for slow frames
if total_duration.as_millis() > 20 {
// Warn if frame takes more than 20ms
if total_duration.as_millis() > 17 {
// Warn if frame takes too long
let slowest_systems = timings.get_slowest_systems();
let systems_context = if slowest_systems.is_empty() {
"No specific systems identified".to_string()
} else {
slowest_systems
.iter()
.map(|(id, duration)| format!("{} ({:.2?})", id, duration))
.collect::<Vec<String>>()
.join(", ")
};
warn!(
duration_ms = total_duration.as_millis(),
frame_dt = ?std::time::Duration::from_secs_f32(dt),
total = format!("{:.3?}", total_duration),
tick = new_tick,
systems = systems_context,
"Frame took longer than expected"
);
}

View File

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

View File

@@ -3,7 +3,7 @@ use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE};
use crate::map::direction::Direction;
use crate::map::graph::{Graph, Node, TraversalFlags};
use crate::map::parser::MapTileParser;
use crate::systems::movement::NodeId;
use crate::systems::{NodeId, Position};
use bevy_ecs::resource::Resource;
use glam::{I8Vec2, IVec2, Vec2};
use std::collections::{HashMap, VecDeque};
@@ -25,6 +25,8 @@ pub struct NodePositions {
pub inky: NodeId,
/// Clyde starts in the center of the ghost house
pub clyde: NodeId,
/// Fruit spawn location directly below the ghost house
pub fruit_spawn: Position,
}
/// Complete maze representation combining visual layout with navigation pathfinding.
@@ -154,12 +156,32 @@ impl Map {
let (house_entrance_node_id, left_center_node_id, center_center_node_id, right_center_node_id) =
Self::build_house(&mut graph, &grid_to_node, &house_door)?;
// Find fruit spawn location (directly below ghost house)
let left_node_position = I8Vec2::new(13, 17);
let left_node_id = grid_to_node.get(&left_node_position).unwrap();
let right_node_position = I8Vec2::new(14, 17);
let right_node_id = grid_to_node.get(&right_node_position).unwrap();
let distance = graph
.get_node(*right_node_id)
.unwrap()
.position
.distance(graph.get_node(*left_node_id).unwrap().position);
// interpolate between the two nodes
let fruit_spawn_position: Position = Position::Moving {
from: *left_node_id,
to: *right_node_id,
remaining_distance: distance / 2.0,
};
let start_positions = NodePositions {
pacman: grid_to_node[&start_pos],
blinky: house_entrance_node_id,
pinky: left_center_node_id,
inky: right_center_node_id,
clyde: center_center_node_id,
fruit_spawn: fruit_spawn_position,
};
// Build tunnel connections

View File

@@ -1,6 +1,6 @@
use glam::Vec2;
use crate::systems::movement::NodeId;
use crate::systems::NodeId;
use super::direction::Direction;

View File

@@ -59,13 +59,13 @@ pub fn init_console() -> Result<(), PlatformError> {
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::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.wav"))),
Asset::DeathSound => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/death.ogg"))),
}
}

View File

@@ -11,11 +11,8 @@ use std::io::{self, Read, Write};
use std::time::Duration;
// Emscripten FFI functions
#[allow(dead_code)]
extern "C" {
fn emscripten_sleep(ms: u32);
fn emscripten_get_element_css_size(target: *const u8, width: *mut f64, height: *mut f64) -> i32;
// Standard C functions that Emscripten redirects to console
fn printf(format: *const u8, ...) -> i32;
}
@@ -65,20 +62,6 @@ impl Write for EmscriptenConsoleWriter {
}
}
#[allow(dead_code)]
pub fn get_canvas_size() -> Option<(u32, u32)> {
let mut width = 0.0;
let mut height = 0.0;
unsafe {
emscripten_get_element_css_size(c"canvas".as_ptr().cast(), &mut width, &mut height);
if width == 0.0 || height == 0.0 {
return None;
}
}
Some((width as u32, height as u32))
}
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()))?;

View File

@@ -3,10 +3,12 @@
#[cfg(not(target_os = "emscripten"))]
mod desktop;
#[cfg(not(target_os = "emscripten"))]
pub mod tracing_buffer;
#[cfg(not(target_os = "emscripten"))]
pub use desktop::*;
/// Tracing buffer is only used on Windows.
#[cfg(windows)]
pub mod tracing_buffer;
#[cfg(target_os = "emscripten")]
pub use emscripten::*;
#[cfg(target_os = "emscripten")]

View File

@@ -1,4 +1,3 @@
#![allow(dead_code)]
//! Buffered tracing setup for handling logs before console attachment.
use crate::formatter::CustomFormatter;

View File

@@ -1,14 +1,10 @@
use bevy_ecs::{
component::Component,
entity::Entity,
query::{Has, With},
system::{Commands, Query, Res},
system::{Query, Res},
};
use crate::systems::{
components::{DeltaTime, Renderable},
Frozen, Hidden,
};
use crate::systems::{DeltaTime, Frozen, Renderable, Visibility};
#[derive(Component, Debug)]
pub struct Blinking {
@@ -31,18 +27,11 @@ impl Blinking {
/// accumulating ticks and toggling visibility when the specified interval is reached.
/// Uses integer arithmetic for deterministic behavior.
#[allow(clippy::type_complexity)]
pub fn blinking_system(
mut commands: Commands,
time: Res<DeltaTime>,
mut query: Query<(Entity, &mut Blinking, Has<Hidden>, Has<Frozen>), With<Renderable>>,
) {
for (entity, mut blinking, hidden, frozen) in query.iter_mut() {
// If the entity is frozen, blinking is disabled and the entity is unhidden (if it was hidden)
pub fn blinking_system(time: Res<DeltaTime>, mut query: Query<(&mut Blinking, &mut Visibility, Has<Frozen>), With<Renderable>>) {
for (mut blinking, mut visibility, frozen) in query.iter_mut() {
// If the entity is frozen, blinking is disabled and the entity is made visible
if frozen {
if hidden {
commands.entity(entity).remove::<Hidden>();
}
visibility.show();
continue;
}
@@ -52,11 +41,7 @@ pub fn blinking_system(
// Handle zero interval case (immediate toggling)
if blinking.interval_ticks == 0 {
if time.ticks > 0 {
if hidden {
commands.entity(entity).remove::<Hidden>();
} else {
commands.entity(entity).insert(Hidden);
}
visibility.toggle();
}
continue;
}
@@ -72,14 +57,10 @@ pub fn blinking_system(
// Update the timer to the remainder after complete intervals
blinking.tick_timer %= blinking.interval_ticks;
// Toggle the Hidden component for each complete interval
// Toggle the visibility for each complete interval
// Since toggling twice is a no-op, we only need to toggle if the count is odd
if complete_intervals % 2 == 1 {
if hidden {
commands.entity(entity).remove::<Hidden>();
} else {
commands.entity(entity).insert(Hidden);
}
visibility.toggle();
}
}
}

View File

@@ -0,0 +1,104 @@
use bevy_ecs::{
component::Component,
query::{Has, Or, With, Without},
system::{Query, Res},
};
use crate::{
systems::{DeltaTime, Dying, Frozen, LinearAnimation, Looping, Position, Renderable, Velocity},
texture::animated::DirectionalTiles,
};
/// Directional animation component with shared timing across all directions
#[derive(Component, Clone)]
pub struct DirectionalAnimation {
pub moving_tiles: DirectionalTiles,
pub stopped_tiles: DirectionalTiles,
pub current_frame: usize,
pub time_bank: u16,
pub frame_duration: u16,
}
impl DirectionalAnimation {
/// Creates a new directional animation with the given tiles and frame duration
pub fn new(moving_tiles: DirectionalTiles, stopped_tiles: DirectionalTiles, frame_duration: u16) -> Self {
Self {
moving_tiles,
stopped_tiles,
current_frame: 0,
time_bank: 0,
frame_duration,
}
}
}
/// Updates directional animated entities with synchronized timing across directions.
///
/// This runs before the render system to update sprites based on current direction and movement state.
/// All directions share the same frame timing to ensure perfect synchronization.
pub fn directional_render_system(
dt: Res<DeltaTime>,
mut query: Query<(&Position, &Velocity, &mut DirectionalAnimation, &mut Renderable, Has<Frozen>)>,
) {
let ticks = (dt.seconds * 60.0).round() as u16; // Convert from seconds to ticks at 60 ticks/sec
for (position, velocity, mut anim, mut renderable, frozen) in query.iter_mut() {
let stopped = matches!(position, Position::Stopped { .. });
// Only tick animation when moving to preserve stopped frame
if !stopped && !frozen {
// Tick shared animation state
anim.time_bank += ticks;
while anim.time_bank >= anim.frame_duration {
anim.time_bank -= anim.frame_duration;
anim.current_frame += 1;
}
}
// Get tiles for current direction and movement state
let tiles = if stopped {
anim.stopped_tiles.get(velocity.direction)
} else {
anim.moving_tiles.get(velocity.direction)
};
if !tiles.is_empty() {
let new_tile = tiles.get_tile(anim.current_frame);
if renderable.sprite != new_tile {
renderable.sprite = new_tile;
}
}
}
}
/// System that updates `Renderable` sprites for entities with `LinearAnimation`.
#[allow(clippy::type_complexity)]
pub fn linear_render_system(
dt: Res<DeltaTime>,
mut query: Query<(&mut LinearAnimation, &mut Renderable, Has<Looping>), Or<(Without<Frozen>, With<Dying>)>>,
) {
for (mut anim, mut renderable, looping) in query.iter_mut() {
if anim.finished {
continue;
}
anim.time_bank += dt.ticks as u16;
let frames_to_advance = (anim.time_bank / anim.frame_duration) as usize;
if frames_to_advance == 0 {
continue;
}
let total_frames = anim.tiles.len();
if !looping && anim.current_frame + frames_to_advance >= total_frames {
anim.finished = true;
anim.current_frame = total_frames - 1;
} else {
anim.current_frame += frames_to_advance;
}
anim.time_bank %= anim.frame_duration;
renderable.sprite = anim.tiles.get_tile(anim.current_frame);
}
}

View File

@@ -0,0 +1,30 @@
use crate::texture::animated::TileSequence;
use bevy_ecs::component::Component;
use bevy_ecs::resource::Resource;
/// Tag component to mark animations that should loop when they reach the end
#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)]
pub struct Looping;
/// Linear animation component for non-directional animations (frightened ghosts)
#[derive(Component, Resource, Clone)]
pub struct LinearAnimation {
pub tiles: TileSequence,
pub current_frame: usize,
pub time_bank: u16,
pub frame_duration: u16,
pub finished: bool,
}
impl LinearAnimation {
/// Creates a new linear animation with the given tiles and frame duration
pub fn new(tiles: TileSequence, frame_duration: u16) -> Self {
Self {
tiles,
current_frame: 0,
time_bank: 0,
frame_duration,
finished: false,
}
}
}

View File

@@ -0,0 +1,7 @@
mod blinking;
mod directional;
mod linear;
pub use self::blinking::*;
pub use self::directional::*;
pub use self::linear::*;

View File

@@ -1,19 +1,23 @@
use bevy_ecs::{
component::Component,
entity::Entity,
event::{EventReader, EventWriter},
event::EventWriter,
observer::Trigger,
query::With,
system::{Commands, Query, Res, ResMut},
};
use tracing::{debug, trace, warn};
use crate::error::GameError;
use crate::events::{GameEvent, StageTransition};
use crate::map::builder::Map;
use crate::systems::{
components::GhostState, movement::Position, AudioEvent, DyingSequence, Frozen, GameStage, Ghost, PlayerControlled,
ScoreResource,
use crate::{
constants,
systems::{movement::Position, AudioEvent, DyingSequence, FruitSprites, GameStage, Ghost, ScoreResource, SpawnTrigger},
};
use crate::{error::GameError, systems::GhostState};
use crate::{
events::{CollisionTrigger, StageTransition},
systems::PelletCount,
};
use crate::{map::builder::Map, systems::EntityType};
/// A component for defining the collision area of an entity.
#[derive(Component)]
@@ -58,11 +62,11 @@ pub fn check_collision(
Ok(collider1.collides_with(collider2.size, distance))
}
/// Detects overlapping entities and generates collision events for gameplay systems.
/// Detects overlapping entities and triggers collision observers immediately.
///
/// Performs distance-based collision detection between Pac-Man and collectible items
/// using each entity's position and collision radius. When entities overlap, emits
/// a `GameEvent::Collision` for the item system to handle scoring and removal.
/// using each entity's position and collision radius. When entities overlap, triggers
/// collision observers for immediate handling without race conditions.
/// Collision detection accounts for both entities being in motion and supports
/// circular collision boundaries for accurate gameplay feel.
///
@@ -73,8 +77,8 @@ pub fn collision_system(
map: Res<Map>,
pacman_query: Query<(Entity, &Position, &Collider), With<PacmanCollider>>,
item_query: Query<(Entity, &Position, &Collider), With<ItemCollider>>,
ghost_query: Query<(Entity, &Position, &Collider), With<GhostCollider>>,
mut events: EventWriter<GameEvent>,
ghost_query: Query<(Entity, &Position, &Collider, &Ghost, &GhostState), With<GhostCollider>>,
mut commands: Commands,
mut errors: EventWriter<GameError>,
) {
// Check PACMAN × ITEM collisions
@@ -83,8 +87,8 @@ pub fn collision_system(
match check_collision(pacman_pos, pacman_collider, item_pos, item_collider, &map) {
Ok(colliding) => {
if colliding {
trace!(pacman_entity = ?pacman_entity, item_entity = ?item_entity, "Item collision detected");
events.write(GameEvent::Collision(pacman_entity, item_entity));
trace!("Item collision detected");
commands.trigger(CollisionTrigger::ItemCollision { item: item_entity });
}
}
Err(e) => {
@@ -97,13 +101,19 @@ pub fn collision_system(
}
// Check PACMAN × GHOST collisions
for (ghost_entity, ghost_pos, ghost_collider) in ghost_query.iter() {
for (ghost_entity, ghost_pos, ghost_collider, ghost, ghost_state) in ghost_query.iter() {
match check_collision(pacman_pos, pacman_collider, ghost_pos, ghost_collider, &map) {
Ok(colliding) => {
if colliding {
trace!(pacman_entity = ?pacman_entity, ghost_entity = ?ghost_entity, "Ghost collision detected");
events.write(GameEvent::Collision(pacman_entity, ghost_entity));
if !colliding || matches!(*ghost_state, GhostState::Eyes) {
continue;
}
trace!(ghost = ?ghost, "Ghost collision detected");
commands.trigger(CollisionTrigger::GhostCollision {
pacman: pacman_entity,
ghost: ghost_entity,
ghost_type: *ghost,
});
}
Err(e) => {
errors.write(GameError::InvalidState(format!(
@@ -116,56 +126,128 @@ pub fn collision_system(
}
}
/// Observer for handling ghost collisions immediately when they occur
#[allow(clippy::too_many_arguments)]
pub fn ghost_collision_system(
mut commands: Commands,
mut collision_events: EventReader<GameEvent>,
pub fn ghost_collision_observer(
trigger: Trigger<CollisionTrigger>,
mut stage_events: EventWriter<StageTransition>,
mut score: ResMut<ScoreResource>,
mut game_state: ResMut<GameStage>,
pacman_query: Query<Entity, With<PlayerControlled>>,
ghost_query: Query<(Entity, &Ghost), With<GhostCollider>>,
mut ghost_state_query: Query<&mut GhostState>,
mut events: EventWriter<AudioEvent>,
) {
for event in collision_events.read() {
if let GameEvent::Collision(entity1, entity2) = event {
// Check if one is Pacman and the other is a ghost
let (pacman_entity, ghost_entity) = if pacman_query.get(*entity1).is_ok() && ghost_query.get(*entity2).is_ok() {
(*entity1, *entity2)
} else if pacman_query.get(*entity2).is_ok() && ghost_query.get(*entity1).is_ok() {
(*entity2, *entity1)
if let CollisionTrigger::GhostCollision {
pacman: _pacman,
ghost,
ghost_type,
} = *trigger
{
// Check if Pac-Man is already dying
if matches!(*game_state, GameStage::PlayerDying(_)) {
return;
}
// Check if the ghost is frightened
if let Ok(mut ghost_state) = ghost_state_query.get_mut(ghost) {
// Check if ghost is in frightened state
if matches!(*ghost_state, GhostState::Frightened { .. }) {
// Pac-Man eats the ghost
// Add score (200 points per ghost eaten)
debug!(ghost = ?ghost_type, score_added = 200, new_score = score.0 + 200, "Pacman ate frightened ghost");
score.0 += 200;
*ghost_state = GhostState::Eyes;
// Enter short pause to show bonus points, hide ghost, then set Eyes after pause
// Request transition via event so stage_system can process it
stage_events.write(StageTransition::GhostEatenPause {
ghost_entity: ghost,
ghost_type,
});
// Play eat sound
events.write(AudioEvent::PlayEat);
} else if matches!(*ghost_state, GhostState::Normal) {
// Pac-Man dies
warn!(ghost = ?ghost_type, "Pacman hit by normal ghost, player dies");
*game_state = GameStage::PlayerDying(DyingSequence::Frozen { remaining_ticks: 60 });
events.write(AudioEvent::StopAll);
} else {
continue;
};
trace!(ghost_state = ?*ghost_state, "Ghost collision ignored due to state");
}
}
}
}
// Check if the ghost is frightened
if let Ok((ghost_ent, _ghost_type)) = ghost_query.get(ghost_entity) {
if let Ok(ghost_state) = ghost_state_query.get_mut(ghost_ent) {
// Check if ghost is in frightened state
if matches!(*ghost_state, GhostState::Frightened { .. }) {
// Pac-Man eats the ghost
// Add score (200 points per ghost eaten)
debug!(ghost_entity = ?ghost_ent, score_added = 200, new_score = score.0 + 200, "Pacman ate frightened ghost");
score.0 += 200;
/// Observer for handling item collisions immediately when they occur
#[allow(clippy::too_many_arguments)]
pub fn item_collision_observer(
trigger: Trigger<CollisionTrigger>,
mut commands: Commands,
mut score: ResMut<ScoreResource>,
mut pellet_count: ResMut<PelletCount>,
item_query: Query<(Entity, &EntityType, &Position), With<ItemCollider>>,
mut ghost_query: Query<&mut GhostState, With<GhostCollider>>,
mut fruit_sprites: ResMut<FruitSprites>,
mut events: EventWriter<AudioEvent>,
) {
if let CollisionTrigger::ItemCollision { item } = *trigger {
// Get the item type and update score
if let Ok((item_ent, entity_type, position)) = item_query.get(item) {
if let Some(score_value) = entity_type.score_value() {
trace!(item_entity = ?item_ent, item_type = ?entity_type, score_value, new_score = score.0 + score_value, "Item collected by player");
score.0 += score_value;
// Enter short pause to show bonus points, hide ghost, then set Eyes after pause
// Request transition via event so stage_system can process it
stage_events.write(StageTransition::GhostEatenPause { ghost_entity: ghost_ent });
// Remove the collected item
commands.entity(item_ent).despawn();
// Play eat sound
events.write(AudioEvent::PlayEat);
} else if matches!(*ghost_state, GhostState::Normal) {
// Pac-Man dies
warn!(ghost_entity = ?ghost_ent, "Pacman hit by normal ghost, player dies");
*game_state = GameStage::PlayerDying(DyingSequence::Frozen { remaining_ticks: 60 });
commands.entity(pacman_entity).insert(Frozen);
commands.entity(ghost_entity).insert(Frozen);
events.write(AudioEvent::StopAll);
} else {
trace!(ghost_state = ?*ghost_state, "Ghost collision ignored due to state");
// Track pellet consumption for fruit spawning
if *entity_type == EntityType::Pellet {
pellet_count.0 += 1;
trace!(pellet_count = pellet_count.0, "Pellet consumed");
// Check if we should spawn a fruit
if pellet_count.0 == 5 || pellet_count.0 == 170 {
debug!(pellet_count = pellet_count.0, "Fruit spawn milestone reached");
commands.trigger(SpawnTrigger::Fruit);
}
}
// Trigger bonus points effect if a fruit is collected
if let EntityType::Fruit(fruit) = *entity_type {
fruit_sprites.0.push(fruit);
commands.trigger(SpawnTrigger::Bonus {
position: *position,
value: entity_type.score_value().unwrap(),
ttl: 60 * 2,
});
}
// Trigger audio if appropriate
if entity_type.is_collectible() {
events.write(AudioEvent::PlayEat);
}
// Make non-eaten ghosts frightened when power pellet is collected
if matches!(*entity_type, EntityType::PowerPellet) {
debug!(
duration_ticks = constants::animation::GHOST_FRIGHTENED_TICKS,
"Power pellet collected, frightening ghosts"
);
for mut ghost_state in ghost_query.iter_mut() {
if matches!(*ghost_state, GhostState::Normal) {
*ghost_state = GhostState::new_frightened(
constants::animation::GHOST_FRIGHTENED_TICKS,
constants::animation::GHOST_FRIGHTENED_FLASH_START_TICKS,
);
}
}
debug!(
frightened_count = ghost_query.iter().count(),
"Ghosts set to frightened state"
);
}
}
}
}

View File

@@ -0,0 +1,43 @@
use bevy_ecs::bundle::Bundle;
use crate::systems::{
BufferedDirection, Collider, DirectionalAnimation, EntityType, Ghost, GhostCollider, GhostState, ItemCollider,
LastAnimationState, MovementModifiers, PacmanCollider, PlayerControlled, Position, Renderable, Velocity,
};
#[derive(Bundle)]
pub struct PlayerBundle {
pub player: PlayerControlled,
pub position: Position,
pub velocity: Velocity,
pub buffered_direction: BufferedDirection,
pub sprite: Renderable,
pub directional_animation: DirectionalAnimation,
pub entity_type: EntityType,
pub collider: Collider,
pub movement_modifiers: MovementModifiers,
pub pacman_collider: PacmanCollider,
}
#[derive(Bundle)]
pub struct ItemBundle {
pub position: Position,
pub sprite: Renderable,
pub entity_type: EntityType,
pub collider: Collider,
pub item_collider: ItemCollider,
}
#[derive(Bundle)]
pub struct GhostBundle {
pub ghost: Ghost,
pub position: Position,
pub velocity: Velocity,
pub sprite: Renderable,
pub directional_animation: DirectionalAnimation,
pub entity_type: EntityType,
pub collider: Collider,
pub ghost_collider: GhostCollider,
pub ghost_state: GhostState,
pub last_animation_state: LastAnimationState,
}

View File

@@ -0,0 +1,106 @@
use bevy_ecs::{component::Component, resource::Resource};
use crate::{map::graph::TraversalFlags, systems::FruitType};
/// A tag component denoting the type of entity.
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EntityType {
Player,
Ghost,
Pellet,
PowerPellet,
Fruit(FruitType),
Effect,
}
impl EntityType {
/// Returns the traversal flags for this entity type.
pub fn traversal_flags(&self) -> TraversalFlags {
match self {
EntityType::Player => TraversalFlags::PACMAN,
EntityType::Ghost => TraversalFlags::GHOST,
_ => TraversalFlags::empty(), // Static entities don't traverse
}
}
pub fn score_value(&self) -> Option<u32> {
match self {
EntityType::Pellet => Some(10),
EntityType::PowerPellet => Some(50),
EntityType::Fruit(fruit_type) => Some(fruit_type.score_value()),
_ => None,
}
}
pub fn is_collectible(&self) -> bool {
matches!(self, EntityType::Pellet | EntityType::PowerPellet | EntityType::Fruit(_))
}
}
#[derive(Resource)]
pub struct GlobalState {
pub exit: bool,
}
#[derive(Resource)]
pub struct ScoreResource(pub u32);
#[derive(Resource)]
pub struct DeltaTime {
/// Floating-point delta time in seconds
pub seconds: f32,
/// Integer tick delta (usually 1, but can be different for testing)
pub ticks: u32,
}
#[allow(dead_code)]
impl DeltaTime {
/// Creates a new DeltaTime from a floating-point delta time in seconds
///
/// While this method exists as a helper, it does not mean that seconds and ticks are interchangeable.
pub fn from_seconds(seconds: f32) -> Self {
Self {
seconds,
ticks: (seconds * 60.0).round() as u32,
}
}
/// Creates a new DeltaTime from an integer tick delta
///
/// While this method exists as a helper, it does not mean that seconds and ticks are interchangeable.
pub fn from_ticks(ticks: u32) -> Self {
Self {
seconds: ticks as f32 / 60.0,
ticks,
}
}
}
/// Movement modifiers that can affect Pac-Man's speed or handling.
#[derive(Component, Debug, Clone, Copy)]
pub struct MovementModifiers {
/// Multiplier applied to base speed (e.g., tunnels)
pub speed_multiplier: f32,
/// True when currently in a tunnel slowdown region
pub tunnel_slowdown_active: bool,
}
impl Default for MovementModifiers {
fn default() -> Self {
Self {
speed_multiplier: 1.0,
tunnel_slowdown_active: false,
}
}
}
/// Tag component for entities that should be frozen during startup
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub struct Frozen;
/// Component for HUD life sprite entities.
/// Each life sprite entity has an index indicating its position from left to right (0, 1, 2, etc.).
/// This mostly functions as a tag component for sprites.
#[derive(Component, Debug, Clone, Copy)]
pub struct PlayerLife {
pub index: u32,
}

View File

@@ -0,0 +1,5 @@
pub mod bundles;
pub mod components;
pub use self::bundles::*;
pub use self::components::*;

View File

@@ -1,403 +0,0 @@
use std::collections::HashMap;
use bevy_ecs::{bundle::Bundle, component::Component, resource::Resource};
use bitflags::bitflags;
use crate::{
map::graph::TraversalFlags,
systems::{
movement::{BufferedDirection, Position, Velocity},
Collider, GhostCollider, ItemCollider, PacmanCollider,
},
texture::{
animated::{DirectionalTiles, TileSequence},
sprite::AtlasTile,
},
};
/// A tag component for entities that are controlled by the player.
#[derive(Default, Component)]
pub struct PlayerControlled;
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Ghost {
Blinky,
Pinky,
Inky,
Clyde,
}
impl Ghost {
/// Returns the ghost type name for atlas lookups.
pub fn as_str(self) -> &'static str {
match self {
Ghost::Blinky => "blinky",
Ghost::Pinky => "pinky",
Ghost::Inky => "inky",
Ghost::Clyde => "clyde",
}
}
/// Returns the base movement speed for this ghost type.
pub fn base_speed(self) -> f32 {
match self {
Ghost::Blinky => 1.0,
Ghost::Pinky => 0.95,
Ghost::Inky => 0.9,
Ghost::Clyde => 0.85,
}
}
/// Returns the ghost's color for debug rendering.
#[allow(dead_code)]
pub fn debug_color(&self) -> sdl2::pixels::Color {
match self {
Ghost::Blinky => sdl2::pixels::Color::RGB(255, 0, 0), // Red
Ghost::Pinky => sdl2::pixels::Color::RGB(255, 182, 255), // Pink
Ghost::Inky => sdl2::pixels::Color::RGB(0, 255, 255), // Cyan
Ghost::Clyde => sdl2::pixels::Color::RGB(255, 182, 85), // Orange
}
}
}
/// A tag component denoting the type of entity.
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EntityType {
Player,
Ghost,
Pellet,
PowerPellet,
}
impl EntityType {
/// Returns the traversal flags for this entity type.
pub fn traversal_flags(&self) -> TraversalFlags {
match self {
EntityType::Player => TraversalFlags::PACMAN,
EntityType::Ghost => TraversalFlags::GHOST,
_ => TraversalFlags::empty(), // Static entities don't traverse
}
}
pub fn score_value(&self) -> Option<u32> {
match self {
EntityType::Pellet => Some(10),
EntityType::PowerPellet => Some(50),
_ => None,
}
}
pub fn is_collectible(&self) -> bool {
matches!(self, EntityType::Pellet | EntityType::PowerPellet)
}
}
/// A component for entities that have a sprite, with a layer for ordering.
///
/// This is intended to be modified by other entities allowing animation.
#[derive(Component)]
pub struct Renderable {
pub sprite: AtlasTile,
pub layer: u8,
}
/// Directional animation component with shared timing across all directions
#[derive(Component, Clone)]
pub struct DirectionalAnimation {
pub moving_tiles: DirectionalTiles,
pub stopped_tiles: DirectionalTiles,
pub current_frame: usize,
pub time_bank: u16,
pub frame_duration: u16,
}
impl DirectionalAnimation {
/// Creates a new directional animation with the given tiles and frame duration
pub fn new(moving_tiles: DirectionalTiles, stopped_tiles: DirectionalTiles, frame_duration: u16) -> Self {
Self {
moving_tiles,
stopped_tiles,
current_frame: 0,
time_bank: 0,
frame_duration,
}
}
}
/// Tag component to mark animations that should loop when they reach the end
#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)]
pub struct Looping;
/// Linear animation component for non-directional animations (frightened ghosts)
#[derive(Component, Resource, Clone)]
pub struct LinearAnimation {
pub tiles: TileSequence,
pub current_frame: usize,
pub time_bank: u16,
pub frame_duration: u16,
pub finished: bool,
}
impl LinearAnimation {
/// Creates a new linear animation with the given tiles and frame duration
pub fn new(tiles: TileSequence, frame_duration: u16) -> Self {
Self {
tiles,
current_frame: 0,
time_bank: 0,
frame_duration,
finished: false,
}
}
}
bitflags! {
#[derive(Component, Default, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CollisionLayer: u8 {
const PACMAN = 1 << 0;
const GHOST = 1 << 1;
const ITEM = 1 << 2;
}
}
#[derive(Resource)]
pub struct GlobalState {
pub exit: bool,
}
#[derive(Resource)]
pub struct ScoreResource(pub u32);
#[derive(Resource)]
pub struct DeltaTime {
/// Floating-point delta time in seconds
pub seconds: f32,
/// Integer tick delta (usually 1, but can be different for testing)
pub ticks: u32,
}
#[allow(dead_code)]
impl DeltaTime {
/// Creates a new DeltaTime from a floating-point delta time in seconds
///
/// While this method exists as a helper, it does not mean that seconds and ticks are interchangeable.
pub fn from_seconds(seconds: f32) -> Self {
Self {
seconds,
ticks: (seconds * 60.0).round() as u32,
}
}
/// Creates a new DeltaTime from an integer tick delta
///
/// While this method exists as a helper, it does not mean that seconds and ticks are interchangeable.
pub fn from_ticks(ticks: u32) -> Self {
Self {
seconds: ticks as f32 / 60.0,
ticks,
}
}
}
/// Movement modifiers that can affect Pac-Man's speed or handling.
#[derive(Component, Debug, Clone, Copy)]
pub struct MovementModifiers {
/// Multiplier applied to base speed (e.g., tunnels)
pub speed_multiplier: f32,
/// True when currently in a tunnel slowdown region
pub tunnel_slowdown_active: bool,
}
impl Default for MovementModifiers {
fn default() -> Self {
Self {
speed_multiplier: 1.0,
tunnel_slowdown_active: false,
}
}
}
/// Tag component for entities that should be frozen during startup
#[derive(Component, Debug, Clone, Copy)]
pub struct Frozen;
/// Tag component for eaten ghosts
#[derive(Component, Debug, Clone, Copy)]
pub struct Eaten;
/// Tag component for Pac-Man during his death animation.
/// This is mainly because the Frozen tag would stop both movement and animation, while the Dying tag can signal that the animation should continue despite being frozen.
#[derive(Component, Debug, Clone, Copy)]
pub struct Dying;
#[derive(Component, Debug, Clone, Copy)]
pub enum GhostState {
/// Normal ghost behavior - chasing Pac-Man
Normal,
/// Frightened state after power pellet - ghost can be eaten
Frightened {
remaining_ticks: u32,
flash: bool,
remaining_flash_ticks: u32,
},
/// Eyes state - ghost has been eaten and is returning to ghost house
Eyes,
}
/// Component to track the last animation state for efficient change detection
#[derive(Component, Debug, Clone, Copy, PartialEq)]
pub struct LastAnimationState(pub GhostAnimation);
impl GhostState {
/// Creates a new frightened state with the specified duration
pub fn new_frightened(total_ticks: u32, flash_start_ticks: u32) -> Self {
Self::Frightened {
remaining_ticks: total_ticks,
flash: false,
remaining_flash_ticks: flash_start_ticks, // Time until flashing starts
}
}
/// Ticks the ghost state, returning true if the state changed.
pub fn tick(&mut self) -> bool {
if let GhostState::Frightened {
remaining_ticks,
flash,
remaining_flash_ticks,
} = self
{
// Transition out of frightened state
if *remaining_ticks == 0 {
*self = GhostState::Normal;
return true;
}
*remaining_ticks -= 1;
if *remaining_flash_ticks > 0 {
*remaining_flash_ticks = remaining_flash_ticks.saturating_sub(1);
if *remaining_flash_ticks == 0 {
*flash = true;
true
} else {
false
}
} else {
false
}
} else {
false
}
}
/// Returns the appropriate animation state for this ghost state
pub fn animation_state(&self) -> GhostAnimation {
match self {
GhostState::Normal => GhostAnimation::Normal,
GhostState::Eyes => GhostAnimation::Eyes,
GhostState::Frightened { flash: false, .. } => GhostAnimation::Frightened { flash: false },
GhostState::Frightened { flash: true, .. } => GhostAnimation::Frightened { flash: true },
}
}
}
/// Enumeration of different ghost animation states.
/// Note that this is used in micromap which has a fixed size based on the number of variants,
/// so extending this should be done with caution, and will require updating the micromap's capacity.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GhostAnimation {
/// Normal ghost appearance with directional movement animations
Normal,
/// Blue ghost appearance when vulnerable (power pellet active)
Frightened { flash: bool },
/// Eyes-only animation when ghost has been consumed by Pac-Man (Eaten state)
Eyes,
}
/// Global resource containing pre-loaded animation sets for all ghost types.
///
/// This resource is initialized once during game startup and provides O(1) access
/// to animation sets for each ghost type. The animation system uses this resource
/// to efficiently switch between different ghost states without runtime asset loading.
///
/// The HashMap is keyed by `Ghost` enum variants (Blinky, Pinky, Inky, Clyde) and
/// contains the normal directional animation for each ghost type.
#[derive(Resource)]
pub struct GhostAnimations {
pub normal: HashMap<Ghost, DirectionalAnimation>,
pub eyes: DirectionalAnimation,
pub frightened: LinearAnimation,
pub frightened_flashing: LinearAnimation,
}
impl GhostAnimations {
/// Creates a new GhostAnimations resource with the provided data.
pub fn new(
normal: HashMap<Ghost, DirectionalAnimation>,
eyes: DirectionalAnimation,
frightened: LinearAnimation,
frightened_flashing: LinearAnimation,
) -> Self {
Self {
normal,
eyes,
frightened,
frightened_flashing,
}
}
/// Gets the normal directional animation for the specified ghost type.
pub fn get_normal(&self, ghost_type: &Ghost) -> Option<&DirectionalAnimation> {
self.normal.get(ghost_type)
}
/// Gets the eyes animation (shared across all ghosts).
pub fn eyes(&self) -> &DirectionalAnimation {
&self.eyes
}
/// Gets the frightened animations (shared across all ghosts).
pub fn frightened(&self, flash: bool) -> &LinearAnimation {
if flash {
&self.frightened_flashing
} else {
&self.frightened
}
}
}
#[derive(Bundle)]
pub struct PlayerBundle {
pub player: PlayerControlled,
pub position: Position,
pub velocity: Velocity,
pub buffered_direction: BufferedDirection,
pub sprite: Renderable,
pub directional_animation: DirectionalAnimation,
pub entity_type: EntityType,
pub collider: Collider,
pub movement_modifiers: MovementModifiers,
pub pacman_collider: PacmanCollider,
}
#[derive(Bundle)]
pub struct ItemBundle {
pub position: Position,
pub sprite: Renderable,
pub entity_type: EntityType,
pub collider: Collider,
pub item_collider: ItemCollider,
}
#[derive(Bundle)]
pub struct GhostBundle {
pub ghost: Ghost,
pub position: Position,
pub velocity: Velocity,
pub sprite: Renderable,
pub directional_animation: DirectionalAnimation,
pub entity_type: EntityType,
pub collider: Collider,
pub ghost_collider: GhostCollider,
pub ghost_state: GhostState,
pub last_animation_state: LastAnimationState,
}

View File

@@ -1,5 +1,4 @@
//! Debug rendering system
#[cfg_attr(coverage_nightly, feature(coverage_attribute))]
use crate::constants::{self, BOARD_PIXEL_OFFSET};
use crate::map::builder::Map;
use crate::systems::{Collider, CursorPosition, NodeId, Position, SystemTimings};

View File

@@ -1,7 +1,7 @@
use std::collections::HashMap;
use crate::platform;
use crate::systems::components::{
DirectionalAnimation, Frozen, GhostAnimation, GhostState, LastAnimationState, LinearAnimation, Looping,
};
use crate::systems::{DirectionalAnimation, Frozen, LinearAnimation, Looping};
use crate::{
map::{
builder::Map,
@@ -9,18 +9,186 @@ use crate::{
graph::{Edge, TraversalFlags},
},
systems::{
components::{DeltaTime, Ghost},
components::DeltaTime,
movement::{Position, Velocity},
},
};
use bevy_ecs::component::Component;
use bevy_ecs::resource::Resource;
use tracing::{debug, trace, warn};
use crate::systems::GhostAnimations;
use bevy_ecs::query::Without;
use bevy_ecs::system::{Commands, Query, Res};
use rand::seq::IndexedRandom;
use smallvec::SmallVec;
/// Tag component for Pac-Man during his death animation.
/// This is mainly because the Frozen tag would stop both movement and animation, while the Dying tag can signal that the animation should continue despite being frozen.
#[derive(Component, Debug, Clone, Copy)]
pub struct Dying;
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Ghost {
Blinky,
Pinky,
Inky,
Clyde,
}
impl Ghost {
/// Returns the ghost type name for atlas lookups.
pub fn as_str(self) -> &'static str {
match self {
Ghost::Blinky => "blinky",
Ghost::Pinky => "pinky",
Ghost::Inky => "inky",
Ghost::Clyde => "clyde",
}
}
/// Returns the base movement speed for this ghost type.
pub fn base_speed(self) -> f32 {
match self {
Ghost::Blinky => 1.0,
Ghost::Pinky => 0.95,
Ghost::Inky => 0.9,
Ghost::Clyde => 0.85,
}
}
}
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub enum GhostState {
/// Normal ghost behavior - chasing Pac-Man
Normal,
/// Frightened state after power pellet - ghost can be eaten
Frightened {
remaining_ticks: u32,
flash: bool,
remaining_flash_ticks: u32,
},
/// Eyes state - ghost has been eaten and is returning to ghost house
Eyes,
}
impl GhostState {
/// Creates a new frightened state with the specified duration
pub fn new_frightened(total_ticks: u32, flash_start_ticks: u32) -> Self {
Self::Frightened {
remaining_ticks: total_ticks,
flash: false,
remaining_flash_ticks: flash_start_ticks, // Time until flashing starts
}
}
/// Ticks the ghost state, returning true if the state changed.
pub fn tick(&mut self) -> bool {
if let GhostState::Frightened {
remaining_ticks,
flash,
remaining_flash_ticks,
} = self
{
// Transition out of frightened state
if *remaining_ticks == 0 {
*self = GhostState::Normal;
return true;
}
*remaining_ticks -= 1;
if *remaining_flash_ticks > 0 {
*remaining_flash_ticks = remaining_flash_ticks.saturating_sub(1);
if *remaining_flash_ticks == 0 {
*flash = true;
true
} else {
false
}
} else {
false
}
} else {
false
}
}
/// Returns the appropriate animation state for this ghost state
pub fn animation_state(&self) -> GhostAnimation {
match self {
GhostState::Normal => GhostAnimation::Normal,
GhostState::Eyes => GhostAnimation::Eyes,
GhostState::Frightened { flash: false, .. } => GhostAnimation::Frightened { flash: false },
GhostState::Frightened { flash: true, .. } => GhostAnimation::Frightened { flash: true },
}
}
}
/// Enumeration of different ghost animation states.
/// Note that this is used in micromap which has a fixed size based on the number of variants,
/// so extending this should be done with caution, and will require updating the micromap's capacity.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GhostAnimation {
/// Normal ghost appearance with directional movement animations
Normal,
/// Blue ghost appearance when vulnerable (power pellet active)
Frightened { flash: bool },
/// Eyes-only animation when ghost has been consumed by Pac-Man (Eaten state)
Eyes,
}
/// Global resource containing pre-loaded animation sets for all ghost types.
///
/// This resource is initialized once during game startup and provides O(1) access
/// to animation sets for each ghost type. The animation system uses this resource
/// to efficiently switch between different ghost states without runtime asset loading.
///
/// The HashMap is keyed by `Ghost` enum variants (Blinky, Pinky, Inky, Clyde) and
/// contains the normal directional animation for each ghost type.
#[derive(Resource)]
pub struct GhostAnimations {
pub normal: HashMap<Ghost, DirectionalAnimation>,
pub eyes: DirectionalAnimation,
pub frightened: LinearAnimation,
pub frightened_flashing: LinearAnimation,
}
impl GhostAnimations {
/// Creates a new GhostAnimations resource with the provided data.
pub fn new(
normal: HashMap<Ghost, DirectionalAnimation>,
eyes: DirectionalAnimation,
frightened: LinearAnimation,
frightened_flashing: LinearAnimation,
) -> Self {
Self {
normal,
eyes,
frightened,
frightened_flashing,
}
}
/// Gets the normal directional animation for the specified ghost type.
pub fn get_normal(&self, ghost_type: &Ghost) -> Option<&DirectionalAnimation> {
self.normal.get(ghost_type)
}
/// Gets the eyes animation (shared across all ghosts).
pub fn eyes(&self) -> &DirectionalAnimation {
&self.eyes
}
/// Gets the frightened animations (shared across all ghosts).
pub fn frightened(&self, flash: bool) -> &LinearAnimation {
if flash {
&self.frightened_flashing
} else {
&self.frightened
}
}
}
/// Autonomous ghost AI system implementing randomized movement with backtracking avoidance.
pub fn ghost_movement_system(
map: Res<Map>,
@@ -86,7 +254,7 @@ pub fn ghost_movement_system(
pub fn eaten_ghost_system(
map: Res<Map>,
delta_time: Res<DeltaTime>,
mut eaten_ghosts: Query<(&Ghost, &mut Position, &mut Velocity, &mut GhostState)>,
mut eaten_ghosts: Query<(&Ghost, &mut Position, &mut Velocity, &mut GhostState), Without<Frozen>>,
) {
for (ghost_type, mut position, mut velocity, mut ghost_state) in eaten_ghosts.iter_mut() {
// Only process ghosts that are in Eyes state
@@ -185,6 +353,10 @@ fn find_direction_to_target(
None
}
/// Component to track the last animation state for efficient change detection
#[derive(Component, Debug, Clone, Copy, PartialEq)]
pub struct LastAnimationState(pub GhostAnimation);
/// Unified system that manages ghost state transitions and animations with component swapping
pub fn ghost_state_system(
mut commands: Commands,

79
src/systems/hud/fruits.rs Normal file
View File

@@ -0,0 +1,79 @@
use crate::systems::item::FruitType;
use crate::texture::sprites::GameSprite;
use bevy_ecs::component::Component;
use bevy_ecs::resource::Resource;
#[derive(Component)]
pub struct FruitInHud {
pub index: u32,
}
#[derive(Resource, Default)]
pub struct FruitSprites(pub Vec<FruitType>);
use crate::constants::{BOARD_BOTTOM_PIXEL_OFFSET, CANVAS_SIZE, CELL_SIZE};
use crate::error::GameError;
use crate::systems::{PixelPosition, Renderable};
use crate::texture::sprite::SpriteAtlas;
use bevy_ecs::entity::Entity;
use bevy_ecs::event::EventWriter;
use bevy_ecs::system::{Commands, NonSendMut, Query, Res};
use glam::Vec2;
/// Calculates the pixel position for a fruit sprite based on its index
fn calculate_fruit_sprite_position(index: u32) -> Vec2 {
let start_x = CANVAS_SIZE.x - CELL_SIZE * 2; // 2 cells from right
let start_y = CANVAS_SIZE.y - BOARD_BOTTOM_PIXEL_OFFSET.y + (CELL_SIZE / 2) + 1; // In bottom area
let sprite_spacing = CELL_SIZE + CELL_SIZE / 2; // 1.5 cells between sprites
let x = start_x - ((index as f32) * (sprite_spacing as f32 * 1.5)).round() as u32;
let y = start_y - CELL_SIZE / 2;
Vec2::new((x - CELL_SIZE) as f32, (y + CELL_SIZE) as f32)
}
/// System that manages fruit sprite entities in the HUD.
/// Spawns and despawns fruit sprite entities based on changes to FruitSprites resource.
/// Displays up to 6 fruits, sorted by value.
pub fn fruit_sprite_system(
mut commands: Commands,
atlas: NonSendMut<SpriteAtlas>,
current_fruit_sprites: Query<(Entity, &FruitInHud)>,
fruit_sprites: Res<FruitSprites>,
mut errors: EventWriter<GameError>,
) {
// We only want to display the greatest 6 fruits
let fruits_to_display: Vec<_> = fruit_sprites.0.iter().rev().take(6).collect();
let mut current_sprites: Vec<_> = current_fruit_sprites.iter().collect();
current_sprites.sort_by_key(|(_, fruit)| fruit.index);
// Despawn all current sprites. We will respawn them.
// This is simpler than trying to match them up.
for (entity, _) in &current_sprites {
commands.entity(*entity).despawn();
}
for (i, fruit_type) in fruits_to_display.iter().enumerate() {
let fruit_sprite = match atlas.get_tile(&GameSprite::Fruit(**fruit_type).to_path()) {
Ok(sprite) => sprite,
Err(e) => {
errors.write(e.into());
continue;
}
};
let position = calculate_fruit_sprite_position(i as u32);
commands.spawn((
FruitInHud { index: i as u32 },
Renderable {
sprite: fruit_sprite,
layer: 255, // High layer to render on top
},
PixelPosition {
pixel_position: position,
},
));
}
}

89
src/systems/hud/lives.rs Normal file
View File

@@ -0,0 +1,89 @@
use std::cmp::Ordering;
use crate::constants::{BOARD_BOTTOM_PIXEL_OFFSET, CANVAS_SIZE, CELL_SIZE};
use crate::error::GameError;
use crate::map::direction::Direction;
use crate::systems::{PixelPosition, PlayerLife, PlayerLives, Renderable};
use crate::texture::sprite::SpriteAtlas;
use crate::texture::sprites::{GameSprite, PacmanSprite};
use bevy_ecs::entity::Entity;
use bevy_ecs::event::EventWriter;
use bevy_ecs::system::{Commands, NonSendMut, Query, Res};
use glam::Vec2;
/// Calculates the pixel position for a life sprite based on its index
fn calculate_life_sprite_position(index: u32) -> Vec2 {
let start_x = CELL_SIZE * 2; // 2 cells from left
let start_y = CANVAS_SIZE.y - BOARD_BOTTOM_PIXEL_OFFSET.y + (CELL_SIZE / 2) + 1; // In bottom area
let sprite_spacing = CELL_SIZE + CELL_SIZE / 2; // 1.5 cells between sprites
let x = start_x + ((index as f32) * (sprite_spacing as f32 * 1.5)).round() as u32;
let y = start_y - CELL_SIZE / 2;
Vec2::new((x + CELL_SIZE) as f32, (y + CELL_SIZE) as f32)
}
/// System that manages player life sprite entities.
/// Spawns and despawns life sprite entities based on changes to PlayerLives resource.
/// Each life sprite is positioned based on its index (0, 1, 2, etc. from left to right).
pub fn player_life_sprite_system(
mut commands: Commands,
atlas: NonSendMut<SpriteAtlas>,
current_life_sprites: Query<(Entity, &PlayerLife)>,
player_lives: Res<PlayerLives>,
mut errors: EventWriter<GameError>,
) {
let displayed_lives = player_lives.0.saturating_sub(1);
// Get current life sprite entities, sorted by index
let mut current_sprites: Vec<_> = current_life_sprites.iter().collect();
current_sprites.sort_by_key(|(_, life)| life.index);
let current_count = current_sprites.len() as u8;
// Calculate the difference
let diff = (displayed_lives as i8) - (current_count as i8);
match diff.cmp(&0) {
// Ignore when the number of lives displayed is correct
Ordering::Equal => {}
// Spawn new life sprites
Ordering::Greater => {
let life_sprite = match atlas.get_tile(&GameSprite::Pacman(PacmanSprite::Moving(Direction::Left, 1)).to_path()) {
Ok(sprite) => sprite,
Err(e) => {
errors.write(e.into());
return;
}
};
for i in 0..diff {
let position = calculate_life_sprite_position(i as u32);
commands.spawn((
PlayerLife { index: i as u32 },
Renderable {
sprite: life_sprite,
layer: 255, // High layer to render on top
},
PixelPosition {
pixel_position: position,
},
));
}
}
// Remove excess life sprites (highest indices first)
Ordering::Less => {
let to_remove = diff.unsigned_abs();
let sprites_to_remove: Vec<_> = current_sprites
.iter()
.rev() // Start from highest index
.take(to_remove as usize)
.map(|(entity, _)| *entity)
.collect();
for entity in sprites_to_remove {
commands.entity(entity).despawn();
}
}
}
}

9
src/systems/hud/mod.rs Normal file
View File

@@ -0,0 +1,9 @@
pub mod fruits;
pub mod lives;
pub mod score;
pub mod touch;
pub use self::fruits::*;
pub use self::lives::*;
pub use self::score::*;
pub use self::touch::*;

86
src/systems/hud/score.rs Normal file
View File

@@ -0,0 +1,86 @@
use crate::constants;
use crate::error::{GameError, TextureError};
use crate::systems::{BackbufferResource, GameStage, ScoreResource, StartupSequence};
use crate::texture::sprite::SpriteAtlas;
use crate::texture::text::TextTexture;
use bevy_ecs::event::EventWriter;
use bevy_ecs::system::{NonSendMut, Res};
use sdl2::pixels::Color;
use sdl2::render::Canvas;
use sdl2::video::Window;
/// Renders the HUD (score, lives, etc.) on top of the game.
#[allow(clippy::too_many_arguments)]
pub fn hud_render_system(
mut backbuffer: NonSendMut<BackbufferResource>,
mut canvas: NonSendMut<&mut Canvas<Window>>,
mut atlas: NonSendMut<SpriteAtlas>,
score: Res<ScoreResource>,
stage: Res<GameStage>,
mut errors: EventWriter<GameError>,
) {
let _ = canvas.with_texture_canvas(&mut backbuffer.0, |canvas| {
let mut text_renderer = TextTexture::new(1.0);
// Render lives and high score text in white
let lives_text = "1UP HIGH SCORE ";
let lives_position = glam::UVec2::new(4 + 8 * 3, 2); // x_offset + lives_offset * 8, y_offset
if let Err(e) = text_renderer.render(canvas, &mut atlas, lives_text, lives_position) {
errors.write(TextureError::RenderFailed(format!("Failed to render lives text: {}", e)).into());
}
// Render score text
let score_text = format!("{:02}", score.0);
let score_offset = 7 - (score_text.len() as i32);
let score_position = glam::UVec2::new(4 + 8 * score_offset as u32, 10); // x_offset + score_offset * 8, 8 + y_offset
if let Err(e) = text_renderer.render(canvas, &mut atlas, &score_text, score_position) {
errors.write(TextureError::RenderFailed(format!("Failed to render score text: {}", e)).into());
}
// Render high score text
let high_score_text = format!("{:02}", score.0);
let high_score_offset = 17 - (high_score_text.len() as i32);
let high_score_position = glam::UVec2::new(4 + 8 * high_score_offset as u32, 10); // x_offset + score_offset * 8, 8 + y_offset
if let Err(e) = text_renderer.render(canvas, &mut atlas, &high_score_text, high_score_position) {
errors.write(TextureError::RenderFailed(format!("Failed to render high score text: {}", e)).into());
}
// Render GAME OVER text
if matches!(*stage, GameStage::GameOver) {
let game_over_text = "GAME OVER";
let game_over_width = text_renderer.text_width(game_over_text);
let game_over_position = glam::UVec2::new((constants::CANVAS_SIZE.x - game_over_width) / 2, 160);
if let Err(e) = text_renderer.render_with_color(canvas, &mut atlas, game_over_text, game_over_position, Color::RED) {
errors.write(TextureError::RenderFailed(format!("Failed to render GAME OVER text: {}", e)).into());
}
}
// Render text based on StartupSequence stage
if matches!(
*stage,
GameStage::Starting(StartupSequence::TextOnly { .. })
| GameStage::Starting(StartupSequence::CharactersVisible { .. })
) {
let ready_text = "READY!";
let ready_width = text_renderer.text_width(ready_text);
let ready_position = glam::UVec2::new((constants::CANVAS_SIZE.x - ready_width) / 2, 160);
if let Err(e) = text_renderer.render_with_color(canvas, &mut atlas, ready_text, ready_position, Color::YELLOW) {
errors.write(TextureError::RenderFailed(format!("Failed to render READY text: {}", e)).into());
}
if matches!(*stage, GameStage::Starting(StartupSequence::TextOnly { .. })) {
let player_one_text = "PLAYER ONE";
let player_one_width = text_renderer.text_width(player_one_text);
let player_one_position = glam::UVec2::new((constants::CANVAS_SIZE.x - player_one_width) / 2, 113);
if let Err(e) =
text_renderer.render_with_color(canvas, &mut atlas, player_one_text, player_one_position, Color::CYAN)
{
errors.write(TextureError::RenderFailed(format!("Failed to render PLAYER ONE text: {}", e)).into());
}
}
}
});
}

81
src/systems/hud/touch.rs Normal file
View File

@@ -0,0 +1,81 @@
use crate::error::{GameError, TextureError};
use crate::systems::{BackbufferResource, TouchState};
use bevy_ecs::event::EventWriter;
use bevy_ecs::system::{NonSendMut, Res};
use sdl2::pixels::Color;
use sdl2::rect::Point;
use sdl2::render::{BlendMode, Canvas};
use sdl2::video::Window;
/// Renders touch UI overlay for mobile/testing.
pub fn touch_ui_render_system(
mut backbuffer: NonSendMut<BackbufferResource>,
mut canvas: NonSendMut<&mut Canvas<Window>>,
touch_state: Res<TouchState>,
mut errors: EventWriter<GameError>,
) {
if let Some(ref touch_data) = touch_state.active_touch {
let _ = canvas.with_texture_canvas(&mut backbuffer.0, |canvas| {
// Set blend mode for transparency
canvas.set_blend_mode(BlendMode::Blend);
// Draw semi-transparent circle at touch start position
canvas.set_draw_color(Color::RGBA(255, 255, 255, 100));
let center = Point::new(touch_data.start_pos.x as i32, touch_data.start_pos.y as i32);
// Draw a simple circle by drawing filled rectangles (basic approach)
let radius = 30;
for dy in -radius..=radius {
for dx in -radius..=radius {
if dx * dx + dy * dy <= radius * radius {
let point = Point::new(center.x + dx, center.y + dy);
if let Err(e) = canvas.draw_point(point) {
errors.write(TextureError::RenderFailed(format!("Touch UI render error: {}", e)).into());
return;
}
}
}
}
// Draw direction indicator if we have a direction
if let Some(direction) = touch_data.current_direction {
canvas.set_draw_color(Color::RGBA(0, 255, 0, 150));
// Draw arrow indicating direction
let arrow_length = 40;
let (dx, dy) = match direction {
crate::map::direction::Direction::Up => (0, -arrow_length),
crate::map::direction::Direction::Down => (0, arrow_length),
crate::map::direction::Direction::Left => (-arrow_length, 0),
crate::map::direction::Direction::Right => (arrow_length, 0),
};
let end_point = Point::new(center.x + dx, center.y + dy);
if let Err(e) = canvas.draw_line(center, end_point) {
errors.write(TextureError::RenderFailed(format!("Touch arrow render error: {}", e)).into());
}
// Draw arrowhead (simple approach)
let arrow_size = 8;
match direction {
crate::map::direction::Direction::Up => {
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y + arrow_size));
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y + arrow_size));
}
crate::map::direction::Direction::Down => {
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y - arrow_size));
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y - arrow_size));
}
crate::map::direction::Direction::Left => {
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y - arrow_size));
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y + arrow_size));
}
crate::map::direction::Direction::Right => {
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y - arrow_size));
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y + arrow_size));
}
}
}
});
}
}

View File

@@ -13,7 +13,7 @@ use sdl2::{
};
use smallvec::{smallvec, SmallVec};
use crate::systems::components::DeltaTime;
use crate::systems::DeltaTime;
use crate::{
events::{GameCommand, GameEvent},
map::direction::Direction,
@@ -85,8 +85,12 @@ 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);
}
let movement_keys = HashSet::from([
Keycode::W,

View File

@@ -1,80 +1,137 @@
use bevy_ecs::{
entity::Entity,
event::{EventReader, EventWriter},
query::With,
system::{Commands, Query, ResMut},
event::Event,
observer::Trigger,
system::{Commands, NonSendMut, Res},
};
use tracing::{debug, trace};
use strum_macros::IntoStaticStr;
use tracing::debug;
use crate::{
constants::animation::FRIGHTENED_FLASH_START_TICKS,
events::GameEvent,
systems::{AudioEvent, EntityType, GhostCollider, GhostState, ItemCollider, PacmanCollider, ScoreResource},
constants,
map::builder::Map,
systems::{common::bundles::ItemBundle, Collider, Position, Renderable, TimeToLive},
texture::{
sprite::SpriteAtlas,
sprites::{EffectSprite, GameSprite},
},
};
/// Determines if a collision between two entity types should be handled by the item system.
///
/// Returns `true` if one entity is a player and the other is a collectible item.
#[allow(dead_code)]
pub fn is_valid_item_collision(entity1: EntityType, entity2: EntityType) -> bool {
match (entity1, entity2) {
(EntityType::Player, entity) | (entity, EntityType::Player) => entity.is_collectible(),
_ => false,
use crate::{systems::common::components::EntityType, systems::ItemCollider};
use std::cmp::Ordering;
/// Tracks the number of pellets consumed by the player for fruit spawning mechanics.
#[derive(bevy_ecs::resource::Resource, Debug, Default)]
pub struct PelletCount(pub u32);
/// Represents the different fruit sprites that can appear as bonus items.
#[derive(IntoStaticStr, Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[strum(serialize_all = "snake_case")]
pub enum FruitType {
Cherry,
Strawberry,
Orange,
Apple,
Melon,
Galaxian,
Bell,
Key,
}
impl PartialOrd for FruitType {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
pub fn item_system(
mut commands: Commands,
mut collision_events: EventReader<GameEvent>,
mut score: ResMut<ScoreResource>,
pacman_query: Query<Entity, With<PacmanCollider>>,
item_query: Query<(Entity, &EntityType), With<ItemCollider>>,
mut ghost_query: Query<&mut GhostState, With<GhostCollider>>,
mut events: EventWriter<AudioEvent>,
) {
for event in collision_events.read() {
if let GameEvent::Collision(entity1, entity2) = event {
// Check if one is Pacman and the other is an item
let (_pacman_entity, item_entity) = if pacman_query.get(*entity1).is_ok() && item_query.get(*entity2).is_ok() {
(*entity1, *entity2)
} else if pacman_query.get(*entity2).is_ok() && item_query.get(*entity1).is_ok() {
(*entity2, *entity1)
} else {
continue;
};
impl Ord for FruitType {
fn cmp(&self, other: &Self) -> Ordering {
(self.score_value()).cmp(&other.score_value())
}
}
// Get the item type and update score
if let Ok((item_ent, entity_type)) = item_query.get(item_entity) {
if let Some(score_value) = entity_type.score_value() {
trace!(item_entity = ?item_ent, item_type = ?entity_type, score_value, new_score = score.0 + score_value, "Item collected by player");
score.0 += score_value;
impl FruitType {
/// Returns the score value for this fruit type.
pub fn score_value(self) -> u32 {
match self {
FruitType::Cherry => 100,
FruitType::Strawberry => 300,
FruitType::Orange => 500,
FruitType::Apple => 700,
FruitType::Melon => 1000,
FruitType::Galaxian => 2000,
FruitType::Bell => 3000,
FruitType::Key => 5000,
}
}
// Remove the collected item
commands.entity(item_ent).despawn();
// Trigger audio if appropriate
if entity_type.is_collectible() {
events.write(AudioEvent::PlayEat);
}
// Make ghosts frightened when power pellet is collected
if *entity_type == EntityType::PowerPellet {
// Convert seconds to frames (assumes 60 FPS)
let total_ticks = 60 * 5; // 5 seconds total
debug!(duration_ticks = total_ticks, "Power pellet collected, frightening ghosts");
// Set all ghosts to frightened state, except those in Eyes state
let mut frightened_count = 0;
for mut ghost_state in ghost_query.iter_mut() {
if !matches!(*ghost_state, GhostState::Eyes) {
*ghost_state = GhostState::new_frightened(total_ticks, FRIGHTENED_FLASH_START_TICKS);
frightened_count += 1;
}
}
debug!(frightened_count, "Ghosts set to frightened state");
}
}
}
pub fn from_index(index: u8) -> Self {
match index {
0 => FruitType::Cherry,
1 => FruitType::Strawberry,
2 => FruitType::Orange,
3 => FruitType::Apple,
4 => FruitType::Melon,
5 => FruitType::Galaxian,
6 => FruitType::Bell,
7 => FruitType::Key,
_ => panic!("Invalid fruit index: {}", index),
}
}
}
/// Trigger to spawn a fruit
#[derive(Event, Clone, Copy, Debug)]
pub enum SpawnTrigger {
Fruit,
Bonus { position: Position, value: u32, ttl: u32 },
}
pub fn spawn_fruit_observer(
trigger: Trigger<SpawnTrigger>,
mut commands: Commands,
atlas: NonSendMut<SpriteAtlas>,
map: Res<Map>,
) {
let entity = match *trigger {
SpawnTrigger::Fruit => {
// Use cherry sprite as the default fruit (first fruit in original Pac-Man)
let sprite = &atlas
.get_tile(&GameSprite::Fruit(FruitType::from_index(0)).to_path())
.unwrap();
let bundle = ItemBundle {
position: map.start_positions.fruit_spawn,
sprite: Renderable {
sprite: *sprite,
layer: 1,
},
entity_type: EntityType::Fruit(FruitType::Cherry),
collider: Collider {
size: constants::collider::FRUIT_SIZE,
},
item_collider: ItemCollider,
};
commands.spawn(bundle)
}
SpawnTrigger::Bonus { position, value, ttl } => {
let sprite = &atlas
.get_tile(&GameSprite::Effect(EffectSprite::Bonus(value)).to_path())
.unwrap();
let bundle = (
position,
TimeToLive::new(ttl),
Renderable {
sprite: *sprite,
layer: 1,
},
EntityType::Effect,
);
commands.spawn(bundle)
}
};
debug!(entity = ?entity.id(), "Entity spawned via trigger");
}

View File

@@ -4,7 +4,7 @@ use bevy_ecs::{
system::{Commands, Query, Res},
};
use crate::systems::components::DeltaTime;
use crate::systems::DeltaTime;
/// Component for entities that should be automatically deleted after a certain number of ticks
#[derive(Component, Debug, Clone, Copy)]

View File

@@ -1,5 +1,6 @@
//! This module contains all the systems in the game.
// These modules are excluded from coverage.
#[cfg_attr(coverage_nightly, coverage(off))]
pub mod audio;
#[cfg_attr(coverage_nightly, coverage(off))]
@@ -9,10 +10,11 @@ pub mod profiling;
#[cfg_attr(coverage_nightly, coverage(off))]
pub mod render;
pub mod blinking;
pub mod collision;
pub mod components;
pub mod ghost;
mod animation;
mod collision;
pub mod common;
mod ghost;
mod hud;
pub mod input;
pub mod item;
pub mod lifetime;
@@ -22,12 +24,13 @@ pub mod state;
// Re-export all the modules. Do not fine-tune the exports.
pub use self::animation::*;
pub use self::audio::*;
pub use self::blinking::*;
pub use self::collision::*;
pub use self::components::*;
pub use self::common::*;
pub use self::debug::*;
pub use self::ghost::*;
pub use self::hud::*;
pub use self::input::*;
pub use self::item::*;
pub use self::lifetime::*;

View File

@@ -1,22 +1,26 @@
use bevy_ecs::{
event::{EventReader, EventWriter},
component::Component,
event::EventReader,
query::{With, Without},
system::{Query, Res, ResMut},
system::{Query, Res, ResMut, Single},
};
use tracing::trace;
use crate::{
error::GameError,
events::{GameCommand, GameEvent},
map::{builder::Map, graph::Edge},
systems::{
components::{DeltaTime, EntityType, Frozen, GlobalState, MovementModifiers, PlayerControlled},
components::{DeltaTime, EntityType, Frozen, GlobalState, MovementModifiers},
debug::DebugState,
movement::{BufferedDirection, Position, Velocity},
AudioState,
},
};
/// A tag component for entities that are controlled by the player.
#[derive(Default, Component)]
pub struct PlayerControlled;
pub fn can_traverse(entity_type: EntityType, edge: Edge) -> bool {
let entity_flags = entity_type.traversal_flags();
edge.traversal_flags.contains(entity_flags)
@@ -28,49 +32,40 @@ pub fn can_traverse(entity_type: EntityType, edge: Edge) -> bool {
/// toggling, audio muting, and game exit requests. Movement commands are buffered
/// to allow direction changes before reaching intersections, improving gameplay
/// responsiveness. Non-movement commands immediately modify global game state.
#[allow(clippy::type_complexity)]
pub fn player_control_system(
mut events: EventReader<GameEvent>,
mut state: ResMut<GlobalState>,
mut debug_state: ResMut<DebugState>,
mut audio_state: ResMut<AudioState>,
mut players: Query<&mut BufferedDirection, (With<PlayerControlled>, Without<Frozen>)>,
mut errors: EventWriter<GameError>,
mut player: Option<Single<&mut BufferedDirection, (With<PlayerControlled>, Without<Frozen>)>>,
) {
// Handle events
for event in events.read() {
if let GameEvent::Command(command) = event {
match command {
GameCommand::MovePlayer(direction) => {
// Get the player's movable component (ensuring there is only one player)
let mut buffered_direction = match players.single_mut() {
Ok(tuple) => tuple,
Err(e) => {
errors.write(GameError::InvalidState(format!(
"No/multiple entities queried for player system: {}",
e
)));
return;
}
};
let GameEvent::Command(command) = event;
match command {
GameCommand::MovePlayer(direction) => {
// Only handle movement if there's an unfrozen player
if let Some(player_single) = player.as_mut() {
trace!(direction = ?*direction, "Player direction buffered for movement");
*buffered_direction = BufferedDirection::Some {
***player_single = BufferedDirection::Some {
direction: *direction,
remaining_time: 0.25,
};
}
GameCommand::Exit => {
state.exit = true;
}
GameCommand::ToggleDebug => {
debug_state.enabled = !debug_state.enabled;
}
GameCommand::MuteAudio => {
audio_state.muted = !audio_state.muted;
tracing::info!("Audio {}", if audio_state.muted { "muted" } else { "unmuted" });
}
_ => {}
}
GameCommand::Exit => {
state.exit = true;
}
GameCommand::ToggleDebug => {
debug_state.enabled = !debug_state.enabled;
}
GameCommand::MuteAudio => {
audio_state.muted = !audio_state.muted;
tracing::info!("Audio {}", if audio_state.muted { "muted" } else { "unmuted" });
}
_ => {}
}
}
}
@@ -168,24 +163,23 @@ pub fn player_movement_system(
}
/// Applies tunnel slowdown based on the current node tile
pub fn player_tunnel_slowdown_system(map: Res<Map>, mut q: Query<(&Position, &mut MovementModifiers), With<PlayerControlled>>) {
if let Ok((position, mut modifiers)) = q.single_mut() {
let node = position.current_node();
let in_tunnel = map
.tile_at_node(node)
.map(|t| t == crate::constants::MapTile::Tunnel)
.unwrap_or(false);
pub fn player_tunnel_slowdown_system(map: Res<Map>, player: Single<(&Position, &mut MovementModifiers), With<PlayerControlled>>) {
let (position, mut modifiers) = player.into_inner();
let node = position.current_node();
let in_tunnel = map
.tile_at_node(node)
.map(|t| t == crate::constants::MapTile::Tunnel)
.unwrap_or(false);
if modifiers.tunnel_slowdown_active != in_tunnel {
trace!(
node,
in_tunnel,
speed_multiplier = if in_tunnel { 0.6 } else { 1.0 },
"Player tunnel slowdown state changed"
);
}
modifiers.tunnel_slowdown_active = in_tunnel;
modifiers.speed_multiplier = if in_tunnel { 0.6 } else { 1.0 };
if modifiers.tunnel_slowdown_active != in_tunnel {
trace!(
node,
in_tunnel,
speed_multiplier = if in_tunnel { 0.6 } else { 1.0 },
"Player tunnel slowdown state changed"
);
}
modifiers.tunnel_slowdown_active = in_tunnel;
modifiers.speed_multiplier = if in_tunnel { 0.6 } else { 1.0 };
}

View File

@@ -52,6 +52,11 @@ impl TimingBuffer {
self.last_tick = current_tick;
}
/// Gets the most recent timing from the buffer.
pub fn get_most_recent_timing(&self) -> Duration {
self.buffer.back().copied().unwrap_or(Duration::ZERO)
}
/// Gets statistics for this timing buffer.
///
/// # Panics
@@ -248,6 +253,61 @@ impl SystemTimings {
// Use the formatting module to format the data
format_timing_display(timing_data)
}
/// Returns a list of systems with their timings, likely responsible for slow frame timings.
///
/// First, checks if any systems took longer than 2ms on the most recent tick.
/// If none exceed 2ms, accumulates systems until the top 30% of total timing
/// is reached, stopping at 5 systems maximum.
///
/// Returns tuples of (SystemId, Duration) in a SmallVec capped at 5 items.
pub fn get_slowest_systems(&self) -> SmallVec<[(SystemId, Duration); 5]> {
let mut system_timings: Vec<(SystemId, Duration)> = Vec::new();
let mut total_duration = Duration::ZERO;
// Collect most recent timing for each system (excluding Total)
for id in SystemId::iter() {
if id == SystemId::Total {
continue;
}
if let Some(buffer) = self.timings.get(&id) {
let recent_timing = buffer.lock().get_most_recent_timing();
system_timings.push((id, recent_timing));
total_duration += recent_timing;
}
}
// Sort by duration (highest first)
system_timings.sort_by(|a, b| b.1.cmp(&a.1));
// Check for systems over 2ms threshold
let over_threshold: SmallVec<[(SystemId, Duration); 5]> = system_timings
.iter()
.filter(|(_, duration)| duration.as_millis() >= 2)
.copied()
.collect();
if !over_threshold.is_empty() {
return over_threshold;
}
// Accumulate top systems until 30% of total is reached (max 5 systems)
let threshold = total_duration.as_nanos() as f64 * 0.3;
let mut accumulated = 0u128;
let mut result = SmallVec::new();
for (id, duration) in system_timings.iter().take(5) {
result.push((*id, *duration));
accumulated += duration.as_nanos();
if accumulated as f64 >= threshold {
break;
}
}
result
}
}
pub fn profile<S, M>(id: SystemId, system: S) -> impl FnMut(&mut bevy_ecs::world::World)

View File

@@ -1,37 +1,85 @@
use crate::error::{GameError, TextureError};
use crate::map::builder::Map;
use crate::map::direction::Direction;
use crate::systems::input::TouchState;
use crate::systems::{
debug_render_system, BatchedLinesResource, Collider, CursorPosition, DebugState, DebugTextureResource, DeltaTime,
DirectionalAnimation, Dying, Frozen, GameStage, LinearAnimation, Looping, PlayerLives, Position, Renderable, ScoreResource,
StartupSequence, SystemId, SystemTimings, TtfAtlasResource, Velocity,
};
use crate::texture::sprite::SpriteAtlas;
use crate::texture::sprites::{GameSprite, PacmanSprite};
use crate::texture::text::TextTexture;
use crate::{
constants::{BOARD_BOTTOM_PIXEL_OFFSET, CANVAS_SIZE, CELL_SIZE},
error::{GameError, TextureError},
debug_render_system, BatchedLinesResource, Collider, CursorPosition, DebugState, DebugTextureResource, Position, SystemId,
SystemTimings, TtfAtlasResource,
};
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
use bevy_ecs::component::Component;
use bevy_ecs::entity::Entity;
use bevy_ecs::event::EventWriter;
use bevy_ecs::query::{Changed, Has, Or, With, Without};
use bevy_ecs::query::{Changed, Or, With};
use bevy_ecs::removal_detection::RemovedComponents;
use bevy_ecs::resource::Resource;
use bevy_ecs::system::{NonSendMut, Query, Res, ResMut};
use sdl2::pixels::Color;
use glam::Vec2;
use sdl2::rect::{Point, Rect};
use sdl2::render::{BlendMode, Canvas, Texture};
use sdl2::video::Window;
use std::time::Instant;
/// A component for entities that have a sprite, with a layer for ordering.
///
/// This is intended to be modified by other entities allowing animation.
#[derive(Component)]
pub struct Renderable {
pub sprite: AtlasTile,
pub layer: u8,
}
#[derive(Resource, Default)]
pub struct RenderDirty(pub bool);
#[derive(Component)]
pub struct Hidden;
/// A component that controls entity visibility in the render system.
///
/// Entities without this component are considered visible by default.
/// This allows for efficient rendering where only entities that need
/// visibility control have this component.
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub struct Visibility(pub bool);
impl Default for Visibility {
fn default() -> Self {
Self(true) // Default to visible
}
}
impl Visibility {
/// Creates a visible Visibility component
pub fn visible() -> Self {
Self(true)
}
/// Creates a hidden Visibility component
pub fn hidden() -> Self {
Self(false)
}
/// Returns true if the entity is visible
pub fn is_visible(&self) -> bool {
self.0
}
/// Returns true if the entity is hidden
#[allow(dead_code)] // Used in tests
pub fn is_hidden(&self) -> bool {
!self.0
}
/// Makes the entity visible
pub fn show(&mut self) {
self.0 = true;
}
/// Toggles the visibility state
pub fn toggle(&mut self) {
self.0 = !self.0;
}
}
/// Enum to identify which texture is being rendered to in the combined render system
#[derive(Debug, Clone, Copy)]
enum RenderTarget {
@@ -42,88 +90,18 @@ enum RenderTarget {
#[allow(clippy::type_complexity)]
pub fn dirty_render_system(
mut dirty: ResMut<RenderDirty>,
changed: Query<(), Or<(Changed<Renderable>, Changed<Position>)>>,
removed_hidden: RemovedComponents<Hidden>,
changed: Query<(), Or<(Changed<Renderable>, Changed<Position>, Changed<Visibility>)>>,
removed_renderables: RemovedComponents<Renderable>,
) {
let changed_count = changed.iter().count();
let removed_hidden_count = removed_hidden.len();
let removed_renderables_count = removed_renderables.len();
if changed_count > 0 || removed_hidden_count > 0 || removed_renderables_count > 0 {
if changed.iter().count() > 0 || !removed_renderables.is_empty() {
dirty.0 = true;
}
}
/// Updates directional animated entities with synchronized timing across directions.
///
/// This runs before the render system to update sprites based on current direction and movement state.
/// All directions share the same frame timing to ensure perfect synchronization.
pub fn directional_render_system(
dt: Res<DeltaTime>,
mut query: Query<(&Position, &Velocity, &mut DirectionalAnimation, &mut Renderable), Without<Frozen>>,
) {
let ticks = (dt.seconds * 60.0).round() as u16; // Convert from seconds to ticks at 60 ticks/sec
for (position, velocity, mut anim, mut renderable) in query.iter_mut() {
let stopped = matches!(position, Position::Stopped { .. });
// Only tick animation when moving to preserve stopped frame
if !stopped {
// Tick shared animation state
anim.time_bank += ticks;
while anim.time_bank >= anim.frame_duration {
anim.time_bank -= anim.frame_duration;
anim.current_frame += 1;
}
}
// Get tiles for current direction and movement state
let tiles = if stopped {
anim.stopped_tiles.get(velocity.direction)
} else {
anim.moving_tiles.get(velocity.direction)
};
if !tiles.is_empty() {
let new_tile = tiles.get_tile(anim.current_frame);
if renderable.sprite != new_tile {
renderable.sprite = new_tile;
}
}
}
}
/// System that updates `Renderable` sprites for entities with `LinearAnimation`.
#[allow(clippy::type_complexity)]
pub fn linear_render_system(
dt: Res<DeltaTime>,
mut query: Query<(&mut LinearAnimation, &mut Renderable, Has<Looping>), Or<(Without<Frozen>, With<Dying>)>>,
) {
for (mut anim, mut renderable, looping) in query.iter_mut() {
if anim.finished {
continue;
}
anim.time_bank += dt.ticks as u16;
let frames_to_advance = (anim.time_bank / anim.frame_duration) as usize;
if frames_to_advance == 0 {
continue;
}
let total_frames = anim.tiles.len();
if !looping && anim.current_frame + frames_to_advance >= total_frames {
anim.finished = true;
anim.current_frame = total_frames - 1;
} else {
anim.current_frame += frames_to_advance;
}
anim.time_bank %= anim.frame_duration;
renderable.sprite = anim.tiles.get_tile(anim.current_frame);
}
/// Component for Renderables to store an exact pixel position
#[derive(Component)]
pub struct PixelPosition {
pub pixel_position: Vec2,
}
/// A non-send resource for the map texture. This just wraps the texture with a type so it can be differentiated when exposed as a resource.
@@ -132,193 +110,24 @@ pub struct MapTextureResource(pub Texture);
/// A non-send resource for the backbuffer texture. This just wraps the texture with a type so it can be differentiated when exposed as a resource.
pub struct BackbufferResource(pub Texture);
/// Renders touch UI overlay for mobile/testing.
pub fn touch_ui_render_system(
mut backbuffer: NonSendMut<BackbufferResource>,
mut canvas: NonSendMut<&mut Canvas<Window>>,
touch_state: Res<TouchState>,
mut errors: EventWriter<GameError>,
) {
if let Some(ref touch_data) = touch_state.active_touch {
let _ = canvas.with_texture_canvas(&mut backbuffer.0, |canvas| {
// Set blend mode for transparency
canvas.set_blend_mode(BlendMode::Blend);
// Draw semi-transparent circle at touch start position
canvas.set_draw_color(Color::RGBA(255, 255, 255, 100));
let center = Point::new(touch_data.start_pos.x as i32, touch_data.start_pos.y as i32);
// Draw a simple circle by drawing filled rectangles (basic approach)
let radius = 30;
for dy in -radius..=radius {
for dx in -radius..=radius {
if dx * dx + dy * dy <= radius * radius {
let point = Point::new(center.x + dx, center.y + dy);
if let Err(e) = canvas.draw_point(point) {
errors.write(TextureError::RenderFailed(format!("Touch UI render error: {}", e)).into());
return;
}
}
}
}
// Draw direction indicator if we have a direction
if let Some(direction) = touch_data.current_direction {
canvas.set_draw_color(Color::RGBA(0, 255, 0, 150));
// Draw arrow indicating direction
let arrow_length = 40;
let (dx, dy) = match direction {
crate::map::direction::Direction::Up => (0, -arrow_length),
crate::map::direction::Direction::Down => (0, arrow_length),
crate::map::direction::Direction::Left => (-arrow_length, 0),
crate::map::direction::Direction::Right => (arrow_length, 0),
};
let end_point = Point::new(center.x + dx, center.y + dy);
if let Err(e) = canvas.draw_line(center, end_point) {
errors.write(TextureError::RenderFailed(format!("Touch arrow render error: {}", e)).into());
}
// Draw arrowhead (simple approach)
let arrow_size = 8;
match direction {
crate::map::direction::Direction::Up => {
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y + arrow_size));
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y + arrow_size));
}
crate::map::direction::Direction::Down => {
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y - arrow_size));
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y - arrow_size));
}
crate::map::direction::Direction::Left => {
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y - arrow_size));
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y + arrow_size));
}
crate::map::direction::Direction::Right => {
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y - arrow_size));
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y + arrow_size));
}
}
}
});
}
}
/// Renders the HUD (score, lives, etc.) on top of the game.
#[allow(clippy::too_many_arguments)]
pub fn hud_render_system(
mut backbuffer: NonSendMut<BackbufferResource>,
mut canvas: NonSendMut<&mut Canvas<Window>>,
mut atlas: NonSendMut<SpriteAtlas>,
player_lives: Res<PlayerLives>,
score: Res<ScoreResource>,
stage: Res<GameStage>,
mut errors: EventWriter<GameError>,
) {
let _ = canvas.with_texture_canvas(&mut backbuffer.0, |canvas| {
let mut text_renderer = TextTexture::new(1.0);
// Render lives and high score text in white
let lives_text = "1UP HIGH SCORE ";
let lives_position = glam::UVec2::new(4 + 8 * 3, 2); // x_offset + lives_offset * 8, y_offset
if let Err(e) = text_renderer.render(canvas, &mut atlas, lives_text, lives_position) {
errors.write(TextureError::RenderFailed(format!("Failed to render lives text: {}", e)).into());
}
// Render Pac-Man life sprites in bottom left
let lives = player_lives.0;
let life_sprite_path = &GameSprite::Pacman(PacmanSprite::Moving(Direction::Left, 1)).to_path();
// Get the sprite from the atlas for life display
match atlas.get_tile(life_sprite_path) {
Ok(life_sprite) => {
let start_x = CELL_SIZE * 2; // 2 cells from left
let start_y = CANVAS_SIZE.y - BOARD_BOTTOM_PIXEL_OFFSET.y + (CELL_SIZE / 2) + 1; // In bottom area
let sprite_spacing = CELL_SIZE + CELL_SIZE / 2; // 1.5 cells between sprites
// Render one sprite for each remaining life (lives - 1, since current life isn't shown)
let sprites_to_show = if lives > 0 { lives - 1 } else { 0 };
for i in 0..sprites_to_show {
let x = start_x + ((i as f32) * (sprite_spacing as f32 * 1.5)).round() as u32;
let y = start_y - CELL_SIZE / 2;
let dest = sdl2::rect::Rect::new(x as i32, y as i32, life_sprite.size.x as u32, life_sprite.size.y as u32);
if let Err(e) = life_sprite.render(canvas, &mut atlas, dest) {
errors.write(TextureError::RenderFailed(format!("Failed to render life sprite: {}", e)).into());
}
}
}
Err(e) => {
errors.write(e.into());
}
}
// Render score text
let score_text = format!("{:02}", score.0);
let score_offset = 7 - (score_text.len() as i32);
let score_position = glam::UVec2::new(4 + 8 * score_offset as u32, 10); // x_offset + score_offset * 8, 8 + y_offset
if let Err(e) = text_renderer.render(canvas, &mut atlas, &score_text, score_position) {
errors.write(TextureError::RenderFailed(format!("Failed to render score text: {}", e)).into());
}
// Render high score text
let high_score_text = format!("{:02}", score.0);
let high_score_offset = 17 - (high_score_text.len() as i32);
let high_score_position = glam::UVec2::new(4 + 8 * high_score_offset as u32, 10); // x_offset + score_offset * 8, 8 + y_offset
if let Err(e) = text_renderer.render(canvas, &mut atlas, &high_score_text, high_score_position) {
errors.write(TextureError::RenderFailed(format!("Failed to render high score text: {}", e)).into());
}
// Render GAME OVER text
if matches!(*stage, GameStage::GameOver) {
let game_over_text = "GAME OVER";
let game_over_width = text_renderer.text_width(game_over_text);
let game_over_position = glam::UVec2::new((CANVAS_SIZE.x - game_over_width) / 2, 160);
if let Err(e) = text_renderer.render_with_color(canvas, &mut atlas, game_over_text, game_over_position, Color::RED) {
errors.write(TextureError::RenderFailed(format!("Failed to render GAME OVER text: {}", e)).into());
}
}
// Render text based on StartupSequence stage
if matches!(
*stage,
GameStage::Starting(StartupSequence::TextOnly { .. })
| GameStage::Starting(StartupSequence::CharactersVisible { .. })
) {
let ready_text = "READY!";
let ready_width = text_renderer.text_width(ready_text);
let ready_position = glam::UVec2::new((CANVAS_SIZE.x - ready_width) / 2, 160);
if let Err(e) = text_renderer.render_with_color(canvas, &mut atlas, ready_text, ready_position, Color::YELLOW) {
errors.write(TextureError::RenderFailed(format!("Failed to render READY text: {}", e)).into());
}
if matches!(*stage, GameStage::Starting(StartupSequence::TextOnly { .. })) {
let player_one_text = "PLAYER ONE";
let player_one_width = text_renderer.text_width(player_one_text);
let player_one_position = glam::UVec2::new((CANVAS_SIZE.x - player_one_width) / 2, 113);
if let Err(e) =
text_renderer.render_with_color(canvas, &mut atlas, player_one_text, player_one_position, Color::CYAN)
{
errors.write(TextureError::RenderFailed(format!("Failed to render PLAYER ONE text: {}", e)).into());
}
}
}
});
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::type_complexity)]
pub fn render_system(
canvas: &mut Canvas<Window>,
map_texture: &NonSendMut<MapTextureResource>,
atlas: &mut SpriteAtlas,
map: &Res<Map>,
dirty: &Res<RenderDirty>,
renderables: &Query<(Entity, &Renderable, &Position), Without<Hidden>>,
renderables: &Query<
(
Entity,
&Renderable,
Option<&Position>,
Option<&PixelPosition>,
Option<&Visibility>,
),
Or<(With<Position>, With<PixelPosition>)>,
>,
errors: &mut EventWriter<GameError>,
) {
if !dirty.0 {
@@ -334,13 +143,25 @@ pub fn render_system(
errors.write(TextureError::RenderFailed(e.to_string()).into());
}
// Render all entities to the backbuffer
for (_, renderable, position) in renderables
// Collect and filter visible entities, then sort by layer
let mut visible_entities: Vec<_> = renderables
.iter()
.sort_by_key::<(Entity, &Renderable, &Position), _>(|(_, renderable, _)| renderable.layer)
.rev()
{
let pos = position.get_pixel_position(&map.graph);
.filter(|(_, _, _, _, visibility)| visibility.copied().unwrap_or_default().is_visible())
.collect();
visible_entities.sort_by_key(|(_, renderable, _, _, _)| renderable.layer);
visible_entities.reverse();
// Render all visible entities to the backbuffer
for (_entity, renderable, position, pixel_position, _visibility) in visible_entities {
let pos = if let Some(position) = position {
position.get_pixel_position(&map.graph)
} else {
Ok(pixel_position
.expect("Pixel position should be present via query filtering, but got None on both")
.pixel_position)
};
match pos {
Ok(pos) => {
let dest = Rect::from_center(
@@ -365,6 +186,7 @@ pub fn render_system(
/// Combined render system that renders to both backbuffer and debug textures in a single
/// with_multiple_texture_canvas call for reduced overhead
#[allow(clippy::too_many_arguments)]
#[allow(clippy::type_complexity)]
pub fn combined_render_system(
mut canvas: NonSendMut<&mut Canvas<Window>>,
map_texture: NonSendMut<MapTextureResource>,
@@ -378,7 +200,16 @@ pub fn combined_render_system(
timing: Res<crate::systems::profiling::Timing>,
map: Res<Map>,
dirty: Res<RenderDirty>,
renderables: Query<(Entity, &Renderable, &Position), Without<Hidden>>,
renderables: Query<
(
Entity,
&Renderable,
Option<&Position>,
Option<&PixelPosition>,
Option<&Visibility>,
),
Or<(With<Position>, With<PixelPosition>)>,
>,
colliders: Query<(&Collider, &Position)>,
cursor: Res<CursorPosition>,
mut errors: EventWriter<GameError>,

View File

@@ -1,21 +1,23 @@
use std::mem::discriminant;
use tracing::{debug, info, warn};
use crate::constants;
use crate::events::StageTransition;
use crate::map::direction::Direction;
use crate::systems::{EntityType, ItemCollider, SpawnTrigger, Velocity};
use crate::{
map::builder::Map,
systems::{
AudioEvent, Blinking, DirectionalAnimation, Dying, Eaten, Frozen, Ghost, GhostCollider, GhostState, Hidden,
LinearAnimation, Looping, NodeId, PlayerControlled, Position, Renderable, TimeToLive,
AudioEvent, Blinking, DirectionalAnimation, Dying, Frozen, Ghost, GhostCollider, GhostState, LinearAnimation, Looping,
NodeId, PlayerControlled, Position, Visibility,
},
texture::{animated::TileSequence, sprite::SpriteAtlas},
};
use bevy_ecs::{
entity::Entity,
event::{EventReader, EventWriter},
query::{With, Without},
resource::Resource,
system::{Commands, NonSendMut, Query, Res, ResMut},
system::{Commands, Query, Res, ResMut, Single},
};
#[derive(Resource, Clone)]
@@ -34,16 +36,54 @@ pub enum GameStage {
GhostEatenPause {
remaining_ticks: u32,
ghost_entity: Entity,
ghost_type: Ghost,
node: NodeId,
},
/// The player has died and the death sequence is in progress.
/// The player has died and the death sequence is in progress. At the end, the player will return to the startup sequence or game over.
PlayerDying(DyingSequence),
/// The level is restarting after a death.
LevelRestarting,
/// The game has ended.
GameOver,
}
pub trait TooSimilar {
fn too_similar(&self, other: &Self) -> bool;
}
impl TooSimilar for GameStage {
fn too_similar(&self, other: &Self) -> bool {
discriminant(self) == discriminant(other) && {
// These states are very simple, so they're 'too similar' automatically
if matches!(self, GameStage::Playing | GameStage::GameOver) {
return true;
}
// Since the discriminant is the same but the values are different, it's the interior value that is somehow different
match (self, other) {
// These states are similar if their interior values are similar as well
(GameStage::Starting(startup), GameStage::Starting(other)) => startup.too_similar(other),
(GameStage::PlayerDying(dying), GameStage::PlayerDying(other)) => dying.too_similar(other),
(
GameStage::GhostEatenPause {
ghost_entity,
ghost_type,
node,
..
},
GameStage::GhostEatenPause {
ghost_entity: other_ghost_entity,
ghost_type: other_ghost_type,
node: other_node,
..
},
) => ghost_entity == other_ghost_entity && ghost_type == other_ghost_type && node == other_node,
// Already handled, but kept to properly exhaust the match
(GameStage::Playing, _) | (GameStage::GameOver, _) => unreachable!(),
_ => unreachable!(),
}
}
}
}
/// A resource that manages the multi-stage startup sequence of the game.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum StartupSequence {
@@ -70,6 +110,12 @@ impl Default for GameStage {
}
}
impl TooSimilar for StartupSequence {
fn too_similar(&self, other: &Self) -> bool {
discriminant(self) == discriminant(other)
}
}
/// The state machine for the multi-stage death sequence.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum DyingSequence {
@@ -81,6 +127,12 @@ pub enum DyingSequence {
Hidden { remaining_ticks: u32 },
}
impl TooSimilar for DyingSequence {
fn too_similar(&self, other: &Self) -> bool {
discriminant(self) == discriminant(other)
}
}
/// A resource to store the number of player lives.
#[derive(Resource, Debug)]
pub struct PlayerLives(pub u8);
@@ -92,24 +144,6 @@ impl Default for PlayerLives {
}
/// Handles startup sequence transitions and component management
/// Maps sprite index to the corresponding effect sprite path
fn sprite_index_to_path(index: u8) -> &'static str {
match index {
0 => "effects/100.png",
1 => "effects/200.png",
2 => "effects/300.png",
3 => "effects/400.png",
4 => "effects/700.png",
5 => "effects/800.png",
6 => "effects/1000.png",
7 => "effects/1600.png",
8 => "effects/2000.png",
9 => "effects/3000.png",
10 => "effects/5000.png",
_ => "effects/200.png", // fallback to index 1
}
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::type_complexity)]
pub fn stage_system(
@@ -122,26 +156,26 @@ pub fn stage_system(
mut audio_events: EventWriter<AudioEvent>,
mut stage_event_reader: EventReader<StageTransition>,
mut blinking_query: Query<Entity, With<Blinking>>,
mut player_query: Query<(Entity, &mut Position), With<PlayerControlled>>,
mut ghost_query: Query<(Entity, &Ghost, &mut Position), (With<GhostCollider>, Without<PlayerControlled>)>,
atlas: NonSendMut<SpriteAtlas>,
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>)>,
) {
let old_state = *game_state;
let mut new_state: Option<GameStage> = None;
// Handle stage transition requests before normal ticking
for event in stage_event_reader.read() {
let StageTransition::GhostEatenPause { ghost_entity } = *event;
let pac_node = player_query
.single_mut()
.ok()
.map(|(_, pos)| pos.current_node())
.unwrap_or(map.start_positions.pacman);
let StageTransition::GhostEatenPause {
ghost_entity,
ghost_type,
} = *event;
let pac_node = player.1.current_node();
debug!(ghost_entity = ?ghost_entity, 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 {
remaining_ticks: 30,
ghost_entity,
ghost_type,
node: pac_node,
});
}
@@ -154,7 +188,6 @@ pub fn stage_system(
remaining_ticks: remaining_ticks - 1,
})
} else {
debug!("Transitioning from text-only to characters visible startup stage");
GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 })
}
}
@@ -173,12 +206,14 @@ pub fn stage_system(
GameStage::GhostEatenPause {
remaining_ticks,
ghost_entity,
ghost_type,
node,
} => {
if remaining_ticks > 0 {
GameStage::GhostEatenPause {
remaining_ticks: remaining_ticks.saturating_sub(1),
ghost_entity,
ghost_type,
node,
}
} else {
@@ -217,8 +252,8 @@ pub fn stage_system(
player_lives.0 = player_lives.0.saturating_sub(1);
if player_lives.0 > 0 {
info!(remaining_lives = player_lives.0, "Player died, restarting level");
GameStage::LevelRestarting
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");
GameStage::GameOver
@@ -226,10 +261,6 @@ pub fn stage_system(
}
}
},
GameStage::LevelRestarting => {
debug!("Level restart complete, returning to startup sequence");
GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 })
}
GameStage::GameOver => GameStage::GameOver,
};
@@ -237,157 +268,142 @@ pub fn stage_system(
return;
}
if !old_state.too_similar(&new_state) {
debug!(old_state = ?old_state, new_state = ?new_state, "Game stage transition");
}
match (old_state, new_state) {
(GameStage::Playing, GameStage::GhostEatenPause { ghost_entity, node, .. }) => {
// Freeze the player & ghosts
for entity in player_query
.iter_mut()
.map(|(e, _)| e)
.chain(ghost_query.iter_mut().map(|(e, _, _)| e))
{
commands.entity(entity).insert(Frozen);
// Freeze the player & non-eaten ghosts
commands.entity(player.0).insert(Frozen);
commands.entity(ghost_entity).insert(Frozen);
for (entity, _, _, state) in ghost_query.iter_mut() {
// Only freeze ghosts that are not currently eaten
if *state != GhostState::Eyes {
debug!(ghost = ?entity, "Freezing ghost");
commands.entity(entity).insert(Frozen);
}
}
// Hide the player & eaten ghost
for (player_entity, _) in player_query.iter_mut() {
commands.entity(player_entity).insert(Hidden);
}
commands.entity(ghost_entity).insert(Hidden);
commands.entity(player.0).insert(Visibility::hidden());
commands.entity(ghost_entity).insert(Visibility::hidden());
// Spawn bonus points entity at Pac-Man's position
let sprite_index = 1; // Index 1 = 200 points (default for ghost eating)
let sprite_path = sprite_index_to_path(sprite_index);
if let Ok(sprite_tile) = SpriteAtlas::get_tile(&atlas, sprite_path) {
let tile_sequence = TileSequence::single(sprite_tile);
let animation = LinearAnimation::new(tile_sequence, 1);
commands.spawn((
Position::Stopped { node },
Renderable {
sprite: sprite_tile,
layer: 2, // Above other entities
},
animation,
TimeToLive::new(30),
));
}
commands.trigger(SpawnTrigger::Bonus {
position: Position::Stopped { node },
// TODO: Doubling score value for each consecutive ghost eaten
value: 200,
ttl: 30,
});
}
(GameStage::GhostEatenPause { ghost_entity, .. }, GameStage::Playing) => {
// Unfreeze and reveal the player & all ghosts
for entity in player_query
.iter_mut()
.map(|(e, _)| e)
.chain(ghost_query.iter_mut().map(|(e, _, _)| e))
{
commands.entity(entity).remove::<(Frozen, Hidden)>();
commands.entity(player.0).remove::<Frozen>().insert(Visibility::visible());
for (entity, _, _, _) in ghost_query.iter_mut() {
commands.entity(entity).remove::<Frozen>().insert(Visibility::visible());
}
// Reveal the eaten ghost and switch it to Eyes state
commands.entity(ghost_entity).insert(GhostState::Eyes);
}
(GameStage::Playing, GameStage::PlayerDying(DyingSequence::Frozen { .. })) => {
(_, GameStage::PlayerDying(DyingSequence::Frozen { .. })) => {
// Freeze the player & ghosts
for entity in player_query
.iter_mut()
.map(|(e, _)| e)
.chain(ghost_query.iter_mut().map(|(e, _, _)| e))
{
commands.entity(player.0).insert(Frozen);
for (entity, _, _, _) in ghost_query.iter_mut() {
commands.entity(entity).insert(Frozen);
}
}
(GameStage::PlayerDying(DyingSequence::Frozen { .. }), GameStage::PlayerDying(DyingSequence::Animating { .. })) => {
// Hide the ghosts
for (entity, _, _) in ghost_query.iter_mut() {
commands.entity(entity).insert(Hidden);
for (entity, _, _, _) in ghost_query.iter_mut() {
commands.entity(entity).insert(Visibility::hidden());
}
// Start Pac-Man's death animation
if let Ok((player_entity, _)) = player_query.single_mut() {
commands
.entity(player_entity)
.insert((Dying, player_death_animation.0.clone()));
}
commands
.entity(player.0)
.remove::<DirectionalAnimation>()
.insert((Dying, player_death_animation.0.clone()));
// Play the death sound
audio_events.write(AudioEvent::PlayDeath);
}
(GameStage::PlayerDying(DyingSequence::Animating { .. }), GameStage::PlayerDying(DyingSequence::Hidden { .. })) => {
// Hide the player
if let Ok((player_entity, _)) = player_query.single_mut() {
commands.entity(player_entity).insert(Hidden);
}
}
(_, GameStage::LevelRestarting) => {
if let Ok((player_entity, mut pos)) = player_query.single_mut() {
*pos = Position::Stopped {
node: map.start_positions.pacman,
};
(_, GameStage::PlayerDying(DyingSequence::Hidden { .. })) => {
// Pac-Man's death animation is complete, so he should be hidden just like the ghosts.
// Then, we reset them all back to their original positions and states.
// Freeze the blinking, force them to be visible (if they were hidden by blinking)
for entity in blinking_query.iter_mut() {
commands.entity(entity).insert(Frozen).remove::<Hidden>();
}
// Reset the player animation
commands
.entity(player_entity)
.remove::<(Frozen, Dying, LinearAnimation, Looping)>()
.insert(player_animation.0.clone());
// Freeze the blinking power pellets, force them to be visible (if they were hidden by blinking)
for entity in blinking_query.iter_mut() {
commands.entity(entity).insert(Frozen).insert(Visibility::visible());
}
// Delete any fruit entities
for (entity, _) in item_query
.iter_mut()
.filter(|(_, entity_type)| matches!(entity_type, EntityType::Fruit(_)))
{
commands.entity(entity).despawn();
}
// Reset the player animation
commands
.entity(player.0)
.remove::<(Dying, LinearAnimation, Looping)>()
.insert((
Velocity {
speed: constants::mechanics::PLAYER_SPEED,
direction: Direction::Left,
},
Position::Stopped {
node: map.start_positions.pacman,
},
player_animation.0.clone(),
Visibility::hidden(),
Frozen,
));
// Reset ghost positions and state
for (ghost_entity, ghost, mut ghost_pos) in ghost_query.iter_mut() {
*ghost_pos = Position::Stopped {
node: match ghost {
Ghost::Blinky => map.start_positions.blinky,
Ghost::Pinky => map.start_positions.pinky,
Ghost::Inky => map.start_positions.inky,
Ghost::Clyde => map.start_positions.clyde,
for (ghost_entity, ghost, _, _) in ghost_query.iter_mut() {
commands.entity(ghost_entity).insert((
GhostState::Normal,
Position::Stopped {
node: match ghost {
Ghost::Blinky => map.start_positions.blinky,
Ghost::Pinky => map.start_positions.pinky,
Ghost::Inky => map.start_positions.inky,
Ghost::Clyde => map.start_positions.clyde,
},
},
};
commands
.entity(ghost_entity)
.remove::<(Frozen, Hidden, Eaten)>()
.insert(GhostState::Normal);
Frozen,
Visibility::hidden(),
));
}
}
(_, GameStage::Starting(StartupSequence::CharactersVisible { .. })) => {
// Unhide the player & ghosts
for entity in player_query
.iter_mut()
.map(|(e, _)| e)
.chain(ghost_query.iter_mut().map(|(e, _, _)| e))
{
commands.entity(entity).remove::<Hidden>();
commands.entity(player.0).insert(Visibility::visible());
for (entity, _, _, _) in ghost_query.iter_mut() {
commands.entity(entity).insert(Visibility::visible());
}
}
(GameStage::Starting(StartupSequence::CharactersVisible { .. }), GameStage::Playing) => {
// Unfreeze the player & ghosts & blinking
for entity in player_query
.iter_mut()
.map(|(e, _)| e)
.chain(ghost_query.iter_mut().map(|(e, _, _)| e))
.chain(blinking_query.iter_mut())
{
commands.entity(player.0).remove::<Frozen>();
for (entity, _, _, _) in ghost_query.iter_mut() {
commands.entity(entity).remove::<Frozen>();
}
for entity in blinking_query.iter_mut() {
commands.entity(entity).remove::<Frozen>();
}
}
(GameStage::PlayerDying(..), GameStage::GameOver) => {
(_, GameStage::GameOver) => {
// Freeze blinking
for entity in blinking_query.iter_mut() {
commands.entity(entity).insert(Frozen);
}
}
_ => {
let different = discriminant(&old_state) != discriminant(&new_state);
if different {
tracing::warn!(
new_state = ?new_state,
old_state = ?old_state,
"Unhandled game stage transition");
}
}
_ => {}
}
*game_state = new_state;

View File

@@ -14,11 +14,6 @@ impl TileSequence {
Self { tiles: tiles.to_vec() }
}
/// Creates a tile sequence with a single tile.
pub fn single(tile: AtlasTile) -> Self {
Self { tiles: vec![tile] }
}
/// Returns the tile at the given frame index, wrapping if necessary
pub fn get_tile(&self, frame: usize) -> AtlasTile {
if self.tiles.is_empty() {

View File

@@ -58,19 +58,6 @@ impl AtlasTile {
canvas.copy(&atlas.texture, src, dest).map_err(TextureError::RenderFailed)?;
Ok(())
}
/// Creates a new atlas tile.
#[allow(dead_code)]
pub fn new(pos: U16Vec2, size: U16Vec2, color: Option<Color>) -> Self {
Self { pos, size, color }
}
/// Sets the color of the tile.
#[allow(dead_code)]
pub fn with_color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
}
/// High-performance sprite atlas providing fast texture region lookups and rendering.
@@ -120,32 +107,4 @@ impl SpriteAtlas {
color: self.default_color,
})
}
#[allow(dead_code)]
pub fn set_color(&mut self, color: Color) {
self.default_color = Some(color);
}
#[allow(dead_code)]
pub fn texture(&self) -> &Texture {
&self.texture
}
/// Returns the number of tiles in the atlas.
#[allow(dead_code)]
pub fn tiles_count(&self) -> usize {
self.tiles.len()
}
/// Returns true if the atlas has a tile with the given name.
#[allow(dead_code)]
pub fn has_tile(&self, name: &str) -> bool {
self.tiles.contains_key(name)
}
/// Returns the default color of the atlas.
#[allow(dead_code)]
pub fn default_color(&self) -> Option<Color> {
self.default_color
}
}

View File

@@ -5,8 +5,10 @@
//! The `GameSprite` enum is the main entry point, and its `to_path` method
//! generates the correct path for a given sprite in the texture atlas.
use crate::map::direction::Direction;
use crate::systems::components::Ghost;
use crate::{
map::direction::Direction,
systems::{FruitType, Ghost},
};
/// Represents the different sprites for Pac-Man.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -48,12 +50,20 @@ pub enum MazeSprite {
Energizer,
}
/// Represents the different effect sprites that can appear as bonus items.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EffectSprite {
Bonus(u32),
}
/// A top-level enum that encompasses all game sprites.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GameSprite {
Pacman(PacmanSprite),
Ghost(GhostSprite),
Maze(MazeSprite),
Fruit(FruitType),
Effect(EffectSprite),
}
impl GameSprite {
@@ -106,6 +116,18 @@ impl GameSprite {
GameSprite::Maze(MazeSprite::Tile(index)) => format!("maze/tiles/{}.png", index),
GameSprite::Maze(MazeSprite::Pellet) => "maze/pellet.png".to_string(),
GameSprite::Maze(MazeSprite::Energizer) => "maze/energizer.png".to_string(),
// Fruit sprites
GameSprite::Fruit(fruit) => format!("edible/{}.png", Into::<&'static str>::into(fruit)),
// Effect sprites
GameSprite::Effect(EffectSprite::Bonus(value)) => match value {
100 | 200 | 300 | 400 | 700 | 800 | 1000 | 2000 | 3000 | 5000 => format!("effects/{}.png", value),
_ => {
tracing::warn!("Invalid bonus value: {}", value);
"effects/100.png".to_string()
}
},
}
}
}

View File

@@ -1,5 +1,3 @@
#![allow(dead_code)]
//! This module provides text rendering using the texture atlas.
//!
//! The TextTexture system renders text from the atlas using character mapping.
@@ -109,6 +107,7 @@ impl TextTexture {
}
}
#[allow(dead_code)]
pub fn get_char_map(&self) -> &HashMap<char, AtlasTile> {
&self.char_map
}
@@ -167,26 +166,6 @@ impl TextTexture {
Ok(())
}
/// Sets the default color for text rendering.
pub fn set_color(&mut self, color: Color) {
self.default_color = Some(color);
}
/// Gets the current default color.
pub fn color(&self) -> Option<Color> {
self.default_color
}
/// Sets the scale for text rendering.
pub fn set_scale(&mut self, scale: f32) {
self.scale = scale;
}
/// Gets the current scale.
pub fn scale(&self) -> f32 {
self.scale
}
/// Calculates the width of a string in pixels at the current scale.
pub fn text_width(&self, text: &str) -> u32 {
let char_width = (8.0 * self.scale) as u32;

View File

@@ -31,6 +31,8 @@ pub struct TtfAtlas {
char_tiles: HashMap<char, TtfCharTile>,
/// Cached color modulation state to avoid redundant SDL2 calls
last_modulation: Option<Color>,
/// Cached maximum character height
max_char_height: u32,
}
const TTF_CHARS: &str = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.,:-/()ms μµ%± ";
@@ -101,6 +103,7 @@ impl TtfAtlas {
texture: atlas_texture,
char_tiles,
last_modulation: None,
max_char_height: max_height,
})
}
@@ -261,12 +264,6 @@ impl TtfRenderer {
/// Calculate the height of text in pixels
pub fn text_height(&self, atlas: &TtfAtlas) -> u32 {
// Find the maximum height among all characters
atlas
.char_tiles
.values()
.map(|tile| tile.size.y)
.max()
.unwrap_or(0)
.saturating_mul(self.scale as u32)
(atlas.max_char_height as f32 * self.scale) as u32
}
}

View File

@@ -1,9 +1,5 @@
use bevy_ecs::{entity::Entity, system::RunSystemOnce, world::World};
use pacman::systems::{
blinking::{blinking_system, Blinking},
components::{DeltaTime, Renderable},
Frozen, Hidden,
};
use pacman::systems::{blinking_system, Blinking, DeltaTime, Frozen, Renderable, Visibility};
use speculoos::prelude::*;
mod common;
@@ -24,11 +20,12 @@ fn spawn_blinking_entity(world: &mut World, interval_ticks: u32) -> Entity {
sprite: common::mock_atlas_tile(1),
layer: 0,
},
Visibility::visible(),
))
.id()
}
/// Spawns a test entity with blinking, renderable, and hidden components
/// Spawns a test entity with blinking, renderable, and hidden visibility
fn spawn_hidden_blinking_entity(world: &mut World, interval_ticks: u32) -> Entity {
world
.spawn((
@@ -37,7 +34,7 @@ fn spawn_hidden_blinking_entity(world: &mut World, interval_ticks: u32) -> Entit
sprite: common::mock_atlas_tile(1),
layer: 0,
},
Hidden,
Visibility::hidden(),
))
.id()
}
@@ -51,12 +48,13 @@ fn spawn_frozen_blinking_entity(world: &mut World, interval_ticks: u32) -> Entit
sprite: common::mock_atlas_tile(1),
layer: 0,
},
Visibility::visible(),
Frozen,
))
.id()
}
/// Spawns a test entity with blinking, renderable, hidden, and frozen components
/// Spawns a test entity with blinking, renderable, hidden visibility, and frozen components
fn spawn_frozen_hidden_blinking_entity(world: &mut World, interval_ticks: u32) -> Entity {
world
.spawn((
@@ -65,7 +63,7 @@ fn spawn_frozen_hidden_blinking_entity(world: &mut World, interval_ticks: u32) -
sprite: common::mock_atlas_tile(1),
layer: 0,
},
Hidden,
Visibility::hidden(),
Frozen,
))
.id()
@@ -77,9 +75,22 @@ fn run_blinking_system(world: &mut World, delta_ticks: u32) {
world.run_system_once(blinking_system).unwrap();
}
/// Checks if an entity has the Hidden component
fn has_hidden_component(world: &World, entity: Entity) -> bool {
world.entity(entity).contains::<Hidden>()
/// Checks if an entity is visible
fn is_entity_visible(world: &World, entity: Entity) -> bool {
world
.entity(entity)
.get::<Visibility>()
.map(|v| v.is_visible())
.unwrap_or(true) // Default to visible if no Visibility component
}
/// Checks if an entity is hidden
fn is_entity_hidden(world: &World, entity: Entity) -> bool {
world
.entity(entity)
.get::<Visibility>()
.map(|v| v.is_hidden())
.unwrap_or(false) // Default to visible if no Visibility component
}
/// Checks if an entity has the Frozen component
@@ -104,7 +115,7 @@ fn test_blinking_system_normal_interval_no_toggle() {
run_blinking_system(&mut world, 3);
// Entity should not be hidden yet
assert_that(&has_hidden_component(&world, entity)).is_false();
assert_that(&is_entity_visible(&world, entity)).is_true();
// Check that timer was updated
let blinking = world.entity(entity).get::<Blinking>().unwrap();
@@ -120,7 +131,7 @@ fn test_blinking_system_normal_interval_first_toggle() {
run_blinking_system(&mut world, 5);
// Entity should now be hidden
assert_that(&has_hidden_component(&world, entity)).is_true();
assert_that(&is_entity_hidden(&world, entity)).is_true();
// Check that timer was reset
let blinking = world.entity(entity).get::<Blinking>().unwrap();
@@ -134,11 +145,11 @@ fn test_blinking_system_normal_interval_second_toggle() {
// First toggle: 5 ticks
run_blinking_system(&mut world, 5);
assert_that(&has_hidden_component(&world, entity)).is_true();
assert_that(&is_entity_hidden(&world, entity)).is_true();
// Second toggle: another 5 ticks
run_blinking_system(&mut world, 5);
assert_that(&has_hidden_component(&world, entity)).is_false();
assert_that(&is_entity_visible(&world, entity)).is_true();
}
#[test]
@@ -150,7 +161,7 @@ fn test_blinking_system_normal_interval_multiple_intervals() {
run_blinking_system(&mut world, 7);
// Should toggle twice (even number), so back to original state (not hidden)
assert_that(&has_hidden_component(&world, entity)).is_false();
assert_that(&is_entity_visible(&world, entity)).is_true();
// Check that timer was updated to remainder
let blinking = world.entity(entity).get::<Blinking>().unwrap();
@@ -166,7 +177,7 @@ fn test_blinking_system_normal_interval_odd_intervals() {
run_blinking_system(&mut world, 5);
// Should toggle twice (even number), so back to original state (not hidden)
assert_that(&has_hidden_component(&world, entity)).is_false();
assert_that(&is_entity_visible(&world, entity)).is_true();
// Check that timer was updated to remainder
let blinking = world.entity(entity).get::<Blinking>().unwrap();
@@ -182,7 +193,7 @@ fn test_blinking_system_zero_interval_with_ticks() {
run_blinking_system(&mut world, 1);
// Entity should be hidden immediately
assert_that(&has_hidden_component(&world, entity)).is_true();
assert_that(&is_entity_hidden(&world, entity)).is_true();
}
#[test]
@@ -194,7 +205,7 @@ fn test_blinking_system_zero_interval_no_ticks() {
run_blinking_system(&mut world, 0);
// Entity should not be hidden (no time passed)
assert_that(&has_hidden_component(&world, entity)).is_false();
assert_that(&is_entity_visible(&world, entity)).is_true();
}
#[test]
@@ -206,7 +217,7 @@ fn test_blinking_system_zero_interval_toggle_back() {
run_blinking_system(&mut world, 1);
// Entity should be unhidden
assert_that(&has_hidden_component(&world, entity)).is_false();
assert_that(&is_entity_visible(&world, entity)).is_true();
}
#[test]
@@ -218,7 +229,7 @@ fn test_blinking_system_frozen_entity_unhidden() {
run_blinking_system(&mut world, 10);
// Frozen entity should be unhidden and stay unhidden
assert_that(&has_hidden_component(&world, entity)).is_false();
assert_that(&is_entity_visible(&world, entity)).is_true();
assert_that(&has_frozen_component(&world, entity)).is_true();
}
@@ -231,7 +242,7 @@ fn test_blinking_system_frozen_entity_no_blinking() {
run_blinking_system(&mut world, 10);
// Frozen entity should not be hidden (blinking disabled)
assert_that(&has_hidden_component(&world, entity)).is_false();
assert_that(&is_entity_visible(&world, entity)).is_true();
assert_that(&has_frozen_component(&world, entity)).is_true();
}
@@ -259,7 +270,7 @@ fn test_blinking_system_entity_without_renderable_ignored() {
run_blinking_system(&mut world, 10);
// Entity should not be affected (not in query)
assert_that(&has_hidden_component(&world, entity)).is_false();
assert_that(&is_entity_visible(&world, entity)).is_true();
}
#[test]
@@ -278,7 +289,7 @@ fn test_blinking_system_entity_without_blinking_ignored() {
run_blinking_system(&mut world, 10);
// Entity should not be affected (not in query)
assert_that(&has_hidden_component(&world, entity)).is_false();
assert_that(&is_entity_visible(&world, entity)).is_true();
}
#[test]
@@ -290,7 +301,7 @@ fn test_blinking_system_large_interval() {
run_blinking_system(&mut world, 500);
// Entity should not be hidden yet
assert_that(&has_hidden_component(&world, entity)).is_false();
assert_that(&is_entity_visible(&world, entity)).is_true();
// Check that timer was updated
let blinking = world.entity(entity).get::<Blinking>().unwrap();
@@ -306,11 +317,11 @@ fn test_blinking_system_very_small_interval() {
run_blinking_system(&mut world, 1);
// Entity should be hidden
assert_that(&has_hidden_component(&world, entity)).is_true();
assert_that(&is_entity_hidden(&world, entity)).is_true();
// Run system with another 1 tick
run_blinking_system(&mut world, 1);
// Entity should be unhidden
assert_that(&has_hidden_component(&world, entity)).is_false();
assert_that(&is_entity_visible(&world, entity)).is_true();
}

View File

@@ -36,19 +36,17 @@ fn test_check_collision_helper() {
#[test]
fn test_collision_system_pacman_item() {
let mut world = common::create_test_world();
let (mut world, mut schedule) = common::create_test_world();
let _pacman = common::spawn_test_pacman(&mut world, 0);
let _item = common::spawn_test_item(&mut world, 0, EntityType::Pellet);
// Run collision system - should not panic
world
.run_system_once(collision_system)
.expect("System should run successfully");
schedule.run(&mut world);
}
#[test]
fn test_collision_system_pacman_ghost() {
let mut world = common::create_test_world();
let (mut world, _) = common::create_test_world();
let _pacman = common::spawn_test_pacman(&mut world, 0);
let _ghost = common::spawn_test_ghost(&mut world, 0, GhostState::Normal);
@@ -60,19 +58,17 @@ fn test_collision_system_pacman_ghost() {
#[test]
fn test_collision_system_no_collision() {
let mut world = common::create_test_world();
let (mut world, mut schedule) = common::create_test_world();
let _pacman = common::spawn_test_pacman(&mut world, 0);
let _ghost = common::spawn_test_ghost(&mut world, 1, GhostState::Normal); // Different node
// Run collision system - should not panic
world
.run_system_once(collision_system)
.expect("System should run successfully");
schedule.run(&mut world);
}
#[test]
fn test_collision_system_multiple_entities() {
let mut world = common::create_test_world();
let (mut world, _) = common::create_test_world();
let _pacman = common::spawn_test_pacman(&mut world, 0);
let _item = common::spawn_test_item(&mut world, 0, EntityType::Pellet);
let _ghost = common::spawn_test_ghost(&mut world, 0, GhostState::Normal);

View File

@@ -1,11 +1,11 @@
#![allow(dead_code)]
use bevy_ecs::{entity::Entity, event::Events, world::World};
use bevy_ecs::{entity::Entity, event::Events, schedule::Schedule, world::World};
use glam::{U16Vec2, Vec2};
use pacman::{
asset::{get_asset_bytes, Asset},
constants::RAW_BOARD,
events::GameEvent,
events::{CollisionTrigger, GameEvent},
game::ATLAS_FRAMES,
map::{
builder::Map,
@@ -13,8 +13,9 @@ use pacman::{
graph::{Graph, Node},
},
systems::{
AudioEvent, AudioState, BufferedDirection, Collider, DebugState, DeltaTime, EntityType, Ghost, GhostCollider, GhostState,
GlobalState, ItemCollider, MovementModifiers, PacmanCollider, 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},
};
@@ -74,7 +75,7 @@ pub fn create_test_graph() -> Graph {
}
/// Creates a basic test world with required resources for ECS systems
pub fn create_test_world() -> World {
pub fn create_test_world() -> (World, Schedule) {
let mut world = World::new();
// Add required resources
@@ -82,16 +83,22 @@ pub fn create_test_world() -> World {
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());
world.insert_resource(PelletCount(0));
world.insert_resource(DeltaTime {
seconds: 1.0 / 60.0,
ticks: 1,
}); // 60 FPS
world.insert_resource(create_test_map());
world
let schedule = Schedule::default();
world.add_observer(item_collision_observer);
(world, schedule)
}
/// Creates a test map using the default RAW_BOARD
@@ -161,9 +168,8 @@ pub fn send_game_event(world: &mut World, event: GameEvent) {
}
/// Sends a collision event between two entities
pub fn send_collision_event(world: &mut World, entity1: Entity, entity2: Entity) {
let mut events = world.resource_mut::<Events<GameEvent>>();
events.send(GameEvent::Collision(entity1, entity2));
pub fn trigger_collision(world: &mut World, event: CollisionTrigger) {
world.trigger(event);
}
/// Creates a mock atlas tile for testing

View File

@@ -1,66 +0,0 @@
use pacman::error::{GameError, GameResult, IntoGameError, OptionExt, ResultExt};
use speculoos::prelude::*;
use std::io;
#[test]
fn test_into_game_error_trait() {
let result: Result<i32, io::Error> = Err(io::Error::new(io::ErrorKind::Other, "test error"));
let game_result: GameResult<i32> = result.into_game_error();
assert_that(&game_result.is_err()).is_true();
if let Err(GameError::InvalidState(msg)) = game_result {
assert_that(&msg.contains("test error")).is_true();
} else {
panic!("Expected InvalidState error");
}
}
#[test]
fn test_into_game_error_trait_success() {
let result: Result<i32, io::Error> = Ok(42);
let game_result: GameResult<i32> = result.into_game_error();
assert_that(&game_result.unwrap()).is_equal_to(42);
}
#[test]
fn test_option_ext_some() {
let option: Option<i32> = Some(42);
let result: GameResult<i32> = option.ok_or_game_error(|| GameError::InvalidState("Not found".to_string()));
assert_that(&result.unwrap()).is_equal_to(42);
}
#[test]
fn test_option_ext_none() {
let option: Option<i32> = None;
let result: GameResult<i32> = option.ok_or_game_error(|| GameError::InvalidState("Not found".to_string()));
assert_that(&result.is_err()).is_true();
if let Err(GameError::InvalidState(msg)) = result {
assert_that(&msg).is_equal_to("Not found".to_string());
} else {
panic!("Expected InvalidState error");
}
}
#[test]
fn test_result_ext_success() {
let result: Result<i32, io::Error> = Ok(42);
let game_result: GameResult<i32> = result.with_context(|_| GameError::InvalidState("Context".to_string()));
assert_that(&game_result.unwrap()).is_equal_to(42);
}
#[test]
fn test_result_ext_error() {
let result: Result<i32, io::Error> = Err(io::Error::new(io::ErrorKind::Other, "original error"));
let game_result: GameResult<i32> = result.with_context(|_| GameError::InvalidState("Context error".to_string()));
assert_that(&game_result.is_err()).is_true();
if let Err(GameError::InvalidState(msg)) = game_result {
assert_that(&msg).is_equal_to("Context error".to_string());
} else {
panic!("Expected InvalidState error");
}
}

View File

@@ -1,5 +1,8 @@
use bevy_ecs::{entity::Entity, system::RunSystemOnce};
use pacman::systems::{is_valid_item_collision, item_system, EntityType, GhostState, Position, ScoreResource};
use bevy_ecs::entity::Entity;
use pacman::{
events::CollisionTrigger,
systems::{EntityType, GhostState, Position, ScoreResource},
};
use speculoos::prelude::*;
mod common;
@@ -24,35 +27,18 @@ fn test_is_collectible_item() {
assert_that(&EntityType::Ghost.is_collectible()).is_false();
}
#[test]
fn test_is_valid_item_collision() {
// Player-item collisions should be valid
assert_that(&is_valid_item_collision(EntityType::Player, EntityType::Pellet)).is_true();
assert_that(&is_valid_item_collision(EntityType::Player, EntityType::PowerPellet)).is_true();
assert_that(&is_valid_item_collision(EntityType::Pellet, EntityType::Player)).is_true();
assert_that(&is_valid_item_collision(EntityType::PowerPellet, EntityType::Player)).is_true();
// Non-player-item collisions should be invalid
assert_that(&is_valid_item_collision(EntityType::Player, EntityType::Ghost)).is_false();
assert_that(&is_valid_item_collision(EntityType::Ghost, EntityType::Pellet)).is_false();
assert_that(&is_valid_item_collision(EntityType::Pellet, EntityType::PowerPellet)).is_false();
assert_that(&is_valid_item_collision(EntityType::Player, EntityType::Player)).is_false();
}
#[test]
fn test_item_system_pellet_collection() {
let mut world = common::create_test_world();
let pacman = common::spawn_test_pacman(&mut world, 0);
let (mut world, mut _schedule) = common::create_test_world();
let pellet = common::spawn_test_item(&mut world, 1, EntityType::Pellet);
// Send collision event
common::send_collision_event(&mut world, pacman, pellet);
common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: pellet });
// Run the item system
world.run_system_once(item_system).expect("System should run successfully");
world.flush();
// Check that score was updated
let score = world.resource::<ScoreResource>();
let score = world.resource_mut::<ScoreResource>();
assert_that(&score.0).is_equal_to(10);
// Check that the pellet was despawned (query should return empty)
@@ -66,13 +52,12 @@ fn test_item_system_pellet_collection() {
#[test]
fn test_item_system_power_pellet_collection() {
let mut world = common::create_test_world();
let pacman = common::spawn_test_pacman(&mut world, 0);
let (mut world, mut _schedule) = common::create_test_world();
let power_pellet = common::spawn_test_item(&mut world, 1, EntityType::PowerPellet);
common::send_collision_event(&mut world, pacman, power_pellet);
common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: power_pellet });
world.run_system_once(item_system).expect("System should run successfully");
world.flush();
// Check that score was updated with power pellet value
let score = world.resource::<ScoreResource>();
@@ -89,18 +74,17 @@ fn test_item_system_power_pellet_collection() {
#[test]
fn test_item_system_multiple_collections() {
let mut world = common::create_test_world();
let pacman = common::spawn_test_pacman(&mut world, 0);
let (mut world, mut _schedule) = common::create_test_world();
let pellet1 = common::spawn_test_item(&mut world, 1, EntityType::Pellet);
let pellet2 = common::spawn_test_item(&mut world, 2, EntityType::Pellet);
let power_pellet = common::spawn_test_item(&mut world, 3, EntityType::PowerPellet);
// Send multiple collision events
common::send_collision_event(&mut world, pacman, pellet1);
common::send_collision_event(&mut world, pacman, pellet2);
common::send_collision_event(&mut world, pacman, power_pellet);
common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: pellet1 });
common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: pellet2 });
common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: power_pellet });
world.run_system_once(item_system).expect("System should run successfully");
world.flush();
// Check final score: 2 pellets (20) + 1 power pellet (50) = 70
let score = world.resource::<ScoreResource>();
@@ -123,8 +107,7 @@ fn test_item_system_multiple_collections() {
#[test]
fn test_item_system_ignores_non_item_collisions() {
let mut world = common::create_test_world();
let pacman = common::spawn_test_pacman(&mut world, 0);
let (mut world, mut _schedule) = common::create_test_world();
// Create a ghost entity (not an item)
let ghost = world.spawn((Position::Stopped { node: 2 }, EntityType::Ghost)).id();
@@ -133,9 +116,9 @@ fn test_item_system_ignores_non_item_collisions() {
let initial_score = world.resource::<ScoreResource>().0;
// Send collision event between pacman and ghost
common::send_collision_event(&mut world, pacman, ghost);
common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: ghost });
world.run_system_once(item_system).expect("System should run successfully");
world.flush();
// Score should remain unchanged
let score = world.resource::<ScoreResource>();
@@ -152,14 +135,14 @@ fn test_item_system_ignores_non_item_collisions() {
#[test]
fn test_item_system_no_collision_events() {
let mut world = common::create_test_world();
let (mut world, mut _schedule) = common::create_test_world();
let _pacman = common::spawn_test_pacman(&mut world, 0);
let _pellet = common::spawn_test_item(&mut world, 1, EntityType::Pellet);
let initial_score = world.resource::<ScoreResource>().0;
// Run system without any collision events
world.run_system_once(item_system).expect("System should run successfully");
world.flush();
// Nothing should change
let score = world.resource::<ScoreResource>();
@@ -174,19 +157,15 @@ fn test_item_system_no_collision_events() {
#[test]
fn test_item_system_collision_with_missing_entity() {
let mut world = common::create_test_world();
let pacman = common::spawn_test_pacman(&mut world, 0);
let (mut world, mut _schedule) = common::create_test_world();
// Create a fake entity ID that doesn't exist
let fake_entity = Entity::from_raw(999);
common::send_collision_event(&mut world, pacman, fake_entity);
common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: fake_entity });
// System should handle gracefully and not crash
world
.run_system_once(item_system)
.expect("System should handle missing entities gracefully");
world.flush();
// Score should remain unchanged
let score = world.resource::<ScoreResource>();
assert_that(&score.0).is_equal_to(0);
@@ -194,17 +173,16 @@ fn test_item_system_collision_with_missing_entity() {
#[test]
fn test_item_system_preserves_existing_score() {
let mut world = common::create_test_world();
let (mut world, mut _schedule) = common::create_test_world();
// Set initial score
world.insert_resource(ScoreResource(100));
let pacman = common::spawn_test_pacman(&mut world, 0);
let pellet = common::spawn_test_item(&mut world, 1, EntityType::Pellet);
common::send_collision_event(&mut world, pacman, pellet);
common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: pellet });
world.run_system_once(item_system).expect("System should run successfully");
world.flush();
// Score should be initial + pellet value
let score = world.resource::<ScoreResource>();
@@ -213,8 +191,7 @@ fn test_item_system_preserves_existing_score() {
#[test]
fn test_power_pellet_does_not_affect_ghosts_in_eyes_state() {
let mut world = common::create_test_world();
let pacman = common::spawn_test_pacman(&mut world, 0);
let (mut world, mut _schedule) = common::create_test_world();
let power_pellet = common::spawn_test_item(&mut world, 1, EntityType::PowerPellet);
// Spawn a ghost in Eyes state (returning to ghost house)
@@ -223,9 +200,9 @@ fn test_power_pellet_does_not_affect_ghosts_in_eyes_state() {
// Spawn a ghost in Normal state
let normal_ghost = common::spawn_test_ghost(&mut world, 3, GhostState::Normal);
common::send_collision_event(&mut world, pacman, power_pellet);
common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: power_pellet });
world.run_system_once(item_system).expect("System should run successfully");
world.flush();
// Check that the power pellet was collected and score updated
let score = world.resource::<ScoreResource>();

View File

@@ -112,7 +112,7 @@ fn test_entity_type_traversal_flags() {
#[test]
fn test_player_control_system_move_command() {
let mut world = common::create_test_world();
let (mut world, _) = common::create_test_world();
let _player = common::spawn_test_player(&mut world, 0);
// Send move command
@@ -141,7 +141,7 @@ fn test_player_control_system_move_command() {
#[test]
fn test_player_control_system_exit_command() {
let mut world = common::create_test_world();
let (mut world, _) = common::create_test_world();
let _player = common::spawn_test_player(&mut world, 0);
// Send exit command
@@ -159,7 +159,7 @@ fn test_player_control_system_exit_command() {
#[test]
fn test_player_control_system_toggle_debug() {
let mut world = common::create_test_world();
let (mut world, _) = common::create_test_world();
let _player = common::spawn_test_player(&mut world, 0);
// Send toggle debug command
@@ -177,7 +177,7 @@ fn test_player_control_system_toggle_debug() {
#[test]
fn test_player_control_system_mute_audio() {
let mut world = common::create_test_world();
let (mut world, _) = common::create_test_world();
let _player = common::spawn_test_player(&mut world, 0);
// Send mute audio command
@@ -206,7 +206,7 @@ fn test_player_control_system_mute_audio() {
#[test]
fn test_player_control_system_no_player_entity() {
let mut world = common::create_test_world();
let (mut world, _) = common::create_test_world();
// Don't spawn a player entity
common::send_game_event(&mut world, GameEvent::Command(GameCommand::MovePlayer(Direction::Up)));
@@ -214,16 +214,14 @@ fn test_player_control_system_no_player_entity() {
// Run the system - should write an error
world
.run_system_once(player_control_system)
.expect("System should run successfully");
.expect("System should run successfully even with no player entity");
// Check that an error was written (we can't easily check Events without manual management,
// so for this test we just verify the system ran without panicking)
// In a real implementation, you might expose error checking through the ECS world
// The system should run successfully and simply ignore movement commands when there's no player
}
#[test]
fn test_player_movement_system_buffered_direction_expires() {
let mut world = common::create_test_world();
let (mut world, _) = common::create_test_world();
let player = common::spawn_test_player(&mut world, 0);
// Set a buffered direction with short time
@@ -253,7 +251,7 @@ fn test_player_movement_system_buffered_direction_expires() {
#[test]
fn test_player_movement_system_start_moving_from_stopped() {
let mut world = common::create_test_world();
let (mut world, _) = common::create_test_world();
let _player = common::spawn_test_player(&mut world, 0);
// Player starts at node 0, facing right (towards node 1)
@@ -278,7 +276,7 @@ fn test_player_movement_system_start_moving_from_stopped() {
#[test]
fn test_player_movement_system_buffered_direction_change() {
let mut world = common::create_test_world();
let (mut world, _) = common::create_test_world();
let player = common::spawn_test_player(&mut world, 0);
// Set a buffered direction to go down (towards node 2)
@@ -309,7 +307,7 @@ fn test_player_movement_system_buffered_direction_change() {
#[test]
fn test_player_movement_system_no_valid_edge() {
let mut world = common::create_test_world();
let (mut world, _) = common::create_test_world();
let player = common::spawn_test_player(&mut world, 0);
// Set velocity to direction with no edge
@@ -334,7 +332,7 @@ fn test_player_movement_system_no_valid_edge() {
#[test]
fn test_player_movement_system_continue_moving() {
let mut world = common::create_test_world();
let (mut world, _) = common::create_test_world();
let player = common::spawn_test_player(&mut world, 0);
// Set player to already be moving
@@ -364,7 +362,7 @@ fn test_player_movement_system_continue_moving() {
#[test]
fn test_full_player_input_to_movement_flow() {
let mut world = common::create_test_world();
let (mut world, _) = common::create_test_world();
let _player = common::spawn_test_player(&mut world, 0);
// Send move command
@@ -398,7 +396,7 @@ fn test_full_player_input_to_movement_flow() {
#[test]
fn test_buffered_direction_timing() {
let mut world = common::create_test_world();
let (mut world, _) = common::create_test_world();
let _player = common::spawn_test_player(&mut world, 0);
// Send move command
@@ -437,7 +435,7 @@ fn test_buffered_direction_timing() {
#[test]
fn test_multiple_rapid_direction_changes() {
let mut world = common::create_test_world();
let (mut world, _) = common::create_test_world();
let _player = common::spawn_test_player(&mut world, 0);
// Send multiple rapid direction changes
@@ -470,7 +468,7 @@ fn test_multiple_rapid_direction_changes() {
#[test]
fn test_player_state_persistence_across_systems() {
let mut world = common::create_test_world();
let (mut world, _) = common::create_test_world();
let _player = common::spawn_test_player(&mut world, 0);
// Test that multiple commands can be processed - but need to handle events properly

View File

@@ -1,70 +0,0 @@
use glam::U16Vec2;
use pacman::texture::sprite::{AtlasMapper, AtlasTile, MapperFrame};
use sdl2::pixels::Color;
use speculoos::prelude::*;
use std::collections::HashMap;
mod common;
#[test]
fn test_atlas_mapper_frame_lookup() {
let mut frames = HashMap::new();
frames.insert(
"test".to_string(),
MapperFrame {
pos: U16Vec2::new(10, 20),
size: U16Vec2::new(32, 64),
},
);
let mapper = AtlasMapper { frames };
// Test direct frame lookup
let frame = mapper.frames.get("test");
assert_that(&frame.is_some()).is_true();
let frame = frame.unwrap();
assert_that(&frame.pos).is_equal_to(U16Vec2::new(10, 20));
assert_that(&frame.size).is_equal_to(U16Vec2::new(32, 64));
}
#[test]
fn test_atlas_mapper_multiple_frames() {
let mut frames = HashMap::new();
frames.insert(
"tile1".to_string(),
MapperFrame {
pos: U16Vec2::new(0, 0),
size: U16Vec2::new(32, 32),
},
);
frames.insert(
"tile2".to_string(),
MapperFrame {
pos: U16Vec2::new(32, 0),
size: U16Vec2::new(64, 64),
},
);
let mapper = AtlasMapper { frames };
assert_that(&mapper.frames.len()).is_equal_to(2);
assert_that(&mapper.frames.contains_key("tile1")).is_true();
assert_that(&mapper.frames.contains_key("tile2")).is_true();
assert_that(&mapper.frames.contains_key("tile3")).is_false();
assert_that(&mapper.frames.contains_key("nonexistent")).is_false();
}
#[test]
fn test_atlas_tile_new_and_with_color() {
let pos = U16Vec2::new(10, 20);
let size = U16Vec2::new(30, 40);
let color = Color::RGB(100, 150, 200);
let tile = AtlasTile::new(pos, size, None);
assert_that(&tile.pos).is_equal_to(pos);
assert_that(&tile.size).is_equal_to(size);
assert_that(&tile.color).is_equal_to(None);
let tile_with_color = tile.with_color(color);
assert_that(&tile_with_color.color).is_equal_to(Some(color));
}

View File

@@ -2,7 +2,7 @@
use pacman::{
game::ATLAS_FRAMES,
map::direction::Direction,
systems::components::Ghost,
systems::Ghost,
texture::sprites::{FrightenedColor, GameSprite, GhostSprite, MazeSprite, PacmanSprite},
};

View File

@@ -81,44 +81,20 @@ fn test_text_scale() -> Result<(), String> {
let string = "ABCDEFG !-/\"";
let base_width = (string.len() * 8) as u32;
let mut text_texture = TextTexture::new(0.5);
assert_that(&text_texture.scale()).is_equal_to(0.5);
let text_texture = TextTexture::new(0.5);
assert_that(&text_texture.text_height()).is_equal_to(4);
assert_that(&text_texture.text_width("")).is_equal_to(0);
assert_that(&text_texture.text_width(string)).is_equal_to(base_width / 2);
text_texture.set_scale(2.0);
assert_that(&text_texture.scale()).is_equal_to(2.0);
let text_texture = TextTexture::new(2.0);
assert_that(&text_texture.text_height()).is_equal_to(16);
assert_that(&text_texture.text_width(string)).is_equal_to(base_width * 2);
assert_that(&text_texture.text_width("")).is_equal_to(0);
text_texture.set_scale(1.0);
assert_that(&text_texture.scale()).is_equal_to(1.0);
let text_texture = TextTexture::new(1.0);
assert_that(&text_texture.text_height()).is_equal_to(8);
assert_that(&text_texture.text_width(string)).is_equal_to(base_width);
assert_that(&text_texture.text_width("")).is_equal_to(0);
Ok(())
}
#[test]
fn test_text_color() -> Result<(), String> {
let mut text_texture = TextTexture::new(1.0);
// Test default color (should be None initially)
assert_that(&text_texture.color()).is_equal_to(None);
// Test setting color
let test_color = sdl2::pixels::Color::YELLOW;
text_texture.set_color(test_color);
assert_that(&text_texture.color()).is_equal_to(Some(test_color));
// Test changing color
let new_color = sdl2::pixels::Color::RED;
text_texture.set_color(new_color);
assert_that(&text_texture.color()).is_equal_to(Some(new_color));
Ok(())
}