mirror of
https://github.com/Xevion/Pac-Man.git
synced 2025-12-06 13:15:47 -06:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36e9de1a1f | ||
|
|
9ad1704806 | ||
|
|
86331afd52 | ||
|
|
cca205fe95 | ||
|
|
00a65954e6 | ||
|
|
43532dac56 | ||
|
|
08c964c32e | ||
|
|
8b2d18b3da | ||
|
|
46a73c5ace | ||
|
|
a2783ae62d | ||
|
|
83e0d1d737 | ||
|
|
d86864b6a3 | ||
|
|
d7a6ee7684 | ||
|
|
d84f0c831e | ||
|
|
ae19ca1795 | ||
|
|
abf341d753 | ||
|
|
7b6dad0c74 | ||
|
|
5563b64044 | ||
|
|
cb691b0907 | ||
|
|
ce8ea347e1 | ||
|
|
afae3c5e7b | ||
|
|
4f7902fc50 | ||
|
|
2a2cca675a | ||
|
|
f3a6b72931 | ||
|
|
ca006b5073 | ||
|
|
139afb2d40 | ||
|
|
5d56b31353 | ||
|
|
b4990af109 | ||
|
|
088c496ad9 | ||
|
|
5bdf11dfb6 | ||
|
|
c163171304 |
@@ -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.
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -20,7 +20,3 @@ coverage.html
|
||||
# Profiling output
|
||||
flamegraph.svg
|
||||
/profile.*
|
||||
|
||||
# temporary
|
||||
assets/game/sound/*.wav
|
||||
/*.py
|
||||
|
||||
@@ -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
|
||||
|
||||
136
Cargo.lock
generated
136
Cargo.lock
generated
@@ -228,6 +228,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.19.0"
|
||||
@@ -268,6 +277,15 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "critical-section"
|
||||
version = "1.2.0"
|
||||
@@ -289,6 +307,16 @@ version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deprecate-until"
|
||||
version = "0.1.1"
|
||||
@@ -337,6 +365,16 @@ version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "disqualified"
|
||||
version = "1.0.0"
|
||||
@@ -408,6 +446,16 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.3"
|
||||
@@ -663,7 +711,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "pacman"
|
||||
version = "0.78.2"
|
||||
version = "0.80.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bevy_ecs",
|
||||
@@ -678,6 +726,7 @@ dependencies = [
|
||||
"phf",
|
||||
"pretty_assertions",
|
||||
"rand",
|
||||
"rust-embed",
|
||||
"sdl2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -907,6 +956,40 @@ version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed"
|
||||
version = "8.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "025908b8682a26ba8d12f6f2d66b987584a4a87bc024abc5bbc12553a8cd178a"
|
||||
dependencies = [
|
||||
"rust-embed-impl",
|
||||
"rust-embed-utils",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed-impl"
|
||||
version = "8.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6065f1a4392b71819ec1ea1df1120673418bf386f50de1d6f54204d836d4349c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rust-embed-utils",
|
||||
"syn",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed-utils"
|
||||
version = "8.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6cc0c81648b20b70c491ff8cce00c1c3b223bb8ed2b5d41f0e54c6c4c0a3594"
|
||||
dependencies = [
|
||||
"sha2",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
@@ -925,6 +1008,15 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
@@ -994,6 +1086,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.4"
|
||||
@@ -1253,6 +1356,12 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.11"
|
||||
@@ -1306,6 +1415,22 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "579a42fc0b8e0c63b76519a339be31bed574929511fa53c1a3acae26eb258f29"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.14.2+wasi-0.2.4"
|
||||
@@ -1396,6 +1521,15 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "pacman"
|
||||
version = "0.78.2"
|
||||
version = "0.80.0"
|
||||
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"] }
|
||||
@@ -50,6 +50,7 @@ windows-sys = { version = "0.61.0", features = ["Win32_System_Console"] }
|
||||
# On desktop platforms, build SDL2 with cargo-vcpkg
|
||||
sdl2 = { version = "0.38", default-features = false, features = ["image", "ttf", "gfx", "mixer", "unsafe_textures", "static-link", "use-vcpkg"] }
|
||||
rand = { version = "0.9.2", default-features = false, features = ["thread_rng"] }
|
||||
rust-embed = "8.7.2"
|
||||
spin_sleep = "1.3.3"
|
||||
|
||||
# Browser-specific dependencies
|
||||
|
||||
5
Justfile
5
Justfile
@@ -37,3 +37,8 @@ samply:
|
||||
web *args:
|
||||
bun run web.build.ts {{args}};
|
||||
caddy file-server --root dist
|
||||
|
||||
# Run cargo fix
|
||||
fix:
|
||||
cargo fix --workspace --lib --allow-dirty
|
||||
cargo fmt --all
|
||||
|
||||
@@ -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
|
||||
|
||||
176
ROADMAP.md
176
ROADMAP.md
@@ -1,30 +1,164 @@
|
||||
# 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
|
||||
- [x] Intro jingle
|
||||
- [ ] 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
|
||||
- [x] Ghost eaten sounds
|
||||
- [x] Pac-Man Death
|
||||
- [ ] 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
|
||||
- [x] Pause System
|
||||
- [x] 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
|
||||
|
||||
2
STORY.md
2
STORY.md
@@ -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
|
||||
|
||||
BIN
assets/game/sound/begin.ogg
Normal file
BIN
assets/game/sound/begin.ogg
Normal file
Binary file not shown.
BIN
assets/game/sound/intermission.ogg
Normal file
BIN
assets/game/sound/intermission.ogg
Normal file
Binary file not shown.
BIN
assets/game/sound/pacman/death.ogg
Normal file
BIN
assets/game/sound/pacman/death.ogg
Normal file
Binary file not shown.
BIN
assets/game/sound/pacman/extra_life.ogg
Normal file
BIN
assets/game/sound/pacman/extra_life.ogg
Normal file
Binary file not shown.
BIN
assets/game/sound/pacman/fruit.ogg
Normal file
BIN
assets/game/sound/pacman/fruit.ogg
Normal file
Binary file not shown.
BIN
assets/game/sound/pacman/ghost.ogg
Normal file
BIN
assets/game/sound/pacman/ghost.ogg
Normal file
Binary file not shown.
BIN
assets/game/sound/pacman/waka/1.ogg
Normal file
BIN
assets/game/sound/pacman/waka/1.ogg
Normal file
Binary file not shown.
BIN
assets/game/sound/pacman/waka/2.ogg
Normal file
BIN
assets/game/sound/pacman/waka/2.ogg
Normal file
Binary file not shown.
BIN
assets/game/sound/pacman/waka/3.ogg
Normal file
BIN
assets/game/sound/pacman/waka/3.ogg
Normal file
Binary file not shown.
BIN
assets/game/sound/pacman/waka/4.ogg
Normal file
BIN
assets/game/sound/pacman/waka/4.ogg
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
@@ -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()
|
||||
124
src/asset.rs
124
src/asset.rs
@@ -1,26 +1,64 @@
|
||||
#![allow(dead_code)]
|
||||
//! Cross-platform asset loading abstraction.
|
||||
//! On desktop, assets are embedded using include_bytes!; on Emscripten, assets are loaded from the filesystem.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use strum_macros::EnumIter;
|
||||
use std::iter;
|
||||
|
||||
use crate::audio::Sound;
|
||||
use crate::error::AssetError;
|
||||
|
||||
/// Enumeration of all game assets with cross-platform loading support.
|
||||
///
|
||||
/// Each variant corresponds to a specific file that can be loaded either from
|
||||
/// binary-embedded data or embedded filesystem (Emscripten).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumIter)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Asset {
|
||||
Wav1,
|
||||
Wav2,
|
||||
Wav3,
|
||||
Wav4,
|
||||
/// Main sprite atlas containing all game graphics (atlas.png)
|
||||
AtlasImage,
|
||||
/// Terminal Vector font for text rendering (TerminalVector.ttf)
|
||||
Font,
|
||||
/// Sound effect for Pac-Man's death
|
||||
DeathSound,
|
||||
/// Sound file assets
|
||||
SoundFile(Sound),
|
||||
}
|
||||
|
||||
use strum::IntoEnumIterator;
|
||||
|
||||
impl Asset {
|
||||
#[allow(dead_code)]
|
||||
pub fn into_iter() -> AssetIter {
|
||||
AssetIter {
|
||||
sound_iter: None,
|
||||
state: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub struct AssetIter {
|
||||
sound_iter: Option<iter::Map<<Sound as IntoEnumIterator>::Iterator, fn(Sound) -> Asset>>,
|
||||
state: u8,
|
||||
}
|
||||
|
||||
impl Iterator for AssetIter {
|
||||
type Item = Asset;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
match self.state {
|
||||
0 => {
|
||||
self.state = 1;
|
||||
Some(Asset::AtlasImage)
|
||||
}
|
||||
1 => {
|
||||
self.state = 2;
|
||||
Some(Asset::Font)
|
||||
}
|
||||
2 => self
|
||||
.sound_iter
|
||||
.get_or_insert_with(|| Sound::iter().map(Asset::SoundFile))
|
||||
.next(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Asset {
|
||||
@@ -29,47 +67,71 @@ impl Asset {
|
||||
/// Paths are consistent across platforms and used by the Emscripten backend
|
||||
/// for filesystem loading. Desktop builds embed assets directly and don't
|
||||
/// use these paths at runtime.
|
||||
#[allow(dead_code)]
|
||||
pub fn path(&self) -> &str {
|
||||
use Asset::*;
|
||||
match self {
|
||||
Wav1 => "sound/waka/1.ogg",
|
||||
Wav2 => "sound/waka/2.ogg",
|
||||
Wav3 => "sound/waka/3.ogg",
|
||||
Wav4 => "sound/waka/4.ogg",
|
||||
SoundFile(Sound::Waka(0)) => "sound/pacman/waka/1.ogg",
|
||||
SoundFile(Sound::Waka(1)) => "sound/pacman/waka/2.ogg",
|
||||
SoundFile(Sound::Waka(2)) => "sound/pacman/waka/3.ogg",
|
||||
SoundFile(Sound::Waka(3..=u8::MAX)) => "sound/pacman/waka/4.ogg",
|
||||
SoundFile(Sound::PacmanDeath) => "sound/pacman/death.ogg",
|
||||
SoundFile(Sound::ExtraLife) => "sound/pacman/extra_life.ogg",
|
||||
SoundFile(Sound::Fruit) => "sound/pacman/fruit.ogg",
|
||||
SoundFile(Sound::Ghost) => "sound/pacman/ghost.ogg",
|
||||
SoundFile(Sound::Beginning) => "sound/begin.ogg",
|
||||
SoundFile(Sound::Intermission) => "sound/intermission.ogg",
|
||||
AtlasImage => "atlas.png",
|
||||
Font => "TerminalVector.ttf",
|
||||
DeathSound => "sound/pacman_death.wav",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod imp {
|
||||
use super::*;
|
||||
use crate::error::AssetError;
|
||||
use crate::platform;
|
||||
use tracing::trace;
|
||||
|
||||
/// Loads asset bytes using the appropriate platform-specific method.
|
||||
///
|
||||
/// On desktop platforms, returns embedded compile-time data via `include_bytes!`.
|
||||
/// On desktop platforms, returns embedded compile-time data via `rust-embed`.
|
||||
/// On Emscripten, loads from the filesystem using the asset's path. The returned
|
||||
/// `Cow` allows zero-copy access to embedded data while supporting owned data
|
||||
/// when loaded from disk.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `AssetError::NotFound` if the asset file cannot be located (Emscripten only),
|
||||
/// Returns `AssetError::NotFound` if the asset file cannot be located,
|
||||
/// or `AssetError::Io` for filesystem I/O failures.
|
||||
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
|
||||
trace!(asset = ?asset, path = asset.path(), "Loading game asset");
|
||||
let result = platform::get_asset_bytes(asset);
|
||||
pub fn get_bytes(&self) -> Result<Cow<'static, [u8]>, AssetError> {
|
||||
use tracing::trace;
|
||||
trace!(asset = ?self, "Loading game asset");
|
||||
let result = self.get_bytes_platform();
|
||||
match &result {
|
||||
Ok(bytes) => trace!(asset = ?asset, size_bytes = bytes.len(), "Asset loaded successfully"),
|
||||
Err(e) => trace!(asset = ?asset, error = ?e, "Asset loading failed"),
|
||||
Ok(bytes) => trace!(asset = ?self, size_bytes = bytes.len(), "Asset loaded successfully"),
|
||||
Err(e) => trace!(asset = ?self, error = ?e, "Asset loading failed"),
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
pub use imp::get_asset_bytes;
|
||||
#[cfg(not(target_os = "emscripten"))]
|
||||
fn get_bytes_platform(&self) -> Result<Cow<'static, [u8]>, AssetError> {
|
||||
#[derive(rust_embed::Embed)]
|
||||
#[folder = "assets/game/"]
|
||||
struct EmbeddedAssets;
|
||||
|
||||
let path = self.path();
|
||||
EmbeddedAssets::get(path)
|
||||
.map(|file| file.data)
|
||||
.ok_or_else(|| AssetError::NotFound(path.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "emscripten")]
|
||||
fn get_bytes_platform(&self) -> Result<Cow<'static, [u8]>, AssetError> {
|
||||
use sdl2::rwops::RWops;
|
||||
use std::io::{self, Read};
|
||||
|
||||
let path = format!("assets/game/{}", self.path());
|
||||
let mut rwops = RWops::from_file(&path, "rb").map_err(|_| AssetError::NotFound(self.path().to_string()))?;
|
||||
|
||||
let len = rwops.len().ok_or_else(|| AssetError::NotFound(self.path().to_string()))?;
|
||||
|
||||
let mut buf = vec![0u8; len];
|
||||
rwops.read_exact(&mut buf).map_err(|e| AssetError::Io(io::Error::other(e)))?;
|
||||
|
||||
Ok(Cow::Owned(buf))
|
||||
}
|
||||
}
|
||||
|
||||
135
src/audio.rs
135
src/audio.rs
@@ -1,23 +1,53 @@
|
||||
//! This module handles the audio playback for the game.
|
||||
use crate::asset::{get_asset_bytes, Asset};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::asset::Asset;
|
||||
use sdl2::{
|
||||
mixer::{self, Chunk, InitFlag, LoaderRWops, DEFAULT_FORMAT},
|
||||
mixer::{self, Chunk, InitFlag, LoaderRWops, AUDIO_S16LSB, DEFAULT_CHANNELS},
|
||||
rwops::RWops,
|
||||
};
|
||||
use strum::IntoEnumIterator;
|
||||
|
||||
const SOUND_ASSETS: [Asset; 4] = [Asset::Wav1, Asset::Wav2, Asset::Wav3, Asset::Wav4];
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Sound {
|
||||
Waka(u8),
|
||||
PacmanDeath,
|
||||
ExtraLife,
|
||||
Fruit,
|
||||
Ghost,
|
||||
Beginning,
|
||||
Intermission,
|
||||
}
|
||||
|
||||
impl IntoEnumIterator for Sound {
|
||||
type Iterator = std::vec::IntoIter<Sound>;
|
||||
|
||||
fn iter() -> Self::Iterator {
|
||||
vec![
|
||||
Sound::Waka(0),
|
||||
Sound::Waka(1),
|
||||
Sound::Waka(2),
|
||||
Sound::Waka(3),
|
||||
Sound::PacmanDeath,
|
||||
Sound::ExtraLife,
|
||||
Sound::Fruit,
|
||||
Sound::Ghost,
|
||||
Sound::Beginning,
|
||||
Sound::Intermission,
|
||||
]
|
||||
.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
/// The audio system for the game.
|
||||
///
|
||||
/// 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>,
|
||||
death_sound: Option<Chunk>,
|
||||
next_sound_index: usize,
|
||||
sounds: HashMap<Sound, Chunk>,
|
||||
next_waka_index: u8,
|
||||
muted: bool,
|
||||
disabled: bool,
|
||||
}
|
||||
@@ -34,25 +64,36 @@ impl Audio {
|
||||
/// If audio fails to initialize, the audio system will be disabled and
|
||||
/// all functions will silently do nothing.
|
||||
pub fn new() -> Self {
|
||||
let frequency = 44100;
|
||||
let format = DEFAULT_FORMAT;
|
||||
let channels = 4;
|
||||
let chunk_size = 256; // 256 is minimum for emscripten
|
||||
let frequency = 16_000;
|
||||
let format = AUDIO_S16LSB;
|
||||
let chunk_size = {
|
||||
// 256 is the minimum for Emscripten, but in practice 1024 is much more reliable
|
||||
#[cfg(target_os = "emscripten")]
|
||||
{
|
||||
1024
|
||||
}
|
||||
|
||||
// Otherwise, 256 is plenty safe.
|
||||
#[cfg(not(target_os = "emscripten"))]
|
||||
{
|
||||
256
|
||||
}
|
||||
};
|
||||
|
||||
// Try to open audio, but don't panic if it fails
|
||||
if let Err(e) = mixer::open_audio(frequency, format, 1, chunk_size) {
|
||||
if let Err(e) = mixer::open_audio(frequency, format, DEFAULT_CHANNELS, chunk_size) {
|
||||
tracing::warn!("Failed to open audio: {}. Audio will be disabled.", e);
|
||||
return Self {
|
||||
_mixer_context: None,
|
||||
sounds: Vec::new(),
|
||||
death_sound: None,
|
||||
next_sound_index: 0,
|
||||
sounds: HashMap::new(),
|
||||
next_waka_index: 0u8,
|
||||
muted: false,
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
mixer::allocate_channels(channels);
|
||||
let channels = 4;
|
||||
mixer::allocate_channels(4);
|
||||
|
||||
// set channel volume
|
||||
for i in 0..channels {
|
||||
@@ -66,9 +107,8 @@ impl Audio {
|
||||
tracing::warn!("Failed to initialize SDL2_mixer: {}. Audio will be disabled.", e);
|
||||
return Self {
|
||||
_mixer_context: None,
|
||||
sounds: Vec::new(),
|
||||
death_sound: None,
|
||||
next_sound_index: 0,
|
||||
sounds: HashMap::new(),
|
||||
next_waka_index: 0u8,
|
||||
muted: false,
|
||||
disabled: true,
|
||||
};
|
||||
@@ -76,12 +116,15 @@ impl Audio {
|
||||
};
|
||||
|
||||
// Try to load sounds, but don't panic if any fail
|
||||
let mut sounds = Vec::new();
|
||||
for (i, asset) in SOUND_ASSETS.iter().enumerate() {
|
||||
match get_asset_bytes(*asset) {
|
||||
let mut sounds = HashMap::new();
|
||||
for (i, sound_type) in Sound::iter().enumerate() {
|
||||
let asset = Asset::SoundFile(sound_type);
|
||||
match asset.get_bytes() {
|
||||
Ok(data) => match RWops::from_bytes(&data) {
|
||||
Ok(rwops) => match rwops.load_wav() {
|
||||
Ok(chunk) => sounds.push(chunk),
|
||||
Ok(chunk) => {
|
||||
sounds.insert(sound_type, chunk);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to load sound {} from asset API: {}", i + 1, e);
|
||||
}
|
||||
@@ -96,7 +139,7 @@ impl Audio {
|
||||
}
|
||||
}
|
||||
|
||||
let death_sound = match get_asset_bytes(Asset::DeathSound) {
|
||||
let death_sound = match Asset::SoundFile(Sound::PacmanDeath).get_bytes() {
|
||||
Ok(data) => match RWops::from_bytes(&data) {
|
||||
Ok(rwops) => match rwops.load_wav() {
|
||||
Ok(chunk) => Some(chunk),
|
||||
@@ -121,9 +164,8 @@ impl Audio {
|
||||
tracing::warn!("No sounds loaded successfully. Audio will be disabled.");
|
||||
return Self {
|
||||
_mixer_context: Some(mixer_context),
|
||||
sounds: Vec::new(),
|
||||
death_sound: None,
|
||||
next_sound_index: 0,
|
||||
sounds: HashMap::new(),
|
||||
next_waka_index: 0u8,
|
||||
muted: false,
|
||||
disabled: true,
|
||||
};
|
||||
@@ -132,8 +174,7 @@ impl Audio {
|
||||
Audio {
|
||||
_mixer_context: Some(mixer_context),
|
||||
sounds,
|
||||
death_sound,
|
||||
next_sound_index: 0,
|
||||
next_waka_index: 0u8,
|
||||
muted: false,
|
||||
disabled: false,
|
||||
}
|
||||
@@ -144,33 +185,32 @@ impl Audio {
|
||||
/// Automatically rotates through the four eating sound assets. The sound plays on channel 0 and the internal sound index
|
||||
/// advances to the next variant. Silently returns if audio is disabled, muted,
|
||||
/// or no sounds were loaded successfully.
|
||||
#[allow(dead_code)]
|
||||
pub fn eat(&mut self) {
|
||||
pub fn waka(&mut self) {
|
||||
if self.disabled || self.muted || self.sounds.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(chunk) = self.sounds.get(self.next_sound_index) {
|
||||
match mixer::Channel(0).play(chunk, 0) {
|
||||
if let Some(chunk) = self.sounds.get(&Sound::Waka(self.next_waka_index)) {
|
||||
match mixer::Channel::all().play(chunk, 0) {
|
||||
Ok(channel) => {
|
||||
tracing::trace!("Playing sound #{} on channel {:?}", self.next_sound_index + 1, channel);
|
||||
tracing::trace!("Playing sound #{} on channel {:?}", self.next_waka_index + 1, channel);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Could not play sound #{}: {}", self.next_sound_index + 1, e);
|
||||
tracing::warn!("Could not play sound #{}: {}", self.next_waka_index + 1, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.next_sound_index = (self.next_sound_index + 1) % self.sounds.len();
|
||||
self.next_waka_index = (self.next_waka_index + 1) & 3;
|
||||
}
|
||||
|
||||
/// Plays the death sound effect.
|
||||
pub fn death(&mut self) {
|
||||
/// Plays the provided sound effect once.
|
||||
pub fn play(&mut self, sound: Sound) {
|
||||
if self.disabled || self.muted {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(chunk) = &self.death_sound {
|
||||
mixer::Channel::all().play(chunk, 0).ok();
|
||||
if let Some(chunk) = self.sounds.get(&sound) {
|
||||
let _ = mixer::Channel::all().play(chunk, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +221,20 @@ impl Audio {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pauses all currently playing audio channels.
|
||||
pub fn pause_all(&mut self) {
|
||||
if !self.disabled {
|
||||
mixer::Channel::all().pause();
|
||||
}
|
||||
}
|
||||
|
||||
/// Resumes all currently playing audio channels.
|
||||
pub fn resume_all(&mut self) {
|
||||
if !self.disabled {
|
||||
mixer::Channel::all().resume();
|
||||
}
|
||||
}
|
||||
|
||||
/// Instantly mutes or unmutes all audio channels by adjusting their volume.
|
||||
///
|
||||
/// Sets all 4 mixer channels to zero volume when muting, or restores them to
|
||||
@@ -211,7 +265,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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
57
src/error.rs
57
src/error.rs
@@ -46,19 +46,16 @@ pub enum AssetError {
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[error("Asset not found: {0}")]
|
||||
NotFound(String),
|
||||
}
|
||||
|
||||
/// 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 +107,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
159
src/game.rs
159
src/game.rs
@@ -3,23 +3,26 @@
|
||||
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::item::PelletCount;
|
||||
use crate::systems::state::IntroPlayed;
|
||||
use crate::systems::{
|
||||
self, audio_system, blinking_system, collision_system, combined_render_system, directional_render_system,
|
||||
dirty_render_system, eaten_ghost_system, ghost_collision_system, ghost_movement_system, ghost_state_system,
|
||||
hud_render_system, item_system, 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,
|
||||
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, Paused, PlayerAnimation, PlayerBundle, PlayerControlled, PlayerDeathAnimation, PlayerLives, Position,
|
||||
RenderDirty, Renderable, ScoreResource, StartupSequence, SystemId, SystemTimings, Timing, TouchState, Velocity, Visibility,
|
||||
};
|
||||
|
||||
use crate::texture::animated::{DirectionalTiles, TileSequence};
|
||||
@@ -39,18 +42,30 @@ use sdl2::video::{Window, WindowContext};
|
||||
use sdl2::EventPump;
|
||||
|
||||
use crate::{
|
||||
asset::{get_asset_bytes, Asset},
|
||||
asset::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 +143,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 +164,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)?;
|
||||
@@ -232,7 +249,7 @@ impl Game {
|
||||
debug_texture.set_blend_mode(BlendMode::Blend);
|
||||
debug_texture.set_scale_mode(ScaleMode::Nearest);
|
||||
|
||||
let font_data: &'static [u8] = get_asset_bytes(Asset::Font)?.to_vec().leak();
|
||||
let font_data: &'static [u8] = Asset::Font.get_bytes()?.to_vec().leak();
|
||||
let font_asset = RWops::from_bytes(font_data).map_err(|_| GameError::Sdl("Failed to load font".to_string()))?;
|
||||
let debug_font = ttf_context
|
||||
.load_font_from_rwops(font_asset, constants::ui::DEBUG_FONT_SIZE)
|
||||
@@ -246,7 +263,7 @@ impl Game {
|
||||
|
||||
fn load_atlas_and_map_tiles(texture_creator: &TextureCreator<WindowContext>) -> GameResult<(SpriteAtlas, Vec<AtlasTile>)> {
|
||||
trace!("Loading atlas image from embedded assets");
|
||||
let atlas_bytes = get_asset_bytes(Asset::AtlasImage)?;
|
||||
let atlas_bytes = Asset::AtlasImage.get_bytes()?;
|
||||
let atlas_texture = texture_creator.load_texture_bytes(&atlas_bytes).map_err(|e| {
|
||||
if e.to_string().contains("format") || e.to_string().contains("unsupported") {
|
||||
GameError::Texture(crate::error::TextureError::InvalidFormat(format!(
|
||||
@@ -375,6 +392,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 +401,9 @@ impl Game {
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
world.add_observer(ghost_collision_observer);
|
||||
world.add_observer(item_collision_observer);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -405,11 +426,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(PelletCount(0));
|
||||
world.insert_resource(SystemTimings::default());
|
||||
world.insert_resource(Timing::default());
|
||||
world.insert_resource(Bindings::default());
|
||||
@@ -417,11 +440,13 @@ impl Game {
|
||||
world.insert_resource(RenderDirty::default());
|
||||
world.insert_resource(DebugState::default());
|
||||
world.insert_resource(AudioState::default());
|
||||
world.insert_resource(IntroPlayed::default());
|
||||
world.insert_resource(CursorPosition::default());
|
||||
world.insert_resource(TouchState::default());
|
||||
world.insert_resource(GameStage::Starting(StartupSequence::TextOnly {
|
||||
remaining_ticks: constants::startup::STARTUP_FRAMES,
|
||||
}));
|
||||
world.insert_resource(Paused(false));
|
||||
|
||||
world.insert_non_send_resource(event_pump);
|
||||
world.insert_non_send_resource::<&mut Canvas<Window>>(Box::leak(Box::new(canvas)));
|
||||
@@ -436,13 +461,12 @@ impl Game {
|
||||
fn configure_schedule(schedule: &mut Schedule) {
|
||||
let stage_system = profile(SystemId::Stage, systems::stage_system);
|
||||
let input_system = profile(SystemId::Input, systems::input::input_system);
|
||||
let pause_system = profile(SystemId::Input, systems::handle_pause_command);
|
||||
let player_control_system = profile(SystemId::PlayerControls, systems::player_control_system);
|
||||
let player_movement_system = profile(SystemId::PlayerMovement, systems::player_movement_system);
|
||||
let player_tunnel_slowdown_system = profile(SystemId::PlayerMovement, systems::player::player_tunnel_slowdown_system);
|
||||
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);
|
||||
@@ -450,18 +474,12 @@ impl Game {
|
||||
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 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>| {
|
||||
@@ -470,37 +488,55 @@ impl Game {
|
||||
*local % 2 == 0
|
||||
}),
|
||||
player_control_system,
|
||||
pause_system,
|
||||
)
|
||||
.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,
|
||||
player_life_sprite_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.run_if(|paused: Res<Paused>| !paused.0),
|
||||
GameplaySet::Respond.run_if(|paused: Res<Paused>| !paused.0),
|
||||
RenderSet::Animation.run_if(|paused: Res<Paused>| !paused.0),
|
||||
RenderSet::Draw,
|
||||
RenderSet::Present,
|
||||
));
|
||||
}
|
||||
|
||||
fn spawn_items(world: &mut World) -> GameResult<()> {
|
||||
@@ -599,7 +635,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");
|
||||
}
|
||||
|
||||
@@ -714,12 +750,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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use glam::Vec2;
|
||||
|
||||
use crate::systems::movement::NodeId;
|
||||
use crate::systems::NodeId;
|
||||
|
||||
use super::direction::Direction;
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
//! Desktop platform implementation.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::time::Duration;
|
||||
|
||||
use rand::rngs::ThreadRng;
|
||||
use rust_embed::Embed;
|
||||
|
||||
use crate::asset::Asset;
|
||||
use crate::error::{AssetError, PlatformError};
|
||||
use crate::error::PlatformError;
|
||||
|
||||
#[derive(Embed)]
|
||||
#[folder = "assets/game/"]
|
||||
struct EmbeddedAssets;
|
||||
|
||||
/// Desktop platform implementation.
|
||||
pub fn sleep(duration: Duration, focused: bool) {
|
||||
@@ -57,18 +60,6 @@ pub fn init_console() -> Result<(), PlatformError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
|
||||
match asset {
|
||||
Asset::Wav1 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/1.ogg"))),
|
||||
Asset::Wav2 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/2.ogg"))),
|
||||
Asset::Wav3 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/3.ogg"))),
|
||||
Asset::Wav4 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/4.ogg"))),
|
||||
Asset::AtlasImage => Ok(Cow::Borrowed(include_bytes!("../../assets/game/atlas.png"))),
|
||||
Asset::Font => Ok(Cow::Borrowed(include_bytes!("../../assets/game/TerminalVector.ttf"))),
|
||||
Asset::DeathSound => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman_death.wav"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rng() -> ThreadRng {
|
||||
rand::rng()
|
||||
}
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
//! Emscripten platform implementation.
|
||||
|
||||
use crate::asset::Asset;
|
||||
use crate::error::{AssetError, PlatformError};
|
||||
use crate::error::PlatformError;
|
||||
use crate::formatter::CustomFormatter;
|
||||
use rand::{rngs::SmallRng, SeedableRng};
|
||||
use sdl2::rwops::RWops;
|
||||
use std::borrow::Cow;
|
||||
use std::ffi::CString;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::io::{self, Write};
|
||||
use std::time::Duration;
|
||||
|
||||
// Emscripten FFI functions
|
||||
#[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,32 +59,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()))?;
|
||||
|
||||
let len = rwops.len().ok_or_else(|| AssetError::NotFound(asset.path().to_string()))?;
|
||||
|
||||
let mut buf = vec![0u8; len];
|
||||
rwops.read_exact(&mut buf).map_err(|e| AssetError::Io(io::Error::other(e)))?;
|
||||
|
||||
Ok(Cow::Owned(buf))
|
||||
}
|
||||
|
||||
pub fn rng() -> SmallRng {
|
||||
SmallRng::from_os_rng()
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#![allow(dead_code)]
|
||||
//! Buffered tracing setup for handling logs before console attachment.
|
||||
|
||||
use crate::formatter::CustomFormatter;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
104
src/systems/animation/directional.rs
Normal file
104
src/systems/animation/directional.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
30
src/systems/animation/linear.rs
Normal file
30
src/systems/animation/linear.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
7
src/systems/animation/mod.rs
Normal file
7
src/systems/animation/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod blinking;
|
||||
mod directional;
|
||||
mod linear;
|
||||
|
||||
pub use self::blinking::*;
|
||||
pub use self::directional::*;
|
||||
pub use self::linear::*;
|
||||
@@ -11,7 +11,7 @@ use bevy_ecs::{
|
||||
};
|
||||
use tracing::{debug, trace};
|
||||
|
||||
use crate::{audio::Audio, error::GameError};
|
||||
use crate::{audio::Audio, audio::Sound, error::GameError};
|
||||
|
||||
/// Resource for tracking audio state
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
@@ -25,12 +25,16 @@ pub struct AudioState {
|
||||
/// Events for triggering audio playback
|
||||
#[derive(Event, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AudioEvent {
|
||||
/// Play the "eat" sound when Pac-Man consumes a pellet
|
||||
PlayEat,
|
||||
/// Play the death sound
|
||||
PlayDeath,
|
||||
/// Play a specific sound effect
|
||||
PlaySound(Sound),
|
||||
/// Play the cycling waka sound variant
|
||||
Waka,
|
||||
/// Stop all currently playing sounds
|
||||
StopAll,
|
||||
/// Pause all sounds
|
||||
Pause,
|
||||
/// Resume all sounds
|
||||
Resume,
|
||||
}
|
||||
|
||||
/// Non-send resource wrapper for SDL2 audio system
|
||||
@@ -57,10 +61,10 @@ pub fn audio_system(
|
||||
// Process audio events
|
||||
for event in audio_events.read() {
|
||||
match event {
|
||||
AudioEvent::PlayEat => {
|
||||
AudioEvent::Waka => {
|
||||
if !audio.0.is_disabled() && !audio_state.muted {
|
||||
trace!(sound_index = audio_state.sound_index, "Playing eat sound");
|
||||
audio.0.eat();
|
||||
audio.0.waka();
|
||||
// Update the sound index for cycling through sounds
|
||||
audio_state.sound_index = (audio_state.sound_index + 1) % 4;
|
||||
// 4 eat sounds available
|
||||
@@ -72,15 +76,15 @@ pub fn audio_system(
|
||||
);
|
||||
}
|
||||
}
|
||||
AudioEvent::PlayDeath => {
|
||||
AudioEvent::PlaySound(sound) => {
|
||||
if !audio.0.is_disabled() && !audio_state.muted {
|
||||
trace!("Playing death sound");
|
||||
audio.0.death();
|
||||
trace!(?sound, "Playing sound");
|
||||
audio.0.play(*sound);
|
||||
} else {
|
||||
debug!(
|
||||
disabled = audio.0.is_disabled(),
|
||||
muted = audio_state.muted,
|
||||
"Skipping death sound due to audio state"
|
||||
"Skipping sound due to audio state"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -92,6 +96,22 @@ pub fn audio_system(
|
||||
debug!("Audio disabled, ignoring stop all request");
|
||||
}
|
||||
}
|
||||
AudioEvent::Pause => {
|
||||
if !audio.0.is_disabled() {
|
||||
debug!("Pausing all audio");
|
||||
audio.0.pause_all();
|
||||
} else {
|
||||
debug!("Audio disabled, ignoring pause all request");
|
||||
}
|
||||
}
|
||||
AudioEvent::Resume => {
|
||||
if !audio.0.is_disabled() {
|
||||
debug!("Resuming all audio");
|
||||
audio.0.resume_all();
|
||||
} else {
|
||||
debug!("Audio disabled, ignoring resume all request");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
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::audio::Sound;
|
||||
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 +63,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 +78,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 +88,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 +102,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 +127,136 @@ 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 ghost eaten sound
|
||||
events.write(AudioEvent::PlaySound(Sound::Ghost));
|
||||
} else if matches!(*ghost_state, GhostState::Normal) {
|
||||
// Pac-Man dies
|
||||
warn!(ghost = ?ghost_type, "Pacman hit by normal ghost, player dies");
|
||||
*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() {
|
||||
match *entity_type {
|
||||
EntityType::Fruit(_) => {
|
||||
events.write(AudioEvent::PlaySound(Sound::Fruit));
|
||||
}
|
||||
EntityType::Pellet | EntityType::PowerPellet => {
|
||||
events.write(AudioEvent::Waka);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Make non-eaten ghosts frightened when power pellet is collected
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
43
src/systems/common/bundles.rs
Normal file
43
src/systems/common/bundles.rs
Normal 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,
|
||||
}
|
||||
106
src/systems/common/components.rs
Normal file
106
src/systems/common/components.rs
Normal 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,
|
||||
}
|
||||
5
src/systems/common/mod.rs
Normal file
5
src/systems/common/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod bundles;
|
||||
pub mod components;
|
||||
|
||||
pub use self::bundles::*;
|
||||
pub use self::components::*;
|
||||
@@ -1,411 +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;
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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
79
src/systems/hud/fruits.rs
Normal 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 ¤t_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
89
src/systems/hud/lives.rs
Normal 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
9
src/systems/hud/mod.rs
Normal 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
86
src/systems/hud/score.rs
Normal 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
81
src/systems/hud/touch.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,38 +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, PlayerLife, 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::{Commands, NonSendMut, Query, Res, ResMut};
|
||||
use bevy_ecs::system::{NonSendMut, Query, Res, ResMut};
|
||||
use glam::Vec2;
|
||||
use sdl2::pixels::Color;
|
||||
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 {
|
||||
@@ -43,324 +90,28 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
if diff > 0 {
|
||||
// Spawn new life sprites
|
||||
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.abs() {
|
||||
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,
|
||||
},
|
||||
));
|
||||
}
|
||||
} else if diff < 0 {
|
||||
// Remove excess life sprites (highest indices first)
|
||||
let to_remove = diff.abs() as usize;
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Component for Renderables to store an exact pixel position
|
||||
#[derive(Component)]
|
||||
pub struct PixelPosition {
|
||||
pub pixel_position: 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)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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>,
|
||||
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((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>,
|
||||
@@ -368,8 +119,14 @@ pub fn render_system(
|
||||
map: &Res<Map>,
|
||||
dirty: &Res<RenderDirty>,
|
||||
renderables: &Query<
|
||||
(Entity, &Renderable, Option<&Position>, Option<&PixelPosition>),
|
||||
(Without<Hidden>, Or<(With<Position>, With<PixelPosition>)>),
|
||||
(
|
||||
Entity,
|
||||
&Renderable,
|
||||
Option<&Position>,
|
||||
Option<&PixelPosition>,
|
||||
Option<&Visibility>,
|
||||
),
|
||||
Or<(With<Position>, With<PixelPosition>)>,
|
||||
>,
|
||||
errors: &mut EventWriter<GameError>,
|
||||
) {
|
||||
@@ -386,14 +143,17 @@ pub fn render_system(
|
||||
errors.write(TextureError::RenderFailed(e.to_string()).into());
|
||||
}
|
||||
|
||||
// Render all entities to the backbuffer
|
||||
for (_entity, renderable, position, pixel_position) in renderables
|
||||
// Collect and filter visible entities, then sort by layer
|
||||
let mut visible_entities: Vec<_> = renderables
|
||||
.iter()
|
||||
.sort_by_key::<(Entity, &Renderable, Option<&Position>, Option<&PixelPosition>), _>(|(_, renderable, _, _)| {
|
||||
renderable.layer
|
||||
})
|
||||
.rev()
|
||||
{
|
||||
.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 {
|
||||
@@ -426,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>,
|
||||
@@ -440,8 +201,14 @@ pub fn combined_render_system(
|
||||
map: Res<Map>,
|
||||
dirty: Res<RenderDirty>,
|
||||
renderables: Query<
|
||||
(Entity, &Renderable, Option<&Position>, Option<&PixelPosition>),
|
||||
(Without<Hidden>, Or<(With<Position>, With<PixelPosition>)>),
|
||||
(
|
||||
Entity,
|
||||
&Renderable,
|
||||
Option<&Position>,
|
||||
Option<&PixelPosition>,
|
||||
Option<&Visibility>,
|
||||
),
|
||||
Or<(With<Position>, With<PixelPosition>)>,
|
||||
>,
|
||||
colliders: Query<(&Collider, &Position)>,
|
||||
cursor: Res<CursorPosition>,
|
||||
|
||||
@@ -1,29 +1,37 @@
|
||||
use std::mem::discriminant;
|
||||
use tracing::{debug, info, warn};
|
||||
use tracing::{debug, info};
|
||||
|
||||
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},
|
||||
};
|
||||
|
||||
use crate::events::{GameCommand, GameEvent};
|
||||
|
||||
#[derive(Resource, Clone)]
|
||||
pub struct PlayerAnimation(pub DirectionalAnimation);
|
||||
|
||||
#[derive(Resource, Clone)]
|
||||
pub struct PlayerDeathAnimation(pub LinearAnimation);
|
||||
|
||||
/// Tracks whether the beginning sound has been played for the current startup sequence
|
||||
#[derive(Resource, Debug, Default, Clone, Copy)]
|
||||
pub struct IntroPlayed(pub bool);
|
||||
|
||||
/// A resource to track the overall stage of the game from a high-level perspective.
|
||||
#[derive(Resource, Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub enum GameStage {
|
||||
@@ -34,16 +42,76 @@ 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,
|
||||
}
|
||||
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct Paused(pub bool);
|
||||
|
||||
pub fn handle_pause_command(
|
||||
mut events: EventReader<GameEvent>,
|
||||
mut paused: ResMut<Paused>,
|
||||
mut audio_events: EventWriter<AudioEvent>,
|
||||
) {
|
||||
for event in events.read() {
|
||||
if let GameEvent::Command(GameCommand::TogglePause) = event {
|
||||
paused.0 = !paused.0;
|
||||
if paused.0 {
|
||||
info!("Game paused");
|
||||
audio_events.write(AudioEvent::Pause);
|
||||
} else {
|
||||
info!("Game resumed");
|
||||
audio_events.write(AudioEvent::Resume);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 +138,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 +155,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 +172,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,63 +184,47 @@ 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>)>,
|
||||
mut intro_played: ResMut<IntroPlayed>,
|
||||
) {
|
||||
let old_state = *game_state;
|
||||
let mut new_state: Option<GameStage> = None;
|
||||
let mut new_state_opt: Option<GameStage> = None;
|
||||
|
||||
// Handle stage transition requests before normal ticking
|
||||
for event in stage_event_reader.read() {
|
||||
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");
|
||||
new_state = Some(GameStage::GhostEatenPause {
|
||||
debug!(ghost = ?ghost_type, node = pac_node, "Ghost eaten, entering pause state");
|
||||
new_state_opt = Some(GameStage::GhostEatenPause {
|
||||
remaining_ticks: 30,
|
||||
ghost_entity,
|
||||
ghost_type,
|
||||
node: pac_node,
|
||||
});
|
||||
}
|
||||
|
||||
let new_state: GameStage = match new_state.unwrap_or(*game_state) {
|
||||
GameStage::Starting(startup) => match startup {
|
||||
StartupSequence::TextOnly { remaining_ticks } => {
|
||||
if remaining_ticks > 0 {
|
||||
GameStage::Starting(StartupSequence::TextOnly {
|
||||
remaining_ticks: remaining_ticks - 1,
|
||||
})
|
||||
} else {
|
||||
debug!("Transitioning from text-only to characters visible startup stage");
|
||||
GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 })
|
||||
}
|
||||
}
|
||||
StartupSequence::CharactersVisible { remaining_ticks } => {
|
||||
if remaining_ticks > 0 {
|
||||
GameStage::Starting(StartupSequence::CharactersVisible {
|
||||
remaining_ticks: remaining_ticks - 1,
|
||||
})
|
||||
} else {
|
||||
info!("Startup sequence completed, beginning gameplay");
|
||||
GameStage::Playing
|
||||
}
|
||||
}
|
||||
},
|
||||
GameStage::Playing => GameStage::Playing,
|
||||
let new_state: GameStage = new_state_opt.unwrap_or_else(|| match *game_state {
|
||||
GameStage::Playing => {
|
||||
// This is the default state, do nothing
|
||||
*game_state
|
||||
}
|
||||
GameStage::GhostEatenPause {
|
||||
remaining_ticks,
|
||||
ghost_entity,
|
||||
ghost_type,
|
||||
node,
|
||||
} => {
|
||||
if remaining_ticks > 0 {
|
||||
GameStage::GhostEatenPause {
|
||||
remaining_ticks: remaining_ticks.saturating_sub(1),
|
||||
ghost_entity,
|
||||
ghost_type,
|
||||
node,
|
||||
}
|
||||
} else {
|
||||
@@ -186,11 +232,37 @@ pub fn stage_system(
|
||||
GameStage::Playing
|
||||
}
|
||||
}
|
||||
GameStage::PlayerDying(dying) => match dying {
|
||||
GameStage::Starting(sequence) => match sequence {
|
||||
StartupSequence::TextOnly { remaining_ticks } => {
|
||||
// Play the beginning sound once at the start of TextOnly stage
|
||||
if !intro_played.0 {
|
||||
audio_events.write(AudioEvent::PlaySound(crate::audio::Sound::Beginning));
|
||||
intro_played.0 = true;
|
||||
}
|
||||
if remaining_ticks > 0 {
|
||||
GameStage::Starting(StartupSequence::TextOnly {
|
||||
remaining_ticks: remaining_ticks.saturating_sub(1),
|
||||
})
|
||||
} else {
|
||||
GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 })
|
||||
}
|
||||
}
|
||||
StartupSequence::CharactersVisible { remaining_ticks } => {
|
||||
if remaining_ticks > 0 {
|
||||
GameStage::Starting(StartupSequence::CharactersVisible {
|
||||
remaining_ticks: remaining_ticks.saturating_sub(1),
|
||||
})
|
||||
} else {
|
||||
info!("Startup sequence completed, beginning gameplay");
|
||||
GameStage::Playing
|
||||
}
|
||||
}
|
||||
},
|
||||
GameStage::PlayerDying(sequence) => match sequence {
|
||||
DyingSequence::Frozen { remaining_ticks } => {
|
||||
if remaining_ticks > 0 {
|
||||
GameStage::PlayerDying(DyingSequence::Frozen {
|
||||
remaining_ticks: remaining_ticks - 1,
|
||||
remaining_ticks: remaining_ticks.saturating_sub(1),
|
||||
})
|
||||
} else {
|
||||
let death_animation = &player_death_animation.0;
|
||||
@@ -202,7 +274,7 @@ pub fn stage_system(
|
||||
DyingSequence::Animating { remaining_ticks } => {
|
||||
if remaining_ticks > 0 {
|
||||
GameStage::PlayerDying(DyingSequence::Animating {
|
||||
remaining_ticks: remaining_ticks - 1,
|
||||
remaining_ticks: remaining_ticks.saturating_sub(1),
|
||||
})
|
||||
} else {
|
||||
GameStage::PlayerDying(DyingSequence::Hidden { remaining_ticks: 60 })
|
||||
@@ -211,183 +283,166 @@ pub fn stage_system(
|
||||
DyingSequence::Hidden { remaining_ticks } => {
|
||||
if remaining_ticks > 0 {
|
||||
GameStage::PlayerDying(DyingSequence::Hidden {
|
||||
remaining_ticks: remaining_ticks - 1,
|
||||
remaining_ticks: remaining_ticks.saturating_sub(1),
|
||||
})
|
||||
} else {
|
||||
player_lives.0 = player_lives.0.saturating_sub(1);
|
||||
|
||||
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");
|
||||
info!("All lives lost, game over");
|
||||
GameStage::GameOver
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
GameStage::LevelRestarting => {
|
||||
debug!("Level restart complete, returning to startup sequence");
|
||||
GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 })
|
||||
}
|
||||
GameStage::GameOver => GameStage::GameOver,
|
||||
};
|
||||
GameStage::GameOver => *game_state,
|
||||
});
|
||||
|
||||
if old_state == new_state {
|
||||
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);
|
||||
audio_events.write(AudioEvent::PlaySound(crate::audio::Sound::PacmanDeath));
|
||||
}
|
||||
(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>();
|
||||
}
|
||||
// Reset intro flag for the next round
|
||||
intro_played.0 = false;
|
||||
}
|
||||
(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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
use pacman::asset::Asset;
|
||||
use speculoos::prelude::*;
|
||||
use strum::IntoEnumIterator;
|
||||
|
||||
#[test]
|
||||
fn all_asset_paths_exist() {
|
||||
for asset in Asset::iter() {
|
||||
for asset in Asset::into_iter() {
|
||||
let path = asset.path();
|
||||
let full_path = format!("assets/game/{}", path);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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},
|
||||
asset::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},
|
||||
};
|
||||
@@ -42,7 +43,7 @@ pub fn setup_sdl() -> Result<(Canvas<Window>, TextureCreator<WindowContext>, Sdl
|
||||
|
||||
pub fn create_atlas(canvas: &mut sdl2::render::Canvas<sdl2::video::Window>) -> SpriteAtlas {
|
||||
let texture_creator = canvas.texture_creator();
|
||||
let atlas_bytes = get_asset_bytes(Asset::AtlasImage).unwrap();
|
||||
let atlas_bytes = Asset::AtlasImage.get_bytes().unwrap();
|
||||
|
||||
let texture = texture_creator.load_texture_bytes(&atlas_bytes).unwrap();
|
||||
|
||||
@@ -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
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
use pacman::{
|
||||
game::ATLAS_FRAMES,
|
||||
map::direction::Direction,
|
||||
systems::components::Ghost,
|
||||
systems::Ghost,
|
||||
texture::sprites::{FrightenedColor, GameSprite, GhostSprite, MazeSprite, PacmanSprite},
|
||||
};
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user