Compare commits

...

41 Commits

Author SHA1 Message Date
Ryan Walters
11af44c469 feat: add bottom row HUD, proper life display sprites 2025-09-08 14:30:33 -05:00
Ryan Walters
7675608391 chore(version): bump to v0.78.0 2025-09-08 14:07:34 -05:00
Ryan Walters
7d5b8e11dd chore: bump dependencies, spin-sleep & windows/windows-sys 2025-09-08 14:06:53 -05:00
Ryan Walters
5aba1862c9 feat: improve tracing logs application-wide 2025-09-08 13:50:38 -05:00
Ryan Walters
e46d39a938 chore: split tests & checks into separate workflows 2025-09-08 13:22:58 -05:00
Ryan Walters
49a6a5cc39 feat: implement stage transition for ghost eaten pause and add TimeToLive component
- `StageTransition` enum allows for collision system to apply state transition for ghost pausing.
- Added `TimeToLive` component & `time_to_live_system` to provide temporary sprite rendering of bonus sprites.
- Updated `stage_system` to handle the new ghost eaten pause state, including freezing entities and spawning bonus points.
2025-09-08 13:01:40 -05:00
Ryan Walters
ca50d0f3d8 chore: reformat README, move ideas into ROADMAP, add screenshots & image banner 2025-09-08 12:21:59 -05:00
Ryan Walters
774dc010bf chore: add justforfunnoreally.dev badge, improve README.md, fixup STORY.md 2025-09-08 11:36:38 -05:00
Ryan Walters
e87d458121 fix: set PlayerLives default to 3, use resource for HUD lives count in top left
yes I am fully aware that the UP is not the player lives, I'm just
wanting the indicator to be somewhere and I'll make the proper indicator
tomorrow probably
2025-09-08 01:23:26 -05:00
Ryan Walters
44f0b5d373 fix: use coveralls in README, use proper 'coverage' recipe, remove codecov.yml 2025-09-08 01:18:55 -05:00
Ryan Walters
c828034d18 chore(version): bump version to v0.77.0 2025-09-08 01:15:40 -05:00
Ryan Walters
823f480916 feat: setup pacman collision, level restart, game over, death sequence, switch to Vec for TileSequence 2025-09-08 01:14:32 -05:00
Ryan Walters
53306de155 chore: add precommit bacon job 2025-09-07 16:41:43 -05:00
Ryan Walters
6ddc6d1181 chore: setup auto tag & bump scripts with pre-commit 2025-09-07 15:12:19 -05:00
Ryan Walters
fff44faa05 fix: use serial single-thread testing for game integration tests 2025-09-07 00:10:49 -05:00
Ryan Walters
ca17984d98 feat: use cfg-based coverage exclusion to replace 'ignore-filename-regex' option, setup coveralls & nightly-based coverage 2025-09-06 14:51:23 -05:00
Ryan Walters
c8f389b163 feat: add pacman death sound 2025-09-06 12:15:08 -05:00
Ryan Walters
9c274de901 feat: setup dying sprites with sprite validation tests 2025-09-06 12:15:08 -05:00
Ryan Walters
9633611ae8 fix: downgrade to codecov-action v4, update escapes pattern, ignore codecov.json, slim codecov config 2025-09-06 12:15:07 -05:00
Ryan Walters
897b9b8621 fix: switch from lcov to codecov.json for Codecov reporting 2025-09-06 12:15:07 -05:00
Ryan Walters
ee2569b70c ci: drop coveralls, add codecov config, change badge 2025-09-06 12:15:07 -05:00
Ryan Walters
84caa6c25f ci: setup codecov coverage 2025-09-06 12:15:06 -05:00
Ryan Walters
f92c9175b9 test: add ttf renderer tests 2025-09-06 12:15:06 -05:00
Ryan Walters
d561b446c5 test: remove useless/redundant tests 2025-09-06 12:15:05 -05:00
Ryan Walters
9219c771d7 test: improve input & map_builder test coverage 2025-09-06 12:15:05 -05:00
Ryan Walters
cd501aafc4 test: general game testing 2025-09-06 12:15:05 -05:00
Ryan Walters
feae1ee191 test: add asset tests, file exists & has min size 2025-09-06 12:15:04 -05:00
Ryan Walters
2f0b9825c6 test: blinking system tests 2025-09-06 12:15:04 -05:00
Ryan Walters
cac490565e refactor: use speculoos for all test assertions 2025-09-06 12:15:04 -05:00
Ryan Walters
b60888219b fix: remove unused BlinkingTexture 2025-09-06 12:15:03 -05:00
Ryan Walters
3c50bfeab6 refactor: add ticks to DeltaTime, rewrite Blinking system for tick-based calculations with absolute calculations, rewrite Blinking/Direction tests 2025-09-06 12:15:03 -05:00
Ryan Walters
132067c573 feat: re-implement CustomFormatter to clone Full formatterr 2025-09-06 12:15:03 -05:00
Ryan Walters
42e309a46b feat: enhance profiling with tick-based timing management and zero-padding for skipped frames 2025-09-06 12:15:02 -05:00
Ryan Walters
a38423f006 refactor: use welford's algorithm for one-pass avg/std dev. calculations, input logging tweaks 2025-09-06 12:15:02 -05:00
Ryan Walters
07bd127596 chore: move ttf context out of game.rs, remove unnecessary window event logging 2025-09-06 12:15:01 -05:00
Ryan Walters
da42d017e7 refactor: reorganize game.rs new() into separate functions 2025-09-06 12:15:01 -05:00
Ryan Walters
8b623ffabe feat: sprite enums for avoiding hardcoded string paths 2025-09-06 12:15:01 -05:00
Ryan Walters
af81390e30 fix: use LARGE_SCALE for BatchedLineResource calculations 2025-09-06 12:15:00 -05:00
Ryan Walters
2fabd5d7a2 feat: measure total system timings using threading indifferent method, padded formatting 2025-09-06 12:15:00 -05:00
Ryan Walters
bcd9865430 chore: move BufferedWriter into tracing_buffer.rs 2025-09-06 12:15:00 -05:00
Ryan Walters
ed16da1e8f feat: special formatting with game tick counter, remove date from tracing formatter 2025-09-06 12:14:59 -05:00
81 changed files with 3682 additions and 1520 deletions

View File

@@ -3,3 +3,10 @@ fail-fast = false
[profile.coverage]
status-level = "none"
[[profile.default.overrides]]
filter = 'test(pacman::game::)'
test-group = 'serial'
[test-groups]
serial = { max-threads = 1 }

1
.gitattributes vendored
View File

