mirror of
https://github.com/Xevion/Pac-Man.git
synced 2025-12-06 11:15:46 -06:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca006b5073 | ||
|
|
139afb2d40 | ||
|
|
5d56b31353 | ||
|
|
b4990af109 | ||
|
|
088c496ad9 | ||
|
|
5bdf11dfb6 | ||
|
|
c163171304 | ||
|
|
63e1059df8 | ||
|
|
11af44c469 | ||
|
|
7675608391 | ||
|
|
7d5b8e11dd | ||
|
|
5aba1862c9 | ||
|
|
e46d39a938 | ||
|
|
49a6a5cc39 | ||
|
|
ca50d0f3d8 | ||
|
|
774dc010bf |
53
.github/workflows/checks.yaml
vendored
Normal file
53
.github/workflows/checks.yaml
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
name: Checks
|
||||
|
||||
on: ["push", "pull_request"]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_TOOLCHAIN: 1.86.0
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Cache vcpkg
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: target/vcpkg
|
||||
key: A-vcpkg-${{ runner.os }}-${{ hashFiles('Cargo.toml', 'Cargo.lock') }}
|
||||
restore-keys: |
|
||||
A-vcpkg-${{ runner.os }}-
|
||||
|
||||
- name: Vcpkg Linux Dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libltdl-dev
|
||||
|
||||
- name: Vcpkg
|
||||
run: |
|
||||
cargo install cargo-vcpkg
|
||||
cargo vcpkg -v build
|
||||
|
||||
- name: Run clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt -- --check
|
||||
|
||||
- uses: taiki-e/install-action@cargo-audit
|
||||
|
||||
- name: Run security audit
|
||||
run: cargo audit
|
||||
14
.github/workflows/tests.yaml
vendored
14
.github/workflows/tests.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Tests & Checks
|
||||
name: Tests
|
||||
|
||||
on: ["push", "pull_request"]
|
||||
|
||||
@@ -18,7 +18,6 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
@@ -45,14 +44,3 @@ jobs:
|
||||
|
||||
- name: Run nextest
|
||||
run: cargo nextest run --workspace
|
||||
|
||||
- name: Run clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt -- --check
|
||||
|
||||
- uses: taiki-e/install-action@cargo-audit
|
||||
|
||||
- name: Run security audit
|
||||
run: cargo audit
|
||||
|
||||
136
Cargo.lock
generated
136
Cargo.lock
generated
@@ -663,7 +663,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "pacman"
|
||||
version = "0.77.1"
|
||||
version = "0.78.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bevy_ecs",
|
||||
@@ -693,7 +693,7 @@ dependencies = [
|
||||
"tracing-error",
|
||||
"tracing-subscriber",
|
||||
"windows",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -722,7 +722,7 @@ dependencies = [
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-targets 0.52.6",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1051,11 +1051,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "spin_sleep"
|
||||
version = "1.3.2"
|
||||
version = "1.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14ac0e4b54d028c2000a13895bcd84cd02a1d63c4f78e08e4ec5ec8f53efd4b9"
|
||||
checksum = "9c07347b7c0301b9adba4350bdcf09c039d0e7160922050db0439b3c6723c8ab"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1398,9 +1398,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.61.3"
|
||||
version = "0.62.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
||||
checksum = "9579d0e6970fd5250aa29aba5994052385ff55cf7b28a059e484bb79ea842e42"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-core",
|
||||
@@ -1411,18 +1411,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windows-collections"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
|
||||
checksum = "a90dd7a7b86859ec4cdf864658b311545ef19dbcf17a672b52ab7cefe80c336f"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.61.2"
|
||||
version = "0.62.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
||||
checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
@@ -1433,9 +1433,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.2.1"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
|
||||
checksum = "b2194dee901458cb79e1148a4e9aac2b164cc95fa431891e7b296ff0b2f1d8a6"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
@@ -1466,15 +1466,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.1.3"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
|
||||
checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65"
|
||||
|
||||
[[package]]
|
||||
name = "windows-numerics"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
|
||||
checksum = "2ce3498fe0aba81e62e477408383196b4b0363db5e0c27646f932676283b43d8"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
@@ -1482,18 +1482,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
|
||||
checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.4.2"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
|
||||
checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
@@ -1504,16 +1504,16 @@ version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
version = "0.61.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa"
|
||||
dependencies = [
|
||||
"windows-targets 0.53.2",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1522,37 +1522,21 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm 0.52.6",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.53.0",
|
||||
"windows_aarch64_msvc 0.53.0",
|
||||
"windows_i686_gnu 0.53.0",
|
||||
"windows_i686_gnullvm 0.53.0",
|
||||
"windows_i686_msvc 0.53.0",
|
||||
"windows_x86_64_gnu 0.53.0",
|
||||
"windows_x86_64_gnullvm 0.53.0",
|
||||
"windows_x86_64_msvc 0.53.0",
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
|
||||
checksum = "ab47f085ad6932defa48855254c758cdd0e2f2d48e62a34118a268d8f345e118"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
@@ -1563,96 +1547,48 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.12"
|
||||
|
||||
10
Cargo.toml
10
Cargo.toml
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "pacman"
|
||||
version = "0.77.1"
|
||||
version = "0.78.5"
|
||||
authors = ["Xevion"]
|
||||
edition = "2021"
|
||||
rust-version = "1.86.0"
|
||||
@@ -21,7 +21,7 @@ default-run = "pacman"
|
||||
bevy_ecs = "0.16.1"
|
||||
glam = "0.30.5"
|
||||
pathfinding = "4.14"
|
||||
tracing = { version = "0.1.41", features = ["max_level_debug", "release_max_level_debug"]}
|
||||
tracing = { version = "0.1.41", features = ["max_level_trace", "release_max_level_debug"]}
|
||||
tracing-error = "0.2.0"
|
||||
tracing-subscriber = {version = "0.3.20", features = ["env-filter"]}
|
||||
time = { version = "0.3.43", features = ["formatting", "macros"] }
|
||||
@@ -42,15 +42,15 @@ phf = { version = "0.13.1", features = ["macros"] }
|
||||
# Windows-specific dependencies
|
||||
[target.'cfg(target_os = "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.61.3", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Console"] }
|
||||
windows-sys = { version = "0.60.2", features = ["Win32_System_Console"] }
|
||||
windows = { version = "0.62.0", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Console"] }
|
||||
windows-sys = { version = "0.61.0", features = ["Win32_System_Console"] }
|
||||
|
||||
# Desktop-specific dependencies
|
||||
[target.'cfg(not(target_os = "emscripten"))'.dependencies]
|
||||
# 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"] }
|
||||
spin_sleep = "1.3.2"
|
||||
spin_sleep = "1.3.3"
|
||||
|
||||
# Browser-specific dependencies
|
||||
[target.'cfg(target_os = "emscripten")'.dependencies]
|
||||
|
||||
111
README.md
111
README.md
@@ -1,80 +1,92 @@
|
||||
<div align="center">
|
||||
<img src="assets/repo/banner.png" alt="Pac-Man Banner Screenshot">
|
||||
</div>
|
||||
|
||||
# Pac-Man
|
||||
|
||||
[![Tests Status][badge-test]][test] [![Build Status][badge-build]][build] [![If you're seeing this, Coveralls.io is broken again and it's not my fault.][badge-coverage]][coverage] [![Online Demo][badge-online-demo]][demo] [![Last Commit][badge-last-commit]][commits]
|
||||
[![A project just for fun, no really!][badge-justforfunnoreally]][justforfunnoreally] ![Built with Rust][badge-built-with-rust] [![Build Status][badge-build]][build] [![Tests Status][badge-test]][test] [![Checks Status][badge-checks]][checks] [![If you're seeing this, Coveralls.io is broken again and it's not my fault.][badge-coverage]][coverage] [![Online Demo][badge-online-demo]][demo]
|
||||
|
||||
[badge-built-with-rust]: https://img.shields.io/badge/Built_with-Rust-blue?logo=rust
|
||||
[badge-justforfunnoreally]: https://img.shields.io/badge/justforfunnoreally-dev-9ff
|
||||
[badge-test]: https://github.com/Xevion/Pac-Man/actions/workflows/tests.yaml/badge.svg
|
||||
[badge-checks]: https://github.com/Xevion/Pac-Man/actions/workflows/checks.yaml/badge.svg
|
||||
[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-demo]: https://img.shields.io/github/deployments/Xevion/Pac-Man/github-pages?label=GitHub%20Pages
|
||||
[badge-online-demo]: https://img.shields.io/badge/GitHub%20Pages-Demo-brightgreen
|
||||
[badge-last-commit]: https://img.shields.io/github/last-commit/Xevion/Pac-Man
|
||||
[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
|
||||
[checks]: https://github.com/Xevion/Pac-Man/actions/workflows/checks.yaml
|
||||
[coverage]: https://coveralls.io/github/Xevion/Pac-Man?branch=master
|
||||
[demo]: https://xevion.github.io/Pac-Man/
|
||||
[commits]: https://github.com/Xevion/Pac-Man/commits/master
|
||||
|
||||
A faithful recreation of the classic Pac-Man arcade game written in Rust. This project aims to replicate the original game's mechanics, graphics, sound, and behavior as accurately as possible while providing modern development features like cross-platform compatibility and WebAssembly support.
|
||||
A faithful recreation of the classic Pac-Man arcade game, written in Rust.
|
||||
|
||||
This project aims to replicate the original game's mechanics, graphics, sound, and behavior as accurately as possible while providing modern development features like cross-platform compatibility and WebAssembly support.
|
||||
|
||||
The game includes all the original features you'd expect from Pac-Man:
|
||||
|
||||
- [x] Classic maze navigation and dot collection
|
||||
- [x] Classic maze navigation with tunnels and dot collection
|
||||
- [ ] Four ghosts with their unique AI behaviors (Blinky, Pinky, Inky, and Clyde)
|
||||
- [ ] Power pellets that allow Pac-Man to eat ghosts
|
||||
- [x] Power pellets that allow Pac-Man to eat ghosts
|
||||
- [ ] Fruit bonuses that appear periodically
|
||||
- [ ] Progressive difficulty with faster ghosts and shorter power pellet duration
|
||||
- [x] Authentic sound effects and sprites
|
||||
|
||||
This cross-platform implementation is built with SDL2 for graphics, audio, and input handling. It can run on Windows, Linux, macOS, and in web browsers via WebAssembly.
|
||||
This cross-platform implementation is built with SDL2 for graphics, audio, and input handling. It can run on Windows, Linux, macOS, even web browsers via WebAssembly.
|
||||
|
||||
## Quick Start
|
||||
|
||||
The easiest way to play is to visit the [online demo][demo]. It is more or less identical to the desktop experience at this time.
|
||||
|
||||
While I do plan to have desktop builds released automatically, the game is still a work in progress, and I'm not quite ready to start uploading releases.
|
||||
|
||||
However, every commit has build artifacts, so you can grab the [latest build artifacts][build-workflow] if available.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/repo/screenshots/0.png" alt="Screenshot 0 - Starting Game">
|
||||
<p><em>Starting a new game</em></p>
|
||||
|
||||
<img src="assets/repo/screenshots/1.png" alt="Screenshot 1 - Eating Dots">
|
||||
<p><em>Pac-Man collecting dots and avoiding ghosts</em></p>
|
||||
|
||||
<img src="assets/repo/screenshots/2.png" alt="Screenshot 2 - Game Over">
|
||||
<p><em>Game over screen after losing all lives</em></p>
|
||||
|
||||
<img src="assets/repo/screenshots/3.png" alt="Screenshot 3 - Debug Mode">
|
||||
<p><em>Debug mode showing hitboxes, node graph, and performance details.</em></p>
|
||||
</div>
|
||||
|
||||
## Why?
|
||||
|
||||
Just because. And because I wanted to learn more about Rust, inter-operability with C, and compiling to WebAssembly.
|
||||
[Just for fun.][justforfunnoreally] And because I wanted to learn more about Rust, inter-operability with C, and compiling to WebAssembly.
|
||||
|
||||
I was inspired by a certain code review video on YouTube; [SOME UNIQUE C++ CODE // Pacman Clone Code Review](https://www.youtube.com/watch?v=OKs_JewEeOo) by The Cherno.
|
||||
Originally, I was inspired by a certain code review video on YouTube; [SOME UNIQUE C++ CODE // Pacman Clone Code Review](https://www.youtube.com/watch?v=OKs_JewEeOo). For some reason, I was inspired to try and replicate it in Rust, and it was uniquely challenging. It's not easy to integrate SDL2 with Rust, and even harder to get it working with Emscripten.
|
||||
|
||||
For some reason, I was inspired to try and replicate it in Rust, and it was uniquely challenging.
|
||||
I wanted to hit a lot of goals and features, making it a 'perfect' project that I could be proud of.
|
||||
|
||||
I wanted to hit a log of goals and features, making it a 'perfect' project that I could be proud of.
|
||||
|
||||
- Near-perfect replication of logic, scoring, graphics, sound, and behaviors. No hacks, workarounds, or poor designs.
|
||||
- Written in Rust, buildable on Windows, Linux, Mac and WebAssembly. Statically linked, no runtime dependencies.
|
||||
- Near-perfect replication of logic, scoring, graphics, sound, and behaviors. No hacks, workarounds, or poor designs. Well documented, well-tested, and maintainable.
|
||||
- Written in Rust, buildable on Windows, Linux, Mac and WebAssembly. Statically linked, no runtime dependencies, automatically built with GitHub Actions.
|
||||
- Performant, low memory, CPU and GPU usage.
|
||||
- Online demo, playable in a browser.
|
||||
- Completely automatic build system with releases for all platforms.
|
||||
- Well documented, well-tested, and maintainable.
|
||||
- Online demo, playable in a browser, built automatically with GitHub Actions.
|
||||
|
||||
## Experimental Ideas
|
||||
If you're curious about the journey of this project, you can read the [story](STORY.md) file. Eventually, I will be using this as the basis for some sort of blog post or more official page, but for now, I'm keeping it within the repository as a simple file.
|
||||
|
||||
- Debug tooling
|
||||
- Game state visualization
|
||||
- Game speed controls + pausing
|
||||
- Log tracing
|
||||
- Performance details
|
||||
- Customized Themes & Colors
|
||||
- Color-blind friendly
|
||||
- Perfected Ghost Algorithms
|
||||
- More than 4 ghosts
|
||||
- Custom Level Generation
|
||||
- Multi-map tunnelling
|
||||
- Online Scoreboard
|
||||
- An online axum server with a simple database and OAuth2 authentication.
|
||||
- Integrates with GitHub, Discord, and Google OAuth2 to acquire an email identifier & avatar.
|
||||
- Avatars are optional for score submission and can be disabled, instead using a blank avatar.
|
||||
- Avatars are downscaled to a low resolution pixellated image to maintain the 8-bit aesthetic.
|
||||
- A custom name is used for the score submission, which is checked for potential abusive language.
|
||||
- A max length of 14 characters, and a min length of 3 characters.
|
||||
- Names are checked for potential abusive language via an external API.
|
||||
- The client implementation should require zero configuration, environment variables, or special secrets.
|
||||
- It simply defaults to the pacman server API, or can be overriden manually.
|
||||
## Roadmap
|
||||
|
||||
You can read the [roadmap](ROADMAP.md) file for more details on the project's goals and future plans.
|
||||
|
||||
## Build Notes
|
||||
|
||||
Since this project is still in progress, I'm only going to cover non-obvious build details. By reading the code, build scripts, and copying the online build workflows, you should be able to replicate the build process.
|
||||
|
||||
- Install `cargo-vcpkg` with `cargo install cargo-vcpkg`, then run `cargo vcpkg build` to build the requisite dependencies via vcpkg.
|
||||
- This is only required for the desktop builds, not the web build.
|
||||
- We use rustc 1.86.0 for the build, due to bulk-memory-opt related issues on wasm32-unknown-emscripten.
|
||||
- Technically, we could probably use stable or even nightly on desktop targets, but using different versions for different targets is a pain, mainly because of clippy warnings changing between versions.
|
||||
- Install `cargo-vcpkg` with `cargo install cargo-vcpkg`, then run `cargo vcpkg build` to build the requisite dependencies via vcpkg.
|
||||
- For the WASM build, you need to have the Emscripten SDK cloned; you can do so with `git clone https://github.com/emscripten-core/emsdk.git`
|
||||
- The first time you clone, you'll need to install the appropriate SDK version with `./emsdk install 3.1.43` and then activate it with `./emsdk activate 3.1.43`. On Windows, use `./emsdk/emsdk.ps1` instead.
|
||||
- I'm still not sure _why_ 3.1.43 is required, but it is. Perhaps in the future I will attempt to use a more modern version.
|
||||
@@ -87,3 +99,18 @@ Since this project is still in progress, I'm only going to cover non-obvious bui
|
||||
- `caddy file-server --root dist` (install with `[sudo apt|brew|choco] install caddy` or [a dozen other ways](https://caddyserver.com/docs/install))
|
||||
- `web.build.ts` auto installs dependencies, but you may need to pass `-i` or `--install=fallback|force` to install missing packages. My guess is that if you have some packages installed, it won't install any missing ones. If you have no packages installed, it will install all of them.
|
||||
- If you want to have TypeScript resolution for development, you can manually install the dependencies with `bun install` in the `assets/site` folder.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a pull request or open an issue.
|
||||
|
||||
- The code is not exactly stable or bulletproof, but it is functional and has a lot of tests.
|
||||
- I am not actively looking for contributors, but I will review pull requests and merge them if they are useful.
|
||||
- If you have any ideas, please feel free to submit an issue.
|
||||
- If you have any private issues, security concerns, or anything sensitive, you can email me at [xevion@xevion.dev](mailto:xevion@xevion.dev).
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the GPLv3 license. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
[build-workflow]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml
|
||||
|
||||
161
ROADMAP.md
Normal file
161
ROADMAP.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# Roadmap
|
||||
|
||||
A comprehensive list of features needed to complete the Pac-Man emulation, organized by priority and implementation complexity.
|
||||
|
||||
## 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
|
||||
- [ ] Fruit display in bottom-right corner
|
||||
- [x] Fruit collection and scoring
|
||||
- [x] Bonus point display system
|
||||
|
||||
### Level Progression
|
||||
|
||||
- [ ] Multiple Levels
|
||||
- [ ] Level completion detection
|
||||
- [ ] Progressive difficulty scaling
|
||||
- [ ] Ghost speed increases per level
|
||||
- [ ] Power pellet duration decreases
|
||||
- [ ] Intermission Screens
|
||||
- [ ] Between-level cutscenes
|
||||
- [ ] Proper graphics and timing
|
||||
|
||||
### Audio System Completion
|
||||
|
||||
- [x] Core Audio Infrastructure
|
||||
- [x] Audio event system
|
||||
- [x] Sound effect playback
|
||||
- [x] Audio muting controls
|
||||
- [ ] Background Music
|
||||
- [ ] Continuous gameplay music
|
||||
- [ ] Escalating siren based on remaining pellets
|
||||
- [ ] Power pellet mode music
|
||||
- [ ] Intermission music
|
||||
- [x] Sound Effects
|
||||
- [x] Pellet eating sounds
|
||||
- [x] Fruit collection sounds
|
||||
- [ ] Ghost movement sounds
|
||||
- [ ] Level completion fanfare
|
||||
|
||||
### Game Mechanics
|
||||
|
||||
- [ ] Bonus Lives
|
||||
- [ ] Extra life at 10,000 points
|
||||
- [x] Life counter display
|
||||
- [ ] High Score System
|
||||
- [ ] High score tracking
|
||||
- [x] High score display
|
||||
- [ ] Score persistence
|
||||
|
||||
## Secondary Features (Medium Priority)
|
||||
|
||||
### Game Polish
|
||||
|
||||
- [x] Core Input System
|
||||
- [x] Keyboard controls
|
||||
- [x] Direction buffering for responsive controls
|
||||
- [x] Touch controls for mobile
|
||||
- [ ] Pause System
|
||||
- [ ] Pause/unpause functionality
|
||||
- [ ] Pause menu with options
|
||||
- [ ] Input System
|
||||
- [ ] Input remapping
|
||||
- [ ] Multiple input methods
|
||||
|
||||
## Advanced Features (Lower Priority)
|
||||
|
||||
### Difficulty Options
|
||||
|
||||
- [ ] Easy/Normal/Hard modes
|
||||
- [ ] Customizable ghost speeds
|
||||
|
||||
### Data Persistence
|
||||
|
||||
- [ ] High Score Persistence
|
||||
- [ ] Save high scores to file
|
||||
- [ ] High score table display
|
||||
- [ ] Settings Storage
|
||||
- [ ] Save user preferences
|
||||
- [ ] Audio/visual settings
|
||||
- [ ] Statistics Tracking
|
||||
- [ ] Game statistics
|
||||
- [ ] Achievement system
|
||||
|
||||
### Debug & Development Tools
|
||||
|
||||
- [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 & Extensions
|
||||
|
||||
### Visual Customization
|
||||
|
||||
- [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
|
||||
|
||||
### 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
|
||||
6
STORY.md
6
STORY.md
@@ -31,7 +31,7 @@ WebAssembly.
|
||||
The problem is that much of this work was done for pure-Rust applications - and SDL is C++.
|
||||
This requires a C++ WebAssembly compiler such as Emscripten; and it's a pain to get working.
|
||||
|
||||
Luckily though, someone else has done this before, and they fully documented it - [RuggRouge][ruggrouge].
|
||||
Luckily though, someone else has done this before, and they fully documented it - [RuggRouge][ruggrogue].
|
||||
|
||||
- Built with Rust
|
||||
- Uses SDL2
|
||||
@@ -92,7 +92,7 @@ This was weird, and honestly, I'm confused as to why the 2-year old sample code
|
||||
|
||||
After a bit of time, I noted that the `Instant` times were printing with only the whole seconds changing, and the nanoseconds were always 0.
|
||||
|
||||
```
|
||||
```rust
|
||||
Instant { tv_sec: 0, tv_nsec: 0 }
|
||||
Instant { tv_sec: 1, tv_nsec: 0 }
|
||||
Instant { tv_sec: 2, tv_nsec: 0 }
|
||||
@@ -357,7 +357,7 @@ Doing so required a full re-work of the animation and texture system, and I ende
|
||||
|
||||
So, I ended up using `unsafe` to forcibly cast the lifetimes to `'static`, which was a bit of a gamble, but given that they essentially behave as `'static` in practice, there wasn't much risk as I see it. I might re-look into my understanding of lifetimes and this in the future, but for the time being, it's a good solution that makes the codebase far easier to work with.
|
||||
|
||||
## Cross-platform Builds
|
||||
## Implementing Cross-platform Builds for Pac-Man
|
||||
|
||||
Since the original `rust-sdl2-emscripten` demo project had cross-platform builds, I was ready to get it working for this project. For the most part, it wasn't hard, things tended to click into place, but unfortunately, the `emscripten` os target and somehow, the `linux` os target were both failing.
|
||||
|
||||
|
||||
BIN
assets/repo/banner.png
Normal file
BIN
assets/repo/banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.3 KiB |
BIN
assets/repo/screenshots/0.png
Normal file
BIN
assets/repo/screenshots/0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
assets/repo/screenshots/1.png
Normal file
BIN
assets/repo/screenshots/1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
assets/repo/screenshots/2.png
Normal file
BIN
assets/repo/screenshots/2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
BIN
assets/repo/screenshots/3.png
Normal file
BIN
assets/repo/screenshots/3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
27
src/app.rs
27
src/app.rs
@@ -10,7 +10,7 @@ use crate::platform;
|
||||
use sdl2::pixels::PixelFormatEnum;
|
||||
use sdl2::render::RendererInfo;
|
||||
use sdl2::{AudioSubsystem, Sdl};
|
||||
use tracing::debug;
|
||||
use tracing::{debug, info, trace};
|
||||
|
||||
/// Main application wrapper that manages SDL initialization, window lifecycle, and the game loop.
|
||||
pub struct App {
|
||||
@@ -30,12 +30,20 @@ impl App {
|
||||
/// Returns `GameError::Sdl` if any SDL initialization step fails, or propagates
|
||||
/// errors from `Game::new()` during game state setup.
|
||||
pub fn new() -> GameResult<Self> {
|
||||
info!("Initializing SDL2 application");
|
||||
let sdl_context = sdl2::init().map_err(|e| GameError::Sdl(e.to_string()))?;
|
||||
debug!("Initializing SDL2 subsystems");
|
||||
let ttf_context = sdl2::ttf::init().map_err(|e| GameError::Sdl(e.to_string()))?;
|
||||
let video_subsystem = sdl_context.video().map_err(|e| GameError::Sdl(e.to_string()))?;
|
||||
let audio_subsystem = sdl_context.audio().map_err(|e| GameError::Sdl(e.to_string()))?;
|
||||
let event_pump = sdl_context.event_pump().map_err(|e| GameError::Sdl(e.to_string()))?;
|
||||
|
||||
trace!(
|
||||
width = (CANVAS_SIZE.x as f32 * SCALE).round() as u32,
|
||||
height = (CANVAS_SIZE.y as f32 * SCALE).round() as u32,
|
||||
scale = SCALE,
|
||||
"Creating game window"
|
||||
);
|
||||
let window = video_subsystem
|
||||
.window(
|
||||
"Pac-Man",
|
||||
@@ -64,7 +72,7 @@ impl App {
|
||||
{
|
||||
let mut names = drivers.keys().collect::<Vec<_>>();
|
||||
names.sort_by_key(|k| get_driver(k));
|
||||
debug!("Drivers: {names:?}")
|
||||
trace!("Drivers: {names:?}")
|
||||
}
|
||||
|
||||
// Count the number of times each pixel format is supported by each driver
|
||||
@@ -76,11 +84,12 @@ impl App {
|
||||
counts
|
||||
});
|
||||
|
||||
debug!("Pixel format counts: {pixel_format_counts:?}");
|
||||
trace!(pixel_format_counts = ?pixel_format_counts, "Available pixel formats per driver");
|
||||
|
||||
let index = get_driver("direct3d");
|
||||
debug!("Driver index: {index:?}");
|
||||
trace!(driver_index = ?index, "Selected graphics driver");
|
||||
|
||||
trace!("Creating hardware-accelerated canvas");
|
||||
let mut canvas = window
|
||||
.into_canvas()
|
||||
.accelerated()
|
||||
@@ -88,15 +97,23 @@ impl App {
|
||||
.build()
|
||||
.map_err(|e| GameError::Sdl(e.to_string()))?;
|
||||
|
||||
trace!(
|
||||
logical_width = CANVAS_SIZE.x,
|
||||
logical_height = CANVAS_SIZE.y,
|
||||
"Setting canvas logical size"
|
||||
);
|
||||
canvas
|
||||
.set_logical_size(CANVAS_SIZE.x, CANVAS_SIZE.y)
|
||||
.map_err(|e| GameError::Sdl(e.to_string()))?;
|
||||
debug!("Renderer: {:?}", canvas.info());
|
||||
debug!(renderer_info = ?canvas.info(), "Canvas renderer initialized");
|
||||
|
||||
trace!("Creating texture factory");
|
||||
let texture_creator = canvas.texture_creator();
|
||||
|
||||
info!("Starting game initialization");
|
||||
let game = Game::new(canvas, ttf_context, texture_creator, event_pump)?;
|
||||
|
||||
info!("Application initialization completed successfully");
|
||||
Ok(App {
|
||||
game,
|
||||
focused: true,
|
||||
|
||||
10
src/asset.rs
10
src/asset.rs
@@ -1,4 +1,3 @@
|
||||
#![allow(dead_code)]
|
||||
//! Cross-platform asset loading abstraction.
|
||||
//! On desktop, assets are embedded using include_bytes!; on Emscripten, assets are loaded from the filesystem.
|
||||
|
||||
@@ -48,6 +47,7 @@ mod imp {
|
||||
use super::*;
|
||||
use crate::error::AssetError;
|
||||
use crate::platform;
|
||||
use tracing::trace;
|
||||
|
||||
/// Loads asset bytes using the appropriate platform-specific method.
|
||||
///
|
||||
@@ -61,7 +61,13 @@ mod imp {
|
||||
/// Returns `AssetError::NotFound` if the asset file cannot be located (Emscripten only),
|
||||
/// or `AssetError::Io` for filesystem I/O failures.
|
||||
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
|
||||
platform::get_asset_bytes(asset)
|
||||
trace!(asset = ?asset, "Loading game asset");
|
||||
let result = platform::get_asset_bytes(asset);
|
||||
match &result {
|
||||
Ok(bytes) => trace!(asset = ?asset, size_bytes = bytes.len(), "Asset loaded successfully"),
|
||||
Err(e) => trace!(asset = ?asset, error = ?e, "Asset loading failed"),
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ const SOUND_ASSETS: [Asset; 4] = [Asset::Wav1, Asset::Wav2, Asset::Wav3, Asset::
|
||||
/// 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>,
|
||||
@@ -144,7 +143,6 @@ impl Audio {
|
||||
/// Automatically rotates through the four eating sound assets. The sound plays on channel 0 and the internal sound index
|
||||
/// advances to the next variant. Silently returns if audio is disabled, muted,
|
||||
/// or no sounds were loaded successfully.
|
||||
#[allow(dead_code)]
|
||||
pub fn eat(&mut self) {
|
||||
if self.disabled || self.muted || self.sounds.is_empty() {
|
||||
return;
|
||||
@@ -211,7 +209,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
|
||||
}
|
||||
|
||||
@@ -25,12 +25,25 @@ pub const SCALE: f32 = 2.6;
|
||||
/// screen for score display, player lives, and other UI elements.
|
||||
pub const BOARD_CELL_OFFSET: UVec2 = UVec2::new(0, 3);
|
||||
|
||||
/// Bottom HUD row offset to reserve space below the game board.
|
||||
///
|
||||
/// The 2-cell vertical offset (16 pixels) provides space at the bottom of the
|
||||
/// screen for displaying Pac-Man's lives (left) and fruit symbols (right).
|
||||
pub const BOARD_BOTTOM_CELL_OFFSET: UVec2 = UVec2::new(0, 2);
|
||||
|
||||
/// Pixel-space equivalent of `BOARD_CELL_OFFSET` for rendering calculations.
|
||||
///
|
||||
/// Automatically calculated from the cell offset to maintain consistency
|
||||
/// when the cell size changes. Used for positioning sprites and debug overlays.
|
||||
pub const BOARD_PIXEL_OFFSET: UVec2 = UVec2::new(BOARD_CELL_OFFSET.x * CELL_SIZE, BOARD_CELL_OFFSET.y * CELL_SIZE);
|
||||
|
||||
/// Pixel-space equivalent of `BOARD_BOTTOM_CELL_OFFSET` for rendering calculations.
|
||||
///
|
||||
/// Automatically calculated from the cell offset to maintain consistency
|
||||
/// when the cell size changes. Used for positioning bottom HUD elements.
|
||||
pub const BOARD_BOTTOM_PIXEL_OFFSET: UVec2 =
|
||||
UVec2::new(BOARD_BOTTOM_CELL_OFFSET.x * CELL_SIZE, BOARD_BOTTOM_CELL_OFFSET.y * CELL_SIZE);
|
||||
|
||||
/// Animation timing constants for ghost state management
|
||||
pub mod animation {
|
||||
/// Normal ghost movement animation speed (ticks per frame at 60 ticks/sec)
|
||||
@@ -45,15 +58,15 @@ pub mod animation {
|
||||
}
|
||||
/// The size of the canvas, in pixels.
|
||||
pub const CANVAS_SIZE: UVec2 = UVec2::new(
|
||||
(BOARD_CELL_SIZE.x + BOARD_CELL_OFFSET.x) * CELL_SIZE,
|
||||
(BOARD_CELL_SIZE.y + BOARD_CELL_OFFSET.y) * CELL_SIZE,
|
||||
(BOARD_CELL_SIZE.x + BOARD_CELL_OFFSET.x + BOARD_BOTTOM_CELL_OFFSET.x) * CELL_SIZE,
|
||||
(BOARD_CELL_SIZE.y + BOARD_CELL_OFFSET.y + BOARD_BOTTOM_CELL_OFFSET.y) * CELL_SIZE,
|
||||
);
|
||||
|
||||
pub const LARGE_SCALE: f32 = 2.6;
|
||||
|
||||
pub const LARGE_CANVAS_SIZE: UVec2 = UVec2::new(
|
||||
(((BOARD_CELL_SIZE.x + BOARD_CELL_OFFSET.x) * CELL_SIZE) as f32 * LARGE_SCALE) as u32,
|
||||
(((BOARD_CELL_SIZE.y + BOARD_CELL_OFFSET.y) * CELL_SIZE) as f32 * LARGE_SCALE) as u32,
|
||||
(((BOARD_CELL_SIZE.x + BOARD_CELL_OFFSET.x + BOARD_BOTTOM_CELL_OFFSET.x) * CELL_SIZE) as f32 * LARGE_SCALE) as u32,
|
||||
(((BOARD_CELL_SIZE.y + BOARD_CELL_OFFSET.y + BOARD_BOTTOM_CELL_OFFSET.y) * CELL_SIZE) as f32 * LARGE_SCALE) as u32,
|
||||
);
|
||||
|
||||
/// Collider size constants for different entity types
|
||||
@@ -66,6 +79,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
|
||||
|
||||
56
src/error.rs
56
src/error.rs
@@ -46,6 +46,7 @@ pub enum AssetError {
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
// This error is only possible on Emscripten, as the assets are loaded from a 'filesystem' of sorts (while on Desktop, they are included in the binary at compile time)
|
||||
#[allow(dead_code)]
|
||||
#[error("Asset not found: {0}")]
|
||||
NotFound(String),
|
||||
@@ -53,12 +54,9 @@ pub enum AssetError {
|
||||
|
||||
/// Platform-specific errors.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
#[allow(dead_code)]
|
||||
pub enum PlatformError {
|
||||
#[error("Console initialization failed: {0}")]
|
||||
ConsoleInit(String),
|
||||
#[error("Platform-specific error: {0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// Error type for map parsing operations.
|
||||
@@ -110,55 +108,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,3 +40,9 @@ impl From<GameCommand> for GameEvent {
|
||||
GameEvent::Command(command)
|
||||
}
|
||||
}
|
||||
|
||||
/// Data for requesting stage transitions; processed centrally in stage_system
|
||||
#[derive(Event, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum StageTransition {
|
||||
GhostEatenPause { ghost_entity: 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);
|
||||
}
|
||||
|
||||
94
src/game.rs
94
src/game.rs
@@ -3,22 +3,23 @@
|
||||
include!(concat!(env!("OUT_DIR"), "/atlas_data.rs"));
|
||||
|
||||
use std::collections::HashMap;
|
||||
use tracing::{debug, info, trace, warn};
|
||||
|
||||
use crate::constants::{self, animation, MapTile, CANVAS_SIZE};
|
||||
use crate::error::{GameError, GameResult};
|
||||
use crate::events::GameEvent;
|
||||
use crate::events::{GameEvent, StageTransition};
|
||||
use crate::map::builder::Map;
|
||||
use crate::map::direction::Direction;
|
||||
use crate::systems::{
|
||||
self, audio_system, blinking_system, collision_system, combined_render_system, directional_render_system,
|
||||
dirty_render_system, eaten_ghost_system, ghost_collision_system, ghost_movement_system, ghost_state_system,
|
||||
hud_render_system, item_system, linear_render_system, present_system, profile, 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,
|
||||
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,
|
||||
};
|
||||
|
||||
use crate::texture::animated::{DirectionalTiles, TileSequence};
|
||||
@@ -41,8 +42,7 @@ use crate::{
|
||||
asset::{get_asset_bytes, Asset},
|
||||
events::GameCommand,
|
||||
map::render::MapRenderer,
|
||||
systems::debug::{BatchedLinesResource, TtfAtlasResource},
|
||||
systems::input::{Bindings, CursorPosition},
|
||||
systems::{BatchedLinesResource, Bindings, CursorPosition, TtfAtlasResource},
|
||||
texture::sprite::{AtlasMapper, SpriteAtlas},
|
||||
};
|
||||
|
||||
@@ -89,31 +89,47 @@ impl Game {
|
||||
texture_creator: TextureCreator<WindowContext>,
|
||||
mut event_pump: EventPump,
|
||||
) -> GameResult<Game> {
|
||||
info!("Starting game initialization");
|
||||
|
||||
debug!("Disabling unnecessary SDL events");
|
||||
Self::disable_sdl_events(&mut event_pump);
|
||||
|
||||
debug!("Setting up textures and fonts");
|
||||
let (backbuffer, mut map_texture, debug_texture, ttf_atlas) =
|
||||
Self::setup_textures_and_fonts(&mut canvas, &texture_creator, ttf_context)?;
|
||||
|
||||
debug!("Initializing audio subsystem");
|
||||
let audio = crate::audio::Audio::new();
|
||||
|
||||
debug!("Loading sprite atlas and map tiles");
|
||||
let (mut atlas, map_tiles) = Self::load_atlas_and_map_tiles(&texture_creator)?;
|
||||
debug!("Rendering static map to texture cache");
|
||||
canvas
|
||||
.with_texture_canvas(&mut map_texture, |map_canvas| {
|
||||
MapRenderer::render_map(map_canvas, &mut atlas, &map_tiles);
|
||||
})
|
||||
.map_err(|e| GameError::Sdl(e.to_string()))?;
|
||||
|
||||
debug!("Building navigation graph from map layout");
|
||||
let map = Map::new(constants::RAW_BOARD)?;
|
||||
|
||||
debug!("Creating player animations and bundle");
|
||||
let (player_animation, player_start_sprite) = Self::create_player_animations(&atlas)?;
|
||||
let player_bundle = Self::create_player_bundle(&map, player_animation, player_start_sprite);
|
||||
|
||||
debug!("Creating death animation sequence");
|
||||
let death_animation = Self::create_death_animation(&atlas)?;
|
||||
|
||||
debug!("Initializing ECS world and system schedule");
|
||||
let mut world = World::default();
|
||||
let mut schedule = Schedule::default();
|
||||
|
||||
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,
|
||||
map,
|
||||
@@ -127,12 +143,18 @@ impl Game {
|
||||
ttf_atlas,
|
||||
death_animation,
|
||||
)?;
|
||||
|
||||
debug!("Configuring system execution schedule");
|
||||
Self::configure_schedule(&mut schedule);
|
||||
|
||||
debug!("Spawning player entity");
|
||||
world.spawn(player_bundle).insert((Frozen, Hidden));
|
||||
|
||||
info!("Spawning game entities");
|
||||
Self::spawn_ghosts(&mut world)?;
|
||||
Self::spawn_items(&mut world)?;
|
||||
|
||||
info!("Game initialization completed successfully");
|
||||
Ok(Game { world, schedule })
|
||||
}
|
||||
|
||||
@@ -224,6 +246,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_texture = texture_creator.load_texture_bytes(&atlas_bytes).map_err(|e| {
|
||||
if e.to_string().contains("format") || e.to_string().contains("unsupported") {
|
||||
@@ -235,11 +258,13 @@ impl Game {
|
||||
}
|
||||
})?;
|
||||
|
||||
debug!(frame_count = ATLAS_FRAMES.len(), "Creating sprite atlas from texture");
|
||||
let atlas_mapper = AtlasMapper {
|
||||
frames: ATLAS_FRAMES.into_iter().map(|(k, v)| (k.to_string(), *v)).collect(),
|
||||
};
|
||||
let atlas = SpriteAtlas::new(atlas_texture, atlas_mapper);
|
||||
|
||||
trace!("Extracting map tile sprites from atlas");
|
||||
let mut map_tiles = Vec::with_capacity(35);
|
||||
for i in 0..35 {
|
||||
let tile_name = GameSprite::Maze(MazeSprite::Tile(i)).to_path();
|
||||
@@ -350,6 +375,7 @@ impl Game {
|
||||
EventRegistry::register_event::<GameError>(world);
|
||||
EventRegistry::register_event::<GameEvent>(world);
|
||||
EventRegistry::register_event::<AudioEvent>(world);
|
||||
EventRegistry::register_event::<StageTransition>(world);
|
||||
|
||||
world.add_observer(
|
||||
|event: Trigger<GameEvent>, mut state: ResMut<GlobalState>, _score: ResMut<ScoreResource>| {
|
||||
@@ -383,9 +409,9 @@ impl Game {
|
||||
world.insert_resource(BatchedLinesResource::new(&map, constants::LARGE_SCALE));
|
||||
world.insert_resource(map);
|
||||
world.insert_resource(GlobalState { exit: false });
|
||||
world.insert_resource(GameStage::default());
|
||||
world.insert_resource(PlayerLives::default());
|
||||
world.insert_resource(ScoreResource(0));
|
||||
world.insert_resource(crate::systems::item::PelletCount(0));
|
||||
world.insert_resource(SystemTimings::default());
|
||||
world.insert_resource(Timing::default());
|
||||
world.insert_resource(Bindings::default());
|
||||
@@ -425,20 +451,18 @@ impl Game {
|
||||
let linear_render_system = profile(SystemId::LinearRender, linear_render_system);
|
||||
let dirty_render_system = profile(SystemId::DirtyRender, dirty_render_system);
|
||||
let hud_render_system = profile(SystemId::HudRender, hud_render_system);
|
||||
let player_life_sprite_system = profile(SystemId::HudRender, player_life_sprite_system);
|
||||
let present_system = profile(SystemId::Present, present_system);
|
||||
let unified_ghost_state_system = profile(SystemId::GhostStateAnimation, ghost_state_system);
|
||||
// let death_sequence_system = profile(SystemId::DeathSequence, death_sequence_system);
|
||||
// let game_over_system = profile(SystemId::GameOver, systems::game_over_system);
|
||||
let eaten_ghost_system = profile(SystemId::EatenGhost, eaten_ghost_system);
|
||||
let time_to_live_system = profile(SystemId::TimeToLive, time_to_live_system);
|
||||
|
||||
let forced_dirty_system = |mut dirty: ResMut<RenderDirty>| {
|
||||
dirty.0 = true;
|
||||
};
|
||||
|
||||
schedule.add_systems(
|
||||
forced_dirty_system
|
||||
.run_if(|score: Res<ScoreResource>, stage: Res<GameStage>| score.is_changed() || stage.is_changed()),
|
||||
);
|
||||
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 = (
|
||||
@@ -463,6 +487,7 @@ impl Game {
|
||||
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,
|
||||
@@ -470,6 +495,7 @@ impl Game {
|
||||
dirty_render_system,
|
||||
combined_render_system,
|
||||
hud_render_system,
|
||||
player_life_sprite_system,
|
||||
touch_ui_render_system,
|
||||
present_system,
|
||||
)
|
||||
@@ -480,6 +506,7 @@ impl Game {
|
||||
}
|
||||
|
||||
fn spawn_items(world: &mut World) -> GameResult<()> {
|
||||
trace!("Loading item sprites from atlas");
|
||||
let pellet_sprite = SpriteAtlas::get_tile(
|
||||
world.non_send_resource::<SpriteAtlas>(),
|
||||
&GameSprite::Maze(MazeSprite::Pellet).to_path(),
|
||||
@@ -504,6 +531,12 @@ impl Game {
|
||||
})
|
||||
.collect();
|
||||
|
||||
info!(
|
||||
pellet_count = nodes.iter().filter(|(_, t, _, _)| *t == EntityType::Pellet).count(),
|
||||
power_pellet_count = nodes.iter().filter(|(_, t, _, _)| *t == EntityType::PowerPellet).count(),
|
||||
"Spawning collectible items"
|
||||
);
|
||||
|
||||
for (id, item_type, sprite, size) in nodes {
|
||||
let mut item = world.spawn(ItemBundle {
|
||||
position: Position::Stopped { node: id },
|
||||
@@ -527,6 +560,7 @@ impl Game {
|
||||
/// Returns `GameError::Texture` if any ghost sprite cannot be found in the atlas,
|
||||
/// typically indicating missing or misnamed sprite files.
|
||||
fn spawn_ghosts(world: &mut World) -> GameResult<()> {
|
||||
trace!("Spawning ghost entities with AI personalities");
|
||||
// Extract the data we need first to avoid borrow conflicts
|
||||
let ghost_start_positions = {
|
||||
let map = world.resource::<Map>();
|
||||
@@ -567,9 +601,11 @@ impl Game {
|
||||
}
|
||||
};
|
||||
|
||||
world.spawn(ghost).insert((Frozen, Hidden));
|
||||
let entity = world.spawn(ghost).insert((Frozen, Hidden)).id();
|
||||
trace!(ghost = ?ghost_type, entity = ?entity, start_node, "Spawned ghost entity");
|
||||
}
|
||||
|
||||
info!("All ghost entities spawned successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -678,6 +714,28 @@ impl Game {
|
||||
) {
|
||||
let new_tick = timing.increment_tick();
|
||||
timings.add_total_timing(total_duration, new_tick);
|
||||
|
||||
// Log performance warnings for slow frames
|
||||
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!(
|
||||
total = format!("{:.3?}", total_duration),
|
||||
tick = new_tick,
|
||||
systems = systems_context,
|
||||
"Frame took longer than expected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let state = self
|
||||
|
||||
@@ -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.
|
||||
@@ -56,11 +58,17 @@ impl Map {
|
||||
/// This function will panic if the board layout contains unknown characters or if
|
||||
/// the house door is not defined by exactly two '=' characters.
|
||||
pub fn new(raw_board: [&str; BOARD_CELL_SIZE.y as usize]) -> GameResult<Map> {
|
||||
debug!("Starting map construction from character layout");
|
||||
let parsed_map = MapTileParser::parse_board(raw_board)?;
|
||||
|
||||
let map = parsed_map.tiles;
|
||||
let house_door = parsed_map.house_door;
|
||||
let tunnel_ends = parsed_map.tunnel_ends;
|
||||
debug!(
|
||||
house_door_count = house_door.len(),
|
||||
tunnel_ends_count = tunnel_ends.len(),
|
||||
"Parsed map special locations"
|
||||
);
|
||||
|
||||
let mut graph = Graph::new();
|
||||
let mut grid_to_node = HashMap::new();
|
||||
@@ -148,17 +156,44 @@ 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,
|
||||
};
|
||||
|
||||
tracing::warn!(
|
||||
fruit_spawn_position = ?fruit_spawn_position,
|
||||
"Fruit spawn position found"
|
||||
);
|
||||
|
||||
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
|
||||
debug!("Building tunnel connections");
|
||||
Self::build_tunnels(&mut graph, &grid_to_node, &tunnel_ends)?;
|
||||
|
||||
debug!(node_count = graph.nodes().count(), "Map construction completed successfully");
|
||||
Ok(Map {
|
||||
graph,
|
||||
grid_to_node,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use glam::Vec2;
|
||||
|
||||
use crate::systems::movement::NodeId;
|
||||
use crate::systems::NodeId;
|
||||
|
||||
use super::direction::Direction;
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn init_console() -> Result<(), PlatformError> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use crate::platform::tracing_buffer::setup_switchable_subscriber;
|
||||
use tracing::{debug, info};
|
||||
use tracing::{debug, info, trace};
|
||||
use windows::Win32::System::Console::GetConsoleWindow;
|
||||
|
||||
// Setup buffered tracing subscriber that will buffer logs until console is ready
|
||||
@@ -32,13 +32,13 @@ pub fn init_console() -> Result<(), PlatformError> {
|
||||
debug!("Already have a console window");
|
||||
return Ok(());
|
||||
} else {
|
||||
debug!("No existing console window found");
|
||||
trace!("No existing console window found");
|
||||
}
|
||||
|
||||
if let Some(file_type) = is_output_setup()? {
|
||||
debug!(r#type = file_type, "Existing output detected");
|
||||
trace!(r#type = file_type, "Existing output detected");
|
||||
} else {
|
||||
debug!("No existing output detected");
|
||||
trace!("No existing output detected");
|
||||
|
||||
// Try to attach to parent console for direct cargo run
|
||||
attach_to_parent_console()?;
|
||||
@@ -46,7 +46,7 @@ pub fn init_console() -> Result<(), PlatformError> {
|
||||
}
|
||||
|
||||
// Now that console is initialized, flush buffered logs and switch to direct output
|
||||
debug!("Switching to direct logging mode and flushing buffer...");
|
||||
trace!("Switching to direct logging mode and flushing buffer...");
|
||||
if let Err(error) = switchable_writer.switch_to_direct_mode() {
|
||||
use tracing::warn;
|
||||
|
||||
@@ -79,7 +79,7 @@ pub fn rng() -> ThreadRng {
|
||||
/// Windows-only
|
||||
#[cfg(windows)]
|
||||
fn is_output_setup() -> Result<Option<&'static str>, PlatformError> {
|
||||
use tracing::{debug, warn};
|
||||
use tracing::{trace, warn};
|
||||
|
||||
use windows::Win32::Storage::FileSystem::{
|
||||
GetFileType, FILE_TYPE_CHAR, FILE_TYPE_DISK, FILE_TYPE_PIPE, FILE_TYPE_REMOTE, FILE_TYPE_UNKNOWN,
|
||||
@@ -114,7 +114,7 @@ fn is_output_setup() -> Result<Option<&'static str>, PlatformError> {
|
||||
}
|
||||
};
|
||||
|
||||
debug!("File type: {file_type:?}, well known: {well_known}");
|
||||
trace!("File type: {file_type:?}, well known: {well_known}");
|
||||
|
||||
// If it's anything recognizable and valid, assume that a parent process has setup an output stream
|
||||
Ok(well_known.then_some(file_type))
|
||||
|
||||
@@ -11,11 +11,8 @@ use std::io::{self, Read, Write};
|
||||
use std::time::Duration;
|
||||
|
||||
// Emscripten FFI functions
|
||||
#[allow(dead_code)]
|
||||
extern "C" {
|
||||
fn emscripten_sleep(ms: u32);
|
||||
fn emscripten_get_element_css_size(target: *const u8, width: *mut f64, height: *mut f64) -> i32;
|
||||
// Standard C functions that Emscripten redirects to console
|
||||
fn printf(format: *const u8, ...) -> i32;
|
||||
}
|
||||
|
||||
@@ -65,20 +62,6 @@ impl Write for EmscriptenConsoleWriter {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_canvas_size() -> Option<(u32, u32)> {
|
||||
let mut width = 0.0;
|
||||
let mut height = 0.0;
|
||||
|
||||
unsafe {
|
||||
emscripten_get_element_css_size(c"canvas".as_ptr().cast(), &mut width, &mut height);
|
||||
if width == 0.0 || height == 0.0 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some((width as u32, height as u32))
|
||||
}
|
||||
|
||||
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
|
||||
let path = format!("assets/game/{}", asset.path());
|
||||
let mut rwops = RWops::from_file(&path, "rb").map_err(|_| AssetError::NotFound(asset.path().to_string()))?;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#![allow(dead_code)]
|
||||
//! Buffered tracing setup for handling logs before console attachment.
|
||||
|
||||
use crate::formatter::CustomFormatter;
|
||||
|
||||
132
src/systems/animation.rs
Normal file
132
src/systems/animation.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
use bevy_ecs::{
|
||||
component::Component,
|
||||
query::{Has, Or, With, Without},
|
||||
resource::Resource,
|
||||
system::{Query, Res},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
systems::{DeltaTime, Dying, Frozen, Position, Renderable, Velocity},
|
||||
texture::animated::{DirectionalTiles, TileSequence},
|
||||
};
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use bevy_ecs::{
|
||||
resource::Resource,
|
||||
system::{NonSendMut, ResMut},
|
||||
};
|
||||
use tracing::{debug, trace};
|
||||
|
||||
use crate::{audio::Audio, error::GameError};
|
||||
|
||||
@@ -49,6 +50,7 @@ pub fn audio_system(
|
||||
) {
|
||||
// Set mute state if it has changed
|
||||
if audio.0.is_muted() != audio_state.muted {
|
||||
debug!(muted = audio_state.muted, "Audio mute state changed");
|
||||
audio.0.set_mute(audio_state.muted);
|
||||
}
|
||||
|
||||
@@ -57,20 +59,37 @@ pub fn audio_system(
|
||||
match event {
|
||||
AudioEvent::PlayEat => {
|
||||
if !audio.0.is_disabled() && !audio_state.muted {
|
||||
trace!(sound_index = audio_state.sound_index, "Playing eat sound");
|
||||
audio.0.eat();
|
||||
// Update the sound index for cycling through sounds
|
||||
audio_state.sound_index = (audio_state.sound_index + 1) % 4;
|
||||
// 4 eat sounds available
|
||||
} else {
|
||||
debug!(
|
||||
disabled = audio.0.is_disabled(),
|
||||
muted = audio_state.muted,
|
||||
"Skipping eat sound due to audio state"
|
||||
);
|
||||
}
|
||||
}
|
||||
AudioEvent::PlayDeath => {
|
||||
if !audio.0.is_disabled() && !audio_state.muted {
|
||||
trace!("Playing death sound");
|
||||
audio.0.death();
|
||||
} else {
|
||||
debug!(
|
||||
disabled = audio.0.is_disabled(),
|
||||
muted = audio_state.muted,
|
||||
"Skipping death sound due to audio state"
|
||||
);
|
||||
}
|
||||
}
|
||||
AudioEvent::StopAll => {
|
||||
if !audio.0.is_disabled() {
|
||||
debug!("Stopping all audio");
|
||||
audio.0.stop_all();
|
||||
} else {
|
||||
debug!("Audio disabled, ignoring stop all request");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,7 @@ use bevy_ecs::{
|
||||
system::{Commands, Query, Res},
|
||||
};
|
||||
|
||||
use crate::systems::{
|
||||
components::{DeltaTime, Renderable},
|
||||
Frozen, Hidden,
|
||||
};
|
||||
use crate::systems::{DeltaTime, Frozen, Hidden, Renderable};
|
||||
|
||||
#[derive(Component, Debug)]
|
||||
pub struct Blinking {
|
||||
|
||||
@@ -3,16 +3,14 @@ use bevy_ecs::{
|
||||
entity::Entity,
|
||||
event::{EventReader, EventWriter},
|
||||
query::With,
|
||||
system::{Commands, Query, Res, ResMut},
|
||||
system::{Commands, Query, Res, ResMut, Single},
|
||||
};
|
||||
use tracing::{debug, trace, warn};
|
||||
|
||||
use crate::error::GameError;
|
||||
use crate::events::GameEvent;
|
||||
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::systems::{movement::Position, AudioEvent, DyingSequence, Frozen, GameStage, Ghost, PlayerControlled, ScoreResource};
|
||||
use crate::{error::GameError, systems::GhostState};
|
||||
|
||||
/// A component for defining the collision area of an entity.
|
||||
#[derive(Component)]
|
||||
@@ -82,6 +80,7 @@ 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));
|
||||
}
|
||||
}
|
||||
@@ -99,6 +98,7 @@ pub fn collision_system(
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -117,9 +117,10 @@ pub fn collision_system(
|
||||
pub fn ghost_collision_system(
|
||||
mut commands: Commands,
|
||||
mut collision_events: EventReader<GameEvent>,
|
||||
mut stage_events: EventWriter<StageTransition>,
|
||||
mut score: ResMut<ScoreResource>,
|
||||
mut game_state: ResMut<GameStage>,
|
||||
pacman_query: Query<Entity, With<PlayerControlled>>,
|
||||
player: Single<Entity, With<PlayerControlled>>,
|
||||
ghost_query: Query<(Entity, &Ghost), With<GhostCollider>>,
|
||||
mut ghost_state_query: Query<&mut GhostState>,
|
||||
mut events: EventWriter<AudioEvent>,
|
||||
@@ -127,9 +128,9 @@ pub fn ghost_collision_system(
|
||||
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() {
|
||||
let (pacman_entity, ghost_entity) = if *entity1 == *player && ghost_query.get(*entity2).is_ok() {
|
||||
(*entity1, *entity2)
|
||||
} else if pacman_query.get(*entity2).is_ok() && ghost_query.get(*entity1).is_ok() {
|
||||
} else if *entity2 == *player && ghost_query.get(*entity1).is_ok() {
|
||||
(*entity2, *entity1)
|
||||
} else {
|
||||
continue;
|
||||
@@ -137,24 +138,29 @@ pub fn ghost_collision_system(
|
||||
|
||||
// Check if the ghost is frightened
|
||||
if let Ok((ghost_ent, _ghost_type)) = ghost_query.get(ghost_entity) {
|
||||
if let Ok(mut ghost_state) = ghost_state_query.get_mut(ghost_ent) {
|
||||
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;
|
||||
|
||||
// Set ghost state to Eyes
|
||||
*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_ent });
|
||||
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)]
|
||||
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,403 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bevy_ecs::{bundle::Bundle, component::Component, resource::Resource};
|
||||
use bitflags::bitflags;
|
||||
|
||||
use crate::{
|
||||
map::graph::TraversalFlags,
|
||||
systems::{
|
||||
movement::{BufferedDirection, Position, Velocity},
|
||||
Collider, GhostCollider, ItemCollider, PacmanCollider,
|
||||
},
|
||||
texture::{
|
||||
animated::{DirectionalTiles, TileSequence},
|
||||
sprite::AtlasTile,
|
||||
},
|
||||
};
|
||||
|
||||
/// A tag component for entities that are controlled by the player.
|
||||
#[derive(Default, Component)]
|
||||
pub struct PlayerControlled;
|
||||
|
||||
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Ghost {
|
||||
Blinky,
|
||||
Pinky,
|
||||
Inky,
|
||||
Clyde,
|
||||
}
|
||||
|
||||
impl Ghost {
|
||||
/// Returns the ghost type name for atlas lookups.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Ghost::Blinky => "blinky",
|
||||
Ghost::Pinky => "pinky",
|
||||
Ghost::Inky => "inky",
|
||||
Ghost::Clyde => "clyde",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the base movement speed for this ghost type.
|
||||
pub fn base_speed(self) -> f32 {
|
||||
match self {
|
||||
Ghost::Blinky => 1.0,
|
||||
Ghost::Pinky => 0.95,
|
||||
Ghost::Inky => 0.9,
|
||||
Ghost::Clyde => 0.85,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the ghost's color for debug rendering.
|
||||
#[allow(dead_code)]
|
||||
pub fn debug_color(&self) -> sdl2::pixels::Color {
|
||||
match self {
|
||||
Ghost::Blinky => sdl2::pixels::Color::RGB(255, 0, 0), // Red
|
||||
Ghost::Pinky => sdl2::pixels::Color::RGB(255, 182, 255), // Pink
|
||||
Ghost::Inky => sdl2::pixels::Color::RGB(0, 255, 255), // Cyan
|
||||
Ghost::Clyde => sdl2::pixels::Color::RGB(255, 182, 85), // Orange
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A tag component denoting the type of entity.
|
||||
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum EntityType {
|
||||
Player,
|
||||
Ghost,
|
||||
Pellet,
|
||||
PowerPellet,
|
||||
}
|
||||
|
||||
impl EntityType {
|
||||
/// Returns the traversal flags for this entity type.
|
||||
pub fn traversal_flags(&self) -> TraversalFlags {
|
||||
match self {
|
||||
EntityType::Player => TraversalFlags::PACMAN,
|
||||
EntityType::Ghost => TraversalFlags::GHOST,
|
||||
_ => TraversalFlags::empty(), // Static entities don't traverse
|
||||
}
|
||||
}
|
||||
pub fn score_value(&self) -> Option<u32> {
|
||||
match self {
|
||||
EntityType::Pellet => Some(10),
|
||||
EntityType::PowerPellet => Some(50),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_collectible(&self) -> bool {
|
||||
matches!(self, EntityType::Pellet | EntityType::PowerPellet)
|
||||
}
|
||||
}
|
||||
|
||||
/// A component for entities that have a sprite, with a layer for ordering.
|
||||
///
|
||||
/// This is intended to be modified by other entities allowing animation.
|
||||
#[derive(Component)]
|
||||
pub struct Renderable {
|
||||
pub sprite: AtlasTile,
|
||||
pub layer: u8,
|
||||
}
|
||||
|
||||
/// Directional animation component with shared timing across all directions
|
||||
#[derive(Component, Clone)]
|
||||
pub struct DirectionalAnimation {
|
||||
pub moving_tiles: DirectionalTiles,
|
||||
pub stopped_tiles: DirectionalTiles,
|
||||
pub current_frame: usize,
|
||||
pub time_bank: u16,
|
||||
pub frame_duration: u16,
|
||||
}
|
||||
|
||||
impl DirectionalAnimation {
|
||||
/// Creates a new directional animation with the given tiles and frame duration
|
||||
pub fn new(moving_tiles: DirectionalTiles, stopped_tiles: DirectionalTiles, frame_duration: u16) -> Self {
|
||||
Self {
|
||||
moving_tiles,
|
||||
stopped_tiles,
|
||||
current_frame: 0,
|
||||
time_bank: 0,
|
||||
frame_duration,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tag component to mark animations that should loop when they reach the end
|
||||
#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Looping;
|
||||
|
||||
/// Linear animation component for non-directional animations (frightened ghosts)
|
||||
#[derive(Component, Resource, Clone)]
|
||||
pub struct LinearAnimation {
|
||||
pub tiles: TileSequence,
|
||||
pub current_frame: usize,
|
||||
pub time_bank: u16,
|
||||
pub frame_duration: u16,
|
||||
pub finished: bool,
|
||||
}
|
||||
|
||||
impl LinearAnimation {
|
||||
/// Creates a new linear animation with the given tiles and frame duration
|
||||
pub fn new(tiles: TileSequence, frame_duration: u16) -> Self {
|
||||
Self {
|
||||
tiles,
|
||||
current_frame: 0,
|
||||
time_bank: 0,
|
||||
frame_duration,
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bitflags! {
|
||||
#[derive(Component, Default, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct CollisionLayer: u8 {
|
||||
const PACMAN = 1 << 0;
|
||||
const GHOST = 1 << 1;
|
||||
const ITEM = 1 << 2;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
pub struct GlobalState {
|
||||
pub exit: bool,
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
pub struct ScoreResource(pub u32);
|
||||
|
||||
#[derive(Resource)]
|
||||
pub struct DeltaTime {
|
||||
/// Floating-point delta time in seconds
|
||||
pub seconds: f32,
|
||||
/// Integer tick delta (usually 1, but can be different for testing)
|
||||
pub ticks: u32,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl DeltaTime {
|
||||
/// Creates a new DeltaTime from a floating-point delta time in seconds
|
||||
///
|
||||
/// While this method exists as a helper, it does not mean that seconds and ticks are interchangeable.
|
||||
pub fn from_seconds(seconds: f32) -> Self {
|
||||
Self {
|
||||
seconds,
|
||||
ticks: (seconds * 60.0).round() as u32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new DeltaTime from an integer tick delta
|
||||
///
|
||||
/// While this method exists as a helper, it does not mean that seconds and ticks are interchangeable.
|
||||
pub fn from_ticks(ticks: u32) -> Self {
|
||||
Self {
|
||||
seconds: ticks as f32 / 60.0,
|
||||
ticks,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Movement modifiers that can affect Pac-Man's speed or handling.
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct MovementModifiers {
|
||||
/// Multiplier applied to base speed (e.g., tunnels)
|
||||
pub speed_multiplier: f32,
|
||||
/// True when currently in a tunnel slowdown region
|
||||
pub tunnel_slowdown_active: bool,
|
||||
}
|
||||
|
||||
impl Default for MovementModifiers {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
speed_multiplier: 1.0,
|
||||
tunnel_slowdown_active: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tag component for entities that should be frozen during startup
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct Frozen;
|
||||
|
||||
/// Tag component for eaten ghosts
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct Eaten;
|
||||
|
||||
/// Tag component for Pac-Man during his death animation.
|
||||
/// This is mainly because the Frozen tag would stop both movement and animation, while the Dying tag can signal that the animation should continue despite being frozen.
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct Dying;
|
||||
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub enum GhostState {
|
||||
/// Normal ghost behavior - chasing Pac-Man
|
||||
Normal,
|
||||
/// Frightened state after power pellet - ghost can be eaten
|
||||
Frightened {
|
||||
remaining_ticks: u32,
|
||||
flash: bool,
|
||||
remaining_flash_ticks: u32,
|
||||
},
|
||||
/// Eyes state - ghost has been eaten and is returning to ghost house
|
||||
Eyes,
|
||||
}
|
||||
|
||||
/// Component to track the last animation state for efficient change detection
|
||||
#[derive(Component, Debug, Clone, Copy, PartialEq)]
|
||||
pub struct LastAnimationState(pub GhostAnimation);
|
||||
|
||||
impl GhostState {
|
||||
/// Creates a new frightened state with the specified duration
|
||||
pub fn new_frightened(total_ticks: u32, flash_start_ticks: u32) -> Self {
|
||||
Self::Frightened {
|
||||
remaining_ticks: total_ticks,
|
||||
flash: false,
|
||||
remaining_flash_ticks: flash_start_ticks, // Time until flashing starts
|
||||
}
|
||||
}
|
||||
|
||||
/// Ticks the ghost state, returning true if the state changed.
|
||||
pub fn tick(&mut self) -> bool {
|
||||
if let GhostState::Frightened {
|
||||
remaining_ticks,
|
||||
flash,
|
||||
remaining_flash_ticks,
|
||||
} = self
|
||||
{
|
||||
// Transition out of frightened state
|
||||
if *remaining_ticks == 0 {
|
||||
*self = GhostState::Normal;
|
||||
return true;
|
||||
}
|
||||
|
||||
*remaining_ticks -= 1;
|
||||
|
||||
if *remaining_flash_ticks > 0 {
|
||||
*remaining_flash_ticks = remaining_flash_ticks.saturating_sub(1);
|
||||
if *remaining_flash_ticks == 0 {
|
||||
*flash = true;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the appropriate animation state for this ghost state
|
||||
pub fn animation_state(&self) -> GhostAnimation {
|
||||
match self {
|
||||
GhostState::Normal => GhostAnimation::Normal,
|
||||
GhostState::Eyes => GhostAnimation::Eyes,
|
||||
GhostState::Frightened { flash: false, .. } => GhostAnimation::Frightened { flash: false },
|
||||
GhostState::Frightened { flash: true, .. } => GhostAnimation::Frightened { flash: true },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enumeration of different ghost animation states.
|
||||
/// Note that this is used in micromap which has a fixed size based on the number of variants,
|
||||
/// so extending this should be done with caution, and will require updating the micromap's capacity.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum GhostAnimation {
|
||||
/// Normal ghost appearance with directional movement animations
|
||||
Normal,
|
||||
/// Blue ghost appearance when vulnerable (power pellet active)
|
||||
Frightened { flash: bool },
|
||||
/// Eyes-only animation when ghost has been consumed by Pac-Man (Eaten state)
|
||||
Eyes,
|
||||
}
|
||||
|
||||
/// Global resource containing pre-loaded animation sets for all ghost types.
|
||||
///
|
||||
/// This resource is initialized once during game startup and provides O(1) access
|
||||
/// to animation sets for each ghost type. The animation system uses this resource
|
||||
/// to efficiently switch between different ghost states without runtime asset loading.
|
||||
///
|
||||
/// The HashMap is keyed by `Ghost` enum variants (Blinky, Pinky, Inky, Clyde) and
|
||||
/// contains the normal directional animation for each ghost type.
|
||||
#[derive(Resource)]
|
||||
pub struct GhostAnimations {
|
||||
pub normal: HashMap<Ghost, DirectionalAnimation>,
|
||||
pub eyes: DirectionalAnimation,
|
||||
pub frightened: LinearAnimation,
|
||||
pub frightened_flashing: LinearAnimation,
|
||||
}
|
||||
|
||||
impl GhostAnimations {
|
||||
/// Creates a new GhostAnimations resource with the provided data.
|
||||
pub fn new(
|
||||
normal: HashMap<Ghost, DirectionalAnimation>,
|
||||
eyes: DirectionalAnimation,
|
||||
frightened: LinearAnimation,
|
||||
frightened_flashing: LinearAnimation,
|
||||
) -> Self {
|
||||
Self {
|
||||
normal,
|
||||
eyes,
|
||||
frightened,
|
||||
frightened_flashing,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the normal directional animation for the specified ghost type.
|
||||
pub fn get_normal(&self, ghost_type: &Ghost) -> Option<&DirectionalAnimation> {
|
||||
self.normal.get(ghost_type)
|
||||
}
|
||||
|
||||
/// Gets the eyes animation (shared across all ghosts).
|
||||
pub fn eyes(&self) -> &DirectionalAnimation {
|
||||
&self.eyes
|
||||
}
|
||||
|
||||
/// Gets the frightened animations (shared across all ghosts).
|
||||
pub fn frightened(&self, flash: bool) -> &LinearAnimation {
|
||||
if flash {
|
||||
&self.frightened_flashing
|
||||
} else {
|
||||
&self.frightened
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Bundle)]
|
||||
pub struct PlayerBundle {
|
||||
pub player: PlayerControlled,
|
||||
pub position: Position,
|
||||
pub velocity: Velocity,
|
||||
pub buffered_direction: BufferedDirection,
|
||||
pub sprite: Renderable,
|
||||
pub directional_animation: DirectionalAnimation,
|
||||
pub entity_type: EntityType,
|
||||
pub collider: Collider,
|
||||
pub movement_modifiers: MovementModifiers,
|
||||
pub pacman_collider: PacmanCollider,
|
||||
}
|
||||
|
||||
#[derive(Bundle)]
|
||||
pub struct ItemBundle {
|
||||
pub position: Position,
|
||||
pub sprite: Renderable,
|
||||
pub entity_type: EntityType,
|
||||
pub collider: Collider,
|
||||
pub item_collider: ItemCollider,
|
||||
}
|
||||
|
||||
#[derive(Bundle)]
|
||||
pub struct GhostBundle {
|
||||
pub ghost: Ghost,
|
||||
pub position: Position,
|
||||
pub velocity: Velocity,
|
||||
pub sprite: Renderable,
|
||||
pub directional_animation: DirectionalAnimation,
|
||||
pub entity_type: EntityType,
|
||||
pub collider: Collider,
|
||||
pub ghost_collider: GhostCollider,
|
||||
pub ghost_state: GhostState,
|
||||
pub last_animation_state: LastAnimationState,
|
||||
}
|
||||
@@ -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,17 +9,190 @@ 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 eaten ghosts
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct Eaten;
|
||||
|
||||
/// Tag component for Pac-Man during his death animation.
|
||||
/// This is mainly because the Frozen tag would stop both movement and animation, while the Dying tag can signal that the animation should continue despite being frozen.
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct Dying;
|
||||
|
||||
#[derive(Component, Debug, Clone, Copy, 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)]
|
||||
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>,
|
||||
@@ -45,8 +218,10 @@ pub fn ghost_movement_system(
|
||||
|
||||
let new_edge: Edge = if non_opposite_options.is_empty() {
|
||||
if let Some(edge) = intersection.get(opposite) {
|
||||
trace!(node = current_node, ghost = ?_ghost, direction = ?opposite, "Ghost forced to reverse direction");
|
||||
edge
|
||||
} else {
|
||||
warn!(node = current_node, ghost = ?_ghost, "Ghost stuck with no available directions");
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
@@ -118,6 +293,7 @@ pub fn eaten_ghost_system(
|
||||
// Reached target node, check if we're at ghost house center
|
||||
if to == ghost_house_center {
|
||||
// Respawn the ghost - set state back to normal
|
||||
debug!(ghost = ?ghost_type, "Eaten ghost reached ghost house, respawning as normal");
|
||||
*ghost_state = GhostState::Normal;
|
||||
// Reset to stopped at ghost house center
|
||||
*position = Position::Stopped {
|
||||
@@ -181,6 +357,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,
|
||||
@@ -194,6 +374,7 @@ pub fn ghost_state_system(
|
||||
// Only update animation if the animation state actually changed
|
||||
let current_animation_state = ghost_state.animation_state();
|
||||
if last_animation_state.0 != current_animation_state {
|
||||
trace!(ghost = ?ghost_type, old_state = ?last_animation_state.0, new_state = ?current_animation_state, "Ghost animation state changed");
|
||||
match current_animation_state {
|
||||
GhostAnimation::Frightened { flash } => {
|
||||
// Remove DirectionalAnimation, add LinearAnimation with Looping component
|
||||
@@ -212,6 +393,7 @@ pub fn ghost_state_system(
|
||||
}
|
||||
GhostAnimation::Eyes => {
|
||||
// Remove LinearAnimation and Looping, add DirectionalAnimation (eyes animation)
|
||||
trace!(ghost = ?ghost_type, "Switching to eyes animation for eaten ghost");
|
||||
commands
|
||||
.entity(entity)
|
||||
.remove::<(LinearAnimation, Looping)>()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,74 +1,209 @@
|
||||
use bevy_ecs::{
|
||||
entity::Entity,
|
||||
event::{EventReader, EventWriter},
|
||||
event::{Event, EventReader, EventWriter},
|
||||
observer::Trigger,
|
||||
query::With,
|
||||
system::{Commands, Query, ResMut},
|
||||
system::{Commands, NonSendMut, Query, Res, ResMut, Single},
|
||||
};
|
||||
use strum_macros::IntoStaticStr;
|
||||
use tracing::{debug, trace};
|
||||
|
||||
use crate::{
|
||||
constants,
|
||||
map::builder::Map,
|
||||
systems::{common::bundles::ItemBundle, Collider, Position, Renderable, TimeToLive},
|
||||
texture::{
|
||||
sprite::SpriteAtlas,
|
||||
sprites::{EffectSprite, GameSprite},
|
||||
},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
constants::animation::FRIGHTENED_FLASH_START_TICKS,
|
||||
events::GameEvent,
|
||||
systems::{AudioEvent, EntityType, GhostCollider, GhostState, ItemCollider, PacmanCollider, ScoreResource},
|
||||
systems::common::components::EntityType,
|
||||
systems::{AudioEvent, GhostCollider, GhostState, ItemCollider, PacmanCollider, ScoreResource},
|
||||
};
|
||||
|
||||
/// 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,
|
||||
/// 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 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,
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
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 pellet_count: ResMut<PelletCount>,
|
||||
pacman: Single<Entity, With<PacmanCollider>>,
|
||||
item_query: Query<(Entity, &EntityType, &Position), 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)
|
||||
let (_, item_entity) = if *pacman == *entity1 && item_query.get(*entity2).is_ok() {
|
||||
(*pacman, *entity2)
|
||||
} else if *pacman == *entity2 && item_query.get(*entity1).is_ok() {
|
||||
(*pacman, *entity1)
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Get the item type and update score
|
||||
if let Ok((item_ent, entity_type)) = item_query.get(item_entity) {
|
||||
if let Ok((item_ent, entity_type, position)) = 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;
|
||||
|
||||
// Remove the collected item
|
||||
commands.entity(item_ent).despawn();
|
||||
|
||||
// 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 matches!(*entity_type, EntityType::Fruit(_)) {
|
||||
commands.trigger(SpawnTrigger::Bonus {
|
||||
position: *position,
|
||||
value: entity_type.score_value().unwrap(),
|
||||
ttl: 60 * 2,
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger audio if appropriate
|
||||
if entity_type.is_collectible() {
|
||||
events.write(AudioEvent::PlayEat);
|
||||
}
|
||||
|
||||
// Make ghosts frightened when power pellet is collected
|
||||
if *entity_type == EntityType::PowerPellet {
|
||||
if matches!(*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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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");
|
||||
}
|
||||
|
||||
33
src/systems/lifetime.rs
Normal file
33
src/systems/lifetime.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use bevy_ecs::{
|
||||
component::Component,
|
||||
entity::Entity,
|
||||
system::{Commands, Query, Res},
|
||||
};
|
||||
|
||||
use crate::systems::DeltaTime;
|
||||
|
||||
/// Component for entities that should be automatically deleted after a certain number of ticks
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct TimeToLive {
|
||||
pub remaining_ticks: u32,
|
||||
}
|
||||
|
||||
impl TimeToLive {
|
||||
pub fn new(ticks: u32) -> Self {
|
||||
Self { remaining_ticks: ticks }
|
||||
}
|
||||
}
|
||||
|
||||
/// System that manages entities with TimeToLive components, decrementing their remaining ticks
|
||||
/// and despawning them when they expire
|
||||
pub fn time_to_live_system(mut commands: Commands, dt: Res<DeltaTime>, mut query: Query<(Entity, &mut TimeToLive)>) {
|
||||
for (entity, mut ttl) in query.iter_mut() {
|
||||
if ttl.remaining_ticks <= dt.ticks {
|
||||
// Entity has expired, despawn it
|
||||
commands.entity(entity).despawn();
|
||||
} else {
|
||||
// Decrement remaining time
|
||||
ttl.remaining_ticks = ttl.remaining_ticks.saturating_sub(dt.ticks);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,26 +10,30 @@ pub mod profiling;
|
||||
#[cfg_attr(coverage_nightly, coverage(off))]
|
||||
pub mod render;
|
||||
|
||||
pub mod animation;
|
||||
pub mod blinking;
|
||||
pub mod collision;
|
||||
pub mod components;
|
||||
pub mod common;
|
||||
pub mod ghost;
|
||||
pub mod input;
|
||||
pub mod item;
|
||||
pub mod lifetime;
|
||||
pub mod movement;
|
||||
pub mod player;
|
||||
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::input::*;
|
||||
pub use self::item::*;
|
||||
pub use self::lifetime::*;
|
||||
pub use self::movement::*;
|
||||
pub use self::player::*;
|
||||
pub use self::profiling::*;
|
||||
|
||||
@@ -1,21 +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)
|
||||
@@ -27,35 +32,27 @@ 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;
|
||||
}
|
||||
};
|
||||
|
||||
*buffered_direction = BufferedDirection::Some {
|
||||
direction: *direction,
|
||||
remaining_time: 0.25,
|
||||
};
|
||||
// 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");
|
||||
***player_single = BufferedDirection::Some {
|
||||
direction: *direction,
|
||||
remaining_time: 0.25,
|
||||
};
|
||||
}
|
||||
}
|
||||
GameCommand::Exit => {
|
||||
state.exit = true;
|
||||
@@ -86,6 +83,7 @@ pub fn player_movement_system(
|
||||
(&MovementModifiers, &mut Position, &mut Velocity, &mut BufferedDirection),
|
||||
(With<PlayerControlled>, Without<Frozen>),
|
||||
>,
|
||||
mut last_stopped_node: bevy_ecs::system::Local<Option<crate::systems::movement::NodeId>>,
|
||||
) {
|
||||
for (modifiers, mut position, mut velocity, mut buffered_direction) in entities.iter_mut() {
|
||||
// Decrement the buffered direction remaining time
|
||||
@@ -95,6 +93,7 @@ pub fn player_movement_system(
|
||||
} = *buffered_direction
|
||||
{
|
||||
if remaining_time <= 0.0 {
|
||||
trace!("Buffered direction expired");
|
||||
*buffered_direction = BufferedDirection::None;
|
||||
} else {
|
||||
*buffered_direction = BufferedDirection::Some {
|
||||
@@ -115,6 +114,8 @@ pub fn player_movement_system(
|
||||
if let Some(edge) = map.graph.find_edge_in_direction(position.current_node(), direction) {
|
||||
// If there is an edge in that direction (and it's traversable), start moving towards it and consume the buffered direction.
|
||||
if can_traverse(EntityType::Player, edge) {
|
||||
trace!(from = position.current_node(), to = edge.target, direction = ?direction, "Player started moving using buffered direction");
|
||||
*last_stopped_node = None; // Reset stopped state when starting to move
|
||||
velocity.direction = edge.direction;
|
||||
*position = Position::Moving {
|
||||
from: position.current_node(),
|
||||
@@ -129,6 +130,8 @@ pub fn player_movement_system(
|
||||
// If there is no buffered direction (or it's not yet valid), continue in the current direction.
|
||||
if let Some(edge) = map.graph.find_edge_in_direction(position.current_node(), velocity.direction) {
|
||||
if can_traverse(EntityType::Player, edge) {
|
||||
trace!(from = position.current_node(), to = edge.target, direction = ?velocity.direction, "Player continued in current direction");
|
||||
*last_stopped_node = None; // Reset stopped state when starting to move
|
||||
velocity.direction = edge.direction;
|
||||
*position = Position::Moving {
|
||||
from: position.current_node(),
|
||||
@@ -138,6 +141,11 @@ pub fn player_movement_system(
|
||||
}
|
||||
} else {
|
||||
// No edge in our current direction either, erase the buffered direction and stop.
|
||||
let current_node = position.current_node();
|
||||
if *last_stopped_node != Some(current_node) {
|
||||
trace!(node = current_node, direction = ?velocity.direction, "Player stopped - no valid edge in current direction");
|
||||
*last_stopped_node = Some(current_node);
|
||||
}
|
||||
*buffered_direction = BufferedDirection::None;
|
||||
break;
|
||||
}
|
||||
@@ -155,14 +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);
|
||||
modifiers.tunnel_slowdown_active = in_tunnel;
|
||||
modifiers.speed_multiplier = if in_tunnel { 0.6 } else { 1.0 };
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -157,6 +162,7 @@ pub enum SystemId {
|
||||
Stage,
|
||||
GhostStateAnimation,
|
||||
EatenGhost,
|
||||
TimeToLive,
|
||||
}
|
||||
|
||||
impl Display for SystemId {
|
||||
@@ -247,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,29 +1,40 @@
|
||||
use crate::map::builder::Map;
|
||||
use crate::systems::input::TouchState;
|
||||
use crate::map::direction::Direction;
|
||||
use crate::systems::{
|
||||
debug_render_system, BatchedLinesResource, Collider, CursorPosition, DebugState, DebugTextureResource, DeltaTime,
|
||||
DirectionalAnimation, Dying, Frozen, GameStage, LinearAnimation, Looping, PlayerLives, Position, Renderable, ScoreResource,
|
||||
StartupSequence, SystemId, SystemTimings, TtfAtlasResource, Velocity,
|
||||
debug_render_system, BatchedLinesResource, Collider, CursorPosition, DebugState, DebugTextureResource, GameStage, PlayerLife,
|
||||
PlayerLives, Position, ScoreResource, StartupSequence, SystemId, SystemTimings, TouchState, TtfAtlasResource,
|
||||
};
|
||||
use crate::texture::sprite::SpriteAtlas;
|
||||
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
|
||||
use crate::texture::sprites::{GameSprite, PacmanSprite};
|
||||
use crate::texture::text::TextTexture;
|
||||
use crate::{
|
||||
constants::CANVAS_SIZE,
|
||||
constants::{BOARD_BOTTOM_PIXEL_OFFSET, CANVAS_SIZE, CELL_SIZE},
|
||||
error::{GameError, TextureError},
|
||||
};
|
||||
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, Without};
|
||||
use bevy_ecs::removal_detection::RemovedComponents;
|
||||
use bevy_ecs::resource::Resource;
|
||||
use bevy_ecs::system::{NonSendMut, Query, Res, ResMut};
|
||||
use bevy_ecs::system::{Commands, 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::cmp::Ordering;
|
||||
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);
|
||||
|
||||
@@ -44,80 +55,96 @@ pub fn dirty_render_system(
|
||||
removed_hidden: RemovedComponents<Hidden>,
|
||||
removed_renderables: RemovedComponents<Renderable>,
|
||||
) {
|
||||
if !changed.is_empty() || !removed_hidden.is_empty() || !removed_renderables.is_empty() {
|
||||
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 {
|
||||
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>>,
|
||||
/// 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 ticks = (dt.seconds * 60.0).round() as u16; // Convert from seconds to ticks at 60 ticks/sec
|
||||
let displayed_lives = player_lives.0.saturating_sub(1);
|
||||
|
||||
for (position, velocity, mut anim, mut renderable) in query.iter_mut() {
|
||||
let stopped = matches!(position, Position::Stopped { .. });
|
||||
// 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;
|
||||
|
||||
// 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;
|
||||
// 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();
|
||||
|
||||
// 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;
|
||||
for entity in sprites_to_remove {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
/// Component for Renderables to store an exact pixel position
|
||||
#[derive(Component)]
|
||||
pub struct PixelPosition {
|
||||
pub pixel_position: Vec2,
|
||||
}
|
||||
|
||||
anim.time_bank += dt.ticks as u16;
|
||||
let frames_to_advance = (anim.time_bank / anim.frame_duration) as usize;
|
||||
/// 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
|
||||
|
||||
if frames_to_advance == 0 {
|
||||
continue;
|
||||
}
|
||||
let x = start_x + ((index as f32) * (sprite_spacing as f32 * 1.5)).round() as u32;
|
||||
let y = start_y - CELL_SIZE / 2;
|
||||
|
||||
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);
|
||||
}
|
||||
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.
|
||||
@@ -200,11 +227,11 @@ pub fn touch_ui_render_system(
|
||||
}
|
||||
|
||||
/// Renders the HUD (score, lives, etc.) on top of the game.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn hud_render_system(
|
||||
mut backbuffer: NonSendMut<BackbufferResource>,
|
||||
mut canvas: NonSendMut<&mut Canvas<Window>>,
|
||||
mut atlas: NonSendMut<SpriteAtlas>,
|
||||
player_lives: Res<PlayerLives>,
|
||||
score: Res<ScoreResource>,
|
||||
stage: Res<GameStage>,
|
||||
mut errors: EventWriter<GameError>,
|
||||
@@ -213,11 +240,10 @@ pub fn hud_render_system(
|
||||
let mut text_renderer = TextTexture::new(1.0);
|
||||
|
||||
// Render lives and high score text in white
|
||||
let lives = player_lives.0;
|
||||
let lives_text = format!("{lives}UP HIGH SCORE ");
|
||||
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) {
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -277,13 +303,17 @@ pub fn hud_render_system(
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn render_system(
|
||||
canvas: &mut Canvas<Window>,
|
||||
map_texture: &NonSendMut<MapTextureResource>,
|
||||
atlas: &mut SpriteAtlas,
|
||||
map: &Res<Map>,
|
||||
dirty: &Res<RenderDirty>,
|
||||
renderables: &Query<(Entity, &Renderable, &Position), Without<Hidden>>,
|
||||
renderables: &Query<
|
||||
(Entity, &Renderable, Option<&Position>, Option<&PixelPosition>),
|
||||
(Without<Hidden>, Or<(With<Position>, With<PixelPosition>)>),
|
||||
>,
|
||||
errors: &mut EventWriter<GameError>,
|
||||
) {
|
||||
if !dirty.0 {
|
||||
@@ -300,12 +330,21 @@ pub fn render_system(
|
||||
}
|
||||
|
||||
// Render all entities to the backbuffer
|
||||
for (_, renderable, position) in renderables
|
||||
for (_entity, renderable, position, pixel_position) in renderables
|
||||
.iter()
|
||||
.sort_by_key::<(Entity, &Renderable, &Position), _>(|(_, renderable, _)| renderable.layer)
|
||||
.sort_by_key::<(Entity, &Renderable, Option<&Position>, Option<&PixelPosition>), _>(|(_, renderable, _, _)| {
|
||||
renderable.layer
|
||||
})
|
||||
.rev()
|
||||
{
|
||||
let pos = position.get_pixel_position(&map.graph);
|
||||
let pos = if let Some(position) = position {
|
||||
position.get_pixel_position(&map.graph)
|
||||
} else {
|
||||
Ok(pixel_position
|
||||
.expect("Pixel position should be present via query filtering, but got None on both")
|
||||
.pixel_position)
|
||||
};
|
||||
|
||||
match pos {
|
||||
Ok(pos) => {
|
||||
let dest = Rect::from_center(
|
||||
@@ -330,6 +369,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>,
|
||||
@@ -343,7 +383,10 @@ pub fn combined_render_system(
|
||||
timing: Res<crate::systems::profiling::Timing>,
|
||||
map: Res<Map>,
|
||||
dirty: Res<RenderDirty>,
|
||||
renderables: Query<(Entity, &Renderable, &Position), Without<Hidden>>,
|
||||
renderables: Query<
|
||||
(Entity, &Renderable, Option<&Position>, Option<&PixelPosition>),
|
||||
(Without<Hidden>, Or<(With<Position>, With<PixelPosition>)>),
|
||||
>,
|
||||
colliders: Query<(&Collider, &Position)>,
|
||||
cursor: Res<CursorPosition>,
|
||||
mut errors: EventWriter<GameError>,
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
use std::mem::discriminant;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::events::StageTransition;
|
||||
use crate::systems::SpawnTrigger;
|
||||
use crate::{
|
||||
map::builder::Map,
|
||||
systems::{
|
||||
AudioEvent, Blinking, DirectionalAnimation, Dying, Eaten, Frozen, Ghost, GhostCollider, GhostState, Hidden,
|
||||
LinearAnimation, Looping, PlayerControlled, Position,
|
||||
LinearAnimation, Looping, NodeId, PlayerControlled, Position,
|
||||
},
|
||||
};
|
||||
use bevy_ecs::{
|
||||
entity::Entity,
|
||||
event::EventWriter,
|
||||
event::{EventReader, EventWriter},
|
||||
query::{With, Without},
|
||||
resource::Resource,
|
||||
system::{Commands, Query, Res, ResMut},
|
||||
system::{Commands, Query, Res, ResMut, Single},
|
||||
};
|
||||
|
||||
#[derive(Resource, Clone)]
|
||||
@@ -27,6 +30,12 @@ pub enum GameStage {
|
||||
Starting(StartupSequence),
|
||||
/// The main gameplay loop is active.
|
||||
Playing,
|
||||
/// Short freeze after Pac-Man eats a ghost to display bonus score
|
||||
GhostEatenPause {
|
||||
remaining_ticks: u32,
|
||||
ghost_entity: Entity,
|
||||
node: NodeId,
|
||||
},
|
||||
/// The player has died and the death sequence is in progress.
|
||||
PlayerDying(DyingSequence),
|
||||
/// The level is restarting after a death.
|
||||
@@ -93,71 +102,111 @@ pub fn stage_system(
|
||||
map: Res<Map>,
|
||||
mut commands: Commands,
|
||||
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>>,
|
||||
player: Single<(Entity, &mut Position), With<PlayerControlled>>,
|
||||
mut ghost_query: Query<(Entity, &Ghost, &mut Position), (With<GhostCollider>, Without<PlayerControlled>)>,
|
||||
) {
|
||||
let old_state = *game_state;
|
||||
let new_state: GameStage = match &mut *game_state {
|
||||
let mut new_state: Option<GameStage> = None;
|
||||
|
||||
// Handle stage transition requests before normal ticking
|
||||
for event in stage_event_reader.read() {
|
||||
let StageTransition::GhostEatenPause { ghost_entity } = *event;
|
||||
let pac_node = player.1.current_node();
|
||||
|
||||
debug!(ghost_entity = ?ghost_entity, node = pac_node, "Ghost eaten, entering pause state");
|
||||
new_state = Some(GameStage::GhostEatenPause {
|
||||
remaining_ticks: 30,
|
||||
ghost_entity,
|
||||
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 {
|
||||
if remaining_ticks > 0 {
|
||||
GameStage::Starting(StartupSequence::TextOnly {
|
||||
remaining_ticks: *remaining_ticks - 1,
|
||||
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 {
|
||||
if remaining_ticks > 0 {
|
||||
GameStage::Starting(StartupSequence::CharactersVisible {
|
||||
remaining_ticks: *remaining_ticks - 1,
|
||||
remaining_ticks: remaining_ticks - 1,
|
||||
})
|
||||
} else {
|
||||
info!("Startup sequence completed, beginning gameplay");
|
||||
GameStage::Playing
|
||||
}
|
||||
}
|
||||
},
|
||||
GameStage::Playing => GameStage::Playing,
|
||||
GameStage::GhostEatenPause {
|
||||
remaining_ticks,
|
||||
ghost_entity,
|
||||
node,
|
||||
} => {
|
||||
if remaining_ticks > 0 {
|
||||
GameStage::GhostEatenPause {
|
||||
remaining_ticks: remaining_ticks.saturating_sub(1),
|
||||
ghost_entity,
|
||||
node,
|
||||
}
|
||||
} else {
|
||||
debug!("Ghost eaten pause ended, resuming gameplay");
|
||||
GameStage::Playing
|
||||
}
|
||||
}
|
||||
GameStage::PlayerDying(dying) => match dying {
|
||||
DyingSequence::Frozen { remaining_ticks } => {
|
||||
if *remaining_ticks > 0 {
|
||||
if remaining_ticks > 0 {
|
||||
GameStage::PlayerDying(DyingSequence::Frozen {
|
||||
remaining_ticks: *remaining_ticks - 1,
|
||||
remaining_ticks: remaining_ticks - 1,
|
||||
})
|
||||
} else {
|
||||
let death_animation = &player_death_animation.0;
|
||||
let remaining_ticks = (death_animation.tiles.len() * death_animation.frame_duration as usize) as u32;
|
||||
debug!(animation_frames = remaining_ticks, "Starting player death animation");
|
||||
GameStage::PlayerDying(DyingSequence::Animating { remaining_ticks })
|
||||
}
|
||||
}
|
||||
DyingSequence::Animating { remaining_ticks } => {
|
||||
if *remaining_ticks > 0 {
|
||||
if remaining_ticks > 0 {
|
||||
GameStage::PlayerDying(DyingSequence::Animating {
|
||||
remaining_ticks: *remaining_ticks - 1,
|
||||
remaining_ticks: remaining_ticks - 1,
|
||||
})
|
||||
} else {
|
||||
GameStage::PlayerDying(DyingSequence::Hidden { remaining_ticks: 60 })
|
||||
}
|
||||
}
|
||||
DyingSequence::Hidden { remaining_ticks } => {
|
||||
if *remaining_ticks > 0 {
|
||||
if remaining_ticks > 0 {
|
||||
GameStage::PlayerDying(DyingSequence::Hidden {
|
||||
remaining_ticks: *remaining_ticks - 1,
|
||||
remaining_ticks: remaining_ticks - 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
|
||||
} else {
|
||||
warn!("All lives lost, game over");
|
||||
GameStage::GameOver
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
GameStage::LevelRestarting => GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 }),
|
||||
GameStage::LevelRestarting => {
|
||||
debug!("Level restart complete, returning to startup sequence");
|
||||
GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 })
|
||||
}
|
||||
GameStage::GameOver => GameStage::GameOver,
|
||||
};
|
||||
|
||||
@@ -166,13 +215,39 @@ pub fn stage_system(
|
||||
}
|
||||
|
||||
match (old_state, new_state) {
|
||||
(GameStage::Playing, GameStage::GhostEatenPause { ghost_entity, node, .. }) => {
|
||||
// Freeze the player & ghosts
|
||||
commands.entity(player.0).insert(Frozen);
|
||||
for (entity, _, _) in ghost_query.iter_mut() {
|
||||
commands.entity(entity).insert(Frozen);
|
||||
}
|
||||
|
||||
// Hide the player & eaten ghost
|
||||
commands.entity(player.0).insert(Hidden);
|
||||
commands.entity(ghost_entity).insert(Hidden);
|
||||
|
||||
// Spawn bonus points entity at Pac-Man's position
|
||||
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
|
||||
commands.entity(player.0).remove::<(Frozen, Hidden)>();
|
||||
for (entity, _, _) in ghost_query.iter_mut() {
|
||||
commands.entity(entity).remove::<(Frozen, Hidden)>();
|
||||
}
|
||||
|
||||
// Reveal the eaten ghost and switch it to Eyes state
|
||||
commands.entity(ghost_entity).insert(GhostState::Eyes);
|
||||
}
|
||||
(GameStage::Playing, 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);
|
||||
}
|
||||
}
|
||||
@@ -183,39 +258,32 @@ pub fn stage_system(
|
||||
}
|
||||
|
||||
// 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).insert((Dying, player_death_animation.0.clone()));
|
||||
|
||||
// Play the death sound
|
||||
audio_events.write(AudioEvent::PlayDeath);
|
||||
}
|
||||
(GameStage::PlayerDying(DyingSequence::Animating { .. }), GameStage::PlayerDying(DyingSequence::Hidden { .. })) => {
|
||||
// Hide the player
|
||||
if let Ok((player_entity, _)) = player_query.single_mut() {
|
||||
commands.entity(player_entity).insert(Hidden);
|
||||
}
|
||||
commands.entity(player.0).insert(Hidden);
|
||||
}
|
||||
(_, GameStage::LevelRestarting) => {
|
||||
if let Ok((player_entity, mut pos)) = player_query.single_mut() {
|
||||
*pos = Position::Stopped {
|
||||
node: map.start_positions.pacman,
|
||||
};
|
||||
let (player_entity, mut pos) = player.into_inner();
|
||||
*pos = Position::Stopped {
|
||||
node: map.start_positions.pacman,
|
||||
};
|
||||
|
||||
// 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, Hidden, LinearAnimation, Looping)>()
|
||||
.insert(player_animation.0.clone());
|
||||
// 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());
|
||||
|
||||
// Reset ghost positions and state
|
||||
for (ghost_entity, ghost, mut ghost_pos) in ghost_query.iter_mut() {
|
||||
*ghost_pos = Position::Stopped {
|
||||
@@ -232,27 +300,20 @@ pub fn stage_system(
|
||||
.insert(GhostState::Normal);
|
||||
}
|
||||
}
|
||||
(
|
||||
GameStage::Starting(StartupSequence::TextOnly { .. }),
|
||||
GameStage::Starting(StartupSequence::CharactersVisible { .. }),
|
||||
) => {
|
||||
(_, 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(player.0).remove::<Hidden>();
|
||||
for (entity, _, _) in ghost_query.iter_mut() {
|
||||
commands.entity(entity).remove::<Hidden>();
|
||||
}
|
||||
}
|
||||
(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>();
|
||||
}
|
||||
}
|
||||
@@ -275,41 +336,3 @@ pub fn stage_system(
|
||||
|
||||
*game_state = new_state;
|
||||
}
|
||||
|
||||
// if let GameState::LevelRestarting = &*game_state {
|
||||
// // When restarting, jump straight to the CharactersVisible stage
|
||||
// // and unhide the entities.
|
||||
// *startup = StartupSequence::new(0, 60 * 2); // 2 seconds for READY! text
|
||||
// if let StartupSequence::TextOnly { .. } = *startup {
|
||||
// // This will immediately transition to CharactersVisible on the next line
|
||||
// } else {
|
||||
// // Should be unreachable as we just set it
|
||||
// }
|
||||
|
||||
// // Freeze Pac-Man and ghosts
|
||||
// for entity in player_query.iter().chain(ghost_query.iter()) {
|
||||
// commands.entity(entity).insert(Frozen);
|
||||
// }
|
||||
|
||||
// *game_state = GameState::Playing;
|
||||
// }
|
||||
|
||||
// if let Some((old_state, new_state)) = startup.tick() {
|
||||
// debug!("StartupSequence transition from {old_state:?} to {new_state:?}");
|
||||
// match (old_state, new_state) {
|
||||
// (StartupSequence::TextOnly { .. }, StartupSequence::CharactersVisible { .. }) => {
|
||||
// // Unhide the player & ghosts
|
||||
// for entity in player_query.iter().chain(ghost_query.iter()) {
|
||||
// commands.entity(entity).remove::<Hidden>();
|
||||
// }
|
||||
// }
|
||||
// (StartupSequence::CharactersVisible { .. }, StartupSequence::GameActive) => {
|
||||
// // Unfreeze Pac-Man, ghosts and energizers
|
||||
// for entity in player_query.iter().chain(ghost_query.iter()).chain(blinking_query.iter()) {
|
||||
// commands.entity(entity).remove::<Frozen>();
|
||||
// }
|
||||
// *game_state = GameState::Playing;
|
||||
// }
|
||||
// _ => {}
|
||||
// }
|
||||
// }
|
||||
|
||||
@@ -4,6 +4,7 @@ use sdl2::pixels::Color;
|
||||
use sdl2::rect::Rect;
|
||||
use sdl2::render::{Canvas, RenderTarget, Texture};
|
||||
use std::collections::HashMap;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::error::TextureError;
|
||||
|
||||
@@ -57,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.
|
||||
@@ -90,8 +78,10 @@ pub struct SpriteAtlas {
|
||||
|
||||
impl SpriteAtlas {
|
||||
pub fn new(texture: Texture, mapper: AtlasMapper) -> Self {
|
||||
let tile_count = mapper.frames.len();
|
||||
let tiles = mapper.frames.into_iter().collect();
|
||||
|
||||
debug!(tile_count, "Created sprite atlas");
|
||||
Self {
|
||||
texture,
|
||||
tiles,
|
||||
@@ -107,42 +97,14 @@ impl SpriteAtlas {
|
||||
/// atlas. The returned tile can be used for immediate rendering or stored
|
||||
/// for repeated use in animations and entity sprites.
|
||||
pub fn get_tile(&self, name: &str) -> Result<AtlasTile, TextureError> {
|
||||
let frame = self
|
||||
.tiles
|
||||
.get(name)
|
||||
.ok_or_else(|| TextureError::AtlasTileNotFound(name.to_string()))?;
|
||||
let frame = self.tiles.get(name).ok_or_else(|| {
|
||||
debug!(tile_name = name, "Atlas tile not found");
|
||||
TextureError::AtlasTileNotFound(name.to_string())
|
||||
})?;
|
||||
Ok(AtlasTile {
|
||||
pos: frame.pos,
|
||||
size: frame.size,
|
||||
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;
|
||||
|
||||
@@ -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, Hidden, Renderable};
|
||||
use speculoos::prelude::*;
|
||||
|
||||
mod common;
|
||||
|
||||
@@ -14,7 +14,8 @@ use pacman::{
|
||||
},
|
||||
systems::{
|
||||
AudioEvent, AudioState, BufferedDirection, Collider, DebugState, DeltaTime, EntityType, Ghost, GhostCollider, GhostState,
|
||||
GlobalState, ItemCollider, MovementModifiers, PacmanCollider, PlayerControlled, Position, ScoreResource, Velocity,
|
||||
GlobalState, ItemCollider, MovementModifiers, PacmanCollider, PelletCount, PlayerControlled, Position, ScoreResource,
|
||||
Velocity,
|
||||
},
|
||||
texture::sprite::{AtlasMapper, AtlasTile, SpriteAtlas},
|
||||
};
|
||||
@@ -85,6 +86,7 @@ pub fn create_test_world() -> World {
|
||||
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,
|
||||
@@ -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,5 @@
|
||||
use bevy_ecs::{entity::Entity, system::RunSystemOnce};
|
||||
use pacman::systems::{is_valid_item_collision, item_system, EntityType, GhostState, Position, ScoreResource};
|
||||
use pacman::systems::{item_system, EntityType, GhostState, Position, ScoreResource};
|
||||
use speculoos::prelude::*;
|
||||
|
||||
mod common;
|
||||
@@ -24,21 +24,6 @@ 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();
|
||||
|
||||
@@ -214,11 +214,9 @@ 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]
|
||||
|
||||
@@ -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