@@ -1 +1,2 @@
* text=auto eol=lf
scripts/* linguist-detectable=false

53
.github/workflows/checks.yaml vendored Normal file
View 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

View File

@@ -4,13 +4,11 @@ on: ["push", "pull_request"]
env:
CARGO_TERM_COLOR: always
RUST_TOOLCHAIN: 1.86.0
RUST_TOOLCHAIN: nightly
jobs:
coverage:
runs-on: ubuntu-latest
env:
COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}
steps:
- name: Checkout code
uses: actions/checkout@v5
@@ -50,33 +48,9 @@ jobs:
run: |
just coverage
- name: Download Coveralls CLI
if: ${{ env.COVERALLS_REPO_TOKEN != '' }}
run: |
# use GitHub Releases URL instead of coveralls.io because they can't maintain their own files; it 404s
curl -L https://github.com/coverallsapp/coverage-reporter/releases/download/v0.6.15/coveralls-linux-x86_64.tar.gz | tar -xz -C /usr/local/bin
- name: Upload coverage to Coveralls
if: ${{ env.COVERALLS_REPO_TOKEN != '' }}
run: |
if [ ! -f "lcov.info" ]; then
echo "Error: lcov.info file not found. Coverage generation may have failed."
exit 1
fi
for i in {1..10}; do
echo "Attempt $i: Uploading coverage to Coveralls..."
if coveralls -n report lcov.info; then
echo "Successfully uploaded coverage report."
exit 0
fi
if [ $i -lt 10 ]; then
delay=$((2**i))
echo "Attempt $i failed. Retrying in $delay seconds..."
sleep $delay
fi
done
echo "Failed to upload coverage report after 10 attempts."
exit 1
- name: Coveralls upload
uses: coverallsapp/github-action@v2
with:
github-token: ${{ secrets.COVERALLS_REPO_TOKEN }}
path-to-lcov: lcov.info
debug: true

View File

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

5
.gitignore vendored
View File

@@ -14,8 +14,13 @@ assets/site/build.css
# Coverage reports
lcov.info
codecov.json
coverage.html
# Profiling output
flamegraph.svg
/profile.*
# temporary
assets/game/sound/*.wav
/*.py

View File

@@ -12,6 +12,13 @@ repos:
- id: forbid-submodules
- id: mixed-line-ending
- repo: https://github.com/compilerla/conventional-pre-commit
rev: v4.2.0
hooks:
- id: conventional-pre-commit
stages: [commit-msg]
args: []
- repo: local
hooks:
- id: cargo-fmt
@@ -20,15 +27,31 @@ repos:
language: system
types: [rust]
pass_filenames: false
- id: cargo-check
name: cargo check
entry: cargo check --all-targets
language: system
types_or: [rust, cargo, cargo-lock]
pass_filenames: false
- id: cargo-check-wasm
name: cargo check for wasm32-unknown-emscripten
entry: cargo check --all-targets --target=wasm32-unknown-emscripten
language: system
types_or: [rust, cargo, cargo-lock]
pass_filenames: false
- id: bump-version
name: bump version based on commit message
entry: python scripts/bump-version.py
language: system
stages: [commit-msg]
always_run: true
- id: tag-version
name: tag version based on commit message
entry: python scripts/tag-version.py
language: system
stages: [post-commit]
always_run: true

263
Cargo.lock generated
View File

@@ -301,6 +301,15 @@ dependencies = [
"syn",
]
[[package]]
name = "deranged"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc"
dependencies = [
"powerfmt",
]
[[package]]
name = "derive_more"
version = "1.0.0"
@@ -561,6 +570,76 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
dependencies = [
"autocfg",
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -584,7 +663,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "pacman"
version = "0.2.0"
version = "0.78.1"
dependencies = [
"anyhow",
"bevy_ecs",
@@ -603,16 +682,18 @@ dependencies = [
"serde",
"serde_json",
"smallvec",
"speculoos",
"spin_sleep",
"strum",
"strum_macros",
"thiserror",
"thousands",
"time",
"tracing",
"tracing-error",
"tracing-subscriber",
"windows",
"windows-sys 0.60.2",
"windows-sys 0.61.0",
]
[[package]]
@@ -641,7 +722,7 @@ dependencies = [
"libc",
"redox_syscall",
"smallvec",
"windows-targets 0.52.6",
"windows-targets",
]
[[package]]
@@ -722,6 +803,12 @@ dependencies = [
"portable-atomic",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -943,6 +1030,16 @@ dependencies = [
"serde",
]
[[package]]
name = "speculoos"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00c84ba5fa63b0de837c0d3cef5373ac1c3c6342053b7f446a210a1dde79a034"
dependencies = [
"num",
"serde_json",
]
[[package]]
name = "spin"
version = "0.9.8"
@@ -954,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]]
@@ -1032,6 +1129,36 @@ dependencies = [
"once_cell",
]
[[package]]
name = "time"
version = "0.3.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83bde6f1ec10e72d583d91623c939f623002284ef622b87de38cfd546cbf2031"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
[[package]]
name = "time-macros"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
@@ -1271,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",
@@ -1284,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",
@@ -1306,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",
@@ -1339,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",
@@ -1355,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",
]
@@ -1377,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]]
@@ -1395,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",
]
@@ -1436,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"

View File

@@ -1,6 +1,6 @@
[package]
name = "pacman"
version = "0.2.0"
version = "0.78.1"
authors = ["Xevion"]
edition = "2021"
rust-version = "1.86.0"
@@ -21,9 +21,10 @@ 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"] }
thiserror = "2.0.16"
anyhow = "1.0"
smallvec = "1.15.1"
@@ -41,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]
@@ -61,6 +62,7 @@ libc = "0.2.175" # TODO: Describe why this is required.
[dev-dependencies]
pretty_assertions = "1.4.1"
speculoos = "0.13.0"
[build-dependencies]
phf = { version = "0.13.1", features = ["macros"] }
@@ -96,3 +98,6 @@ x86_64-pc-windows-msvc = { triplet = "x64-windows-static-md" }
x86_64-unknown-linux-gnu = { triplet = "x64-linux" }
x86_64-apple-darwin = { triplet = "x64-osx" }
aarch64-apple-darwin = { triplet = "arm64-osx" }
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage,coverage_nightly)'] }

View File

@@ -1,9 +1,6 @@
set shell := ["bash", "-c"]
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
# Regex to exclude files from coverage report, double escapes for Justfile + CLI
# You can use src\\\\..., but the filename alone is acceptable too
coverage_exclude_pattern := "src\\\\app\\.rs|audio\\.rs|src\\\\error\\.rs|platform\\\\emscripten\\.rs|bin\\\\.+\\.rs|main\\.rs|platform\\\\desktop\\.rs|platform\\\\tracing_buffer\\.rs|platform\\\\buffered_writer\\.rs|systems\\\\debug\\.rs|systems\\\\profiling\\.rs"
binary_extension := if os() == "windows" { ".exe" } else { "" }
@@ -14,22 +11,19 @@ binary_extension := if os() == "windows" { ".exe" } else { "" }
html: coverage
cargo llvm-cov report \
--remap-path-prefix \
--ignore-filename-regex "{{ coverage_exclude_pattern }}" \
--html \
--open
# Display report (for humans)
report-coverage: coverage
cargo llvm-cov report \
--remap-path-prefix \
--ignore-filename-regex "{{ coverage_exclude_pattern }}"
cargo llvm-cov report --remap-path-prefix
# Run & generate report (for CI)
# Run & generate LCOV report (as base report)
coverage:
cargo llvm-cov \
cargo +nightly llvm-cov \
--lcov \
--remap-path-prefix \
--ignore-filename-regex "{{ coverage_exclude_pattern }}" \
--workspace \
--output-path lcov.info \
--profile coverage \
--no-fail-fast nextest

111
README.md
View File

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

30
ROADMAP.md Normal file
View File

@@ -0,0 +1,30 @@
# Roadmap
A list of ideas and features that I might implement in the future.
## Debug Tooling
- [ ] Game state visualization
- [ ] Game speed controls + pausing
- [ ] Log tracing
- [x] Performance details
## Customization
- [ ] Themes & Colors
- Color-blind friendly options
- [ ] Perfected ghost AI algorithms
- [ ] Support for >4 ghosts
- [ ] Custom level generation with multi-map tunneling
## Online Features
- [ ] Scoreboard system
- Axum server with database and OAuth2 auth
- Authentication via GitHub/Discord/Google
- Profile features:
- [ ] Optional avatars (downscaled to match 8-bit aesthetic)
- Custom names (3-14 chars, filtered for abuse)
- Zero-config client implementation
- Uses default API endpoint
- Manual override available

View File

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

View File

Binary file not shown.

BIN
assets/repo/banner.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -28,16 +28,18 @@ need_stdout = false
[jobs.test]
command = [
"cargo", "nextest", "run",
"--hide-progress-bar", "--failure-output", "final"
"cargo",
"nextest",
"run",
"--hide-progress-bar",
"--failure-output",
"final",
]
need_stdout = true
analyzer = "nextest"
[jobs.coverage]
command = [
"just", "report-coverage"
]
command = ["just", "report-coverage"]
need_stdout = true
ignored_lines = [
"info:",
@@ -54,7 +56,7 @@ ignored_lines = [
"\\s*Finished.+in \\d+",
"\\s*Summary\\s+\\[",
"\\s*Blocking",
"Finished report saved to"
"Finished report saved to",
]
on_change_strategy = "wait_then_restart"
@@ -66,21 +68,26 @@ need_stdout = false
[jobs.doc-open]
command = ["cargo", "doc", "--no-deps", "--open"]
need_stdout = false
on_success = "back" # so that we don't open the browser at each change
on_success = "back" # so that we don't open the browser at each change
[jobs.run]
command = [
"cargo", "run",
]
command = ["cargo", "run"]
need_stdout = true
allow_warnings = true
background = false
on_change_strategy = "kill_then_restart"
# kill = ["pkill", "-TERM", "-P"]'
[jobs.precommit]
command = ["pre-commit", "run", "--all-files"]
need_stdout = true
background = false
on_change_strategy = "kill_then_restart"
[keybindings]
c = "job:clippy"
alt-c = "job:check"
ctrl-alt-c = "job:check-all"
shift-c = "job:clippy-all"
f = "job:coverage"
p = "job:precommit"

143
scripts/bump-version.py Normal file
View File

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

125
scripts/tag-version.py Normal file
View File

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

View File

@@ -4,18 +4,15 @@ use std::time::{Duration, Instant};
use crate::error::{GameError, GameResult};
use crate::constants::{CANVAS_SIZE, LOOP_TIME, SCALE};
use crate::formatter;
use crate::game::Game;
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.
///
/// Handles platform-specific setup, maintains consistent frame timing, and delegates
/// game logic to the contained `Game` instance. The app manages focus state to
/// optimize CPU usage when the window loses focus.
pub struct App {
pub game: Game,
last_tick: Instant,
@@ -28,21 +25,25 @@ pub struct App {
impl App {
/// Initializes SDL subsystems, creates the game window, and sets up the game state.
///
/// Performs comprehensive initialization including video/audio subsystems,
/// window creation with proper scaling, and canvas configuration. All SDL
/// resources are leaked to maintain 'static lifetimes required by the game architecture.
///
/// # Errors
///
/// 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()))?;
// TTF context is initialized within Game::new where it is leaked for font usage
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",
@@ -71,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
@@ -83,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()
@@ -95,16 +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();
let game = Game::new(canvas, texture_creator, event_pump)?;
// game.audio.set_mute(cfg!(debug_assertions));
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,
@@ -131,6 +140,9 @@ impl App {
let dt = self.last_tick.elapsed().as_secs_f32();
self.last_tick = start;
// Increment the global tick counter for tracing
formatter::increment_tick();
let exit = self.game.tick(dt);
if exit {

View File

@@ -19,6 +19,8 @@ pub enum Asset {
AtlasImage,
/// Terminal Vector font for text rendering (TerminalVector.ttf)
Font,
/// Sound effect for Pac-Man's death
DeathSound,
}
impl Asset {
@@ -37,6 +39,7 @@ impl Asset {
Wav4 => "sound/waka/4.ogg",
AtlasImage => "atlas.png",
Font => "TerminalVector.ttf",
DeathSound => "sound/pacman_death.wav",
}
}
}
@@ -45,6 +48,7 @@ mod imp {
use super::*;
use crate::error::AssetError;
use crate::platform;
use tracing::trace;
/// Loads asset bytes using the appropriate platform-specific method.
///
@@ -58,7 +62,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, path = asset.path(), "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
}
}

View File

@@ -16,6 +16,7 @@ const SOUND_ASSETS: [Asset; 4] = [Asset::Wav1, Asset::Wav2, Asset::Wav3, Asset::
pub struct Audio {
_mixer_context: Option<mixer::Sdl2MixerContext>,
sounds: Vec<Chunk>,
death_sound: Option<Chunk>,
next_sound_index: usize,
muted: bool,
disabled: bool,
@@ -44,6 +45,7 @@ impl Audio {
return Self {
_mixer_context: None,
sounds: Vec::new(),
death_sound: None,
next_sound_index: 0,
muted: false,
disabled: true,
@@ -65,6 +67,7 @@ impl Audio {
return Self {
_mixer_context: None,
sounds: Vec::new(),
death_sound: None,
next_sound_index: 0,
muted: false,
disabled: true,
@@ -93,12 +96,33 @@ impl Audio {
}
}
let death_sound = match get_asset_bytes(Asset::DeathSound) {
Ok(data) => match RWops::from_bytes(&data) {
Ok(rwops) => match rwops.load_wav() {
Ok(chunk) => Some(chunk),
Err(e) => {
tracing::warn!("Failed to load death sound from asset API: {}", e);
None
}
},
Err(e) => {
tracing::warn!("Failed to create RWops for death sound: {}", e);
None
}
},
Err(e) => {
tracing::warn!("Failed to load death sound asset: {}", e);
None
}
};
// If no sounds loaded successfully, disable audio
if sounds.is_empty() {
if sounds.is_empty() && death_sound.is_none() {
tracing::warn!("No sounds loaded successfully. Audio will be disabled.");
return Self {
_mixer_context: Some(mixer_context),
sounds: Vec::new(),
death_sound: None,
next_sound_index: 0,
muted: false,
disabled: true,
@@ -108,6 +132,7 @@ impl Audio {
Audio {
_mixer_context: Some(mixer_context),
sounds,
death_sound,
next_sound_index: 0,
muted: false,
disabled: false,
@@ -138,6 +163,24 @@ impl Audio {
self.next_sound_index = (self.next_sound_index + 1) % self.sounds.len();
}
/// Plays the death sound effect.
pub fn death(&mut self) {
if self.disabled || self.muted {
return;
}
if let Some(chunk) = &self.death_sound {
mixer::Channel::all().play(chunk, 0).ok();
}
}
/// Halts all currently playing audio channels.
pub fn stop_all(&mut self) {
if !self.disabled {
mixer::Channel::all().halt();
}
}
/// Instantly mutes or unmutes all audio channels by adjusting their volume.
///
/// Sets all 4 mixer channels to zero volume when muting, or restores them to

View File

@@ -1,3 +1,6 @@
#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
#![cfg_attr(coverage_nightly, coverage(off))]
use std::time::{Duration, Instant};
use sdl2::event::Event;

View File

@@ -1,3 +1,6 @@
#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
#![cfg_attr(coverage_nightly, coverage(off))]
use circular_buffer::CircularBuffer;
use pacman::constants::CANVAS_SIZE;
use sdl2::event::Event;

View File

@@ -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
@@ -72,8 +85,8 @@ pub mod collider {
pub mod ui {
/// Debug font size in points
pub const DEBUG_FONT_SIZE: u16 = 12;
/// Power pellet blink rate in seconds
pub const POWER_PELLET_BLINK_RATE: f32 = 0.2;
/// Power pellet blink rate in ticks (at 60 FPS, 12 ticks = 0.2 seconds)
pub const POWER_PELLET_BLINK_RATE: u32 = 12;
}
/// Map tile types that define gameplay behavior and collision properties.
@@ -132,8 +145,6 @@ pub const RAW_BOARD: [&str; BOARD_CELL_SIZE.y as usize] = [
pub mod startup {
/// Number of frames for the startup sequence (3 seconds at 60 FPS)
pub const STARTUP_FRAMES: u32 = 60 * 3;
/// Number of ticks per frame during startup
pub const STARTUP_TICKS_PER_FRAME: u32 = 60;
}
/// Game mechanics constants

View File

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

160
src/formatter.rs Normal file
View File

@@ -0,0 +1,160 @@
//! Custom tracing formatter with tick counter integration
use std::fmt;
use std::sync::atomic::{AtomicU64, Ordering};
use time::macros::format_description;
use time::{format_description::FormatItem, OffsetDateTime};
use tracing::{Event, Level, Subscriber};
use tracing_subscriber::fmt::format::Writer;
use tracing_subscriber::fmt::{FmtContext, FormatEvent, FormatFields, FormattedFields};
use tracing_subscriber::registry::LookupSpan;
/// Global atomic counter for tracking game ticks
static TICK_COUNTER: AtomicU64 = AtomicU64::new(0);
/// Maximum value for tick counter display (16-bit hex)
const TICK_DISPLAY_MASK: u64 = 0xFFFF;
/// Cached format description for timestamps
/// Uses 3 subsecond digits on Emscripten, 5 otherwise for better performance
#[cfg(target_os = "emscripten")]
const TIMESTAMP_FORMAT: &[FormatItem<'static>] = format_description!("[hour]:[minute]:[second].[subsecond digits:3]");
#[cfg(not(target_os = "emscripten"))]
const TIMESTAMP_FORMAT: &[FormatItem<'static>] = format_description!("[hour]:[minute]:[second].[subsecond digits:5]");
/// A custom formatter that includes both timestamp and tick counter in hexadecimal
///
/// Re-implementation of the Full formatter to add a tick counter and timestamp.
pub struct CustomFormatter;
impl<S, N> FormatEvent<S, N> for CustomFormatter
where
S: Subscriber + for<'a> LookupSpan<'a>,
N: for<'a> FormatFields<'a> + 'static,
{
fn format_event(&self, ctx: &FmtContext<'_, S, N>, mut writer: Writer<'_>, event: &Event<'_>) -> fmt::Result {
let meta = event.metadata();
// 1) Timestamp (dimmed when ANSI)
let now = OffsetDateTime::now_utc();
let formatted_time = now.format(&TIMESTAMP_FORMAT).map_err(|e| {
eprintln!("Failed to format timestamp: {}", e);
fmt::Error
})?;
write_dimmed(&mut writer, formatted_time)?;
writer.write_char(' ')?;
// 2) Tick counter, dim when ANSI
let tick_count = get_tick_count() & TICK_DISPLAY_MASK;
if writer.has_ansi_escapes() {
write!(writer, "\x1b[2m0x{:04X}\x1b[0m ", tick_count)?;
} else {
write!(writer, "0x{:04X} ", tick_count)?;
}
// 3) Colored 5-char level like Full
write_colored_level(&mut writer, meta.level())?;
writer.write_char(' ')?;
// 4) Span scope chain (bold names, fields in braces, dimmed ':')
if let Some(scope) = ctx.event_scope() {
let mut saw_any = false;
for span in scope.from_root() {
write_bold(&mut writer, span.metadata().name())?;
saw_any = true;
let ext = span.extensions();
if let Some(fields) = &ext.get::<FormattedFields<N>>() {
if !fields.is_empty() {
write_bold(&mut writer, "{")?;
write!(writer, "{}", fields)?;
write_bold(&mut writer, "}")?;
}
}
if writer.has_ansi_escapes() {
write!(writer, "\x1b[2m:\x1b[0m")?;
} else {
writer.write_char(':')?;
}
}
if saw_any {
writer.write_char(' ')?;
}
}
// 5) Target (dimmed), then a space
if writer.has_ansi_escapes() {
write!(writer, "\x1b[2m{}\x1b[0m\x1b[2m:\x1b[0m ", meta.target())?;
} else {
write!(writer, "{}: ", meta.target())?;
}
// 6) Event fields
ctx.format_fields(writer.by_ref(), event)?;
// 7) Newline
writeln!(writer)
}
}
/// Write the verbosity level with the same coloring/alignment as the Full formatter.
fn write_colored_level(writer: &mut Writer<'_>, level: &Level) -> fmt::Result {
if writer.has_ansi_escapes() {
// Basic ANSI color sequences; reset with \x1b[0m
let (color, text) = match *level {
Level::TRACE => ("\x1b[35m", "TRACE"), // purple
Level::DEBUG => ("\x1b[34m", "DEBUG"), // blue
Level::INFO => ("\x1b[32m", " INFO"), // green, note leading space
Level::WARN => ("\x1b[33m", " WARN"), // yellow, note leading space
Level::ERROR => ("\x1b[31m", "ERROR"), // red
};
write!(writer, "{}{}\x1b[0m", color, text)
} else {
// Right-pad to width 5 like Full's non-ANSI mode
match *level {
Level::TRACE => write!(writer, "{:>5}", "TRACE"),
Level::DEBUG => write!(writer, "{:>5}", "DEBUG"),
Level::INFO => write!(writer, "{:>5}", " INFO"),
Level::WARN => write!(writer, "{:>5}", " WARN"),
Level::ERROR => write!(writer, "{:>5}", "ERROR"),
}
}
}
fn write_dimmed(writer: &mut Writer<'_>, s: impl fmt::Display) -> fmt::Result {
if writer.has_ansi_escapes() {
write!(writer, "\x1b[2m{}\x1b[0m", s)
} else {
write!(writer, "{}", s)
}
}
fn write_bold(writer: &mut Writer<'_>, s: impl fmt::Display) -> fmt::Result {
if writer.has_ansi_escapes() {
write!(writer, "\x1b[1m{}\x1b[0m", s)
} else {
write!(writer, "{}", s)
}
}
/// Increment the global tick counter by 1
///
/// This should be called once per game tick/frame from the main game loop
pub fn increment_tick() {
TICK_COUNTER.fetch_add(1, Ordering::Relaxed);
}
/// Get the current tick count
///
/// Returns the current value of the global tick counter
pub fn get_tick_count() -> u64 {
TICK_COUNTER.load(Ordering::Relaxed)
}
/// Reset the tick counter to 0
///
/// This can be used for testing or when restarting the game
#[allow(dead_code)]
pub fn reset_tick_counter() {
TICK_COUNTER.store(0, Ordering::Relaxed);
}

View File

@@ -3,38 +3,34 @@
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, TextureError};
use crate::events::GameEvent;
use crate::error::{GameError, GameResult};
use crate::events::{GameEvent, StageTransition};
use crate::map::builder::Map;
use crate::map::direction::Direction;
use crate::systems::blinking::Blinking;
use crate::systems::components::{GhostAnimation, GhostState, LastAnimationState};
use crate::systems::movement::{BufferedDirection, Position, Velocity};
use crate::systems::profiling::SystemId;
use crate::systems::render::touch_ui_render_system;
use crate::systems::render::RenderDirty;
use crate::systems::{
self, combined_render_system, ghost_collision_system, present_system, Hidden, LinearAnimation, MovementModifiers, NodeId,
};
use crate::systems::{
audio_system, blinking_system, collision_system, directional_render_system, dirty_render_system, eaten_ghost_system,
ghost_movement_system, ghost_state_system, hud_render_system, item_system, linear_render_system, profile, AudioEvent,
AudioResource, AudioState, BackbufferResource, Collider, DebugState, DebugTextureResource, DeltaTime, DirectionalAnimation,
EntityType, Frozen, Ghost, GhostAnimations, GhostBundle, GhostCollider, GlobalState, ItemBundle, ItemCollider,
MapTextureResource, PacmanCollider, PlayerBundle, PlayerControlled, Renderable, ScoreResource, StartupSequence,
SystemTimings,
self, audio_system, blinking_system, collision_system, combined_render_system, directional_render_system,
dirty_render_system, eaten_ghost_system, ghost_collision_system, ghost_movement_system, ghost_state_system,
hud_render_system, item_system, linear_render_system, present_system, profile, time_to_live_system, touch_ui_render_system,
AudioEvent, AudioResource, AudioState, BackbufferResource, Blinking, BufferedDirection, Collider, DebugState,
DebugTextureResource, DeltaTime, DirectionalAnimation, EntityType, Frozen, GameStage, Ghost, GhostAnimation, GhostAnimations,
GhostBundle, GhostCollider, GhostState, GlobalState, Hidden, ItemBundle, ItemCollider, LastAnimationState, LinearAnimation,
MapTextureResource, MovementModifiers, NodeId, PacmanCollider, PlayerAnimation, PlayerBundle, PlayerControlled,
PlayerDeathAnimation, PlayerLives, Position, RenderDirty, Renderable, ScoreResource, StartupSequence, SystemId,
SystemTimings, Timing, TouchState, Velocity,
};
use crate::texture::animated::{DirectionalTiles, TileSequence};
use crate::texture::sprite::AtlasTile;
use crate::texture::sprites::{FrightenedColor, GameSprite, GhostSprite, MazeSprite, PacmanSprite};
use bevy_ecs::change_detection::DetectChanges;
use bevy_ecs::event::EventRegistry;
use bevy_ecs::observer::Trigger;
use bevy_ecs::schedule::common_conditions::resource_changed;
use bevy_ecs::schedule::{Condition, IntoScheduleConfigs, Schedule, SystemSet};
use bevy_ecs::system::{Local, ResMut};
use bevy_ecs::schedule::{IntoScheduleConfigs, Schedule, SystemSet};
use bevy_ecs::system::{Local, Res, ResMut};
use bevy_ecs::world::World;
use glam::UVec2;
use sdl2::event::EventType;
use sdl2::image::LoadTexture;
use sdl2::render::{BlendMode, Canvas, ScaleMode, TextureCreator};
@@ -53,7 +49,9 @@ use crate::{
/// System set for all rendering systems to ensure they run after gameplay logic
#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
pub struct RenderSet;
enum RenderSet {
Animation,
}
/// Core game state manager built on the Bevy ECS architecture.
///
@@ -88,10 +86,78 @@ impl Game {
/// errors, or entity initialization issues.
pub fn new(
mut canvas: Canvas<Window>,
ttf_context: sdl2::ttf::Sdl2TtfContext,
texture_creator: TextureCreator<WindowContext>,
mut event_pump: EventPump,
) -> GameResult<Game> {
// Disable uninteresting events
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);
debug!("Inserting resources into ECS world");
Self::insert_resources(
&mut world,
map,
audio,
atlas,
event_pump,
canvas,
backbuffer,
map_texture,
debug_texture,
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 })
}
fn disable_sdl_events(event_pump: &mut EventPump) {
for event_type in [
EventType::JoyAxisMotion,
EventType::JoyBallMotion,
@@ -109,9 +175,6 @@ impl Game {
EventType::ControllerTouchpadDown,
EventType::ControllerTouchpadMotion,
EventType::ControllerTouchpadUp,
// EventType::FingerDown, // Enable for touch controls
// EventType::FingerUp, // Enable for touch controls
// EventType::FingerMotion, // Enable for touch controls
EventType::DollarGesture,
EventType::DollarRecord,
EventType::MultiGesture,
@@ -128,11 +191,7 @@ impl Game {
EventType::TextInput,
EventType::TextEditing,
EventType::Display,
// EventType::Window,
EventType::MouseWheel,
// EventType::MouseMotion,
// EventType::MouseButtonDown, // Enable for desktop touch testing
// EventType::MouseButtonUp, // Enable for desktop touch testing
EventType::AppDidEnterBackground,
EventType::AppWillEnterForeground,
EventType::AppWillEnterBackground,
@@ -144,8 +203,18 @@ impl Game {
] {
event_pump.disable_event(event_type);
}
}
let ttf_context = Box::leak(Box::new(sdl2::ttf::init().map_err(|e| GameError::Sdl(e.to_string()))?));
fn setup_textures_and_fonts(
canvas: &mut Canvas<Window>,
texture_creator: &TextureCreator<WindowContext>,
ttf_context: sdl2::ttf::Sdl2TtfContext,
) -> GameResult<(
sdl2::render::Texture,
sdl2::render::Texture,
sdl2::render::Texture,
crate::texture::ttf::TtfAtlas,
)> {
let mut backbuffer = texture_creator
.create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y)
.map_err(|e| GameError::Sdl(e.to_string()))?;
@@ -156,31 +225,27 @@ impl Game {
.map_err(|e| GameError::Sdl(e.to_string()))?;
map_texture.set_scale_mode(ScaleMode::Nearest);
// Create debug texture at output resolution for crisp debug rendering
let output_size = constants::LARGE_CANVAS_SIZE;
let mut debug_texture = texture_creator
.create_texture_target(Some(sdl2::pixels::PixelFormatEnum::ARGB8888), output_size.x, output_size.y)
.map_err(|e| GameError::Sdl(e.to_string()))?;
// Debug texture is copied over the backbuffer, it requires transparency abilities
debug_texture.set_blend_mode(BlendMode::Blend);
debug_texture.set_scale_mode(ScaleMode::Nearest);
// Create debug text atlas for efficient debug rendering
let font_data: &'static [u8] = get_asset_bytes(Asset::Font)?.to_vec().leak();
let font_asset = RWops::from_bytes(font_data).map_err(|_| GameError::Sdl("Failed to load font".to_string()))?;
let debug_font = ttf_context
.load_font_from_rwops(font_asset, constants::ui::DEBUG_FONT_SIZE)
.map_err(|e| GameError::Sdl(e.to_string()))?;
let mut ttf_atlas = crate::texture::ttf::TtfAtlas::new(&texture_creator, &debug_font)?;
// Populate the atlas with actual character data
ttf_atlas.populate_atlas(&mut canvas, &texture_creator, &debug_font)?;
let mut ttf_atlas = crate::texture::ttf::TtfAtlas::new(texture_creator, &debug_font)?;
ttf_atlas.populate_atlas(canvas, texture_creator, &debug_font)?;
// Initialize audio system
let audio = crate::audio::Audio::new();
Ok((backbuffer, map_texture, debug_texture, ttf_atlas))
}
// Load atlas and create map texture
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") {
@@ -192,60 +257,49 @@ 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 mut atlas = SpriteAtlas::new(atlas_texture, atlas_mapper);
let atlas = SpriteAtlas::new(atlas_texture, atlas_mapper);
// Create map tiles
trace!("Extracting map tile sprites from atlas");
let mut map_tiles = Vec::with_capacity(35);
for i in 0..35 {
let tile_name = format!("maze/tiles/{}.png", i);
let tile = atlas.get_tile(&tile_name).unwrap();
let tile_name = GameSprite::Maze(MazeSprite::Tile(i)).to_path();
let tile = atlas.get_tile(&tile_name)?;
map_tiles.push(tile);
}
// Render map to texture
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()))?;
Ok((atlas, map_tiles))
}
let map = Map::new(constants::RAW_BOARD)?;
// Create directional animated textures for Pac-Man
fn create_player_animations(atlas: &SpriteAtlas) -> GameResult<(DirectionalAnimation, AtlasTile)> {
let up_moving_tiles = [
SpriteAtlas::get_tile(&atlas, "pacman/up_a.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/up_a.png".to_string())))?,
SpriteAtlas::get_tile(&atlas, "pacman/up_b.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/up_b.png".to_string())))?,
SpriteAtlas::get_tile(&atlas, "pacman/full.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?,
SpriteAtlas::get_tile(atlas, &GameSprite::Pacman(PacmanSprite::Moving(Direction::Up, 0)).to_path())?,
SpriteAtlas::get_tile(atlas, &GameSprite::Pacman(PacmanSprite::Moving(Direction::Up, 1)).to_path())?,
SpriteAtlas::get_tile(atlas, &GameSprite::Pacman(PacmanSprite::Full).to_path())?,
];
let down_moving_tiles = [
SpriteAtlas::get_tile(&atlas, "pacman/down_a.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/down_a.png".to_string())))?,
SpriteAtlas::get_tile(&atlas, "pacman/down_b.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/down_b.png".to_string())))?,
SpriteAtlas::get_tile(&atlas, "pacman/full.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?,
SpriteAtlas::get_tile(atlas, &GameSprite::Pacman(PacmanSprite::Moving(Direction::Down, 0)).to_path())?,
SpriteAtlas::get_tile(atlas, &GameSprite::Pacman(PacmanSprite::Moving(Direction::Down, 1)).to_path())?,
SpriteAtlas::get_tile(atlas, &GameSprite::Pacman(PacmanSprite::Full).to_path())?,
];
let left_moving_tiles = [
SpriteAtlas::get_tile(&atlas, "pacman/left_a.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/left_a.png".to_string())))?,
SpriteAtlas::get_tile(&atlas, "pacman/left_b.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/left_b.png".to_string())))?,
SpriteAtlas::get_tile(&atlas, "pacman/full.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?,
SpriteAtlas::get_tile(atlas, &GameSprite::Pacman(PacmanSprite::Moving(Direction::Left, 0)).to_path())?,
SpriteAtlas::get_tile(atlas, &GameSprite::Pacman(PacmanSprite::Moving(Direction::Left, 1)).to_path())?,
SpriteAtlas::get_tile(atlas, &GameSprite::Pacman(PacmanSprite::Full).to_path())?,
];
let right_moving_tiles = [
SpriteAtlas::get_tile(&atlas, "pacman/right_a.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/right_a.png".to_string())))?,
SpriteAtlas::get_tile(&atlas, "pacman/right_b.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/right_b.png".to_string())))?,
SpriteAtlas::get_tile(&atlas, "pacman/full.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?,
SpriteAtlas::get_tile(
atlas,
&GameSprite::Pacman(PacmanSprite::Moving(Direction::Right, 0)).to_path(),
)?,
SpriteAtlas::get_tile(
atlas,
&GameSprite::Pacman(PacmanSprite::Moving(Direction::Right, 1)).to_path(),
)?,
SpriteAtlas::get_tile(atlas, &GameSprite::Pacman(PacmanSprite::Full).to_path())?,
];
let moving_tiles = DirectionalTiles::new(
@@ -255,14 +309,16 @@ impl Game {
TileSequence::new(&right_moving_tiles),
);
let up_stopped_tile = SpriteAtlas::get_tile(&atlas, "pacman/up_b.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/up_b.png".to_string())))?;
let down_stopped_tile = SpriteAtlas::get_tile(&atlas, "pacman/down_b.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/down_b.png".to_string())))?;
let left_stopped_tile = SpriteAtlas::get_tile(&atlas, "pacman/left_b.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/left_b.png".to_string())))?;
let right_stopped_tile = SpriteAtlas::get_tile(&atlas, "pacman/right_b.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/right_b.png".to_string())))?;
let up_stopped_tile =
SpriteAtlas::get_tile(atlas, &GameSprite::Pacman(PacmanSprite::Moving(Direction::Up, 1)).to_path())?;
let down_stopped_tile =
SpriteAtlas::get_tile(atlas, &GameSprite::Pacman(PacmanSprite::Moving(Direction::Down, 1)).to_path())?;
let left_stopped_tile =
SpriteAtlas::get_tile(atlas, &GameSprite::Pacman(PacmanSprite::Moving(Direction::Left, 1)).to_path())?;
let right_stopped_tile = SpriteAtlas::get_tile(
atlas,
&GameSprite::Pacman(PacmanSprite::Moving(Direction::Right, 1)).to_path(),
)?;
let stopped_tiles = DirectionalTiles::new(
TileSequence::new(&[up_stopped_tile]),
@@ -271,7 +327,26 @@ impl Game {
TileSequence::new(&[right_stopped_tile]),
);
let player = PlayerBundle {
let player_animation = DirectionalAnimation::new(moving_tiles, stopped_tiles, 5);
let player_start_sprite = SpriteAtlas::get_tile(atlas, &GameSprite::Pacman(PacmanSprite::Full).to_path())?;
Ok((player_animation, player_start_sprite))
}
fn create_death_animation(atlas: &SpriteAtlas) -> GameResult<LinearAnimation> {
let mut death_tiles = Vec::new();
for i in 0..=10 {
// Assuming death animation has 11 frames named pacman/die_0, pacman/die_1, etc.
let tile = atlas.get_tile(&GameSprite::Pacman(PacmanSprite::Dying(i)).to_path())?;
death_tiles.push(tile);
}
let tile_sequence = TileSequence::new(&death_tiles);
Ok(LinearAnimation::new(tile_sequence, 8)) // 8 ticks per frame, non-looping
}
fn create_player_bundle(map: &Map, player_animation: DirectionalAnimation, player_start_sprite: AtlasTile) -> PlayerBundle {
PlayerBundle {
player: PlayerControlled,
position: Position::Stopped {
node: map.start_positions.pacman,
@@ -283,54 +358,23 @@ impl Game {
movement_modifiers: MovementModifiers::default(),
buffered_direction: BufferedDirection::None,
sprite: Renderable {
sprite: SpriteAtlas::get_tile(&atlas, "pacman/full.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?,
sprite: player_start_sprite,
layer: 0,
},
directional_animation: DirectionalAnimation::new(moving_tiles, stopped_tiles, 5),
directional_animation: player_animation,
entity_type: EntityType::Player,
collider: Collider {
size: constants::collider::PLAYER_GHOST_SIZE,
},
pacman_collider: PacmanCollider,
};
}
}
let mut world = World::default();
let mut schedule = Schedule::default();
EventRegistry::register_event::<GameError>(&mut world);
EventRegistry::register_event::<GameEvent>(&mut world);
EventRegistry::register_event::<AudioEvent>(&mut world);
let scale =
(UVec2::from(canvas.output_size().unwrap()).as_vec2() / UVec2::from(canvas.logical_size()).as_vec2()).min_element();
world.insert_resource(BatchedLinesResource::new(&map, scale));
world.insert_resource(Self::create_ghost_animations(&atlas)?);
world.insert_resource(map);
world.insert_resource(GlobalState { exit: false });
world.insert_resource(ScoreResource(0));
world.insert_resource(SystemTimings::default());
world.insert_resource(Bindings::default());
world.insert_resource(DeltaTime(0f32));
world.insert_resource(RenderDirty::default());
world.insert_resource(DebugState::default());
world.insert_resource(AudioState::default());
world.insert_resource(CursorPosition::default());
world.insert_resource(systems::input::TouchState::default());
world.insert_resource(StartupSequence::new(
constants::startup::STARTUP_FRAMES,
constants::startup::STARTUP_TICKS_PER_FRAME,
));
world.insert_non_send_resource(atlas);
world.insert_non_send_resource(event_pump);
world.insert_non_send_resource::<&mut Canvas<Window>>(Box::leak(Box::new(canvas)));
world.insert_non_send_resource(BackbufferResource(backbuffer));
world.insert_non_send_resource(MapTextureResource(map_texture));
world.insert_non_send_resource(DebugTextureResource(debug_texture));
world.insert_non_send_resource(TtfAtlasResource(ttf_atlas));
world.insert_non_send_resource(AudioResource(audio));
fn setup_ecs(world: &mut World) {
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>| {
@@ -339,16 +383,65 @@ impl Game {
}
},
);
}
#[allow(clippy::too_many_arguments)]
fn insert_resources(
world: &mut World,
map: Map,
audio: crate::audio::Audio,
atlas: SpriteAtlas,
event_pump: EventPump,
canvas: Canvas<Window>,
backbuffer: sdl2::render::Texture,
map_texture: sdl2::render::Texture,
debug_texture: sdl2::render::Texture,
ttf_atlas: crate::texture::ttf::TtfAtlas,
death_animation: LinearAnimation,
) -> GameResult<()> {
world.insert_non_send_resource(atlas);
world.insert_resource(Self::create_ghost_animations(world.non_send_resource::<SpriteAtlas>())?);
let player_animation = Self::create_player_animations(world.non_send_resource::<SpriteAtlas>())?.0;
world.insert_resource(PlayerAnimation(player_animation));
world.insert_resource(PlayerDeathAnimation(death_animation));
world.insert_resource(BatchedLinesResource::new(&map, constants::LARGE_SCALE));
world.insert_resource(map);
world.insert_resource(GlobalState { exit: false });
world.insert_resource(PlayerLives::default());
world.insert_resource(ScoreResource(0));
world.insert_resource(SystemTimings::default());
world.insert_resource(Timing::default());
world.insert_resource(Bindings::default());
world.insert_resource(DeltaTime { seconds: 0.0, ticks: 0 });
world.insert_resource(RenderDirty::default());
world.insert_resource(DebugState::default());
world.insert_resource(AudioState::default());
world.insert_resource(CursorPosition::default());
world.insert_resource(TouchState::default());
world.insert_resource(GameStage::Starting(StartupSequence::TextOnly {
remaining_ticks: constants::startup::STARTUP_FRAMES,
}));
world.insert_non_send_resource(event_pump);
world.insert_non_send_resource::<&mut Canvas<Window>>(Box::leak(Box::new(canvas)));
world.insert_non_send_resource(BackbufferResource(backbuffer));
world.insert_non_send_resource(MapTextureResource(map_texture));
world.insert_non_send_resource(DebugTextureResource(debug_texture));
world.insert_non_send_resource(TtfAtlasResource(ttf_atlas));
world.insert_non_send_resource(AudioResource(audio));
Ok(())
}
fn configure_schedule(schedule: &mut Schedule) {
let stage_system = profile(SystemId::Stage, systems::stage_system);
let input_system = profile(SystemId::Input, systems::input::input_system);
let player_control_system = profile(SystemId::PlayerControls, systems::player_control_system);
let player_movement_system = profile(SystemId::PlayerMovement, systems::player_movement_system);
let startup_stage_system = profile(SystemId::Stage, systems::startup_stage_system);
let player_tunnel_slowdown_system = profile(SystemId::PlayerMovement, systems::player::player_tunnel_slowdown_system);
let ghost_movement_system = profile(SystemId::Ghost, ghost_movement_system);
let collision_system = profile(SystemId::Collision, collision_system);
let ghost_collision_system = profile(SystemId::GhostCollision, ghost_collision_system);
let item_system = profile(SystemId::Item, item_system);
let audio_system = profile(SystemId::Audio, audio_system);
let blinking_system = profile(SystemId::Blinking, blinking_system);
@@ -358,55 +451,71 @@ impl Game {
let hud_render_system = profile(SystemId::HudRender, hud_render_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(resource_changed::<ScoreResource>.or(resource_changed::<StartupSequence>)),
(
input_system.run_if(|mut local: Local<u8>| {
*local = local.wrapping_add(1u8);
// run every nth frame
*local % 2 == 0
}),
player_control_system,
player_movement_system,
startup_stage_system,
)
.chain(),
player_tunnel_slowdown_system,
ghost_movement_system,
profile(SystemId::EatenGhost, eaten_ghost_system),
unified_ghost_state_system,
schedule.add_systems(
forced_dirty_system
.run_if(|score: Res<ScoreResource>, stage: Res<GameStage>| score.is_changed() || stage.is_changed()),
);
// Input system should always run to prevent SDL event pump from blocking
let input_systems = (
input_system.run_if(|mut local: Local<u8>| {
*local = local.wrapping_add(1u8);
// run every nth frame
*local % 2 == 0
}),
player_control_system,
)
.chain();
let gameplay_systems = (
(player_movement_system, player_tunnel_slowdown_system, ghost_movement_system).chain(),
eaten_ghost_system,
(collision_system, ghost_collision_system, item_system).chain(),
audio_system,
blinking_system,
unified_ghost_state_system,
)
.chain()
.run_if(|game_state: Res<GameStage>| matches!(*game_state, GameStage::Playing));
schedule.add_systems((blinking_system, directional_render_system, linear_render_system).in_set(RenderSet::Animation));
schedule.add_systems((
time_to_live_system,
stage_system,
input_systems,
gameplay_systems,
(
directional_render_system,
linear_render_system,
dirty_render_system,
combined_render_system,
hud_render_system,
touch_ui_render_system,
present_system,
)
.chain(),
.chain()
.after(RenderSet::Animation),
audio_system,
));
}
// Spawn player and attach initial state bundle
world.spawn(player).insert((Frozen, Hidden));
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(),
)?;
let energizer_sprite = SpriteAtlas::get_tile(
world.non_send_resource::<SpriteAtlas>(),
&GameSprite::Maze(MazeSprite::Energizer).to_path(),
)?;
// Spawn ghosts
Self::spawn_ghosts(&mut world)?;
let pellet_sprite = SpriteAtlas::get_tile(world.non_send_resource::<SpriteAtlas>(), "maze/pellet.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("maze/pellet.png".to_string())))?;
let energizer_sprite = SpriteAtlas::get_tile(world.non_send_resource::<SpriteAtlas>(), "maze/energizer.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("maze/energizer.png".to_string())))?;
// Build a list of item entities to spawn from the map
let nodes: Vec<(NodeId, EntityType, AtlasTile, f32)> = world
.resource::<Map>()
.iter_nodes()
@@ -422,7 +531,12 @@ impl Game {
})
.collect();
// Construct and spawn the item entities
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 },
@@ -432,13 +546,11 @@ impl Game {
item_collider: ItemCollider,
});
// Make power pellets blink
if item_type == EntityType::PowerPellet {
item.insert((Frozen, Blinking::new(constants::ui::POWER_PELLET_BLINK_RATE)));
}
}
Ok(Game { world, schedule })
Ok(())
}
/// Creates and spawns all four ghosts with unique AI personalities and directional animations.
@@ -448,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>();
@@ -462,8 +575,9 @@ impl Game {
for (ghost_type, start_node) in ghost_start_positions {
// Create the ghost bundle in a separate scope to manage borrows
let ghost = {
let animations = *world.resource::<GhostAnimations>().get_normal(&ghost_type).unwrap();
let animations = world.resource::<GhostAnimations>().get_normal(&ghost_type).unwrap().clone();
let atlas = world.non_send_resource::<SpriteAtlas>();
let sprite_path = GameSprite::Ghost(GhostSprite::Normal(ghost_type, Direction::Left, 0)).to_path();
GhostBundle {
ghost: ghost_type,
@@ -473,14 +587,7 @@ impl Game {
direction: Direction::Left,
},
sprite: Renderable {
sprite: SpriteAtlas::get_tile(atlas, &format!("ghost/{}/left_a.png", ghost_type.as_str())).ok_or_else(
|| {
GameError::Texture(TextureError::AtlasTileNotFound(format!(
"ghost/{}/left_a.png",
ghost_type.as_str()
)))
},
)?,
sprite: SpriteAtlas::get_tile(atlas, &sprite_path)?,
layer: 0,
},
directional_animation: animations,
@@ -494,26 +601,20 @@ 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(())
}
fn create_ghost_animations(atlas: &SpriteAtlas) -> GameResult<GhostAnimations> {
// Eaten (eyes) animations - single tile per direction
let up_eye = atlas
.get_tile("ghost/eyes/up.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("ghost/eyes/up.png".to_string())))?;
let down_eye = atlas
.get_tile("ghost/eyes/down.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("ghost/eyes/down.png".to_string())))?;
let left_eye = atlas
.get_tile("ghost/eyes/left.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("ghost/eyes/left.png".to_string())))?;
let right_eye = atlas
.get_tile("ghost/eyes/right.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("ghost/eyes/right.png".to_string())))?;
let up_eye = atlas.get_tile(&GameSprite::Ghost(GhostSprite::Eyes(Direction::Up)).to_path())?;
let down_eye = atlas.get_tile(&GameSprite::Ghost(GhostSprite::Eyes(Direction::Down)).to_path())?;
let left_eye = atlas.get_tile(&GameSprite::Ghost(GhostSprite::Eyes(Direction::Left)).to_path())?;
let right_eye = atlas.get_tile(&GameSprite::Ghost(GhostSprite::Eyes(Direction::Right)).to_path())?;
let eyes_tiles = DirectionalTiles::new(
TileSequence::new(&[up_eye]),
@@ -521,83 +622,27 @@ impl Game {
TileSequence::new(&[left_eye]),
TileSequence::new(&[right_eye]),
);
let eyes = DirectionalAnimation::new(eyes_tiles, eyes_tiles, animation::GHOST_EATEN_SPEED);
let eyes = DirectionalAnimation::new(eyes_tiles.clone(), eyes_tiles, animation::GHOST_EATEN_SPEED);
let mut animations = HashMap::new();
for ghost_type in [Ghost::Blinky, Ghost::Pinky, Ghost::Inky, Ghost::Clyde] {
// Normal animations - create directional tiles for each direction
let up_tiles = [
atlas
.get_tile(&format!("ghost/{}/up_a.png", ghost_type.as_str()))
.ok_or_else(|| {
GameError::Texture(TextureError::AtlasTileNotFound(format!(
"ghost/{}/up_a.png",
ghost_type.as_str()
)))
})?,
atlas
.get_tile(&format!("ghost/{}/up_b.png", ghost_type.as_str()))
.ok_or_else(|| {
GameError::Texture(TextureError::AtlasTileNotFound(format!(
"ghost/{}/up_b.png",
ghost_type.as_str()
)))
})?,
atlas.get_tile(&GameSprite::Ghost(GhostSprite::Normal(ghost_type, Direction::Up, 0)).to_path())?,
atlas.get_tile(&GameSprite::Ghost(GhostSprite::Normal(ghost_type, Direction::Up, 1)).to_path())?,
];
let down_tiles = [
atlas
.get_tile(&format!("ghost/{}/down_a.png", ghost_type.as_str()))
.ok_or_else(|| {
GameError::Texture(TextureError::AtlasTileNotFound(format!(
"ghost/{}/down_a.png",
ghost_type.as_str()
)))
})?,
atlas
.get_tile(&format!("ghost/{}/down_b.png", ghost_type.as_str()))
.ok_or_else(|| {
GameError::Texture(TextureError::AtlasTileNotFound(format!(
"ghost/{}/down_b.png",
ghost_type.as_str()
)))
})?,
atlas.get_tile(&GameSprite::Ghost(GhostSprite::Normal(ghost_type, Direction::Down, 0)).to_path())?,
atlas.get_tile(&GameSprite::Ghost(GhostSprite::Normal(ghost_type, Direction::Down, 1)).to_path())?,
];
let left_tiles = [
atlas
.get_tile(&format!("ghost/{}/left_a.png", ghost_type.as_str()))
.ok_or_else(|| {
GameError::Texture(TextureError::AtlasTileNotFound(format!(
"ghost/{}/left_a.png",
ghost_type.as_str()
)))
})?,
atlas
.get_tile(&format!("ghost/{}/left_b.png", ghost_type.as_str()))
.ok_or_else(|| {
GameError::Texture(TextureError::AtlasTileNotFound(format!(
"ghost/{}/left_b.png",
ghost_type.as_str()
)))
})?,
atlas.get_tile(&GameSprite::Ghost(GhostSprite::Normal(ghost_type, Direction::Left, 0)).to_path())?,
atlas.get_tile(&GameSprite::Ghost(GhostSprite::Normal(ghost_type, Direction::Left, 1)).to_path())?,
];
let right_tiles = [
atlas
.get_tile(&format!("ghost/{}/right_a.png", ghost_type.as_str()))
.ok_or_else(|| {
GameError::Texture(TextureError::AtlasTileNotFound(format!(
"ghost/{}/right_a.png",
ghost_type.as_str()
)))
})?,
atlas
.get_tile(&format!("ghost/{}/right_b.png", ghost_type.as_str()))
.ok_or_else(|| {
GameError::Texture(TextureError::AtlasTileNotFound(format!(
"ghost/{}/right_b.png",
ghost_type.as_str()
)))
})?,
atlas.get_tile(&GameSprite::Ghost(GhostSprite::Normal(ghost_type, Direction::Right, 0)).to_path())?,
atlas.get_tile(&GameSprite::Ghost(GhostSprite::Normal(ghost_type, Direction::Right, 1)).to_path())?,
];
let normal_moving = DirectionalTiles::new(
@@ -606,25 +651,21 @@ impl Game {
TileSequence::new(&left_tiles),
TileSequence::new(&right_tiles),
);
let normal = DirectionalAnimation::new(normal_moving, normal_moving, animation::GHOST_NORMAL_SPEED);
let normal = DirectionalAnimation::new(normal_moving.clone(), normal_moving, animation::GHOST_NORMAL_SPEED);
animations.insert(ghost_type, normal);
}
let (frightened, frightened_flashing) = {
// Load frightened animation tiles (same for all ghosts)
let frightened_blue_a = atlas
.get_tile("ghost/frightened/blue_a.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("ghost/frightened/blue_a.png".to_string())))?;
let frightened_blue_b = atlas
.get_tile("ghost/frightened/blue_b.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("ghost/frightened/blue_b.png".to_string())))?;
let frightened_white_a = atlas
.get_tile("ghost/frightened/white_a.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("ghost/frightened/white_a.png".to_string())))?;
let frightened_white_b = atlas
.get_tile("ghost/frightened/white_b.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("ghost/frightened/white_b.png".to_string())))?;
let frightened_blue_a =
atlas.get_tile(&GameSprite::Ghost(GhostSprite::Frightened(FrightenedColor::Blue, 0)).to_path())?;
let frightened_blue_b =
atlas.get_tile(&GameSprite::Ghost(GhostSprite::Frightened(FrightenedColor::Blue, 1)).to_path())?;
let frightened_white_a =
atlas.get_tile(&GameSprite::Ghost(GhostSprite::Frightened(FrightenedColor::White, 0)).to_path())?;
let frightened_white_b =
atlas.get_tile(&GameSprite::Ghost(GhostSprite::Frightened(FrightenedColor::White, 1)).to_path())?;
(
LinearAnimation::new(
@@ -657,10 +698,34 @@ impl Game {
///
/// `true` if the game should terminate (exit command received), `false` to continue
pub fn tick(&mut self, dt: f32) -> bool {
self.world.insert_resource(DeltaTime(dt));
self.world.insert_resource(DeltaTime { seconds: dt, ticks: 1 });
// Run all systems
// Note: We don't need to read the current tick here since we increment it after running systems
// Measure total frame time including all systems
let start = std::time::Instant::now();
self.schedule.run(&mut self.world);
let total_duration = start.elapsed();
// Increment tick counter and record the total timing
if let (Some(timings), Some(timing)) = (
self.world.get_resource::<systems::profiling::SystemTimings>(),
self.world.get_resource::<Timing>(),
) {
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() > 20 {
// Warn if frame takes more than 20ms
warn!(
duration_ms = total_duration.as_millis(),
frame_dt = ?std::time::Duration::from_secs_f32(dt),
tick = new_tick,
"Frame took longer than expected"
);
}
}
let state = self
.world
@@ -669,68 +734,4 @@ impl Game {
state.exit
}
// /// Renders pathfinding debug lines from each ghost to Pac-Man.
// ///
// /// Each ghost's path is drawn in its respective color with a small offset
// /// to prevent overlapping lines.
// fn render_pathfinding_debug<T: sdl2::render::RenderTarget>(&self, canvas: &mut Canvas<T>) -> GameResult<()> {
// let pacman_node = self.state.pacman.current_node_id();
// for ghost in self.state.ghosts.iter() {
// if let Ok(path) = ghost.calculate_path_to_target(&self.state.map.graph, pacman_node) {
// if path.len() < 2 {
// continue; // Skip if path is too short
// }
// // Set the ghost's color
// canvas.set_draw_color(ghost.debug_color());
// // Calculate offset based on ghost index to prevent overlapping lines
// // let offset = (i as f32) * 2.0 - 3.0; // Offset range: -3.0 to 3.0
// // Calculate a consistent offset direction for the entire path
// // let first_node = self.map.graph.get_node(path[0]).unwrap();
// // let last_node = self.map.graph.get_node(path[path.len() - 1]).unwrap();
// // Use the overall direction from start to end to determine the perpendicular offset
// let offset = match ghost.ghost_type {
// GhostType::Blinky => glam::Vec2::new(0.25, 0.5),
// GhostType::Pinky => glam::Vec2::new(-0.25, -0.25),
// GhostType::Inky => glam::Vec2::new(0.5, -0.5),
// GhostType::Clyde => glam::Vec2::new(-0.5, 0.25),
// } * 5.0;
// // Calculate offset positions for all nodes using the same perpendicular direction
// let mut offset_positions = Vec::new();
// for &node_id in &path {
// let node = self
// .state
// .map
// .graph
// .get_node(node_id)
// .ok_or(crate::error::EntityError::NodeNotFound(node_id))?;
// let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
// offset_positions.push(pos + offset);
// }
// // Draw lines between the offset positions
// for window in offset_positions.windows(2) {
// if let (Some(from), Some(to)) = (window.first(), window.get(1)) {
// // Skip if the distance is too far (used for preventing lines between tunnel portals)
// if from.distance_squared(*to) > (crate::constants::CELL_SIZE * 16).pow(2) as f32 {
// continue;
// }
// // Draw the line
// canvas
// .draw_line((from.x as i32, from.y as i32), (to.x as i32, to.y as i32))
// .map_err(|e| crate::error::GameError::Sdl(e.to_string()))?;
// }
// }
// }
// }
// Ok(())
// }
}

View File

@@ -1,13 +1,22 @@
//! Pac-Man game library crate.
#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
#[cfg_attr(coverage_nightly, coverage(off))]
pub mod app;
pub mod asset;
#[cfg_attr(coverage_nightly, coverage(off))]
pub mod audio;
pub mod constants;
#[cfg_attr(coverage_nightly, coverage(off))]
pub mod error;
#[cfg_attr(coverage_nightly, coverage(off))]
pub mod events;
#[cfg_attr(coverage_nightly, coverage(off))]
pub mod formatter;
#[cfg_attr(coverage_nightly, coverage(off))]
pub mod platform;
pub mod asset;
pub mod constants;
pub mod game;
pub mod map;
pub mod platform;
pub mod systems;
pub mod texture;

View File

@@ -1,19 +1,27 @@
// 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))]
use crate::{app::App, constants::LOOP_TIME};
use tracing::info;
#[cfg_attr(coverage_nightly, coverage(off))]
mod app;
mod asset;
#[cfg_attr(coverage_nightly, coverage(off))]
mod audio;
mod constants;
#[cfg_attr(coverage_nightly, coverage(off))]
mod error;
#[cfg_attr(coverage_nightly, coverage(off))]
mod events;
#[cfg_attr(coverage_nightly, coverage(off))]
mod formatter;
#[cfg_attr(coverage_nightly, coverage(off))]
mod platform;
mod asset;
mod constants;
mod game;
mod map;
mod platform;
mod systems;
mod texture;
@@ -21,6 +29,7 @@ mod texture;
///
/// This function initializes SDL, the window, the game state, and then enters
/// the main game loop.
#[cfg_attr(coverage_nightly, coverage(off))]
pub fn main() {
// On Windows, this connects output streams to the console dynamically
// On Emscripten, this connects the subscriber to the browser console

View File

@@ -56,11 +56,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();
@@ -157,8 +163,10 @@ impl Map {
};
// 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,
@@ -359,12 +367,7 @@ impl Map {
+ IVec2::from(Direction::Left.as_ivec2()).as_vec2() * (CELL_SIZE as f32 * 2.0),
},
)
.map_err(|e| {
MapError::InvalidConfig(format!(
"Failed to connect left tunnel entrance to left tunnel hidden node: {}",
e
))
})?
.expect("Failed to connect left tunnel entrance to left tunnel hidden node")
};
// Create the right tunnel nodes
@@ -384,12 +387,7 @@ impl Map {
+ IVec2::from(Direction::Right.as_ivec2()).as_vec2() * (CELL_SIZE as f32 * 2.0),
},
)
.map_err(|e| {
MapError::InvalidConfig(format!(
"Failed to connect right tunnel entrance to right tunnel hidden node: {}",
e
))
})?
.expect("Failed to connect right tunnel entrance to right tunnel hidden node")
};
// Connect the left tunnel hidden node to the right tunnel hidden node
@@ -401,12 +399,7 @@ impl Map {
Some(0.0),
Direction::Left,
)
.map_err(|e| {
MapError::InvalidConfig(format!(
"Failed to connect left tunnel hidden node to right tunnel hidden node: {}",
e
))
})?;
.expect("Failed to connect left tunnel hidden node to right tunnel hidden node");
Ok(())
}

View File

@@ -1,57 +0,0 @@
#![allow(dead_code)]
//! Buffered writer for tracing logs that can store logs before console attachment.
use parking_lot::Mutex;
use std::io::{self, Write};
use std::sync::Arc;
/// A thread-safe buffered writer that stores logs in memory until flushed.
#[derive(Clone)]
pub struct BufferedWriter {
buffer: Arc<Mutex<Vec<u8>>>,
}
impl BufferedWriter {
/// Creates a new buffered writer.
pub fn new() -> Self {
Self {
buffer: Arc::new(Mutex::new(Vec::new())),
}
}
/// Flushes all buffered content to the provided writer and clears the buffer.
pub fn flush_to<W: Write>(&self, mut writer: W) -> io::Result<()> {
let mut buffer = self.buffer.lock();
if !buffer.is_empty() {
writer.write_all(&buffer)?;
writer.flush()?;
buffer.clear();
}
Ok(())
}
/// Returns the current buffer size in bytes.
pub fn buffer_size(&self) -> usize {
self.buffer.lock().len()
}
}
impl Write for BufferedWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let mut buffer = self.buffer.lock();
buffer.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
// For buffered writer, flush is a no-op since we're storing in memory
Ok(())
}
}
impl Default for BufferedWriter {
fn default() -> Self {
Self::new()
}
}

View File

@@ -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;
@@ -65,6 +65,7 @@ pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
Asset::Wav4 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/4.ogg"))),
Asset::AtlasImage => Ok(Cow::Borrowed(include_bytes!("../../assets/game/atlas.png"))),
Asset::Font => Ok(Cow::Borrowed(include_bytes!("../../assets/game/TerminalVector.ttf"))),
Asset::DeathSound => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman_death.wav"))),
}
}
@@ -78,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,
@@ -113,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))

View File

@@ -2,6 +2,7 @@
use crate::asset::Asset;
use crate::error::{AssetError, PlatformError};
use crate::formatter::CustomFormatter;
use rand::{rngs::SmallRng, SeedableRng};
use sdl2::rwops::RWops;
use std::borrow::Cow;
@@ -33,8 +34,7 @@ pub fn init_console() -> Result<(), PlatformError> {
fmt::layer()
.with_writer(|| EmscriptenConsoleWriter)
.with_ansi(false)
.without_time()
.with_target(false),
.event_format(CustomFormatter),
)
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug")));

View File

@@ -1,7 +1,5 @@
//! Platform abstraction layer for cross-platform functionality.
#[cfg(not(target_os = "emscripten"))]
pub mod buffered_writer;
#[cfg(not(target_os = "emscripten"))]
mod desktop;
#[cfg(not(target_os = "emscripten"))]

View File

@@ -1,14 +1,66 @@
#![allow(dead_code)]
//! Buffered tracing setup for handling logs before console attachment.
use crate::platform::buffered_writer::BufferedWriter;
use crate::formatter::CustomFormatter;
use parking_lot::Mutex;
use std::io;
use std::io::Write;
use std::sync::Arc;
use tracing::{debug, Level};
use tracing_error::ErrorLayer;
use tracing_subscriber::fmt::MakeWriter;
use tracing_subscriber::layer::SubscriberExt;
/// A thread-safe buffered writer that stores logs in memory until flushed.
#[derive(Clone)]
pub struct BufferedWriter {
buffer: Arc<Mutex<Vec<u8>>>,
}
impl BufferedWriter {
/// Creates a new buffered writer.
pub fn new() -> Self {
Self {
buffer: Arc::new(Mutex::new(Vec::new())),
}
}
/// Flushes all buffered content to the provided writer and clears the buffer.
pub fn flush_to<W: Write>(&self, mut writer: W) -> io::Result<()> {
let mut buffer = self.buffer.lock();
if !buffer.is_empty() {
writer.write_all(&buffer)?;
writer.flush()?;
buffer.clear();
}
Ok(())
}
/// Returns the current buffer size in bytes.
pub fn buffer_size(&self) -> usize {
self.buffer.lock().len()
}
}
impl Write for BufferedWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let mut buffer = self.buffer.lock();
buffer.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
// For buffered writer, flush is a no-op since we're storing in memory
Ok(())
}
}
impl Default for BufferedWriter {
fn default() -> Self {
Self::new()
}
}
/// A writer that can switch between buffering and direct output.
#[derive(Clone, Default)]
pub struct SwitchableWriter {
@@ -90,6 +142,7 @@ pub fn setup_switchable_subscriber() -> SwitchableWriter {
let _subscriber = tracing_subscriber::fmt()
.with_ansi(cfg!(not(target_os = "emscripten")))
.with_max_level(Level::DEBUG)
.event_format(CustomFormatter)
.with_writer(make_writer)
.finish()
.with(ErrorLayer::default());

View File

@@ -9,6 +9,7 @@ use bevy_ecs::{
resource::Resource,
system::{NonSendMut, ResMut},
};
use tracing::{debug, trace};
use crate::{audio::Audio, error::GameError};
@@ -26,6 +27,10 @@ pub struct AudioState {
pub enum AudioEvent {
/// Play the "eat" sound when Pac-Man consumes a pellet
PlayEat,
/// Play the death sound
PlayDeath,
/// Stop all currently playing sounds
StopAll,
}
/// Non-send resource wrapper for SDL2 audio system
@@ -45,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);
}
@@ -53,10 +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");
}
}
}

View File

@@ -12,20 +12,24 @@ use crate::systems::{
#[derive(Component, Debug)]
pub struct Blinking {
pub timer: f32,
pub interval: f32,
pub tick_timer: u32,
pub interval_ticks: u32,
}
impl Blinking {
pub fn new(interval: f32) -> Self {
Self { timer: 0.0, interval }
pub fn new(interval_ticks: u32) -> Self {
Self {
tick_timer: 0,
interval_ticks,
}
}
}
/// Updates blinking entities by toggling their visibility at regular intervals.
///
/// This system manages entities that have both `Blinking` and `Renderable` components,
/// accumulating time and toggling visibility when the specified interval is reached.
/// accumulating ticks and toggling visibility when the specified interval is reached.
/// Uses integer arithmetic for deterministic behavior.
#[allow(clippy::type_complexity)]
pub fn blinking_system(
mut commands: Commands,
@@ -42,22 +46,40 @@ pub fn blinking_system(
continue;
}
// Increase the timer by the delta time
blinking.timer += time.0;
// Increase the timer by the delta ticks
blinking.tick_timer += time.ticks;
// If the timer is less than the interval, there's nothing to do yet
if blinking.timer < blinking.interval {
// Handle zero interval case (immediate toggling)
if blinking.interval_ticks == 0 {
if time.ticks > 0 {
if hidden {
commands.entity(entity).remove::<Hidden>();
} else {
commands.entity(entity).insert(Hidden);
}
}
continue;
}
// Subtract the interval (allows for the timer to retain partial interval progress)
blinking.timer -= blinking.interval;
// Calculate how many complete intervals have passed
let complete_intervals = blinking.tick_timer / blinking.interval_ticks;
// Toggle the Hidden component
if hidden {
commands.entity(entity).remove::<Hidden>();
} else {
commands.entity(entity).insert(Hidden);
// If no complete intervals have passed, there's nothing to do yet
if complete_intervals == 0 {
continue;
}
// Update the timer to the remainder after complete intervals
blinking.tick_timer %= blinking.interval_ticks;
// Toggle the Hidden component for each complete interval
// Since toggling twice is a no-op, we only need to toggle if the count is odd
if complete_intervals % 2 == 1 {
if hidden {
commands.entity(entity).remove::<Hidden>();
} else {
commands.entity(entity).insert(Hidden);
}
}
}
}

View File

@@ -1,15 +1,21 @@
use bevy_ecs::component::Component;
use bevy_ecs::entity::Entity;
use bevy_ecs::event::{EventReader, EventWriter};
use bevy_ecs::query::With;
use bevy_ecs::system::{Query, Res, ResMut};
use bevy_ecs::{
component::Component,
entity::Entity,
event::{EventReader, EventWriter},
query::With,
system::{Commands, Query, Res, ResMut},
};
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::movement::Position;
use crate::systems::{AudioEvent, Ghost, GhostState, PlayerControlled, ScoreResource};
use crate::systems::{
components::GhostState, movement::Position, AudioEvent, DyingSequence, Frozen, GameStage, Ghost, PlayerControlled,
ScoreResource,
};
/// A component for defining the collision area of an entity.
#[derive(Component)]
pub struct Collider {
pub size: f32,
@@ -62,6 +68,7 @@ pub fn check_collision(
///
/// Also detects collisions between Pac-Man and ghosts for gameplay mechanics like
/// power pellet effects, ghost eating, and player death.
#[allow(clippy::too_many_arguments)]
pub fn collision_system(
map: Res<Map>,
pacman_query: Query<(Entity, &Position, &Collider), With<PacmanCollider>>,
@@ -76,6 +83,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));
}
}
@@ -93,6 +101,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));
}
}
@@ -107,10 +116,14 @@ pub fn collision_system(
}
}
#[allow(clippy::too_many_arguments)]
pub fn ghost_collision_system(
mut commands: Commands,
mut collision_events: EventReader<GameEvent>,
mut stage_events: EventWriter<StageTransition>,
mut score: ResMut<ScoreResource>,
pacman_query: Query<(), With<PlayerControlled>>,
mut game_state: ResMut<GameStage>,
pacman_query: Query<Entity, With<PlayerControlled>>,
ghost_query: Query<(Entity, &Ghost), With<GhostCollider>>,
mut ghost_state_query: Query<&mut GhostState>,
mut events: EventWriter<AudioEvent>,
@@ -118,7 +131,7 @@ 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 pacman_query.get(*entity1).is_ok() && ghost_query.get(*entity2).is_ok() {
(*entity1, *entity2)
} else if pacman_query.get(*entity2).is_ok() && ghost_query.get(*entity1).is_ok() {
(*entity2, *entity1)
@@ -128,20 +141,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 {
// Pac-Man dies (this would need a death system)
trace!(ghost_state = ?*ghost_state, "Ghost collision ignored due to state");
}
}
}

View File

@@ -101,7 +101,7 @@ pub struct Renderable {
}
/// Directional animation component with shared timing across all directions
#[derive(Component, Clone, Copy)]
#[derive(Component, Clone)]
pub struct DirectionalAnimation {
pub moving_tiles: DirectionalTiles,
pub stopped_tiles: DirectionalTiles,
@@ -123,13 +123,18 @@ impl DirectionalAnimation {
}
}
/// 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, Clone, Copy)]
#[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 {
@@ -140,6 +145,7 @@ impl LinearAnimation {
current_frame: 0,
time_bank: 0,
frame_duration,
finished: false,
}
}
}
@@ -162,7 +168,35 @@ pub struct GlobalState {
pub struct ScoreResource(pub u32);
#[derive(Resource)]
pub struct DeltaTime(pub f32);
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)]
@@ -190,6 +224,11 @@ pub struct Frozen;
#[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

View File

@@ -1,6 +1,5 @@
//! Debug rendering system
use std::cmp::Ordering;
#[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};
@@ -13,6 +12,7 @@ use sdl2::rect::{Point, Rect};
use sdl2::render::{Canvas, Texture};
use sdl2::video::Window;
use smallvec::SmallVec;
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use tracing::warn;
@@ -149,14 +149,16 @@ fn transform_position_with_offset(pos: Vec2, scale: f32) -> IVec2 {
}
/// Renders timing information in the top-left corner of the screen using the debug text atlas
#[cfg_attr(coverage_nightly, coverage(off))]
fn render_timing_display(
canvas: &mut Canvas<Window>,
timings: &SystemTimings,
current_tick: u64,
text_renderer: &TtfRenderer,
atlas: &mut TtfAtlas,
) {
// Format timing information using the formatting module
let lines = timings.format_timing_display();
let lines = timings.format_timing_display(current_tick);
let line_height = text_renderer.text_height(atlas) as i32 + 2; // Add 2px line spacing
let padding = 10;
@@ -202,12 +204,14 @@ fn render_timing_display(
}
#[allow(clippy::too_many_arguments)]
#[cfg_attr(coverage_nightly, coverage(off))]
pub fn debug_render_system(
canvas: &mut Canvas<Window>,
ttf_atlas: &mut TtfAtlasResource,
batched_lines: &Res<BatchedLinesResource>,
debug_state: &Res<DebugState>,
timings: &Res<SystemTimings>,
timing: &Res<crate::systems::profiling::Timing>,
map: &Res<Map>,
colliders: &Query<(&Collider, &Position)>,
cursor: &Res<CursorPosition>,
@@ -329,5 +333,8 @@ pub fn debug_render_system(
}
// Render timing information in the top-left corner
render_timing_display(canvas, timings, &text_renderer, &mut ttf_atlas.0);
// Use previous tick since current tick is incomplete (frame is still running)
let current_tick = timing.get_current_tick();
let previous_tick = current_tick.saturating_sub(1);
render_timing_display(canvas, timings, previous_tick, &text_renderer, &mut ttf_atlas.0);
}

View File

@@ -1,5 +1,7 @@
use crate::platform;
use crate::systems::components::{DirectionalAnimation, Frozen, GhostAnimation, GhostState, LastAnimationState, LinearAnimation};
use crate::systems::components::{
DirectionalAnimation, Frozen, GhostAnimation, GhostState, LastAnimationState, LinearAnimation, Looping,
};
use crate::{
map::{
builder::Map,
@@ -11,6 +13,7 @@ use crate::{
movement::{Position, Velocity},
},
};
use tracing::{debug, trace, warn};
use crate::systems::GhostAnimations;
use bevy_ecs::query::Without;
@@ -25,7 +28,7 @@ pub fn ghost_movement_system(
mut ghosts: Query<(&Ghost, &mut Velocity, &mut Position), Without<Frozen>>,
) {
for (_ghost, mut velocity, mut position) in ghosts.iter_mut() {
let mut distance = velocity.speed * 60.0 * delta_time.0;
let mut distance = velocity.speed * 60.0 * delta_time.seconds;
loop {
match *position {
Position::Stopped { node: current_node } => {
@@ -43,8 +46,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 {
@@ -111,11 +116,12 @@ pub fn eaten_ghost_system(
}
}
Position::Moving { to, .. } => {
let distance = velocity.speed * 60.0 * delta_time.0;
let distance = velocity.speed * 60.0 * delta_time.seconds;
if let Some(_overflow) = position.tick(distance) {
// 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 {
@@ -192,24 +198,30 @@ 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
// Remove DirectionalAnimation, add LinearAnimation with Looping component
commands
.entity(entity)
.remove::<DirectionalAnimation>()
.insert(*animations.frightened(flash));
.insert(animations.frightened(flash).clone())
.insert(Looping);
}
GhostAnimation::Normal => {
// Remove LinearAnimation, add DirectionalAnimation
// Remove LinearAnimation and Looping, add DirectionalAnimation
commands
.entity(entity)
.remove::<LinearAnimation>()
.insert(*animations.get_normal(ghost_type).unwrap());
.remove::<(LinearAnimation, Looping)>()
.insert(animations.get_normal(ghost_type).unwrap().clone());
}
GhostAnimation::Eyes => {
// Remove LinearAnimation, add DirectionalAnimation (eyes animation)
commands.entity(entity).remove::<LinearAnimation>().insert(*animations.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)>()
.insert(animations.eyes().clone());
}
}
last_animation_state.0 = current_animation_state;

View File

@@ -12,7 +12,6 @@ use sdl2::{
EventPump,
};
use smallvec::{smallvec, SmallVec};
use tracing::{debug, info};
use crate::systems::components::DeltaTime;
use crate::{
@@ -21,10 +20,10 @@ use crate::{
};
// Touch input constants
const TOUCH_DIRECTION_THRESHOLD: f32 = 10.0;
const TOUCH_EASING_DISTANCE_THRESHOLD: f32 = 1.0;
const MAX_TOUCH_MOVEMENT_SPEED: f32 = 100.0;
const TOUCH_EASING_FACTOR: f32 = 1.5;
pub const TOUCH_DIRECTION_THRESHOLD: f32 = 10.0;
pub const TOUCH_EASING_DISTANCE_THRESHOLD: f32 = 1.0;
pub const MAX_TOUCH_MOVEMENT_SPEED: f32 = 100.0;
pub const TOUCH_EASING_FACTOR: f32 = 1.5;
#[derive(Resource, Default, Debug, Copy, Clone)]
pub enum CursorPosition {
@@ -36,7 +35,7 @@ pub enum CursorPosition {
},
}
#[derive(Resource, Default, Debug)]
#[derive(Resource, Default, Debug, Clone)]
pub struct TouchState {
pub active_touch: Option<TouchData>,
}
@@ -161,7 +160,7 @@ pub fn process_simple_key_events(bindings: &mut Bindings, frame_events: &[Simple
}
/// Calculates the primary direction from a 2D vector delta
fn calculate_direction_from_delta(delta: Vec2) -> Direction {
pub fn calculate_direction_from_delta(delta: Vec2) -> Direction {
if delta.x.abs() > delta.y.abs() {
if delta.x > 0.0 {
Direction::Right
@@ -180,7 +179,7 @@ fn calculate_direction_from_delta(delta: Vec2) -> Direction {
/// This slowly moves the start_pos towards the current_pos, with the speed
/// decreasing as the distance gets smaller. The maximum movement speed is capped.
/// Returns the delta vector and its length for reuse by the caller.
fn update_touch_reference_position(touch_data: &mut TouchData, delta_time: f32) -> (Vec2, f32) {
pub fn update_touch_reference_position(touch_data: &mut TouchData, delta_time: f32) -> (Vec2, f32) {
// Calculate the vector from start to current position
let delta = touch_data.current_pos - touch_data.start_pos;
let distance = delta.length();
@@ -221,16 +220,6 @@ pub fn input_system(
// Collect all events for this frame.
let frame_events: SmallVec<[Event; 3]> = pump.poll_iter().collect();
// Warn if the smallvec was heap allocated due to exceeding stack capacity
#[cfg(debug_assertions)]
if frame_events.len() > frame_events.capacity() {
tracing::warn!(
"More than {} events in a frame, consider adjusting stack capacity: {:?}",
frame_events.capacity(),
frame_events
);
}
// Handle non-keyboard events inline and build a simplified keyboard event stream.
let mut simple_key_events: SmallVec<[SimpleKeyEvent; 3]> = smallvec![];
for event in &frame_events {
@@ -298,19 +287,15 @@ pub fn input_system(
simple_key_events.push(SimpleKeyEvent::KeyUp(key));
}
}
Event::Window { win_event, .. } => match win_event {
WindowEvent::Resized(w, h) => {
info!("Window resized to {}x{}", w, h);
Event::Window { win_event, .. } => {
if let WindowEvent::Resized(w, h) = win_event {
tracing::info!(width = w, height = h, event = ?win_event, "Window Resized");
}
_ => {
debug!("Window event: {:?}", win_event);
}
},
Event::RenderTargetsReset { .. } => {
// No-op
}
// Despite disabling this event, it's still received, so we ignore it explicitly.
Event::RenderTargetsReset { .. } => {}
_ => {
tracing::warn!("Unhandled event, consider disabling: {:?}", event);
tracing::warn!(event = ?event, "Unhandled Event");
}
}
}
@@ -324,7 +309,7 @@ pub fn input_system(
// Update touch reference position with easing
if let Some(ref mut touch_data) = touch_state.active_touch {
// Apply easing to the reference position and get the delta for direction calculation
let (delta, distance) = update_touch_reference_position(touch_data, delta_time.0);
let (delta, distance) = update_touch_reference_position(touch_data, delta_time.seconds);
// Check for direction based on updated reference position
if distance >= TOUCH_DIRECTION_THRESHOLD {
@@ -341,7 +326,7 @@ pub fn input_system(
}
if let (false, CursorPosition::Some { remaining_time, .. }) = (cursor_seen, &mut *cursor) {
*remaining_time -= delta_time.0;
*remaining_time -= delta_time.seconds;
if *remaining_time <= 0.0 {
*cursor = CursorPosition::None;
}

View File

@@ -4,6 +4,7 @@ use bevy_ecs::{
query::With,
system::{Commands, Query, ResMut},
};
use tracing::{debug, trace};
use crate::{
constants::animation::FRIGHTENED_FLASH_START_TICKS,
@@ -45,6 +46,7 @@ pub fn item_system(
// Get the item type and update score
if let Ok((item_ent, entity_type)) = item_query.get(item_entity) {
if let Some(score_value) = entity_type.score_value() {
trace!(item_entity = ?item_ent, item_type = ?entity_type, score_value, new_score = score.0 + score_value, "Item collected by player");
score.0 += score_value;
// Remove the collected item
@@ -59,13 +61,17 @@ pub fn item_system(
if *entity_type == EntityType::PowerPellet {
// Convert seconds to frames (assumes 60 FPS)
let total_ticks = 60 * 5; // 5 seconds total
debug!(duration_ticks = total_ticks, "Power pellet collected, frightening ghosts");
// Set all ghosts to frightened state, except those in Eyes state
let mut frightened_count = 0;
for mut ghost_state in ghost_query.iter_mut() {
if !matches!(*ghost_state, GhostState::Eyes) {
*ghost_state = GhostState::new_frightened(total_ticks, FRIGHTENED_FLASH_START_TICKS);
frightened_count += 1;
}
}
debug!(frightened_count, "Ghosts set to frightened state");
}
}
}

33
src/systems/lifetime.rs Normal file
View File

@@ -0,0 +1,33 @@
use bevy_ecs::{
component::Component,
entity::Entity,
system::{Commands, Query, Res},
};
use crate::systems::components::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);
}
}
}

View File

@@ -1,21 +1,26 @@
//! The Entity-Component-System (ECS) module.
//!
//! This module contains all the ECS-related logic, including components, systems,
//! and resources.
//! This module contains all the systems in the game.
#[cfg_attr(coverage_nightly, coverage(off))]
pub mod audio;
#[cfg_attr(coverage_nightly, coverage(off))]
pub mod debug;
#[cfg_attr(coverage_nightly, coverage(off))]
pub mod profiling;
#[cfg_attr(coverage_nightly, coverage(off))]
pub mod render;
pub mod blinking;
pub mod collision;
pub mod components;
pub mod debug;
pub mod ghost;
pub mod input;
pub mod item;
pub mod lifetime;
pub mod movement;
pub mod player;
pub mod profiling;
pub mod render;
pub mod stage;
pub mod state;
// Re-export all the modules. Do not fine-tune the exports.
pub use self::audio::*;
pub use self::blinking::*;
@@ -25,8 +30,9 @@ 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::*;
pub use self::render::*;
pub use self::stage::*;
pub use self::state::*;

View File

@@ -3,6 +3,7 @@ use bevy_ecs::{
query::{With, Without},
system::{Query, Res, ResMut},
};
use tracing::trace;
use crate::{
error::GameError,
@@ -52,6 +53,7 @@ pub fn player_control_system(
}
};
trace!(direction = ?*direction, "Player direction buffered for movement");
*buffered_direction = BufferedDirection::Some {
direction: *direction,
remaining_time: 0.25,
@@ -86,6 +88,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,16 +98,17 @@ 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 {
direction,
remaining_time: remaining_time - delta_time.0,
remaining_time: remaining_time - delta_time.seconds,
};
}
}
let mut distance = velocity.speed * modifiers.speed_multiplier * 60.0 * delta_time.0;
let mut distance = velocity.speed * modifiers.speed_multiplier * 60.0 * delta_time.seconds;
loop {
match *position {
@@ -115,6 +119,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 +135,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 +146,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;
}
@@ -162,6 +175,16 @@ pub fn player_tunnel_slowdown_system(map: Res<Map>, mut q: Query<(&Position, &mu
.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 };
}

View File

@@ -1,11 +1,11 @@
use bevy_ecs::system::IntoSystem;
use bevy_ecs::{resource::Resource, system::System};
use circular_buffer::CircularBuffer;
use micromap::Map;
use num_width::NumberWidth;
use parking_lot::Mutex;
use smallvec::SmallVec;
use std::fmt::Display;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use strum::{EnumCount, IntoEnumIterator};
use strum_macros::{EnumCount, EnumIter, IntoStaticStr};
@@ -16,8 +16,127 @@ const MAX_SYSTEMS: usize = SystemId::COUNT;
/// The number of durations to keep in the circular buffer.
const TIMING_WINDOW_SIZE: usize = 30;
/// A timing buffer that tracks durations and automatically inserts zero durations for skipped ticks.
#[derive(Debug, Default)]
pub struct TimingBuffer {
/// Circular buffer storing timing durations
buffer: CircularBuffer<TIMING_WINDOW_SIZE, Duration>,
/// The last tick when this buffer was updated
last_tick: u64,
}
impl TimingBuffer {
/// Adds a timing duration for the current tick.
///
/// # Panics
///
/// Panics if `current_tick` is less than `last_tick`, indicating time went backwards.
pub fn add_timing(&mut self, duration: Duration, current_tick: u64) {
if current_tick < self.last_tick {
panic!(
"Time went backwards: current_tick ({}) < last_tick ({})",
current_tick, self.last_tick
);
}
// Insert zero durations for any skipped ticks (but not the current tick)
if current_tick > self.last_tick {
let skipped_ticks = current_tick - self.last_tick - 1;
for _ in 0..skipped_ticks {
self.buffer.push_back(Duration::ZERO);
}
}
// Add the actual timing
self.buffer.push_back(duration);
self.last_tick = current_tick;
}
/// Gets statistics for this timing buffer.
///
/// # Panics
///
/// Panics if `current_tick` is less than `last_tick`, indicating time went backwards.
pub fn get_stats(&mut self, current_tick: u64) -> (Duration, Duration) {
// Insert zero durations for any skipped ticks since last update (but not the current tick)
if current_tick > self.last_tick {
let skipped_ticks = current_tick - self.last_tick - 1;
for _ in 0..skipped_ticks {
self.buffer.push_back(Duration::ZERO);
}
self.last_tick = current_tick;
}
// Calculate statistics using Welford's algorithm
let mut sample_count = 0u16;
let mut running_mean = 0.0;
let mut sum_squared_diff = 0.0;
let skip = self.last_tick.saturating_sub(current_tick);
for duration in self.buffer.iter().skip(skip as usize) {
let duration_secs = duration.as_secs_f32();
sample_count += 1;
let diff_from_mean = duration_secs - running_mean;
running_mean += diff_from_mean / sample_count as f32;
let diff_from_new_mean = duration_secs - running_mean;
sum_squared_diff += diff_from_mean * diff_from_new_mean;
}
if sample_count > 0 {
let variance = if sample_count > 1 {
sum_squared_diff / (sample_count - 1) as f32
} else {
0.0
};
(
Duration::from_secs_f32(running_mean),
Duration::from_secs_f32(variance.sqrt()),
)
} else {
(Duration::ZERO, Duration::ZERO)
}
}
}
/// A resource that tracks the current game tick using an atomic counter.
/// This ensures thread-safe access to the tick counter across systems.
#[derive(Resource, Debug)]
pub struct Timing {
/// Atomic counter for the current game tick
current_tick: AtomicU64,
}
impl Timing {
/// Creates a new Timing resource starting at tick 0
pub fn new() -> Self {
Self {
current_tick: AtomicU64::new(0),
}
}
/// Gets the current tick value
pub fn get_current_tick(&self) -> u64 {
self.current_tick.load(Ordering::Relaxed)
}
/// Increments the tick counter and returns the new value
pub fn increment_tick(&self) -> u64 {
self.current_tick.fetch_add(1, Ordering::Relaxed) + 1
}
}
impl Default for Timing {
fn default() -> Self {
Self::new()
}
}
#[derive(EnumCount, EnumIter, IntoStaticStr, Debug, PartialEq, Eq, Hash, Copy, Clone)]
pub enum SystemId {
Total,
Input,
PlayerControls,
Ghost,
@@ -38,37 +157,29 @@ pub enum SystemId {
Stage,
GhostStateAnimation,
EatenGhost,
TimeToLive,
}
impl Display for SystemId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Use strum_macros::IntoStaticStr to get the static string
write!(f, "{}", Into::<&'static str>::into(self).to_ascii_lowercase())
}
}
#[derive(Resource, Debug)]
pub struct SystemTimings {
/// Map of system names to a queue of durations, using a circular buffer.
///
/// Uses a RwLock to allow multiple readers for the HashMap, and a Mutex on the circular buffer for exclusive access.
/// This is probably overkill, but it's fun to play with.
///
/// Also, we use a micromap::Map as the number of systems is generally quite small.
/// Just make sure to set the capacity appropriately, or it will panic.
///
/// Pre-populated with all SystemId variants during initialization to avoid runtime allocations
/// and allow systems to have default zero timings when they don't submit data.
pub timings: Map<SystemId, Mutex<CircularBuffer<TIMING_WINDOW_SIZE, Duration>>, MAX_SYSTEMS>,
/// Statically sized map of system names to timing buffers.
pub timings: micromap::Map<SystemId, Mutex<TimingBuffer>, MAX_SYSTEMS>,
}
impl Default for SystemTimings {
fn default() -> Self {
let mut timings = Map::new();
let mut timings = micromap::Map::new();
// Pre-populate with all SystemId variants to avoid runtime allocations
// and provide default zero timings for systems that don't submit data
for id in SystemId::iter() {
timings.insert(id, Mutex::new(CircularBuffer::new()));
timings.insert(id, Mutex::new(TimingBuffer::default()));
}
Self { timings }
@@ -76,94 +187,61 @@ impl Default for SystemTimings {
}
impl SystemTimings {
pub fn add_timing(&self, id: SystemId, duration: Duration) {
pub fn add_timing(&self, id: SystemId, duration: Duration, current_tick: u64) {
// Since all SystemId variants are pre-populated, we can use a simple read lock
let queue = self
let buffer = self
.timings
.get(&id)
.expect("SystemId not found in pre-populated map - this is a bug");
queue.lock().push_back(duration);
buffer.lock().add_timing(duration, current_tick);
}
pub fn get_stats(&self) -> Map<SystemId, (Duration, Duration), MAX_SYSTEMS> {
let mut stats = Map::new();
/// Add timing for the Total system (total frame time including scheduler.run)
pub fn add_total_timing(&self, duration: Duration, current_tick: u64) {
self.add_timing(SystemId::Total, duration, current_tick);
}
pub fn get_stats(&self, current_tick: u64) -> micromap::Map<SystemId, (Duration, Duration), MAX_SYSTEMS> {
let mut stats = micromap::Map::new();
// Iterate over all SystemId variants to ensure every system has an entry
for id in SystemId::iter() {
let queue = self
let buffer = self
.timings
.get(&id)
.expect("SystemId not found in pre-populated map - this is a bug");
let queue_guard = queue.lock();
if queue_guard.is_empty() {
// Return zero timing for systems that haven't submitted any data
stats.insert(id, (Duration::ZERO, Duration::ZERO));
continue;
}
let durations: Vec<f64> = queue_guard.iter().map(|d| d.as_secs_f64() * 1000.0).collect();
let count = durations.len() as f64;
let sum: f64 = durations.iter().sum();
let mean = sum / count;
let variance = durations.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (count - 1.0).max(1.0);
let std_dev = variance.sqrt();
stats.insert(
id,
(
Duration::from_secs_f64(mean / 1000.0),
Duration::from_secs_f64(std_dev / 1000.0),
),
);
let (average, standard_deviation) = buffer.lock().get_stats(current_tick);
stats.insert(id, (average, standard_deviation));
}
stats
}
pub fn get_total_stats(&self) -> (Duration, Duration) {
let duration_sums = {
self.timings
.iter()
.map(|(_, queue)| queue.lock().iter().sum::<Duration>())
.collect::<Vec<_>>()
};
pub fn format_timing_display(&self, current_tick: u64) -> SmallVec<[String; SystemId::COUNT]> {
let stats = self.get_stats(current_tick);
let mean = duration_sums.iter().sum::<Duration>() / duration_sums.len() as u32;
let variance = duration_sums
.iter()
.map(|x| {
let diff_secs = x.as_secs_f64() - mean.as_secs_f64();
diff_secs * diff_secs
})
.sum::<f64>()
/ (duration_sums.len() - 1).max(1) as f64;
let std_dev_secs = variance.sqrt();
(mean, Duration::from_secs_f64(std_dev_secs))
}
pub fn format_timing_display(&self) -> SmallVec<[String; SystemId::COUNT]> {
let stats = self.get_stats();
let (total_avg, total_std) = self.get_total_stats();
// Get the Total system metrics instead of averaging all systems
let (total_avg, total_std) = stats
.get(&SystemId::Total)
.copied()
.unwrap_or((Duration::ZERO, Duration::ZERO));
let effective_fps = match 1.0 / total_avg.as_secs_f64() {
f if f > 100.0 => (f as u32).separate_with_commas(),
f if f > 100.0 => format!("{:>5} FPS", (f as u32).separate_with_commas()),
f if f < 10.0 => format!("{:.1} FPS", f),
f => format!("{:.0} FPS", f),
f => format!("{:5.0} FPS", f),
};
// Collect timing data for formatting
let mut timing_data = vec![(effective_fps, total_avg, total_std)];
// Sort the stats by average duration
let mut sorted_stats: Vec<_> = stats.iter().collect();
// Sort the stats by average duration, excluding the Total system
let mut sorted_stats: Vec<_> = stats.iter().filter(|(id, _)| **id != SystemId::Total).collect();
sorted_stats.sort_by(|a, b| b.1 .0.cmp(&a.1 .0));
// Add the top 5 most expensive systems
for (name, (avg, std_dev)) in sorted_stats.iter().take(7) {
// Add the top 7 most expensive systems (excluding Total)
for (name, (avg, std_dev)) in sorted_stats.iter().take(9) {
timing_data.push((name.to_string(), *avg, *std_dev));
}
@@ -188,8 +266,9 @@ where
system.run((), world);
let duration = start.elapsed();
if let Some(timings) = world.get_resource::<SystemTimings>() {
timings.add_timing(id, duration);
if let (Some(timings), Some(timing)) = (world.get_resource::<SystemTimings>(), world.get_resource::<Timing>()) {
let current_tick = timing.get_current_tick();
timings.add_timing(id, duration, current_tick);
}
}
}

View File

@@ -1,18 +1,22 @@
use crate::constants::CANVAS_SIZE;
use crate::error::{GameError, TextureError};
use crate::map::builder::Map;
use crate::map::direction::Direction;
use crate::systems::input::TouchState;
use crate::systems::{
debug_render_system, BatchedLinesResource, Collider, CursorPosition, DebugState, DebugTextureResource, DeltaTime,
DirectionalAnimation, LinearAnimation, Position, Renderable, ScoreResource, StartupSequence, SystemId, SystemTimings,
TtfAtlasResource, Velocity,
DirectionalAnimation, Dying, Frozen, GameStage, LinearAnimation, Looping, PlayerLives, Position, Renderable, ScoreResource,
StartupSequence, SystemId, SystemTimings, TtfAtlasResource, Velocity,
};
use crate::texture::sprite::SpriteAtlas;
use crate::texture::sprites::{GameSprite, PacmanSprite};
use crate::texture::text::TextTexture;
use crate::{
constants::{BOARD_BOTTOM_PIXEL_OFFSET, CANVAS_SIZE, CELL_SIZE},
error::{GameError, TextureError},
};
use bevy_ecs::component::Component;
use bevy_ecs::entity::Entity;
use bevy_ecs::event::EventWriter;
use bevy_ecs::query::{Changed, Or, Without};
use bevy_ecs::query::{Changed, Has, Or, With, Without};
use bevy_ecs::removal_detection::RemovedComponents;
use bevy_ecs::resource::Resource;
use bevy_ecs::system::{NonSendMut, Query, Res, ResMut};
@@ -42,7 +46,11 @@ 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;
}
}
@@ -53,9 +61,9 @@ pub fn dirty_render_system(
/// 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)>,
mut query: Query<(&Position, &Velocity, &mut DirectionalAnimation, &mut Renderable), Without<Frozen>>,
) {
let ticks = (dt.0 * 60.0).round() as u16; // Convert from seconds to ticks at 60 ticks/sec
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 { .. });
@@ -86,26 +94,35 @@ pub fn directional_render_system(
}
}
/// Updates linear animated entities (used for non-directional animations like frightened ghosts).
///
/// This system handles entities that use LinearAnimation component for simple frame cycling.
pub fn linear_render_system(dt: Res<DeltaTime>, mut query: Query<(&mut LinearAnimation, &mut Renderable)>) {
let ticks = (dt.0 * 60.0).round() as u16; // Convert from seconds to ticks at 60 ticks/sec
for (mut anim, mut renderable) in query.iter_mut() {
// Tick animation
anim.time_bank += ticks;
while anim.time_bank >= anim.frame_duration {
anim.time_bank -= anim.frame_duration;
anim.current_frame += 1;
/// 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;
}
if !anim.tiles.is_empty() {
let new_tile = anim.tiles.get_tile(anim.current_frame);
if renderable.sprite != new_tile {
renderable.sprite = new_tile;
}
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);
}
}
@@ -189,26 +206,56 @@ 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>,
startup: Res<StartupSequence>,
stage: Res<GameStage>,
mut errors: EventWriter<GameError>,
) {
let _ = canvas.with_texture_canvas(&mut backbuffer.0, |canvas| {
let mut text_renderer = TextTexture::new(1.0);
// Render lives and high score text in white
let lives = 3; // TODO: Get from actual lives resource
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());
}
// Render Pac-Man life sprites in bottom left
let lives = player_lives.0;
let life_sprite_path = &GameSprite::Pacman(PacmanSprite::Moving(Direction::Left, 1)).to_path();
// Get the sprite from the atlas for life display
match atlas.get_tile(life_sprite_path) {
Ok(life_sprite) => {
let start_x = CELL_SIZE * 2; // 2 cells from left
let start_y = CANVAS_SIZE.y - BOARD_BOTTOM_PIXEL_OFFSET.y + (CELL_SIZE / 2) + 1; // In bottom area
let sprite_spacing = CELL_SIZE + CELL_SIZE / 2; // 1.5 cells between sprites
// Render one sprite for each remaining life (lives - 1, since current life isn't shown)
let sprites_to_show = if lives > 0 { lives - 1 } else { 0 };
for i in 0..sprites_to_show {
let x = start_x + ((i as f32) * (sprite_spacing as f32 * 1.5)).round() as u32;
let y = start_y - CELL_SIZE / 2;
let dest = sdl2::rect::Rect::new(x as i32, y as i32, life_sprite.size.x as u32, life_sprite.size.y as u32);
if let Err(e) = life_sprite.render(canvas, &mut atlas, dest) {
errors.write(TextureError::RenderFailed(format!("Failed to render life sprite: {}", e)).into());
}
}
}
Err(e) => {
errors.write(e.into());
}
}
// Render score text
let score_text = format!("{:02}", score.0);
let score_offset = 7 - (score_text.len() as i32);
@@ -226,10 +273,21 @@ pub fn hud_render_system(
errors.write(TextureError::RenderFailed(format!("Failed to render high score text: {}", e)).into());
}
// Render GAME OVER text
if matches!(*stage, GameStage::GameOver) {
let game_over_text = "GAME OVER";
let game_over_width = text_renderer.text_width(game_over_text);
let game_over_position = glam::UVec2::new((CANVAS_SIZE.x - game_over_width) / 2, 160);
if let Err(e) = text_renderer.render_with_color(canvas, &mut atlas, game_over_text, game_over_position, Color::RED) {
errors.write(TextureError::RenderFailed(format!("Failed to render GAME OVER text: {}", e)).into());
}
}
// Render text based on StartupSequence stage
if matches!(
*startup,
StartupSequence::TextOnly { .. } | StartupSequence::CharactersVisible { .. }
*stage,
GameStage::Starting(StartupSequence::TextOnly { .. })
| GameStage::Starting(StartupSequence::CharactersVisible { .. })
) {
let ready_text = "READY!";
let ready_width = text_renderer.text_width(ready_text);
@@ -238,7 +296,7 @@ pub fn hud_render_system(
errors.write(TextureError::RenderFailed(format!("Failed to render READY text: {}", e)).into());
}
if matches!(*startup, StartupSequence::TextOnly { .. }) {
if matches!(*stage, GameStage::Starting(StartupSequence::TextOnly { .. })) {
let player_one_text = "PLAYER ONE";
let player_one_width = text_renderer.text_width(player_one_text);
let player_one_position = glam::UVec2::new((CANVAS_SIZE.x - player_one_width) / 2, 113);
@@ -317,6 +375,7 @@ pub fn combined_render_system(
batched_lines: Res<BatchedLinesResource>,
debug_state: Res<DebugState>,
timings: Res<SystemTimings>,
timing: Res<crate::systems::profiling::Timing>,
map: Res<Map>,
dirty: Res<RenderDirty>,
renderables: Query<(Entity, &Renderable, &Position), Without<Hidden>>,
@@ -367,6 +426,7 @@ pub fn combined_render_system(
&batched_lines,
&debug_state,
&timings,
&timing,
&map,
&colliders,
&cursor,
@@ -381,11 +441,13 @@ pub fn combined_render_system(
}
// Record timings for each system independently
let current_tick = timing.get_current_tick();
if let Some(duration) = render_duration {
timings.add_timing(SystemId::Render, duration);
timings.add_timing(SystemId::Render, duration, current_tick);
}
if let Some(duration) = debug_render_duration {
timings.add_timing(SystemId::DebugRender, duration);
timings.add_timing(SystemId::DebugRender, duration, current_tick);
}
}

View File

@@ -1,101 +0,0 @@
use bevy_ecs::{
entity::Entity,
query::With,
resource::Resource,
system::{Commands, Query, ResMut},
};
use tracing::debug;
use crate::systems::{Blinking, Frozen, GhostCollider, Hidden, PlayerControlled};
#[derive(Resource, Debug, Clone, Copy)]
pub enum StartupSequence {
/// Stage 1: Text-only stage
/// - Player & ghosts are hidden
/// - READY! and PLAYER ONE text are shown
/// - Energizers do not blink
TextOnly {
/// Remaining ticks in this stage
remaining_ticks: u32,
},
/// Stage 2: Characters visible stage
/// - PLAYER ONE text is hidden, READY! text remains
/// - Ghosts and Pac-Man are now shown
CharactersVisible {
/// Remaining ticks in this stage
remaining_ticks: u32,
},
/// Stage 3: Game begins
/// - Final state, game is fully active
GameActive,
}
impl StartupSequence {
/// Creates a new StartupSequence with the specified duration in ticks
pub fn new(text_only_ticks: u32, _characters_visible_ticks: u32) -> Self {
Self::TextOnly {
remaining_ticks: text_only_ticks,
}
}
/// Ticks the timer by one frame, returning transition information if state changes
pub fn tick(&mut self) -> Option<(StartupSequence, StartupSequence)> {
match self {
StartupSequence::TextOnly { remaining_ticks } => {
if *remaining_ticks > 0 {
*remaining_ticks -= 1;
None
} else {
let from = *self;
*self = StartupSequence::CharactersVisible {
remaining_ticks: 60, // 1 second at 60 FPS
};
Some((from, *self))
}
}
StartupSequence::CharactersVisible { remaining_ticks } => {
if *remaining_ticks > 0 {
*remaining_ticks -= 1;
None
} else {
let from = *self;
*self = StartupSequence::GameActive;
Some((from, *self))
}
}
StartupSequence::GameActive => None,
}
}
}
/// Handles startup sequence transitions and component management
pub fn startup_stage_system(
mut startup: ResMut<StartupSequence>,
mut commands: Commands,
mut blinking_query: Query<Entity, With<Blinking>>,
mut player_query: Query<Entity, With<PlayerControlled>>,
mut ghost_query: Query<Entity, With<GhostCollider>>,
) {
if let Some((from, to)) = startup.tick() {
debug!("StartupSequence transition from {from:?} to {to:?}");
match (from, to) {
(StartupSequence::TextOnly { .. }, StartupSequence::CharactersVisible { .. }) => {
// Unhide the player & ghosts
for entity in player_query.iter_mut().chain(ghost_query.iter_mut()) {
commands.entity(entity).remove::<Hidden>();
}
}
(StartupSequence::CharactersVisible { .. }, StartupSequence::GameActive) => {
// Unfreeze the player & ghosts & pellet blinking
for entity in player_query
.iter_mut()
.chain(ghost_query.iter_mut())
.chain(blinking_query.iter_mut())
{
commands.entity(entity).remove::<Frozen>();
}
}
_ => {}
}
}
}

394
src/systems/state.rs Normal file
View File

@@ -0,0 +1,394 @@
use std::mem::discriminant;
use tracing::{debug, info, warn};
use crate::events::StageTransition;
use crate::{
map::builder::Map,
systems::{
AudioEvent, Blinking, DirectionalAnimation, Dying, Eaten, Frozen, Ghost, GhostCollider, GhostState, Hidden,
LinearAnimation, Looping, NodeId, PlayerControlled, Position, Renderable, TimeToLive,
},
texture::{animated::TileSequence, sprite::SpriteAtlas},
};
use bevy_ecs::{
entity::Entity,
event::{EventReader, EventWriter},
query::{With, Without},
resource::Resource,
system::{Commands, NonSendMut, Query, Res, ResMut},
};
#[derive(Resource, Clone)]
pub struct PlayerAnimation(pub DirectionalAnimation);
#[derive(Resource, Clone)]
pub struct PlayerDeathAnimation(pub LinearAnimation);
/// A resource to track the overall stage of the game from a high-level perspective.
#[derive(Resource, Debug, PartialEq, Eq, Clone, Copy)]
pub enum GameStage {
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.
LevelRestarting,
/// The game has ended.
GameOver,
}
/// A resource that manages the multi-stage startup sequence of the game.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum StartupSequence {
/// Stage 1: Text-only stage
/// - Player & ghosts are hidden
/// - READY! and PLAYER ONE text are shown
/// - Energizers do not blink
TextOnly {
/// Remaining ticks in this stage
remaining_ticks: u32,
},
/// Stage 2: Characters visible stage
/// - PLAYER ONE text is hidden, READY! text remains
/// - Ghosts and Pac-Man are now shown
CharactersVisible {
/// Remaining ticks in this stage
remaining_ticks: u32,
},
}
impl Default for GameStage {
fn default() -> Self {
Self::Playing
}
}
/// The state machine for the multi-stage death sequence.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum DyingSequence {
/// Initial stage: entities are frozen, waiting for a delay.
Frozen { remaining_ticks: u32 },
/// Second stage: Pac-Man's death animation is playing.
Animating { remaining_ticks: u32 },
/// Third stage: Pac-Man is now gone, waiting a moment before the level restarts.
Hidden { remaining_ticks: u32 },
}
/// A resource to store the number of player lives.
#[derive(Resource, Debug)]
pub struct PlayerLives(pub u8);
impl Default for PlayerLives {
fn default() -> Self {
Self(3)
}
}
/// Handles startup sequence transitions and component management
/// Maps sprite index to the corresponding effect sprite path
fn sprite_index_to_path(index: u8) -> &'static str {
match index {
0 => "effects/100.png",
1 => "effects/200.png",
2 => "effects/300.png",
3 => "effects/400.png",
4 => "effects/700.png",
5 => "effects/800.png",
6 => "effects/1000.png",
7 => "effects/1600.png",
8 => "effects/2000.png",
9 => "effects/3000.png",
10 => "effects/5000.png",
_ => "effects/200.png", // fallback to index 1
}
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::type_complexity)]
pub fn stage_system(
mut game_state: ResMut<GameStage>,
player_death_animation: Res<PlayerDeathAnimation>,
player_animation: Res<PlayerAnimation>,
mut player_lives: ResMut<PlayerLives>,
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>>,
mut ghost_query: Query<(Entity, &Ghost, &mut Position), (With<GhostCollider>, Without<PlayerControlled>)>,
atlas: NonSendMut<SpriteAtlas>,
) {
let old_state = *game_state;
let mut new_state: Option<GameStage> = None;
// Handle stage transition requests before normal ticking
for event in stage_event_reader.read() {
let StageTransition::GhostEatenPause { ghost_entity } = *event;
let pac_node = player_query
.single_mut()
.ok()
.map(|(_, pos)| pos.current_node())
.unwrap_or(map.start_positions.pacman);
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 {
GameStage::Starting(StartupSequence::TextOnly {
remaining_ticks: remaining_ticks - 1,
})
} else {
debug!("Transitioning from text-only to characters visible startup stage");
GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 })
}
}
StartupSequence::CharactersVisible { remaining_ticks } => {
if remaining_ticks > 0 {
GameStage::Starting(StartupSequence::CharactersVisible {
remaining_ticks: remaining_ticks - 1,
})
} else {
info!("Startup sequence completed, beginning gameplay");
GameStage::Playing
}
}
},
GameStage::Playing => GameStage::Playing,
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 {
GameStage::PlayerDying(DyingSequence::Frozen {
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 {
GameStage::PlayerDying(DyingSequence::Animating {
remaining_ticks: remaining_ticks - 1,
})
} else {
GameStage::PlayerDying(DyingSequence::Hidden { remaining_ticks: 60 })
}
}
DyingSequence::Hidden { remaining_ticks } => {
if remaining_ticks > 0 {
GameStage::PlayerDying(DyingSequence::Hidden {
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 => {
debug!("Level restart complete, returning to startup sequence");
GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 })
}
GameStage::GameOver => GameStage::GameOver,
};
if old_state == new_state {
return;
}
match (old_state, new_state) {
(GameStage::Playing, GameStage::GhostEatenPause { ghost_entity, node, .. }) => {
// Freeze the player & ghosts
for entity in player_query
.iter_mut()
.map(|(e, _)| e)
.chain(ghost_query.iter_mut().map(|(e, _, _)| e))
{
commands.entity(entity).insert(Frozen);
}
// Hide the player & eaten ghost
for (player_entity, _) in player_query.iter_mut() {
commands.entity(player_entity).insert(Hidden);
}
commands.entity(ghost_entity).insert(Hidden);
// Spawn bonus points entity at Pac-Man's position
let sprite_index = 1; // Index 1 = 200 points (default for ghost eating)
let sprite_path = sprite_index_to_path(sprite_index);
if let Ok(sprite_tile) = SpriteAtlas::get_tile(&atlas, sprite_path) {
let tile_sequence = TileSequence::single(sprite_tile);
let animation = LinearAnimation::new(tile_sequence, 1);
commands.spawn((
Position::Stopped { node },
Renderable {
sprite: sprite_tile,
layer: 2, // Above other entities
},
animation,
TimeToLive::new(30),
));
}
}
(GameStage::GhostEatenPause { ghost_entity, .. }, GameStage::Playing) => {
// Unfreeze and reveal the player & all ghosts
for entity in player_query
.iter_mut()
.map(|(e, _)| e)
.chain(ghost_query.iter_mut().map(|(e, _, _)| e))
{
commands.entity(entity).remove::<(Frozen, Hidden)>();
}
// 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(entity).insert(Frozen);
}
}
(GameStage::PlayerDying(DyingSequence::Frozen { .. }), GameStage::PlayerDying(DyingSequence::Animating { .. })) => {
// Hide the ghosts
for (entity, _, _) in ghost_query.iter_mut() {
commands.entity(entity).insert(Hidden);
}
// 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()));
}
// Play the death sound
audio_events.write(AudioEvent::PlayDeath);
}
(GameStage::PlayerDying(DyingSequence::Animating { .. }), GameStage::PlayerDying(DyingSequence::Hidden { .. })) => {
// Hide the player
if let Ok((player_entity, _)) = player_query.single_mut() {
commands.entity(player_entity).insert(Hidden);
}
}
(_, GameStage::LevelRestarting) => {
if let Ok((player_entity, mut pos)) = player_query.single_mut() {
*pos = Position::Stopped {
node: map.start_positions.pacman,
};
// 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 {
node: match ghost {
Ghost::Blinky => map.start_positions.blinky,
Ghost::Pinky => map.start_positions.pinky,
Ghost::Inky => map.start_positions.inky,
Ghost::Clyde => map.start_positions.clyde,
},
};
commands
.entity(ghost_entity)
.remove::<(Frozen, Hidden, Eaten)>()
.insert(GhostState::Normal);
}
}
(_, GameStage::Starting(StartupSequence::CharactersVisible { .. })) => {
// Unhide the player & ghosts
for entity in player_query
.iter_mut()
.map(|(e, _)| e)
.chain(ghost_query.iter_mut().map(|(e, _, _)| e))
{
commands.entity(entity).remove::<Hidden>();
}
}
(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(entity).remove::<Frozen>();
}
}
(GameStage::PlayerDying(..), GameStage::GameOver) => {
// Freeze blinking
for entity in blinking_query.iter_mut() {
commands.entity(entity).insert(Frozen);
}
}
_ => {
let different = discriminant(&old_state) != discriminant(&new_state);
if different {
tracing::warn!(
new_state = ?new_state,
old_state = ?old_state,
"Unhandled game stage transition");
}
}
}
*game_state = new_state;
}

View File

@@ -1,53 +1,50 @@
use crate::map::direction::Direction;
use crate::texture::sprite::AtlasTile;
use glam::U16Vec2;
/// Fixed-size tile sequence that avoids heap allocation
#[derive(Clone, Copy, Debug)]
use crate::{map::direction::Direction, texture::sprite::AtlasTile};
/// A sequence of tiles for animation, backed by a vector.
#[derive(Debug, Clone)]
pub struct TileSequence {
tiles: [AtlasTile; 4], // Fixed array, max 4 frames
count: usize, // Actual number of frames used
tiles: Vec<AtlasTile>,
}
impl TileSequence {
/// Creates a new tile sequence from a slice of tiles
/// Creates a new tile sequence from a slice of tiles.
pub fn new(tiles: &[AtlasTile]) -> Self {
let mut tile_array = [AtlasTile {
pos: glam::U16Vec2::ZERO,
size: glam::U16Vec2::ZERO,
color: None,
}; 4];
Self { tiles: tiles.to_vec() }
}
let count = tiles.len().min(4);
tile_array[..count].copy_from_slice(&tiles[..count]);
Self {
tiles: tile_array,
count,
}
/// Creates a tile sequence with a single tile.
pub fn single(tile: AtlasTile) -> Self {
Self { tiles: vec![tile] }
}
/// Returns the tile at the given frame index, wrapping if necessary
pub fn get_tile(&self, frame: usize) -> AtlasTile {
if self.count == 0 {
// Return a default empty tile if no tiles
AtlasTile {
pos: glam::U16Vec2::ZERO,
size: glam::U16Vec2::ZERO,
if self.tiles.is_empty() {
// Return a default or handle the error appropriately
// For now, let's return a default tile, assuming it's a sensible default
return AtlasTile {
pos: U16Vec2::ZERO,
size: U16Vec2::ZERO,
color: None,
}
} else {
self.tiles[frame % self.count]
};
}
self.tiles[frame % self.tiles.len()]
}
/// Returns true if this sequence has no tiles
pub fn len(&self) -> usize {
self.tiles.len()
}
/// Checks if the sequence contains any tiles.
pub fn is_empty(&self) -> bool {
self.count == 0
self.tiles.is_empty()
}
}
/// Type-safe directional tile storage with named fields
#[derive(Clone, Copy, Debug)]
/// A collection of tile sequences for each cardinal direction.
#[derive(Debug, Clone)]
pub struct DirectionalTiles {
pub up: TileSequence,
pub down: TileSequence,

View File

@@ -1,46 +0,0 @@
#![allow(dead_code)]
use crate::texture::sprite::AtlasTile;
#[derive(Clone)]
pub struct BlinkingTexture {
tile: AtlasTile,
blink_duration: f32,
time_bank: f32,
is_on: bool,
}
impl BlinkingTexture {
pub fn new(tile: AtlasTile, blink_duration: f32) -> Self {
Self {
tile,
blink_duration,
time_bank: 0.0,
is_on: true,
}
}
pub fn tick(&mut self, dt: f32) {
self.time_bank += dt;
if self.time_bank >= self.blink_duration {
self.time_bank -= self.blink_duration;
self.is_on = !self.is_on;
}
}
pub fn is_on(&self) -> bool {
self.is_on
}
pub fn tile(&self) -> &AtlasTile {
&self.tile
}
// Helper methods for testing
pub fn time_bank(&self) -> f32 {
self.time_bank
}
pub fn blink_duration(&self) -> f32 {
self.blink_duration
}
}

View File

@@ -1,5 +1,5 @@
pub mod animated;
pub mod blinking;
pub mod sprite;
pub mod sprites;
pub mod text;
pub mod ttf;

View File

@@ -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;
@@ -20,7 +21,8 @@ pub struct MapperFrame {
pub size: U16Vec2,
}
#[derive(Copy, Clone, Debug, PartialEq)]
/// A single tile within a sprite atlas, defined by its position and size.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct AtlasTile {
pub pos: U16Vec2,
pub size: U16Vec2,
@@ -89,9 +91,13 @@ 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: mapper.frames,
tiles,
default_color: None,
last_modulation: None,
}
@@ -103,11 +109,15 @@ impl SpriteAtlas {
/// for the named sprite, or `None` if the sprite name is not found in the
/// 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) -> Option<AtlasTile> {
self.tiles.get(name).map(|frame| AtlasTile {
pub fn get_tile(&self, name: &str) -> Result<AtlasTile, TextureError> {
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: None,
color: self.default_color,
})
}

111
src/texture/sprites.rs Normal file
View File

@@ -0,0 +1,111 @@
//! A structured representation of all sprite assets in the game.
//!
//! This module provides a set of enums to represent every sprite, allowing for
//! type-safe access to asset paths and avoiding the use of raw strings.
//! 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;
/// Represents the different sprites for Pac-Man.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PacmanSprite {
/// A moving Pac-Man sprite for a given direction and animation frame.
Moving(Direction, u8),
/// The full, closed-mouth Pac-Man sprite.
Full,
/// A single frame of the dying animation.
Dying(u8),
}
/// Represents the color of a frightened ghost.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FrightenedColor {
Blue,
White,
}
/// Represents the different sprites for ghosts.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GhostSprite {
/// The normal appearance of a ghost for a given type, direction, and animation frame.
Normal(Ghost, Direction, u8),
/// The frightened appearance of a ghost, with a specific color and animation frame.
Frightened(FrightenedColor, u8),
/// The "eyes only" appearance of a ghost after being eaten.
Eyes(Direction),
}
/// Represents the different sprites for the maze and collectibles.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MazeSprite {
/// A specific tile of the maze.
Tile(u8),
/// A standard pellet.
Pellet,
/// An energizer/power pellet.
Energizer,
}
/// 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),
}
impl GameSprite {
/// Generates the asset path for the sprite.
///
/// This path corresponds to the filename in the texture atlas JSON file.
pub fn to_path(self) -> String {
match self {
GameSprite::Pacman(PacmanSprite::Moving(dir, frame)) => format!(
"pacman/{}_{}.png",
dir.as_ref(),
match frame {
0 => "a",
1 => "b",
_ => panic!("Invalid animation frame"),
}
),
GameSprite::Pacman(PacmanSprite::Full) => "pacman/full.png".to_string(),
GameSprite::Pacman(PacmanSprite::Dying(frame)) => format!("pacman/death/{}.png", frame),
// Ghost sprites
GameSprite::Ghost(GhostSprite::Normal(ghost_type, dir, frame)) => {
let frame_char = match frame {
0 => 'a',
1 => 'b',
_ => panic!("Invalid animation frame"),
};
format!(
"ghost/{}/{}_{}.png",
ghost_type.as_str(),
dir.as_ref().to_lowercase(),
frame_char
)
}
GameSprite::Ghost(GhostSprite::Frightened(color, frame)) => {
let frame_char = match frame {
0 => 'a',
1 => 'b',
_ => panic!("Invalid animation frame"),
};
let color_str = match color {
FrightenedColor::Blue => "blue",
FrightenedColor::White => "white",
};
format!("ghost/frightened/{}_{}.png", color_str, frame_char)
}
GameSprite::Ghost(GhostSprite::Eyes(dir)) => format!("ghost/eyes/{}.png", dir.as_ref().to_lowercase()),
// Maze sprites
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(),
}
}
}

View File

@@ -60,10 +60,7 @@ use sdl2::pixels::Color;
use sdl2::render::{Canvas, RenderTarget};
use std::collections::HashMap;
use crate::{
error::{GameError, TextureError},
texture::sprite::{AtlasTile, SpriteAtlas},
};
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
/// Converts a character to its tile name in the atlas.
fn char_to_tile_name(c: char) -> Option<String> {
@@ -122,9 +119,7 @@ impl TextTexture {
}
if let Some(tile_name) = char_to_tile_name(c) {
let tile = atlas
.get_tile(&tile_name)
.ok_or(GameError::Texture(TextureError::AtlasTileNotFound(tile_name)))?;
let tile = atlas.get_tile(&tile_name)?;
self.char_map.insert(c, tile);
Ok(self.char_map.get(&c))
} else {

17
tests/asset.rs Normal file
View File

@@ -0,0 +1,17 @@
use pacman::asset::Asset;
use speculoos::prelude::*;
use strum::IntoEnumIterator;
#[test]
fn all_asset_paths_exist() {
for asset in Asset::iter() {
let path = asset.path();
let full_path = format!("assets/game/{}", path);
let metadata = std::fs::metadata(&full_path)
.map_err(|e| format!("Error getting metadata for {}: {}", full_path, e))
.unwrap();
assert_that(&metadata.is_file()).is_true();
assert_that(&metadata.len()).is_greater_than(1024);
}
}

View File

@@ -1,40 +1,316 @@
use pacman::texture::blinking::BlinkingTexture;
use bevy_ecs::{entity::Entity, system::RunSystemOnce, world::World};
use pacman::systems::{
blinking::{blinking_system, Blinking},
components::{DeltaTime, Renderable},
Frozen, Hidden,
};
use speculoos::prelude::*;
mod common;
#[test]
fn test_blinking_texture() {
let tile = common::mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 0.5);
/// Creates a test world with blinking system resources
fn create_blinking_test_world() -> World {
let mut world = World::new();
world.insert_resource(DeltaTime::from_ticks(1));
world
}
assert!(texture.is_on());
/// Spawns a test entity with blinking and renderable components
fn spawn_blinking_entity(world: &mut World, interval_ticks: u32) -> Entity {
world
.spawn((
Blinking::new(interval_ticks),
Renderable {
sprite: common::mock_atlas_tile(1),
layer: 0,
},
))
.id()
}
texture.tick(0.5);
assert!(!texture.is_on());
/// Spawns a test entity with blinking, renderable, and hidden components
fn spawn_hidden_blinking_entity(world: &mut World, interval_ticks: u32) -> Entity {
world
.spawn((
Blinking::new(interval_ticks),
Renderable {
sprite: common::mock_atlas_tile(1),
layer: 0,
},
Hidden,
))
.id()
}
texture.tick(0.5);
assert!(texture.is_on());
/// Spawns a test entity with blinking, renderable, and frozen components
fn spawn_frozen_blinking_entity(world: &mut World, interval_ticks: u32) -> Entity {
world
.spawn((
Blinking::new(interval_ticks),
Renderable {
sprite: common::mock_atlas_tile(1),
layer: 0,
},
Frozen,
))
.id()
}
texture.tick(0.5);
assert!(!texture.is_on());
/// Spawns a test entity with blinking, renderable, hidden, and frozen components
fn spawn_frozen_hidden_blinking_entity(world: &mut World, interval_ticks: u32) -> Entity {
world
.spawn((
Blinking::new(interval_ticks),
Renderable {
sprite: common::mock_atlas_tile(1),
layer: 0,
},
Hidden,
Frozen,
))
.id()
}
/// Runs the blinking system with the given delta time
fn run_blinking_system(world: &mut World, delta_ticks: u32) {
world.resource_mut::<DeltaTime>().ticks = delta_ticks;
world.run_system_once(blinking_system).unwrap();
}
/// Checks if an entity has the Hidden component
fn has_hidden_component(world: &World, entity: Entity) -> bool {
world.entity(entity).contains::<Hidden>()
}
/// Checks if an entity has the Frozen component
fn has_frozen_component(world: &World, entity: Entity) -> bool {
world.entity(entity).contains::<Frozen>()
}
#[test]
fn test_blinking_texture_partial_duration() {
let tile = common::mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 0.5);
fn test_blinking_component_creation() {
let blinking = Blinking::new(10);
texture.tick(0.625);
assert!(!texture.is_on());
assert_eq!(texture.time_bank(), 0.125);
assert_that(&blinking.tick_timer).is_equal_to(0);
assert_that(&blinking.interval_ticks).is_equal_to(10);
}
#[test]
fn test_blinking_texture_negative_time() {
let tile = common::mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 0.5);
fn test_blinking_system_normal_interval_no_toggle() {
let mut world = create_blinking_test_world();
let entity = spawn_blinking_entity(&mut world, 5);
texture.tick(-0.1);
assert!(texture.is_on());
assert_eq!(texture.time_bank(), -0.1);
// Run system with 3 ticks (less than interval)
run_blinking_system(&mut world, 3);
// Entity should not be hidden yet
assert_that(&has_hidden_component(&world, entity)).is_false();
// Check that timer was updated
let blinking = world.entity(entity).get::<Blinking>().unwrap();
assert_that(&blinking.tick_timer).is_equal_to(3);
}
#[test]
fn test_blinking_system_normal_interval_first_toggle() {
let mut world = create_blinking_test_world();
let entity = spawn_blinking_entity(&mut world, 5);
// Run system with 5 ticks (exactly one interval)
run_blinking_system(&mut world, 5);
// Entity should now be hidden
assert_that(&has_hidden_component(&world, entity)).is_true();
// Check that timer was reset
let blinking = world.entity(entity).get::<Blinking>().unwrap();
assert_that(&blinking.tick_timer).is_equal_to(0);
}
#[test]
fn test_blinking_system_normal_interval_second_toggle() {
let mut world = create_blinking_test_world();
let entity = spawn_blinking_entity(&mut world, 5);
// First toggle: 5 ticks
run_blinking_system(&mut world, 5);
assert_that(&has_hidden_component(&world, entity)).is_true();
// Second toggle: another 5 ticks
run_blinking_system(&mut world, 5);
assert_that(&has_hidden_component(&world, entity)).is_false();
}
#[test]
fn test_blinking_system_normal_interval_multiple_intervals() {
let mut world = create_blinking_test_world();
let entity = spawn_blinking_entity(&mut world, 3);
// Run system with 7 ticks (2 complete intervals + 1 remainder)
run_blinking_system(&mut world, 7);
// Should toggle twice (even number), so back to original state (not hidden)
assert_that(&has_hidden_component(&world, entity)).is_false();
// Check that timer was updated to remainder
let blinking = world.entity(entity).get::<Blinking>().unwrap();
assert_that(&blinking.tick_timer).is_equal_to(1);
}
#[test]
fn test_blinking_system_normal_interval_odd_intervals() {
let mut world = create_blinking_test_world();
let entity = spawn_blinking_entity(&mut world, 2);
// Run system with 5 ticks (2 complete intervals + 1 remainder)
run_blinking_system(&mut world, 5);
// Should toggle twice (even number), so back to original state (not hidden)
assert_that(&has_hidden_component(&world, entity)).is_false();
// Check that timer was updated to remainder
let blinking = world.entity(entity).get::<Blinking>().unwrap();
assert_that(&blinking.tick_timer).is_equal_to(1);
}
#[test]
fn test_blinking_system_zero_interval_with_ticks() {
let mut world = create_blinking_test_world();
let entity = spawn_blinking_entity(&mut world, 0);
// Run system with any positive ticks
run_blinking_system(&mut world, 1);
// Entity should be hidden immediately
assert_that(&has_hidden_component(&world, entity)).is_true();
}
#[test]
fn test_blinking_system_zero_interval_no_ticks() {
let mut world = create_blinking_test_world();
let entity = spawn_blinking_entity(&mut world, 0);
// Run system with 0 ticks
run_blinking_system(&mut world, 0);
// Entity should not be hidden (no time passed)
assert_that(&has_hidden_component(&world, entity)).is_false();
}
#[test]
fn test_blinking_system_zero_interval_toggle_back() {
let mut world = create_blinking_test_world();
let entity = spawn_hidden_blinking_entity(&mut world, 0);
// Run system with any positive ticks
run_blinking_system(&mut world, 1);
// Entity should be unhidden
assert_that(&has_hidden_component(&world, entity)).is_false();
}
#[test]
fn test_blinking_system_frozen_entity_unhidden() {
let mut world = create_blinking_test_world();
let entity = spawn_frozen_hidden_blinking_entity(&mut world, 5);
// Run system with ticks
run_blinking_system(&mut world, 10);
// Frozen entity should be unhidden and stay unhidden
assert_that(&has_hidden_component(&world, entity)).is_false();
assert_that(&has_frozen_component(&world, entity)).is_true();
}
#[test]
fn test_blinking_system_frozen_entity_no_blinking() {
let mut world = create_blinking_test_world();
let entity = spawn_frozen_blinking_entity(&mut world, 5);
// Run system with ticks
run_blinking_system(&mut world, 10);
// Frozen entity should not be hidden (blinking disabled)
assert_that(&has_hidden_component(&world, entity)).is_false();
assert_that(&has_frozen_component(&world, entity)).is_true();
}
#[test]
fn test_blinking_system_frozen_entity_timer_not_updated() {
let mut world = create_blinking_test_world();
let entity = spawn_frozen_blinking_entity(&mut world, 5);
// Run system with ticks
run_blinking_system(&mut world, 10);
// Timer should not be updated for frozen entities
let blinking = world.entity(entity).get::<Blinking>().unwrap();
assert_that(&blinking.tick_timer).is_equal_to(0);
}
#[test]
fn test_blinking_system_entity_without_renderable_ignored() {
let mut world = create_blinking_test_world();
// Spawn entity with only Blinking component (no Renderable)
let entity = world.spawn(Blinking::new(5)).id();
// Run system
run_blinking_system(&mut world, 10);
// Entity should not be affected (not in query)
assert_that(&has_hidden_component(&world, entity)).is_false();
}
#[test]
fn test_blinking_system_entity_without_blinking_ignored() {
let mut world = create_blinking_test_world();
// Spawn entity with only Renderable component (no Blinking)
let entity = world
.spawn(Renderable {
sprite: common::mock_atlas_tile(1),
layer: 0,
})
.id();
// Run system
run_blinking_system(&mut world, 10);
// Entity should not be affected (not in query)
assert_that(&has_hidden_component(&world, entity)).is_false();
}
#[test]
fn test_blinking_system_large_interval() {
let mut world = create_blinking_test_world();
let entity = spawn_blinking_entity(&mut world, 1000);
// Run system with 500 ticks (less than interval)
run_blinking_system(&mut world, 500);
// Entity should not be hidden yet
assert_that(&has_hidden_component(&world, entity)).is_false();
// Check that timer was updated
let blinking = world.entity(entity).get::<Blinking>().unwrap();
assert_that(&blinking.tick_timer).is_equal_to(500);
}
#[test]
fn test_blinking_system_very_small_interval() {
let mut world = create_blinking_test_world();
let entity = spawn_blinking_entity(&mut world, 1);
// Run system with 1 tick
run_blinking_system(&mut world, 1);
// Entity should be hidden
assert_that(&has_hidden_component(&world, entity)).is_true();
// Run system with another 1 tick
run_blinking_system(&mut world, 1);
// Entity should be unhidden
assert_that(&has_hidden_component(&world, entity)).is_false();
}

View File

@@ -1,5 +1,6 @@
use bevy_ecs::system::RunSystemOnce;
use pacman::systems::{check_collision, collision_system, Collider, EntityType, GhostState, Position};
use speculoos::prelude::*;
mod common;
@@ -9,8 +10,8 @@ fn test_collider_collision_detection() {
let collider2 = Collider { size: 8.0 };
// Test collision detection
assert!(collider1.collides_with(collider2.size, 5.0)); // Should collide (distance < 9.0)
assert!(!collider1.collides_with(collider2.size, 15.0)); // Should not collide (distance > 9.0)
assert_that(&collider1.collides_with(collider2.size, 5.0)).is_true(); // Should collide (distance < 9.0)
assert_that(&collider1.collides_with(collider2.size, 15.0)).is_false(); // Should not collide (distance > 9.0)
}
#[test]
@@ -23,13 +24,13 @@ fn test_check_collision_helper() {
// Test collision at same position
let result = check_collision(&pos1, &collider1, &pos2, &collider2, &map);
assert!(result.is_ok());
assert!(result.unwrap()); // Should collide at same position
assert_that(&result.is_ok()).is_true();
assert_that(&result.unwrap()).is_true(); // Should collide at same position
// Test collision at different positions
let pos3 = Position::Stopped { node: 1 }; // Different position
let result = check_collision(&pos1, &collider1, &pos3, &collider2, &map);
assert!(result.is_ok());
assert_that(&result.is_ok()).is_true();
// May or may not collide depending on actual node positions
}

View File

@@ -85,7 +85,10 @@ 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(DeltaTime(1.0 / 60.0)); // 60 FPS
world.insert_resource(DeltaTime {
seconds: 1.0 / 60.0,
ticks: 1,
}); // 60 FPS
world.insert_resource(create_test_map());
world

View File

@@ -1,35 +0,0 @@
use pacman::constants::*;
#[test]
fn test_raw_board_structure() {
// Test board dimensions match expected size
assert_eq!(RAW_BOARD.len(), BOARD_CELL_SIZE.y as usize);
for row in RAW_BOARD.iter() {
assert_eq!(row.len(), BOARD_CELL_SIZE.x as usize);
}
// Test boundaries are properly walled
assert!(RAW_BOARD[0].chars().all(|c| c == '#'));
assert!(RAW_BOARD[RAW_BOARD.len() - 1].chars().all(|c| c == '#'));
}
#[test]
fn test_raw_board_contains_required_elements() {
// Test that essential game elements are present
assert!(
RAW_BOARD.iter().any(|row| row.contains('X')),
"Board should contain Pac-Man start position"
);
assert!(
RAW_BOARD.iter().any(|row| row.contains("==")),
"Board should contain ghost house door"
);
assert!(
RAW_BOARD.iter().any(|row| row.chars().any(|c| c == 'T')),
"Board should contain tunnel entrances"
);
assert!(
RAW_BOARD.iter().any(|row| row.chars().any(|c| c == 'o')),
"Board should contain power pellets"
);
}

View File

@@ -1,5 +1,5 @@
use glam::I8Vec2;
use pacman::map::direction::*;
use speculoos::prelude::*;
#[test]
fn test_direction_opposite() {
@@ -11,21 +11,47 @@ fn test_direction_opposite() {
];
for (dir, expected) in test_cases {
assert_eq!(dir.opposite(), expected);
assert_that(&dir.opposite()).is_equal_to(expected);
}
}
#[test]
fn test_direction_as_ivec2() {
let test_cases = [
(Direction::Up, -I8Vec2::Y),
(Direction::Down, I8Vec2::Y),
(Direction::Left, -I8Vec2::X),
(Direction::Right, I8Vec2::X),
];
for (dir, expected) in test_cases {
assert_eq!(dir.as_ivec2(), expected);
assert_eq!(I8Vec2::from(dir), expected);
fn test_direction_opposite_symmetry() {
// Test that opposite() is symmetric: opposite(opposite(d)) == d
for &dir in &Direction::DIRECTIONS {
assert_that(&dir.opposite().opposite()).is_equal_to(dir);
}
}
#[test]
fn test_direction_opposite_exhaustive() {
// Test that every direction has a unique opposite
let mut opposites = std::collections::HashSet::new();
for &dir in &Direction::DIRECTIONS {
let opposite = dir.opposite();
assert_that(&opposites.insert(opposite)).is_true();
}
assert_that(&opposites).has_length(4);
}
#[test]
fn test_direction_as_usize_exhaustive() {
// Test that as_usize() returns unique values for all directions
let mut usizes = std::collections::HashSet::new();
for &dir in &Direction::DIRECTIONS {
let usize_val = dir.as_usize();
assert_that(&usizes.insert(usize_val)).is_true();
}
assert_that(&usizes).has_length(4);
}
#[test]
fn test_direction_as_ivec2_exhaustive() {
// Test that as_ivec2() returns unique values for all directions
let mut ivec2s = std::collections::HashSet::new();
for &dir in &Direction::DIRECTIONS {
let ivec2_val = dir.as_ivec2();
assert_that(&ivec2s.insert(ivec2_val)).is_true();
}
assert_that(&ivec2s).has_length(4);
}

View File

@@ -1,83 +1,15 @@
use pacman::error::{
AssetError, EntityError, GameError, GameResult, IntoGameError, MapError, OptionExt, ParseError, ResultExt, TextureError,
};
use pacman::error::{GameError, GameResult, IntoGameError, OptionExt, ResultExt};
use speculoos::prelude::*;
use std::io;
#[test]
fn test_game_error_from_asset_error() {
let asset_error = AssetError::NotFound("test.png".to_string());
let game_error: GameError = asset_error.into();
assert!(matches!(game_error, GameError::Asset(_)));
}
#[test]
fn test_game_error_from_parse_error() {
let parse_error = ParseError::UnknownCharacter('Z');
let game_error: GameError = parse_error.into();
assert!(matches!(game_error, GameError::MapParse(_)));
}
#[test]
fn test_game_error_from_map_error() {
let map_error = MapError::NodeNotFound(42);
let game_error: GameError = map_error.into();
assert!(matches!(game_error, GameError::Map(_)));
}
#[test]
fn test_game_error_from_texture_error() {
let texture_error = TextureError::LoadFailed("Failed to load".to_string());
let game_error: GameError = texture_error.into();
assert!(matches!(game_error, GameError::Texture(_)));
}
#[test]
fn test_game_error_from_entity_error() {
let entity_error = EntityError::NodeNotFound(10);
let game_error: GameError = entity_error.into();
assert!(matches!(game_error, GameError::Entity(_)));
}
#[test]
fn test_game_error_from_io_error() {
let io_error = io::Error::new(io::ErrorKind::NotFound, "File not found");
let game_error: GameError = io_error.into();
assert!(matches!(game_error, GameError::Io(_)));
}
#[test]
fn test_asset_error_from_io_error() {
let io_error = io::Error::new(io::ErrorKind::PermissionDenied, "Permission denied");
let asset_error: AssetError = io_error.into();
assert!(matches!(asset_error, AssetError::Io(_)));
}
#[test]
fn test_parse_error_display() {
let error = ParseError::UnknownCharacter('!');
assert_eq!(error.to_string(), "Unknown character in board: !");
let error = ParseError::InvalidHouseDoorCount(3);
assert_eq!(error.to_string(), "House door must have exactly 2 positions, found 3");
}
#[test]
fn test_entity_error_display() {
let error = EntityError::NodeNotFound(42);
assert_eq!(error.to_string(), "Node not found in graph: 42");
let error = EntityError::EdgeNotFound { from: 1, to: 2 };
assert_eq!(error.to_string(), "Edge not found: from 1 to 2");
}
#[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!(game_result.is_err());
assert_that(&game_result.is_err()).is_true();
if let Err(GameError::InvalidState(msg)) = game_result {
assert!(msg.contains("test error"));
assert_that(&msg.contains("test error")).is_true();
} else {
panic!("Expected InvalidState error");
}
@@ -88,7 +20,7 @@ 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_eq!(game_result.unwrap(), 42);
assert_that(&game_result.unwrap()).is_equal_to(42);
}
#[test]
@@ -96,7 +28,7 @@ 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_eq!(result.unwrap(), 42);
assert_that(&result.unwrap()).is_equal_to(42);
}
#[test]
@@ -104,9 +36,9 @@ 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!(result.is_err());
assert_that(&result.is_err()).is_true();
if let Err(GameError::InvalidState(msg)) = result {
assert_eq!(msg, "Not found");
assert_that(&msg).is_equal_to("Not found".to_string());
} else {
panic!("Expected InvalidState error");
}
@@ -117,7 +49,7 @@ 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_eq!(game_result.unwrap(), 42);
assert_that(&game_result.unwrap()).is_equal_to(42);
}
#[test]
@@ -125,9 +57,9 @@ 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!(game_result.is_err());
assert_that(&game_result.is_err()).is_true();
if let Err(GameError::InvalidState(msg)) = game_result {
assert_eq!(msg, "Context error");
assert_that(&msg).is_equal_to("Context error".to_string());
} else {
panic!("Expected InvalidState error");
}

View File

@@ -1,8 +1,7 @@
use pacman::systems::profiling::format_timing_display;
use speculoos::prelude::*;
use std::time::Duration;
use pretty_assertions::assert_eq;
fn get_timing_data() -> Vec<(String, Duration, Duration)> {
vec![
("total".to_string(), Duration::from_micros(1234), Duration::from_micros(570)),
@@ -53,45 +52,25 @@ fn test_complex_formatting_alignment() {
});
// Assert that all positions were found
assert_eq!(
[
assert_that(
&[
&colon_positions,
&first_decimal_positions,
&second_decimal_positions,
&first_unit_positions,
&second_unit_positions
&second_unit_positions,
]
.iter()
.all(|p| p.len() == 6),
true
);
)
.is_true();
// Assert that all positions are the same
assert!(
colon_positions.iter().all(|&p| p == colon_positions[0]),
"colon positions are not the same {:?}",
colon_positions
);
assert!(
first_decimal_positions.iter().all(|&p| p == first_decimal_positions[0]),
"first decimal positions are not the same {:?}",
first_decimal_positions
);
assert!(
second_decimal_positions.iter().all(|&p| p == second_decimal_positions[0]),
"second decimal positions are not the same {:?}",
second_decimal_positions
);
assert!(
first_unit_positions.iter().all(|&p| p == first_unit_positions[0]),
"first unit positions are not the same {:?}",
first_unit_positions
);
assert!(
second_unit_positions.iter().all(|&p| p == second_unit_positions[0]),
"second unit positions are not the same {:?}",
second_unit_positions
);
assert_that(&colon_positions.iter().all(|&p| p == colon_positions[0])).is_true();
assert_that(&first_decimal_positions.iter().all(|&p| p == first_decimal_positions[0])).is_true();
assert_that(&second_decimal_positions.iter().all(|&p| p == second_decimal_positions[0])).is_true();
assert_that(&first_unit_positions.iter().all(|&p| p == first_unit_positions[0])).is_true();
assert_that(&second_unit_positions.iter().all(|&p| p == second_unit_positions[0])).is_true();
}
#[test]
@@ -105,17 +84,17 @@ fn test_format_timing_display_basic() {
let formatted = format_timing_display(timing_data);
// Should have 3 lines (one for each system)
assert_eq!(formatted.len(), 3);
assert_that(&formatted.len()).is_equal_to(3);
// Each line should contain the system name
assert!(formatted.iter().any(|line| line.contains("render")));
assert!(formatted.iter().any(|line| line.contains("input")));
assert!(formatted.iter().any(|line| line.contains("physics")));
assert_that(&formatted.iter().any(|line| line.contains("render"))).is_true();
assert_that(&formatted.iter().any(|line| line.contains("input"))).is_true();
assert_that(&formatted.iter().any(|line| line.contains("physics"))).is_true();
// Each line should contain timing information with proper units
for line in formatted.iter() {
assert!(line.contains(":"), "Line should contain colon separator: {}", line);
assert!(line.contains("±"), "Line should contain ± symbol: {}", line);
assert_that(&line.contains(":")).is_true();
assert_that(&line.contains("±")).is_true();
}
}
@@ -132,10 +111,10 @@ fn test_format_timing_display_units() {
// Check that appropriate units are used
let all_lines = formatted.join(" ");
assert!(all_lines.contains("s"), "Should contain seconds unit");
assert!(all_lines.contains("ms"), "Should contain milliseconds unit");
assert!(all_lines.contains("µs"), "Should contain microseconds unit");
assert!(all_lines.contains("ns"), "Should contain nanoseconds unit");
assert_that(&all_lines.contains("s")).is_true();
assert_that(&all_lines.contains("ms")).is_true();
assert_that(&all_lines.contains("µs")).is_true();
assert_that(&all_lines.contains("ns")).is_true();
}
#[test]
@@ -157,9 +136,6 @@ fn test_format_timing_display_alignment() {
// All colons should be at the same position (aligned)
if colon_positions.len() > 1 {
let first_pos = colon_positions[0];
assert!(
colon_positions.iter().all(|&pos| pos == first_pos),
"Colons should be aligned at the same position"
);
assert_that(&colon_positions.iter().all(|&pos| pos == first_pos)).is_true();
}
}

79
tests/game.rs Normal file
View File

@@ -0,0 +1,79 @@
use pacman::error::{GameError, GameResult};
use pacman::game::Game;
use speculoos::prelude::*;
mod common;
use common::setup_sdl;
#[test]
fn test_game_30_seconds_60fps() -> GameResult<()> {
let (canvas, texture_creator, _sdl_context) = setup_sdl().map_err(GameError::Sdl)?;
let ttf_context = sdl2::ttf::init().map_err(GameError::Sdl)?;
let event_pump = _sdl_context
.event_pump()
.map_err(|e| pacman::error::GameError::Sdl(e.to_string()))?;
let mut game = Game::new(canvas, ttf_context, texture_creator, event_pump)?;
// Run for 30 seconds at 60 FPS = 1800 frames
let frame_time = 1.0 / 60.0;
let total_frames = 1800;
let mut frame_count = 0;
for _ in 0..total_frames {
let should_exit = game.tick(frame_time);
if should_exit {
break;
}
frame_count += 1;
}
assert_eq!(
frame_count, total_frames,
"Should have processed exactly {} frames",
total_frames
);
Ok(())
}
/// Test that runs the game for 30 seconds with variable frame timing
#[test]
fn test_game_30_seconds_variable_timing() -> GameResult<()> {
let (canvas, texture_creator, _sdl_context) = setup_sdl().map_err(GameError::Sdl)?;
let ttf_context = sdl2::ttf::init().map_err(|e| GameError::Sdl(e.to_string()))?;
let event_pump = _sdl_context
.event_pump()
.map_err(|e| pacman::error::GameError::Sdl(e.to_string()))?;
let mut game = Game::new(canvas, ttf_context, texture_creator, event_pump)?;
// Simulate 30 seconds with variable frame timing
let mut total_time = 0.0;
let target_time = 30.0;
let mut frame_count = 0;
while total_time < target_time {
// Alternate between different frame rates to simulate real gameplay
let frame_time = match frame_count % 4 {
0 => 1.0 / 60.0, // 60 FPS
1 => 1.0 / 30.0, // 30 FPS (lag spike)
2 => 1.0 / 120.0, // 120 FPS (very fast)
_ => 1.0 / 60.0, // 60 FPS
};
let should_exit = game.tick(frame_time);
if should_exit {
break;
}
total_time += frame_time;
frame_count += 1;
}
assert_that(&total_time).is_greater_than_or_equal_to(target_time);
Ok(())
}

View File

@@ -1,5 +1,6 @@
use pacman::map::direction::Direction;
use pacman::map::graph::{Graph, Node, TraversalFlags};
use speculoos::prelude::*;
mod common;
@@ -13,10 +14,10 @@ fn test_graph_basic_operations() {
position: glam::Vec2::new(16.0, 0.0),
});
assert_eq!(graph.nodes().count(), 2);
assert!(graph.get_node(node1).is_some());
assert!(graph.get_node(node2).is_some());
assert!(graph.get_node(999).is_none());
assert_that(&graph.nodes().count()).is_equal_to(2);
assert_that(&graph.get_node(node1).is_some()).is_true();
assert_that(&graph.get_node(node2).is_some()).is_true();
assert_that(&graph.get_node(999).is_none()).is_true();
}
#[test]
@@ -29,15 +30,15 @@ fn test_graph_connect() {
position: glam::Vec2::new(16.0, 0.0),
});
assert!(graph.connect(node1, node2, false, None, Direction::Right).is_ok());
assert_that(&graph.connect(node1, node2, false, None, Direction::Right).is_ok()).is_true();
let edge1 = graph.find_edge_in_direction(node1, Direction::Right);
let edge2 = graph.find_edge_in_direction(node2, Direction::Left);
assert!(edge1.is_some());
assert!(edge2.is_some());
assert_eq!(edge1.unwrap().target, node2);
assert_eq!(edge2.unwrap().target, node1);
assert_that(&edge1.is_some()).is_true();
assert_that(&edge2.is_some()).is_true();
assert_that(&edge1.unwrap().target).is_equal_to(node2);
assert_that(&edge2.unwrap().target).is_equal_to(node1);
}
#[test]
@@ -47,8 +48,8 @@ fn test_graph_connect_errors() {
position: glam::Vec2::new(0.0, 0.0),
});
assert!(graph.connect(node1, 999, false, None, Direction::Right).is_err());
assert!(graph.connect(999, node1, false, None, Direction::Right).is_err());
assert_that(&graph.connect(node1, 999, false, None, Direction::Right).is_err()).is_true();
assert_that(&graph.connect(999, node1, false, None, Direction::Right).is_err()).is_true();
}
#[test]
@@ -66,7 +67,7 @@ fn test_graph_edge_permissions() {
.unwrap();
let edge = graph.find_edge_in_direction(node1, Direction::Right).unwrap();
assert_eq!(edge.traversal_flags, TraversalFlags::GHOST);
assert_that(&edge.traversal_flags).is_equal_to(TraversalFlags::GHOST);
}
#[test]
@@ -86,10 +87,10 @@ fn should_add_connected_node() {
)
.unwrap();
assert_eq!(graph.nodes().count(), 2);
assert_that(&graph.nodes().count()).is_equal_to(2);
let edge = graph.find_edge(node1, node2);
assert!(edge.is_some());
assert_eq!(edge.unwrap().direction, Direction::Right);
assert_that(&edge.is_some()).is_true();
assert_that(&edge.unwrap().direction).is_equal_to(Direction::Right);
}
#[test]
@@ -103,33 +104,33 @@ fn should_error_on_negative_edge_distance() {
});
let result = graph.add_edge(node1, node2, false, Some(-1.0), Direction::Right, TraversalFlags::ALL);
assert!(result.is_err());
assert_that(&result.is_err()).is_true();
}
#[test]
fn should_error_on_duplicate_edge_without_replace() {
let mut graph = common::create_test_graph();
let result = graph.add_edge(0, 1, false, None, Direction::Right, TraversalFlags::ALL);
assert!(result.is_err());
assert_that(&result.is_err()).is_true();
}
#[test]
fn should_allow_replacing_an_edge() {
let mut graph = common::create_test_graph();
let result = graph.add_edge(0, 1, true, Some(42.0), Direction::Right, TraversalFlags::ALL);
assert!(result.is_ok());
assert_that(&result.is_ok()).is_true();
let edge = graph.find_edge(0, 1).unwrap();
assert_eq!(edge.distance, 42.0);
assert_that(&edge.distance).is_equal_to(42.0);
}
#[test]
fn should_find_edge_between_nodes() {
let graph = common::create_test_graph();
let edge = graph.find_edge(0, 1);
assert!(edge.is_some());
assert_eq!(edge.unwrap().target, 1);
assert_that(&edge.is_some()).is_true();
assert_that(&edge.unwrap().target).is_equal_to(1);
let non_existent_edge = graph.find_edge(0, 99);
assert!(non_existent_edge.is_none());
assert_that(&non_existent_edge.is_none()).is_true();
}

View File

@@ -1,38 +1,321 @@
use glam::Vec2;
use pacman::events::{GameCommand, GameEvent};
use pacman::map::direction::Direction;
use pacman::systems::input::{process_simple_key_events, Bindings, SimpleKeyEvent};
use pacman::systems::input::{
calculate_direction_from_delta, process_simple_key_events, update_touch_reference_position, Bindings, CursorPosition,
SimpleKeyEvent, TouchData, TouchState, TOUCH_DIRECTION_THRESHOLD, TOUCH_EASING_DISTANCE_THRESHOLD,
};
use sdl2::keyboard::Keycode;
use speculoos::prelude::*;
#[test]
fn resumes_previous_direction_when_secondary_key_released() {
let mut bindings = Bindings::default();
// Test modules for better organization
mod keyboard_tests {
use super::*;
// Frame 1: Press W (Up) => emits Move Up
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::W)]);
assert!(events.contains(&GameEvent::Command(GameCommand::MovePlayer(Direction::Up))));
#[test]
fn key_down_emits_bound_command() {
let mut bindings = Bindings::default();
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::W)]);
assert_that(&events).contains(GameEvent::Command(GameCommand::MovePlayer(Direction::Up)));
}
// Frame 2: Press D (Right) => emits Move Right
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::D)]);
assert!(events.contains(&GameEvent::Command(GameCommand::MovePlayer(Direction::Right))));
#[test]
fn key_down_emits_non_movement_commands() {
let mut bindings = Bindings::default();
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::P)]);
assert_that(&events).contains(GameEvent::Command(GameCommand::TogglePause));
}
// Frame 3: Release D, no new key this frame => should continue previous key W (Up)
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyUp(Keycode::D)]);
assert!(events.contains(&GameEvent::Command(GameCommand::MovePlayer(Direction::Up))));
#[test]
fn unbound_key_emits_nothing() {
let mut bindings = Bindings::default();
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::Z)]);
assert_that(&events).is_empty();
}
#[test]
fn movement_key_held_continues_across_frames() {
let mut bindings = Bindings::default();
process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::Left)]);
let events = process_simple_key_events(&mut bindings, &[]);
assert_that(&events).contains(GameEvent::Command(GameCommand::MovePlayer(Direction::Left)));
}
#[test]
fn releasing_movement_key_stops_continuation() {
let mut bindings = Bindings::default();
process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::Up)]);
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyUp(Keycode::Up)]);
assert_that(&events).is_empty();
}
#[test]
fn multiple_movement_keys_resumes_previous_when_current_released() {
let mut bindings = Bindings::default();
process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::W)]);
process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::D)]);
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyUp(Keycode::D)]);
assert_that(&events).contains(GameEvent::Command(GameCommand::MovePlayer(Direction::Up)));
}
}
#[test]
fn holds_last_pressed_key_across_frames_when_no_new_input() {
let mut bindings = Bindings::default();
mod direction_calculation_tests {
use super::*;
// Frame 1: Press Left
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::Left)]);
assert!(events.contains(&GameEvent::Command(GameCommand::MovePlayer(Direction::Left))));
#[test]
fn prioritizes_horizontal_movement() {
let test_cases = vec![
(Vec2::new(6.0, 5.0), Direction::Right),
(Vec2::new(-6.0, 5.0), Direction::Left),
];
// Frame 2: No input => continues Left
let events = process_simple_key_events(&mut bindings, &[]);
assert!(events.contains(&GameEvent::Command(GameCommand::MovePlayer(Direction::Left))));
for (delta, expected) in test_cases {
assert_that(&calculate_direction_from_delta(delta)).is_equal_to(expected);
}
}
// Frame 3: Release Left, no input remains => nothing emitted
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyUp(Keycode::Left)]);
assert!(events.is_empty());
#[test]
fn uses_vertical_when_dominant() {
let test_cases = vec![
(Vec2::new(3.0, 10.0), Direction::Down),
(Vec2::new(3.0, -10.0), Direction::Up),
];
for (delta, expected) in test_cases {
assert_that(&calculate_direction_from_delta(delta)).is_equal_to(expected);
}
}
#[test]
fn handles_zero_delta() {
let delta = Vec2::ZERO;
// Should default to Up when both components are zero
assert_that(&calculate_direction_from_delta(delta)).is_equal_to(Direction::Up);
}
#[test]
fn handles_equal_magnitudes() {
// When x and y have equal absolute values, should prioritize vertical
let delta = Vec2::new(5.0, 5.0);
assert_that(&calculate_direction_from_delta(delta)).is_equal_to(Direction::Down);
let delta = Vec2::new(-5.0, 5.0);
assert_that(&calculate_direction_from_delta(delta)).is_equal_to(Direction::Down);
}
}
mod touch_easing_tests {
use super::*;
#[test]
fn easing_within_threshold_does_nothing() {
let mut touch_data = TouchData::new(0, Vec2::new(100.0, 100.0));
touch_data.current_pos = Vec2::new(100.0 + TOUCH_EASING_DISTANCE_THRESHOLD - 0.1, 100.0);
let (_delta, distance) = update_touch_reference_position(&mut touch_data, 0.016);
assert_that(&distance).is_less_than(TOUCH_EASING_DISTANCE_THRESHOLD);
assert_that(&touch_data.start_pos).is_equal_to(Vec2::new(100.0, 100.0));
}
#[test]
fn easing_beyond_threshold_moves_towards_target() {
let mut touch_data = TouchData::new(0, Vec2::new(100.0, 100.0));
touch_data.current_pos = Vec2::new(150.0, 100.0);
let original_start_pos = touch_data.start_pos;
let (_delta, distance) = update_touch_reference_position(&mut touch_data, 0.016);
assert_that(&distance).is_greater_than(TOUCH_EASING_DISTANCE_THRESHOLD);
assert_that(&touch_data.start_pos.x).is_greater_than(original_start_pos.x);
assert_that(&touch_data.start_pos.x).is_less_than(touch_data.current_pos.x);
}
#[test]
fn easing_overshoot_sets_to_target() {
let mut touch_data = TouchData::new(0, Vec2::new(100.0, 100.0));
touch_data.current_pos = Vec2::new(101.0, 100.0);
let (_delta, _distance) = update_touch_reference_position(&mut touch_data, 10.0);
assert_that(&touch_data.start_pos).is_equal_to(touch_data.current_pos);
}
#[test]
fn easing_returns_correct_delta() {
let mut touch_data = TouchData::new(0, Vec2::new(100.0, 100.0));
touch_data.current_pos = Vec2::new(120.0, 110.0);
let (delta, distance) = update_touch_reference_position(&mut touch_data, 0.016);
let expected_delta = Vec2::new(20.0, 10.0);
let expected_distance = expected_delta.length();
assert_that(&delta).is_equal_to(expected_delta);
assert_that(&distance).is_equal_to(expected_distance);
}
}
// Integration tests for the full input system
mod integration_tests {
use super::*;
fn mouse_motion_event(x: i32, y: i32) -> sdl2::event::Event {
sdl2::event::Event::MouseMotion {
x,
y,
xrel: 0,
yrel: 0,
mousestate: sdl2::mouse::MouseState::from_sdl_state(0),
which: 0,
window_id: 0,
timestamp: 0,
}
}
fn mouse_button_down_event(x: i32, y: i32) -> sdl2::event::Event {
sdl2::event::Event::MouseButtonDown {
x,
y,
mouse_btn: sdl2::mouse::MouseButton::Left,
clicks: 1,
which: 0,
window_id: 0,
timestamp: 0,
}
}
fn mouse_button_up_event(x: i32, y: i32) -> sdl2::event::Event {
sdl2::event::Event::MouseButtonUp {
x,
y,
mouse_btn: sdl2::mouse::MouseButton::Left,
clicks: 1,
which: 0,
window_id: 0,
timestamp: 0,
}
}
// Simplified helper for testing SDL integration
fn run_input_system_with_events(events: Vec<sdl2::event::Event>, delta_time: f32) -> (CursorPosition, TouchState) {
use bevy_ecs::{event::Events, system::RunSystemOnce, world::World};
use pacman::systems::components::DeltaTime;
use pacman::systems::input::input_system;
let sdl_context = sdl2::init().expect("Failed to initialize SDL");
let event_subsystem = sdl_context.event().expect("Failed to get event subsystem");
let event_pump = sdl_context.event_pump().expect("Failed to create event pump");
let mut world = World::new();
world.insert_resource(Events::<GameEvent>::default());
world.insert_resource(DeltaTime {
seconds: delta_time,
ticks: 1,
});
world.insert_resource(Bindings::default());
world.insert_resource(CursorPosition::None);
world.insert_resource(TouchState::default());
world.insert_non_send_resource(event_pump);
// Inject events into SDL's event queue
for event in events {
event_subsystem.push_event(event).expect("Failed to push event");
}
// Run the real input system
world
.run_system_once(input_system)
.expect("Input system should run successfully");
let cursor = *world.resource::<CursorPosition>();
let touch_state = world.resource::<TouchState>().clone();
(cursor, touch_state)
}
#[test]
fn mouse_motion_updates_cursor_position() {
let events = vec![mouse_motion_event(100, 200)];
let (cursor, _touch_state) = run_input_system_with_events(events, 0.016);
match cursor {
CursorPosition::Some {
position,
remaining_time,
} => {
assert_that(&position).is_equal_to(Vec2::new(100.0, 200.0));
assert_that(&remaining_time).is_equal_to(0.20);
}
CursorPosition::None => panic!("Expected cursor position to be set"),
}
}
#[test]
fn mouse_button_down_starts_touch() {
let events = vec![mouse_button_down_event(150, 250)];
let (_cursor, touch_state) = run_input_system_with_events(events, 0.016);
assert_that(&touch_state.active_touch).is_some();
if let Some(touch_data) = &touch_state.active_touch {
assert_that(&touch_data.finger_id).is_equal_to(0);
assert_that(&touch_data.start_pos).is_equal_to(Vec2::new(150.0, 250.0));
}
}
#[test]
fn mouse_button_up_ends_touch() {
let events = vec![mouse_button_down_event(150, 250), mouse_button_up_event(150, 250)];
let (_cursor, touch_state) = run_input_system_with_events(events, 0.016);
assert_that(&touch_state.active_touch).is_none();
}
}
// Touch direction tests
mod touch_direction_tests {
use super::*;
#[test]
fn movement_above_threshold_emits_direction() {
let mut touch_data = TouchData::new(1, Vec2::new(100.0, 100.0));
touch_data.current_pos = Vec2::new(100.0 + TOUCH_DIRECTION_THRESHOLD + 5.0, 100.0);
let (delta, distance) = update_touch_reference_position(&mut touch_data, 0.016);
assert_that(&distance).is_greater_than_or_equal_to(TOUCH_DIRECTION_THRESHOLD);
let direction = calculate_direction_from_delta(delta);
assert_that(&direction).is_equal_to(Direction::Right);
}
#[test]
fn movement_below_threshold_no_direction() {
let mut touch_data = TouchData::new(1, Vec2::new(100.0, 100.0));
touch_data.current_pos = Vec2::new(100.0 + TOUCH_DIRECTION_THRESHOLD - 1.0, 100.0);
let (_delta, distance) = update_touch_reference_position(&mut touch_data, 0.016);
assert_that(&distance).is_less_than(TOUCH_DIRECTION_THRESHOLD);
}
#[test]
fn all_directions_work_correctly() {
let test_cases = vec![
(Vec2::new(TOUCH_DIRECTION_THRESHOLD + 5.0, 0.0), Direction::Right),
(Vec2::new(-TOUCH_DIRECTION_THRESHOLD - 5.0, 0.0), Direction::Left),
(Vec2::new(0.0, TOUCH_DIRECTION_THRESHOLD + 5.0), Direction::Down),
(Vec2::new(0.0, -TOUCH_DIRECTION_THRESHOLD - 5.0), Direction::Up),
];
for (offset, expected_direction) in test_cases {
let mut touch_data = TouchData::new(1, Vec2::new(100.0, 100.0));
touch_data.current_pos = Vec2::new(100.0, 100.0) + offset;
let (delta, distance) = update_touch_reference_position(&mut touch_data, 0.016);
assert_that(&distance).is_greater_than_or_equal_to(TOUCH_DIRECTION_THRESHOLD);
let direction = calculate_direction_from_delta(delta);
assert_that(&direction).is_equal_to(expected_direction);
}
}
}

View File

@@ -1,41 +1,42 @@
use bevy_ecs::{entity::Entity, system::RunSystemOnce};
use pacman::systems::{is_valid_item_collision, item_system, EntityType, GhostState, Position, ScoreResource};
use speculoos::prelude::*;
mod common;
#[test]
fn test_calculate_score_for_item() {
assert!(EntityType::Pellet.score_value() < EntityType::PowerPellet.score_value());
assert!(EntityType::Pellet.score_value().is_some());
assert!(EntityType::PowerPellet.score_value().is_some());
assert!(EntityType::Player.score_value().is_none());
assert!(EntityType::Ghost.score_value().is_none());
assert_that(&(EntityType::Pellet.score_value() < EntityType::PowerPellet.score_value())).is_true();
assert_that(&EntityType::Pellet.score_value().is_some()).is_true();
assert_that(&EntityType::PowerPellet.score_value().is_some()).is_true();
assert_that(&EntityType::Player.score_value().is_none()).is_true();
assert_that(&EntityType::Ghost.score_value().is_none()).is_true();
}
#[test]
fn test_is_collectible_item() {
// Collectible
assert!(EntityType::Pellet.is_collectible());
assert!(EntityType::PowerPellet.is_collectible());
assert_that(&EntityType::Pellet.is_collectible()).is_true();
assert_that(&EntityType::PowerPellet.is_collectible()).is_true();
// Non-collectible
assert!(!EntityType::Player.is_collectible());
assert!(!EntityType::Ghost.is_collectible());
assert_that(&EntityType::Player.is_collectible()).is_false();
assert_that(&EntityType::Ghost.is_collectible()).is_false();
}
#[test]
fn test_is_valid_item_collision() {
// Player-item collisions should be valid
assert!(is_valid_item_collision(EntityType::Player, EntityType::Pellet));
assert!(is_valid_item_collision(EntityType::Player, EntityType::PowerPellet));
assert!(is_valid_item_collision(EntityType::Pellet, EntityType::Player));
assert!(is_valid_item_collision(EntityType::PowerPellet, EntityType::Player));
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!(!is_valid_item_collision(EntityType::Player, EntityType::Ghost));
assert!(!is_valid_item_collision(EntityType::Ghost, EntityType::Pellet));
assert!(!is_valid_item_collision(EntityType::Pellet, EntityType::PowerPellet));
assert!(!is_valid_item_collision(EntityType::Player, EntityType::Player));
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]
@@ -52,7 +53,7 @@ fn test_item_system_pellet_collection() {
// Check that score was updated
let score = world.resource::<ScoreResource>();
assert_eq!(score.0, 10);
assert_that(&score.0).is_equal_to(10);
// Check that the pellet was despawned (query should return empty)
let item_count = world
@@ -60,7 +61,7 @@ fn test_item_system_pellet_collection() {
.iter(&world)
.filter(|&entity_type| matches!(entity_type, EntityType::Pellet))
.count();
assert_eq!(item_count, 0);
assert_that(&item_count).is_equal_to(0);
}
#[test]
@@ -75,7 +76,7 @@ fn test_item_system_power_pellet_collection() {
// Check that score was updated with power pellet value
let score = world.resource::<ScoreResource>();
assert_eq!(score.0, 50);
assert_that(&score.0).is_equal_to(50);
// Check that the power pellet was despawned (query should return empty)
let item_count = world
@@ -83,7 +84,7 @@ fn test_item_system_power_pellet_collection() {
.iter(&world)
.filter(|&entity_type| matches!(entity_type, EntityType::PowerPellet))
.count();
assert_eq!(item_count, 0);
assert_that(&item_count).is_equal_to(0);
}
#[test]
@@ -103,7 +104,7 @@ fn test_item_system_multiple_collections() {
// Check final score: 2 pellets (20) + 1 power pellet (50) = 70
let score = world.resource::<ScoreResource>();
assert_eq!(score.0, 70);
assert_that(&score.0).is_equal_to(70);
// Check that all items were despawned
let pellet_count = world
@@ -116,8 +117,8 @@ fn test_item_system_multiple_collections() {
.iter(&world)
.filter(|&entity_type| matches!(entity_type, EntityType::PowerPellet))
.count();
assert_eq!(pellet_count, 0);
assert_eq!(power_pellet_count, 0);
assert_that(&pellet_count).is_equal_to(0);
assert_that(&power_pellet_count).is_equal_to(0);
}
#[test]
@@ -138,7 +139,7 @@ fn test_item_system_ignores_non_item_collisions() {
// Score should remain unchanged
let score = world.resource::<ScoreResource>();
assert_eq!(score.0, initial_score);
assert_that(&score.0).is_equal_to(initial_score);
// Ghost should still exist (not despawned)
let ghost_count = world
@@ -146,7 +147,7 @@ fn test_item_system_ignores_non_item_collisions() {
.iter(&world)
.filter(|&entity_type| matches!(entity_type, EntityType::Ghost))
.count();
assert_eq!(ghost_count, 1);
assert_that(&ghost_count).is_equal_to(1);
}
#[test]
@@ -162,13 +163,13 @@ fn test_item_system_no_collision_events() {
// Nothing should change
let score = world.resource::<ScoreResource>();
assert_eq!(score.0, initial_score);
assert_that(&score.0).is_equal_to(initial_score);
let pellet_count = world
.query::<&EntityType>()
.iter(&world)
.filter(|&entity_type| matches!(entity_type, EntityType::Pellet))
.count();
assert_eq!(pellet_count, 1);
assert_that(&pellet_count).is_equal_to(1);
}
#[test]
@@ -188,7 +189,7 @@ fn test_item_system_collision_with_missing_entity() {
// Score should remain unchanged
let score = world.resource::<ScoreResource>();
assert_eq!(score.0, 0);
assert_that(&score.0).is_equal_to(0);
}
#[test]
@@ -207,7 +208,7 @@ fn test_item_system_preserves_existing_score() {
// Score should be initial + pellet value
let score = world.resource::<ScoreResource>();
assert_eq!(score.0, 110);
assert_that(&score.0).is_equal_to(110);
}
#[test]
@@ -228,7 +229,7 @@ fn test_power_pellet_does_not_affect_ghosts_in_eyes_state() {
// Check that the power pellet was collected and score updated
let score = world.resource::<ScoreResource>();
assert_eq!(score.0, 50);
assert_that(&score.0).is_equal_to(50);
// Check that the power pellet was despawned
let power_pellet_count = world
@@ -236,13 +237,13 @@ fn test_power_pellet_does_not_affect_ghosts_in_eyes_state() {
.iter(&world)
.filter(|&entity_type| matches!(entity_type, EntityType::PowerPellet))
.count();
assert_eq!(power_pellet_count, 0);
assert_that(&power_pellet_count).is_equal_to(0);
// Check that the Eyes ghost state was not changed
let eyes_ghost_state = world.entity(eyes_ghost).get::<GhostState>().unwrap();
assert!(matches!(*eyes_ghost_state, GhostState::Eyes));
assert_that(&matches!(*eyes_ghost_state, GhostState::Eyes)).is_true();
// Check that the Normal ghost state was changed to Frightened
let normal_ghost_state = world.entity(normal_ghost).get::<GhostState>().unwrap();
assert!(matches!(*normal_ghost_state, GhostState::Frightened { .. }));
assert_that(&matches!(*normal_ghost_state, GhostState::Frightened { .. })).is_true();
}

View File

@@ -1,13 +1,15 @@
use glam::Vec2;
use pacman::constants::{CELL_SIZE, RAW_BOARD};
use pacman::map::builder::Map;
use pacman::map::graph::TraversalFlags;
use speculoos::prelude::*;
#[test]
fn test_map_creation() {
fn test_map_creation_success() {
let map = Map::new(RAW_BOARD).unwrap();
assert!(map.graph.nodes().count() > 0);
assert!(!map.grid_to_node.is_empty());
assert_that(&map.graph.nodes().count()).is_greater_than(0);
assert_that(&map.grid_to_node.is_empty()).is_false();
// Check that some connections were made
let mut has_connections = false;
@@ -17,11 +19,11 @@ fn test_map_creation() {
break;
}
}
assert!(has_connections);
assert_that(&has_connections).is_true();
}
#[test]
fn test_map_node_positions() {
fn test_map_node_positions_accuracy() {
let map = Map::new(RAW_BOARD).unwrap();
for (grid_pos, &node_id) in &map.grid_to_node {
@@ -31,6 +33,57 @@ fn test_map_node_positions() {
(grid_pos.y as i32 * CELL_SIZE as i32) as f32,
) + Vec2::splat(CELL_SIZE as f32 / 2.0);
assert_eq!(node.position, expected_pos);
assert_that(&node.position).is_equal_to(expected_pos);
}
}
#[test]
fn test_start_positions_are_valid() {
let map = Map::new(RAW_BOARD).unwrap();
let positions = &map.start_positions;
// All start positions should exist in the graph
assert_that(&map.graph.get_node(positions.pacman)).is_some();
assert_that(&map.graph.get_node(positions.blinky)).is_some();
assert_that(&map.graph.get_node(positions.pinky)).is_some();
assert_that(&map.graph.get_node(positions.inky)).is_some();
assert_that(&map.graph.get_node(positions.clyde)).is_some();
}
#[test]
fn test_ghost_house_has_ghost_only_entrance() {
let map = Map::new(RAW_BOARD).unwrap();
// Find the house entrance node
let house_entrance = map.start_positions.blinky;
// Check that there's a ghost-only connection from the house entrance
let mut has_ghost_only_connection = false;
for edge in map.graph.adjacency_list[house_entrance as usize].edges() {
if edge.traversal_flags == TraversalFlags::GHOST {
has_ghost_only_connection = true;
break;
}
}
assert_that(&has_ghost_only_connection).is_true();
}
#[test]
fn test_tunnel_connections_exist() {
let map = Map::new(RAW_BOARD).unwrap();
// Find tunnel nodes by looking for nodes with zero-distance connections
let mut has_tunnel_connection = false;
for intersection in &map.graph.adjacency_list {
for edge in intersection.edges() {
if edge.distance == 0.0f32 {
has_tunnel_connection = true;
break;
}
}
if has_tunnel_connection {
break;
}
}
assert_that(&has_tunnel_connection).is_true();
}

View File

@@ -1,6 +1,7 @@
use glam::Vec2;
use pacman::map::direction::Direction;
use pacman::systems::movement::{BufferedDirection, Position, Velocity};
use speculoos::prelude::*;
mod common;
@@ -13,8 +14,8 @@ fn test_position_is_at_node() {
remaining_distance: 8.0,
};
assert!(stopped_pos.is_at_node());
assert!(!moving_pos.is_at_node());
assert_that(&stopped_pos.is_at_node()).is_true();
assert_that(&moving_pos.is_at_node()).is_false();
}
#[test]
@@ -26,8 +27,8 @@ fn test_position_current_node() {
remaining_distance: 12.0,
};
assert_eq!(stopped_pos.current_node(), 5);
assert_eq!(moving_pos.current_node(), 3);
assert_that(&stopped_pos.current_node()).is_equal_to(5);
assert_that(&moving_pos.current_node()).is_equal_to(3);
}
#[test]
@@ -35,8 +36,8 @@ fn test_position_tick_no_movement_when_stopped() {
let mut pos = Position::Stopped { node: 0 };
let result = pos.tick(5.0);
assert!(result.is_none());
assert_eq!(pos, Position::Stopped { node: 0 });
assert_that(&result.is_none()).is_true();
assert_that(&pos).is_equal_to(Position::Stopped { node: 0 });
}
#[test]
@@ -48,15 +49,12 @@ fn test_position_tick_no_movement_when_zero_distance() {
};
let result = pos.tick(0.0);
assert!(result.is_none());
assert_eq!(
pos,
Position::Moving {
from: 0,
to: 1,
remaining_distance: 10.0,
}
);
assert_that(&result.is_none()).is_true();
assert_that(&pos).is_equal_to(Position::Moving {
from: 0,
to: 1,
remaining_distance: 10.0,
});
}
#[test]
@@ -68,15 +66,12 @@ fn test_position_tick_partial_movement() {
};
let result = pos.tick(3.0);
assert!(result.is_none());
assert_eq!(
pos,
Position::Moving {
from: 0,
to: 1,
remaining_distance: 7.0,
}
);
assert_that(&result.is_none()).is_true();
assert_that(&pos).is_equal_to(Position::Moving {
from: 0,
to: 1,
remaining_distance: 7.0,
});
}
#[test]
@@ -88,8 +83,8 @@ fn test_position_tick_exact_arrival() {
};
let result = pos.tick(5.0);
assert!(result.is_none());
assert_eq!(pos, Position::Stopped { node: 1 });
assert_that(&result.is_none()).is_true();
assert_that(&pos).is_equal_to(Position::Stopped { node: 1 });
}
#[test]
@@ -101,8 +96,8 @@ fn test_position_tick_overshoot_with_overflow() {
};
let result = pos.tick(8.0);
assert_eq!(result, Some(5.0));
assert_eq!(pos, Position::Stopped { node: 1 });
assert_that(&result).is_equal_to(Some(5.0));
assert_that(&pos).is_equal_to(Position::Stopped { node: 1 });
}
#[test]
@@ -116,7 +111,7 @@ fn test_position_get_pixel_position_stopped() {
0.0 + pacman::constants::BOARD_PIXEL_OFFSET.y as f32,
);
assert_eq!(pixel_pos, expected);
assert_that(&pixel_pos).is_equal_to(expected);
}
#[test]
@@ -135,7 +130,7 @@ fn test_position_get_pixel_position_moving() {
0.0 + pacman::constants::BOARD_PIXEL_OFFSET.y as f32,
);
assert_eq!(pixel_pos, expected);
assert_that(&pixel_pos).is_equal_to(expected);
}
#[test]
@@ -145,14 +140,14 @@ fn test_velocity_basic_properties() {
direction: Direction::Up,
};
assert_eq!(velocity.speed, 2.5);
assert_eq!(velocity.direction, Direction::Up);
assert_that(&velocity.speed).is_equal_to(2.5);
assert_that(&velocity.direction).is_equal_to(Direction::Up);
}
#[test]
fn test_buffered_direction_none() {
let buffered = BufferedDirection::None;
assert_eq!(buffered, BufferedDirection::None);
assert_that(&buffered).is_equal_to(BufferedDirection::None);
}
#[test]
@@ -167,8 +162,8 @@ fn test_buffered_direction_some() {
remaining_time,
} = buffered
{
assert_eq!(direction, Direction::Left);
assert_eq!(remaining_time, 0.5);
assert_that(&direction).is_equal_to(Direction::Left);
assert_that(&remaining_time).is_equal_to(0.5);
} else {
panic!("Expected BufferedDirection::Some");
}

View File

@@ -1,6 +1,7 @@
use pacman::constants::{BOARD_CELL_SIZE, RAW_BOARD};
use pacman::error::ParseError;
use pacman::map::parser::MapTileParser;
use speculoos::prelude::*;
#[test]
fn test_parse_character() {
@@ -15,25 +16,25 @@ fn test_parse_character() {
];
for (char, _expected) in test_cases {
assert!(matches!(MapTileParser::parse_character(char).unwrap(), _expected));
assert_that(&matches!(MapTileParser::parse_character(char).unwrap(), _expected)).is_true();
}
assert!(MapTileParser::parse_character('Z').is_err());
assert_that(&MapTileParser::parse_character('Z').is_err()).is_true();
}
#[test]
fn test_parse_board() {
let result = MapTileParser::parse_board(RAW_BOARD);
assert!(result.is_ok());
assert_that(&result.is_ok()).is_true();
let parsed = result.unwrap();
assert_eq!(parsed.tiles.len(), BOARD_CELL_SIZE.x as usize);
assert_eq!(parsed.tiles[0].len(), BOARD_CELL_SIZE.y as usize);
assert!(parsed.house_door[0].is_some());
assert!(parsed.house_door[1].is_some());
assert!(parsed.tunnel_ends[0].is_some());
assert!(parsed.tunnel_ends[1].is_some());
assert!(parsed.pacman_start.is_some());
assert_that(&parsed.tiles.len()).is_equal_to(BOARD_CELL_SIZE.x as usize);
assert_that(&parsed.tiles[0].len()).is_equal_to(BOARD_CELL_SIZE.y as usize);
assert_that(&parsed.house_door[0].is_some()).is_true();
assert_that(&parsed.house_door[1].is_some()).is_true();
assert_that(&parsed.tunnel_ends[0].is_some()).is_true();
assert_that(&parsed.tunnel_ends[1].is_some()).is_true();
assert_that(&parsed.pacman_start.is_some()).is_true();
}
#[test]
@@ -42,6 +43,6 @@ fn test_parse_board_invalid_character() {
invalid_board[0] = "###########################Z".to_string();
let result = MapTileParser::parse_board(invalid_board.each_ref().map(|s| s.as_str()));
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ParseError::UnknownCharacter('Z')));
assert_that(&result.is_err()).is_true();
assert_that(&matches!(result.unwrap_err(), ParseError::UnknownCharacter('Z'))).is_true();
}

View File

@@ -10,6 +10,7 @@ use pacman::{
EntityType, GlobalState, Position, Velocity,
},
};
use speculoos::prelude::*;
mod common;
@@ -22,7 +23,7 @@ fn test_can_traverse_player_on_all_edges() {
traversal_flags: TraversalFlags::ALL,
};
assert!(can_traverse(EntityType::Player, edge));
assert_that(&can_traverse(EntityType::Player, edge)).is_true();
}
#[test]
@@ -34,7 +35,7 @@ fn test_can_traverse_player_on_pacman_only_edges() {
traversal_flags: TraversalFlags::PACMAN,
};
assert!(can_traverse(EntityType::Player, edge));
assert_that(&can_traverse(EntityType::Player, edge)).is_true();
}
#[test]
@@ -46,7 +47,7 @@ fn test_can_traverse_player_blocked_on_ghost_only_edges() {
traversal_flags: TraversalFlags::GHOST,
};
assert!(!can_traverse(EntityType::Player, edge));
assert_that(&can_traverse(EntityType::Player, edge)).is_false();
}
#[test]
@@ -58,7 +59,7 @@ fn test_can_traverse_ghost_on_all_edges() {
traversal_flags: TraversalFlags::ALL,
};
assert!(can_traverse(EntityType::Ghost, edge));
assert_that(&can_traverse(EntityType::Ghost, edge)).is_true();
}
#[test]
@@ -70,7 +71,7 @@ fn test_can_traverse_ghost_on_ghost_only_edges() {
traversal_flags: TraversalFlags::GHOST,
};
assert!(can_traverse(EntityType::Ghost, edge));
assert_that(&can_traverse(EntityType::Ghost, edge)).is_true();
}
#[test]
@@ -82,7 +83,7 @@ fn test_can_traverse_ghost_blocked_on_pacman_only_edges() {
traversal_flags: TraversalFlags::PACMAN,
};
assert!(!can_traverse(EntityType::Ghost, edge));
assert_that(&can_traverse(EntityType::Ghost, edge)).is_false();
}
#[test]
@@ -97,16 +98,16 @@ fn test_can_traverse_static_entities_flags() {
// Static entities have empty traversal flags but can still "traverse"
// in the sense that empty flags are contained in any flag set
// This is the expected behavior since empty ⊆ any set
assert!(can_traverse(EntityType::Pellet, edge));
assert!(can_traverse(EntityType::PowerPellet, edge));
assert_that(&can_traverse(EntityType::Pellet, edge)).is_true();
assert_that(&can_traverse(EntityType::PowerPellet, edge)).is_true();
}
#[test]
fn test_entity_type_traversal_flags() {
assert_eq!(EntityType::Player.traversal_flags(), TraversalFlags::PACMAN);
assert_eq!(EntityType::Ghost.traversal_flags(), TraversalFlags::GHOST);
assert_eq!(EntityType::Pellet.traversal_flags(), TraversalFlags::empty());
assert_eq!(EntityType::PowerPellet.traversal_flags(), TraversalFlags::empty());
assert_that(&EntityType::Player.traversal_flags()).is_equal_to(TraversalFlags::PACMAN);
assert_that(&EntityType::Ghost.traversal_flags()).is_equal_to(TraversalFlags::GHOST);
assert_that(&EntityType::Pellet.traversal_flags()).is_equal_to(TraversalFlags::empty());
assert_that(&EntityType::PowerPellet.traversal_flags()).is_equal_to(TraversalFlags::empty());
}
#[test]
@@ -131,8 +132,8 @@ fn test_player_control_system_move_command() {
direction,
remaining_time,
} => {
assert_eq!(direction, Direction::Up);
assert_eq!(remaining_time, 0.25);
assert_that(&direction).is_equal_to(Direction::Up);
assert_that(&remaining_time).is_equal_to(0.25);
}
BufferedDirection::None => panic!("Expected buffered direction to be set"),
}
@@ -153,7 +154,7 @@ fn test_player_control_system_exit_command() {
// Check that exit flag was set
let state = world.resource::<GlobalState>();
assert!(state.exit);
assert_that(&state.exit).is_true();
}
#[test]
@@ -171,7 +172,7 @@ fn test_player_control_system_toggle_debug() {
// Check that debug state changed
let debug_state = world.resource::<DebugState>();
assert!(debug_state.enabled);
assert_that(&debug_state.enabled).is_true();
}
#[test]
@@ -189,7 +190,7 @@ fn test_player_control_system_mute_audio() {
// Check that audio was muted
let audio_state = world.resource::<AudioState>();
assert!(audio_state.muted);
assert_that(&audio_state.muted).is_true();
// Send mute audio command again to unmute - need fresh events
world.resource_mut::<Events<GameEvent>>().clear(); // Clear previous events
@@ -200,7 +201,7 @@ fn test_player_control_system_mute_audio() {
// Check that audio was unmuted
let audio_state = world.resource::<AudioState>();
assert!(!audio_state.muted, "Audio should be unmuted after second toggle");
assert_that(&audio_state.muted).is_false();
}
#[test]
@@ -232,7 +233,7 @@ fn test_player_movement_system_buffered_direction_expires() {
});
// Set delta time to expire the buffered direction
world.insert_resource(DeltaTime(0.02));
world.insert_resource(DeltaTime::from_seconds(0.02));
// Run the system
world
@@ -245,10 +246,7 @@ fn test_player_movement_system_buffered_direction_expires() {
match *buffered_direction {
BufferedDirection::None => {} // Expected - fully expired
BufferedDirection::Some { remaining_time, .. } => {
assert!(
remaining_time <= 0.0,
"Buffered direction should be expired or have non-positive time"
);
assert_that(&(remaining_time <= 0.0)).is_true();
}
}
}
@@ -271,7 +269,7 @@ fn test_player_movement_system_start_moving_from_stopped() {
match *position {
Position::Moving { from, .. } => {
assert_eq!(from, 0, "Player should start from node 0");
assert_that(&from).is_equal_to(0);
// Don't assert exact target node since the real map has different connectivity
}
Position::Stopped { .. } => {} // May stay stopped if no valid edge in current direction
@@ -299,8 +297,8 @@ fn test_player_movement_system_buffered_direction_change() {
match *position {
Position::Moving { from, to, .. } => {
assert_eq!(from, 0);
assert_eq!(to, 2); // Should be moving to node 2 (down)
assert_that(&from).is_equal_to(0);
assert_that(&to).is_equal_to(2); // Should be moving to node 2 (down)
}
Position::Stopped { .. } => panic!("Player should have started moving"),
}
@@ -329,7 +327,7 @@ fn test_player_movement_system_no_valid_edge() {
let position = query.single(&world).expect("Player should exist");
match *position {
Position::Stopped { node } => assert_eq!(node, 0),
Position::Stopped { node } => assert_that(&node).is_equal_to(0),
Position::Moving { .. } => panic!("Player shouldn't be able to move without valid edge"),
}
}
@@ -356,7 +354,7 @@ fn test_player_movement_system_continue_moving() {
match *position {
Position::Moving { remaining_distance, .. } => {
assert!(remaining_distance < 50.0); // Should have moved
assert_that(&(remaining_distance < 50.0)).is_true(); // Should have moved
}
Position::Stopped { .. } => {
// If player reached destination, that's also valid
@@ -388,8 +386,8 @@ fn test_full_player_input_to_movement_flow() {
match *position {
Position::Moving { from, to, .. } => {
assert_eq!(from, 0);
assert_eq!(to, 2); // Moving to node 2 (down)
assert_that(&from).is_equal_to(0);
assert_that(&to).is_equal_to(2); // Moving to node 2 (down)
}
Position::Stopped { .. } => panic!("Player should be moving"),
}
@@ -410,7 +408,7 @@ fn test_buffered_direction_timing() {
.expect("System should run successfully");
// Run movement system multiple times with small delta times
world.insert_resource(DeltaTime(0.1)); // 0.1 seconds
world.insert_resource(DeltaTime::from_seconds(0.1)); // 0.1 seconds
// First run - buffered direction should still be active
world
@@ -421,20 +419,20 @@ fn test_buffered_direction_timing() {
match *buffered_direction {
BufferedDirection::Some { remaining_time, .. } => {
assert!(remaining_time > 0.0);
assert!(remaining_time < 0.25);
assert_that(&(remaining_time > 0.0)).is_true();
assert_that(&(remaining_time < 0.25)).is_true();
}
BufferedDirection::None => panic!("Buffered direction should still be active"),
}
// Run again to fully expire the buffered direction
world.insert_resource(DeltaTime(0.2)); // Total 0.3 seconds, should expire
world.insert_resource(DeltaTime::from_seconds(0.2)); // Total 0.3 seconds, should expire
world
.run_system_once(player_movement_system)
.expect("System should run successfully");
let buffered_direction = query.single(&world).expect("Player should exist");
assert_eq!(*buffered_direction, BufferedDirection::None);
assert_that(buffered_direction).is_equal_to(BufferedDirection::None);
}
#[test]
@@ -464,7 +462,7 @@ fn test_multiple_rapid_direction_changes() {
match *buffered_direction {
BufferedDirection::Some { direction, .. } => {
assert_eq!(direction, Direction::Left);
assert_that(&direction).is_equal_to(Direction::Left);
}
BufferedDirection::None => panic!("Expected buffered direction"),
}
@@ -510,8 +508,8 @@ fn test_player_state_persistence_across_systems() {
let position = *query.single(&world).expect("Player should exist");
// Check that the state changes persisted individually
assert!(debug_state_after_toggle.enabled, "Debug state should have toggled");
assert!(audio_muted_after_toggle, "Audio should be muted");
assert_that(&debug_state_after_toggle.enabled).is_true();
assert_that(&audio_muted_after_toggle).is_true();
// Player position depends on actual map connectivity
match position {

View File

@@ -1,4 +1,5 @@
use pacman::systems::profiling::{SystemId, SystemTimings};
use speculoos::prelude::*;
use std::time::Duration;
use strum::IntoEnumIterator;
@@ -6,15 +7,7 @@ macro_rules! assert_close {
($actual:expr, $expected:expr, $concern:expr) => {
let tolerance = Duration::from_micros(500);
let diff = $actual.abs_diff($expected);
assert!(
diff < tolerance,
"Expected {expected:?} ± {tolerance:.0?}, got {actual:?}, off by {diff:?} ({concern})",
concern = $concern,
expected = $expected,
actual = $actual,
tolerance = tolerance,
diff = diff
);
assert_that(&(diff < tolerance)).is_true();
};
}
@@ -22,33 +15,26 @@ macro_rules! assert_close {
fn test_timing_statistics() {
let timings = SystemTimings::default();
// 10ms average, 2ms std dev
timings.add_timing(SystemId::PlayerControls, Duration::from_millis(10));
timings.add_timing(SystemId::PlayerControls, Duration::from_millis(12));
timings.add_timing(SystemId::PlayerControls, Duration::from_millis(8));
// Add consecutive timing measurements (no skipped ticks to avoid zero padding)
timings.add_timing(SystemId::PlayerControls, Duration::from_millis(10), 1);
timings.add_timing(SystemId::PlayerControls, Duration::from_millis(12), 2);
timings.add_timing(SystemId::PlayerControls, Duration::from_millis(8), 3);
// 2ms average, 1ms std dev
timings.add_timing(SystemId::Blinking, Duration::from_millis(3));
timings.add_timing(SystemId::Blinking, Duration::from_millis(2));
timings.add_timing(SystemId::Blinking, Duration::from_millis(1));
// Add consecutive timing measurements for another system
timings.add_timing(SystemId::Blinking, Duration::from_millis(3), 1);
timings.add_timing(SystemId::Blinking, Duration::from_millis(2), 2);
timings.add_timing(SystemId::Blinking, Duration::from_millis(1), 3);
{
let stats = timings.get_stats();
let stats = timings.get_stats(3);
let (avg, std_dev) = stats.get(&SystemId::PlayerControls).unwrap();
assert_close!(*avg, Duration::from_millis(10), "PlayerControls average timing");
assert_close!(*std_dev, Duration::from_millis(2), "PlayerControls standard deviation timing");
}
{
let (total_avg, total_std) = timings.get_total_stats();
assert_close!(total_avg, Duration::from_millis(2), "Total average timing across all systems");
assert_close!(
total_std,
Duration::from_millis(7),
"Total standard deviation timing across all systems"
);
}
// Note: get_total_stats() was removed as we now use the Total system directly
// This test now focuses on individual system statistics
}
#[test]
@@ -56,22 +42,22 @@ fn test_default_zero_timing_for_unused_systems() {
let timings = SystemTimings::default();
// Add timing data for only one system
timings.add_timing(SystemId::PlayerControls, Duration::from_millis(5));
timings.add_timing(SystemId::PlayerControls, Duration::from_millis(5), 1);
let stats = timings.get_stats();
let stats = timings.get_stats(1);
// Verify all SystemId variants are present in the stats
let expected_count = SystemId::iter().count();
assert_eq!(stats.len(), expected_count, "All SystemId variants should be in stats");
assert_that(&stats.len()).is_equal_to(expected_count);
// Verify that the system with data has non-zero timing
let (avg, std_dev) = stats.get(&SystemId::PlayerControls).unwrap();
assert_close!(*avg, Duration::from_millis(5), "System with data should have correct timing");
assert_close!(*std_dev, Duration::ZERO, "Single measurement should have zero std dev");
// Verify that all other systems have zero timing
// Verify that all other systems have zero timing (excluding Total which is special)
for id in SystemId::iter() {
if id != SystemId::PlayerControls {
if id != SystemId::PlayerControls && id != SystemId::Total {
let (avg, std_dev) = stats.get(&id).unwrap();
assert_close!(
*avg,
@@ -88,23 +74,19 @@ fn test_default_zero_timing_for_unused_systems() {
}
#[test]
fn test_pre_populated_timing_entries() {
fn test_total_system_timing() {
let timings = SystemTimings::default();
// Verify that we can add timing to any SystemId without panicking
// (this would fail with the old implementation if the entry didn't exist)
for id in SystemId::iter() {
timings.add_timing(id, Duration::from_nanos(1));
}
// Add some timing data to the Total system
timings.add_total_timing(Duration::from_millis(16), 1);
timings.add_total_timing(Duration::from_millis(18), 2);
timings.add_total_timing(Duration::from_millis(14), 3);
// Verify all systems now have non-zero timing
let stats = timings.get_stats();
for id in SystemId::iter() {
let (avg, _) = stats.get(&id).unwrap();
assert!(
*avg > Duration::ZERO,
"System {:?} should have non-zero timing after add_timing",
id
);
}
let stats = timings.get_stats(3);
let (avg, std_dev) = stats.get(&SystemId::Total).unwrap();
// Should have 16ms average (16+18+14)/3 = 16ms
assert_close!(*avg, Duration::from_millis(16), "Total system average timing");
// Should have some standard deviation
assert_that(&(*std_dev > Duration::ZERO)).is_true();
}

View File

@@ -1,6 +1,7 @@
use glam::U16Vec2;
use pacman::texture::sprite::{AtlasMapper, AtlasTile, MapperFrame};
use sdl2::pixels::Color;
use speculoos::prelude::*;
use std::collections::HashMap;
mod common;
@@ -20,10 +21,10 @@ fn test_atlas_mapper_frame_lookup() {
// Test direct frame lookup
let frame = mapper.frames.get("test");
assert!(frame.is_some());
assert_that(&frame.is_some()).is_true();
let frame = frame.unwrap();
assert_eq!(frame.pos, U16Vec2::new(10, 20));
assert_eq!(frame.size, U16Vec2::new(32, 64));
assert_that(&frame.pos).is_equal_to(U16Vec2::new(10, 20));
assert_that(&frame.size).is_equal_to(U16Vec2::new(32, 64));
}
#[test]
@@ -46,11 +47,11 @@ fn test_atlas_mapper_multiple_frames() {
let mapper = AtlasMapper { frames };
assert_eq!(mapper.frames.len(), 2);
assert!(mapper.frames.contains_key("tile1"));
assert!(mapper.frames.contains_key("tile2"));
assert!(!mapper.frames.contains_key("tile3"));
assert!(!mapper.frames.contains_key("nonexistent"));
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]
@@ -60,10 +61,10 @@ fn test_atlas_tile_new_and_with_color() {
let color = Color::RGB(100, 150, 200);
let tile = AtlasTile::new(pos, size, None);
assert_eq!(tile.pos, pos);
assert_eq!(tile.size, size);
assert_eq!(tile.color, 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_eq!(tile_with_color.color, Some(color));
assert_that(&tile_with_color.color).is_equal_to(Some(color));
}

73
tests/sprites.rs Normal file
View File

@@ -0,0 +1,73 @@
//! Tests for the sprite path generation.
use pacman::{
game::ATLAS_FRAMES,
map::direction::Direction,
systems::components::Ghost,
texture::sprites::{FrightenedColor, GameSprite, GhostSprite, MazeSprite, PacmanSprite},
};
#[test]
fn test_all_sprite_paths_exist() {
let mut sprites_to_test = Vec::new();
// Pac-Man sprites
for &dir in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] {
for frame in 0..2 {
sprites_to_test.push(GameSprite::Pacman(PacmanSprite::Moving(dir, frame)));
}
}
sprites_to_test.push(GameSprite::Pacman(PacmanSprite::Full));
for frame in 0..=10 {
sprites_to_test.push(GameSprite::Pacman(PacmanSprite::Dying(frame)));
}
// Ghost sprites
for &ghost in &[Ghost::Blinky, Ghost::Pinky, Ghost::Inky, Ghost::Clyde] {
for &dir in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] {
for frame in 0..2 {
sprites_to_test.push(GameSprite::Ghost(GhostSprite::Normal(ghost, dir, frame)));
}
sprites_to_test.push(GameSprite::Ghost(GhostSprite::Eyes(dir)));
}
}
for &color in &[FrightenedColor::Blue, FrightenedColor::White] {
for frame in 0..2 {
sprites_to_test.push(GameSprite::Ghost(GhostSprite::Frightened(color, frame)));
}
}
// Maze sprites
for i in 0..=34 {
sprites_to_test.push(GameSprite::Maze(MazeSprite::Tile(i)));
}
sprites_to_test.push(GameSprite::Maze(MazeSprite::Pellet));
sprites_to_test.push(GameSprite::Maze(MazeSprite::Energizer));
for sprite in sprites_to_test {
let path = sprite.to_path();
assert!(
ATLAS_FRAMES.contains_key(&path),
"Sprite path '{}' does not exist in the atlas.",
path
);
}
}
#[test]
fn test_invalid_sprite_paths_do_not_exist() {
let invalid_sprites = vec![
// An invalid Pac-Man dying frame
GameSprite::Pacman(PacmanSprite::Dying(99)),
// An invalid maze tile
GameSprite::Maze(MazeSprite::Tile(99)),
];
for sprite in invalid_sprites {
let path = sprite.to_path();
assert!(
!ATLAS_FRAMES.contains_key(&path),
"Invalid sprite path '{}' was found in the atlas, but it should not exist.",
path
);
}
}

View File

@@ -1,4 +1,5 @@
use pacman::texture::{sprite::SpriteAtlas, text::TextTexture};
use speculoos::prelude::*;
mod common;
@@ -16,22 +17,16 @@ fn get_all_chars() -> String {
/// Helper function to check if a character is in the atlas and char_map
fn check_char(text_texture: &mut TextTexture, atlas: &mut SpriteAtlas, c: char) {
// Check that the character is not in the char_map yet
assert!(
!text_texture.get_char_map().contains_key(&c),
"Character {c} should not yet be in char_map"
);
assert_that(&text_texture.get_char_map().contains_key(&c)).is_false();
// Get the tile from the atlas, which caches the tile in the char_map
let tile = text_texture.get_tile(c, atlas);
assert!(tile.is_ok(), "Failed to get tile for character {c}");
assert!(tile.unwrap().is_some(), "Tile for character {c} not found in atlas");
assert_that(&tile.is_ok()).is_true();
assert_that(&tile.unwrap().is_some()).is_true();
// Check that the tile is now cached in the char_map
assert!(
text_texture.get_char_map().contains_key(&c),
"Tile for character {c} was not cached in char_map"
);
assert_that(&text_texture.get_char_map().contains_key(&c)).is_true();
}
#[test]
@@ -74,8 +69,8 @@ fn test_text_width() -> Result<(), String> {
let width = text_texture.text_width(&string);
let height = text_texture.text_height();
assert!(width > 0, "Width for string {string} should be greater than 0");
assert!(height > 0, "Height for string {string} should be greater than 0");
assert_that(&(width > 0)).is_true();
assert_that(&(height > 0)).is_true();
}
Ok(())
@@ -88,22 +83,22 @@ fn test_text_scale() -> Result<(), String> {
let mut text_texture = TextTexture::new(0.5);
assert_eq!(text_texture.scale(), 0.5);
assert_eq!(text_texture.text_height(), 4);
assert_eq!(text_texture.text_width(""), 0);
assert_eq!(text_texture.text_width(string), base_width / 2);
assert_that(&text_texture.scale()).is_equal_to(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_eq!(text_texture.scale(), 2.0);
assert_eq!(text_texture.text_height(), 16);
assert_eq!(text_texture.text_width(string), base_width * 2);
assert_eq!(text_texture.text_width(""), 0);
assert_that(&text_texture.scale()).is_equal_to(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_eq!(text_texture.scale(), 1.0);
assert_eq!(text_texture.text_height(), 8);
assert_eq!(text_texture.text_width(string), base_width);
assert_eq!(text_texture.text_width(""), 0);
assert_that(&text_texture.scale()).is_equal_to(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(())
}
@@ -113,17 +108,17 @@ fn test_text_color() -> Result<(), String> {
let mut text_texture = TextTexture::new(1.0);
// Test default color (should be None initially)
assert_eq!(text_texture.color(), None);
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_eq!(text_texture.color(), Some(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_eq!(text_texture.color(), Some(new_color));
assert_that(&text_texture.color()).is_equal_to(Some(new_color));
Ok(())
}

115
tests/ttf.rs Normal file
View File

@@ -0,0 +1,115 @@
use pacman::texture::ttf::{TtfAtlas, TtfRenderer};
use sdl2::pixels::Color;
mod common;
#[test]
fn text_width_calculates_correctly_for_empty_string() {
let (mut canvas, texture_creator, _sdl) = common::setup_sdl().unwrap();
let _ttf_context = sdl2::ttf::init().unwrap();
let font = _ttf_context.load_font("assets/game/TerminalVector.ttf", 16).unwrap();
let mut atlas = TtfAtlas::new(&texture_creator, &font).unwrap();
atlas.populate_atlas(&mut canvas, &texture_creator, &font).unwrap();
let renderer = TtfRenderer::new(1.0);
let width = renderer.text_width(&atlas, "");
assert_eq!(width, 0);
}
#[test]
fn text_width_calculates_correctly_for_single_character() {
let (mut canvas, texture_creator, _sdl) = common::setup_sdl().unwrap();
let _ttf_context = sdl2::ttf::init().unwrap();
let font = _ttf_context.load_font("assets/game/TerminalVector.ttf", 16).unwrap();
let mut atlas = TtfAtlas::new(&texture_creator, &font).unwrap();
atlas.populate_atlas(&mut canvas, &texture_creator, &font).unwrap();
let renderer = TtfRenderer::new(1.0);
let width = renderer.text_width(&atlas, "A");
assert!(width > 0);
}
#[test]
fn text_width_scales_correctly() {
let (mut canvas, texture_creator, _sdl) = common::setup_sdl().unwrap();
let _ttf_context = sdl2::ttf::init().unwrap();
let font = _ttf_context.load_font("assets/game/TerminalVector.ttf", 16).unwrap();
let mut atlas = TtfAtlas::new(&texture_creator, &font).unwrap();
atlas.populate_atlas(&mut canvas, &texture_creator, &font).unwrap();
let renderer1 = TtfRenderer::new(1.0);
let renderer2 = TtfRenderer::new(2.0);
let width1 = renderer1.text_width(&atlas, "Test");
let width2 = renderer2.text_width(&atlas, "Test");
assert_eq!(width2, width1 * 2);
}
#[test]
fn text_height_returns_non_zero_for_valid_atlas() {
let (mut canvas, texture_creator, _sdl) = common::setup_sdl().unwrap();
let _ttf_context = sdl2::ttf::init().unwrap();
let font = _ttf_context.load_font("assets/game/TerminalVector.ttf", 16).unwrap();
let mut atlas = TtfAtlas::new(&texture_creator, &font).unwrap();
atlas.populate_atlas(&mut canvas, &texture_creator, &font).unwrap();
let renderer = TtfRenderer::new(1.0);
let height = renderer.text_height(&atlas);
assert!(height > 0);
}
#[test]
fn text_height_scales_correctly() {
let (mut canvas, texture_creator, _sdl) = common::setup_sdl().unwrap();
let _ttf_context = sdl2::ttf::init().unwrap();
let font = _ttf_context.load_font("assets/game/TerminalVector.ttf", 16).unwrap();
let mut atlas = TtfAtlas::new(&texture_creator, &font).unwrap();
atlas.populate_atlas(&mut canvas, &texture_creator, &font).unwrap();
let renderer1 = TtfRenderer::new(1.0);
let renderer2 = TtfRenderer::new(2.0);
let height1 = renderer1.text_height(&atlas);
let height2 = renderer2.text_height(&atlas);
assert_eq!(height2, height1 * 2);
}
#[test]
fn render_text_handles_empty_string() {
let (mut canvas, texture_creator, _sdl) = common::setup_sdl().unwrap();
let _ttf_context = sdl2::ttf::init().unwrap();
let font = _ttf_context.load_font("assets/game/TerminalVector.ttf", 16).unwrap();
let mut atlas = TtfAtlas::new(&texture_creator, &font).unwrap();
atlas.populate_atlas(&mut canvas, &texture_creator, &font).unwrap();
let renderer = TtfRenderer::new(1.0);
let result = renderer.render_text(&mut canvas, &mut atlas, "", glam::Vec2::new(0.0, 0.0), Color::WHITE);
assert!(result.is_ok());
}
#[test]
fn render_text_handles_single_character() {
let (mut canvas, texture_creator, _sdl) = common::setup_sdl().unwrap();
let _ttf_context = sdl2::ttf::init().unwrap();
let font = _ttf_context.load_font("assets/game/TerminalVector.ttf", 16).unwrap();
let mut atlas = TtfAtlas::new(&texture_creator, &font).unwrap();
atlas.populate_atlas(&mut canvas, &texture_creator, &font).unwrap();
let renderer = TtfRenderer::new(1.0);
let result = renderer.render_text(&mut canvas, &mut atlas, "A", glam::Vec2::new(10.0, 10.0), Color::RED);
assert!(result.is_ok());
}