mirror of
https://github.com/Xevion/Pac-Man.git
synced 2025-12-06 13:15:47 -06:00
Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46a73c5ace | ||
|
|
a2783ae62d | ||
|
|
83e0d1d737 | ||
|
|
d86864b6a3 | ||
|
|
d7a6ee7684 | ||
|
|
d84f0c831e | ||
|
|
ae19ca1795 | ||
|
|
abf341d753 | ||
|
|
7b6dad0c74 | ||
|
|
5563b64044 | ||
|
|
cb691b0907 | ||
|
|
ce8ea347e1 | ||
|
|
afae3c5e7b | ||
|
|
4f7902fc50 | ||
|
|
2a2cca675a | ||
|
|
f3a6b72931 | ||
|
|
ca006b5073 | ||
|
|
139afb2d40 | ||
|
|
5d56b31353 | ||
|
|
b4990af109 | ||
|
|
088c496ad9 | ||
|
|
5bdf11dfb6 | ||
|
|
c163171304 | ||
|
|
63e1059df8 | ||
|
|
11af44c469 | ||
|
|
7675608391 | ||
|
|
7d5b8e11dd | ||
|
|
5aba1862c9 | ||
|
|
e46d39a938 | ||
|
|
49a6a5cc39 | ||
|
|
ca50d0f3d8 | ||
|
|
774dc010bf | ||
|
|
e87d458121 | ||
|
|
44f0b5d373 | ||
|
|
c828034d18 | ||
|
|
823f480916 | ||
|
|
53306de155 | ||
|
|
6ddc6d1181 | ||
|
|
fff44faa05 | ||
|
|
ca17984d98 | ||
|
|
c8f389b163 | ||
|
|
9c274de901 | ||
|
|
9633611ae8 | ||
|
|
897b9b8621 | ||
|
|
ee2569b70c | ||
|
|
84caa6c25f | ||
|
|
f92c9175b9 | ||
|
|
d561b446c5 | ||
|
|
9219c771d7 | ||
|
|
cd501aafc4 | ||
|
|
feae1ee191 | ||
|
|
2f0b9825c6 | ||
|
|
cac490565e | ||
|
|
b60888219b | ||
|
|
3c50bfeab6 | ||
|
|
132067c573 | ||
|
|
42e309a46b | ||
|
|
a38423f006 | ||
|
|
07bd127596 | ||
|
|
da42d017e7 | ||
|
|
8b623ffabe | ||
|
|
af81390e30 | ||
|
|
2fabd5d7a2 | ||
|
|
bcd9865430 | ||
|
|
ed16da1e8f |
@@ -7,6 +7,8 @@ rustflags = [
|
|||||||
]
|
]
|
||||||
runner = "node"
|
runner = "node"
|
||||||
|
|
||||||
|
# despite being semantically identical to `target_os = "linux"`, the `cfg(linux)` syntax is not supported here. Who knows why...
|
||||||
|
# https://github.com/Xevion/Pac-Man/actions/runs/17596477856
|
||||||
[target.'cfg(target_os = "linux")']
|
[target.'cfg(target_os = "linux")']
|
||||||
rustflags = [
|
rustflags = [
|
||||||
# Manually link zlib.
|
# Manually link zlib.
|
||||||
|
|||||||
@@ -3,3 +3,10 @@ fail-fast = false
|
|||||||
|
|
||||||
[profile.coverage]
|
[profile.coverage]
|
||||||
status-level = "none"
|
status-level = "none"
|
||||||
|
|
||||||
|
[[profile.default.overrides]]
|
||||||
|
filter = 'test(pacman::game::)'
|
||||||
|
test-group = 'serial'
|
||||||
|
|
||||||
|
[test-groups]
|
||||||
|
serial = { max-threads = 1 }
|
||||||
|
|||||||
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -1 +1,2 @@
|
|||||||
* text=auto eol=lf
|
* text=auto eol=lf
|
||||||
|
scripts/* linguist-detectable=false
|
||||||
|
|||||||
53
.github/workflows/checks.yaml
vendored
Normal file
53
.github/workflows/checks.yaml
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
name: Checks
|
||||||
|
|
||||||
|
on: ["push", "pull_request"]
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUST_TOOLCHAIN: 1.86.0
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
checks:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@master
|
||||||
|
with:
|
||||||
|
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||||
|
components: clippy, rustfmt
|
||||||
|
|
||||||
|
- name: Rust Cache
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
- name: Cache vcpkg
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: target/vcpkg
|
||||||
|
key: A-vcpkg-${{ runner.os }}-${{ hashFiles('Cargo.toml', 'Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
A-vcpkg-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Vcpkg Linux Dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libltdl-dev
|
||||||
|
|
||||||
|
- name: Vcpkg
|
||||||
|
run: |
|
||||||
|
cargo install cargo-vcpkg
|
||||||
|
cargo vcpkg -v build
|
||||||
|
|
||||||
|
- name: Run clippy
|
||||||
|
run: cargo clippy -- -D warnings
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: cargo fmt -- --check
|
||||||
|
|
||||||
|
- uses: taiki-e/install-action@cargo-audit
|
||||||
|
|
||||||
|
- name: Run security audit
|
||||||
|
run: cargo audit
|
||||||
40
.github/workflows/coverage.yaml
vendored
40
.github/workflows/coverage.yaml
vendored
@@ -4,13 +4,11 @@ on: ["push", "pull_request"]
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
RUST_TOOLCHAIN: 1.86.0
|
RUST_TOOLCHAIN: nightly
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
coverage:
|
coverage:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
@@ -50,33 +48,9 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
just coverage
|
just coverage
|
||||||
|
|
||||||
- name: Download Coveralls CLI
|
- name: Coveralls upload
|
||||||
if: ${{ env.COVERALLS_REPO_TOKEN != '' }}
|
uses: coverallsapp/github-action@v2
|
||||||
run: |
|
with:
|
||||||
# use GitHub Releases URL instead of coveralls.io because they can't maintain their own files; it 404s
|
github-token: ${{ secrets.COVERALLS_REPO_TOKEN }}
|
||||||
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
|
path-to-lcov: lcov.info
|
||||||
|
debug: true
|
||||||
- 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
|
|
||||||
|
|||||||
14
.github/workflows/tests.yaml
vendored
14
.github/workflows/tests.yaml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: Tests & Checks
|
name: Tests
|
||||||
|
|
||||||
on: ["push", "pull_request"]
|
on: ["push", "pull_request"]
|
||||||
|
|
||||||
@@ -18,7 +18,6 @@ jobs:
|
|||||||
uses: dtolnay/rust-toolchain@master
|
uses: dtolnay/rust-toolchain@master
|
||||||
with:
|
with:
|
||||||
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||||
components: clippy, rustfmt
|
|
||||||
|
|
||||||
- name: Rust Cache
|
- name: Rust Cache
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: Swatinem/rust-cache@v2
|
||||||
@@ -45,14 +44,3 @@ jobs:
|
|||||||
|
|
||||||
- name: Run nextest
|
- name: Run nextest
|
||||||
run: cargo nextest run --workspace
|
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
5
.gitignore
vendored
@@ -14,8 +14,13 @@ assets/site/build.css
|
|||||||
|
|
||||||
# Coverage reports
|
# Coverage reports
|
||||||
lcov.info
|
lcov.info
|
||||||
|
codecov.json
|
||||||
coverage.html
|
coverage.html
|
||||||
|
|
||||||
# Profiling output
|
# Profiling output
|
||||||
flamegraph.svg
|
flamegraph.svg
|
||||||
/profile.*
|
/profile.*
|
||||||
|
|
||||||
|
# temporary
|
||||||
|
assets/game/sound/*.wav
|
||||||
|
/*.py
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ repos:
|
|||||||
- id: forbid-submodules
|
- id: forbid-submodules
|
||||||
- id: mixed-line-ending
|
- 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
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
- id: cargo-fmt
|
- id: cargo-fmt
|
||||||
@@ -20,12 +27,14 @@ repos:
|
|||||||
language: system
|
language: system
|
||||||
types: [rust]
|
types: [rust]
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|
||||||
- id: cargo-check
|
- id: cargo-check
|
||||||
name: cargo check
|
name: cargo check
|
||||||
entry: cargo check --all-targets
|
entry: cargo check --all-targets
|
||||||
language: system
|
language: system
|
||||||
types_or: [rust, cargo, cargo-lock]
|
types_or: [rust, cargo, cargo-lock]
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|
||||||
- id: cargo-check-wasm
|
- id: cargo-check-wasm
|
||||||
name: cargo check for wasm32-unknown-emscripten
|
name: cargo check for wasm32-unknown-emscripten
|
||||||
entry: cargo check --all-targets --target=wasm32-unknown-emscripten
|
entry: cargo check --all-targets --target=wasm32-unknown-emscripten
|
||||||
|
|||||||
263
Cargo.lock
generated
263
Cargo.lock
generated
@@ -301,6 +301,15 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deranged"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc"
|
||||||
|
dependencies = [
|
||||||
|
"powerfmt",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@@ -561,6 +570,76 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"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]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -584,7 +663,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pacman"
|
name = "pacman"
|
||||||
version = "0.2.0"
|
version = "0.79.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bevy_ecs",
|
"bevy_ecs",
|
||||||
@@ -603,16 +682,18 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
|
"speculoos",
|
||||||
"spin_sleep",
|
"spin_sleep",
|
||||||
"strum",
|
"strum",
|
||||||
"strum_macros",
|
"strum_macros",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"thousands",
|
"thousands",
|
||||||
|
"time",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-error",
|
"tracing-error",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"windows",
|
"windows",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -641,7 +722,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"redox_syscall",
|
"redox_syscall",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"windows-targets 0.52.6",
|
"windows-targets",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -722,6 +803,12 @@ dependencies = [
|
|||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "powerfmt"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -943,6 +1030,16 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "spin"
|
name = "spin"
|
||||||
version = "0.9.8"
|
version = "0.9.8"
|
||||||
@@ -954,11 +1051,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spin_sleep"
|
name = "spin_sleep"
|
||||||
version = "1.3.2"
|
version = "1.3.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "14ac0e4b54d028c2000a13895bcd84cd02a1d63c4f78e08e4ec5ec8f53efd4b9"
|
checksum = "9c07347b7c0301b9adba4350bdcf09c039d0e7160922050db0439b3c6723c8ab"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1032,6 +1129,36 @@ dependencies = [
|
|||||||
"once_cell",
|
"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]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "0.6.11"
|
version = "0.6.11"
|
||||||
@@ -1271,9 +1398,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows"
|
name = "windows"
|
||||||
version = "0.61.3"
|
version = "0.62.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
checksum = "9579d0e6970fd5250aa29aba5994052385ff55cf7b28a059e484bb79ea842e42"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-collections",
|
"windows-collections",
|
||||||
"windows-core",
|
"windows-core",
|
||||||
@@ -1284,18 +1411,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-collections"
|
name = "windows-collections"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
|
checksum = "a90dd7a7b86859ec4cdf864658b311545ef19dbcf17a672b52ab7cefe80c336f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-core",
|
"windows-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.61.2"
|
version = "0.62.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-implement",
|
"windows-implement",
|
||||||
"windows-interface",
|
"windows-interface",
|
||||||
@@ -1306,9 +1433,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-future"
|
name = "windows-future"
|
||||||
version = "0.2.1"
|
version = "0.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
|
checksum = "b2194dee901458cb79e1148a4e9aac2b164cc95fa431891e7b296ff0b2f1d8a6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-core",
|
"windows-core",
|
||||||
"windows-link",
|
"windows-link",
|
||||||
@@ -1339,15 +1466,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.1.3"
|
version = "0.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
|
checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-numerics"
|
name = "windows-numerics"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
|
checksum = "2ce3498fe0aba81e62e477408383196b4b0363db5e0c27646f932676283b43d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-core",
|
"windows-core",
|
||||||
"windows-link",
|
"windows-link",
|
||||||
@@ -1355,18 +1482,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.3.4"
|
version = "0.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
|
checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-strings"
|
name = "windows-strings"
|
||||||
version = "0.4.2"
|
version = "0.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
|
checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
@@ -1377,16 +1504,16 @@ version = "0.52.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-targets 0.52.6",
|
"windows-targets",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.60.2"
|
version = "0.61.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-targets 0.53.2",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1395,37 +1522,21 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows_aarch64_gnullvm 0.52.6",
|
"windows_aarch64_gnullvm",
|
||||||
"windows_aarch64_msvc 0.52.6",
|
"windows_aarch64_msvc",
|
||||||
"windows_i686_gnu 0.52.6",
|
"windows_i686_gnu",
|
||||||
"windows_i686_gnullvm 0.52.6",
|
"windows_i686_gnullvm",
|
||||||
"windows_i686_msvc 0.52.6",
|
"windows_i686_msvc",
|
||||||
"windows_x86_64_gnu 0.52.6",
|
"windows_x86_64_gnu",
|
||||||
"windows_x86_64_gnullvm 0.52.6",
|
"windows_x86_64_gnullvm",
|
||||||
"windows_x86_64_msvc 0.52.6",
|
"windows_x86_64_msvc",
|
||||||
]
|
|
||||||
|
|
||||||
[[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",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-threading"
|
name = "windows-threading"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
|
checksum = "ab47f085ad6932defa48855254c758cdd0e2f2d48e62a34118a268d8f345e118"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
@@ -1436,96 +1547,48 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_aarch64_gnullvm"
|
|
||||||
version = "0.53.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_aarch64_msvc"
|
|
||||||
version = "0.53.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_i686_gnu"
|
|
||||||
version = "0.53.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnullvm"
|
name = "windows_i686_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_i686_gnullvm"
|
|
||||||
version = "0.53.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_i686_msvc"
|
|
||||||
version = "0.53.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
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]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "0.7.12"
|
version = "0.7.12"
|
||||||
|
|||||||
17
Cargo.toml
17
Cargo.toml
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pacman"
|
name = "pacman"
|
||||||
version = "0.2.0"
|
version = "0.79.2"
|
||||||
authors = ["Xevion"]
|
authors = ["Xevion"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.86.0"
|
rust-version = "1.86.0"
|
||||||
@@ -21,9 +21,10 @@ default-run = "pacman"
|
|||||||
bevy_ecs = "0.16.1"
|
bevy_ecs = "0.16.1"
|
||||||
glam = "0.30.5"
|
glam = "0.30.5"
|
||||||
pathfinding = "4.14"
|
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-error = "0.2.0"
|
||||||
tracing-subscriber = {version = "0.3.20", features = ["env-filter"]}
|
tracing-subscriber = {version = "0.3.20", features = ["env-filter"]}
|
||||||
|
time = { version = "0.3.43", features = ["formatting", "macros"] }
|
||||||
thiserror = "2.0.16"
|
thiserror = "2.0.16"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
smallvec = "1.15.1"
|
smallvec = "1.15.1"
|
||||||
@@ -39,17 +40,17 @@ num-width = "0.1.0"
|
|||||||
phf = { version = "0.13.1", features = ["macros"] }
|
phf = { version = "0.13.1", features = ["macros"] }
|
||||||
|
|
||||||
# Windows-specific dependencies
|
# Windows-specific dependencies
|
||||||
[target.'cfg(target_os = "windows")'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
# Used for customizing console output on Windows; both are required due to the `windows` crate having poor Result handling with `GetStdHandle`.
|
# 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 = { version = "0.62.0", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Console"] }
|
||||||
windows-sys = { version = "0.60.2", features = ["Win32_System_Console"] }
|
windows-sys = { version = "0.61.0", features = ["Win32_System_Console"] }
|
||||||
|
|
||||||
# Desktop-specific dependencies
|
# Desktop-specific dependencies
|
||||||
[target.'cfg(not(target_os = "emscripten"))'.dependencies]
|
[target.'cfg(not(target_os = "emscripten"))'.dependencies]
|
||||||
# On desktop platforms, build SDL2 with cargo-vcpkg
|
# 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"] }
|
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"] }
|
rand = { version = "0.9.2", default-features = false, features = ["thread_rng"] }
|
||||||
spin_sleep = "1.3.2"
|
spin_sleep = "1.3.3"
|
||||||
|
|
||||||
# Browser-specific dependencies
|
# Browser-specific dependencies
|
||||||
[target.'cfg(target_os = "emscripten")'.dependencies]
|
[target.'cfg(target_os = "emscripten")'.dependencies]
|
||||||
@@ -61,6 +62,7 @@ libc = "0.2.175" # TODO: Describe why this is required.
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
pretty_assertions = "1.4.1"
|
pretty_assertions = "1.4.1"
|
||||||
|
speculoos = "0.13.0"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
phf = { version = "0.13.1", features = ["macros"] }
|
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-unknown-linux-gnu = { triplet = "x64-linux" }
|
||||||
x86_64-apple-darwin = { triplet = "x64-osx" }
|
x86_64-apple-darwin = { triplet = "x64-osx" }
|
||||||
aarch64-apple-darwin = { triplet = "arm64-osx" }
|
aarch64-apple-darwin = { triplet = "arm64-osx" }
|
||||||
|
|
||||||
|
[lints.rust]
|
||||||
|
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage,coverage_nightly)'] }
|
||||||
|
|||||||
14
Justfile
14
Justfile
@@ -1,9 +1,6 @@
|
|||||||
set shell := ["bash", "-c"]
|
set shell := ["bash", "-c"]
|
||||||
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
|
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 { "" }
|
binary_extension := if os() == "windows" { ".exe" } else { "" }
|
||||||
|
|
||||||
@@ -14,22 +11,19 @@ binary_extension := if os() == "windows" { ".exe" } else { "" }
|
|||||||
html: coverage
|
html: coverage
|
||||||
cargo llvm-cov report \
|
cargo llvm-cov report \
|
||||||
--remap-path-prefix \
|
--remap-path-prefix \
|
||||||
--ignore-filename-regex "{{ coverage_exclude_pattern }}" \
|
|
||||||
--html \
|
--html \
|
||||||
--open
|
--open
|
||||||
|
|
||||||
# Display report (for humans)
|
# Display report (for humans)
|
||||||
report-coverage: coverage
|
report-coverage: coverage
|
||||||
cargo llvm-cov report \
|
cargo llvm-cov report --remap-path-prefix
|
||||||
--remap-path-prefix \
|
|
||||||
--ignore-filename-regex "{{ coverage_exclude_pattern }}"
|
|
||||||
|
|
||||||
# Run & generate report (for CI)
|
# Run & generate LCOV report (as base report)
|
||||||
coverage:
|
coverage:
|
||||||
cargo llvm-cov \
|
cargo +nightly llvm-cov \
|
||||||
--lcov \
|
--lcov \
|
||||||
--remap-path-prefix \
|
--remap-path-prefix \
|
||||||
--ignore-filename-regex "{{ coverage_exclude_pattern }}" \
|
--workspace \
|
||||||
--output-path lcov.info \
|
--output-path lcov.info \
|
||||||
--profile coverage \
|
--profile coverage \
|
||||||
--no-fail-fast nextest
|
--no-fail-fast nextest
|
||||||
|
|||||||
113
README.md
113
README.md
@@ -1,80 +1,94 @@
|
|||||||
|
<!-- markdownlint-disable MD033 -->
|
||||||
|
<!-- markdownlint-disable MD041 -->
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="assets/repo/banner.png" alt="Pac-Man Banner Screenshot">
|
||||||
|
</div>
|
||||||
|
|
||||||
# Pac-Man
|
# 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-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-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-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/Online%20Demo-Click%20Me!-brightgreen
|
||||||
[badge-online-demo]: https://img.shields.io/badge/GitHub%20Pages-Demo-brightgreen
|
[justforfunnoreally]: https://justforfunnoreally.dev
|
||||||
[badge-last-commit]: https://img.shields.io/github/last-commit/Xevion/Pac-Man
|
|
||||||
[build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml
|
[build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml
|
||||||
[test]: https://github.com/Xevion/Pac-Man/actions/workflows/tests.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
|
[coverage]: https://coveralls.io/github/Xevion/Pac-Man?branch=master
|
||||||
[demo]: https://xevion.github.io/Pac-Man/
|
[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:
|
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)
|
- [ ] 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
|
- [ ] Fruit bonuses that appear periodically
|
||||||
- [ ] Progressive difficulty with faster ghosts and shorter power pellet duration
|
- [ ] Progressive difficulty with faster ghosts and shorter power pellet duration
|
||||||
- [x] Authentic sound effects and sprites
|
- [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?
|
## 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. 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.
|
||||||
- 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.
|
|
||||||
- Performant, low memory, CPU and GPU usage.
|
- Performant, low memory, CPU and GPU usage.
|
||||||
- Online demo, playable in a browser.
|
- Online demo, playable in a browser, built automatically with GitHub Actions.
|
||||||
- Completely automatic build system with releases for all platforms.
|
|
||||||
- Well documented, well-tested, and maintainable.
|
|
||||||
|
|
||||||
## 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
|
## Roadmap
|
||||||
- Game state visualization
|
|
||||||
- Game speed controls + pausing
|
You can read the [roadmap](ROADMAP.md) file for more details on the project's goals and future plans.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Build Notes
|
## 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.
|
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.
|
- 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.
|
- 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`
|
- 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.
|
- 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.
|
- 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 +101,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))
|
- `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.
|
- `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.
|
- If you want to have TypeScript resolution for development, you can manually install the dependencies with `bun install` in the `assets/site` folder.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please feel free to submit a pull request or open an issue.
|
||||||
|
|
||||||
|
- The code is not exactly stable or bulletproof, but it is functional and has a lot of tests.
|
||||||
|
- I am not actively looking for contributors, but I will review pull requests and merge them if they are useful.
|
||||||
|
- If you have any ideas, please feel free to submit an issue.
|
||||||
|
- If you have any private issues, security concerns, or anything sensitive, you can email me at [xevion@xevion.dev](mailto:xevion@xevion.dev).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the GPLv3 license. See the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
[build-workflow]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml
|
||||||
|
|||||||
161
ROADMAP.md
Normal file
161
ROADMAP.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# Roadmap
|
||||||
|
|
||||||
|
A comprehensive list of features needed to complete the Pac-Man emulation, organized by priority and implementation complexity.
|
||||||
|
|
||||||
|
## Core Game Features
|
||||||
|
|
||||||
|
### Ghost AI & Behavior
|
||||||
|
|
||||||
|
- [x] Core Ghost System Architecture
|
||||||
|
- [x] Ghost entity types (Blinky, Pinky, Inky, Clyde)
|
||||||
|
- [x] Ghost state management (Normal, Frightened, Eyes)
|
||||||
|
- [x] Ghost movement and pathfinding systems
|
||||||
|
- [ ] Authentic Ghost AI Personalities
|
||||||
|
- [ ] Blinky (Red): Direct chase behavior
|
||||||
|
- [ ] Pinky (Pink): Target 4 tiles ahead of Pac-Man
|
||||||
|
- [ ] Inky (Cyan): Complex behavior based on Blinky's position
|
||||||
|
- [ ] Clyde (Orange): Chase when far, flee when close
|
||||||
|
- [x] Mode Switching System
|
||||||
|
- [ ] Scatter/Chase pattern with proper timing
|
||||||
|
- [x] Frightened mode transitions
|
||||||
|
- [ ] Ghost house entry/exit mechanics
|
||||||
|
- [x] Ghost House Behavior
|
||||||
|
- [x] Proper spawning sequence
|
||||||
|
- [ ] Exit timing and patterns
|
||||||
|
- [ ] House-specific movement rules
|
||||||
|
|
||||||
|
### Fruit Bonus System
|
||||||
|
|
||||||
|
- [x] Fruit Spawning Mechanics
|
||||||
|
- [x] Spawn at pellet counts 70 and 170
|
||||||
|
- [x] Fruit display in bottom-right corner
|
||||||
|
- [x] Fruit collection and scoring
|
||||||
|
- [x] Bonus point display system
|
||||||
|
|
||||||
|
### Level Progression
|
||||||
|
|
||||||
|
- [ ] Multiple Levels
|
||||||
|
- [ ] Level completion detection
|
||||||
|
- [ ] Progressive difficulty scaling
|
||||||
|
- [ ] Ghost speed increases per level
|
||||||
|
- [ ] Power pellet duration decreases
|
||||||
|
- [ ] Intermission Screens
|
||||||
|
- [ ] Between-level cutscenes
|
||||||
|
- [ ] Proper graphics and timing
|
||||||
|
|
||||||
|
### Audio System Completion
|
||||||
|
|
||||||
|
- [x] Core Audio Infrastructure
|
||||||
|
- [x] Audio event system
|
||||||
|
- [x] Sound effect playback
|
||||||
|
- [x] Audio muting controls
|
||||||
|
- [ ] Background Music
|
||||||
|
- [ ] Continuous gameplay music
|
||||||
|
- [ ] Escalating siren based on remaining pellets
|
||||||
|
- [ ] Power pellet mode music
|
||||||
|
- [ ] Intermission music
|
||||||
|
- [x] Sound Effects
|
||||||
|
- [x] Pellet eating sounds
|
||||||
|
- [x] Fruit collection sounds
|
||||||
|
- [ ] Ghost movement sounds
|
||||||
|
- [ ] Level completion fanfare
|
||||||
|
|
||||||
|
### Game Mechanics
|
||||||
|
|
||||||
|
- [ ] Bonus Lives
|
||||||
|
- [ ] Extra life at 10,000 points
|
||||||
|
- [x] Life counter display
|
||||||
|
- [ ] High Score System
|
||||||
|
- [ ] High score tracking
|
||||||
|
- [x] High score display
|
||||||
|
- [ ] Score persistence
|
||||||
|
|
||||||
|
## Secondary Features (Medium Priority)
|
||||||
|
|
||||||
|
### Game Polish
|
||||||
|
|
||||||
|
- [x] Core Input System
|
||||||
|
- [x] Keyboard controls
|
||||||
|
- [x] Direction buffering for responsive controls
|
||||||
|
- [x] Touch controls for mobile
|
||||||
|
- [ ] Pause System
|
||||||
|
- [ ] Pause/unpause functionality
|
||||||
|
- [ ] Pause menu with options
|
||||||
|
- [ ] Input System
|
||||||
|
- [ ] Input remapping
|
||||||
|
- [ ] Multiple input methods
|
||||||
|
|
||||||
|
## Advanced Features (Lower Priority)
|
||||||
|
|
||||||
|
### Difficulty Options
|
||||||
|
|
||||||
|
- [ ] Easy/Normal/Hard modes
|
||||||
|
- [ ] Customizable ghost speeds
|
||||||
|
|
||||||
|
### Data Persistence
|
||||||
|
|
||||||
|
- [ ] High Score Persistence
|
||||||
|
- [ ] Save high scores to file
|
||||||
|
- [ ] High score table display
|
||||||
|
- [ ] Settings Storage
|
||||||
|
- [ ] Save user preferences
|
||||||
|
- [ ] Audio/visual settings
|
||||||
|
- [ ] Statistics Tracking
|
||||||
|
- [ ] Game statistics
|
||||||
|
- [ ] Achievement system
|
||||||
|
|
||||||
|
### Debug & Development Tools
|
||||||
|
|
||||||
|
- [x] Performance details
|
||||||
|
- [x] Core Debug Infrastructure
|
||||||
|
- [x] Debug mode toggle
|
||||||
|
- [x] Comprehensive game event logging
|
||||||
|
- [x] Performance profiling tools
|
||||||
|
- [ ] Game State Visualization
|
||||||
|
- [ ] Ghost AI state display
|
||||||
|
- [ ] Pathfinding visualization
|
||||||
|
- [ ] Collision detection display
|
||||||
|
- [ ] Game Speed Controls
|
||||||
|
- [ ] Variable game speed for testing
|
||||||
|
- [ ] Frame-by-frame stepping
|
||||||
|
|
||||||
|
## Customization & Extensions
|
||||||
|
|
||||||
|
### Visual Customization
|
||||||
|
|
||||||
|
- [x] Core Rendering System
|
||||||
|
- [x] Sprite-based rendering
|
||||||
|
- [x] Layered rendering system
|
||||||
|
- [x] Animation system
|
||||||
|
- [x] HUD rendering
|
||||||
|
- [ ] Display Options
|
||||||
|
- [ ] Fullscreen support
|
||||||
|
- [x] Window resizing
|
||||||
|
- [ ] Pause while resizing (SDL2 limitation mitigation)
|
||||||
|
- [ ] Multiple resolution support
|
||||||
|
|
||||||
|
### Gameplay Extensions
|
||||||
|
|
||||||
|
- [ ] Advanced Ghost AI
|
||||||
|
- [ ] Support for >4 ghosts
|
||||||
|
- [ ] Custom ghost behaviors
|
||||||
|
- [ ] Level Generation
|
||||||
|
- [ ] Custom level creation
|
||||||
|
- [ ] Multi-map tunneling
|
||||||
|
- [ ] Level editor
|
||||||
|
|
||||||
|
## Online Features (Future)
|
||||||
|
|
||||||
|
### Scoreboard System
|
||||||
|
|
||||||
|
- [ ] Backend Infrastructure
|
||||||
|
- [ ] Axum server with database
|
||||||
|
- [ ] OAuth2 authentication
|
||||||
|
- [ ] GitHub/Discord/Google auth
|
||||||
|
- [ ] Profile Features
|
||||||
|
- [ ] Optional avatars (8-bit aesthetic)
|
||||||
|
- [ ] Custom names (3-14 chars, filtered)
|
||||||
|
- [ ] Client Implementation
|
||||||
|
- [ ] Zero-config client
|
||||||
|
- [ ] Default API endpoint
|
||||||
|
- [ ] Manual override available
|
||||||
8
STORY.md
8
STORY.md
@@ -31,7 +31,7 @@ WebAssembly.
|
|||||||
The problem is that much of this work was done for pure-Rust applications - and SDL is C++.
|
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.
|
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
|
- Built with Rust
|
||||||
- Uses SDL2
|
- 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.
|
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: 0, tv_nsec: 0 }
|
||||||
Instant { tv_sec: 1, tv_nsec: 0 }
|
Instant { tv_sec: 1, tv_nsec: 0 }
|
||||||
Instant { tv_sec: 2, 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.
|
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.
|
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.
|
||||||
|
|
||||||
@@ -412,8 +412,8 @@ The bigger downside was that I had to toss out almost all the existing code for
|
|||||||
|
|
||||||
This ended up being okay though, as I was able to clean up a lot of gross code, and the system ended up being easier to work with by comparison.
|
This ended up being okay though, as I was able to clean up a lot of gross code, and the system ended up being easier to work with by comparison.
|
||||||
|
|
||||||
[code-review-video]: https://www.youtube.com/watch?v=OKs_JewEeOo
|
|
||||||
[code-review-thumbnail]: https://img.youtube.com/vi/OKs_JewEeOo/hqdefault.jpg
|
[code-review-thumbnail]: https://img.youtube.com/vi/OKs_JewEeOo/hqdefault.jpg
|
||||||
|
[code-review-video]: https://www.youtube.com/watch?v=OKs_JewEeOo
|
||||||
[fighting-lifetimes-1]: https://devcry.heiho.net/html/2022/20220709-rust-and-sdl2-fighting-with-lifetimes.html
|
[fighting-lifetimes-1]: https://devcry.heiho.net/html/2022/20220709-rust-and-sdl2-fighting-with-lifetimes.html
|
||||||
[fighting-lifetimes-2]: https://devcry.heiho.net/html/2022/20220716-rust-and-sdl2-fighting-with-lifetimes-2.html
|
[fighting-lifetimes-2]: https://devcry.heiho.net/html/2022/20220716-rust-and-sdl2-fighting-with-lifetimes-2.html
|
||||||
[fighting-lifetimes-3]: https://devcry.heiho.net/html/2022/20220724-rust-and-sdl2-fighting-with-lifetimes-3.html
|
[fighting-lifetimes-3]: https://devcry.heiho.net/html/2022/20220724-rust-and-sdl2-fighting-with-lifetimes-3.html
|
||||||
|
|||||||
BIN
assets/game/sound/pacman/death.ogg
Normal file
BIN
assets/game/sound/pacman/death.ogg
Normal file
Binary file not shown.
BIN
assets/game/sound/pacman/death.wav
Normal file
BIN
assets/game/sound/pacman/death.wav
Normal file
Binary file not shown.
BIN
assets/repo/banner.png
Normal file
BIN
assets/repo/banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.3 KiB |
BIN
assets/repo/screenshots/0.png
Normal file
BIN
assets/repo/screenshots/0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
assets/repo/screenshots/1.png
Normal file
BIN
assets/repo/screenshots/1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
assets/repo/screenshots/2.png
Normal file
BIN
assets/repo/screenshots/2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
BIN
assets/repo/screenshots/3.png
Normal file
BIN
assets/repo/screenshots/3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
25
bacon.toml
25
bacon.toml
@@ -28,16 +28,18 @@ need_stdout = false
|
|||||||
|
|
||||||
[jobs.test]
|
[jobs.test]
|
||||||
command = [
|
command = [
|
||||||
"cargo", "nextest", "run",
|
"cargo",
|
||||||
"--hide-progress-bar", "--failure-output", "final"
|
"nextest",
|
||||||
|
"run",
|
||||||
|
"--hide-progress-bar",
|
||||||
|
"--failure-output",
|
||||||
|
"final",
|
||||||
]
|
]
|
||||||
need_stdout = true
|
need_stdout = true
|
||||||
analyzer = "nextest"
|
analyzer = "nextest"
|
||||||
|
|
||||||
[jobs.coverage]
|
[jobs.coverage]
|
||||||
command = [
|
command = ["just", "report-coverage"]
|
||||||
"just", "report-coverage"
|
|
||||||
]
|
|
||||||
need_stdout = true
|
need_stdout = true
|
||||||
ignored_lines = [
|
ignored_lines = [
|
||||||
"info:",
|
"info:",
|
||||||
@@ -54,7 +56,7 @@ ignored_lines = [
|
|||||||
"\\s*Finished.+in \\d+",
|
"\\s*Finished.+in \\d+",
|
||||||
"\\s*Summary\\s+\\[",
|
"\\s*Summary\\s+\\[",
|
||||||
"\\s*Blocking",
|
"\\s*Blocking",
|
||||||
"Finished report saved to"
|
"Finished report saved to",
|
||||||
]
|
]
|
||||||
on_change_strategy = "wait_then_restart"
|
on_change_strategy = "wait_then_restart"
|
||||||
|
|
||||||
@@ -69,18 +71,23 @@ 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]
|
[jobs.run]
|
||||||
command = [
|
command = ["cargo", "run"]
|
||||||
"cargo", "run",
|
|
||||||
]
|
|
||||||
need_stdout = true
|
need_stdout = true
|
||||||
allow_warnings = true
|
allow_warnings = true
|
||||||
background = false
|
background = false
|
||||||
on_change_strategy = "kill_then_restart"
|
on_change_strategy = "kill_then_restart"
|
||||||
# kill = ["pkill", "-TERM", "-P"]'
|
# kill = ["pkill", "-TERM", "-P"]'
|
||||||
|
|
||||||
|
[jobs.precommit]
|
||||||
|
command = ["pre-commit", "run", "--all-files"]
|
||||||
|
need_stdout = true
|
||||||
|
background = false
|
||||||
|
on_change_strategy = "kill_then_restart"
|
||||||
|
|
||||||
[keybindings]
|
[keybindings]
|
||||||
c = "job:clippy"
|
c = "job:clippy"
|
||||||
alt-c = "job:check"
|
alt-c = "job:check"
|
||||||
ctrl-alt-c = "job:check-all"
|
ctrl-alt-c = "job:check-all"
|
||||||
shift-c = "job:clippy-all"
|
shift-c = "job:clippy-all"
|
||||||
f = "job:coverage"
|
f = "job:coverage"
|
||||||
|
p = "job:precommit"
|
||||||
|
|||||||
44
src/app.rs
44
src/app.rs
@@ -4,18 +4,15 @@ use std::time::{Duration, Instant};
|
|||||||
use crate::error::{GameError, GameResult};
|
use crate::error::{GameError, GameResult};
|
||||||
|
|
||||||
use crate::constants::{CANVAS_SIZE, LOOP_TIME, SCALE};
|
use crate::constants::{CANVAS_SIZE, LOOP_TIME, SCALE};
|
||||||
|
use crate::formatter;
|
||||||
use crate::game::Game;
|
use crate::game::Game;
|
||||||
use crate::platform;
|
use crate::platform;
|
||||||
use sdl2::pixels::PixelFormatEnum;
|
use sdl2::pixels::PixelFormatEnum;
|
||||||
use sdl2::render::RendererInfo;
|
use sdl2::render::RendererInfo;
|
||||||
use sdl2::{AudioSubsystem, Sdl};
|
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.
|
/// 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 struct App {
|
||||||
pub game: Game,
|
pub game: Game,
|
||||||
last_tick: Instant,
|
last_tick: Instant,
|
||||||
@@ -28,21 +25,25 @@ pub struct App {
|
|||||||
impl App {
|
impl App {
|
||||||
/// Initializes SDL subsystems, creates the game window, and sets up the game state.
|
/// 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
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns `GameError::Sdl` if any SDL initialization step fails, or propagates
|
/// Returns `GameError::Sdl` if any SDL initialization step fails, or propagates
|
||||||
/// errors from `Game::new()` during game state setup.
|
/// errors from `Game::new()` during game state setup.
|
||||||
pub fn new() -> GameResult<Self> {
|
pub fn new() -> GameResult<Self> {
|
||||||
|
info!("Initializing SDL2 application");
|
||||||
let sdl_context = sdl2::init().map_err(|e| GameError::Sdl(e.to_string()))?;
|
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 video_subsystem = sdl_context.video().map_err(|e| GameError::Sdl(e.to_string()))?;
|
||||||
let audio_subsystem = sdl_context.audio().map_err(|e| GameError::Sdl(e.to_string()))?;
|
let 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()))?;
|
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
|
let window = video_subsystem
|
||||||
.window(
|
.window(
|
||||||
"Pac-Man",
|
"Pac-Man",
|
||||||
@@ -71,7 +72,7 @@ impl App {
|
|||||||
{
|
{
|
||||||
let mut names = drivers.keys().collect::<Vec<_>>();
|
let mut names = drivers.keys().collect::<Vec<_>>();
|
||||||
names.sort_by_key(|k| get_driver(k));
|
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
|
// Count the number of times each pixel format is supported by each driver
|
||||||
@@ -83,11 +84,12 @@ impl App {
|
|||||||
counts
|
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");
|
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
|
let mut canvas = window
|
||||||
.into_canvas()
|
.into_canvas()
|
||||||
.accelerated()
|
.accelerated()
|
||||||
@@ -95,16 +97,23 @@ impl App {
|
|||||||
.build()
|
.build()
|
||||||
.map_err(|e| GameError::Sdl(e.to_string()))?;
|
.map_err(|e| GameError::Sdl(e.to_string()))?;
|
||||||
|
|
||||||
|
trace!(
|
||||||
|
logical_width = CANVAS_SIZE.x,
|
||||||
|
logical_height = CANVAS_SIZE.y,
|
||||||
|
"Setting canvas logical size"
|
||||||
|
);
|
||||||
canvas
|
canvas
|
||||||
.set_logical_size(CANVAS_SIZE.x, CANVAS_SIZE.y)
|
.set_logical_size(CANVAS_SIZE.x, CANVAS_SIZE.y)
|
||||||
.map_err(|e| GameError::Sdl(e.to_string()))?;
|
.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 texture_creator = canvas.texture_creator();
|
||||||
|
|
||||||
let game = Game::new(canvas, texture_creator, event_pump)?;
|
info!("Starting game initialization");
|
||||||
// game.audio.set_mute(cfg!(debug_assertions));
|
let game = Game::new(canvas, ttf_context, texture_creator, event_pump)?;
|
||||||
|
|
||||||
|
info!("Application initialization completed successfully");
|
||||||
Ok(App {
|
Ok(App {
|
||||||
game,
|
game,
|
||||||
focused: true,
|
focused: true,
|
||||||
@@ -131,6 +140,9 @@ impl App {
|
|||||||
let dt = self.last_tick.elapsed().as_secs_f32();
|
let dt = self.last_tick.elapsed().as_secs_f32();
|
||||||
self.last_tick = start;
|
self.last_tick = start;
|
||||||
|
|
||||||
|
// Increment the global tick counter for tracing
|
||||||
|
formatter::increment_tick();
|
||||||
|
|
||||||
let exit = self.game.tick(dt);
|
let exit = self.game.tick(dt);
|
||||||
|
|
||||||
if exit {
|
if exit {
|
||||||
|
|||||||
26
src/asset.rs
26
src/asset.rs
@@ -1,4 +1,3 @@
|
|||||||
#![allow(dead_code)]
|
|
||||||
//! Cross-platform asset loading abstraction.
|
//! Cross-platform asset loading abstraction.
|
||||||
//! On desktop, assets are embedded using include_bytes!; on Emscripten, assets are loaded from the filesystem.
|
//! On desktop, assets are embedded using include_bytes!; on Emscripten, assets are loaded from the filesystem.
|
||||||
|
|
||||||
@@ -11,14 +10,13 @@ use strum_macros::EnumIter;
|
|||||||
/// binary-embedded data or embedded filesystem (Emscripten).
|
/// binary-embedded data or embedded filesystem (Emscripten).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumIter)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumIter)]
|
||||||
pub enum Asset {
|
pub enum Asset {
|
||||||
Wav1,
|
Waka(u8),
|
||||||
Wav2,
|
|
||||||
Wav3,
|
|
||||||
Wav4,
|
|
||||||
/// Main sprite atlas containing all game graphics (atlas.png)
|
/// Main sprite atlas containing all game graphics (atlas.png)
|
||||||
AtlasImage,
|
AtlasImage,
|
||||||
/// Terminal Vector font for text rendering (TerminalVector.ttf)
|
/// Terminal Vector font for text rendering (TerminalVector.ttf)
|
||||||
Font,
|
Font,
|
||||||
|
/// Sound effect for Pac-Man's death
|
||||||
|
DeathSound,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Asset {
|
impl Asset {
|
||||||
@@ -31,10 +29,11 @@ impl Asset {
|
|||||||
pub fn path(&self) -> &str {
|
pub fn path(&self) -> &str {
|
||||||
use Asset::*;
|
use Asset::*;
|
||||||
match self {
|
match self {
|
||||||
Wav1 => "sound/waka/1.ogg",
|
Waka(0) => "sound/pacman/waka/1.ogg",
|
||||||
Wav2 => "sound/waka/2.ogg",
|
Waka(1) => "sound/pacman/waka/2.ogg",
|
||||||
Wav3 => "sound/waka/3.ogg",
|
Waka(2) => "sound/pacman/waka/3.ogg",
|
||||||
Wav4 => "sound/waka/4.ogg",
|
Waka(3..=u8::MAX) => "sound/pacman/waka/4.ogg",
|
||||||
|
DeathSound => "sound/pacman/death.ogg",
|
||||||
AtlasImage => "atlas.png",
|
AtlasImage => "atlas.png",
|
||||||
Font => "TerminalVector.ttf",
|
Font => "TerminalVector.ttf",
|
||||||
}
|
}
|
||||||
@@ -45,6 +44,7 @@ mod imp {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::error::AssetError;
|
use crate::error::AssetError;
|
||||||
use crate::platform;
|
use crate::platform;
|
||||||
|
use tracing::trace;
|
||||||
|
|
||||||
/// Loads asset bytes using the appropriate platform-specific method.
|
/// Loads asset bytes using the appropriate platform-specific method.
|
||||||
///
|
///
|
||||||
@@ -58,7 +58,13 @@ mod imp {
|
|||||||
/// Returns `AssetError::NotFound` if the asset file cannot be located (Emscripten only),
|
/// Returns `AssetError::NotFound` if the asset file cannot be located (Emscripten only),
|
||||||
/// or `AssetError::Io` for filesystem I/O failures.
|
/// or `AssetError::Io` for filesystem I/O failures.
|
||||||
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
|
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
|
||||||
platform::get_asset_bytes(asset)
|
trace!(asset = ?asset, "Loading game asset");
|
||||||
|
let result = platform::get_asset_bytes(asset);
|
||||||
|
match &result {
|
||||||
|
Ok(bytes) => trace!(asset = ?asset, size_bytes = bytes.len(), "Asset loaded successfully"),
|
||||||
|
Err(e) => trace!(asset = ?asset, error = ?e, "Asset loading failed"),
|
||||||
|
}
|
||||||
|
result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
76
src/audio.rs
76
src/audio.rs
@@ -1,21 +1,21 @@
|
|||||||
//! This module handles the audio playback for the game.
|
//! This module handles the audio playback for the game.
|
||||||
use crate::asset::{get_asset_bytes, Asset};
|
use crate::asset::{get_asset_bytes, Asset};
|
||||||
use sdl2::{
|
use sdl2::{
|
||||||
mixer::{self, Chunk, InitFlag, LoaderRWops, DEFAULT_FORMAT},
|
mixer::{self, Chunk, InitFlag, LoaderRWops, AUDIO_S16LSB, DEFAULT_CHANNELS},
|
||||||
rwops::RWops,
|
rwops::RWops,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SOUND_ASSETS: [Asset; 4] = [Asset::Wav1, Asset::Wav2, Asset::Wav3, Asset::Wav4];
|
const SOUND_ASSETS: [Asset; 4] = [Asset::Waka(0), Asset::Waka(1), Asset::Waka(2), Asset::Waka(3)];
|
||||||
|
|
||||||
/// The audio system for the game.
|
/// The audio system for the game.
|
||||||
///
|
///
|
||||||
/// This struct is responsible for initializing the audio device, loading sounds,
|
/// This struct is responsible for initializing the audio device, loading sounds,
|
||||||
/// and playing them. If audio fails to initialize, it will be disabled and all
|
/// and playing them. If audio fails to initialize, it will be disabled and all
|
||||||
/// functions will silently do nothing.
|
/// functions will silently do nothing.
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct Audio {
|
pub struct Audio {
|
||||||
_mixer_context: Option<mixer::Sdl2MixerContext>,
|
_mixer_context: Option<mixer::Sdl2MixerContext>,
|
||||||
sounds: Vec<Chunk>,
|
sounds: Vec<Chunk>,
|
||||||
|
death_sound: Option<Chunk>,
|
||||||
next_sound_index: usize,
|
next_sound_index: usize,
|
||||||
muted: bool,
|
muted: bool,
|
||||||
disabled: bool,
|
disabled: bool,
|
||||||
@@ -33,24 +33,37 @@ impl Audio {
|
|||||||
/// If audio fails to initialize, the audio system will be disabled and
|
/// If audio fails to initialize, the audio system will be disabled and
|
||||||
/// all functions will silently do nothing.
|
/// all functions will silently do nothing.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let frequency = 44100;
|
let frequency = 44_100;
|
||||||
let format = DEFAULT_FORMAT;
|
let format = AUDIO_S16LSB;
|
||||||
let channels = 4;
|
let chunk_size = {
|
||||||
let chunk_size = 256; // 256 is minimum for emscripten
|
// 256 is the minimum for Emscripten, but in practice 1024 is much more reliable
|
||||||
|
#[cfg(target_os = "emscripten")]
|
||||||
|
{
|
||||||
|
1024
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, 256 is plenty safe.
|
||||||
|
#[cfg(not(target_os = "emscripten"))]
|
||||||
|
{
|
||||||
|
256
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Try to open audio, but don't panic if it fails
|
// Try to open audio, but don't panic if it fails
|
||||||
if let Err(e) = mixer::open_audio(frequency, format, 1, chunk_size) {
|
if let Err(e) = mixer::open_audio(frequency, format, DEFAULT_CHANNELS, chunk_size) {
|
||||||
tracing::warn!("Failed to open audio: {}. Audio will be disabled.", e);
|
tracing::warn!("Failed to open audio: {}. Audio will be disabled.", e);
|
||||||
return Self {
|
return Self {
|
||||||
_mixer_context: None,
|
_mixer_context: None,
|
||||||
sounds: Vec::new(),
|
sounds: Vec::new(),
|
||||||
|
death_sound: None,
|
||||||
next_sound_index: 0,
|
next_sound_index: 0,
|
||||||
muted: false,
|
muted: false,
|
||||||
disabled: true,
|
disabled: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
mixer::allocate_channels(channels);
|
let channels = 4;
|
||||||
|
mixer::allocate_channels(4);
|
||||||
|
|
||||||
// set channel volume
|
// set channel volume
|
||||||
for i in 0..channels {
|
for i in 0..channels {
|
||||||
@@ -65,6 +78,7 @@ impl Audio {
|
|||||||
return Self {
|
return Self {
|
||||||
_mixer_context: None,
|
_mixer_context: None,
|
||||||
sounds: Vec::new(),
|
sounds: Vec::new(),
|
||||||
|
death_sound: None,
|
||||||
next_sound_index: 0,
|
next_sound_index: 0,
|
||||||
muted: false,
|
muted: false,
|
||||||
disabled: true,
|
disabled: true,
|
||||||
@@ -93,12 +107,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 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.");
|
tracing::warn!("No sounds loaded successfully. Audio will be disabled.");
|
||||||
return Self {
|
return Self {
|
||||||
_mixer_context: Some(mixer_context),
|
_mixer_context: Some(mixer_context),
|
||||||
sounds: Vec::new(),
|
sounds: Vec::new(),
|
||||||
|
death_sound: None,
|
||||||
next_sound_index: 0,
|
next_sound_index: 0,
|
||||||
muted: false,
|
muted: false,
|
||||||
disabled: true,
|
disabled: true,
|
||||||
@@ -108,6 +143,7 @@ impl Audio {
|
|||||||
Audio {
|
Audio {
|
||||||
_mixer_context: Some(mixer_context),
|
_mixer_context: Some(mixer_context),
|
||||||
sounds,
|
sounds,
|
||||||
|
death_sound,
|
||||||
next_sound_index: 0,
|
next_sound_index: 0,
|
||||||
muted: false,
|
muted: false,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
@@ -119,7 +155,6 @@ impl Audio {
|
|||||||
/// Automatically rotates through the four eating sound assets. The sound plays on channel 0 and the internal sound index
|
/// Automatically rotates through the four eating sound assets. The sound plays on channel 0 and the internal sound index
|
||||||
/// advances to the next variant. Silently returns if audio is disabled, muted,
|
/// advances to the next variant. Silently returns if audio is disabled, muted,
|
||||||
/// or no sounds were loaded successfully.
|
/// or no sounds were loaded successfully.
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn eat(&mut self) {
|
pub fn eat(&mut self) {
|
||||||
if self.disabled || self.muted || self.sounds.is_empty() {
|
if self.disabled || self.muted || self.sounds.is_empty() {
|
||||||
return;
|
return;
|
||||||
@@ -138,6 +173,24 @@ impl Audio {
|
|||||||
self.next_sound_index = (self.next_sound_index + 1) % self.sounds.len();
|
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.
|
/// 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
|
/// Sets all 4 mixer channels to zero volume when muting, or restores them to
|
||||||
@@ -168,7 +221,6 @@ impl Audio {
|
|||||||
/// Audio can be disabled due to SDL2_mixer initialization failures, missing
|
/// Audio can be disabled due to SDL2_mixer initialization failures, missing
|
||||||
/// audio device, or failure to load any sound assets. When disabled, all
|
/// audio device, or failure to load any sound assets. When disabled, all
|
||||||
/// audio operations become no-ops.
|
/// audio operations become no-ops.
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn is_disabled(&self) -> bool {
|
pub fn is_disabled(&self) -> bool {
|
||||||
self.disabled
|
self.disabled
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
|
||||||
|
#![cfg_attr(coverage_nightly, coverage(off))]
|
||||||
|
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use sdl2::event::Event;
|
use sdl2::event::Event;
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
|
||||||
|
#![cfg_attr(coverage_nightly, coverage(off))]
|
||||||
|
|
||||||
use circular_buffer::CircularBuffer;
|
use circular_buffer::CircularBuffer;
|
||||||
use pacman::constants::CANVAS_SIZE;
|
use pacman::constants::CANVAS_SIZE;
|
||||||
use sdl2::event::Event;
|
use sdl2::event::Event;
|
||||||
|
|||||||
@@ -25,12 +25,25 @@ pub const SCALE: f32 = 2.6;
|
|||||||
/// screen for score display, player lives, and other UI elements.
|
/// screen for score display, player lives, and other UI elements.
|
||||||
pub const BOARD_CELL_OFFSET: UVec2 = UVec2::new(0, 3);
|
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.
|
/// Pixel-space equivalent of `BOARD_CELL_OFFSET` for rendering calculations.
|
||||||
///
|
///
|
||||||
/// Automatically calculated from the cell offset to maintain consistency
|
/// Automatically calculated from the cell offset to maintain consistency
|
||||||
/// when the cell size changes. Used for positioning sprites and debug overlays.
|
/// 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);
|
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
|
/// Animation timing constants for ghost state management
|
||||||
pub mod animation {
|
pub mod animation {
|
||||||
/// Normal ghost movement animation speed (ticks per frame at 60 ticks/sec)
|
/// Normal ghost movement animation speed (ticks per frame at 60 ticks/sec)
|
||||||
@@ -39,21 +52,23 @@ pub mod animation {
|
|||||||
pub const GHOST_EATEN_SPEED: u16 = 6;
|
pub const GHOST_EATEN_SPEED: u16 = 6;
|
||||||
/// Frightened ghost animation speed (ticks per frame at 60 ticks/sec)
|
/// Frightened ghost animation speed (ticks per frame at 60 ticks/sec)
|
||||||
pub const GHOST_FRIGHTENED_SPEED: u16 = 12;
|
pub const GHOST_FRIGHTENED_SPEED: u16 = 12;
|
||||||
|
/// Time in ticks for frightened ghosts to return to normal
|
||||||
/// Time in ticks when frightened ghosts start flashing (2 seconds at 60 FPS)
|
pub const GHOST_FRIGHTENED_TICKS: u32 = 300;
|
||||||
pub const FRIGHTENED_FLASH_START_TICKS: u32 = 120;
|
/// Time in ticks when frightened ghosts start flashing
|
||||||
|
pub const GHOST_FRIGHTENED_FLASH_START_TICKS: u32 = GHOST_FRIGHTENED_TICKS - 120;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The size of the canvas, in pixels.
|
/// The size of the canvas, in pixels.
|
||||||
pub const CANVAS_SIZE: UVec2 = UVec2::new(
|
pub const CANVAS_SIZE: UVec2 = UVec2::new(
|
||||||
(BOARD_CELL_SIZE.x + BOARD_CELL_OFFSET.x) * 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) * 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_SCALE: f32 = 2.6;
|
||||||
|
|
||||||
pub const LARGE_CANVAS_SIZE: UVec2 = UVec2::new(
|
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.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) * 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
|
/// Collider size constants for different entity types
|
||||||
@@ -66,14 +81,16 @@ pub mod collider {
|
|||||||
pub const PELLET_SIZE: f32 = CELL_SIZE as f32 * 0.4;
|
pub const PELLET_SIZE: f32 = CELL_SIZE as f32 * 0.4;
|
||||||
/// Collider size for power pellets/energizers (0.95x cell size)
|
/// Collider size for power pellets/energizers (0.95x cell size)
|
||||||
pub const POWER_PELLET_SIZE: f32 = CELL_SIZE as f32 * 0.95;
|
pub const POWER_PELLET_SIZE: f32 = CELL_SIZE as f32 * 0.95;
|
||||||
|
/// Collider size for fruits (0.8x cell size)
|
||||||
|
pub const FRUIT_SIZE: f32 = CELL_SIZE as f32 * 1.375;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// UI and rendering constants
|
/// UI and rendering constants
|
||||||
pub mod ui {
|
pub mod ui {
|
||||||
/// Debug font size in points
|
/// Debug font size in points
|
||||||
pub const DEBUG_FONT_SIZE: u16 = 12;
|
pub const DEBUG_FONT_SIZE: u16 = 12;
|
||||||
/// Power pellet blink rate in seconds
|
/// Power pellet blink rate in ticks (at 60 FPS, 12 ticks = 0.2 seconds)
|
||||||
pub const POWER_PELLET_BLINK_RATE: f32 = 0.2;
|
pub const POWER_PELLET_BLINK_RATE: u32 = 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map tile types that define gameplay behavior and collision properties.
|
/// Map tile types that define gameplay behavior and collision properties.
|
||||||
@@ -132,8 +149,6 @@ pub const RAW_BOARD: [&str; BOARD_CELL_SIZE.y as usize] = [
|
|||||||
pub mod startup {
|
pub mod startup {
|
||||||
/// Number of frames for the startup sequence (3 seconds at 60 FPS)
|
/// Number of frames for the startup sequence (3 seconds at 60 FPS)
|
||||||
pub const STARTUP_FRAMES: u32 = 60 * 3;
|
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
|
/// Game mechanics constants
|
||||||
|
|||||||
57
src/error.rs
57
src/error.rs
@@ -46,6 +46,7 @@ pub enum AssetError {
|
|||||||
#[error("IO error: {0}")]
|
#[error("IO error: {0}")]
|
||||||
Io(#[from] io::Error),
|
Io(#[from] io::Error),
|
||||||
|
|
||||||
|
// This error is only possible on Emscripten, as the assets are loaded from a 'filesystem' of sorts (while on Desktop, they are included in the binary at compile time)
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[error("Asset not found: {0}")]
|
#[error("Asset not found: {0}")]
|
||||||
NotFound(String),
|
NotFound(String),
|
||||||
@@ -53,12 +54,10 @@ pub enum AssetError {
|
|||||||
|
|
||||||
/// Platform-specific errors.
|
/// Platform-specific errors.
|
||||||
#[derive(thiserror::Error, Debug)]
|
#[derive(thiserror::Error, Debug)]
|
||||||
#[allow(dead_code)]
|
|
||||||
pub enum PlatformError {
|
pub enum PlatformError {
|
||||||
#[error("Console initialization failed: {0}")]
|
#[error("Console initialization failed: {0}")]
|
||||||
|
#[cfg(any(windows, target_os = "emscripten"))]
|
||||||
ConsoleInit(String),
|
ConsoleInit(String),
|
||||||
#[error("Platform-specific error: {0}")]
|
|
||||||
Other(String),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Error type for map parsing operations.
|
/// Error type for map parsing operations.
|
||||||
@@ -110,55 +109,3 @@ pub enum MapError {
|
|||||||
|
|
||||||
/// Result type for game operations.
|
/// Result type for game operations.
|
||||||
pub type GameResult<T> = Result<T, GameError>;
|
pub type GameResult<T> = Result<T, GameError>;
|
||||||
|
|
||||||
/// Helper trait for converting other error types to GameError.
|
|
||||||
pub trait IntoGameError<T> {
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn into_game_error(self) -> GameResult<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T, E> IntoGameError<T> for Result<T, E>
|
|
||||||
where
|
|
||||||
E: std::error::Error + Send + Sync + 'static,
|
|
||||||
{
|
|
||||||
fn into_game_error(self) -> GameResult<T> {
|
|
||||||
self.map_err(|e| GameError::InvalidState(e.to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper trait for converting Option to GameResult with a custom error.
|
|
||||||
pub trait OptionExt<T> {
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn ok_or_game_error<F>(self, f: F) -> GameResult<T>
|
|
||||||
where
|
|
||||||
F: FnOnce() -> GameError;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> OptionExt<T> for Option<T> {
|
|
||||||
fn ok_or_game_error<F>(self, f: F) -> GameResult<T>
|
|
||||||
where
|
|
||||||
F: FnOnce() -> GameError,
|
|
||||||
{
|
|
||||||
self.ok_or_else(f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper trait for converting Result to GameResult with context.
|
|
||||||
pub trait ResultExt<T, E> {
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn with_context<F>(self, f: F) -> GameResult<T>
|
|
||||||
where
|
|
||||||
F: FnOnce(&E) -> GameError;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T, E> ResultExt<T, E> for Result<T, E>
|
|
||||||
where
|
|
||||||
E: std::error::Error + Send + Sync + 'static,
|
|
||||||
{
|
|
||||||
fn with_context<F>(self, f: F) -> GameResult<T>
|
|
||||||
where
|
|
||||||
F: FnOnce(&E) -> GameError,
|
|
||||||
{
|
|
||||||
self.map_err(|e| f(&e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use bevy_ecs::{entity::Entity, event::Event};
|
use bevy_ecs::{entity::Entity, event::Event};
|
||||||
|
|
||||||
use crate::map::direction::Direction;
|
use crate::{map::direction::Direction, systems::Ghost};
|
||||||
|
|
||||||
/// Player input commands that trigger specific game actions.
|
/// Player input commands that trigger specific game actions.
|
||||||
///
|
///
|
||||||
@@ -24,15 +24,12 @@ pub enum GameCommand {
|
|||||||
|
|
||||||
/// Global events that flow through the ECS event system to coordinate game behavior.
|
/// Global events that flow through the ECS event system to coordinate game behavior.
|
||||||
///
|
///
|
||||||
/// Events enable loose coupling between systems - input generates commands, collision
|
/// Events enable loose coupling between systems - input generates commands and
|
||||||
/// detection reports overlaps, and various systems respond appropriately without
|
/// various systems respond appropriately without direct dependencies.
|
||||||
/// direct dependencies.
|
|
||||||
#[derive(Event, Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Event, Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum GameEvent {
|
pub enum GameEvent {
|
||||||
/// Player input command to be processed by relevant game systems
|
/// Player input command to be processed by relevant game systems
|
||||||
Command(GameCommand),
|
Command(GameCommand),
|
||||||
/// Physical overlap detected between two entities requiring gameplay response
|
|
||||||
Collision(Entity, Entity),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<GameCommand> for GameEvent {
|
impl From<GameCommand> for GameEvent {
|
||||||
@@ -40,3 +37,22 @@ impl From<GameCommand> for GameEvent {
|
|||||||
GameEvent::Command(command)
|
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, ghost_type: Ghost },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collision triggers for immediate collision handling via observers
|
||||||
|
#[derive(Event, Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum CollisionTrigger {
|
||||||
|
/// Pac-Man collided with a ghost
|
||||||
|
GhostCollision {
|
||||||
|
pacman: Entity,
|
||||||
|
ghost: Entity,
|
||||||
|
ghost_type: Ghost,
|
||||||
|
},
|
||||||
|
/// Pac-Man collided with an item
|
||||||
|
ItemCollision { item: Entity },
|
||||||
|
}
|
||||||
|
|||||||
152
src/formatter.rs
Normal file
152
src/formatter.rs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
//! 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)
|
||||||
|
}
|
||||||
702
src/game.rs
702
src/game.rs
@@ -3,38 +3,35 @@
|
|||||||
include!(concat!(env!("OUT_DIR"), "/atlas_data.rs"));
|
include!(concat!(env!("OUT_DIR"), "/atlas_data.rs"));
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::ops::Not;
|
||||||
|
use tracing::{debug, info, trace, warn};
|
||||||
|
|
||||||
use crate::constants::{self, animation, MapTile, CANVAS_SIZE};
|
use crate::constants::{self, animation, MapTile, CANVAS_SIZE};
|
||||||
use crate::error::{GameError, GameResult, TextureError};
|
use crate::error::{GameError, GameResult};
|
||||||
use crate::events::GameEvent;
|
use crate::events::{CollisionTrigger, GameEvent, StageTransition};
|
||||||
use crate::map::builder::Map;
|
use crate::map::builder::Map;
|
||||||
use crate::map::direction::Direction;
|
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::{
|
use crate::systems::{
|
||||||
self, combined_render_system, ghost_collision_system, present_system, Hidden, LinearAnimation, MovementModifiers, NodeId,
|
self, audio_system, blinking_system, collision_system, combined_render_system, directional_render_system,
|
||||||
};
|
dirty_render_system, eaten_ghost_system, fruit_sprite_system, ghost_collision_observer, ghost_movement_system,
|
||||||
use crate::systems::{
|
ghost_state_system, hud_render_system, item_collision_observer, linear_render_system, player_life_sprite_system,
|
||||||
audio_system, blinking_system, collision_system, directional_render_system, dirty_render_system, eaten_ghost_system,
|
present_system, profile, time_to_live_system, touch_ui_render_system, AudioEvent, AudioResource, AudioState,
|
||||||
ghost_movement_system, ghost_state_system, hud_render_system, item_system, linear_render_system, profile, AudioEvent,
|
BackbufferResource, Blinking, BufferedDirection, Collider, DebugState, DebugTextureResource, DeltaTime, DirectionalAnimation,
|
||||||
AudioResource, AudioState, BackbufferResource, Collider, DebugState, DebugTextureResource, DeltaTime, DirectionalAnimation,
|
EntityType, Frozen, FruitSprites, GameStage, Ghost, GhostAnimation, GhostAnimations, GhostBundle, GhostCollider, GhostState,
|
||||||
EntityType, Frozen, Ghost, GhostAnimations, GhostBundle, GhostCollider, GlobalState, ItemBundle, ItemCollider,
|
GlobalState, ItemBundle, ItemCollider, LastAnimationState, LinearAnimation, MapTextureResource, MovementModifiers, NodeId,
|
||||||
MapTextureResource, PacmanCollider, PlayerBundle, PlayerControlled, Renderable, ScoreResource, StartupSequence,
|
PacmanCollider, PlayerAnimation, PlayerBundle, PlayerControlled, PlayerDeathAnimation, PlayerLives, Position, RenderDirty,
|
||||||
SystemTimings,
|
Renderable, ScoreResource, StartupSequence, SystemId, SystemTimings, Timing, TouchState, Velocity, Visibility,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::texture::animated::{DirectionalTiles, TileSequence};
|
use crate::texture::animated::{DirectionalTiles, TileSequence};
|
||||||
use crate::texture::sprite::AtlasTile;
|
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::event::EventRegistry;
|
||||||
use bevy_ecs::observer::Trigger;
|
use bevy_ecs::observer::Trigger;
|
||||||
use bevy_ecs::schedule::common_conditions::resource_changed;
|
use bevy_ecs::schedule::{IntoScheduleConfigs, Schedule, SystemSet};
|
||||||
use bevy_ecs::schedule::{Condition, IntoScheduleConfigs, Schedule, SystemSet};
|
use bevy_ecs::system::{Local, Res, ResMut};
|
||||||
use bevy_ecs::system::{Local, ResMut};
|
|
||||||
use bevy_ecs::world::World;
|
use bevy_ecs::world::World;
|
||||||
use glam::UVec2;
|
|
||||||
use sdl2::event::EventType;
|
use sdl2::event::EventType;
|
||||||
use sdl2::image::LoadTexture;
|
use sdl2::image::LoadTexture;
|
||||||
use sdl2::render::{BlendMode, Canvas, ScaleMode, TextureCreator};
|
use sdl2::render::{BlendMode, Canvas, ScaleMode, TextureCreator};
|
||||||
@@ -46,14 +43,28 @@ use crate::{
|
|||||||
asset::{get_asset_bytes, Asset},
|
asset::{get_asset_bytes, Asset},
|
||||||
events::GameCommand,
|
events::GameCommand,
|
||||||
map::render::MapRenderer,
|
map::render::MapRenderer,
|
||||||
systems::debug::{BatchedLinesResource, TtfAtlasResource},
|
systems::{BatchedLinesResource, Bindings, CursorPosition, TtfAtlasResource},
|
||||||
systems::input::{Bindings, CursorPosition},
|
|
||||||
texture::sprite::{AtlasMapper, SpriteAtlas},
|
texture::sprite::{AtlasMapper, SpriteAtlas},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// System set for all gameplay systems to ensure they run after input processing
|
||||||
|
#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
|
||||||
|
enum GameplaySet {
|
||||||
|
/// Gameplay systems that process inputs
|
||||||
|
Input,
|
||||||
|
/// Gameplay systems that update the game state
|
||||||
|
Update,
|
||||||
|
/// Gameplay systems that respond to events
|
||||||
|
Respond,
|
||||||
|
}
|
||||||
|
|
||||||
/// System set for all rendering systems to ensure they run after gameplay logic
|
/// System set for all rendering systems to ensure they run after gameplay logic
|
||||||
#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
|
#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
|
||||||
pub struct RenderSet;
|
enum RenderSet {
|
||||||
|
Animation,
|
||||||
|
Draw,
|
||||||
|
Present,
|
||||||
|
}
|
||||||
|
|
||||||
/// Core game state manager built on the Bevy ECS architecture.
|
/// Core game state manager built on the Bevy ECS architecture.
|
||||||
///
|
///
|
||||||
@@ -88,10 +99,80 @@ impl Game {
|
|||||||
/// errors, or entity initialization issues.
|
/// errors, or entity initialization issues.
|
||||||
pub fn new(
|
pub fn new(
|
||||||
mut canvas: Canvas<Window>,
|
mut canvas: Canvas<Window>,
|
||||||
|
ttf_context: sdl2::ttf::Sdl2TtfContext,
|
||||||
texture_creator: TextureCreator<WindowContext>,
|
texture_creator: TextureCreator<WindowContext>,
|
||||||
mut event_pump: EventPump,
|
mut event_pump: EventPump,
|
||||||
) -> GameResult<Game> {
|
) -> 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);
|
||||||
|
|
||||||
|
world.add_observer(systems::spawn_fruit_observer);
|
||||||
|
|
||||||
|
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, Visibility::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 [
|
for event_type in [
|
||||||
EventType::JoyAxisMotion,
|
EventType::JoyAxisMotion,
|
||||||
EventType::JoyBallMotion,
|
EventType::JoyBallMotion,
|
||||||
@@ -109,9 +190,6 @@ impl Game {
|
|||||||
EventType::ControllerTouchpadDown,
|
EventType::ControllerTouchpadDown,
|
||||||
EventType::ControllerTouchpadMotion,
|
EventType::ControllerTouchpadMotion,
|
||||||
EventType::ControllerTouchpadUp,
|
EventType::ControllerTouchpadUp,
|
||||||
// EventType::FingerDown, // Enable for touch controls
|
|
||||||
// EventType::FingerUp, // Enable for touch controls
|
|
||||||
// EventType::FingerMotion, // Enable for touch controls
|
|
||||||
EventType::DollarGesture,
|
EventType::DollarGesture,
|
||||||
EventType::DollarRecord,
|
EventType::DollarRecord,
|
||||||
EventType::MultiGesture,
|
EventType::MultiGesture,
|
||||||
@@ -128,11 +206,7 @@ impl Game {
|
|||||||
EventType::TextInput,
|
EventType::TextInput,
|
||||||
EventType::TextEditing,
|
EventType::TextEditing,
|
||||||
EventType::Display,
|
EventType::Display,
|
||||||
// EventType::Window,
|
|
||||||
EventType::MouseWheel,
|
EventType::MouseWheel,
|
||||||
// EventType::MouseMotion,
|
|
||||||
// EventType::MouseButtonDown, // Enable for desktop touch testing
|
|
||||||
// EventType::MouseButtonUp, // Enable for desktop touch testing
|
|
||||||
EventType::AppDidEnterBackground,
|
EventType::AppDidEnterBackground,
|
||||||
EventType::AppWillEnterForeground,
|
EventType::AppWillEnterForeground,
|
||||||
EventType::AppWillEnterBackground,
|
EventType::AppWillEnterBackground,
|
||||||
@@ -144,8 +218,18 @@ impl Game {
|
|||||||
] {
|
] {
|
||||||
event_pump.disable_event(event_type);
|
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
|
let mut backbuffer = texture_creator
|
||||||
.create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y)
|
.create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y)
|
||||||
.map_err(|e| GameError::Sdl(e.to_string()))?;
|
.map_err(|e| GameError::Sdl(e.to_string()))?;
|
||||||
@@ -156,31 +240,27 @@ impl Game {
|
|||||||
.map_err(|e| GameError::Sdl(e.to_string()))?;
|
.map_err(|e| GameError::Sdl(e.to_string()))?;
|
||||||
map_texture.set_scale_mode(ScaleMode::Nearest);
|
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 output_size = constants::LARGE_CANVAS_SIZE;
|
||||||
let mut debug_texture = texture_creator
|
let mut debug_texture = texture_creator
|
||||||
.create_texture_target(Some(sdl2::pixels::PixelFormatEnum::ARGB8888), output_size.x, output_size.y)
|
.create_texture_target(Some(sdl2::pixels::PixelFormatEnum::ARGB8888), output_size.x, output_size.y)
|
||||||
.map_err(|e| GameError::Sdl(e.to_string()))?;
|
.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_blend_mode(BlendMode::Blend);
|
||||||
debug_texture.set_scale_mode(ScaleMode::Nearest);
|
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_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 font_asset = RWops::from_bytes(font_data).map_err(|_| GameError::Sdl("Failed to load font".to_string()))?;
|
||||||
let debug_font = ttf_context
|
let debug_font = ttf_context
|
||||||
.load_font_from_rwops(font_asset, constants::ui::DEBUG_FONT_SIZE)
|
.load_font_from_rwops(font_asset, constants::ui::DEBUG_FONT_SIZE)
|
||||||
.map_err(|e| GameError::Sdl(e.to_string()))?;
|
.map_err(|e| GameError::Sdl(e.to_string()))?;
|
||||||
|
|
||||||
let mut ttf_atlas = crate::texture::ttf::TtfAtlas::new(&texture_creator, &debug_font)?;
|
let mut ttf_atlas = crate::texture::ttf::TtfAtlas::new(texture_creator, &debug_font)?;
|
||||||
// Populate the atlas with actual character data
|
ttf_atlas.populate_atlas(canvas, texture_creator, &debug_font)?;
|
||||||
ttf_atlas.populate_atlas(&mut canvas, &texture_creator, &debug_font)?;
|
|
||||||
|
|
||||||
// Initialize audio system
|
Ok((backbuffer, map_texture, debug_texture, ttf_atlas))
|
||||||
let audio = crate::audio::Audio::new();
|
}
|
||||||
|
|
||||||
// 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_bytes = get_asset_bytes(Asset::AtlasImage)?;
|
||||||
let atlas_texture = texture_creator.load_texture_bytes(&atlas_bytes).map_err(|e| {
|
let atlas_texture = texture_creator.load_texture_bytes(&atlas_bytes).map_err(|e| {
|
||||||
if e.to_string().contains("format") || e.to_string().contains("unsupported") {
|
if e.to_string().contains("format") || e.to_string().contains("unsupported") {
|
||||||
@@ -192,60 +272,49 @@ impl Game {
|
|||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
debug!(frame_count = ATLAS_FRAMES.len(), "Creating sprite atlas from texture");
|
||||||
let atlas_mapper = AtlasMapper {
|
let atlas_mapper = AtlasMapper {
|
||||||
frames: ATLAS_FRAMES.into_iter().map(|(k, v)| (k.to_string(), *v)).collect(),
|
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);
|
let mut map_tiles = Vec::with_capacity(35);
|
||||||
for i in 0..35 {
|
for i in 0..35 {
|
||||||
let tile_name = format!("maze/tiles/{}.png", i);
|
let tile_name = GameSprite::Maze(MazeSprite::Tile(i)).to_path();
|
||||||
let tile = atlas.get_tile(&tile_name).unwrap();
|
let tile = atlas.get_tile(&tile_name)?;
|
||||||
map_tiles.push(tile);
|
map_tiles.push(tile);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render map to texture
|
Ok((atlas, map_tiles))
|
||||||
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()))?;
|
|
||||||
|
|
||||||
let map = Map::new(constants::RAW_BOARD)?;
|
fn create_player_animations(atlas: &SpriteAtlas) -> GameResult<(DirectionalAnimation, AtlasTile)> {
|
||||||
|
|
||||||
// Create directional animated textures for Pac-Man
|
|
||||||
let up_moving_tiles = [
|
let up_moving_tiles = [
|
||||||
SpriteAtlas::get_tile(&atlas, "pacman/up_a.png")
|
SpriteAtlas::get_tile(atlas, &GameSprite::Pacman(PacmanSprite::Moving(Direction::Up, 0)).to_path())?,
|
||||||
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/up_a.png".to_string())))?,
|
SpriteAtlas::get_tile(atlas, &GameSprite::Pacman(PacmanSprite::Moving(Direction::Up, 1)).to_path())?,
|
||||||
SpriteAtlas::get_tile(&atlas, "pacman/up_b.png")
|
SpriteAtlas::get_tile(atlas, &GameSprite::Pacman(PacmanSprite::Full).to_path())?,
|
||||||
.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())))?,
|
|
||||||
];
|
];
|
||||||
let down_moving_tiles = [
|
let down_moving_tiles = [
|
||||||
SpriteAtlas::get_tile(&atlas, "pacman/down_a.png")
|
SpriteAtlas::get_tile(atlas, &GameSprite::Pacman(PacmanSprite::Moving(Direction::Down, 0)).to_path())?,
|
||||||
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/down_a.png".to_string())))?,
|
SpriteAtlas::get_tile(atlas, &GameSprite::Pacman(PacmanSprite::Moving(Direction::Down, 1)).to_path())?,
|
||||||
SpriteAtlas::get_tile(&atlas, "pacman/down_b.png")
|
SpriteAtlas::get_tile(atlas, &GameSprite::Pacman(PacmanSprite::Full).to_path())?,
|
||||||
.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())))?,
|
|
||||||
];
|
];
|
||||||
let left_moving_tiles = [
|
let left_moving_tiles = [
|
||||||
SpriteAtlas::get_tile(&atlas, "pacman/left_a.png")
|
SpriteAtlas::get_tile(atlas, &GameSprite::Pacman(PacmanSprite::Moving(Direction::Left, 0)).to_path())?,
|
||||||
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/left_a.png".to_string())))?,
|
SpriteAtlas::get_tile(atlas, &GameSprite::Pacman(PacmanSprite::Moving(Direction::Left, 1)).to_path())?,
|
||||||
SpriteAtlas::get_tile(&atlas, "pacman/left_b.png")
|
SpriteAtlas::get_tile(atlas, &GameSprite::Pacman(PacmanSprite::Full).to_path())?,
|
||||||
.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())))?,
|
|
||||||
];
|
];
|
||||||
let right_moving_tiles = [
|
let right_moving_tiles = [
|
||||||
SpriteAtlas::get_tile(&atlas, "pacman/right_a.png")
|
SpriteAtlas::get_tile(
|
||||||
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/right_a.png".to_string())))?,
|
atlas,
|
||||||
SpriteAtlas::get_tile(&atlas, "pacman/right_b.png")
|
&GameSprite::Pacman(PacmanSprite::Moving(Direction::Right, 0)).to_path(),
|
||||||
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/right_b.png".to_string())))?,
|
)?,
|
||||||
SpriteAtlas::get_tile(&atlas, "pacman/full.png")
|
SpriteAtlas::get_tile(
|
||||||
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?,
|
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(
|
let moving_tiles = DirectionalTiles::new(
|
||||||
@@ -255,14 +324,16 @@ impl Game {
|
|||||||
TileSequence::new(&right_moving_tiles),
|
TileSequence::new(&right_moving_tiles),
|
||||||
);
|
);
|
||||||
|
|
||||||
let up_stopped_tile = SpriteAtlas::get_tile(&atlas, "pacman/up_b.png")
|
let up_stopped_tile =
|
||||||
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/up_b.png".to_string())))?;
|
SpriteAtlas::get_tile(atlas, &GameSprite::Pacman(PacmanSprite::Moving(Direction::Up, 1)).to_path())?;
|
||||||
let down_stopped_tile = SpriteAtlas::get_tile(&atlas, "pacman/down_b.png")
|
let down_stopped_tile =
|
||||||
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/down_b.png".to_string())))?;
|
SpriteAtlas::get_tile(atlas, &GameSprite::Pacman(PacmanSprite::Moving(Direction::Down, 1)).to_path())?;
|
||||||
let left_stopped_tile = SpriteAtlas::get_tile(&atlas, "pacman/left_b.png")
|
let left_stopped_tile =
|
||||||
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/left_b.png".to_string())))?;
|
SpriteAtlas::get_tile(atlas, &GameSprite::Pacman(PacmanSprite::Moving(Direction::Left, 1)).to_path())?;
|
||||||
let right_stopped_tile = SpriteAtlas::get_tile(&atlas, "pacman/right_b.png")
|
let right_stopped_tile = SpriteAtlas::get_tile(
|
||||||
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/right_b.png".to_string())))?;
|
atlas,
|
||||||
|
&GameSprite::Pacman(PacmanSprite::Moving(Direction::Right, 1)).to_path(),
|
||||||
|
)?;
|
||||||
|
|
||||||
let stopped_tiles = DirectionalTiles::new(
|
let stopped_tiles = DirectionalTiles::new(
|
||||||
TileSequence::new(&[up_stopped_tile]),
|
TileSequence::new(&[up_stopped_tile]),
|
||||||
@@ -271,7 +342,26 @@ impl Game {
|
|||||||
TileSequence::new(&[right_stopped_tile]),
|
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,
|
player: PlayerControlled,
|
||||||
position: Position::Stopped {
|
position: Position::Stopped {
|
||||||
node: map.start_positions.pacman,
|
node: map.start_positions.pacman,
|
||||||
@@ -283,54 +373,24 @@ impl Game {
|
|||||||
movement_modifiers: MovementModifiers::default(),
|
movement_modifiers: MovementModifiers::default(),
|
||||||
buffered_direction: BufferedDirection::None,
|
buffered_direction: BufferedDirection::None,
|
||||||
sprite: Renderable {
|
sprite: Renderable {
|
||||||
sprite: SpriteAtlas::get_tile(&atlas, "pacman/full.png")
|
sprite: player_start_sprite,
|
||||||
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?,
|
|
||||||
layer: 0,
|
layer: 0,
|
||||||
},
|
},
|
||||||
directional_animation: DirectionalAnimation::new(moving_tiles, stopped_tiles, 5),
|
directional_animation: player_animation,
|
||||||
entity_type: EntityType::Player,
|
entity_type: EntityType::Player,
|
||||||
collider: Collider {
|
collider: Collider {
|
||||||
size: constants::collider::PLAYER_GHOST_SIZE,
|
size: constants::collider::PLAYER_GHOST_SIZE,
|
||||||
},
|
},
|
||||||
pacman_collider: PacmanCollider,
|
pacman_collider: PacmanCollider,
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut world = World::default();
|
fn setup_ecs(world: &mut World) {
|
||||||
let mut schedule = Schedule::default();
|
EventRegistry::register_event::<GameError>(world);
|
||||||
|
EventRegistry::register_event::<GameEvent>(world);
|
||||||
EventRegistry::register_event::<GameError>(&mut world);
|
EventRegistry::register_event::<AudioEvent>(world);
|
||||||
EventRegistry::register_event::<GameEvent>(&mut world);
|
EventRegistry::register_event::<StageTransition>(world);
|
||||||
EventRegistry::register_event::<AudioEvent>(&mut world);
|
EventRegistry::register_event::<CollisionTrigger>(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));
|
|
||||||
|
|
||||||
world.add_observer(
|
world.add_observer(
|
||||||
|event: Trigger<GameEvent>, mut state: ResMut<GlobalState>, _score: ResMut<ScoreResource>| {
|
|event: Trigger<GameEvent>, mut state: ResMut<GlobalState>, _score: ResMut<ScoreResource>| {
|
||||||
@@ -340,73 +400,150 @@ impl Game {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
world.add_observer(ghost_collision_observer);
|
||||||
|
world.add_observer(item_collision_observer);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(FruitSprites::default());
|
||||||
|
world.insert_resource(BatchedLinesResource::new(&map, constants::LARGE_SCALE));
|
||||||
|
world.insert_resource(map);
|
||||||
|
world.insert_resource(GlobalState { exit: false });
|
||||||
|
world.insert_resource(PlayerLives::default());
|
||||||
|
world.insert_resource(ScoreResource(0));
|
||||||
|
world.insert_resource(crate::systems::item::PelletCount(0));
|
||||||
|
world.insert_resource(SystemTimings::default());
|
||||||
|
world.insert_resource(Timing::default());
|
||||||
|
world.insert_resource(Bindings::default());
|
||||||
|
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 input_system = profile(SystemId::Input, systems::input::input_system);
|
||||||
let player_control_system = profile(SystemId::PlayerControls, systems::player_control_system);
|
let player_control_system = profile(SystemId::PlayerControls, systems::player_control_system);
|
||||||
let player_movement_system = profile(SystemId::PlayerMovement, systems::player_movement_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 player_tunnel_slowdown_system = profile(SystemId::PlayerMovement, systems::player::player_tunnel_slowdown_system);
|
||||||
let ghost_movement_system = profile(SystemId::Ghost, ghost_movement_system);
|
let ghost_movement_system = profile(SystemId::Ghost, ghost_movement_system);
|
||||||
let collision_system = profile(SystemId::Collision, collision_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 audio_system = profile(SystemId::Audio, audio_system);
|
||||||
let blinking_system = profile(SystemId::Blinking, blinking_system);
|
let blinking_system = profile(SystemId::Blinking, blinking_system);
|
||||||
let directional_render_system = profile(SystemId::DirectionalRender, directional_render_system);
|
let directional_render_system = profile(SystemId::DirectionalRender, directional_render_system);
|
||||||
let linear_render_system = profile(SystemId::LinearRender, linear_render_system);
|
let linear_render_system = profile(SystemId::LinearRender, linear_render_system);
|
||||||
let dirty_render_system = profile(SystemId::DirtyRender, dirty_render_system);
|
let dirty_render_system = profile(SystemId::DirtyRender, dirty_render_system);
|
||||||
let hud_render_system = profile(SystemId::HudRender, hud_render_system);
|
let hud_render_system = profile(SystemId::HudRender, hud_render_system);
|
||||||
|
let player_life_sprite_system = profile(SystemId::HudRender, player_life_sprite_system);
|
||||||
|
let fruit_sprite_system = profile(SystemId::HudRender, fruit_sprite_system);
|
||||||
let present_system = profile(SystemId::Present, present_system);
|
let present_system = profile(SystemId::Present, present_system);
|
||||||
let unified_ghost_state_system = profile(SystemId::GhostStateAnimation, ghost_state_system);
|
let unified_ghost_state_system = profile(SystemId::GhostStateAnimation, ghost_state_system);
|
||||||
|
let eaten_ghost_system = profile(SystemId::EatenGhost, eaten_ghost_system);
|
||||||
|
let time_to_live_system = profile(SystemId::TimeToLive, time_to_live_system);
|
||||||
|
|
||||||
let forced_dirty_system = |mut dirty: ResMut<RenderDirty>| {
|
// Input system should always run to prevent SDL event pump from blocking
|
||||||
dirty.0 = true;
|
let input_systems = (
|
||||||
};
|
|
||||||
|
|
||||||
schedule.add_systems((
|
|
||||||
forced_dirty_system.run_if(resource_changed::<ScoreResource>.or(resource_changed::<StartupSequence>)),
|
|
||||||
(
|
|
||||||
input_system.run_if(|mut local: Local<u8>| {
|
input_system.run_if(|mut local: Local<u8>| {
|
||||||
*local = local.wrapping_add(1u8);
|
*local = local.wrapping_add(1u8);
|
||||||
// run every nth frame
|
// run every nth frame
|
||||||
*local % 2 == 0
|
*local % 2 == 0
|
||||||
}),
|
}),
|
||||||
player_control_system,
|
player_control_system,
|
||||||
player_movement_system,
|
|
||||||
startup_stage_system,
|
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain();
|
||||||
|
|
||||||
|
// .run_if(|game_state: Res<GameStage>| matches!(*game_state, GameStage::Playing));
|
||||||
|
|
||||||
|
schedule
|
||||||
|
.add_systems((
|
||||||
|
input_systems.in_set(GameplaySet::Input),
|
||||||
|
time_to_live_system.before(GameplaySet::Update),
|
||||||
|
(
|
||||||
|
player_movement_system,
|
||||||
player_tunnel_slowdown_system,
|
player_tunnel_slowdown_system,
|
||||||
ghost_movement_system,
|
ghost_movement_system,
|
||||||
profile(SystemId::EatenGhost, eaten_ghost_system),
|
eaten_ghost_system,
|
||||||
|
collision_system,
|
||||||
unified_ghost_state_system,
|
unified_ghost_state_system,
|
||||||
(collision_system, ghost_collision_system, item_system).chain(),
|
)
|
||||||
audio_system,
|
.in_set(GameplaySet::Update),
|
||||||
blinking_system,
|
|
||||||
(
|
(
|
||||||
|
blinking_system,
|
||||||
directional_render_system,
|
directional_render_system,
|
||||||
linear_render_system,
|
linear_render_system,
|
||||||
dirty_render_system,
|
player_life_sprite_system,
|
||||||
|
fruit_sprite_system,
|
||||||
|
)
|
||||||
|
.in_set(RenderSet::Animation),
|
||||||
|
stage_system.in_set(GameplaySet::Respond),
|
||||||
|
(
|
||||||
|
(|mut dirty: ResMut<RenderDirty>, score: Res<ScoreResource>, stage: Res<GameStage>| {
|
||||||
|
dirty.0 = score.is_changed() || stage.is_changed();
|
||||||
|
}),
|
||||||
|
dirty_render_system.run_if(|dirty: Res<RenderDirty>| dirty.0.not()),
|
||||||
combined_render_system,
|
combined_render_system,
|
||||||
hud_render_system,
|
hud_render_system,
|
||||||
touch_ui_render_system,
|
touch_ui_render_system,
|
||||||
present_system,
|
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain()
|
||||||
|
.in_set(RenderSet::Draw),
|
||||||
|
(present_system, audio_system).chain().in_set(RenderSet::Present),
|
||||||
|
))
|
||||||
|
.configure_sets((
|
||||||
|
GameplaySet::Input,
|
||||||
|
GameplaySet::Update,
|
||||||
|
GameplaySet::Respond,
|
||||||
|
RenderSet::Animation,
|
||||||
|
RenderSet::Draw,
|
||||||
|
RenderSet::Present,
|
||||||
));
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// Spawn player and attach initial state bundle
|
fn spawn_items(world: &mut World) -> GameResult<()> {
|
||||||
world.spawn(player).insert((Frozen, Hidden));
|
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
|
let nodes: Vec<(NodeId, EntityType, AtlasTile, f32)> = world
|
||||||
.resource::<Map>()
|
.resource::<Map>()
|
||||||
.iter_nodes()
|
.iter_nodes()
|
||||||
@@ -422,7 +559,12 @@ impl Game {
|
|||||||
})
|
})
|
||||||
.collect();
|
.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 {
|
for (id, item_type, sprite, size) in nodes {
|
||||||
let mut item = world.spawn(ItemBundle {
|
let mut item = world.spawn(ItemBundle {
|
||||||
position: Position::Stopped { node: id },
|
position: Position::Stopped { node: id },
|
||||||
@@ -432,13 +574,11 @@ impl Game {
|
|||||||
item_collider: ItemCollider,
|
item_collider: ItemCollider,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Make power pellets blink
|
|
||||||
if item_type == EntityType::PowerPellet {
|
if item_type == EntityType::PowerPellet {
|
||||||
item.insert((Frozen, Blinking::new(constants::ui::POWER_PELLET_BLINK_RATE)));
|
item.insert((Frozen, Blinking::new(constants::ui::POWER_PELLET_BLINK_RATE)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
Ok(Game { world, schedule })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates and spawns all four ghosts with unique AI personalities and directional animations.
|
/// Creates and spawns all four ghosts with unique AI personalities and directional animations.
|
||||||
@@ -448,6 +588,7 @@ impl Game {
|
|||||||
/// Returns `GameError::Texture` if any ghost sprite cannot be found in the atlas,
|
/// Returns `GameError::Texture` if any ghost sprite cannot be found in the atlas,
|
||||||
/// typically indicating missing or misnamed sprite files.
|
/// typically indicating missing or misnamed sprite files.
|
||||||
fn spawn_ghosts(world: &mut World) -> GameResult<()> {
|
fn spawn_ghosts(world: &mut World) -> GameResult<()> {
|
||||||
|
trace!("Spawning ghost entities with AI personalities");
|
||||||
// Extract the data we need first to avoid borrow conflicts
|
// Extract the data we need first to avoid borrow conflicts
|
||||||
let ghost_start_positions = {
|
let ghost_start_positions = {
|
||||||
let map = world.resource::<Map>();
|
let map = world.resource::<Map>();
|
||||||
@@ -462,8 +603,9 @@ impl Game {
|
|||||||
for (ghost_type, start_node) in ghost_start_positions {
|
for (ghost_type, start_node) in ghost_start_positions {
|
||||||
// Create the ghost bundle in a separate scope to manage borrows
|
// Create the ghost bundle in a separate scope to manage borrows
|
||||||
let ghost = {
|
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 atlas = world.non_send_resource::<SpriteAtlas>();
|
||||||
|
let sprite_path = GameSprite::Ghost(GhostSprite::Normal(ghost_type, Direction::Left, 0)).to_path();
|
||||||
|
|
||||||
GhostBundle {
|
GhostBundle {
|
||||||
ghost: ghost_type,
|
ghost: ghost_type,
|
||||||
@@ -473,14 +615,7 @@ impl Game {
|
|||||||
direction: Direction::Left,
|
direction: Direction::Left,
|
||||||
},
|
},
|
||||||
sprite: Renderable {
|
sprite: Renderable {
|
||||||
sprite: SpriteAtlas::get_tile(atlas, &format!("ghost/{}/left_a.png", ghost_type.as_str())).ok_or_else(
|
sprite: SpriteAtlas::get_tile(atlas, &sprite_path)?,
|
||||||
|| {
|
|
||||||
GameError::Texture(TextureError::AtlasTileNotFound(format!(
|
|
||||||
"ghost/{}/left_a.png",
|
|
||||||
ghost_type.as_str()
|
|
||||||
)))
|
|
||||||
},
|
|
||||||
)?,
|
|
||||||
layer: 0,
|
layer: 0,
|
||||||
},
|
},
|
||||||
directional_animation: animations,
|
directional_animation: animations,
|
||||||
@@ -494,26 +629,20 @@ impl Game {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
world.spawn(ghost).insert((Frozen, Hidden));
|
let entity = world.spawn(ghost).insert((Frozen, Visibility::hidden())).id();
|
||||||
|
trace!(ghost = ?ghost_type, entity = ?entity, start_node, "Spawned ghost entity");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
info!("All ghost entities spawned successfully");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_ghost_animations(atlas: &SpriteAtlas) -> GameResult<GhostAnimations> {
|
fn create_ghost_animations(atlas: &SpriteAtlas) -> GameResult<GhostAnimations> {
|
||||||
// Eaten (eyes) animations - single tile per direction
|
// Eaten (eyes) animations - single tile per direction
|
||||||
let up_eye = atlas
|
let up_eye = atlas.get_tile(&GameSprite::Ghost(GhostSprite::Eyes(Direction::Up)).to_path())?;
|
||||||
.get_tile("ghost/eyes/up.png")
|
let down_eye = atlas.get_tile(&GameSprite::Ghost(GhostSprite::Eyes(Direction::Down)).to_path())?;
|
||||||
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("ghost/eyes/up.png".to_string())))?;
|
let left_eye = atlas.get_tile(&GameSprite::Ghost(GhostSprite::Eyes(Direction::Left)).to_path())?;
|
||||||
let down_eye = atlas
|
let right_eye = atlas.get_tile(&GameSprite::Ghost(GhostSprite::Eyes(Direction::Right)).to_path())?;
|
||||||
.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 eyes_tiles = DirectionalTiles::new(
|
let eyes_tiles = DirectionalTiles::new(
|
||||||
TileSequence::new(&[up_eye]),
|
TileSequence::new(&[up_eye]),
|
||||||
@@ -521,83 +650,27 @@ impl Game {
|
|||||||
TileSequence::new(&[left_eye]),
|
TileSequence::new(&[left_eye]),
|
||||||
TileSequence::new(&[right_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();
|
let mut animations = HashMap::new();
|
||||||
|
|
||||||
for ghost_type in [Ghost::Blinky, Ghost::Pinky, Ghost::Inky, Ghost::Clyde] {
|
for ghost_type in [Ghost::Blinky, Ghost::Pinky, Ghost::Inky, Ghost::Clyde] {
|
||||||
// Normal animations - create directional tiles for each direction
|
// Normal animations - create directional tiles for each direction
|
||||||
let up_tiles = [
|
let up_tiles = [
|
||||||
atlas
|
atlas.get_tile(&GameSprite::Ghost(GhostSprite::Normal(ghost_type, Direction::Up, 0)).to_path())?,
|
||||||
.get_tile(&format!("ghost/{}/up_a.png", ghost_type.as_str()))
|
atlas.get_tile(&GameSprite::Ghost(GhostSprite::Normal(ghost_type, Direction::Up, 1)).to_path())?,
|
||||||
.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()
|
|
||||||
)))
|
|
||||||
})?,
|
|
||||||
];
|
];
|
||||||
let down_tiles = [
|
let down_tiles = [
|
||||||
atlas
|
atlas.get_tile(&GameSprite::Ghost(GhostSprite::Normal(ghost_type, Direction::Down, 0)).to_path())?,
|
||||||
.get_tile(&format!("ghost/{}/down_a.png", ghost_type.as_str()))
|
atlas.get_tile(&GameSprite::Ghost(GhostSprite::Normal(ghost_type, Direction::Down, 1)).to_path())?,
|
||||||
.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()
|
|
||||||
)))
|
|
||||||
})?,
|
|
||||||
];
|
];
|
||||||
let left_tiles = [
|
let left_tiles = [
|
||||||
atlas
|
atlas.get_tile(&GameSprite::Ghost(GhostSprite::Normal(ghost_type, Direction::Left, 0)).to_path())?,
|
||||||
.get_tile(&format!("ghost/{}/left_a.png", ghost_type.as_str()))
|
atlas.get_tile(&GameSprite::Ghost(GhostSprite::Normal(ghost_type, Direction::Left, 1)).to_path())?,
|
||||||
.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()
|
|
||||||
)))
|
|
||||||
})?,
|
|
||||||
];
|
];
|
||||||
let right_tiles = [
|
let right_tiles = [
|
||||||
atlas
|
atlas.get_tile(&GameSprite::Ghost(GhostSprite::Normal(ghost_type, Direction::Right, 0)).to_path())?,
|
||||||
.get_tile(&format!("ghost/{}/right_a.png", ghost_type.as_str()))
|
atlas.get_tile(&GameSprite::Ghost(GhostSprite::Normal(ghost_type, Direction::Right, 1)).to_path())?,
|
||||||
.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()
|
|
||||||
)))
|
|
||||||
})?,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
let normal_moving = DirectionalTiles::new(
|
let normal_moving = DirectionalTiles::new(
|
||||||
@@ -606,25 +679,21 @@ impl Game {
|
|||||||
TileSequence::new(&left_tiles),
|
TileSequence::new(&left_tiles),
|
||||||
TileSequence::new(&right_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);
|
animations.insert(ghost_type, normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (frightened, frightened_flashing) = {
|
let (frightened, frightened_flashing) = {
|
||||||
// Load frightened animation tiles (same for all ghosts)
|
// Load frightened animation tiles (same for all ghosts)
|
||||||
let frightened_blue_a = atlas
|
let frightened_blue_a =
|
||||||
.get_tile("ghost/frightened/blue_a.png")
|
atlas.get_tile(&GameSprite::Ghost(GhostSprite::Frightened(FrightenedColor::Blue, 0)).to_path())?;
|
||||||
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("ghost/frightened/blue_a.png".to_string())))?;
|
let frightened_blue_b =
|
||||||
let frightened_blue_b = atlas
|
atlas.get_tile(&GameSprite::Ghost(GhostSprite::Frightened(FrightenedColor::Blue, 1)).to_path())?;
|
||||||
.get_tile("ghost/frightened/blue_b.png")
|
let frightened_white_a =
|
||||||
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("ghost/frightened/blue_b.png".to_string())))?;
|
atlas.get_tile(&GameSprite::Ghost(GhostSprite::Frightened(FrightenedColor::White, 0)).to_path())?;
|
||||||
let frightened_white_a = atlas
|
let frightened_white_b =
|
||||||
.get_tile("ghost/frightened/white_a.png")
|
atlas.get_tile(&GameSprite::Ghost(GhostSprite::Frightened(FrightenedColor::White, 1)).to_path())?;
|
||||||
.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())))?;
|
|
||||||
|
|
||||||
(
|
(
|
||||||
LinearAnimation::new(
|
LinearAnimation::new(
|
||||||
@@ -657,10 +726,45 @@ impl Game {
|
|||||||
///
|
///
|
||||||
/// `true` if the game should terminate (exit command received), `false` to continue
|
/// `true` if the game should terminate (exit command received), `false` to continue
|
||||||
pub fn tick(&mut self, dt: f32) -> bool {
|
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);
|
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() > 17 {
|
||||||
|
// Warn if frame takes too long
|
||||||
|
let slowest_systems = timings.get_slowest_systems();
|
||||||
|
let systems_context = if slowest_systems.is_empty() {
|
||||||
|
"No specific systems identified".to_string()
|
||||||
|
} else {
|
||||||
|
slowest_systems
|
||||||
|
.iter()
|
||||||
|
.map(|(id, duration)| format!("{} ({:.2?})", id, duration))
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(", ")
|
||||||
|
};
|
||||||
|
|
||||||
|
warn!(
|
||||||
|
total = format!("{:.3?}", total_duration),
|
||||||
|
tick = new_tick,
|
||||||
|
systems = systems_context,
|
||||||
|
"Frame took longer than expected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let state = self
|
let state = self
|
||||||
.world
|
.world
|
||||||
@@ -669,68 +773,4 @@ impl Game {
|
|||||||
|
|
||||||
state.exit
|
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(())
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|||||||
15
src/lib.rs
15
src/lib.rs
@@ -1,13 +1,22 @@
|
|||||||
//! Pac-Man game library crate.
|
//! Pac-Man game library crate.
|
||||||
|
#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
|
||||||
|
|
||||||
|
#[cfg_attr(coverage_nightly, coverage(off))]
|
||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod asset;
|
#[cfg_attr(coverage_nightly, coverage(off))]
|
||||||
pub mod audio;
|
pub mod audio;
|
||||||
pub mod constants;
|
#[cfg_attr(coverage_nightly, coverage(off))]
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
#[cfg_attr(coverage_nightly, coverage(off))]
|
||||||
pub mod events;
|
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 game;
|
||||||
pub mod map;
|
pub mod map;
|
||||||
pub mod platform;
|
|
||||||
pub mod systems;
|
pub mod systems;
|
||||||
pub mod texture;
|
pub mod texture;
|
||||||
|
|||||||
18
src/main.rs
18
src/main.rs
@@ -1,19 +1,29 @@
|
|||||||
// Note: This disables the console window on Windows. We manually re-attach to the parent terminal or process later on.
|
// Note: This disables the console window on Windows. We manually re-attach to the parent terminal or process later on.
|
||||||
#![windows_subsystem = "windows"]
|
#![windows_subsystem = "windows"]
|
||||||
|
#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
|
||||||
|
#![cfg_attr(coverage_nightly, coverage(off))]
|
||||||
|
|
||||||
use crate::{app::App, constants::LOOP_TIME};
|
use crate::{app::App, constants::LOOP_TIME};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
|
// These modules are excluded from coverage.
|
||||||
|
#[cfg_attr(coverage_nightly, coverage(off))]
|
||||||
mod app;
|
mod app;
|
||||||
mod asset;
|
#[cfg_attr(coverage_nightly, coverage(off))]
|
||||||
mod audio;
|
mod audio;
|
||||||
mod constants;
|
#[cfg_attr(coverage_nightly, coverage(off))]
|
||||||
|
|
||||||
mod error;
|
mod error;
|
||||||
|
#[cfg_attr(coverage_nightly, coverage(off))]
|
||||||
mod events;
|
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 game;
|
||||||
mod map;
|
mod map;
|
||||||
mod platform;
|
|
||||||
mod systems;
|
mod systems;
|
||||||
mod texture;
|
mod texture;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE};
|
|||||||
use crate::map::direction::Direction;
|
use crate::map::direction::Direction;
|
||||||
use crate::map::graph::{Graph, Node, TraversalFlags};
|
use crate::map::graph::{Graph, Node, TraversalFlags};
|
||||||
use crate::map::parser::MapTileParser;
|
use crate::map::parser::MapTileParser;
|
||||||
use crate::systems::movement::NodeId;
|
use crate::systems::{NodeId, Position};
|
||||||
use bevy_ecs::resource::Resource;
|
use bevy_ecs::resource::Resource;
|
||||||
use glam::{I8Vec2, IVec2, Vec2};
|
use glam::{I8Vec2, IVec2, Vec2};
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
@@ -25,6 +25,8 @@ pub struct NodePositions {
|
|||||||
pub inky: NodeId,
|
pub inky: NodeId,
|
||||||
/// Clyde starts in the center of the ghost house
|
/// Clyde starts in the center of the ghost house
|
||||||
pub clyde: NodeId,
|
pub clyde: NodeId,
|
||||||
|
/// Fruit spawn location directly below the ghost house
|
||||||
|
pub fruit_spawn: Position,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Complete maze representation combining visual layout with navigation pathfinding.
|
/// Complete maze representation combining visual layout with navigation pathfinding.
|
||||||
@@ -56,11 +58,17 @@ impl Map {
|
|||||||
/// This function will panic if the board layout contains unknown characters or if
|
/// This function will panic if the board layout contains unknown characters or if
|
||||||
/// the house door is not defined by exactly two '=' characters.
|
/// the house door is not defined by exactly two '=' characters.
|
||||||
pub fn new(raw_board: [&str; BOARD_CELL_SIZE.y as usize]) -> GameResult<Map> {
|
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 parsed_map = MapTileParser::parse_board(raw_board)?;
|
||||||
|
|
||||||
let map = parsed_map.tiles;
|
let map = parsed_map.tiles;
|
||||||
let house_door = parsed_map.house_door;
|
let house_door = parsed_map.house_door;
|
||||||
let tunnel_ends = parsed_map.tunnel_ends;
|
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 graph = Graph::new();
|
||||||
let mut grid_to_node = HashMap::new();
|
let mut grid_to_node = HashMap::new();
|
||||||
@@ -148,17 +156,39 @@ impl Map {
|
|||||||
let (house_entrance_node_id, left_center_node_id, center_center_node_id, right_center_node_id) =
|
let (house_entrance_node_id, left_center_node_id, center_center_node_id, right_center_node_id) =
|
||||||
Self::build_house(&mut graph, &grid_to_node, &house_door)?;
|
Self::build_house(&mut graph, &grid_to_node, &house_door)?;
|
||||||
|
|
||||||
|
// Find fruit spawn location (directly below ghost house)
|
||||||
|
let left_node_position = I8Vec2::new(13, 17);
|
||||||
|
let left_node_id = grid_to_node.get(&left_node_position).unwrap();
|
||||||
|
let right_node_position = I8Vec2::new(14, 17);
|
||||||
|
let right_node_id = grid_to_node.get(&right_node_position).unwrap();
|
||||||
|
|
||||||
|
let distance = graph
|
||||||
|
.get_node(*right_node_id)
|
||||||
|
.unwrap()
|
||||||
|
.position
|
||||||
|
.distance(graph.get_node(*left_node_id).unwrap().position);
|
||||||
|
|
||||||
|
// interpolate between the two nodes
|
||||||
|
let fruit_spawn_position: Position = Position::Moving {
|
||||||
|
from: *left_node_id,
|
||||||
|
to: *right_node_id,
|
||||||
|
remaining_distance: distance / 2.0,
|
||||||
|
};
|
||||||
|
|
||||||
let start_positions = NodePositions {
|
let start_positions = NodePositions {
|
||||||
pacman: grid_to_node[&start_pos],
|
pacman: grid_to_node[&start_pos],
|
||||||
blinky: house_entrance_node_id,
|
blinky: house_entrance_node_id,
|
||||||
pinky: left_center_node_id,
|
pinky: left_center_node_id,
|
||||||
inky: right_center_node_id,
|
inky: right_center_node_id,
|
||||||
clyde: center_center_node_id,
|
clyde: center_center_node_id,
|
||||||
|
fruit_spawn: fruit_spawn_position,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build tunnel connections
|
// Build tunnel connections
|
||||||
|
debug!("Building tunnel connections");
|
||||||
Self::build_tunnels(&mut graph, &grid_to_node, &tunnel_ends)?;
|
Self::build_tunnels(&mut graph, &grid_to_node, &tunnel_ends)?;
|
||||||
|
|
||||||
|
debug!(node_count = graph.nodes().count(), "Map construction completed successfully");
|
||||||
Ok(Map {
|
Ok(Map {
|
||||||
graph,
|
graph,
|
||||||
grid_to_node,
|
grid_to_node,
|
||||||
@@ -359,12 +389,7 @@ impl Map {
|
|||||||
+ IVec2::from(Direction::Left.as_ivec2()).as_vec2() * (CELL_SIZE as f32 * 2.0),
|
+ IVec2::from(Direction::Left.as_ivec2()).as_vec2() * (CELL_SIZE as f32 * 2.0),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.map_err(|e| {
|
.expect("Failed to connect left tunnel entrance to left tunnel hidden node")
|
||||||
MapError::InvalidConfig(format!(
|
|
||||||
"Failed to connect left tunnel entrance to left tunnel hidden node: {}",
|
|
||||||
e
|
|
||||||
))
|
|
||||||
})?
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create the right tunnel nodes
|
// Create the right tunnel nodes
|
||||||
@@ -384,12 +409,7 @@ impl Map {
|
|||||||
+ IVec2::from(Direction::Right.as_ivec2()).as_vec2() * (CELL_SIZE as f32 * 2.0),
|
+ IVec2::from(Direction::Right.as_ivec2()).as_vec2() * (CELL_SIZE as f32 * 2.0),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.map_err(|e| {
|
.expect("Failed to connect right tunnel entrance to right tunnel hidden node")
|
||||||
MapError::InvalidConfig(format!(
|
|
||||||
"Failed to connect right tunnel entrance to right tunnel hidden node: {}",
|
|
||||||
e
|
|
||||||
))
|
|
||||||
})?
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Connect the left tunnel hidden node to the right tunnel hidden node
|
// Connect the left tunnel hidden node to the right tunnel hidden node
|
||||||
@@ -401,12 +421,7 @@ impl Map {
|
|||||||
Some(0.0),
|
Some(0.0),
|
||||||
Direction::Left,
|
Direction::Left,
|
||||||
)
|
)
|
||||||
.map_err(|e| {
|
.expect("Failed to connect left tunnel hidden node to right tunnel hidden node");
|
||||||
MapError::InvalidConfig(format!(
|
|
||||||
"Failed to connect left tunnel hidden node to right tunnel hidden node: {}",
|
|
||||||
e
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use glam::Vec2;
|
use glam::Vec2;
|
||||||
|
|
||||||
use crate::systems::movement::NodeId;
|
use crate::systems::NodeId;
|
||||||
|
|
||||||
use super::direction::Direction;
|
use super::direction::Direction;
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,7 @@ pub fn init_console() -> Result<(), PlatformError> {
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
use crate::platform::tracing_buffer::setup_switchable_subscriber;
|
use crate::platform::tracing_buffer::setup_switchable_subscriber;
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info, trace};
|
||||||
use windows::Win32::System::Console::GetConsoleWindow;
|
use windows::Win32::System::Console::GetConsoleWindow;
|
||||||
|
|
||||||
// Setup buffered tracing subscriber that will buffer logs until console is ready
|
// 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");
|
debug!("Already have a console window");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
} else {
|
} else {
|
||||||
debug!("No existing console window found");
|
trace!("No existing console window found");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(file_type) = is_output_setup()? {
|
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 {
|
} else {
|
||||||
debug!("No existing output detected");
|
trace!("No existing output detected");
|
||||||
|
|
||||||
// Try to attach to parent console for direct cargo run
|
// Try to attach to parent console for direct cargo run
|
||||||
attach_to_parent_console()?;
|
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
|
// 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() {
|
if let Err(error) = switchable_writer.switch_to_direct_mode() {
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
@@ -59,12 +59,13 @@ pub fn init_console() -> Result<(), PlatformError> {
|
|||||||
|
|
||||||
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
|
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
|
||||||
match asset {
|
match asset {
|
||||||
Asset::Wav1 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/1.ogg"))),
|
Asset::Waka(0) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/1.ogg"))),
|
||||||
Asset::Wav2 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/2.ogg"))),
|
Asset::Waka(1) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/2.ogg"))),
|
||||||
Asset::Wav3 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/3.ogg"))),
|
Asset::Waka(2) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/3.ogg"))),
|
||||||
Asset::Wav4 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/4.ogg"))),
|
Asset::Waka(3..=u8::MAX) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/4.ogg"))),
|
||||||
Asset::AtlasImage => Ok(Cow::Borrowed(include_bytes!("../../assets/game/atlas.png"))),
|
Asset::AtlasImage => Ok(Cow::Borrowed(include_bytes!("../../assets/game/atlas.png"))),
|
||||||
Asset::Font => Ok(Cow::Borrowed(include_bytes!("../../assets/game/TerminalVector.ttf"))),
|
Asset::Font => Ok(Cow::Borrowed(include_bytes!("../../assets/game/TerminalVector.ttf"))),
|
||||||
|
Asset::DeathSound => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/death.ogg"))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +79,7 @@ pub fn rng() -> ThreadRng {
|
|||||||
/// Windows-only
|
/// Windows-only
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn is_output_setup() -> Result<Option<&'static str>, PlatformError> {
|
fn is_output_setup() -> Result<Option<&'static str>, PlatformError> {
|
||||||
use tracing::{debug, warn};
|
use tracing::{trace, warn};
|
||||||
|
|
||||||
use windows::Win32::Storage::FileSystem::{
|
use windows::Win32::Storage::FileSystem::{
|
||||||
GetFileType, FILE_TYPE_CHAR, FILE_TYPE_DISK, FILE_TYPE_PIPE, FILE_TYPE_REMOTE, FILE_TYPE_UNKNOWN,
|
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
|
// If it's anything recognizable and valid, assume that a parent process has setup an output stream
|
||||||
Ok(well_known.then_some(file_type))
|
Ok(well_known.then_some(file_type))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use crate::asset::Asset;
|
use crate::asset::Asset;
|
||||||
use crate::error::{AssetError, PlatformError};
|
use crate::error::{AssetError, PlatformError};
|
||||||
|
use crate::formatter::CustomFormatter;
|
||||||
use rand::{rngs::SmallRng, SeedableRng};
|
use rand::{rngs::SmallRng, SeedableRng};
|
||||||
use sdl2::rwops::RWops;
|
use sdl2::rwops::RWops;
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
@@ -10,11 +11,8 @@ use std::io::{self, Read, Write};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
// Emscripten FFI functions
|
// Emscripten FFI functions
|
||||||
#[allow(dead_code)]
|
|
||||||
extern "C" {
|
extern "C" {
|
||||||
fn emscripten_sleep(ms: u32);
|
fn emscripten_sleep(ms: u32);
|
||||||
fn emscripten_get_element_css_size(target: *const u8, width: *mut f64, height: *mut f64) -> i32;
|
|
||||||
// Standard C functions that Emscripten redirects to console
|
|
||||||
fn printf(format: *const u8, ...) -> i32;
|
fn printf(format: *const u8, ...) -> i32;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,8 +31,7 @@ pub fn init_console() -> Result<(), PlatformError> {
|
|||||||
fmt::layer()
|
fmt::layer()
|
||||||
.with_writer(|| EmscriptenConsoleWriter)
|
.with_writer(|| EmscriptenConsoleWriter)
|
||||||
.with_ansi(false)
|
.with_ansi(false)
|
||||||
.without_time()
|
.event_format(CustomFormatter),
|
||||||
.with_target(false),
|
|
||||||
)
|
)
|
||||||
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug")));
|
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug")));
|
||||||
|
|
||||||
@@ -65,20 +62,6 @@ impl Write for EmscriptenConsoleWriter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn get_canvas_size() -> Option<(u32, u32)> {
|
|
||||||
let mut width = 0.0;
|
|
||||||
let mut height = 0.0;
|
|
||||||
|
|
||||||
unsafe {
|
|
||||||
emscripten_get_element_css_size(c"canvas".as_ptr().cast(), &mut width, &mut height);
|
|
||||||
if width == 0.0 || height == 0.0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some((width as u32, height as u32))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
|
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
|
||||||
let path = format!("assets/game/{}", asset.path());
|
let path = format!("assets/game/{}", asset.path());
|
||||||
let mut rwops = RWops::from_file(&path, "rb").map_err(|_| AssetError::NotFound(asset.path().to_string()))?;
|
let mut rwops = RWops::from_file(&path, "rb").map_err(|_| AssetError::NotFound(asset.path().to_string()))?;
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
//! Platform abstraction layer for cross-platform functionality.
|
//! Platform abstraction layer for cross-platform functionality.
|
||||||
|
|
||||||
#[cfg(not(target_os = "emscripten"))]
|
|
||||||
pub mod buffered_writer;
|
|
||||||
#[cfg(not(target_os = "emscripten"))]
|
#[cfg(not(target_os = "emscripten"))]
|
||||||
mod desktop;
|
mod desktop;
|
||||||
#[cfg(not(target_os = "emscripten"))]
|
#[cfg(not(target_os = "emscripten"))]
|
||||||
pub mod tracing_buffer;
|
|
||||||
#[cfg(not(target_os = "emscripten"))]
|
|
||||||
pub use desktop::*;
|
pub use desktop::*;
|
||||||
|
|
||||||
|
/// Tracing buffer is only used on Windows.
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub mod tracing_buffer;
|
||||||
|
|
||||||
#[cfg(target_os = "emscripten")]
|
#[cfg(target_os = "emscripten")]
|
||||||
pub use emscripten::*;
|
pub use emscripten::*;
|
||||||
#[cfg(target_os = "emscripten")]
|
#[cfg(target_os = "emscripten")]
|
||||||
|
|||||||
@@ -1,14 +1,65 @@
|
|||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
//! Buffered tracing setup for handling logs before console attachment.
|
//! 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;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::sync::Arc;
|
||||||
use tracing::{debug, Level};
|
use tracing::{debug, Level};
|
||||||
use tracing_error::ErrorLayer;
|
use tracing_error::ErrorLayer;
|
||||||
use tracing_subscriber::fmt::MakeWriter;
|
use tracing_subscriber::fmt::MakeWriter;
|
||||||
use tracing_subscriber::layer::SubscriberExt;
|
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.
|
/// A writer that can switch between buffering and direct output.
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
pub struct SwitchableWriter {
|
pub struct SwitchableWriter {
|
||||||
@@ -90,6 +141,7 @@ pub fn setup_switchable_subscriber() -> SwitchableWriter {
|
|||||||
let _subscriber = tracing_subscriber::fmt()
|
let _subscriber = tracing_subscriber::fmt()
|
||||||
.with_ansi(cfg!(not(target_os = "emscripten")))
|
.with_ansi(cfg!(not(target_os = "emscripten")))
|
||||||
.with_max_level(Level::DEBUG)
|
.with_max_level(Level::DEBUG)
|
||||||
|
.event_format(CustomFormatter)
|
||||||
.with_writer(make_writer)
|
.with_writer(make_writer)
|
||||||
.finish()
|
.finish()
|
||||||
.with(ErrorLayer::default());
|
.with(ErrorLayer::default());
|
||||||
|
|||||||
66
src/systems/animation/blinking.rs
Normal file
66
src/systems/animation/blinking.rs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
use bevy_ecs::{
|
||||||
|
component::Component,
|
||||||
|
query::{Has, With},
|
||||||
|
system::{Query, Res},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::systems::{DeltaTime, Frozen, Renderable, Visibility};
|
||||||
|
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct Blinking {
|
||||||
|
pub tick_timer: u32,
|
||||||
|
pub interval_ticks: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Blinking {
|
||||||
|
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 ticks and toggling visibility when the specified interval is reached.
|
||||||
|
/// Uses integer arithmetic for deterministic behavior.
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
pub fn blinking_system(time: Res<DeltaTime>, mut query: Query<(&mut Blinking, &mut Visibility, Has<Frozen>), With<Renderable>>) {
|
||||||
|
for (mut blinking, mut visibility, frozen) in query.iter_mut() {
|
||||||
|
// If the entity is frozen, blinking is disabled and the entity is made visible
|
||||||
|
if frozen {
|
||||||
|
visibility.show();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increase the timer by the delta ticks
|
||||||
|
blinking.tick_timer += time.ticks;
|
||||||
|
|
||||||
|
// Handle zero interval case (immediate toggling)
|
||||||
|
if blinking.interval_ticks == 0 {
|
||||||
|
if time.ticks > 0 {
|
||||||
|
visibility.toggle();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate how many complete intervals have passed
|
||||||
|
let complete_intervals = blinking.tick_timer / blinking.interval_ticks;
|
||||||
|
|
||||||
|
// 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 visibility for each complete interval
|
||||||
|
// Since toggling twice is a no-op, we only need to toggle if the count is odd
|
||||||
|
if complete_intervals % 2 == 1 {
|
||||||
|
visibility.toggle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/systems/animation/directional.rs
Normal file
104
src/systems/animation/directional.rs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
use bevy_ecs::{
|
||||||
|
component::Component,
|
||||||
|
query::{Has, Or, With, Without},
|
||||||
|
system::{Query, Res},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
systems::{DeltaTime, Dying, Frozen, LinearAnimation, Looping, Position, Renderable, Velocity},
|
||||||
|
texture::animated::DirectionalTiles,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Directional animation component with shared timing across all directions
|
||||||
|
#[derive(Component, Clone)]
|
||||||
|
pub struct DirectionalAnimation {
|
||||||
|
pub moving_tiles: DirectionalTiles,
|
||||||
|
pub stopped_tiles: DirectionalTiles,
|
||||||
|
pub current_frame: usize,
|
||||||
|
pub time_bank: u16,
|
||||||
|
pub frame_duration: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DirectionalAnimation {
|
||||||
|
/// Creates a new directional animation with the given tiles and frame duration
|
||||||
|
pub fn new(moving_tiles: DirectionalTiles, stopped_tiles: DirectionalTiles, frame_duration: u16) -> Self {
|
||||||
|
Self {
|
||||||
|
moving_tiles,
|
||||||
|
stopped_tiles,
|
||||||
|
current_frame: 0,
|
||||||
|
time_bank: 0,
|
||||||
|
frame_duration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates directional animated entities with synchronized timing across directions.
|
||||||
|
///
|
||||||
|
/// This runs before the render system to update sprites based on current direction and movement state.
|
||||||
|
/// All directions share the same frame timing to ensure perfect synchronization.
|
||||||
|
pub fn directional_render_system(
|
||||||
|
dt: Res<DeltaTime>,
|
||||||
|
mut query: Query<(&Position, &Velocity, &mut DirectionalAnimation, &mut Renderable, Has<Frozen>)>,
|
||||||
|
) {
|
||||||
|
let ticks = (dt.seconds * 60.0).round() as u16; // Convert from seconds to ticks at 60 ticks/sec
|
||||||
|
|
||||||
|
for (position, velocity, mut anim, mut renderable, frozen) in query.iter_mut() {
|
||||||
|
let stopped = matches!(position, Position::Stopped { .. });
|
||||||
|
|
||||||
|
// Only tick animation when moving to preserve stopped frame
|
||||||
|
if !stopped && !frozen {
|
||||||
|
// Tick shared animation state
|
||||||
|
anim.time_bank += ticks;
|
||||||
|
while anim.time_bank >= anim.frame_duration {
|
||||||
|
anim.time_bank -= anim.frame_duration;
|
||||||
|
anim.current_frame += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get tiles for current direction and movement state
|
||||||
|
let tiles = if stopped {
|
||||||
|
anim.stopped_tiles.get(velocity.direction)
|
||||||
|
} else {
|
||||||
|
anim.moving_tiles.get(velocity.direction)
|
||||||
|
};
|
||||||
|
|
||||||
|
if !tiles.is_empty() {
|
||||||
|
let new_tile = tiles.get_tile(anim.current_frame);
|
||||||
|
if renderable.sprite != new_tile {
|
||||||
|
renderable.sprite = new_tile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System that updates `Renderable` sprites for entities with `LinearAnimation`.
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
pub fn linear_render_system(
|
||||||
|
dt: Res<DeltaTime>,
|
||||||
|
mut query: Query<(&mut LinearAnimation, &mut Renderable, Has<Looping>), Or<(Without<Frozen>, With<Dying>)>>,
|
||||||
|
) {
|
||||||
|
for (mut anim, mut renderable, looping) in query.iter_mut() {
|
||||||
|
if anim.finished {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
anim.time_bank += dt.ticks as u16;
|
||||||
|
let frames_to_advance = (anim.time_bank / anim.frame_duration) as usize;
|
||||||
|
|
||||||
|
if frames_to_advance == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_frames = anim.tiles.len();
|
||||||
|
|
||||||
|
if !looping && anim.current_frame + frames_to_advance >= total_frames {
|
||||||
|
anim.finished = true;
|
||||||
|
anim.current_frame = total_frames - 1;
|
||||||
|
} else {
|
||||||
|
anim.current_frame += frames_to_advance;
|
||||||
|
}
|
||||||
|
|
||||||
|
anim.time_bank %= anim.frame_duration;
|
||||||
|
renderable.sprite = anim.tiles.get_tile(anim.current_frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/systems/animation/linear.rs
Normal file
30
src/systems/animation/linear.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
use crate::texture::animated::TileSequence;
|
||||||
|
use bevy_ecs::component::Component;
|
||||||
|
use bevy_ecs::resource::Resource;
|
||||||
|
|
||||||
|
/// Tag component to mark animations that should loop when they reach the end
|
||||||
|
#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Looping;
|
||||||
|
|
||||||
|
/// Linear animation component for non-directional animations (frightened ghosts)
|
||||||
|
#[derive(Component, Resource, Clone)]
|
||||||
|
pub struct LinearAnimation {
|
||||||
|
pub tiles: TileSequence,
|
||||||
|
pub current_frame: usize,
|
||||||
|
pub time_bank: u16,
|
||||||
|
pub frame_duration: u16,
|
||||||
|
pub finished: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinearAnimation {
|
||||||
|
/// Creates a new linear animation with the given tiles and frame duration
|
||||||
|
pub fn new(tiles: TileSequence, frame_duration: u16) -> Self {
|
||||||
|
Self {
|
||||||
|
tiles,
|
||||||
|
current_frame: 0,
|
||||||
|
time_bank: 0,
|
||||||
|
frame_duration,
|
||||||
|
finished: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/systems/animation/mod.rs
Normal file
7
src/systems/animation/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
mod blinking;
|
||||||
|
mod directional;
|
||||||
|
mod linear;
|
||||||
|
|
||||||
|
pub use self::blinking::*;
|
||||||
|
pub use self::directional::*;
|
||||||
|
pub use self::linear::*;
|
||||||
@@ -9,6 +9,7 @@ use bevy_ecs::{
|
|||||||
resource::Resource,
|
resource::Resource,
|
||||||
system::{NonSendMut, ResMut},
|
system::{NonSendMut, ResMut},
|
||||||
};
|
};
|
||||||
|
use tracing::{debug, trace};
|
||||||
|
|
||||||
use crate::{audio::Audio, error::GameError};
|
use crate::{audio::Audio, error::GameError};
|
||||||
|
|
||||||
@@ -26,6 +27,10 @@ pub struct AudioState {
|
|||||||
pub enum AudioEvent {
|
pub enum AudioEvent {
|
||||||
/// Play the "eat" sound when Pac-Man consumes a pellet
|
/// Play the "eat" sound when Pac-Man consumes a pellet
|
||||||
PlayEat,
|
PlayEat,
|
||||||
|
/// Play the death sound
|
||||||
|
PlayDeath,
|
||||||
|
/// Stop all currently playing sounds
|
||||||
|
StopAll,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Non-send resource wrapper for SDL2 audio system
|
/// Non-send resource wrapper for SDL2 audio system
|
||||||
@@ -45,6 +50,7 @@ pub fn audio_system(
|
|||||||
) {
|
) {
|
||||||
// Set mute state if it has changed
|
// Set mute state if it has changed
|
||||||
if audio.0.is_muted() != audio_state.muted {
|
if audio.0.is_muted() != audio_state.muted {
|
||||||
|
debug!(muted = audio_state.muted, "Audio mute state changed");
|
||||||
audio.0.set_mute(audio_state.muted);
|
audio.0.set_mute(audio_state.muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,10 +59,37 @@ pub fn audio_system(
|
|||||||
match event {
|
match event {
|
||||||
AudioEvent::PlayEat => {
|
AudioEvent::PlayEat => {
|
||||||
if !audio.0.is_disabled() && !audio_state.muted {
|
if !audio.0.is_disabled() && !audio_state.muted {
|
||||||
|
trace!(sound_index = audio_state.sound_index, "Playing eat sound");
|
||||||
audio.0.eat();
|
audio.0.eat();
|
||||||
// Update the sound index for cycling through sounds
|
// Update the sound index for cycling through sounds
|
||||||
audio_state.sound_index = (audio_state.sound_index + 1) % 4;
|
audio_state.sound_index = (audio_state.sound_index + 1) % 4;
|
||||||
// 4 eat sounds available
|
// 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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
use bevy_ecs::{
|
|
||||||
component::Component,
|
|
||||||
entity::Entity,
|
|
||||||
query::{Has, With},
|
|
||||||
system::{Commands, Query, Res},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::systems::{
|
|
||||||
components::{DeltaTime, Renderable},
|
|
||||||
Frozen, Hidden,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Component, Debug)]
|
|
||||||
pub struct Blinking {
|
|
||||||
pub timer: f32,
|
|
||||||
pub interval: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Blinking {
|
|
||||||
pub fn new(interval: f32) -> Self {
|
|
||||||
Self { timer: 0.0, interval }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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.
|
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
pub fn blinking_system(
|
|
||||||
mut commands: Commands,
|
|
||||||
time: Res<DeltaTime>,
|
|
||||||
mut query: Query<(Entity, &mut Blinking, Has<Hidden>, Has<Frozen>), With<Renderable>>,
|
|
||||||
) {
|
|
||||||
for (entity, mut blinking, hidden, frozen) in query.iter_mut() {
|
|
||||||
// If the entity is frozen, blinking is disabled and the entity is unhidden (if it was hidden)
|
|
||||||
if frozen {
|
|
||||||
if hidden {
|
|
||||||
commands.entity(entity).remove::<Hidden>();
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Increase the timer by the delta time
|
|
||||||
blinking.timer += time.0;
|
|
||||||
|
|
||||||
// If the timer is less than the interval, there's nothing to do yet
|
|
||||||
if blinking.timer < blinking.interval {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subtract the interval (allows for the timer to retain partial interval progress)
|
|
||||||
blinking.timer -= blinking.interval;
|
|
||||||
|
|
||||||
// Toggle the Hidden component
|
|
||||||
if hidden {
|
|
||||||
commands.entity(entity).remove::<Hidden>();
|
|
||||||
} else {
|
|
||||||
commands.entity(entity).insert(Hidden);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,25 @@
|
|||||||
use bevy_ecs::component::Component;
|
use bevy_ecs::{
|
||||||
use bevy_ecs::entity::Entity;
|
component::Component,
|
||||||
use bevy_ecs::event::{EventReader, EventWriter};
|
entity::Entity,
|
||||||
use bevy_ecs::query::With;
|
event::EventWriter,
|
||||||
use bevy_ecs::system::{Query, Res, ResMut};
|
observer::Trigger,
|
||||||
|
query::With,
|
||||||
|
system::{Commands, Query, Res, ResMut},
|
||||||
|
};
|
||||||
|
use tracing::{debug, trace, warn};
|
||||||
|
|
||||||
use crate::error::GameError;
|
use crate::{
|
||||||
use crate::events::GameEvent;
|
constants,
|
||||||
use crate::map::builder::Map;
|
systems::{movement::Position, AudioEvent, DyingSequence, FruitSprites, GameStage, Ghost, ScoreResource, SpawnTrigger},
|
||||||
use crate::systems::movement::Position;
|
};
|
||||||
use crate::systems::{AudioEvent, Ghost, GhostState, PlayerControlled, ScoreResource};
|
use crate::{error::GameError, systems::GhostState};
|
||||||
|
use crate::{
|
||||||
|
events::{CollisionTrigger, StageTransition},
|
||||||
|
systems::PelletCount,
|
||||||
|
};
|
||||||
|
use crate::{map::builder::Map, systems::EntityType};
|
||||||
|
|
||||||
|
/// A component for defining the collision area of an entity.
|
||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
pub struct Collider {
|
pub struct Collider {
|
||||||
pub size: f32,
|
pub size: f32,
|
||||||
@@ -52,22 +62,23 @@ pub fn check_collision(
|
|||||||
Ok(collider1.collides_with(collider2.size, distance))
|
Ok(collider1.collides_with(collider2.size, distance))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detects overlapping entities and generates collision events for gameplay systems.
|
/// Detects overlapping entities and triggers collision observers immediately.
|
||||||
///
|
///
|
||||||
/// Performs distance-based collision detection between Pac-Man and collectible items
|
/// Performs distance-based collision detection between Pac-Man and collectible items
|
||||||
/// using each entity's position and collision radius. When entities overlap, emits
|
/// using each entity's position and collision radius. When entities overlap, triggers
|
||||||
/// a `GameEvent::Collision` for the item system to handle scoring and removal.
|
/// collision observers for immediate handling without race conditions.
|
||||||
/// Collision detection accounts for both entities being in motion and supports
|
/// Collision detection accounts for both entities being in motion and supports
|
||||||
/// circular collision boundaries for accurate gameplay feel.
|
/// circular collision boundaries for accurate gameplay feel.
|
||||||
///
|
///
|
||||||
/// Also detects collisions between Pac-Man and ghosts for gameplay mechanics like
|
/// Also detects collisions between Pac-Man and ghosts for gameplay mechanics like
|
||||||
/// power pellet effects, ghost eating, and player death.
|
/// power pellet effects, ghost eating, and player death.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn collision_system(
|
pub fn collision_system(
|
||||||
map: Res<Map>,
|
map: Res<Map>,
|
||||||
pacman_query: Query<(Entity, &Position, &Collider), With<PacmanCollider>>,
|
pacman_query: Query<(Entity, &Position, &Collider), With<PacmanCollider>>,
|
||||||
item_query: Query<(Entity, &Position, &Collider), With<ItemCollider>>,
|
item_query: Query<(Entity, &Position, &Collider), With<ItemCollider>>,
|
||||||
ghost_query: Query<(Entity, &Position, &Collider), With<GhostCollider>>,
|
ghost_query: Query<(Entity, &Position, &Collider, &Ghost, &GhostState), With<GhostCollider>>,
|
||||||
mut events: EventWriter<GameEvent>,
|
mut commands: Commands,
|
||||||
mut errors: EventWriter<GameError>,
|
mut errors: EventWriter<GameError>,
|
||||||
) {
|
) {
|
||||||
// Check PACMAN × ITEM collisions
|
// Check PACMAN × ITEM collisions
|
||||||
@@ -76,7 +87,8 @@ pub fn collision_system(
|
|||||||
match check_collision(pacman_pos, pacman_collider, item_pos, item_collider, &map) {
|
match check_collision(pacman_pos, pacman_collider, item_pos, item_collider, &map) {
|
||||||
Ok(colliding) => {
|
Ok(colliding) => {
|
||||||
if colliding {
|
if colliding {
|
||||||
events.write(GameEvent::Collision(pacman_entity, item_entity));
|
trace!("Item collision detected");
|
||||||
|
commands.trigger(CollisionTrigger::ItemCollision { item: item_entity });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -89,12 +101,19 @@ pub fn collision_system(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check PACMAN × GHOST collisions
|
// Check PACMAN × GHOST collisions
|
||||||
for (ghost_entity, ghost_pos, ghost_collider) in ghost_query.iter() {
|
for (ghost_entity, ghost_pos, ghost_collider, ghost, ghost_state) in ghost_query.iter() {
|
||||||
match check_collision(pacman_pos, pacman_collider, ghost_pos, ghost_collider, &map) {
|
match check_collision(pacman_pos, pacman_collider, ghost_pos, ghost_collider, &map) {
|
||||||
Ok(colliding) => {
|
Ok(colliding) => {
|
||||||
if colliding {
|
if !colliding || matches!(*ghost_state, GhostState::Eyes) {
|
||||||
events.write(GameEvent::Collision(pacman_entity, ghost_entity));
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trace!(ghost = ?ghost, "Ghost collision detected");
|
||||||
|
commands.trigger(CollisionTrigger::GhostCollision {
|
||||||
|
pacman: pacman_entity,
|
||||||
|
ghost: ghost_entity,
|
||||||
|
ghost_type: *ghost,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
errors.write(GameError::InvalidState(format!(
|
errors.write(GameError::InvalidState(format!(
|
||||||
@@ -107,44 +126,129 @@ pub fn collision_system(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ghost_collision_system(
|
/// Observer for handling ghost collisions immediately when they occur
|
||||||
mut collision_events: EventReader<GameEvent>,
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn ghost_collision_observer(
|
||||||
|
trigger: Trigger<CollisionTrigger>,
|
||||||
|
mut stage_events: EventWriter<StageTransition>,
|
||||||
mut score: ResMut<ScoreResource>,
|
mut score: ResMut<ScoreResource>,
|
||||||
pacman_query: Query<(), With<PlayerControlled>>,
|
mut game_state: ResMut<GameStage>,
|
||||||
ghost_query: Query<(Entity, &Ghost), With<GhostCollider>>,
|
|
||||||
mut ghost_state_query: Query<&mut GhostState>,
|
mut ghost_state_query: Query<&mut GhostState>,
|
||||||
mut events: EventWriter<AudioEvent>,
|
mut events: EventWriter<AudioEvent>,
|
||||||
) {
|
) {
|
||||||
for event in collision_events.read() {
|
if let CollisionTrigger::GhostCollision {
|
||||||
if let GameEvent::Collision(entity1, entity2) = event {
|
pacman: _pacman,
|
||||||
// Check if one is Pacman and the other is a ghost
|
ghost,
|
||||||
let (_pacman_entity, ghost_entity) = if pacman_query.get(*entity1).is_ok() && ghost_query.get(*entity2).is_ok() {
|
ghost_type,
|
||||||
(*entity1, *entity2)
|
} = *trigger
|
||||||
} else if pacman_query.get(*entity2).is_ok() && ghost_query.get(*entity1).is_ok() {
|
{
|
||||||
(*entity2, *entity1)
|
// Check if Pac-Man is already dying
|
||||||
} else {
|
if matches!(*game_state, GameStage::PlayerDying(_)) {
|
||||||
continue;
|
return;
|
||||||
};
|
}
|
||||||
|
|
||||||
// Check if the ghost is frightened
|
// 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) {
|
||||||
if let Ok(mut ghost_state) = ghost_state_query.get_mut(ghost_ent) {
|
|
||||||
// Check if ghost is in frightened state
|
// Check if ghost is in frightened state
|
||||||
if matches!(*ghost_state, GhostState::Frightened { .. }) {
|
if matches!(*ghost_state, GhostState::Frightened { .. }) {
|
||||||
// Pac-Man eats the ghost
|
// Pac-Man eats the ghost
|
||||||
// Add score (200 points per ghost eaten)
|
// Add score (200 points per ghost eaten)
|
||||||
|
debug!(ghost = ?ghost_type, score_added = 200, new_score = score.0 + 200, "Pacman ate frightened ghost");
|
||||||
score.0 += 200;
|
score.0 += 200;
|
||||||
|
|
||||||
// Set ghost state to Eyes
|
|
||||||
*ghost_state = GhostState::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,
|
||||||
|
ghost_type,
|
||||||
|
});
|
||||||
|
|
||||||
// Play eat sound
|
// Play eat sound
|
||||||
events.write(AudioEvent::PlayEat);
|
events.write(AudioEvent::PlayEat);
|
||||||
|
} else if matches!(*ghost_state, GhostState::Normal) {
|
||||||
|
// Pac-Man dies
|
||||||
|
warn!(ghost = ?ghost_type, "Pacman hit by normal ghost, player dies");
|
||||||
|
*game_state = GameStage::PlayerDying(DyingSequence::Frozen { remaining_ticks: 60 });
|
||||||
|
events.write(AudioEvent::StopAll);
|
||||||
} else {
|
} else {
|
||||||
// Pac-Man dies (this would need a death system)
|
trace!(ghost_state = ?*ghost_state, "Ghost collision ignored due to state");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Observer for handling item collisions immediately when they occur
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn item_collision_observer(
|
||||||
|
trigger: Trigger<CollisionTrigger>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut score: ResMut<ScoreResource>,
|
||||||
|
mut pellet_count: ResMut<PelletCount>,
|
||||||
|
item_query: Query<(Entity, &EntityType, &Position), With<ItemCollider>>,
|
||||||
|
mut ghost_query: Query<&mut GhostState, With<GhostCollider>>,
|
||||||
|
mut fruit_sprites: ResMut<FruitSprites>,
|
||||||
|
mut events: EventWriter<AudioEvent>,
|
||||||
|
) {
|
||||||
|
if let CollisionTrigger::ItemCollision { item } = *trigger {
|
||||||
|
// Get the item type and update score
|
||||||
|
if let Ok((item_ent, entity_type, position)) = item_query.get(item) {
|
||||||
|
if let Some(score_value) = entity_type.score_value() {
|
||||||
|
trace!(item_entity = ?item_ent, item_type = ?entity_type, score_value, new_score = score.0 + score_value, "Item collected by player");
|
||||||
|
score.0 += score_value;
|
||||||
|
|
||||||
|
// Remove the collected item
|
||||||
|
commands.entity(item_ent).despawn();
|
||||||
|
|
||||||
|
// Track pellet consumption for fruit spawning
|
||||||
|
if *entity_type == EntityType::Pellet {
|
||||||
|
pellet_count.0 += 1;
|
||||||
|
trace!(pellet_count = pellet_count.0, "Pellet consumed");
|
||||||
|
|
||||||
|
// Check if we should spawn a fruit
|
||||||
|
if pellet_count.0 == 5 || pellet_count.0 == 170 {
|
||||||
|
debug!(pellet_count = pellet_count.0, "Fruit spawn milestone reached");
|
||||||
|
commands.trigger(SpawnTrigger::Fruit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger bonus points effect if a fruit is collected
|
||||||
|
if let EntityType::Fruit(fruit) = *entity_type {
|
||||||
|
fruit_sprites.0.push(fruit);
|
||||||
|
|
||||||
|
commands.trigger(SpawnTrigger::Bonus {
|
||||||
|
position: *position,
|
||||||
|
value: entity_type.score_value().unwrap(),
|
||||||
|
ttl: 60 * 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger audio if appropriate
|
||||||
|
if entity_type.is_collectible() {
|
||||||
|
events.write(AudioEvent::PlayEat);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make non-eaten ghosts frightened when power pellet is collected
|
||||||
|
if matches!(*entity_type, EntityType::PowerPellet) {
|
||||||
|
debug!(
|
||||||
|
duration_ticks = constants::animation::GHOST_FRIGHTENED_TICKS,
|
||||||
|
"Power pellet collected, frightening ghosts"
|
||||||
|
);
|
||||||
|
for mut ghost_state in ghost_query.iter_mut() {
|
||||||
|
if matches!(*ghost_state, GhostState::Normal) {
|
||||||
|
*ghost_state = GhostState::new_frightened(
|
||||||
|
constants::animation::GHOST_FRIGHTENED_TICKS,
|
||||||
|
constants::animation::GHOST_FRIGHTENED_FLASH_START_TICKS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debug!(
|
||||||
|
frightened_count = ghost_query.iter().count(),
|
||||||
|
"Ghosts set to frightened state"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
43
src/systems/common/bundles.rs
Normal file
43
src/systems/common/bundles.rs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
use bevy_ecs::bundle::Bundle;
|
||||||
|
|
||||||
|
use crate::systems::{
|
||||||
|
BufferedDirection, Collider, DirectionalAnimation, EntityType, Ghost, GhostCollider, GhostState, ItemCollider,
|
||||||
|
LastAnimationState, MovementModifiers, PacmanCollider, PlayerControlled, Position, Renderable, Velocity,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Bundle)]
|
||||||
|
pub struct PlayerBundle {
|
||||||
|
pub player: PlayerControlled,
|
||||||
|
pub position: Position,
|
||||||
|
pub velocity: Velocity,
|
||||||
|
pub buffered_direction: BufferedDirection,
|
||||||
|
pub sprite: Renderable,
|
||||||
|
pub directional_animation: DirectionalAnimation,
|
||||||
|
pub entity_type: EntityType,
|
||||||
|
pub collider: Collider,
|
||||||
|
pub movement_modifiers: MovementModifiers,
|
||||||
|
pub pacman_collider: PacmanCollider,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Bundle)]
|
||||||
|
pub struct ItemBundle {
|
||||||
|
pub position: Position,
|
||||||
|
pub sprite: Renderable,
|
||||||
|
pub entity_type: EntityType,
|
||||||
|
pub collider: Collider,
|
||||||
|
pub item_collider: ItemCollider,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Bundle)]
|
||||||
|
pub struct GhostBundle {
|
||||||
|
pub ghost: Ghost,
|
||||||
|
pub position: Position,
|
||||||
|
pub velocity: Velocity,
|
||||||
|
pub sprite: Renderable,
|
||||||
|
pub directional_animation: DirectionalAnimation,
|
||||||
|
pub entity_type: EntityType,
|
||||||
|
pub collider: Collider,
|
||||||
|
pub ghost_collider: GhostCollider,
|
||||||
|
pub ghost_state: GhostState,
|
||||||
|
pub last_animation_state: LastAnimationState,
|
||||||
|
}
|
||||||
106
src/systems/common/components.rs
Normal file
106
src/systems/common/components.rs
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
use bevy_ecs::{component::Component, resource::Resource};
|
||||||
|
|
||||||
|
use crate::{map::graph::TraversalFlags, systems::FruitType};
|
||||||
|
|
||||||
|
/// A tag component denoting the type of entity.
|
||||||
|
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum EntityType {
|
||||||
|
Player,
|
||||||
|
Ghost,
|
||||||
|
Pellet,
|
||||||
|
PowerPellet,
|
||||||
|
Fruit(FruitType),
|
||||||
|
Effect,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EntityType {
|
||||||
|
/// Returns the traversal flags for this entity type.
|
||||||
|
pub fn traversal_flags(&self) -> TraversalFlags {
|
||||||
|
match self {
|
||||||
|
EntityType::Player => TraversalFlags::PACMAN,
|
||||||
|
EntityType::Ghost => TraversalFlags::GHOST,
|
||||||
|
_ => TraversalFlags::empty(), // Static entities don't traverse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn score_value(&self) -> Option<u32> {
|
||||||
|
match self {
|
||||||
|
EntityType::Pellet => Some(10),
|
||||||
|
EntityType::PowerPellet => Some(50),
|
||||||
|
EntityType::Fruit(fruit_type) => Some(fruit_type.score_value()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_collectible(&self) -> bool {
|
||||||
|
matches!(self, EntityType::Pellet | EntityType::PowerPellet | EntityType::Fruit(_))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Resource)]
|
||||||
|
pub struct GlobalState {
|
||||||
|
pub exit: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Resource)]
|
||||||
|
pub struct ScoreResource(pub u32);
|
||||||
|
|
||||||
|
#[derive(Resource)]
|
||||||
|
pub struct DeltaTime {
|
||||||
|
/// Floating-point delta time in seconds
|
||||||
|
pub seconds: f32,
|
||||||
|
/// Integer tick delta (usually 1, but can be different for testing)
|
||||||
|
pub ticks: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl DeltaTime {
|
||||||
|
/// Creates a new DeltaTime from a floating-point delta time in seconds
|
||||||
|
///
|
||||||
|
/// While this method exists as a helper, it does not mean that seconds and ticks are interchangeable.
|
||||||
|
pub fn from_seconds(seconds: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
seconds,
|
||||||
|
ticks: (seconds * 60.0).round() as u32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new DeltaTime from an integer tick delta
|
||||||
|
///
|
||||||
|
/// While this method exists as a helper, it does not mean that seconds and ticks are interchangeable.
|
||||||
|
pub fn from_ticks(ticks: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
seconds: ticks as f32 / 60.0,
|
||||||
|
ticks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Movement modifiers that can affect Pac-Man's speed or handling.
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
pub struct MovementModifiers {
|
||||||
|
/// Multiplier applied to base speed (e.g., tunnels)
|
||||||
|
pub speed_multiplier: f32,
|
||||||
|
/// True when currently in a tunnel slowdown region
|
||||||
|
pub tunnel_slowdown_active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MovementModifiers {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
speed_multiplier: 1.0,
|
||||||
|
tunnel_slowdown_active: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tag component for entities that should be frozen during startup
|
||||||
|
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Frozen;
|
||||||
|
|
||||||
|
/// Component for HUD life sprite entities.
|
||||||
|
/// Each life sprite entity has an index indicating its position from left to right (0, 1, 2, etc.).
|
||||||
|
/// This mostly functions as a tag component for sprites.
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
pub struct PlayerLife {
|
||||||
|
pub index: u32,
|
||||||
|
}
|
||||||
5
src/systems/common/mod.rs
Normal file
5
src/systems/common/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod bundles;
|
||||||
|
pub mod components;
|
||||||
|
|
||||||
|
pub use self::bundles::*;
|
||||||
|
pub use self::components::*;
|
||||||
@@ -1,364 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use bevy_ecs::{bundle::Bundle, component::Component, resource::Resource};
|
|
||||||
use bitflags::bitflags;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
map::graph::TraversalFlags,
|
|
||||||
systems::{
|
|
||||||
movement::{BufferedDirection, Position, Velocity},
|
|
||||||
Collider, GhostCollider, ItemCollider, PacmanCollider,
|
|
||||||
},
|
|
||||||
texture::{
|
|
||||||
animated::{DirectionalTiles, TileSequence},
|
|
||||||
sprite::AtlasTile,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/// A tag component for entities that are controlled by the player.
|
|
||||||
#[derive(Default, Component)]
|
|
||||||
pub struct PlayerControlled;
|
|
||||||
|
|
||||||
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
||||||
pub enum Ghost {
|
|
||||||
Blinky,
|
|
||||||
Pinky,
|
|
||||||
Inky,
|
|
||||||
Clyde,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Ghost {
|
|
||||||
/// Returns the ghost type name for atlas lookups.
|
|
||||||
pub fn as_str(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Ghost::Blinky => "blinky",
|
|
||||||
Ghost::Pinky => "pinky",
|
|
||||||
Ghost::Inky => "inky",
|
|
||||||
Ghost::Clyde => "clyde",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the base movement speed for this ghost type.
|
|
||||||
pub fn base_speed(self) -> f32 {
|
|
||||||
match self {
|
|
||||||
Ghost::Blinky => 1.0,
|
|
||||||
Ghost::Pinky => 0.95,
|
|
||||||
Ghost::Inky => 0.9,
|
|
||||||
Ghost::Clyde => 0.85,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the ghost's color for debug rendering.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn debug_color(&self) -> sdl2::pixels::Color {
|
|
||||||
match self {
|
|
||||||
Ghost::Blinky => sdl2::pixels::Color::RGB(255, 0, 0), // Red
|
|
||||||
Ghost::Pinky => sdl2::pixels::Color::RGB(255, 182, 255), // Pink
|
|
||||||
Ghost::Inky => sdl2::pixels::Color::RGB(0, 255, 255), // Cyan
|
|
||||||
Ghost::Clyde => sdl2::pixels::Color::RGB(255, 182, 85), // Orange
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A tag component denoting the type of entity.
|
|
||||||
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
||||||
pub enum EntityType {
|
|
||||||
Player,
|
|
||||||
Ghost,
|
|
||||||
Pellet,
|
|
||||||
PowerPellet,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EntityType {
|
|
||||||
/// Returns the traversal flags for this entity type.
|
|
||||||
pub fn traversal_flags(&self) -> TraversalFlags {
|
|
||||||
match self {
|
|
||||||
EntityType::Player => TraversalFlags::PACMAN,
|
|
||||||
EntityType::Ghost => TraversalFlags::GHOST,
|
|
||||||
_ => TraversalFlags::empty(), // Static entities don't traverse
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn score_value(&self) -> Option<u32> {
|
|
||||||
match self {
|
|
||||||
EntityType::Pellet => Some(10),
|
|
||||||
EntityType::PowerPellet => Some(50),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_collectible(&self) -> bool {
|
|
||||||
matches!(self, EntityType::Pellet | EntityType::PowerPellet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A component for entities that have a sprite, with a layer for ordering.
|
|
||||||
///
|
|
||||||
/// This is intended to be modified by other entities allowing animation.
|
|
||||||
#[derive(Component)]
|
|
||||||
pub struct Renderable {
|
|
||||||
pub sprite: AtlasTile,
|
|
||||||
pub layer: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Directional animation component with shared timing across all directions
|
|
||||||
#[derive(Component, Clone, Copy)]
|
|
||||||
pub struct DirectionalAnimation {
|
|
||||||
pub moving_tiles: DirectionalTiles,
|
|
||||||
pub stopped_tiles: DirectionalTiles,
|
|
||||||
pub current_frame: usize,
|
|
||||||
pub time_bank: u16,
|
|
||||||
pub frame_duration: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DirectionalAnimation {
|
|
||||||
/// Creates a new directional animation with the given tiles and frame duration
|
|
||||||
pub fn new(moving_tiles: DirectionalTiles, stopped_tiles: DirectionalTiles, frame_duration: u16) -> Self {
|
|
||||||
Self {
|
|
||||||
moving_tiles,
|
|
||||||
stopped_tiles,
|
|
||||||
current_frame: 0,
|
|
||||||
time_bank: 0,
|
|
||||||
frame_duration,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Linear animation component for non-directional animations (frightened ghosts)
|
|
||||||
#[derive(Component, Clone, Copy)]
|
|
||||||
pub struct LinearAnimation {
|
|
||||||
pub tiles: TileSequence,
|
|
||||||
pub current_frame: usize,
|
|
||||||
pub time_bank: u16,
|
|
||||||
pub frame_duration: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LinearAnimation {
|
|
||||||
/// Creates a new linear animation with the given tiles and frame duration
|
|
||||||
pub fn new(tiles: TileSequence, frame_duration: u16) -> Self {
|
|
||||||
Self {
|
|
||||||
tiles,
|
|
||||||
current_frame: 0,
|
|
||||||
time_bank: 0,
|
|
||||||
frame_duration,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bitflags! {
|
|
||||||
#[derive(Component, Default, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
|
||||||
pub struct CollisionLayer: u8 {
|
|
||||||
const PACMAN = 1 << 0;
|
|
||||||
const GHOST = 1 << 1;
|
|
||||||
const ITEM = 1 << 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Resource)]
|
|
||||||
pub struct GlobalState {
|
|
||||||
pub exit: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Resource)]
|
|
||||||
pub struct ScoreResource(pub u32);
|
|
||||||
|
|
||||||
#[derive(Resource)]
|
|
||||||
pub struct DeltaTime(pub f32);
|
|
||||||
|
|
||||||
/// Movement modifiers that can affect Pac-Man's speed or handling.
|
|
||||||
#[derive(Component, Debug, Clone, Copy)]
|
|
||||||
pub struct MovementModifiers {
|
|
||||||
/// Multiplier applied to base speed (e.g., tunnels)
|
|
||||||
pub speed_multiplier: f32,
|
|
||||||
/// True when currently in a tunnel slowdown region
|
|
||||||
pub tunnel_slowdown_active: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for MovementModifiers {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
speed_multiplier: 1.0,
|
|
||||||
tunnel_slowdown_active: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tag component for entities that should be frozen during startup
|
|
||||||
#[derive(Component, Debug, Clone, Copy)]
|
|
||||||
pub struct Frozen;
|
|
||||||
|
|
||||||
/// Tag component for eaten ghosts
|
|
||||||
#[derive(Component, Debug, Clone, Copy)]
|
|
||||||
pub struct Eaten;
|
|
||||||
|
|
||||||
#[derive(Component, Debug, Clone, Copy)]
|
|
||||||
pub enum GhostState {
|
|
||||||
/// Normal ghost behavior - chasing Pac-Man
|
|
||||||
Normal,
|
|
||||||
/// Frightened state after power pellet - ghost can be eaten
|
|
||||||
Frightened {
|
|
||||||
remaining_ticks: u32,
|
|
||||||
flash: bool,
|
|
||||||
remaining_flash_ticks: u32,
|
|
||||||
},
|
|
||||||
/// Eyes state - ghost has been eaten and is returning to ghost house
|
|
||||||
Eyes,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Component to track the last animation state for efficient change detection
|
|
||||||
#[derive(Component, Debug, Clone, Copy, PartialEq)]
|
|
||||||
pub struct LastAnimationState(pub GhostAnimation);
|
|
||||||
|
|
||||||
impl GhostState {
|
|
||||||
/// Creates a new frightened state with the specified duration
|
|
||||||
pub fn new_frightened(total_ticks: u32, flash_start_ticks: u32) -> Self {
|
|
||||||
Self::Frightened {
|
|
||||||
remaining_ticks: total_ticks,
|
|
||||||
flash: false,
|
|
||||||
remaining_flash_ticks: flash_start_ticks, // Time until flashing starts
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ticks the ghost state, returning true if the state changed.
|
|
||||||
pub fn tick(&mut self) -> bool {
|
|
||||||
if let GhostState::Frightened {
|
|
||||||
remaining_ticks,
|
|
||||||
flash,
|
|
||||||
remaining_flash_ticks,
|
|
||||||
} = self
|
|
||||||
{
|
|
||||||
// Transition out of frightened state
|
|
||||||
if *remaining_ticks == 0 {
|
|
||||||
*self = GhostState::Normal;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
*remaining_ticks -= 1;
|
|
||||||
|
|
||||||
if *remaining_flash_ticks > 0 {
|
|
||||||
*remaining_flash_ticks = remaining_flash_ticks.saturating_sub(1);
|
|
||||||
if *remaining_flash_ticks == 0 {
|
|
||||||
*flash = true;
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the appropriate animation state for this ghost state
|
|
||||||
pub fn animation_state(&self) -> GhostAnimation {
|
|
||||||
match self {
|
|
||||||
GhostState::Normal => GhostAnimation::Normal,
|
|
||||||
GhostState::Eyes => GhostAnimation::Eyes,
|
|
||||||
GhostState::Frightened { flash: false, .. } => GhostAnimation::Frightened { flash: false },
|
|
||||||
GhostState::Frightened { flash: true, .. } => GhostAnimation::Frightened { flash: true },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enumeration of different ghost animation states.
|
|
||||||
/// Note that this is used in micromap which has a fixed size based on the number of variants,
|
|
||||||
/// so extending this should be done with caution, and will require updating the micromap's capacity.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
||||||
pub enum GhostAnimation {
|
|
||||||
/// Normal ghost appearance with directional movement animations
|
|
||||||
Normal,
|
|
||||||
/// Blue ghost appearance when vulnerable (power pellet active)
|
|
||||||
Frightened { flash: bool },
|
|
||||||
/// Eyes-only animation when ghost has been consumed by Pac-Man (Eaten state)
|
|
||||||
Eyes,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Global resource containing pre-loaded animation sets for all ghost types.
|
|
||||||
///
|
|
||||||
/// This resource is initialized once during game startup and provides O(1) access
|
|
||||||
/// to animation sets for each ghost type. The animation system uses this resource
|
|
||||||
/// to efficiently switch between different ghost states without runtime asset loading.
|
|
||||||
///
|
|
||||||
/// The HashMap is keyed by `Ghost` enum variants (Blinky, Pinky, Inky, Clyde) and
|
|
||||||
/// contains the normal directional animation for each ghost type.
|
|
||||||
#[derive(Resource)]
|
|
||||||
pub struct GhostAnimations {
|
|
||||||
pub normal: HashMap<Ghost, DirectionalAnimation>,
|
|
||||||
pub eyes: DirectionalAnimation,
|
|
||||||
pub frightened: LinearAnimation,
|
|
||||||
pub frightened_flashing: LinearAnimation,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GhostAnimations {
|
|
||||||
/// Creates a new GhostAnimations resource with the provided data.
|
|
||||||
pub fn new(
|
|
||||||
normal: HashMap<Ghost, DirectionalAnimation>,
|
|
||||||
eyes: DirectionalAnimation,
|
|
||||||
frightened: LinearAnimation,
|
|
||||||
frightened_flashing: LinearAnimation,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
normal,
|
|
||||||
eyes,
|
|
||||||
frightened,
|
|
||||||
frightened_flashing,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets the normal directional animation for the specified ghost type.
|
|
||||||
pub fn get_normal(&self, ghost_type: &Ghost) -> Option<&DirectionalAnimation> {
|
|
||||||
self.normal.get(ghost_type)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets the eyes animation (shared across all ghosts).
|
|
||||||
pub fn eyes(&self) -> &DirectionalAnimation {
|
|
||||||
&self.eyes
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets the frightened animations (shared across all ghosts).
|
|
||||||
pub fn frightened(&self, flash: bool) -> &LinearAnimation {
|
|
||||||
if flash {
|
|
||||||
&self.frightened_flashing
|
|
||||||
} else {
|
|
||||||
&self.frightened
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Bundle)]
|
|
||||||
pub struct PlayerBundle {
|
|
||||||
pub player: PlayerControlled,
|
|
||||||
pub position: Position,
|
|
||||||
pub velocity: Velocity,
|
|
||||||
pub buffered_direction: BufferedDirection,
|
|
||||||
pub sprite: Renderable,
|
|
||||||
pub directional_animation: DirectionalAnimation,
|
|
||||||
pub entity_type: EntityType,
|
|
||||||
pub collider: Collider,
|
|
||||||
pub movement_modifiers: MovementModifiers,
|
|
||||||
pub pacman_collider: PacmanCollider,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Bundle)]
|
|
||||||
pub struct ItemBundle {
|
|
||||||
pub position: Position,
|
|
||||||
pub sprite: Renderable,
|
|
||||||
pub entity_type: EntityType,
|
|
||||||
pub collider: Collider,
|
|
||||||
pub item_collider: ItemCollider,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Bundle)]
|
|
||||||
pub struct GhostBundle {
|
|
||||||
pub ghost: Ghost,
|
|
||||||
pub position: Position,
|
|
||||||
pub velocity: Velocity,
|
|
||||||
pub sprite: Renderable,
|
|
||||||
pub directional_animation: DirectionalAnimation,
|
|
||||||
pub entity_type: EntityType,
|
|
||||||
pub collider: Collider,
|
|
||||||
pub ghost_collider: GhostCollider,
|
|
||||||
pub ghost_state: GhostState,
|
|
||||||
pub last_animation_state: LastAnimationState,
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
//! Debug rendering system
|
//! Debug rendering system
|
||||||
use std::cmp::Ordering;
|
|
||||||
|
|
||||||
use crate::constants::{self, BOARD_PIXEL_OFFSET};
|
use crate::constants::{self, BOARD_PIXEL_OFFSET};
|
||||||
use crate::map::builder::Map;
|
use crate::map::builder::Map;
|
||||||
use crate::systems::{Collider, CursorPosition, NodeId, Position, SystemTimings};
|
use crate::systems::{Collider, CursorPosition, NodeId, Position, SystemTimings};
|
||||||
@@ -13,6 +11,7 @@ use sdl2::rect::{Point, Rect};
|
|||||||
use sdl2::render::{Canvas, Texture};
|
use sdl2::render::{Canvas, Texture};
|
||||||
use sdl2::video::Window;
|
use sdl2::video::Window;
|
||||||
use smallvec::SmallVec;
|
use smallvec::SmallVec;
|
||||||
|
use std::cmp::Ordering;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
@@ -149,14 +148,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
|
/// 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(
|
fn render_timing_display(
|
||||||
canvas: &mut Canvas<Window>,
|
canvas: &mut Canvas<Window>,
|
||||||
timings: &SystemTimings,
|
timings: &SystemTimings,
|
||||||
|
current_tick: u64,
|
||||||
text_renderer: &TtfRenderer,
|
text_renderer: &TtfRenderer,
|
||||||
atlas: &mut TtfAtlas,
|
atlas: &mut TtfAtlas,
|
||||||
) {
|
) {
|
||||||
// Format timing information using the formatting module
|
// 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 line_height = text_renderer.text_height(atlas) as i32 + 2; // Add 2px line spacing
|
||||||
let padding = 10;
|
let padding = 10;
|
||||||
|
|
||||||
@@ -202,12 +203,14 @@ fn render_timing_display(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
#[cfg_attr(coverage_nightly, coverage(off))]
|
||||||
pub fn debug_render_system(
|
pub fn debug_render_system(
|
||||||
canvas: &mut Canvas<Window>,
|
canvas: &mut Canvas<Window>,
|
||||||
ttf_atlas: &mut TtfAtlasResource,
|
ttf_atlas: &mut TtfAtlasResource,
|
||||||
batched_lines: &Res<BatchedLinesResource>,
|
batched_lines: &Res<BatchedLinesResource>,
|
||||||
debug_state: &Res<DebugState>,
|
debug_state: &Res<DebugState>,
|
||||||
timings: &Res<SystemTimings>,
|
timings: &Res<SystemTimings>,
|
||||||
|
timing: &Res<crate::systems::profiling::Timing>,
|
||||||
map: &Res<Map>,
|
map: &Res<Map>,
|
||||||
colliders: &Query<(&Collider, &Position)>,
|
colliders: &Query<(&Collider, &Position)>,
|
||||||
cursor: &Res<CursorPosition>,
|
cursor: &Res<CursorPosition>,
|
||||||
@@ -329,5 +332,8 @@ pub fn debug_render_system(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Render timing information in the top-left corner
|
// 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::platform;
|
use crate::platform;
|
||||||
use crate::systems::components::{DirectionalAnimation, Frozen, GhostAnimation, GhostState, LastAnimationState, LinearAnimation};
|
use crate::systems::{DirectionalAnimation, Frozen, LinearAnimation, Looping};
|
||||||
use crate::{
|
use crate::{
|
||||||
map::{
|
map::{
|
||||||
builder::Map,
|
builder::Map,
|
||||||
@@ -7,17 +9,186 @@ use crate::{
|
|||||||
graph::{Edge, TraversalFlags},
|
graph::{Edge, TraversalFlags},
|
||||||
},
|
},
|
||||||
systems::{
|
systems::{
|
||||||
components::{DeltaTime, Ghost},
|
components::DeltaTime,
|
||||||
movement::{Position, Velocity},
|
movement::{Position, Velocity},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
use bevy_ecs::component::Component;
|
||||||
|
use bevy_ecs::resource::Resource;
|
||||||
|
use tracing::{debug, trace, warn};
|
||||||
|
|
||||||
use crate::systems::GhostAnimations;
|
|
||||||
use bevy_ecs::query::Without;
|
use bevy_ecs::query::Without;
|
||||||
use bevy_ecs::system::{Commands, Query, Res};
|
use bevy_ecs::system::{Commands, Query, Res};
|
||||||
use rand::seq::IndexedRandom;
|
use rand::seq::IndexedRandom;
|
||||||
use smallvec::SmallVec;
|
use smallvec::SmallVec;
|
||||||
|
|
||||||
|
/// Tag component for Pac-Man during his death animation.
|
||||||
|
/// This is mainly because the Frozen tag would stop both movement and animation, while the Dying tag can signal that the animation should continue despite being frozen.
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
pub struct Dying;
|
||||||
|
|
||||||
|
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum Ghost {
|
||||||
|
Blinky,
|
||||||
|
Pinky,
|
||||||
|
Inky,
|
||||||
|
Clyde,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ghost {
|
||||||
|
/// Returns the ghost type name for atlas lookups.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Ghost::Blinky => "blinky",
|
||||||
|
Ghost::Pinky => "pinky",
|
||||||
|
Ghost::Inky => "inky",
|
||||||
|
Ghost::Clyde => "clyde",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the base movement speed for this ghost type.
|
||||||
|
pub fn base_speed(self) -> f32 {
|
||||||
|
match self {
|
||||||
|
Ghost::Blinky => 1.0,
|
||||||
|
Ghost::Pinky => 0.95,
|
||||||
|
Ghost::Inky => 0.9,
|
||||||
|
Ghost::Clyde => 0.85,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum GhostState {
|
||||||
|
/// Normal ghost behavior - chasing Pac-Man
|
||||||
|
Normal,
|
||||||
|
/// Frightened state after power pellet - ghost can be eaten
|
||||||
|
Frightened {
|
||||||
|
remaining_ticks: u32,
|
||||||
|
flash: bool,
|
||||||
|
remaining_flash_ticks: u32,
|
||||||
|
},
|
||||||
|
/// Eyes state - ghost has been eaten and is returning to ghost house
|
||||||
|
Eyes,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GhostState {
|
||||||
|
/// Creates a new frightened state with the specified duration
|
||||||
|
pub fn new_frightened(total_ticks: u32, flash_start_ticks: u32) -> Self {
|
||||||
|
Self::Frightened {
|
||||||
|
remaining_ticks: total_ticks,
|
||||||
|
flash: false,
|
||||||
|
remaining_flash_ticks: flash_start_ticks, // Time until flashing starts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ticks the ghost state, returning true if the state changed.
|
||||||
|
pub fn tick(&mut self) -> bool {
|
||||||
|
if let GhostState::Frightened {
|
||||||
|
remaining_ticks,
|
||||||
|
flash,
|
||||||
|
remaining_flash_ticks,
|
||||||
|
} = self
|
||||||
|
{
|
||||||
|
// Transition out of frightened state
|
||||||
|
if *remaining_ticks == 0 {
|
||||||
|
*self = GhostState::Normal;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
*remaining_ticks -= 1;
|
||||||
|
|
||||||
|
if *remaining_flash_ticks > 0 {
|
||||||
|
*remaining_flash_ticks = remaining_flash_ticks.saturating_sub(1);
|
||||||
|
if *remaining_flash_ticks == 0 {
|
||||||
|
*flash = true;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the appropriate animation state for this ghost state
|
||||||
|
pub fn animation_state(&self) -> GhostAnimation {
|
||||||
|
match self {
|
||||||
|
GhostState::Normal => GhostAnimation::Normal,
|
||||||
|
GhostState::Eyes => GhostAnimation::Eyes,
|
||||||
|
GhostState::Frightened { flash: false, .. } => GhostAnimation::Frightened { flash: false },
|
||||||
|
GhostState::Frightened { flash: true, .. } => GhostAnimation::Frightened { flash: true },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enumeration of different ghost animation states.
|
||||||
|
/// Note that this is used in micromap which has a fixed size based on the number of variants,
|
||||||
|
/// so extending this should be done with caution, and will require updating the micromap's capacity.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum GhostAnimation {
|
||||||
|
/// Normal ghost appearance with directional movement animations
|
||||||
|
Normal,
|
||||||
|
/// Blue ghost appearance when vulnerable (power pellet active)
|
||||||
|
Frightened { flash: bool },
|
||||||
|
/// Eyes-only animation when ghost has been consumed by Pac-Man (Eaten state)
|
||||||
|
Eyes,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Global resource containing pre-loaded animation sets for all ghost types.
|
||||||
|
///
|
||||||
|
/// This resource is initialized once during game startup and provides O(1) access
|
||||||
|
/// to animation sets for each ghost type. The animation system uses this resource
|
||||||
|
/// to efficiently switch between different ghost states without runtime asset loading.
|
||||||
|
///
|
||||||
|
/// The HashMap is keyed by `Ghost` enum variants (Blinky, Pinky, Inky, Clyde) and
|
||||||
|
/// contains the normal directional animation for each ghost type.
|
||||||
|
#[derive(Resource)]
|
||||||
|
pub struct GhostAnimations {
|
||||||
|
pub normal: HashMap<Ghost, DirectionalAnimation>,
|
||||||
|
pub eyes: DirectionalAnimation,
|
||||||
|
pub frightened: LinearAnimation,
|
||||||
|
pub frightened_flashing: LinearAnimation,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GhostAnimations {
|
||||||
|
/// Creates a new GhostAnimations resource with the provided data.
|
||||||
|
pub fn new(
|
||||||
|
normal: HashMap<Ghost, DirectionalAnimation>,
|
||||||
|
eyes: DirectionalAnimation,
|
||||||
|
frightened: LinearAnimation,
|
||||||
|
frightened_flashing: LinearAnimation,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
normal,
|
||||||
|
eyes,
|
||||||
|
frightened,
|
||||||
|
frightened_flashing,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the normal directional animation for the specified ghost type.
|
||||||
|
pub fn get_normal(&self, ghost_type: &Ghost) -> Option<&DirectionalAnimation> {
|
||||||
|
self.normal.get(ghost_type)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the eyes animation (shared across all ghosts).
|
||||||
|
pub fn eyes(&self) -> &DirectionalAnimation {
|
||||||
|
&self.eyes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the frightened animations (shared across all ghosts).
|
||||||
|
pub fn frightened(&self, flash: bool) -> &LinearAnimation {
|
||||||
|
if flash {
|
||||||
|
&self.frightened_flashing
|
||||||
|
} else {
|
||||||
|
&self.frightened
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Autonomous ghost AI system implementing randomized movement with backtracking avoidance.
|
/// Autonomous ghost AI system implementing randomized movement with backtracking avoidance.
|
||||||
pub fn ghost_movement_system(
|
pub fn ghost_movement_system(
|
||||||
map: Res<Map>,
|
map: Res<Map>,
|
||||||
@@ -25,7 +196,7 @@ pub fn ghost_movement_system(
|
|||||||
mut ghosts: Query<(&Ghost, &mut Velocity, &mut Position), Without<Frozen>>,
|
mut ghosts: Query<(&Ghost, &mut Velocity, &mut Position), Without<Frozen>>,
|
||||||
) {
|
) {
|
||||||
for (_ghost, mut velocity, mut position) in ghosts.iter_mut() {
|
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 {
|
loop {
|
||||||
match *position {
|
match *position {
|
||||||
Position::Stopped { node: current_node } => {
|
Position::Stopped { node: current_node } => {
|
||||||
@@ -43,8 +214,10 @@ pub fn ghost_movement_system(
|
|||||||
|
|
||||||
let new_edge: Edge = if non_opposite_options.is_empty() {
|
let new_edge: Edge = if non_opposite_options.is_empty() {
|
||||||
if let Some(edge) = intersection.get(opposite) {
|
if let Some(edge) = intersection.get(opposite) {
|
||||||
|
trace!(node = current_node, ghost = ?_ghost, direction = ?opposite, "Ghost forced to reverse direction");
|
||||||
edge
|
edge
|
||||||
} else {
|
} else {
|
||||||
|
warn!(node = current_node, ghost = ?_ghost, "Ghost stuck with no available directions");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -81,7 +254,7 @@ pub fn ghost_movement_system(
|
|||||||
pub fn eaten_ghost_system(
|
pub fn eaten_ghost_system(
|
||||||
map: Res<Map>,
|
map: Res<Map>,
|
||||||
delta_time: Res<DeltaTime>,
|
delta_time: Res<DeltaTime>,
|
||||||
mut eaten_ghosts: Query<(&Ghost, &mut Position, &mut Velocity, &mut GhostState)>,
|
mut eaten_ghosts: Query<(&Ghost, &mut Position, &mut Velocity, &mut GhostState), Without<Frozen>>,
|
||||||
) {
|
) {
|
||||||
for (ghost_type, mut position, mut velocity, mut ghost_state) in eaten_ghosts.iter_mut() {
|
for (ghost_type, mut position, mut velocity, mut ghost_state) in eaten_ghosts.iter_mut() {
|
||||||
// Only process ghosts that are in Eyes state
|
// Only process ghosts that are in Eyes state
|
||||||
@@ -111,11 +284,12 @@ pub fn eaten_ghost_system(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Position::Moving { to, .. } => {
|
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) {
|
if let Some(_overflow) = position.tick(distance) {
|
||||||
// Reached target node, check if we're at ghost house center
|
// Reached target node, check if we're at ghost house center
|
||||||
if to == ghost_house_center {
|
if to == ghost_house_center {
|
||||||
// Respawn the ghost - set state back to normal
|
// Respawn the ghost - set state back to normal
|
||||||
|
debug!(ghost = ?ghost_type, "Eaten ghost reached ghost house, respawning as normal");
|
||||||
*ghost_state = GhostState::Normal;
|
*ghost_state = GhostState::Normal;
|
||||||
// Reset to stopped at ghost house center
|
// Reset to stopped at ghost house center
|
||||||
*position = Position::Stopped {
|
*position = Position::Stopped {
|
||||||
@@ -179,6 +353,10 @@ fn find_direction_to_target(
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Component to track the last animation state for efficient change detection
|
||||||
|
#[derive(Component, Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct LastAnimationState(pub GhostAnimation);
|
||||||
|
|
||||||
/// Unified system that manages ghost state transitions and animations with component swapping
|
/// Unified system that manages ghost state transitions and animations with component swapping
|
||||||
pub fn ghost_state_system(
|
pub fn ghost_state_system(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
@@ -192,24 +370,30 @@ pub fn ghost_state_system(
|
|||||||
// Only update animation if the animation state actually changed
|
// Only update animation if the animation state actually changed
|
||||||
let current_animation_state = ghost_state.animation_state();
|
let current_animation_state = ghost_state.animation_state();
|
||||||
if last_animation_state.0 != current_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 {
|
match current_animation_state {
|
||||||
GhostAnimation::Frightened { flash } => {
|
GhostAnimation::Frightened { flash } => {
|
||||||
// Remove DirectionalAnimation, add LinearAnimation
|
// Remove DirectionalAnimation, add LinearAnimation with Looping component
|
||||||
commands
|
commands
|
||||||
.entity(entity)
|
.entity(entity)
|
||||||
.remove::<DirectionalAnimation>()
|
.remove::<DirectionalAnimation>()
|
||||||
.insert(*animations.frightened(flash));
|
.insert(animations.frightened(flash).clone())
|
||||||
|
.insert(Looping);
|
||||||
}
|
}
|
||||||
GhostAnimation::Normal => {
|
GhostAnimation::Normal => {
|
||||||
// Remove LinearAnimation, add DirectionalAnimation
|
// Remove LinearAnimation and Looping, add DirectionalAnimation
|
||||||
commands
|
commands
|
||||||
.entity(entity)
|
.entity(entity)
|
||||||
.remove::<LinearAnimation>()
|
.remove::<(LinearAnimation, Looping)>()
|
||||||
.insert(*animations.get_normal(ghost_type).unwrap());
|
.insert(animations.get_normal(ghost_type).unwrap().clone());
|
||||||
}
|
}
|
||||||
GhostAnimation::Eyes => {
|
GhostAnimation::Eyes => {
|
||||||
// Remove LinearAnimation, add DirectionalAnimation (eyes animation)
|
// Remove LinearAnimation and Looping, add DirectionalAnimation (eyes animation)
|
||||||
commands.entity(entity).remove::<LinearAnimation>().insert(*animations.eyes());
|
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;
|
last_animation_state.0 = current_animation_state;
|
||||||
|
|||||||
79
src/systems/hud/fruits.rs
Normal file
79
src/systems/hud/fruits.rs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
use crate::systems::item::FruitType;
|
||||||
|
use crate::texture::sprites::GameSprite;
|
||||||
|
use bevy_ecs::component::Component;
|
||||||
|
use bevy_ecs::resource::Resource;
|
||||||
|
|
||||||
|
#[derive(Component)]
|
||||||
|
pub struct FruitInHud {
|
||||||
|
pub index: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
pub struct FruitSprites(pub Vec<FruitType>);
|
||||||
|
|
||||||
|
use crate::constants::{BOARD_BOTTOM_PIXEL_OFFSET, CANVAS_SIZE, CELL_SIZE};
|
||||||
|
use crate::error::GameError;
|
||||||
|
use crate::systems::{PixelPosition, Renderable};
|
||||||
|
use crate::texture::sprite::SpriteAtlas;
|
||||||
|
use bevy_ecs::entity::Entity;
|
||||||
|
use bevy_ecs::event::EventWriter;
|
||||||
|
use bevy_ecs::system::{Commands, NonSendMut, Query, Res};
|
||||||
|
use glam::Vec2;
|
||||||
|
|
||||||
|
/// Calculates the pixel position for a fruit sprite based on its index
|
||||||
|
fn calculate_fruit_sprite_position(index: u32) -> Vec2 {
|
||||||
|
let start_x = CANVAS_SIZE.x - CELL_SIZE * 2; // 2 cells from right
|
||||||
|
let start_y = CANVAS_SIZE.y - BOARD_BOTTOM_PIXEL_OFFSET.y + (CELL_SIZE / 2) + 1; // In bottom area
|
||||||
|
let sprite_spacing = CELL_SIZE + CELL_SIZE / 2; // 1.5 cells between sprites
|
||||||
|
|
||||||
|
let x = start_x - ((index as f32) * (sprite_spacing as f32 * 1.5)).round() as u32;
|
||||||
|
let y = start_y - CELL_SIZE / 2;
|
||||||
|
|
||||||
|
Vec2::new((x - CELL_SIZE) as f32, (y + CELL_SIZE) as f32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System that manages fruit sprite entities in the HUD.
|
||||||
|
/// Spawns and despawns fruit sprite entities based on changes to FruitSprites resource.
|
||||||
|
/// Displays up to 6 fruits, sorted by value.
|
||||||
|
pub fn fruit_sprite_system(
|
||||||
|
mut commands: Commands,
|
||||||
|
atlas: NonSendMut<SpriteAtlas>,
|
||||||
|
current_fruit_sprites: Query<(Entity, &FruitInHud)>,
|
||||||
|
fruit_sprites: Res<FruitSprites>,
|
||||||
|
mut errors: EventWriter<GameError>,
|
||||||
|
) {
|
||||||
|
// We only want to display the greatest 6 fruits
|
||||||
|
let fruits_to_display: Vec<_> = fruit_sprites.0.iter().rev().take(6).collect();
|
||||||
|
|
||||||
|
let mut current_sprites: Vec<_> = current_fruit_sprites.iter().collect();
|
||||||
|
current_sprites.sort_by_key(|(_, fruit)| fruit.index);
|
||||||
|
|
||||||
|
// Despawn all current sprites. We will respawn them.
|
||||||
|
// This is simpler than trying to match them up.
|
||||||
|
for (entity, _) in ¤t_sprites {
|
||||||
|
commands.entity(*entity).despawn();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i, fruit_type) in fruits_to_display.iter().enumerate() {
|
||||||
|
let fruit_sprite = match atlas.get_tile(&GameSprite::Fruit(**fruit_type).to_path()) {
|
||||||
|
Ok(sprite) => sprite,
|
||||||
|
Err(e) => {
|
||||||
|
errors.write(e.into());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let position = calculate_fruit_sprite_position(i as u32);
|
||||||
|
|
||||||
|
commands.spawn((
|
||||||
|
FruitInHud { index: i as u32 },
|
||||||
|
Renderable {
|
||||||
|
sprite: fruit_sprite,
|
||||||
|
layer: 255, // High layer to render on top
|
||||||
|
},
|
||||||
|
PixelPosition {
|
||||||
|
pixel_position: position,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/systems/hud/lives.rs
Normal file
89
src/systems/hud/lives.rs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
use std::cmp::Ordering;
|
||||||
|
|
||||||
|
use crate::constants::{BOARD_BOTTOM_PIXEL_OFFSET, CANVAS_SIZE, CELL_SIZE};
|
||||||
|
use crate::error::GameError;
|
||||||
|
use crate::map::direction::Direction;
|
||||||
|
use crate::systems::{PixelPosition, PlayerLife, PlayerLives, Renderable};
|
||||||
|
use crate::texture::sprite::SpriteAtlas;
|
||||||
|
use crate::texture::sprites::{GameSprite, PacmanSprite};
|
||||||
|
use bevy_ecs::entity::Entity;
|
||||||
|
use bevy_ecs::event::EventWriter;
|
||||||
|
use bevy_ecs::system::{Commands, NonSendMut, Query, Res};
|
||||||
|
use glam::Vec2;
|
||||||
|
|
||||||
|
/// Calculates the pixel position for a life sprite based on its index
|
||||||
|
fn calculate_life_sprite_position(index: u32) -> Vec2 {
|
||||||
|
let start_x = CELL_SIZE * 2; // 2 cells from left
|
||||||
|
let start_y = CANVAS_SIZE.y - BOARD_BOTTOM_PIXEL_OFFSET.y + (CELL_SIZE / 2) + 1; // In bottom area
|
||||||
|
let sprite_spacing = CELL_SIZE + CELL_SIZE / 2; // 1.5 cells between sprites
|
||||||
|
|
||||||
|
let x = start_x + ((index as f32) * (sprite_spacing as f32 * 1.5)).round() as u32;
|
||||||
|
let y = start_y - CELL_SIZE / 2;
|
||||||
|
|
||||||
|
Vec2::new((x + CELL_SIZE) as f32, (y + CELL_SIZE) as f32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System that manages player life sprite entities.
|
||||||
|
/// Spawns and despawns life sprite entities based on changes to PlayerLives resource.
|
||||||
|
/// Each life sprite is positioned based on its index (0, 1, 2, etc. from left to right).
|
||||||
|
pub fn player_life_sprite_system(
|
||||||
|
mut commands: Commands,
|
||||||
|
atlas: NonSendMut<SpriteAtlas>,
|
||||||
|
current_life_sprites: Query<(Entity, &PlayerLife)>,
|
||||||
|
player_lives: Res<PlayerLives>,
|
||||||
|
mut errors: EventWriter<GameError>,
|
||||||
|
) {
|
||||||
|
let displayed_lives = player_lives.0.saturating_sub(1);
|
||||||
|
|
||||||
|
// Get current life sprite entities, sorted by index
|
||||||
|
let mut current_sprites: Vec<_> = current_life_sprites.iter().collect();
|
||||||
|
current_sprites.sort_by_key(|(_, life)| life.index);
|
||||||
|
let current_count = current_sprites.len() as u8;
|
||||||
|
|
||||||
|
// Calculate the difference
|
||||||
|
let diff = (displayed_lives as i8) - (current_count as i8);
|
||||||
|
|
||||||
|
match diff.cmp(&0) {
|
||||||
|
// Ignore when the number of lives displayed is correct
|
||||||
|
Ordering::Equal => {}
|
||||||
|
// Spawn new life sprites
|
||||||
|
Ordering::Greater => {
|
||||||
|
let life_sprite = match atlas.get_tile(&GameSprite::Pacman(PacmanSprite::Moving(Direction::Left, 1)).to_path()) {
|
||||||
|
Ok(sprite) => sprite,
|
||||||
|
Err(e) => {
|
||||||
|
errors.write(e.into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for i in 0..diff {
|
||||||
|
let position = calculate_life_sprite_position(i as u32);
|
||||||
|
|
||||||
|
commands.spawn((
|
||||||
|
PlayerLife { index: i as u32 },
|
||||||
|
Renderable {
|
||||||
|
sprite: life_sprite,
|
||||||
|
layer: 255, // High layer to render on top
|
||||||
|
},
|
||||||
|
PixelPosition {
|
||||||
|
pixel_position: position,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remove excess life sprites (highest indices first)
|
||||||
|
Ordering::Less => {
|
||||||
|
let to_remove = diff.unsigned_abs();
|
||||||
|
let sprites_to_remove: Vec<_> = current_sprites
|
||||||
|
.iter()
|
||||||
|
.rev() // Start from highest index
|
||||||
|
.take(to_remove as usize)
|
||||||
|
.map(|(entity, _)| *entity)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for entity in sprites_to_remove {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/systems/hud/mod.rs
Normal file
9
src/systems/hud/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
pub mod fruits;
|
||||||
|
pub mod lives;
|
||||||
|
pub mod score;
|
||||||
|
pub mod touch;
|
||||||
|
|
||||||
|
pub use self::fruits::*;
|
||||||
|
pub use self::lives::*;
|
||||||
|
pub use self::score::*;
|
||||||
|
pub use self::touch::*;
|
||||||
86
src/systems/hud/score.rs
Normal file
86
src/systems/hud/score.rs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
use crate::constants;
|
||||||
|
use crate::error::{GameError, TextureError};
|
||||||
|
use crate::systems::{BackbufferResource, GameStage, ScoreResource, StartupSequence};
|
||||||
|
use crate::texture::sprite::SpriteAtlas;
|
||||||
|
use crate::texture::text::TextTexture;
|
||||||
|
use bevy_ecs::event::EventWriter;
|
||||||
|
use bevy_ecs::system::{NonSendMut, Res};
|
||||||
|
use sdl2::pixels::Color;
|
||||||
|
use sdl2::render::Canvas;
|
||||||
|
use sdl2::video::Window;
|
||||||
|
|
||||||
|
/// Renders the HUD (score, lives, etc.) on top of the game.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn hud_render_system(
|
||||||
|
mut backbuffer: NonSendMut<BackbufferResource>,
|
||||||
|
mut canvas: NonSendMut<&mut Canvas<Window>>,
|
||||||
|
mut atlas: NonSendMut<SpriteAtlas>,
|
||||||
|
score: Res<ScoreResource>,
|
||||||
|
stage: Res<GameStage>,
|
||||||
|
mut errors: EventWriter<GameError>,
|
||||||
|
) {
|
||||||
|
let _ = canvas.with_texture_canvas(&mut backbuffer.0, |canvas| {
|
||||||
|
let mut text_renderer = TextTexture::new(1.0);
|
||||||
|
|
||||||
|
// Render lives and high score text in white
|
||||||
|
let lives_text = "1UP HIGH SCORE ";
|
||||||
|
let lives_position = glam::UVec2::new(4 + 8 * 3, 2); // x_offset + lives_offset * 8, y_offset
|
||||||
|
|
||||||
|
if let Err(e) = text_renderer.render(canvas, &mut atlas, lives_text, lives_position) {
|
||||||
|
errors.write(TextureError::RenderFailed(format!("Failed to render lives text: {}", e)).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render score text
|
||||||
|
let score_text = format!("{:02}", score.0);
|
||||||
|
let score_offset = 7 - (score_text.len() as i32);
|
||||||
|
let score_position = glam::UVec2::new(4 + 8 * score_offset as u32, 10); // x_offset + score_offset * 8, 8 + y_offset
|
||||||
|
|
||||||
|
if let Err(e) = text_renderer.render(canvas, &mut atlas, &score_text, score_position) {
|
||||||
|
errors.write(TextureError::RenderFailed(format!("Failed to render score text: {}", e)).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render high score text
|
||||||
|
let high_score_text = format!("{:02}", score.0);
|
||||||
|
let high_score_offset = 17 - (high_score_text.len() as i32);
|
||||||
|
let high_score_position = glam::UVec2::new(4 + 8 * high_score_offset as u32, 10); // x_offset + score_offset * 8, 8 + y_offset
|
||||||
|
if let Err(e) = text_renderer.render(canvas, &mut atlas, &high_score_text, high_score_position) {
|
||||||
|
errors.write(TextureError::RenderFailed(format!("Failed to render high score text: {}", e)).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render GAME OVER text
|
||||||
|
if matches!(*stage, GameStage::GameOver) {
|
||||||
|
let game_over_text = "GAME OVER";
|
||||||
|
let game_over_width = text_renderer.text_width(game_over_text);
|
||||||
|
let game_over_position = glam::UVec2::new((constants::CANVAS_SIZE.x - game_over_width) / 2, 160);
|
||||||
|
if let Err(e) = text_renderer.render_with_color(canvas, &mut atlas, game_over_text, game_over_position, Color::RED) {
|
||||||
|
errors.write(TextureError::RenderFailed(format!("Failed to render GAME OVER text: {}", e)).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render text based on StartupSequence stage
|
||||||
|
if matches!(
|
||||||
|
*stage,
|
||||||
|
GameStage::Starting(StartupSequence::TextOnly { .. })
|
||||||
|
| GameStage::Starting(StartupSequence::CharactersVisible { .. })
|
||||||
|
) {
|
||||||
|
let ready_text = "READY!";
|
||||||
|
let ready_width = text_renderer.text_width(ready_text);
|
||||||
|
let ready_position = glam::UVec2::new((constants::CANVAS_SIZE.x - ready_width) / 2, 160);
|
||||||
|
if let Err(e) = text_renderer.render_with_color(canvas, &mut atlas, ready_text, ready_position, Color::YELLOW) {
|
||||||
|
errors.write(TextureError::RenderFailed(format!("Failed to render READY text: {}", e)).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(*stage, GameStage::Starting(StartupSequence::TextOnly { .. })) {
|
||||||
|
let player_one_text = "PLAYER ONE";
|
||||||
|
let player_one_width = text_renderer.text_width(player_one_text);
|
||||||
|
let player_one_position = glam::UVec2::new((constants::CANVAS_SIZE.x - player_one_width) / 2, 113);
|
||||||
|
|
||||||
|
if let Err(e) =
|
||||||
|
text_renderer.render_with_color(canvas, &mut atlas, player_one_text, player_one_position, Color::CYAN)
|
||||||
|
{
|
||||||
|
errors.write(TextureError::RenderFailed(format!("Failed to render PLAYER ONE text: {}", e)).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
81
src/systems/hud/touch.rs
Normal file
81
src/systems/hud/touch.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
use crate::error::{GameError, TextureError};
|
||||||
|
use crate::systems::{BackbufferResource, TouchState};
|
||||||
|
use bevy_ecs::event::EventWriter;
|
||||||
|
use bevy_ecs::system::{NonSendMut, Res};
|
||||||
|
use sdl2::pixels::Color;
|
||||||
|
use sdl2::rect::Point;
|
||||||
|
use sdl2::render::{BlendMode, Canvas};
|
||||||
|
use sdl2::video::Window;
|
||||||
|
|
||||||
|
/// Renders touch UI overlay for mobile/testing.
|
||||||
|
pub fn touch_ui_render_system(
|
||||||
|
mut backbuffer: NonSendMut<BackbufferResource>,
|
||||||
|
mut canvas: NonSendMut<&mut Canvas<Window>>,
|
||||||
|
touch_state: Res<TouchState>,
|
||||||
|
mut errors: EventWriter<GameError>,
|
||||||
|
) {
|
||||||
|
if let Some(ref touch_data) = touch_state.active_touch {
|
||||||
|
let _ = canvas.with_texture_canvas(&mut backbuffer.0, |canvas| {
|
||||||
|
// Set blend mode for transparency
|
||||||
|
canvas.set_blend_mode(BlendMode::Blend);
|
||||||
|
|
||||||
|
// Draw semi-transparent circle at touch start position
|
||||||
|
canvas.set_draw_color(Color::RGBA(255, 255, 255, 100));
|
||||||
|
let center = Point::new(touch_data.start_pos.x as i32, touch_data.start_pos.y as i32);
|
||||||
|
|
||||||
|
// Draw a simple circle by drawing filled rectangles (basic approach)
|
||||||
|
let radius = 30;
|
||||||
|
for dy in -radius..=radius {
|
||||||
|
for dx in -radius..=radius {
|
||||||
|
if dx * dx + dy * dy <= radius * radius {
|
||||||
|
let point = Point::new(center.x + dx, center.y + dy);
|
||||||
|
if let Err(e) = canvas.draw_point(point) {
|
||||||
|
errors.write(TextureError::RenderFailed(format!("Touch UI render error: {}", e)).into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw direction indicator if we have a direction
|
||||||
|
if let Some(direction) = touch_data.current_direction {
|
||||||
|
canvas.set_draw_color(Color::RGBA(0, 255, 0, 150));
|
||||||
|
|
||||||
|
// Draw arrow indicating direction
|
||||||
|
let arrow_length = 40;
|
||||||
|
let (dx, dy) = match direction {
|
||||||
|
crate::map::direction::Direction::Up => (0, -arrow_length),
|
||||||
|
crate::map::direction::Direction::Down => (0, arrow_length),
|
||||||
|
crate::map::direction::Direction::Left => (-arrow_length, 0),
|
||||||
|
crate::map::direction::Direction::Right => (arrow_length, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
let end_point = Point::new(center.x + dx, center.y + dy);
|
||||||
|
if let Err(e) = canvas.draw_line(center, end_point) {
|
||||||
|
errors.write(TextureError::RenderFailed(format!("Touch arrow render error: {}", e)).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw arrowhead (simple approach)
|
||||||
|
let arrow_size = 8;
|
||||||
|
match direction {
|
||||||
|
crate::map::direction::Direction::Up => {
|
||||||
|
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y + arrow_size));
|
||||||
|
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y + arrow_size));
|
||||||
|
}
|
||||||
|
crate::map::direction::Direction::Down => {
|
||||||
|
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y - arrow_size));
|
||||||
|
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y - arrow_size));
|
||||||
|
}
|
||||||
|
crate::map::direction::Direction::Left => {
|
||||||
|
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y - arrow_size));
|
||||||
|
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y + arrow_size));
|
||||||
|
}
|
||||||
|
crate::map::direction::Direction::Right => {
|
||||||
|
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y - arrow_size));
|
||||||
|
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y + arrow_size));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,19 +12,18 @@ use sdl2::{
|
|||||||
EventPump,
|
EventPump,
|
||||||
};
|
};
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{smallvec, SmallVec};
|
||||||
use tracing::{debug, info};
|
|
||||||
|
|
||||||
use crate::systems::components::DeltaTime;
|
use crate::systems::DeltaTime;
|
||||||
use crate::{
|
use crate::{
|
||||||
events::{GameCommand, GameEvent},
|
events::{GameCommand, GameEvent},
|
||||||
map::direction::Direction,
|
map::direction::Direction,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Touch input constants
|
// Touch input constants
|
||||||
const TOUCH_DIRECTION_THRESHOLD: f32 = 10.0;
|
pub const TOUCH_DIRECTION_THRESHOLD: f32 = 10.0;
|
||||||
const TOUCH_EASING_DISTANCE_THRESHOLD: f32 = 1.0;
|
pub const TOUCH_EASING_DISTANCE_THRESHOLD: f32 = 1.0;
|
||||||
const MAX_TOUCH_MOVEMENT_SPEED: f32 = 100.0;
|
pub const MAX_TOUCH_MOVEMENT_SPEED: f32 = 100.0;
|
||||||
const TOUCH_EASING_FACTOR: f32 = 1.5;
|
pub const TOUCH_EASING_FACTOR: f32 = 1.5;
|
||||||
|
|
||||||
#[derive(Resource, Default, Debug, Copy, Clone)]
|
#[derive(Resource, Default, Debug, Copy, Clone)]
|
||||||
pub enum CursorPosition {
|
pub enum CursorPosition {
|
||||||
@@ -36,7 +35,7 @@ pub enum CursorPosition {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Resource, Default, Debug)]
|
#[derive(Resource, Default, Debug, Clone)]
|
||||||
pub struct TouchState {
|
pub struct TouchState {
|
||||||
pub active_touch: Option<TouchData>,
|
pub active_touch: Option<TouchData>,
|
||||||
}
|
}
|
||||||
@@ -86,8 +85,12 @@ impl Default for Bindings {
|
|||||||
key_bindings.insert(Keycode::Space, GameCommand::ToggleDebug);
|
key_bindings.insert(Keycode::Space, GameCommand::ToggleDebug);
|
||||||
key_bindings.insert(Keycode::M, GameCommand::MuteAudio);
|
key_bindings.insert(Keycode::M, GameCommand::MuteAudio);
|
||||||
key_bindings.insert(Keycode::R, GameCommand::ResetLevel);
|
key_bindings.insert(Keycode::R, GameCommand::ResetLevel);
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "emscripten"))]
|
||||||
|
{
|
||||||
key_bindings.insert(Keycode::Escape, GameCommand::Exit);
|
key_bindings.insert(Keycode::Escape, GameCommand::Exit);
|
||||||
key_bindings.insert(Keycode::Q, GameCommand::Exit);
|
key_bindings.insert(Keycode::Q, GameCommand::Exit);
|
||||||
|
}
|
||||||
|
|
||||||
let movement_keys = HashSet::from([
|
let movement_keys = HashSet::from([
|
||||||
Keycode::W,
|
Keycode::W,
|
||||||
@@ -161,7 +164,7 @@ pub fn process_simple_key_events(bindings: &mut Bindings, frame_events: &[Simple
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Calculates the primary direction from a 2D vector delta
|
/// 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.abs() > delta.y.abs() {
|
||||||
if delta.x > 0.0 {
|
if delta.x > 0.0 {
|
||||||
Direction::Right
|
Direction::Right
|
||||||
@@ -180,7 +183,7 @@ fn calculate_direction_from_delta(delta: Vec2) -> Direction {
|
|||||||
/// This slowly moves the start_pos towards the current_pos, with the speed
|
/// 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.
|
/// decreasing as the distance gets smaller. The maximum movement speed is capped.
|
||||||
/// Returns the delta vector and its length for reuse by the caller.
|
/// 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
|
// Calculate the vector from start to current position
|
||||||
let delta = touch_data.current_pos - touch_data.start_pos;
|
let delta = touch_data.current_pos - touch_data.start_pos;
|
||||||
let distance = delta.length();
|
let distance = delta.length();
|
||||||
@@ -221,16 +224,6 @@ pub fn input_system(
|
|||||||
// Collect all events for this frame.
|
// Collect all events for this frame.
|
||||||
let frame_events: SmallVec<[Event; 3]> = pump.poll_iter().collect();
|
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.
|
// Handle non-keyboard events inline and build a simplified keyboard event stream.
|
||||||
let mut simple_key_events: SmallVec<[SimpleKeyEvent; 3]> = smallvec![];
|
let mut simple_key_events: SmallVec<[SimpleKeyEvent; 3]> = smallvec![];
|
||||||
for event in &frame_events {
|
for event in &frame_events {
|
||||||
@@ -298,19 +291,15 @@ pub fn input_system(
|
|||||||
simple_key_events.push(SimpleKeyEvent::KeyUp(key));
|
simple_key_events.push(SimpleKeyEvent::KeyUp(key));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::Window { win_event, .. } => match win_event {
|
Event::Window { win_event, .. } => {
|
||||||
WindowEvent::Resized(w, h) => {
|
if let WindowEvent::Resized(w, h) = win_event {
|
||||||
info!("Window resized to {}x{}", w, h);
|
tracing::info!(width = w, height = h, event = ?win_event, "Window Resized");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Despite disabling this event, it's still received, so we ignore it explicitly.
|
||||||
|
Event::RenderTargetsReset { .. } => {}
|
||||||
_ => {
|
_ => {
|
||||||
debug!("Window event: {:?}", win_event);
|
tracing::warn!(event = ?event, "Unhandled Event");
|
||||||
}
|
|
||||||
},
|
|
||||||
Event::RenderTargetsReset { .. } => {
|
|
||||||
// No-op
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
tracing::warn!("Unhandled event, consider disabling: {:?}", event);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,7 +313,7 @@ pub fn input_system(
|
|||||||
// Update touch reference position with easing
|
// Update touch reference position with easing
|
||||||
if let Some(ref mut touch_data) = touch_state.active_touch {
|
if let Some(ref mut touch_data) = touch_state.active_touch {
|
||||||
// Apply easing to the reference position and get the delta for direction calculation
|
// 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
|
// Check for direction based on updated reference position
|
||||||
if distance >= TOUCH_DIRECTION_THRESHOLD {
|
if distance >= TOUCH_DIRECTION_THRESHOLD {
|
||||||
@@ -341,7 +330,7 @@ pub fn input_system(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let (false, CursorPosition::Some { remaining_time, .. }) = (cursor_seen, &mut *cursor) {
|
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 {
|
if *remaining_time <= 0.0 {
|
||||||
*cursor = CursorPosition::None;
|
*cursor = CursorPosition::None;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +1,137 @@
|
|||||||
use bevy_ecs::{
|
use bevy_ecs::{
|
||||||
entity::Entity,
|
event::Event,
|
||||||
event::{EventReader, EventWriter},
|
observer::Trigger,
|
||||||
query::With,
|
system::{Commands, NonSendMut, Res},
|
||||||
system::{Commands, Query, ResMut},
|
|
||||||
};
|
};
|
||||||
|
use strum_macros::IntoStaticStr;
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
constants::animation::FRIGHTENED_FLASH_START_TICKS,
|
constants,
|
||||||
events::GameEvent,
|
map::builder::Map,
|
||||||
systems::{AudioEvent, EntityType, GhostCollider, GhostState, ItemCollider, PacmanCollider, ScoreResource},
|
systems::{common::bundles::ItemBundle, Collider, Position, Renderable, TimeToLive},
|
||||||
|
texture::{
|
||||||
|
sprite::SpriteAtlas,
|
||||||
|
sprites::{EffectSprite, GameSprite},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Determines if a collision between two entity types should be handled by the item system.
|
use crate::{systems::common::components::EntityType, systems::ItemCollider};
|
||||||
///
|
|
||||||
/// Returns `true` if one entity is a player and the other is a collectible item.
|
use std::cmp::Ordering;
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn is_valid_item_collision(entity1: EntityType, entity2: EntityType) -> bool {
|
/// Tracks the number of pellets consumed by the player for fruit spawning mechanics.
|
||||||
match (entity1, entity2) {
|
#[derive(bevy_ecs::resource::Resource, Debug, Default)]
|
||||||
(EntityType::Player, entity) | (entity, EntityType::Player) => entity.is_collectible(),
|
pub struct PelletCount(pub u32);
|
||||||
_ => false,
|
|
||||||
|
/// Represents the different fruit sprites that can appear as bonus items.
|
||||||
|
#[derive(IntoStaticStr, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
#[strum(serialize_all = "snake_case")]
|
||||||
|
pub enum FruitType {
|
||||||
|
Cherry,
|
||||||
|
Strawberry,
|
||||||
|
Orange,
|
||||||
|
Apple,
|
||||||
|
Melon,
|
||||||
|
Galaxian,
|
||||||
|
Bell,
|
||||||
|
Key,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialOrd for FruitType {
|
||||||
|
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||||
|
Some(self.cmp(other))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn item_system(
|
impl Ord for FruitType {
|
||||||
|
fn cmp(&self, other: &Self) -> Ordering {
|
||||||
|
(self.score_value()).cmp(&other.score_value())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FruitType {
|
||||||
|
/// Returns the score value for this fruit type.
|
||||||
|
pub fn score_value(self) -> u32 {
|
||||||
|
match self {
|
||||||
|
FruitType::Cherry => 100,
|
||||||
|
FruitType::Strawberry => 300,
|
||||||
|
FruitType::Orange => 500,
|
||||||
|
FruitType::Apple => 700,
|
||||||
|
FruitType::Melon => 1000,
|
||||||
|
FruitType::Galaxian => 2000,
|
||||||
|
FruitType::Bell => 3000,
|
||||||
|
FruitType::Key => 5000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_index(index: u8) -> Self {
|
||||||
|
match index {
|
||||||
|
0 => FruitType::Cherry,
|
||||||
|
1 => FruitType::Strawberry,
|
||||||
|
2 => FruitType::Orange,
|
||||||
|
3 => FruitType::Apple,
|
||||||
|
4 => FruitType::Melon,
|
||||||
|
5 => FruitType::Galaxian,
|
||||||
|
6 => FruitType::Bell,
|
||||||
|
7 => FruitType::Key,
|
||||||
|
_ => panic!("Invalid fruit index: {}", index),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trigger to spawn a fruit
|
||||||
|
#[derive(Event, Clone, Copy, Debug)]
|
||||||
|
pub enum SpawnTrigger {
|
||||||
|
Fruit,
|
||||||
|
Bonus { position: Position, value: u32, ttl: u32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_fruit_observer(
|
||||||
|
trigger: Trigger<SpawnTrigger>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut collision_events: EventReader<GameEvent>,
|
atlas: NonSendMut<SpriteAtlas>,
|
||||||
mut score: ResMut<ScoreResource>,
|
map: Res<Map>,
|
||||||
pacman_query: Query<Entity, With<PacmanCollider>>,
|
|
||||||
item_query: Query<(Entity, &EntityType), With<ItemCollider>>,
|
|
||||||
mut ghost_query: Query<&mut GhostState, With<GhostCollider>>,
|
|
||||||
mut events: EventWriter<AudioEvent>,
|
|
||||||
) {
|
) {
|
||||||
for event in collision_events.read() {
|
let entity = match *trigger {
|
||||||
if let GameEvent::Collision(entity1, entity2) = event {
|
SpawnTrigger::Fruit => {
|
||||||
// Check if one is Pacman and the other is an item
|
// Use cherry sprite as the default fruit (first fruit in original Pac-Man)
|
||||||
let (_pacman_entity, item_entity) = if pacman_query.get(*entity1).is_ok() && item_query.get(*entity2).is_ok() {
|
let sprite = &atlas
|
||||||
(*entity1, *entity2)
|
.get_tile(&GameSprite::Fruit(FruitType::from_index(0)).to_path())
|
||||||
} else if pacman_query.get(*entity2).is_ok() && item_query.get(*entity1).is_ok() {
|
.unwrap();
|
||||||
(*entity2, *entity1)
|
let bundle = ItemBundle {
|
||||||
} else {
|
position: map.start_positions.fruit_spawn,
|
||||||
continue;
|
sprite: Renderable {
|
||||||
|
sprite: *sprite,
|
||||||
|
layer: 1,
|
||||||
|
},
|
||||||
|
entity_type: EntityType::Fruit(FruitType::Cherry),
|
||||||
|
collider: Collider {
|
||||||
|
size: constants::collider::FRUIT_SIZE,
|
||||||
|
},
|
||||||
|
item_collider: ItemCollider,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get the item type and update score
|
commands.spawn(bundle)
|
||||||
if let Ok((item_ent, entity_type)) = item_query.get(item_entity) {
|
}
|
||||||
if let Some(score_value) = entity_type.score_value() {
|
SpawnTrigger::Bonus { position, value, ttl } => {
|
||||||
score.0 += score_value;
|
let sprite = &atlas
|
||||||
|
.get_tile(&GameSprite::Effect(EffectSprite::Bonus(value)).to_path())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Remove the collected item
|
let bundle = (
|
||||||
commands.entity(item_ent).despawn();
|
position,
|
||||||
|
TimeToLive::new(ttl),
|
||||||
|
Renderable {
|
||||||
|
sprite: *sprite,
|
||||||
|
layer: 1,
|
||||||
|
},
|
||||||
|
EntityType::Effect,
|
||||||
|
);
|
||||||
|
|
||||||
// Trigger audio if appropriate
|
commands.spawn(bundle)
|
||||||
if entity_type.is_collectible() {
|
|
||||||
events.write(AudioEvent::PlayEat);
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Make ghosts frightened when power pellet is collected
|
debug!(entity = ?entity.id(), "Entity spawned via trigger");
|
||||||
if *entity_type == EntityType::PowerPellet {
|
|
||||||
// Convert seconds to frames (assumes 60 FPS)
|
|
||||||
let total_ticks = 60 * 5; // 5 seconds total
|
|
||||||
|
|
||||||
// Set all ghosts to frightened state, except those in Eyes state
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
33
src/systems/lifetime.rs
Normal file
33
src/systems/lifetime.rs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
use bevy_ecs::{
|
||||||
|
component::Component,
|
||||||
|
entity::Entity,
|
||||||
|
system::{Commands, Query, Res},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::systems::DeltaTime;
|
||||||
|
|
||||||
|
/// Component for entities that should be automatically deleted after a certain number of ticks
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
pub struct TimeToLive {
|
||||||
|
pub remaining_ticks: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TimeToLive {
|
||||||
|
pub fn new(ticks: u32) -> Self {
|
||||||
|
Self { remaining_ticks: ticks }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System that manages entities with TimeToLive components, decrementing their remaining ticks
|
||||||
|
/// and despawning them when they expire
|
||||||
|
pub fn time_to_live_system(mut commands: Commands, dt: Res<DeltaTime>, mut query: Query<(Entity, &mut TimeToLive)>) {
|
||||||
|
for (entity, mut ttl) in query.iter_mut() {
|
||||||
|
if ttl.remaining_ticks <= dt.ticks {
|
||||||
|
// Entity has expired, despawn it
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
} else {
|
||||||
|
// Decrement remaining time
|
||||||
|
ttl.remaining_ticks = ttl.remaining_ticks.saturating_sub(dt.ticks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,32 +1,41 @@
|
|||||||
//! The Entity-Component-System (ECS) module.
|
//! This module contains all the systems in the game.
|
||||||
//!
|
|
||||||
//! This module contains all the ECS-related logic, including components, systems,
|
|
||||||
//! and resources.
|
|
||||||
|
|
||||||
|
// These modules are excluded from coverage.
|
||||||
|
#[cfg_attr(coverage_nightly, coverage(off))]
|
||||||
pub mod audio;
|
pub mod audio;
|
||||||
pub mod blinking;
|
#[cfg_attr(coverage_nightly, coverage(off))]
|
||||||
pub mod collision;
|
|
||||||
pub mod components;
|
|
||||||
pub mod debug;
|
pub mod debug;
|
||||||
pub mod ghost;
|
#[cfg_attr(coverage_nightly, coverage(off))]
|
||||||
|
pub mod profiling;
|
||||||
|
#[cfg_attr(coverage_nightly, coverage(off))]
|
||||||
|
pub mod render;
|
||||||
|
|
||||||
|
mod animation;
|
||||||
|
mod collision;
|
||||||
|
pub mod common;
|
||||||
|
mod ghost;
|
||||||
|
mod hud;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
pub mod item;
|
pub mod item;
|
||||||
|
pub mod lifetime;
|
||||||
pub mod movement;
|
pub mod movement;
|
||||||
pub mod player;
|
pub mod player;
|
||||||
pub mod profiling;
|
pub mod state;
|
||||||
pub mod render;
|
|
||||||
pub mod stage;
|
|
||||||
|
|
||||||
|
// Re-export all the modules. Do not fine-tune the exports.
|
||||||
|
|
||||||
|
pub use self::animation::*;
|
||||||
pub use self::audio::*;
|
pub use self::audio::*;
|
||||||
pub use self::blinking::*;
|
|
||||||
pub use self::collision::*;
|
pub use self::collision::*;
|
||||||
pub use self::components::*;
|
pub use self::common::*;
|
||||||
pub use self::debug::*;
|
pub use self::debug::*;
|
||||||
pub use self::ghost::*;
|
pub use self::ghost::*;
|
||||||
|
pub use self::hud::*;
|
||||||
pub use self::input::*;
|
pub use self::input::*;
|
||||||
pub use self::item::*;
|
pub use self::item::*;
|
||||||
|
pub use self::lifetime::*;
|
||||||
pub use self::movement::*;
|
pub use self::movement::*;
|
||||||
pub use self::player::*;
|
pub use self::player::*;
|
||||||
pub use self::profiling::*;
|
pub use self::profiling::*;
|
||||||
pub use self::render::*;
|
pub use self::render::*;
|
||||||
pub use self::stage::*;
|
pub use self::state::*;
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
use bevy_ecs::{
|
use bevy_ecs::{
|
||||||
event::{EventReader, EventWriter},
|
component::Component,
|
||||||
|
event::EventReader,
|
||||||
query::{With, Without},
|
query::{With, Without},
|
||||||
system::{Query, Res, ResMut},
|
system::{Query, Res, ResMut, Single},
|
||||||
};
|
};
|
||||||
|
use tracing::trace;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::GameError,
|
|
||||||
events::{GameCommand, GameEvent},
|
events::{GameCommand, GameEvent},
|
||||||
map::{builder::Map, graph::Edge},
|
map::{builder::Map, graph::Edge},
|
||||||
systems::{
|
systems::{
|
||||||
components::{DeltaTime, EntityType, Frozen, GlobalState, MovementModifiers, PlayerControlled},
|
components::{DeltaTime, EntityType, Frozen, GlobalState, MovementModifiers},
|
||||||
debug::DebugState,
|
debug::DebugState,
|
||||||
movement::{BufferedDirection, Position, Velocity},
|
movement::{BufferedDirection, Position, Velocity},
|
||||||
AudioState,
|
AudioState,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// A tag component for entities that are controlled by the player.
|
||||||
|
#[derive(Default, Component)]
|
||||||
|
pub struct PlayerControlled;
|
||||||
|
|
||||||
pub fn can_traverse(entity_type: EntityType, edge: Edge) -> bool {
|
pub fn can_traverse(entity_type: EntityType, edge: Edge) -> bool {
|
||||||
let entity_flags = entity_type.traversal_flags();
|
let entity_flags = entity_type.traversal_flags();
|
||||||
edge.traversal_flags.contains(entity_flags)
|
edge.traversal_flags.contains(entity_flags)
|
||||||
@@ -27,36 +32,29 @@ pub fn can_traverse(entity_type: EntityType, edge: Edge) -> bool {
|
|||||||
/// toggling, audio muting, and game exit requests. Movement commands are buffered
|
/// toggling, audio muting, and game exit requests. Movement commands are buffered
|
||||||
/// to allow direction changes before reaching intersections, improving gameplay
|
/// to allow direction changes before reaching intersections, improving gameplay
|
||||||
/// responsiveness. Non-movement commands immediately modify global game state.
|
/// responsiveness. Non-movement commands immediately modify global game state.
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
pub fn player_control_system(
|
pub fn player_control_system(
|
||||||
mut events: EventReader<GameEvent>,
|
mut events: EventReader<GameEvent>,
|
||||||
mut state: ResMut<GlobalState>,
|
mut state: ResMut<GlobalState>,
|
||||||
mut debug_state: ResMut<DebugState>,
|
mut debug_state: ResMut<DebugState>,
|
||||||
mut audio_state: ResMut<AudioState>,
|
mut audio_state: ResMut<AudioState>,
|
||||||
mut players: Query<&mut BufferedDirection, (With<PlayerControlled>, Without<Frozen>)>,
|
mut player: Option<Single<&mut BufferedDirection, (With<PlayerControlled>, Without<Frozen>)>>,
|
||||||
mut errors: EventWriter<GameError>,
|
|
||||||
) {
|
) {
|
||||||
// Handle events
|
// Handle events
|
||||||
for event in events.read() {
|
for event in events.read() {
|
||||||
if let GameEvent::Command(command) = event {
|
let GameEvent::Command(command) = event;
|
||||||
|
|
||||||
match command {
|
match command {
|
||||||
GameCommand::MovePlayer(direction) => {
|
GameCommand::MovePlayer(direction) => {
|
||||||
// Get the player's movable component (ensuring there is only one player)
|
// Only handle movement if there's an unfrozen player
|
||||||
let mut buffered_direction = match players.single_mut() {
|
if let Some(player_single) = player.as_mut() {
|
||||||
Ok(tuple) => tuple,
|
trace!(direction = ?*direction, "Player direction buffered for movement");
|
||||||
Err(e) => {
|
***player_single = BufferedDirection::Some {
|
||||||
errors.write(GameError::InvalidState(format!(
|
|
||||||
"No/multiple entities queried for player system: {}",
|
|
||||||
e
|
|
||||||
)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
*buffered_direction = BufferedDirection::Some {
|
|
||||||
direction: *direction,
|
direction: *direction,
|
||||||
remaining_time: 0.25,
|
remaining_time: 0.25,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
GameCommand::Exit => {
|
GameCommand::Exit => {
|
||||||
state.exit = true;
|
state.exit = true;
|
||||||
}
|
}
|
||||||
@@ -70,7 +68,6 @@ pub fn player_control_system(
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Executes frame-by-frame movement for Pac-Man.
|
/// Executes frame-by-frame movement for Pac-Man.
|
||||||
@@ -86,6 +83,7 @@ pub fn player_movement_system(
|
|||||||
(&MovementModifiers, &mut Position, &mut Velocity, &mut BufferedDirection),
|
(&MovementModifiers, &mut Position, &mut Velocity, &mut BufferedDirection),
|
||||||
(With<PlayerControlled>, Without<Frozen>),
|
(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() {
|
for (modifiers, mut position, mut velocity, mut buffered_direction) in entities.iter_mut() {
|
||||||
// Decrement the buffered direction remaining time
|
// Decrement the buffered direction remaining time
|
||||||
@@ -95,16 +93,17 @@ pub fn player_movement_system(
|
|||||||
} = *buffered_direction
|
} = *buffered_direction
|
||||||
{
|
{
|
||||||
if remaining_time <= 0.0 {
|
if remaining_time <= 0.0 {
|
||||||
|
trace!("Buffered direction expired");
|
||||||
*buffered_direction = BufferedDirection::None;
|
*buffered_direction = BufferedDirection::None;
|
||||||
} else {
|
} else {
|
||||||
*buffered_direction = BufferedDirection::Some {
|
*buffered_direction = BufferedDirection::Some {
|
||||||
direction,
|
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 {
|
loop {
|
||||||
match *position {
|
match *position {
|
||||||
@@ -115,6 +114,8 @@ pub fn player_movement_system(
|
|||||||
if let Some(edge) = map.graph.find_edge_in_direction(position.current_node(), direction) {
|
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 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) {
|
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;
|
velocity.direction = edge.direction;
|
||||||
*position = Position::Moving {
|
*position = Position::Moving {
|
||||||
from: position.current_node(),
|
from: position.current_node(),
|
||||||
@@ -129,6 +130,8 @@ pub fn player_movement_system(
|
|||||||
// If there is no buffered direction (or it's not yet valid), continue in the current direction.
|
// If 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 let Some(edge) = map.graph.find_edge_in_direction(position.current_node(), velocity.direction) {
|
||||||
if can_traverse(EntityType::Player, edge) {
|
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;
|
velocity.direction = edge.direction;
|
||||||
*position = Position::Moving {
|
*position = Position::Moving {
|
||||||
from: position.current_node(),
|
from: position.current_node(),
|
||||||
@@ -138,6 +141,11 @@ pub fn player_movement_system(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No edge in our current direction either, erase the buffered direction and stop.
|
// 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;
|
*buffered_direction = BufferedDirection::None;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -155,14 +163,23 @@ pub fn player_movement_system(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Applies tunnel slowdown based on the current node tile
|
/// Applies tunnel slowdown based on the current node tile
|
||||||
pub fn player_tunnel_slowdown_system(map: Res<Map>, mut q: Query<(&Position, &mut MovementModifiers), With<PlayerControlled>>) {
|
pub fn player_tunnel_slowdown_system(map: Res<Map>, player: Single<(&Position, &mut MovementModifiers), With<PlayerControlled>>) {
|
||||||
if let Ok((position, mut modifiers)) = q.single_mut() {
|
let (position, mut modifiers) = player.into_inner();
|
||||||
let node = position.current_node();
|
let node = position.current_node();
|
||||||
let in_tunnel = map
|
let in_tunnel = map
|
||||||
.tile_at_node(node)
|
.tile_at_node(node)
|
||||||
.map(|t| t == crate::constants::MapTile::Tunnel)
|
.map(|t| t == crate::constants::MapTile::Tunnel)
|
||||||
.unwrap_or(false);
|
.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.tunnel_slowdown_active = in_tunnel;
|
||||||
modifiers.speed_multiplier = if in_tunnel { 0.6 } else { 1.0 };
|
modifiers.speed_multiplier = if in_tunnel { 0.6 } else { 1.0 };
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
use bevy_ecs::system::IntoSystem;
|
use bevy_ecs::system::IntoSystem;
|
||||||
use bevy_ecs::{resource::Resource, system::System};
|
use bevy_ecs::{resource::Resource, system::System};
|
||||||
use circular_buffer::CircularBuffer;
|
use circular_buffer::CircularBuffer;
|
||||||
use micromap::Map;
|
|
||||||
use num_width::NumberWidth;
|
use num_width::NumberWidth;
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use smallvec::SmallVec;
|
use smallvec::SmallVec;
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use strum::{EnumCount, IntoEnumIterator};
|
use strum::{EnumCount, IntoEnumIterator};
|
||||||
use strum_macros::{EnumCount, EnumIter, IntoStaticStr};
|
use strum_macros::{EnumCount, EnumIter, IntoStaticStr};
|
||||||
@@ -16,8 +16,132 @@ const MAX_SYSTEMS: usize = SystemId::COUNT;
|
|||||||
/// The number of durations to keep in the circular buffer.
|
/// The number of durations to keep in the circular buffer.
|
||||||
const TIMING_WINDOW_SIZE: usize = 30;
|
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 the most recent timing from the buffer.
|
||||||
|
pub fn get_most_recent_timing(&self) -> Duration {
|
||||||
|
self.buffer.back().copied().unwrap_or(Duration::ZERO)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets statistics for this timing buffer.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// 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)]
|
#[derive(EnumCount, EnumIter, IntoStaticStr, Debug, PartialEq, Eq, Hash, Copy, Clone)]
|
||||||
pub enum SystemId {
|
pub enum SystemId {
|
||||||
|
Total,
|
||||||
Input,
|
Input,
|
||||||
PlayerControls,
|
PlayerControls,
|
||||||
Ghost,
|
Ghost,
|
||||||
@@ -38,37 +162,29 @@ pub enum SystemId {
|
|||||||
Stage,
|
Stage,
|
||||||
GhostStateAnimation,
|
GhostStateAnimation,
|
||||||
EatenGhost,
|
EatenGhost,
|
||||||
|
TimeToLive,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Display for SystemId {
|
impl Display for SystemId {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
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())
|
write!(f, "{}", Into::<&'static str>::into(self).to_ascii_lowercase())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Resource, Debug)]
|
#[derive(Resource, Debug)]
|
||||||
pub struct SystemTimings {
|
pub struct SystemTimings {
|
||||||
/// Map of system names to a queue of durations, using a circular buffer.
|
/// Statically sized map of system names to timing buffers.
|
||||||
///
|
pub timings: micromap::Map<SystemId, Mutex<TimingBuffer>, MAX_SYSTEMS>,
|
||||||
/// 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>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for SystemTimings {
|
impl Default for SystemTimings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
let mut timings = Map::new();
|
let mut timings = micromap::Map::new();
|
||||||
|
|
||||||
// Pre-populate with all SystemId variants to avoid runtime allocations
|
// 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() {
|
for id in SystemId::iter() {
|
||||||
timings.insert(id, Mutex::new(CircularBuffer::new()));
|
timings.insert(id, Mutex::new(TimingBuffer::default()));
|
||||||
}
|
}
|
||||||
|
|
||||||
Self { timings }
|
Self { timings }
|
||||||
@@ -76,100 +192,122 @@ impl Default for SystemTimings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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
|
// Since all SystemId variants are pre-populated, we can use a simple read lock
|
||||||
let queue = self
|
let buffer = self
|
||||||
.timings
|
.timings
|
||||||
.get(&id)
|
.get(&id)
|
||||||
.expect("SystemId not found in pre-populated map - this is a bug");
|
.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> {
|
/// Add timing for the Total system (total frame time including scheduler.run)
|
||||||
let mut stats = Map::new();
|
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
|
// Iterate over all SystemId variants to ensure every system has an entry
|
||||||
for id in SystemId::iter() {
|
for id in SystemId::iter() {
|
||||||
let queue = self
|
let buffer = self
|
||||||
.timings
|
.timings
|
||||||
.get(&id)
|
.get(&id)
|
||||||
.expect("SystemId not found in pre-populated map - this is a bug");
|
.expect("SystemId not found in pre-populated map - this is a bug");
|
||||||
|
|
||||||
let queue_guard = queue.lock();
|
let (average, standard_deviation) = buffer.lock().get_stats(current_tick);
|
||||||
if queue_guard.is_empty() {
|
stats.insert(id, (average, standard_deviation));
|
||||||
// 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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stats
|
stats
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_total_stats(&self) -> (Duration, Duration) {
|
pub fn format_timing_display(&self, current_tick: u64) -> SmallVec<[String; SystemId::COUNT]> {
|
||||||
let duration_sums = {
|
let stats = self.get_stats(current_tick);
|
||||||
self.timings
|
|
||||||
.iter()
|
|
||||||
.map(|(_, queue)| queue.lock().iter().sum::<Duration>())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
};
|
|
||||||
|
|
||||||
let mean = duration_sums.iter().sum::<Duration>() / duration_sums.len() as u32;
|
// Get the Total system metrics instead of averaging all systems
|
||||||
let variance = duration_sums
|
let (total_avg, total_std) = stats
|
||||||
.iter()
|
.get(&SystemId::Total)
|
||||||
.map(|x| {
|
.copied()
|
||||||
let diff_secs = x.as_secs_f64() - mean.as_secs_f64();
|
.unwrap_or((Duration::ZERO, Duration::ZERO));
|
||||||
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();
|
|
||||||
|
|
||||||
let effective_fps = match 1.0 / total_avg.as_secs_f64() {
|
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 if f < 10.0 => format!("{:.1} FPS", f),
|
||||||
f => format!("{:.0} FPS", f),
|
f => format!("{:5.0} FPS", f),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Collect timing data for formatting
|
// Collect timing data for formatting
|
||||||
let mut timing_data = vec![(effective_fps, total_avg, total_std)];
|
let mut timing_data = vec![(effective_fps, total_avg, total_std)];
|
||||||
|
|
||||||
// Sort the stats by average duration
|
// Sort the stats by average duration, excluding the Total system
|
||||||
let mut sorted_stats: Vec<_> = stats.iter().collect();
|
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));
|
sorted_stats.sort_by(|a, b| b.1 .0.cmp(&a.1 .0));
|
||||||
|
|
||||||
// Add the top 5 most expensive systems
|
// Add the top 7 most expensive systems (excluding Total)
|
||||||
for (name, (avg, std_dev)) in sorted_stats.iter().take(7) {
|
for (name, (avg, std_dev)) in sorted_stats.iter().take(9) {
|
||||||
timing_data.push((name.to_string(), *avg, *std_dev));
|
timing_data.push((name.to_string(), *avg, *std_dev));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the formatting module to format the data
|
// Use the formatting module to format the data
|
||||||
format_timing_display(timing_data)
|
format_timing_display(timing_data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a list of systems with their timings, likely responsible for slow frame timings.
|
||||||
|
///
|
||||||
|
/// First, checks if any systems took longer than 2ms on the most recent tick.
|
||||||
|
/// If none exceed 2ms, accumulates systems until the top 30% of total timing
|
||||||
|
/// is reached, stopping at 5 systems maximum.
|
||||||
|
///
|
||||||
|
/// Returns tuples of (SystemId, Duration) in a SmallVec capped at 5 items.
|
||||||
|
pub fn get_slowest_systems(&self) -> SmallVec<[(SystemId, Duration); 5]> {
|
||||||
|
let mut system_timings: Vec<(SystemId, Duration)> = Vec::new();
|
||||||
|
let mut total_duration = Duration::ZERO;
|
||||||
|
|
||||||
|
// Collect most recent timing for each system (excluding Total)
|
||||||
|
for id in SystemId::iter() {
|
||||||
|
if id == SystemId::Total {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(buffer) = self.timings.get(&id) {
|
||||||
|
let recent_timing = buffer.lock().get_most_recent_timing();
|
||||||
|
system_timings.push((id, recent_timing));
|
||||||
|
total_duration += recent_timing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by duration (highest first)
|
||||||
|
system_timings.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
|
||||||
|
// Check for systems over 2ms threshold
|
||||||
|
let over_threshold: SmallVec<[(SystemId, Duration); 5]> = system_timings
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, duration)| duration.as_millis() >= 2)
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !over_threshold.is_empty() {
|
||||||
|
return over_threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accumulate top systems until 30% of total is reached (max 5 systems)
|
||||||
|
let threshold = total_duration.as_nanos() as f64 * 0.3;
|
||||||
|
let mut accumulated = 0u128;
|
||||||
|
let mut result = SmallVec::new();
|
||||||
|
|
||||||
|
for (id, duration) in system_timings.iter().take(5) {
|
||||||
|
result.push((*id, *duration));
|
||||||
|
accumulated += duration.as_nanos();
|
||||||
|
|
||||||
|
if accumulated as f64 >= threshold {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn profile<S, M>(id: SystemId, system: S) -> impl FnMut(&mut bevy_ecs::world::World)
|
pub fn profile<S, M>(id: SystemId, system: S) -> impl FnMut(&mut bevy_ecs::world::World)
|
||||||
@@ -188,8 +326,9 @@ where
|
|||||||
system.run((), world);
|
system.run((), world);
|
||||||
let duration = start.elapsed();
|
let duration = start.elapsed();
|
||||||
|
|
||||||
if let Some(timings) = world.get_resource::<SystemTimings>() {
|
if let (Some(timings), Some(timing)) = (world.get_resource::<SystemTimings>(), world.get_resource::<Timing>()) {
|
||||||
timings.add_timing(id, duration);
|
let current_tick = timing.get_current_tick();
|
||||||
|
timings.add_timing(id, duration, current_tick);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,85 @@
|
|||||||
use crate::constants::CANVAS_SIZE;
|
|
||||||
use crate::error::{GameError, TextureError};
|
use crate::error::{GameError, TextureError};
|
||||||
use crate::map::builder::Map;
|
use crate::map::builder::Map;
|
||||||
use crate::systems::input::TouchState;
|
|
||||||
use crate::systems::{
|
use crate::systems::{
|
||||||
debug_render_system, BatchedLinesResource, Collider, CursorPosition, DebugState, DebugTextureResource, DeltaTime,
|
debug_render_system, BatchedLinesResource, Collider, CursorPosition, DebugState, DebugTextureResource, Position, SystemId,
|
||||||
DirectionalAnimation, LinearAnimation, Position, Renderable, ScoreResource, StartupSequence, SystemId, SystemTimings,
|
SystemTimings, TtfAtlasResource,
|
||||||
TtfAtlasResource, Velocity,
|
|
||||||
};
|
};
|
||||||
use crate::texture::sprite::SpriteAtlas;
|
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
|
||||||
use crate::texture::text::TextTexture;
|
|
||||||
use bevy_ecs::component::Component;
|
use bevy_ecs::component::Component;
|
||||||
use bevy_ecs::entity::Entity;
|
use bevy_ecs::entity::Entity;
|
||||||
use bevy_ecs::event::EventWriter;
|
use bevy_ecs::event::EventWriter;
|
||||||
use bevy_ecs::query::{Changed, Or, Without};
|
use bevy_ecs::query::{Changed, Or, With};
|
||||||
use bevy_ecs::removal_detection::RemovedComponents;
|
use bevy_ecs::removal_detection::RemovedComponents;
|
||||||
use bevy_ecs::resource::Resource;
|
use bevy_ecs::resource::Resource;
|
||||||
use bevy_ecs::system::{NonSendMut, Query, Res, ResMut};
|
use bevy_ecs::system::{NonSendMut, Query, Res, ResMut};
|
||||||
use sdl2::pixels::Color;
|
use glam::Vec2;
|
||||||
use sdl2::rect::{Point, Rect};
|
use sdl2::rect::{Point, Rect};
|
||||||
use sdl2::render::{BlendMode, Canvas, Texture};
|
use sdl2::render::{BlendMode, Canvas, Texture};
|
||||||
use sdl2::video::Window;
|
use sdl2::video::Window;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
|
/// A component for entities that have a sprite, with a layer for ordering.
|
||||||
|
///
|
||||||
|
/// This is intended to be modified by other entities allowing animation.
|
||||||
|
#[derive(Component)]
|
||||||
|
pub struct Renderable {
|
||||||
|
pub sprite: AtlasTile,
|
||||||
|
pub layer: u8,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Resource, Default)]
|
#[derive(Resource, Default)]
|
||||||
pub struct RenderDirty(pub bool);
|
pub struct RenderDirty(pub bool);
|
||||||
|
|
||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
pub struct Hidden;
|
pub struct Hidden;
|
||||||
|
|
||||||
|
/// A component that controls entity visibility in the render system.
|
||||||
|
///
|
||||||
|
/// Entities without this component are considered visible by default.
|
||||||
|
/// This allows for efficient rendering where only entities that need
|
||||||
|
/// visibility control have this component.
|
||||||
|
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Visibility(pub bool);
|
||||||
|
|
||||||
|
impl Default for Visibility {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self(true) // Default to visible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Visibility {
|
||||||
|
/// Creates a visible Visibility component
|
||||||
|
pub fn visible() -> Self {
|
||||||
|
Self(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a hidden Visibility component
|
||||||
|
pub fn hidden() -> Self {
|
||||||
|
Self(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the entity is visible
|
||||||
|
pub fn is_visible(&self) -> bool {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the entity is hidden
|
||||||
|
#[allow(dead_code)] // Used in tests
|
||||||
|
pub fn is_hidden(&self) -> bool {
|
||||||
|
!self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes the entity visible
|
||||||
|
pub fn show(&mut self) {
|
||||||
|
self.0 = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggles the visibility state
|
||||||
|
pub fn toggle(&mut self) {
|
||||||
|
self.0 = !self.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Enum to identify which texture is being rendered to in the combined render system
|
/// Enum to identify which texture is being rendered to in the combined render system
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
enum RenderTarget {
|
enum RenderTarget {
|
||||||
@@ -38,75 +90,18 @@ enum RenderTarget {
|
|||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
pub fn dirty_render_system(
|
pub fn dirty_render_system(
|
||||||
mut dirty: ResMut<RenderDirty>,
|
mut dirty: ResMut<RenderDirty>,
|
||||||
changed: Query<(), Or<(Changed<Renderable>, Changed<Position>)>>,
|
changed: Query<(), Or<(Changed<Renderable>, Changed<Position>, Changed<Visibility>)>>,
|
||||||
removed_hidden: RemovedComponents<Hidden>,
|
|
||||||
removed_renderables: RemovedComponents<Renderable>,
|
removed_renderables: RemovedComponents<Renderable>,
|
||||||
) {
|
) {
|
||||||
if !changed.is_empty() || !removed_hidden.is_empty() || !removed_renderables.is_empty() {
|
if changed.iter().count() > 0 || !removed_renderables.is_empty() {
|
||||||
dirty.0 = true;
|
dirty.0 = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates directional animated entities with synchronized timing across directions.
|
/// Component for Renderables to store an exact pixel position
|
||||||
///
|
#[derive(Component)]
|
||||||
/// This runs before the render system to update sprites based on current direction and movement state.
|
pub struct PixelPosition {
|
||||||
/// All directions share the same frame timing to ensure perfect synchronization.
|
pub pixel_position: Vec2,
|
||||||
pub fn directional_render_system(
|
|
||||||
dt: Res<DeltaTime>,
|
|
||||||
mut query: Query<(&Position, &Velocity, &mut DirectionalAnimation, &mut Renderable)>,
|
|
||||||
) {
|
|
||||||
let ticks = (dt.0 * 60.0).round() as u16; // Convert from seconds to ticks at 60 ticks/sec
|
|
||||||
|
|
||||||
for (position, velocity, mut anim, mut renderable) in query.iter_mut() {
|
|
||||||
let stopped = matches!(position, Position::Stopped { .. });
|
|
||||||
|
|
||||||
// Only tick animation when moving to preserve stopped frame
|
|
||||||
if !stopped {
|
|
||||||
// Tick shared animation state
|
|
||||||
anim.time_bank += ticks;
|
|
||||||
while anim.time_bank >= anim.frame_duration {
|
|
||||||
anim.time_bank -= anim.frame_duration;
|
|
||||||
anim.current_frame += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get tiles for current direction and movement state
|
|
||||||
let tiles = if stopped {
|
|
||||||
anim.stopped_tiles.get(velocity.direction)
|
|
||||||
} else {
|
|
||||||
anim.moving_tiles.get(velocity.direction)
|
|
||||||
};
|
|
||||||
|
|
||||||
if !tiles.is_empty() {
|
|
||||||
let new_tile = tiles.get_tile(anim.current_frame);
|
|
||||||
if renderable.sprite != new_tile {
|
|
||||||
renderable.sprite = new_tile;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !anim.tiles.is_empty() {
|
|
||||||
let new_tile = anim.tiles.get_tile(anim.current_frame);
|
|
||||||
if renderable.sprite != new_tile {
|
|
||||||
renderable.sprite = new_tile;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A non-send resource for the map texture. This just wraps the texture with a type so it can be differentiated when exposed as a resource.
|
/// A non-send resource for the map texture. This just wraps the texture with a type so it can be differentiated when exposed as a resource.
|
||||||
@@ -115,152 +110,24 @@ pub struct MapTextureResource(pub Texture);
|
|||||||
/// A non-send resource for the backbuffer texture. This just wraps the texture with a type so it can be differentiated when exposed as a resource.
|
/// A non-send resource for the backbuffer texture. This just wraps the texture with a type so it can be differentiated when exposed as a resource.
|
||||||
pub struct BackbufferResource(pub Texture);
|
pub struct BackbufferResource(pub Texture);
|
||||||
|
|
||||||
/// Renders touch UI overlay for mobile/testing.
|
|
||||||
pub fn touch_ui_render_system(
|
|
||||||
mut backbuffer: NonSendMut<BackbufferResource>,
|
|
||||||
mut canvas: NonSendMut<&mut Canvas<Window>>,
|
|
||||||
touch_state: Res<TouchState>,
|
|
||||||
mut errors: EventWriter<GameError>,
|
|
||||||
) {
|
|
||||||
if let Some(ref touch_data) = touch_state.active_touch {
|
|
||||||
let _ = canvas.with_texture_canvas(&mut backbuffer.0, |canvas| {
|
|
||||||
// Set blend mode for transparency
|
|
||||||
canvas.set_blend_mode(BlendMode::Blend);
|
|
||||||
|
|
||||||
// Draw semi-transparent circle at touch start position
|
|
||||||
canvas.set_draw_color(Color::RGBA(255, 255, 255, 100));
|
|
||||||
let center = Point::new(touch_data.start_pos.x as i32, touch_data.start_pos.y as i32);
|
|
||||||
|
|
||||||
// Draw a simple circle by drawing filled rectangles (basic approach)
|
|
||||||
let radius = 30;
|
|
||||||
for dy in -radius..=radius {
|
|
||||||
for dx in -radius..=radius {
|
|
||||||
if dx * dx + dy * dy <= radius * radius {
|
|
||||||
let point = Point::new(center.x + dx, center.y + dy);
|
|
||||||
if let Err(e) = canvas.draw_point(point) {
|
|
||||||
errors.write(TextureError::RenderFailed(format!("Touch UI render error: {}", e)).into());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw direction indicator if we have a direction
|
|
||||||
if let Some(direction) = touch_data.current_direction {
|
|
||||||
canvas.set_draw_color(Color::RGBA(0, 255, 0, 150));
|
|
||||||
|
|
||||||
// Draw arrow indicating direction
|
|
||||||
let arrow_length = 40;
|
|
||||||
let (dx, dy) = match direction {
|
|
||||||
crate::map::direction::Direction::Up => (0, -arrow_length),
|
|
||||||
crate::map::direction::Direction::Down => (0, arrow_length),
|
|
||||||
crate::map::direction::Direction::Left => (-arrow_length, 0),
|
|
||||||
crate::map::direction::Direction::Right => (arrow_length, 0),
|
|
||||||
};
|
|
||||||
|
|
||||||
let end_point = Point::new(center.x + dx, center.y + dy);
|
|
||||||
if let Err(e) = canvas.draw_line(center, end_point) {
|
|
||||||
errors.write(TextureError::RenderFailed(format!("Touch arrow render error: {}", e)).into());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw arrowhead (simple approach)
|
|
||||||
let arrow_size = 8;
|
|
||||||
match direction {
|
|
||||||
crate::map::direction::Direction::Up => {
|
|
||||||
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y + arrow_size));
|
|
||||||
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y + arrow_size));
|
|
||||||
}
|
|
||||||
crate::map::direction::Direction::Down => {
|
|
||||||
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y - arrow_size));
|
|
||||||
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y - arrow_size));
|
|
||||||
}
|
|
||||||
crate::map::direction::Direction::Left => {
|
|
||||||
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y - arrow_size));
|
|
||||||
let _ = canvas.draw_line(end_point, Point::new(end_point.x + arrow_size, end_point.y + arrow_size));
|
|
||||||
}
|
|
||||||
crate::map::direction::Direction::Right => {
|
|
||||||
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y - arrow_size));
|
|
||||||
let _ = canvas.draw_line(end_point, Point::new(end_point.x - arrow_size, end_point.y + arrow_size));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Renders the HUD (score, lives, etc.) on top of the game.
|
|
||||||
pub fn hud_render_system(
|
|
||||||
mut backbuffer: NonSendMut<BackbufferResource>,
|
|
||||||
mut canvas: NonSendMut<&mut Canvas<Window>>,
|
|
||||||
mut atlas: NonSendMut<SpriteAtlas>,
|
|
||||||
score: Res<ScoreResource>,
|
|
||||||
startup: Res<StartupSequence>,
|
|
||||||
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_position = glam::UVec2::new(4 + 8 * 3, 2); // x_offset + lives_offset * 8, y_offset
|
|
||||||
|
|
||||||
if let Err(e) = text_renderer.render(canvas, &mut atlas, &lives_text, lives_position) {
|
|
||||||
errors.write(TextureError::RenderFailed(format!("Failed to render lives text: {}", e)).into());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render score text
|
|
||||||
let score_text = format!("{:02}", score.0);
|
|
||||||
let score_offset = 7 - (score_text.len() as i32);
|
|
||||||
let score_position = glam::UVec2::new(4 + 8 * score_offset as u32, 10); // x_offset + score_offset * 8, 8 + y_offset
|
|
||||||
|
|
||||||
if let Err(e) = text_renderer.render(canvas, &mut atlas, &score_text, score_position) {
|
|
||||||
errors.write(TextureError::RenderFailed(format!("Failed to render score text: {}", e)).into());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render high score text
|
|
||||||
let high_score_text = format!("{:02}", score.0);
|
|
||||||
let high_score_offset = 17 - (high_score_text.len() as i32);
|
|
||||||
let high_score_position = glam::UVec2::new(4 + 8 * high_score_offset as u32, 10); // x_offset + score_offset * 8, 8 + y_offset
|
|
||||||
if let Err(e) = text_renderer.render(canvas, &mut atlas, &high_score_text, high_score_position) {
|
|
||||||
errors.write(TextureError::RenderFailed(format!("Failed to render high score text: {}", e)).into());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render text based on StartupSequence stage
|
|
||||||
if matches!(
|
|
||||||
*startup,
|
|
||||||
StartupSequence::TextOnly { .. } | StartupSequence::CharactersVisible { .. }
|
|
||||||
) {
|
|
||||||
let ready_text = "READY!";
|
|
||||||
let ready_width = text_renderer.text_width(ready_text);
|
|
||||||
let ready_position = glam::UVec2::new((CANVAS_SIZE.x - ready_width) / 2, 160);
|
|
||||||
if let Err(e) = text_renderer.render_with_color(canvas, &mut atlas, ready_text, ready_position, Color::YELLOW) {
|
|
||||||
errors.write(TextureError::RenderFailed(format!("Failed to render READY text: {}", e)).into());
|
|
||||||
}
|
|
||||||
|
|
||||||
if matches!(*startup, StartupSequence::TextOnly { .. }) {
|
|
||||||
let player_one_text = "PLAYER ONE";
|
|
||||||
let player_one_width = text_renderer.text_width(player_one_text);
|
|
||||||
let player_one_position = glam::UVec2::new((CANVAS_SIZE.x - player_one_width) / 2, 113);
|
|
||||||
|
|
||||||
if let Err(e) =
|
|
||||||
text_renderer.render_with_color(canvas, &mut atlas, player_one_text, player_one_position, Color::CYAN)
|
|
||||||
{
|
|
||||||
errors.write(TextureError::RenderFailed(format!("Failed to render PLAYER ONE text: {}", e)).into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
pub fn render_system(
|
pub fn render_system(
|
||||||
canvas: &mut Canvas<Window>,
|
canvas: &mut Canvas<Window>,
|
||||||
map_texture: &NonSendMut<MapTextureResource>,
|
map_texture: &NonSendMut<MapTextureResource>,
|
||||||
atlas: &mut SpriteAtlas,
|
atlas: &mut SpriteAtlas,
|
||||||
map: &Res<Map>,
|
map: &Res<Map>,
|
||||||
dirty: &Res<RenderDirty>,
|
dirty: &Res<RenderDirty>,
|
||||||
renderables: &Query<(Entity, &Renderable, &Position), Without<Hidden>>,
|
renderables: &Query<
|
||||||
|
(
|
||||||
|
Entity,
|
||||||
|
&Renderable,
|
||||||
|
Option<&Position>,
|
||||||
|
Option<&PixelPosition>,
|
||||||
|
Option<&Visibility>,
|
||||||
|
),
|
||||||
|
Or<(With<Position>, With<PixelPosition>)>,
|
||||||
|
>,
|
||||||
errors: &mut EventWriter<GameError>,
|
errors: &mut EventWriter<GameError>,
|
||||||
) {
|
) {
|
||||||
if !dirty.0 {
|
if !dirty.0 {
|
||||||
@@ -276,13 +143,25 @@ pub fn render_system(
|
|||||||
errors.write(TextureError::RenderFailed(e.to_string()).into());
|
errors.write(TextureError::RenderFailed(e.to_string()).into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render all entities to the backbuffer
|
// Collect and filter visible entities, then sort by layer
|
||||||
for (_, renderable, position) in renderables
|
let mut visible_entities: Vec<_> = renderables
|
||||||
.iter()
|
.iter()
|
||||||
.sort_by_key::<(Entity, &Renderable, &Position), _>(|(_, renderable, _)| renderable.layer)
|
.filter(|(_, _, _, _, visibility)| visibility.copied().unwrap_or_default().is_visible())
|
||||||
.rev()
|
.collect();
|
||||||
{
|
|
||||||
let pos = position.get_pixel_position(&map.graph);
|
visible_entities.sort_by_key(|(_, renderable, _, _, _)| renderable.layer);
|
||||||
|
visible_entities.reverse();
|
||||||
|
|
||||||
|
// Render all visible entities to the backbuffer
|
||||||
|
for (_entity, renderable, position, pixel_position, _visibility) in visible_entities {
|
||||||
|
let pos = if let Some(position) = position {
|
||||||
|
position.get_pixel_position(&map.graph)
|
||||||
|
} else {
|
||||||
|
Ok(pixel_position
|
||||||
|
.expect("Pixel position should be present via query filtering, but got None on both")
|
||||||
|
.pixel_position)
|
||||||
|
};
|
||||||
|
|
||||||
match pos {
|
match pos {
|
||||||
Ok(pos) => {
|
Ok(pos) => {
|
||||||
let dest = Rect::from_center(
|
let dest = Rect::from_center(
|
||||||
@@ -307,6 +186,7 @@ pub fn render_system(
|
|||||||
/// Combined render system that renders to both backbuffer and debug textures in a single
|
/// Combined render system that renders to both backbuffer and debug textures in a single
|
||||||
/// with_multiple_texture_canvas call for reduced overhead
|
/// with_multiple_texture_canvas call for reduced overhead
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
pub fn combined_render_system(
|
pub fn combined_render_system(
|
||||||
mut canvas: NonSendMut<&mut Canvas<Window>>,
|
mut canvas: NonSendMut<&mut Canvas<Window>>,
|
||||||
map_texture: NonSendMut<MapTextureResource>,
|
map_texture: NonSendMut<MapTextureResource>,
|
||||||
@@ -317,9 +197,19 @@ pub fn combined_render_system(
|
|||||||
batched_lines: Res<BatchedLinesResource>,
|
batched_lines: Res<BatchedLinesResource>,
|
||||||
debug_state: Res<DebugState>,
|
debug_state: Res<DebugState>,
|
||||||
timings: Res<SystemTimings>,
|
timings: Res<SystemTimings>,
|
||||||
|
timing: Res<crate::systems::profiling::Timing>,
|
||||||
map: Res<Map>,
|
map: Res<Map>,
|
||||||
dirty: Res<RenderDirty>,
|
dirty: Res<RenderDirty>,
|
||||||
renderables: Query<(Entity, &Renderable, &Position), Without<Hidden>>,
|
renderables: Query<
|
||||||
|
(
|
||||||
|
Entity,
|
||||||
|
&Renderable,
|
||||||
|
Option<&Position>,
|
||||||
|
Option<&PixelPosition>,
|
||||||
|
Option<&Visibility>,
|
||||||
|
),
|
||||||
|
Or<(With<Position>, With<PixelPosition>)>,
|
||||||
|
>,
|
||||||
colliders: Query<(&Collider, &Position)>,
|
colliders: Query<(&Collider, &Position)>,
|
||||||
cursor: Res<CursorPosition>,
|
cursor: Res<CursorPosition>,
|
||||||
mut errors: EventWriter<GameError>,
|
mut errors: EventWriter<GameError>,
|
||||||
@@ -367,6 +257,7 @@ pub fn combined_render_system(
|
|||||||
&batched_lines,
|
&batched_lines,
|
||||||
&debug_state,
|
&debug_state,
|
||||||
&timings,
|
&timings,
|
||||||
|
&timing,
|
||||||
&map,
|
&map,
|
||||||
&colliders,
|
&colliders,
|
||||||
&cursor,
|
&cursor,
|
||||||
@@ -381,11 +272,13 @@ pub fn combined_render_system(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Record timings for each system independently
|
// Record timings for each system independently
|
||||||
|
let current_tick = timing.get_current_tick();
|
||||||
|
|
||||||
if let Some(duration) = render_duration {
|
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 {
|
if let Some(duration) = debug_render_duration {
|
||||||
timings.add_timing(SystemId::DebugRender, duration);
|
timings.add_timing(SystemId::DebugRender, duration, current_tick);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
410
src/systems/state.rs
Normal file
410
src/systems/state.rs
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
use std::mem::discriminant;
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
use crate::constants;
|
||||||
|
use crate::events::StageTransition;
|
||||||
|
use crate::map::direction::Direction;
|
||||||
|
use crate::systems::{EntityType, ItemCollider, SpawnTrigger, Velocity};
|
||||||
|
use crate::{
|
||||||
|
map::builder::Map,
|
||||||
|
systems::{
|
||||||
|
AudioEvent, Blinking, DirectionalAnimation, Dying, Frozen, Ghost, GhostCollider, GhostState, LinearAnimation, Looping,
|
||||||
|
NodeId, PlayerControlled, Position, Visibility,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use bevy_ecs::{
|
||||||
|
entity::Entity,
|
||||||
|
event::{EventReader, EventWriter},
|
||||||
|
query::{With, Without},
|
||||||
|
resource::Resource,
|
||||||
|
system::{Commands, Query, Res, ResMut, Single},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
ghost_type: Ghost,
|
||||||
|
node: NodeId,
|
||||||
|
},
|
||||||
|
/// The player has died and the death sequence is in progress. At the end, the player will return to the startup sequence or game over.
|
||||||
|
PlayerDying(DyingSequence),
|
||||||
|
/// The game has ended.
|
||||||
|
GameOver,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait TooSimilar {
|
||||||
|
fn too_similar(&self, other: &Self) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TooSimilar for GameStage {
|
||||||
|
fn too_similar(&self, other: &Self) -> bool {
|
||||||
|
discriminant(self) == discriminant(other) && {
|
||||||
|
// These states are very simple, so they're 'too similar' automatically
|
||||||
|
if matches!(self, GameStage::Playing | GameStage::GameOver) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since the discriminant is the same but the values are different, it's the interior value that is somehow different
|
||||||
|
match (self, other) {
|
||||||
|
// These states are similar if their interior values are similar as well
|
||||||
|
(GameStage::Starting(startup), GameStage::Starting(other)) => startup.too_similar(other),
|
||||||
|
(GameStage::PlayerDying(dying), GameStage::PlayerDying(other)) => dying.too_similar(other),
|
||||||
|
(
|
||||||
|
GameStage::GhostEatenPause {
|
||||||
|
ghost_entity,
|
||||||
|
ghost_type,
|
||||||
|
node,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
GameStage::GhostEatenPause {
|
||||||
|
ghost_entity: other_ghost_entity,
|
||||||
|
ghost_type: other_ghost_type,
|
||||||
|
node: other_node,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
) => ghost_entity == other_ghost_entity && ghost_type == other_ghost_type && node == other_node,
|
||||||
|
// Already handled, but kept to properly exhaust the match
|
||||||
|
(GameStage::Playing, _) | (GameStage::GameOver, _) => unreachable!(),
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A resource that manages the multi-stage startup sequence of the game.
|
||||||
|
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||||
|
pub enum StartupSequence {
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TooSimilar for StartupSequence {
|
||||||
|
fn too_similar(&self, other: &Self) -> bool {
|
||||||
|
discriminant(self) == discriminant(other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The state machine for the multi-stage death sequence.
|
||||||
|
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||||
|
pub enum DyingSequence {
|
||||||
|
/// 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 },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TooSimilar for DyingSequence {
|
||||||
|
fn too_similar(&self, other: &Self) -> bool {
|
||||||
|
discriminant(self) == discriminant(other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A resource to store the number of player lives.
|
||||||
|
#[derive(Resource, Debug)]
|
||||||
|
pub struct PlayerLives(pub u8);
|
||||||
|
|
||||||
|
impl Default for PlayerLives {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles startup sequence transitions and component management
|
||||||
|
#[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>>,
|
||||||
|
player: Single<(Entity, &mut Position), With<PlayerControlled>>,
|
||||||
|
mut item_query: Query<(Entity, &EntityType), With<ItemCollider>>,
|
||||||
|
mut ghost_query: Query<(Entity, &Ghost, &mut Position, &mut GhostState), (With<GhostCollider>, Without<PlayerControlled>)>,
|
||||||
|
) {
|
||||||
|
let old_state = *game_state;
|
||||||
|
let mut new_state: Option<GameStage> = None;
|
||||||
|
|
||||||
|
// Handle stage transition requests before normal ticking
|
||||||
|
for event in stage_event_reader.read() {
|
||||||
|
let StageTransition::GhostEatenPause {
|
||||||
|
ghost_entity,
|
||||||
|
ghost_type,
|
||||||
|
} = *event;
|
||||||
|
let pac_node = player.1.current_node();
|
||||||
|
|
||||||
|
debug!(ghost = ?ghost_type, node = pac_node, "Ghost eaten, entering pause state");
|
||||||
|
new_state = Some(GameStage::GhostEatenPause {
|
||||||
|
remaining_ticks: 30,
|
||||||
|
ghost_entity,
|
||||||
|
ghost_type,
|
||||||
|
node: pac_node,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_state: GameStage = match new_state.unwrap_or(*game_state) {
|
||||||
|
GameStage::Starting(startup) => match startup {
|
||||||
|
StartupSequence::TextOnly { remaining_ticks } => {
|
||||||
|
if remaining_ticks > 0 {
|
||||||
|
GameStage::Starting(StartupSequence::TextOnly {
|
||||||
|
remaining_ticks: remaining_ticks - 1,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StartupSequence::CharactersVisible { remaining_ticks } => {
|
||||||
|
if remaining_ticks > 0 {
|
||||||
|
GameStage::Starting(StartupSequence::CharactersVisible {
|
||||||
|
remaining_ticks: remaining_ticks - 1,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
info!("Startup sequence completed, beginning gameplay");
|
||||||
|
GameStage::Playing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
GameStage::Playing => GameStage::Playing,
|
||||||
|
GameStage::GhostEatenPause {
|
||||||
|
remaining_ticks,
|
||||||
|
ghost_entity,
|
||||||
|
ghost_type,
|
||||||
|
node,
|
||||||
|
} => {
|
||||||
|
if remaining_ticks > 0 {
|
||||||
|
GameStage::GhostEatenPause {
|
||||||
|
remaining_ticks: remaining_ticks.saturating_sub(1),
|
||||||
|
ghost_entity,
|
||||||
|
ghost_type,
|
||||||
|
node,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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, returning to startup sequence");
|
||||||
|
GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 })
|
||||||
|
} else {
|
||||||
|
warn!("All lives lost, game over");
|
||||||
|
GameStage::GameOver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
GameStage::GameOver => GameStage::GameOver,
|
||||||
|
};
|
||||||
|
|
||||||
|
if old_state == new_state {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !old_state.too_similar(&new_state) {
|
||||||
|
debug!(old_state = ?old_state, new_state = ?new_state, "Game stage transition");
|
||||||
|
}
|
||||||
|
|
||||||
|
match (old_state, new_state) {
|
||||||
|
(GameStage::Playing, GameStage::GhostEatenPause { ghost_entity, node, .. }) => {
|
||||||
|
// Freeze the player & non-eaten ghosts
|
||||||
|
commands.entity(player.0).insert(Frozen);
|
||||||
|
commands.entity(ghost_entity).insert(Frozen);
|
||||||
|
for (entity, _, _, state) in ghost_query.iter_mut() {
|
||||||
|
// Only freeze ghosts that are not currently eaten
|
||||||
|
if *state != GhostState::Eyes {
|
||||||
|
debug!(ghost = ?entity, "Freezing ghost");
|
||||||
|
commands.entity(entity).insert(Frozen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the player & eaten ghost
|
||||||
|
commands.entity(player.0).insert(Visibility::hidden());
|
||||||
|
commands.entity(ghost_entity).insert(Visibility::hidden());
|
||||||
|
|
||||||
|
// Spawn bonus points entity at Pac-Man's position
|
||||||
|
commands.trigger(SpawnTrigger::Bonus {
|
||||||
|
position: Position::Stopped { node },
|
||||||
|
// TODO: Doubling score value for each consecutive ghost eaten
|
||||||
|
value: 200,
|
||||||
|
ttl: 30,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
(GameStage::GhostEatenPause { ghost_entity, .. }, GameStage::Playing) => {
|
||||||
|
// Unfreeze and reveal the player & all ghosts
|
||||||
|
commands.entity(player.0).remove::<Frozen>().insert(Visibility::visible());
|
||||||
|
for (entity, _, _, _) in ghost_query.iter_mut() {
|
||||||
|
commands.entity(entity).remove::<Frozen>().insert(Visibility::visible());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reveal the eaten ghost and switch it to Eyes state
|
||||||
|
commands.entity(ghost_entity).insert(GhostState::Eyes);
|
||||||
|
}
|
||||||
|
(_, GameStage::PlayerDying(DyingSequence::Frozen { .. })) => {
|
||||||
|
// Freeze the player & ghosts
|
||||||
|
commands.entity(player.0).insert(Frozen);
|
||||||
|
for (entity, _, _, _) in ghost_query.iter_mut() {
|
||||||
|
commands.entity(entity).insert(Frozen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(GameStage::PlayerDying(DyingSequence::Frozen { .. }), GameStage::PlayerDying(DyingSequence::Animating { .. })) => {
|
||||||
|
// Hide the ghosts
|
||||||
|
for (entity, _, _, _) in ghost_query.iter_mut() {
|
||||||
|
commands.entity(entity).insert(Visibility::hidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start Pac-Man's death animation
|
||||||
|
commands
|
||||||
|
.entity(player.0)
|
||||||
|
.remove::<DirectionalAnimation>()
|
||||||
|
.insert((Dying, player_death_animation.0.clone()));
|
||||||
|
|
||||||
|
// Play the death sound
|
||||||
|
audio_events.write(AudioEvent::PlayDeath);
|
||||||
|
}
|
||||||
|
(_, GameStage::PlayerDying(DyingSequence::Hidden { .. })) => {
|
||||||
|
// Pac-Man's death animation is complete, so he should be hidden just like the ghosts.
|
||||||
|
// Then, we reset them all back to their original positions and states.
|
||||||
|
|
||||||
|
// Freeze the blinking power pellets, force them to be visible (if they were hidden by blinking)
|
||||||
|
for entity in blinking_query.iter_mut() {
|
||||||
|
commands.entity(entity).insert(Frozen).insert(Visibility::visible());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete any fruit entities
|
||||||
|
for (entity, _) in item_query
|
||||||
|
.iter_mut()
|
||||||
|
.filter(|(_, entity_type)| matches!(entity_type, EntityType::Fruit(_)))
|
||||||
|
{
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the player animation
|
||||||
|
commands
|
||||||
|
.entity(player.0)
|
||||||
|
.remove::<(Dying, LinearAnimation, Looping)>()
|
||||||
|
.insert((
|
||||||
|
Velocity {
|
||||||
|
speed: constants::mechanics::PLAYER_SPEED,
|
||||||
|
direction: Direction::Left,
|
||||||
|
},
|
||||||
|
Position::Stopped {
|
||||||
|
node: map.start_positions.pacman,
|
||||||
|
},
|
||||||
|
player_animation.0.clone(),
|
||||||
|
Visibility::hidden(),
|
||||||
|
Frozen,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Reset ghost positions and state
|
||||||
|
for (ghost_entity, ghost, _, _) in ghost_query.iter_mut() {
|
||||||
|
commands.entity(ghost_entity).insert((
|
||||||
|
GhostState::Normal,
|
||||||
|
Position::Stopped {
|
||||||
|
node: match ghost {
|
||||||
|
Ghost::Blinky => map.start_positions.blinky,
|
||||||
|
Ghost::Pinky => map.start_positions.pinky,
|
||||||
|
Ghost::Inky => map.start_positions.inky,
|
||||||
|
Ghost::Clyde => map.start_positions.clyde,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Frozen,
|
||||||
|
Visibility::hidden(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(_, GameStage::Starting(StartupSequence::CharactersVisible { .. })) => {
|
||||||
|
// Unhide the player & ghosts
|
||||||
|
commands.entity(player.0).insert(Visibility::visible());
|
||||||
|
for (entity, _, _, _) in ghost_query.iter_mut() {
|
||||||
|
commands.entity(entity).insert(Visibility::visible());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(GameStage::Starting(StartupSequence::CharactersVisible { .. }), GameStage::Playing) => {
|
||||||
|
// Unfreeze the player & ghosts & blinking
|
||||||
|
commands.entity(player.0).remove::<Frozen>();
|
||||||
|
for (entity, _, _, _) in ghost_query.iter_mut() {
|
||||||
|
commands.entity(entity).remove::<Frozen>();
|
||||||
|
}
|
||||||
|
for entity in blinking_query.iter_mut() {
|
||||||
|
commands.entity(entity).remove::<Frozen>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(_, GameStage::GameOver) => {
|
||||||
|
// Freeze blinking
|
||||||
|
for entity in blinking_query.iter_mut() {
|
||||||
|
commands.entity(entity).insert(Frozen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
*game_state = new_state;
|
||||||
|
}
|
||||||
@@ -1,53 +1,45 @@
|
|||||||
use crate::map::direction::Direction;
|
use glam::U16Vec2;
|
||||||
use crate::texture::sprite::AtlasTile;
|
|
||||||
|
|
||||||
/// Fixed-size tile sequence that avoids heap allocation
|
use crate::{map::direction::Direction, texture::sprite::AtlasTile};
|
||||||
#[derive(Clone, Copy, Debug)]
|
|
||||||
|
/// A sequence of tiles for animation, backed by a vector.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
pub struct TileSequence {
|
pub struct TileSequence {
|
||||||
tiles: [AtlasTile; 4], // Fixed array, max 4 frames
|
tiles: Vec<AtlasTile>,
|
||||||
count: usize, // Actual number of frames used
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TileSequence {
|
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 {
|
pub fn new(tiles: &[AtlasTile]) -> Self {
|
||||||
let mut tile_array = [AtlasTile {
|
Self { tiles: tiles.to_vec() }
|
||||||
pos: glam::U16Vec2::ZERO,
|
|
||||||
size: glam::U16Vec2::ZERO,
|
|
||||||
color: None,
|
|
||||||
}; 4];
|
|
||||||
|
|
||||||
let count = tiles.len().min(4);
|
|
||||||
tile_array[..count].copy_from_slice(&tiles[..count]);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
tiles: tile_array,
|
|
||||||
count,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the tile at the given frame index, wrapping if necessary
|
/// Returns the tile at the given frame index, wrapping if necessary
|
||||||
pub fn get_tile(&self, frame: usize) -> AtlasTile {
|
pub fn get_tile(&self, frame: usize) -> AtlasTile {
|
||||||
if self.count == 0 {
|
if self.tiles.is_empty() {
|
||||||
// Return a default empty tile if no tiles
|
// Return a default or handle the error appropriately
|
||||||
AtlasTile {
|
// For now, let's return a default tile, assuming it's a sensible default
|
||||||
pos: glam::U16Vec2::ZERO,
|
return AtlasTile {
|
||||||
size: glam::U16Vec2::ZERO,
|
pos: U16Vec2::ZERO,
|
||||||
|
size: U16Vec2::ZERO,
|
||||||
color: None,
|
color: None,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} else {
|
self.tiles[frame % self.tiles.len()]
|
||||||
self.tiles[frame % self.count]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 {
|
pub fn is_empty(&self) -> bool {
|
||||||
self.count == 0
|
self.tiles.is_empty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Type-safe directional tile storage with named fields
|
/// A collection of tile sequences for each cardinal direction.
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DirectionalTiles {
|
pub struct DirectionalTiles {
|
||||||
pub up: TileSequence,
|
pub up: TileSequence,
|
||||||
pub down: TileSequence,
|
pub down: TileSequence,
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
pub mod animated;
|
pub mod animated;
|
||||||
pub mod blinking;
|
|
||||||
pub mod sprite;
|
pub mod sprite;
|
||||||
|
pub mod sprites;
|
||||||
pub mod text;
|
pub mod text;
|
||||||
pub mod ttf;
|
pub mod ttf;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use sdl2::pixels::Color;
|
|||||||
use sdl2::rect::Rect;
|
use sdl2::rect::Rect;
|
||||||
use sdl2::render::{Canvas, RenderTarget, Texture};
|
use sdl2::render::{Canvas, RenderTarget, Texture};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
use crate::error::TextureError;
|
use crate::error::TextureError;
|
||||||
|
|
||||||
@@ -20,7 +21,8 @@ pub struct MapperFrame {
|
|||||||
pub size: U16Vec2,
|
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 struct AtlasTile {
|
||||||
pub pos: U16Vec2,
|
pub pos: U16Vec2,
|
||||||
pub size: U16Vec2,
|
pub size: U16Vec2,
|
||||||
@@ -56,19 +58,6 @@ impl AtlasTile {
|
|||||||
canvas.copy(&atlas.texture, src, dest).map_err(TextureError::RenderFailed)?;
|
canvas.copy(&atlas.texture, src, dest).map_err(TextureError::RenderFailed)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a new atlas tile.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn new(pos: U16Vec2, size: U16Vec2, color: Option<Color>) -> Self {
|
|
||||||
Self { pos, size, color }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the color of the tile.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn with_color(mut self, color: Color) -> Self {
|
|
||||||
self.color = Some(color);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// High-performance sprite atlas providing fast texture region lookups and rendering.
|
/// High-performance sprite atlas providing fast texture region lookups and rendering.
|
||||||
@@ -89,9 +78,13 @@ pub struct SpriteAtlas {
|
|||||||
|
|
||||||
impl SpriteAtlas {
|
impl SpriteAtlas {
|
||||||
pub fn new(texture: Texture, mapper: AtlasMapper) -> Self {
|
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 {
|
Self {
|
||||||
texture,
|
texture,
|
||||||
tiles: mapper.frames,
|
tiles,
|
||||||
default_color: None,
|
default_color: None,
|
||||||
last_modulation: None,
|
last_modulation: None,
|
||||||
}
|
}
|
||||||
@@ -103,39 +96,15 @@ impl SpriteAtlas {
|
|||||||
/// for the named sprite, or `None` if the sprite name is not found in the
|
/// 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
|
/// atlas. The returned tile can be used for immediate rendering or stored
|
||||||
/// for repeated use in animations and entity sprites.
|
/// for repeated use in animations and entity sprites.
|
||||||
pub fn get_tile(&self, name: &str) -> Option<AtlasTile> {
|
pub fn get_tile(&self, name: &str) -> Result<AtlasTile, TextureError> {
|
||||||
self.tiles.get(name).map(|frame| AtlasTile {
|
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,
|
pos: frame.pos,
|
||||||
size: frame.size,
|
size: frame.size,
|
||||||
color: None,
|
color: self.default_color,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn set_color(&mut self, color: Color) {
|
|
||||||
self.default_color = Some(color);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn texture(&self) -> &Texture {
|
|
||||||
&self.texture
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the number of tiles in the atlas.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn tiles_count(&self) -> usize {
|
|
||||||
self.tiles.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if the atlas has a tile with the given name.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn has_tile(&self, name: &str) -> bool {
|
|
||||||
self.tiles.contains_key(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the default color of the atlas.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn default_color(&self) -> Option<Color> {
|
|
||||||
self.default_color
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
133
src/texture/sprites.rs
Normal file
133
src/texture/sprites.rs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
//! 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,
|
||||||
|
systems::{FruitType, 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents the different effect sprites that can appear as bonus items.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum EffectSprite {
|
||||||
|
Bonus(u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A top-level enum that encompasses all game sprites.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum GameSprite {
|
||||||
|
Pacman(PacmanSprite),
|
||||||
|
Ghost(GhostSprite),
|
||||||
|
Maze(MazeSprite),
|
||||||
|
Fruit(FruitType),
|
||||||
|
Effect(EffectSprite),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GameSprite {
|
||||||
|
/// 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(),
|
||||||
|
|
||||||
|
// Fruit sprites
|
||||||
|
GameSprite::Fruit(fruit) => format!("edible/{}.png", Into::<&'static str>::into(fruit)),
|
||||||
|
|
||||||
|
// Effect sprites
|
||||||
|
GameSprite::Effect(EffectSprite::Bonus(value)) => match value {
|
||||||
|
100 | 200 | 300 | 400 | 700 | 800 | 1000 | 2000 | 3000 | 5000 => format!("effects/{}.png", value),
|
||||||
|
_ => {
|
||||||
|
tracing::warn!("Invalid bonus value: {}", value);
|
||||||
|
"effects/100.png".to_string()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
//! This module provides text rendering using the texture atlas.
|
//! This module provides text rendering using the texture atlas.
|
||||||
//!
|
//!
|
||||||
//! The TextTexture system renders text from the atlas using character mapping.
|
//! The TextTexture system renders text from the atlas using character mapping.
|
||||||
@@ -60,10 +58,7 @@ use sdl2::pixels::Color;
|
|||||||
use sdl2::render::{Canvas, RenderTarget};
|
use sdl2::render::{Canvas, RenderTarget};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::{
|
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
|
||||||
error::{GameError, TextureError},
|
|
||||||
texture::sprite::{AtlasTile, SpriteAtlas},
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Converts a character to its tile name in the atlas.
|
/// Converts a character to its tile name in the atlas.
|
||||||
fn char_to_tile_name(c: char) -> Option<String> {
|
fn char_to_tile_name(c: char) -> Option<String> {
|
||||||
@@ -112,6 +107,7 @@ impl TextTexture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn get_char_map(&self) -> &HashMap<char, AtlasTile> {
|
pub fn get_char_map(&self) -> &HashMap<char, AtlasTile> {
|
||||||
&self.char_map
|
&self.char_map
|
||||||
}
|
}
|
||||||
@@ -122,9 +118,7 @@ impl TextTexture {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(tile_name) = char_to_tile_name(c) {
|
if let Some(tile_name) = char_to_tile_name(c) {
|
||||||
let tile = atlas
|
let tile = atlas.get_tile(&tile_name)?;
|
||||||
.get_tile(&tile_name)
|
|
||||||
.ok_or(GameError::Texture(TextureError::AtlasTileNotFound(tile_name)))?;
|
|
||||||
self.char_map.insert(c, tile);
|
self.char_map.insert(c, tile);
|
||||||
Ok(self.char_map.get(&c))
|
Ok(self.char_map.get(&c))
|
||||||
} else {
|
} else {
|
||||||
@@ -172,26 +166,6 @@ impl TextTexture {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets the default color for text rendering.
|
|
||||||
pub fn set_color(&mut self, color: Color) {
|
|
||||||
self.default_color = Some(color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets the current default color.
|
|
||||||
pub fn color(&self) -> Option<Color> {
|
|
||||||
self.default_color
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the scale for text rendering.
|
|
||||||
pub fn set_scale(&mut self, scale: f32) {
|
|
||||||
self.scale = scale;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets the current scale.
|
|
||||||
pub fn scale(&self) -> f32 {
|
|
||||||
self.scale
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calculates the width of a string in pixels at the current scale.
|
/// Calculates the width of a string in pixels at the current scale.
|
||||||
pub fn text_width(&self, text: &str) -> u32 {
|
pub fn text_width(&self, text: &str) -> u32 {
|
||||||
let char_width = (8.0 * self.scale) as u32;
|
let char_width = (8.0 * self.scale) as u32;
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ pub struct TtfAtlas {
|
|||||||
char_tiles: HashMap<char, TtfCharTile>,
|
char_tiles: HashMap<char, TtfCharTile>,
|
||||||
/// Cached color modulation state to avoid redundant SDL2 calls
|
/// Cached color modulation state to avoid redundant SDL2 calls
|
||||||
last_modulation: Option<Color>,
|
last_modulation: Option<Color>,
|
||||||
|
/// Cached maximum character height
|
||||||
|
max_char_height: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
const TTF_CHARS: &str = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.,:-/()ms μµ%± ";
|
const TTF_CHARS: &str = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.,:-/()ms μµ%± ";
|
||||||
@@ -101,6 +103,7 @@ impl TtfAtlas {
|
|||||||
texture: atlas_texture,
|
texture: atlas_texture,
|
||||||
char_tiles,
|
char_tiles,
|
||||||
last_modulation: None,
|
last_modulation: None,
|
||||||
|
max_char_height: max_height,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,12 +264,6 @@ impl TtfRenderer {
|
|||||||
/// Calculate the height of text in pixels
|
/// Calculate the height of text in pixels
|
||||||
pub fn text_height(&self, atlas: &TtfAtlas) -> u32 {
|
pub fn text_height(&self, atlas: &TtfAtlas) -> u32 {
|
||||||
// Find the maximum height among all characters
|
// Find the maximum height among all characters
|
||||||
atlas
|
(atlas.max_char_height as f32 * self.scale) as u32
|
||||||
.char_tiles
|
|
||||||
.values()
|
|
||||||
.map(|tile| tile.size.y)
|
|
||||||
.max()
|
|
||||||
.unwrap_or(0)
|
|
||||||
.saturating_mul(self.scale as u32)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
tests/asset.rs
Normal file
17
tests/asset.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,40 +1,327 @@
|
|||||||
use pacman::texture::blinking::BlinkingTexture;
|
use bevy_ecs::{entity::Entity, system::RunSystemOnce, world::World};
|
||||||
|
use pacman::systems::{blinking_system, Blinking, DeltaTime, Frozen, Renderable, Visibility};
|
||||||
|
use speculoos::prelude::*;
|
||||||
|
|
||||||
mod common;
|
mod common;
|
||||||
|
|
||||||
#[test]
|
/// Creates a test world with blinking system resources
|
||||||
fn test_blinking_texture() {
|
fn create_blinking_test_world() -> World {
|
||||||
let tile = common::mock_atlas_tile(1);
|
let mut world = World::new();
|
||||||
let mut texture = BlinkingTexture::new(tile, 0.5);
|
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,
|
||||||
|
},
|
||||||
|
Visibility::visible(),
|
||||||
|
))
|
||||||
|
.id()
|
||||||
|
}
|
||||||
|
|
||||||
texture.tick(0.5);
|
/// Spawns a test entity with blinking, renderable, and hidden visibility
|
||||||
assert!(!texture.is_on());
|
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,
|
||||||
|
},
|
||||||
|
Visibility::hidden(),
|
||||||
|
))
|
||||||
|
.id()
|
||||||
|
}
|
||||||
|
|
||||||
texture.tick(0.5);
|
/// Spawns a test entity with blinking, renderable, and frozen components
|
||||||
assert!(texture.is_on());
|
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,
|
||||||
|
},
|
||||||
|
Visibility::visible(),
|
||||||
|
Frozen,
|
||||||
|
))
|
||||||
|
.id()
|
||||||
|
}
|
||||||
|
|
||||||
texture.tick(0.5);
|
/// Spawns a test entity with blinking, renderable, hidden visibility, and frozen components
|
||||||
assert!(!texture.is_on());
|
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,
|
||||||
|
},
|
||||||
|
Visibility::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 is visible
|
||||||
|
fn is_entity_visible(world: &World, entity: Entity) -> bool {
|
||||||
|
world
|
||||||
|
.entity(entity)
|
||||||
|
.get::<Visibility>()
|
||||||
|
.map(|v| v.is_visible())
|
||||||
|
.unwrap_or(true) // Default to visible if no Visibility component
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if an entity is hidden
|
||||||
|
fn is_entity_hidden(world: &World, entity: Entity) -> bool {
|
||||||
|
world
|
||||||
|
.entity(entity)
|
||||||
|
.get::<Visibility>()
|
||||||
|
.map(|v| v.is_hidden())
|
||||||
|
.unwrap_or(false) // Default to visible if no Visibility component
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if an entity has the Frozen component
|
||||||
|
fn has_frozen_component(world: &World, entity: Entity) -> bool {
|
||||||
|
world.entity(entity).contains::<Frozen>()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_blinking_texture_partial_duration() {
|
fn test_blinking_component_creation() {
|
||||||
let tile = common::mock_atlas_tile(1);
|
let blinking = Blinking::new(10);
|
||||||
let mut texture = BlinkingTexture::new(tile, 0.5);
|
|
||||||
|
|
||||||
texture.tick(0.625);
|
assert_that(&blinking.tick_timer).is_equal_to(0);
|
||||||
assert!(!texture.is_on());
|
assert_that(&blinking.interval_ticks).is_equal_to(10);
|
||||||
assert_eq!(texture.time_bank(), 0.125);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_blinking_texture_negative_time() {
|
fn test_blinking_system_normal_interval_no_toggle() {
|
||||||
let tile = common::mock_atlas_tile(1);
|
let mut world = create_blinking_test_world();
|
||||||
let mut texture = BlinkingTexture::new(tile, 0.5);
|
let entity = spawn_blinking_entity(&mut world, 5);
|
||||||
|
|
||||||
texture.tick(-0.1);
|
// Run system with 3 ticks (less than interval)
|
||||||
assert!(texture.is_on());
|
run_blinking_system(&mut world, 3);
|
||||||
assert_eq!(texture.time_bank(), -0.1);
|
|
||||||
|
// Entity should not be hidden yet
|
||||||
|
assert_that(&is_entity_visible(&world, entity)).is_true();
|
||||||
|
|
||||||
|
// 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(&is_entity_hidden(&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(&is_entity_hidden(&world, entity)).is_true();
|
||||||
|
|
||||||
|
// Second toggle: another 5 ticks
|
||||||
|
run_blinking_system(&mut world, 5);
|
||||||
|
assert_that(&is_entity_visible(&world, entity)).is_true();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(&is_entity_visible(&world, entity)).is_true();
|
||||||
|
|
||||||
|
// 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(&is_entity_visible(&world, entity)).is_true();
|
||||||
|
|
||||||
|
// 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(&is_entity_hidden(&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(&is_entity_visible(&world, entity)).is_true();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(&is_entity_visible(&world, entity)).is_true();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(&is_entity_visible(&world, entity)).is_true();
|
||||||
|
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(&is_entity_visible(&world, entity)).is_true();
|
||||||
|
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(&is_entity_visible(&world, entity)).is_true();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(&is_entity_visible(&world, entity)).is_true();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(&is_entity_visible(&world, entity)).is_true();
|
||||||
|
|
||||||
|
// 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(&is_entity_hidden(&world, entity)).is_true();
|
||||||
|
|
||||||
|
// Run system with another 1 tick
|
||||||
|
run_blinking_system(&mut world, 1);
|
||||||
|
|
||||||
|
// Entity should be unhidden
|
||||||
|
assert_that(&is_entity_visible(&world, entity)).is_true();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use bevy_ecs::system::RunSystemOnce;
|
use bevy_ecs::system::RunSystemOnce;
|
||||||
use pacman::systems::{check_collision, collision_system, Collider, EntityType, GhostState, Position};
|
use pacman::systems::{check_collision, collision_system, Collider, EntityType, GhostState, Position};
|
||||||
|
use speculoos::prelude::*;
|
||||||
|
|
||||||
mod common;
|
mod common;
|
||||||
|
|
||||||
@@ -9,8 +10,8 @@ fn test_collider_collision_detection() {
|
|||||||
let collider2 = Collider { size: 8.0 };
|
let collider2 = Collider { size: 8.0 };
|
||||||
|
|
||||||
// Test collision detection
|
// Test collision detection
|
||||||
assert!(collider1.collides_with(collider2.size, 5.0)); // Should collide (distance < 9.0)
|
assert_that(&collider1.collides_with(collider2.size, 5.0)).is_true(); // 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, 15.0)).is_false(); // Should not collide (distance > 9.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -23,31 +24,29 @@ fn test_check_collision_helper() {
|
|||||||
|
|
||||||
// Test collision at same position
|
// Test collision at same position
|
||||||
let result = check_collision(&pos1, &collider1, &pos2, &collider2, &map);
|
let result = check_collision(&pos1, &collider1, &pos2, &collider2, &map);
|
||||||
assert!(result.is_ok());
|
assert_that(&result.is_ok()).is_true();
|
||||||
assert!(result.unwrap()); // Should collide at same position
|
assert_that(&result.unwrap()).is_true(); // Should collide at same position
|
||||||
|
|
||||||
// Test collision at different positions
|
// Test collision at different positions
|
||||||
let pos3 = Position::Stopped { node: 1 }; // Different position
|
let pos3 = Position::Stopped { node: 1 }; // Different position
|
||||||
let result = check_collision(&pos1, &collider1, &pos3, &collider2, &map);
|
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
|
// May or may not collide depending on actual node positions
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_collision_system_pacman_item() {
|
fn test_collision_system_pacman_item() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, mut schedule) = common::create_test_world();
|
||||||
let _pacman = common::spawn_test_pacman(&mut world, 0);
|
let _pacman = common::spawn_test_pacman(&mut world, 0);
|
||||||
let _item = common::spawn_test_item(&mut world, 0, EntityType::Pellet);
|
let _item = common::spawn_test_item(&mut world, 0, EntityType::Pellet);
|
||||||
|
|
||||||
// Run collision system - should not panic
|
// Run collision system - should not panic
|
||||||
world
|
schedule.run(&mut world);
|
||||||
.run_system_once(collision_system)
|
|
||||||
.expect("System should run successfully");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_collision_system_pacman_ghost() {
|
fn test_collision_system_pacman_ghost() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let _pacman = common::spawn_test_pacman(&mut world, 0);
|
let _pacman = common::spawn_test_pacman(&mut world, 0);
|
||||||
let _ghost = common::spawn_test_ghost(&mut world, 0, GhostState::Normal);
|
let _ghost = common::spawn_test_ghost(&mut world, 0, GhostState::Normal);
|
||||||
|
|
||||||
@@ -59,19 +58,17 @@ fn test_collision_system_pacman_ghost() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_collision_system_no_collision() {
|
fn test_collision_system_no_collision() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, mut schedule) = common::create_test_world();
|
||||||
let _pacman = common::spawn_test_pacman(&mut world, 0);
|
let _pacman = common::spawn_test_pacman(&mut world, 0);
|
||||||
let _ghost = common::spawn_test_ghost(&mut world, 1, GhostState::Normal); // Different node
|
let _ghost = common::spawn_test_ghost(&mut world, 1, GhostState::Normal); // Different node
|
||||||
|
|
||||||
// Run collision system - should not panic
|
// Run collision system - should not panic
|
||||||
world
|
schedule.run(&mut world);
|
||||||
.run_system_once(collision_system)
|
|
||||||
.expect("System should run successfully");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_collision_system_multiple_entities() {
|
fn test_collision_system_multiple_entities() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let _pacman = common::spawn_test_pacman(&mut world, 0);
|
let _pacman = common::spawn_test_pacman(&mut world, 0);
|
||||||
let _item = common::spawn_test_item(&mut world, 0, EntityType::Pellet);
|
let _item = common::spawn_test_item(&mut world, 0, EntityType::Pellet);
|
||||||
let _ghost = common::spawn_test_ghost(&mut world, 0, GhostState::Normal);
|
let _ghost = common::spawn_test_ghost(&mut world, 0, GhostState::Normal);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use bevy_ecs::{entity::Entity, event::Events, world::World};
|
use bevy_ecs::{entity::Entity, event::Events, schedule::Schedule, world::World};
|
||||||
use glam::{U16Vec2, Vec2};
|
use glam::{U16Vec2, Vec2};
|
||||||
use pacman::{
|
use pacman::{
|
||||||
asset::{get_asset_bytes, Asset},
|
asset::{get_asset_bytes, Asset},
|
||||||
constants::RAW_BOARD,
|
constants::RAW_BOARD,
|
||||||
events::GameEvent,
|
events::{CollisionTrigger, GameEvent},
|
||||||
game::ATLAS_FRAMES,
|
game::ATLAS_FRAMES,
|
||||||
map::{
|
map::{
|
||||||
builder::Map,
|
builder::Map,
|
||||||
@@ -13,8 +13,9 @@ use pacman::{
|
|||||||
graph::{Graph, Node},
|
graph::{Graph, Node},
|
||||||
},
|
},
|
||||||
systems::{
|
systems::{
|
||||||
AudioEvent, AudioState, BufferedDirection, Collider, DebugState, DeltaTime, EntityType, Ghost, GhostCollider, GhostState,
|
item_collision_observer, AudioEvent, AudioState, BufferedDirection, Collider, DebugState, DeltaTime, EntityType,
|
||||||
GlobalState, ItemCollider, MovementModifiers, PacmanCollider, PlayerControlled, Position, ScoreResource, Velocity,
|
FruitSprites, Ghost, GhostCollider, GhostState, GlobalState, ItemCollider, MovementModifiers, PacmanCollider,
|
||||||
|
PelletCount, PlayerControlled, Position, ScoreResource, Velocity,
|
||||||
},
|
},
|
||||||
texture::sprite::{AtlasMapper, AtlasTile, SpriteAtlas},
|
texture::sprite::{AtlasMapper, AtlasTile, SpriteAtlas},
|
||||||
};
|
};
|
||||||
@@ -74,7 +75,7 @@ pub fn create_test_graph() -> Graph {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a basic test world with required resources for ECS systems
|
/// Creates a basic test world with required resources for ECS systems
|
||||||
pub fn create_test_world() -> World {
|
pub fn create_test_world() -> (World, Schedule) {
|
||||||
let mut world = World::new();
|
let mut world = World::new();
|
||||||
|
|
||||||
// Add required resources
|
// Add required resources
|
||||||
@@ -82,13 +83,22 @@ pub fn create_test_world() -> World {
|
|||||||
world.insert_resource(Events::<pacman::error::GameError>::default());
|
world.insert_resource(Events::<pacman::error::GameError>::default());
|
||||||
world.insert_resource(Events::<AudioEvent>::default());
|
world.insert_resource(Events::<AudioEvent>::default());
|
||||||
world.insert_resource(ScoreResource(0));
|
world.insert_resource(ScoreResource(0));
|
||||||
|
world.insert_resource(FruitSprites::default());
|
||||||
world.insert_resource(AudioState::default());
|
world.insert_resource(AudioState::default());
|
||||||
world.insert_resource(GlobalState { exit: false });
|
world.insert_resource(GlobalState { exit: false });
|
||||||
world.insert_resource(DebugState::default());
|
world.insert_resource(DebugState::default());
|
||||||
world.insert_resource(DeltaTime(1.0 / 60.0)); // 60 FPS
|
world.insert_resource(PelletCount(0));
|
||||||
|
world.insert_resource(DeltaTime {
|
||||||
|
seconds: 1.0 / 60.0,
|
||||||
|
ticks: 1,
|
||||||
|
}); // 60 FPS
|
||||||
world.insert_resource(create_test_map());
|
world.insert_resource(create_test_map());
|
||||||
|
|
||||||
world
|
let schedule = Schedule::default();
|
||||||
|
|
||||||
|
world.add_observer(item_collision_observer);
|
||||||
|
|
||||||
|
(world, schedule)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a test map using the default RAW_BOARD
|
/// Creates a test map using the default RAW_BOARD
|
||||||
@@ -158,9 +168,8 @@ pub fn send_game_event(world: &mut World, event: GameEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sends a collision event between two entities
|
/// Sends a collision event between two entities
|
||||||
pub fn send_collision_event(world: &mut World, entity1: Entity, entity2: Entity) {
|
pub fn trigger_collision(world: &mut World, event: CollisionTrigger) {
|
||||||
let mut events = world.resource_mut::<Events<GameEvent>>();
|
world.trigger(event);
|
||||||
events.send(GameEvent::Collision(entity1, entity2));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a mock atlas tile for testing
|
/// Creates a mock atlas tile for testing
|
||||||
@@ -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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use glam::I8Vec2;
|
|
||||||
use pacman::map::direction::*;
|
use pacman::map::direction::*;
|
||||||
|
use speculoos::prelude::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_direction_opposite() {
|
fn test_direction_opposite() {
|
||||||
@@ -11,21 +11,47 @@ fn test_direction_opposite() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for (dir, expected) in test_cases {
|
for (dir, expected) in test_cases {
|
||||||
assert_eq!(dir.opposite(), expected);
|
assert_that(&dir.opposite()).is_equal_to(expected);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_direction_as_ivec2() {
|
fn test_direction_opposite_symmetry() {
|
||||||
let test_cases = [
|
// Test that opposite() is symmetric: opposite(opposite(d)) == d
|
||||||
(Direction::Up, -I8Vec2::Y),
|
for &dir in &Direction::DIRECTIONS {
|
||||||
(Direction::Down, I8Vec2::Y),
|
assert_that(&dir.opposite().opposite()).is_equal_to(dir);
|
||||||
(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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
|||||||
134
tests/error.rs
134
tests/error.rs
@@ -1,134 +0,0 @@
|
|||||||
use pacman::error::{
|
|
||||||
AssetError, EntityError, GameError, GameResult, IntoGameError, MapError, OptionExt, ParseError, ResultExt, TextureError,
|
|
||||||
};
|
|
||||||
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());
|
|
||||||
if let Err(GameError::InvalidState(msg)) = game_result {
|
|
||||||
assert!(msg.contains("test error"));
|
|
||||||
} else {
|
|
||||||
panic!("Expected InvalidState error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_into_game_error_trait_success() {
|
|
||||||
let result: Result<i32, io::Error> = Ok(42);
|
|
||||||
let game_result: GameResult<i32> = result.into_game_error();
|
|
||||||
|
|
||||||
assert_eq!(game_result.unwrap(), 42);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_option_ext_some() {
|
|
||||||
let option: Option<i32> = Some(42);
|
|
||||||
let result: GameResult<i32> = option.ok_or_game_error(|| GameError::InvalidState("Not found".to_string()));
|
|
||||||
|
|
||||||
assert_eq!(result.unwrap(), 42);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_option_ext_none() {
|
|
||||||
let option: Option<i32> = None;
|
|
||||||
let result: GameResult<i32> = option.ok_or_game_error(|| GameError::InvalidState("Not found".to_string()));
|
|
||||||
|
|
||||||
assert!(result.is_err());
|
|
||||||
if let Err(GameError::InvalidState(msg)) = result {
|
|
||||||
assert_eq!(msg, "Not found");
|
|
||||||
} else {
|
|
||||||
panic!("Expected InvalidState error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_result_ext_success() {
|
|
||||||
let result: Result<i32, io::Error> = Ok(42);
|
|
||||||
let game_result: GameResult<i32> = result.with_context(|_| GameError::InvalidState("Context".to_string()));
|
|
||||||
|
|
||||||
assert_eq!(game_result.unwrap(), 42);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_result_ext_error() {
|
|
||||||
let result: Result<i32, io::Error> = Err(io::Error::new(io::ErrorKind::Other, "original error"));
|
|
||||||
let game_result: GameResult<i32> = result.with_context(|_| GameError::InvalidState("Context error".to_string()));
|
|
||||||
|
|
||||||
assert!(game_result.is_err());
|
|
||||||
if let Err(GameError::InvalidState(msg)) = game_result {
|
|
||||||
assert_eq!(msg, "Context error");
|
|
||||||
} else {
|
|
||||||
panic!("Expected InvalidState error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
use pacman::systems::profiling::format_timing_display;
|
use pacman::systems::profiling::format_timing_display;
|
||||||
|
use speculoos::prelude::*;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use pretty_assertions::assert_eq;
|
|
||||||
|
|
||||||
fn get_timing_data() -> Vec<(String, Duration, Duration)> {
|
fn get_timing_data() -> Vec<(String, Duration, Duration)> {
|
||||||
vec![
|
vec![
|
||||||
("total".to_string(), Duration::from_micros(1234), Duration::from_micros(570)),
|
("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 that all positions were found
|
||||||
assert_eq!(
|
assert_that(
|
||||||
[
|
&[
|
||||||
&colon_positions,
|
&colon_positions,
|
||||||
&first_decimal_positions,
|
&first_decimal_positions,
|
||||||
&second_decimal_positions,
|
&second_decimal_positions,
|
||||||
&first_unit_positions,
|
&first_unit_positions,
|
||||||
&second_unit_positions
|
&second_unit_positions,
|
||||||
]
|
]
|
||||||
.iter()
|
.iter()
|
||||||
.all(|p| p.len() == 6),
|
.all(|p| p.len() == 6),
|
||||||
true
|
)
|
||||||
);
|
.is_true();
|
||||||
|
|
||||||
// Assert that all positions are the same
|
// Assert that all positions are the same
|
||||||
assert!(
|
assert_that(&colon_positions.iter().all(|&p| p == colon_positions[0])).is_true();
|
||||||
colon_positions.iter().all(|&p| p == colon_positions[0]),
|
assert_that(&first_decimal_positions.iter().all(|&p| p == first_decimal_positions[0])).is_true();
|
||||||
"colon positions are not the same {:?}",
|
assert_that(&second_decimal_positions.iter().all(|&p| p == second_decimal_positions[0])).is_true();
|
||||||
colon_positions
|
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();
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -105,17 +84,17 @@ fn test_format_timing_display_basic() {
|
|||||||
let formatted = format_timing_display(timing_data);
|
let formatted = format_timing_display(timing_data);
|
||||||
|
|
||||||
// Should have 3 lines (one for each system)
|
// 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
|
// Each line should contain the system name
|
||||||
assert!(formatted.iter().any(|line| line.contains("render")));
|
assert_that(&formatted.iter().any(|line| line.contains("render"))).is_true();
|
||||||
assert!(formatted.iter().any(|line| line.contains("input")));
|
assert_that(&formatted.iter().any(|line| line.contains("input"))).is_true();
|
||||||
assert!(formatted.iter().any(|line| line.contains("physics")));
|
assert_that(&formatted.iter().any(|line| line.contains("physics"))).is_true();
|
||||||
|
|
||||||
// Each line should contain timing information with proper units
|
// Each line should contain timing information with proper units
|
||||||
for line in formatted.iter() {
|
for line in formatted.iter() {
|
||||||
assert!(line.contains(":"), "Line should contain colon separator: {}", line);
|
assert_that(&line.contains(":")).is_true();
|
||||||
assert!(line.contains("±"), "Line should contain ± symbol: {}", line);
|
assert_that(&line.contains("±")).is_true();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,10 +111,10 @@ fn test_format_timing_display_units() {
|
|||||||
|
|
||||||
// Check that appropriate units are used
|
// Check that appropriate units are used
|
||||||
let all_lines = formatted.join(" ");
|
let all_lines = formatted.join(" ");
|
||||||
assert!(all_lines.contains("s"), "Should contain seconds unit");
|
assert_that(&all_lines.contains("s")).is_true();
|
||||||
assert!(all_lines.contains("ms"), "Should contain milliseconds unit");
|
assert_that(&all_lines.contains("ms")).is_true();
|
||||||
assert!(all_lines.contains("µs"), "Should contain microseconds unit");
|
assert_that(&all_lines.contains("µs")).is_true();
|
||||||
assert!(all_lines.contains("ns"), "Should contain nanoseconds unit");
|
assert_that(&all_lines.contains("ns")).is_true();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -157,9 +136,6 @@ fn test_format_timing_display_alignment() {
|
|||||||
// All colons should be at the same position (aligned)
|
// All colons should be at the same position (aligned)
|
||||||
if colon_positions.len() > 1 {
|
if colon_positions.len() > 1 {
|
||||||
let first_pos = colon_positions[0];
|
let first_pos = colon_positions[0];
|
||||||
assert!(
|
assert_that(&colon_positions.iter().all(|&pos| pos == first_pos)).is_true();
|
||||||
colon_positions.iter().all(|&pos| pos == first_pos),
|
|
||||||
"Colons should be aligned at the same position"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
79
tests/game.rs
Normal file
79
tests/game.rs
Normal 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(())
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
use pacman::map::direction::Direction;
|
use pacman::map::direction::Direction;
|
||||||
use pacman::map::graph::{Graph, Node, TraversalFlags};
|
use pacman::map::graph::{Graph, Node, TraversalFlags};
|
||||||
|
use speculoos::prelude::*;
|
||||||
|
|
||||||
mod common;
|
mod common;
|
||||||
|
|
||||||
@@ -13,10 +14,10 @@ fn test_graph_basic_operations() {
|
|||||||
position: glam::Vec2::new(16.0, 0.0),
|
position: glam::Vec2::new(16.0, 0.0),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert_eq!(graph.nodes().count(), 2);
|
assert_that(&graph.nodes().count()).is_equal_to(2);
|
||||||
assert!(graph.get_node(node1).is_some());
|
assert_that(&graph.get_node(node1).is_some()).is_true();
|
||||||
assert!(graph.get_node(node2).is_some());
|
assert_that(&graph.get_node(node2).is_some()).is_true();
|
||||||
assert!(graph.get_node(999).is_none());
|
assert_that(&graph.get_node(999).is_none()).is_true();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -29,15 +30,15 @@ fn test_graph_connect() {
|
|||||||
position: glam::Vec2::new(16.0, 0.0),
|
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 edge1 = graph.find_edge_in_direction(node1, Direction::Right);
|
||||||
let edge2 = graph.find_edge_in_direction(node2, Direction::Left);
|
let edge2 = graph.find_edge_in_direction(node2, Direction::Left);
|
||||||
|
|
||||||
assert!(edge1.is_some());
|
assert_that(&edge1.is_some()).is_true();
|
||||||
assert!(edge2.is_some());
|
assert_that(&edge2.is_some()).is_true();
|
||||||
assert_eq!(edge1.unwrap().target, node2);
|
assert_that(&edge1.unwrap().target).is_equal_to(node2);
|
||||||
assert_eq!(edge2.unwrap().target, node1);
|
assert_that(&edge2.unwrap().target).is_equal_to(node1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -47,8 +48,8 @@ fn test_graph_connect_errors() {
|
|||||||
position: glam::Vec2::new(0.0, 0.0),
|
position: glam::Vec2::new(0.0, 0.0),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert!(graph.connect(node1, 999, false, None, Direction::Right).is_err());
|
assert_that(&graph.connect(node1, 999, false, None, Direction::Right).is_err()).is_true();
|
||||||
assert!(graph.connect(999, node1, false, None, Direction::Right).is_err());
|
assert_that(&graph.connect(999, node1, false, None, Direction::Right).is_err()).is_true();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -66,7 +67,7 @@ fn test_graph_edge_permissions() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let edge = graph.find_edge_in_direction(node1, Direction::Right).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]
|
#[test]
|
||||||
@@ -86,10 +87,10 @@ fn should_add_connected_node() {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(graph.nodes().count(), 2);
|
assert_that(&graph.nodes().count()).is_equal_to(2);
|
||||||
let edge = graph.find_edge(node1, node2);
|
let edge = graph.find_edge(node1, node2);
|
||||||
assert!(edge.is_some());
|
assert_that(&edge.is_some()).is_true();
|
||||||
assert_eq!(edge.unwrap().direction, Direction::Right);
|
assert_that(&edge.unwrap().direction).is_equal_to(Direction::Right);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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);
|
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]
|
#[test]
|
||||||
fn should_error_on_duplicate_edge_without_replace() {
|
fn should_error_on_duplicate_edge_without_replace() {
|
||||||
let mut graph = common::create_test_graph();
|
let mut graph = common::create_test_graph();
|
||||||
let result = graph.add_edge(0, 1, false, None, Direction::Right, TraversalFlags::ALL);
|
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]
|
#[test]
|
||||||
fn should_allow_replacing_an_edge() {
|
fn should_allow_replacing_an_edge() {
|
||||||
let mut graph = common::create_test_graph();
|
let mut graph = common::create_test_graph();
|
||||||
let result = graph.add_edge(0, 1, true, Some(42.0), Direction::Right, TraversalFlags::ALL);
|
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();
|
let edge = graph.find_edge(0, 1).unwrap();
|
||||||
assert_eq!(edge.distance, 42.0);
|
assert_that(&edge.distance).is_equal_to(42.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_find_edge_between_nodes() {
|
fn should_find_edge_between_nodes() {
|
||||||
let graph = common::create_test_graph();
|
let graph = common::create_test_graph();
|
||||||
let edge = graph.find_edge(0, 1);
|
let edge = graph.find_edge(0, 1);
|
||||||
assert!(edge.is_some());
|
assert_that(&edge.is_some()).is_true();
|
||||||
assert_eq!(edge.unwrap().target, 1);
|
assert_that(&edge.unwrap().target).is_equal_to(1);
|
||||||
|
|
||||||
let non_existent_edge = graph.find_edge(0, 99);
|
let non_existent_edge = graph.find_edge(0, 99);
|
||||||
assert!(non_existent_edge.is_none());
|
assert_that(&non_existent_edge.is_none()).is_true();
|
||||||
}
|
}
|
||||||
|
|||||||
333
tests/input.rs
333
tests/input.rs
@@ -1,38 +1,321 @@
|
|||||||
|
use glam::Vec2;
|
||||||
use pacman::events::{GameCommand, GameEvent};
|
use pacman::events::{GameCommand, GameEvent};
|
||||||
use pacman::map::direction::Direction;
|
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 sdl2::keyboard::Keycode;
|
||||||
|
use speculoos::prelude::*;
|
||||||
|
|
||||||
#[test]
|
// Test modules for better organization
|
||||||
fn resumes_previous_direction_when_secondary_key_released() {
|
mod keyboard_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_down_emits_bound_command() {
|
||||||
let mut bindings = Bindings::default();
|
let mut bindings = Bindings::default();
|
||||||
|
|
||||||
// Frame 1: Press W (Up) => emits Move Up
|
|
||||||
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::W)]);
|
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::W)]);
|
||||||
assert!(events.contains(&GameEvent::Command(GameCommand::MovePlayer(Direction::Up))));
|
assert_that(&events).contains(GameEvent::Command(GameCommand::MovePlayer(Direction::Up)));
|
||||||
|
}
|
||||||
|
|
||||||
// Frame 2: Press D (Right) => emits Move Right
|
#[test]
|
||||||
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::D)]);
|
fn key_down_emits_non_movement_commands() {
|
||||||
assert!(events.contains(&GameEvent::Command(GameCommand::MovePlayer(Direction::Right))));
|
|
||||||
|
|
||||||
// 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 holds_last_pressed_key_across_frames_when_no_new_input() {
|
|
||||||
let mut bindings = Bindings::default();
|
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 1: Press Left
|
#[test]
|
||||||
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::Left)]);
|
fn unbound_key_emits_nothing() {
|
||||||
assert!(events.contains(&GameEvent::Command(GameCommand::MovePlayer(Direction::Left))));
|
let mut bindings = Bindings::default();
|
||||||
|
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::Z)]);
|
||||||
|
assert_that(&events).is_empty();
|
||||||
|
}
|
||||||
|
|
||||||
// Frame 2: No input => continues Left
|
#[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, &[]);
|
let events = process_simple_key_events(&mut bindings, &[]);
|
||||||
assert!(events.contains(&GameEvent::Command(GameCommand::MovePlayer(Direction::Left))));
|
assert_that(&events).contains(GameEvent::Command(GameCommand::MovePlayer(Direction::Left)));
|
||||||
|
}
|
||||||
|
|
||||||
// Frame 3: Release Left, no input remains => nothing emitted
|
#[test]
|
||||||
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyUp(Keycode::Left)]);
|
fn releasing_movement_key_stops_continuation() {
|
||||||
assert!(events.is_empty());
|
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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod direction_calculation_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[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),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (delta, expected) in test_cases {
|
||||||
|
assert_that(&calculate_direction_from_delta(delta)).is_equal_to(expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
138
tests/item.rs
138
tests/item.rs
@@ -1,58 +1,45 @@
|
|||||||
use bevy_ecs::{entity::Entity, system::RunSystemOnce};
|
use bevy_ecs::entity::Entity;
|
||||||
use pacman::systems::{is_valid_item_collision, item_system, EntityType, GhostState, Position, ScoreResource};
|
use pacman::{
|
||||||
|
events::CollisionTrigger,
|
||||||
|
systems::{EntityType, GhostState, Position, ScoreResource},
|
||||||
|
};
|
||||||
|
use speculoos::prelude::*;
|
||||||
|
|
||||||
mod common;
|
mod common;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_calculate_score_for_item() {
|
fn test_calculate_score_for_item() {
|
||||||
assert!(EntityType::Pellet.score_value() < EntityType::PowerPellet.score_value());
|
assert_that(&(EntityType::Pellet.score_value() < EntityType::PowerPellet.score_value())).is_true();
|
||||||
assert!(EntityType::Pellet.score_value().is_some());
|
assert_that(&EntityType::Pellet.score_value().is_some()).is_true();
|
||||||
assert!(EntityType::PowerPellet.score_value().is_some());
|
assert_that(&EntityType::PowerPellet.score_value().is_some()).is_true();
|
||||||
assert!(EntityType::Player.score_value().is_none());
|
assert_that(&EntityType::Player.score_value().is_none()).is_true();
|
||||||
assert!(EntityType::Ghost.score_value().is_none());
|
assert_that(&EntityType::Ghost.score_value().is_none()).is_true();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_is_collectible_item() {
|
fn test_is_collectible_item() {
|
||||||
// Collectible
|
// Collectible
|
||||||
assert!(EntityType::Pellet.is_collectible());
|
assert_that(&EntityType::Pellet.is_collectible()).is_true();
|
||||||
assert!(EntityType::PowerPellet.is_collectible());
|
assert_that(&EntityType::PowerPellet.is_collectible()).is_true();
|
||||||
|
|
||||||
// Non-collectible
|
// Non-collectible
|
||||||
assert!(!EntityType::Player.is_collectible());
|
assert_that(&EntityType::Player.is_collectible()).is_false();
|
||||||
assert!(!EntityType::Ghost.is_collectible());
|
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));
|
|
||||||
|
|
||||||
// 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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_item_system_pellet_collection() {
|
fn test_item_system_pellet_collection() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, mut _schedule) = common::create_test_world();
|
||||||
let pacman = common::spawn_test_pacman(&mut world, 0);
|
|
||||||
let pellet = common::spawn_test_item(&mut world, 1, EntityType::Pellet);
|
let pellet = common::spawn_test_item(&mut world, 1, EntityType::Pellet);
|
||||||
|
|
||||||
// Send collision event
|
// Send collision event
|
||||||
common::send_collision_event(&mut world, pacman, pellet);
|
common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: pellet });
|
||||||
|
|
||||||
// Run the item system
|
world.flush();
|
||||||
world.run_system_once(item_system).expect("System should run successfully");
|
|
||||||
|
|
||||||
// Check that score was updated
|
// Check that score was updated
|
||||||
let score = world.resource::<ScoreResource>();
|
let score = world.resource_mut::<ScoreResource>();
|
||||||
assert_eq!(score.0, 10);
|
assert_that(&score.0).is_equal_to(10);
|
||||||
|
|
||||||
// Check that the pellet was despawned (query should return empty)
|
// Check that the pellet was despawned (query should return empty)
|
||||||
let item_count = world
|
let item_count = world
|
||||||
@@ -60,22 +47,21 @@ fn test_item_system_pellet_collection() {
|
|||||||
.iter(&world)
|
.iter(&world)
|
||||||
.filter(|&entity_type| matches!(entity_type, EntityType::Pellet))
|
.filter(|&entity_type| matches!(entity_type, EntityType::Pellet))
|
||||||
.count();
|
.count();
|
||||||
assert_eq!(item_count, 0);
|
assert_that(&item_count).is_equal_to(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_item_system_power_pellet_collection() {
|
fn test_item_system_power_pellet_collection() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, mut _schedule) = common::create_test_world();
|
||||||
let pacman = common::spawn_test_pacman(&mut world, 0);
|
|
||||||
let power_pellet = common::spawn_test_item(&mut world, 1, EntityType::PowerPellet);
|
let power_pellet = common::spawn_test_item(&mut world, 1, EntityType::PowerPellet);
|
||||||
|
|
||||||
common::send_collision_event(&mut world, pacman, power_pellet);
|
common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: power_pellet });
|
||||||
|
|
||||||
world.run_system_once(item_system).expect("System should run successfully");
|
world.flush();
|
||||||
|
|
||||||
// Check that score was updated with power pellet value
|
// Check that score was updated with power pellet value
|
||||||
let score = world.resource::<ScoreResource>();
|
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)
|
// Check that the power pellet was despawned (query should return empty)
|
||||||
let item_count = world
|
let item_count = world
|
||||||
@@ -83,27 +69,26 @@ fn test_item_system_power_pellet_collection() {
|
|||||||
.iter(&world)
|
.iter(&world)
|
||||||
.filter(|&entity_type| matches!(entity_type, EntityType::PowerPellet))
|
.filter(|&entity_type| matches!(entity_type, EntityType::PowerPellet))
|
||||||
.count();
|
.count();
|
||||||
assert_eq!(item_count, 0);
|
assert_that(&item_count).is_equal_to(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_item_system_multiple_collections() {
|
fn test_item_system_multiple_collections() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, mut _schedule) = common::create_test_world();
|
||||||
let pacman = common::spawn_test_pacman(&mut world, 0);
|
|
||||||
let pellet1 = common::spawn_test_item(&mut world, 1, EntityType::Pellet);
|
let pellet1 = common::spawn_test_item(&mut world, 1, EntityType::Pellet);
|
||||||
let pellet2 = common::spawn_test_item(&mut world, 2, EntityType::Pellet);
|
let pellet2 = common::spawn_test_item(&mut world, 2, EntityType::Pellet);
|
||||||
let power_pellet = common::spawn_test_item(&mut world, 3, EntityType::PowerPellet);
|
let power_pellet = common::spawn_test_item(&mut world, 3, EntityType::PowerPellet);
|
||||||
|
|
||||||
// Send multiple collision events
|
// Send multiple collision events
|
||||||
common::send_collision_event(&mut world, pacman, pellet1);
|
common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: pellet1 });
|
||||||
common::send_collision_event(&mut world, pacman, pellet2);
|
common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: pellet2 });
|
||||||
common::send_collision_event(&mut world, pacman, power_pellet);
|
common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: power_pellet });
|
||||||
|
|
||||||
world.run_system_once(item_system).expect("System should run successfully");
|
world.flush();
|
||||||
|
|
||||||
// Check final score: 2 pellets (20) + 1 power pellet (50) = 70
|
// Check final score: 2 pellets (20) + 1 power pellet (50) = 70
|
||||||
let score = world.resource::<ScoreResource>();
|
let score = world.resource::<ScoreResource>();
|
||||||
assert_eq!(score.0, 70);
|
assert_that(&score.0).is_equal_to(70);
|
||||||
|
|
||||||
// Check that all items were despawned
|
// Check that all items were despawned
|
||||||
let pellet_count = world
|
let pellet_count = world
|
||||||
@@ -116,14 +101,13 @@ fn test_item_system_multiple_collections() {
|
|||||||
.iter(&world)
|
.iter(&world)
|
||||||
.filter(|&entity_type| matches!(entity_type, EntityType::PowerPellet))
|
.filter(|&entity_type| matches!(entity_type, EntityType::PowerPellet))
|
||||||
.count();
|
.count();
|
||||||
assert_eq!(pellet_count, 0);
|
assert_that(&pellet_count).is_equal_to(0);
|
||||||
assert_eq!(power_pellet_count, 0);
|
assert_that(&power_pellet_count).is_equal_to(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_item_system_ignores_non_item_collisions() {
|
fn test_item_system_ignores_non_item_collisions() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, mut _schedule) = common::create_test_world();
|
||||||
let pacman = common::spawn_test_pacman(&mut world, 0);
|
|
||||||
|
|
||||||
// Create a ghost entity (not an item)
|
// Create a ghost entity (not an item)
|
||||||
let ghost = world.spawn((Position::Stopped { node: 2 }, EntityType::Ghost)).id();
|
let ghost = world.spawn((Position::Stopped { node: 2 }, EntityType::Ghost)).id();
|
||||||
@@ -132,13 +116,13 @@ fn test_item_system_ignores_non_item_collisions() {
|
|||||||
let initial_score = world.resource::<ScoreResource>().0;
|
let initial_score = world.resource::<ScoreResource>().0;
|
||||||
|
|
||||||
// Send collision event between pacman and ghost
|
// Send collision event between pacman and ghost
|
||||||
common::send_collision_event(&mut world, pacman, ghost);
|
common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: ghost });
|
||||||
|
|
||||||
world.run_system_once(item_system).expect("System should run successfully");
|
world.flush();
|
||||||
|
|
||||||
// Score should remain unchanged
|
// Score should remain unchanged
|
||||||
let score = world.resource::<ScoreResource>();
|
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)
|
// Ghost should still exist (not despawned)
|
||||||
let ghost_count = world
|
let ghost_count = world
|
||||||
@@ -146,74 +130,68 @@ fn test_item_system_ignores_non_item_collisions() {
|
|||||||
.iter(&world)
|
.iter(&world)
|
||||||
.filter(|&entity_type| matches!(entity_type, EntityType::Ghost))
|
.filter(|&entity_type| matches!(entity_type, EntityType::Ghost))
|
||||||
.count();
|
.count();
|
||||||
assert_eq!(ghost_count, 1);
|
assert_that(&ghost_count).is_equal_to(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_item_system_no_collision_events() {
|
fn test_item_system_no_collision_events() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, mut _schedule) = common::create_test_world();
|
||||||
let _pacman = common::spawn_test_pacman(&mut world, 0);
|
let _pacman = common::spawn_test_pacman(&mut world, 0);
|
||||||
let _pellet = common::spawn_test_item(&mut world, 1, EntityType::Pellet);
|
let _pellet = common::spawn_test_item(&mut world, 1, EntityType::Pellet);
|
||||||
|
|
||||||
let initial_score = world.resource::<ScoreResource>().0;
|
let initial_score = world.resource::<ScoreResource>().0;
|
||||||
|
|
||||||
// Run system without any collision events
|
// Run system without any collision events
|
||||||
world.run_system_once(item_system).expect("System should run successfully");
|
world.flush();
|
||||||
|
|
||||||
// Nothing should change
|
// Nothing should change
|
||||||
let score = world.resource::<ScoreResource>();
|
let score = world.resource::<ScoreResource>();
|
||||||
assert_eq!(score.0, initial_score);
|
assert_that(&score.0).is_equal_to(initial_score);
|
||||||
let pellet_count = world
|
let pellet_count = world
|
||||||
.query::<&EntityType>()
|
.query::<&EntityType>()
|
||||||
.iter(&world)
|
.iter(&world)
|
||||||
.filter(|&entity_type| matches!(entity_type, EntityType::Pellet))
|
.filter(|&entity_type| matches!(entity_type, EntityType::Pellet))
|
||||||
.count();
|
.count();
|
||||||
assert_eq!(pellet_count, 1);
|
assert_that(&pellet_count).is_equal_to(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_item_system_collision_with_missing_entity() {
|
fn test_item_system_collision_with_missing_entity() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, mut _schedule) = common::create_test_world();
|
||||||
let pacman = common::spawn_test_pacman(&mut world, 0);
|
|
||||||
|
|
||||||
// Create a fake entity ID that doesn't exist
|
// Create a fake entity ID that doesn't exist
|
||||||
let fake_entity = Entity::from_raw(999);
|
let fake_entity = Entity::from_raw(999);
|
||||||
|
|
||||||
common::send_collision_event(&mut world, pacman, fake_entity);
|
common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: fake_entity });
|
||||||
|
|
||||||
// System should handle gracefully and not crash
|
// System should handle gracefully and not crash
|
||||||
world
|
world.flush();
|
||||||
.run_system_once(item_system)
|
|
||||||
.expect("System should handle missing entities gracefully");
|
|
||||||
|
|
||||||
// Score should remain unchanged
|
// Score should remain unchanged
|
||||||
let score = world.resource::<ScoreResource>();
|
let score = world.resource::<ScoreResource>();
|
||||||
assert_eq!(score.0, 0);
|
assert_that(&score.0).is_equal_to(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_item_system_preserves_existing_score() {
|
fn test_item_system_preserves_existing_score() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, mut _schedule) = common::create_test_world();
|
||||||
|
|
||||||
// Set initial score
|
// Set initial score
|
||||||
world.insert_resource(ScoreResource(100));
|
world.insert_resource(ScoreResource(100));
|
||||||
|
|
||||||
let pacman = common::spawn_test_pacman(&mut world, 0);
|
|
||||||
let pellet = common::spawn_test_item(&mut world, 1, EntityType::Pellet);
|
let pellet = common::spawn_test_item(&mut world, 1, EntityType::Pellet);
|
||||||
|
|
||||||
common::send_collision_event(&mut world, pacman, pellet);
|
common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: pellet });
|
||||||
|
|
||||||
world.run_system_once(item_system).expect("System should run successfully");
|
world.flush();
|
||||||
|
|
||||||
// Score should be initial + pellet value
|
// Score should be initial + pellet value
|
||||||
let score = world.resource::<ScoreResource>();
|
let score = world.resource::<ScoreResource>();
|
||||||
assert_eq!(score.0, 110);
|
assert_that(&score.0).is_equal_to(110);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_power_pellet_does_not_affect_ghosts_in_eyes_state() {
|
fn test_power_pellet_does_not_affect_ghosts_in_eyes_state() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, mut _schedule) = common::create_test_world();
|
||||||
let pacman = common::spawn_test_pacman(&mut world, 0);
|
|
||||||
let power_pellet = common::spawn_test_item(&mut world, 1, EntityType::PowerPellet);
|
let power_pellet = common::spawn_test_item(&mut world, 1, EntityType::PowerPellet);
|
||||||
|
|
||||||
// Spawn a ghost in Eyes state (returning to ghost house)
|
// Spawn a ghost in Eyes state (returning to ghost house)
|
||||||
@@ -222,13 +200,13 @@ fn test_power_pellet_does_not_affect_ghosts_in_eyes_state() {
|
|||||||
// Spawn a ghost in Normal state
|
// Spawn a ghost in Normal state
|
||||||
let normal_ghost = common::spawn_test_ghost(&mut world, 3, GhostState::Normal);
|
let normal_ghost = common::spawn_test_ghost(&mut world, 3, GhostState::Normal);
|
||||||
|
|
||||||
common::send_collision_event(&mut world, pacman, power_pellet);
|
common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: power_pellet });
|
||||||
|
|
||||||
world.run_system_once(item_system).expect("System should run successfully");
|
world.flush();
|
||||||
|
|
||||||
// Check that the power pellet was collected and score updated
|
// Check that the power pellet was collected and score updated
|
||||||
let score = world.resource::<ScoreResource>();
|
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
|
// Check that the power pellet was despawned
|
||||||
let power_pellet_count = world
|
let power_pellet_count = world
|
||||||
@@ -236,13 +214,13 @@ fn test_power_pellet_does_not_affect_ghosts_in_eyes_state() {
|
|||||||
.iter(&world)
|
.iter(&world)
|
||||||
.filter(|&entity_type| matches!(entity_type, EntityType::PowerPellet))
|
.filter(|&entity_type| matches!(entity_type, EntityType::PowerPellet))
|
||||||
.count();
|
.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
|
// Check that the Eyes ghost state was not changed
|
||||||
let eyes_ghost_state = world.entity(eyes_ghost).get::<GhostState>().unwrap();
|
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
|
// Check that the Normal ghost state was changed to Frightened
|
||||||
let normal_ghost_state = world.entity(normal_ghost).get::<GhostState>().unwrap();
|
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
use glam::Vec2;
|
use glam::Vec2;
|
||||||
use pacman::constants::{CELL_SIZE, RAW_BOARD};
|
use pacman::constants::{CELL_SIZE, RAW_BOARD};
|
||||||
use pacman::map::builder::Map;
|
use pacman::map::builder::Map;
|
||||||
|
use pacman::map::graph::TraversalFlags;
|
||||||
|
use speculoos::prelude::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_map_creation() {
|
fn test_map_creation_success() {
|
||||||
let map = Map::new(RAW_BOARD).unwrap();
|
let map = Map::new(RAW_BOARD).unwrap();
|
||||||
|
|
||||||
assert!(map.graph.nodes().count() > 0);
|
assert_that(&map.graph.nodes().count()).is_greater_than(0);
|
||||||
assert!(!map.grid_to_node.is_empty());
|
assert_that(&map.grid_to_node.is_empty()).is_false();
|
||||||
|
|
||||||
// Check that some connections were made
|
// Check that some connections were made
|
||||||
let mut has_connections = false;
|
let mut has_connections = false;
|
||||||
@@ -17,11 +19,11 @@ fn test_map_creation() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert!(has_connections);
|
assert_that(&has_connections).is_true();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_map_node_positions() {
|
fn test_map_node_positions_accuracy() {
|
||||||
let map = Map::new(RAW_BOARD).unwrap();
|
let map = Map::new(RAW_BOARD).unwrap();
|
||||||
|
|
||||||
for (grid_pos, &node_id) in &map.grid_to_node {
|
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,
|
(grid_pos.y as i32 * CELL_SIZE as i32) as f32,
|
||||||
) + Vec2::splat(CELL_SIZE as f32 / 2.0);
|
) + 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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use glam::Vec2;
|
use glam::Vec2;
|
||||||
use pacman::map::direction::Direction;
|
use pacman::map::direction::Direction;
|
||||||
use pacman::systems::movement::{BufferedDirection, Position, Velocity};
|
use pacman::systems::movement::{BufferedDirection, Position, Velocity};
|
||||||
|
use speculoos::prelude::*;
|
||||||
|
|
||||||
mod common;
|
mod common;
|
||||||
|
|
||||||
@@ -13,8 +14,8 @@ fn test_position_is_at_node() {
|
|||||||
remaining_distance: 8.0,
|
remaining_distance: 8.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(stopped_pos.is_at_node());
|
assert_that(&stopped_pos.is_at_node()).is_true();
|
||||||
assert!(!moving_pos.is_at_node());
|
assert_that(&moving_pos.is_at_node()).is_false();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -26,8 +27,8 @@ fn test_position_current_node() {
|
|||||||
remaining_distance: 12.0,
|
remaining_distance: 12.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(stopped_pos.current_node(), 5);
|
assert_that(&stopped_pos.current_node()).is_equal_to(5);
|
||||||
assert_eq!(moving_pos.current_node(), 3);
|
assert_that(&moving_pos.current_node()).is_equal_to(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -35,8 +36,8 @@ fn test_position_tick_no_movement_when_stopped() {
|
|||||||
let mut pos = Position::Stopped { node: 0 };
|
let mut pos = Position::Stopped { node: 0 };
|
||||||
let result = pos.tick(5.0);
|
let result = pos.tick(5.0);
|
||||||
|
|
||||||
assert!(result.is_none());
|
assert_that(&result.is_none()).is_true();
|
||||||
assert_eq!(pos, Position::Stopped { node: 0 });
|
assert_that(&pos).is_equal_to(Position::Stopped { node: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -48,15 +49,12 @@ fn test_position_tick_no_movement_when_zero_distance() {
|
|||||||
};
|
};
|
||||||
let result = pos.tick(0.0);
|
let result = pos.tick(0.0);
|
||||||
|
|
||||||
assert!(result.is_none());
|
assert_that(&result.is_none()).is_true();
|
||||||
assert_eq!(
|
assert_that(&pos).is_equal_to(Position::Moving {
|
||||||
pos,
|
|
||||||
Position::Moving {
|
|
||||||
from: 0,
|
from: 0,
|
||||||
to: 1,
|
to: 1,
|
||||||
remaining_distance: 10.0,
|
remaining_distance: 10.0,
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -68,15 +66,12 @@ fn test_position_tick_partial_movement() {
|
|||||||
};
|
};
|
||||||
let result = pos.tick(3.0);
|
let result = pos.tick(3.0);
|
||||||
|
|
||||||
assert!(result.is_none());
|
assert_that(&result.is_none()).is_true();
|
||||||
assert_eq!(
|
assert_that(&pos).is_equal_to(Position::Moving {
|
||||||
pos,
|
|
||||||
Position::Moving {
|
|
||||||
from: 0,
|
from: 0,
|
||||||
to: 1,
|
to: 1,
|
||||||
remaining_distance: 7.0,
|
remaining_distance: 7.0,
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -88,8 +83,8 @@ fn test_position_tick_exact_arrival() {
|
|||||||
};
|
};
|
||||||
let result = pos.tick(5.0);
|
let result = pos.tick(5.0);
|
||||||
|
|
||||||
assert!(result.is_none());
|
assert_that(&result.is_none()).is_true();
|
||||||
assert_eq!(pos, Position::Stopped { node: 1 });
|
assert_that(&pos).is_equal_to(Position::Stopped { node: 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -101,8 +96,8 @@ fn test_position_tick_overshoot_with_overflow() {
|
|||||||
};
|
};
|
||||||
let result = pos.tick(8.0);
|
let result = pos.tick(8.0);
|
||||||
|
|
||||||
assert_eq!(result, Some(5.0));
|
assert_that(&result).is_equal_to(Some(5.0));
|
||||||
assert_eq!(pos, Position::Stopped { node: 1 });
|
assert_that(&pos).is_equal_to(Position::Stopped { node: 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -116,7 +111,7 @@ fn test_position_get_pixel_position_stopped() {
|
|||||||
0.0 + pacman::constants::BOARD_PIXEL_OFFSET.y as f32,
|
0.0 + pacman::constants::BOARD_PIXEL_OFFSET.y as f32,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(pixel_pos, expected);
|
assert_that(&pixel_pos).is_equal_to(expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -135,7 +130,7 @@ fn test_position_get_pixel_position_moving() {
|
|||||||
0.0 + pacman::constants::BOARD_PIXEL_OFFSET.y as f32,
|
0.0 + pacman::constants::BOARD_PIXEL_OFFSET.y as f32,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(pixel_pos, expected);
|
assert_that(&pixel_pos).is_equal_to(expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -145,14 +140,14 @@ fn test_velocity_basic_properties() {
|
|||||||
direction: Direction::Up,
|
direction: Direction::Up,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(velocity.speed, 2.5);
|
assert_that(&velocity.speed).is_equal_to(2.5);
|
||||||
assert_eq!(velocity.direction, Direction::Up);
|
assert_that(&velocity.direction).is_equal_to(Direction::Up);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_buffered_direction_none() {
|
fn test_buffered_direction_none() {
|
||||||
let buffered = BufferedDirection::None;
|
let buffered = BufferedDirection::None;
|
||||||
assert_eq!(buffered, BufferedDirection::None);
|
assert_that(&buffered).is_equal_to(BufferedDirection::None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -167,8 +162,8 @@ fn test_buffered_direction_some() {
|
|||||||
remaining_time,
|
remaining_time,
|
||||||
} = buffered
|
} = buffered
|
||||||
{
|
{
|
||||||
assert_eq!(direction, Direction::Left);
|
assert_that(&direction).is_equal_to(Direction::Left);
|
||||||
assert_eq!(remaining_time, 0.5);
|
assert_that(&remaining_time).is_equal_to(0.5);
|
||||||
} else {
|
} else {
|
||||||
panic!("Expected BufferedDirection::Some");
|
panic!("Expected BufferedDirection::Some");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use pacman::constants::{BOARD_CELL_SIZE, RAW_BOARD};
|
use pacman::constants::{BOARD_CELL_SIZE, RAW_BOARD};
|
||||||
use pacman::error::ParseError;
|
use pacman::error::ParseError;
|
||||||
use pacman::map::parser::MapTileParser;
|
use pacman::map::parser::MapTileParser;
|
||||||
|
use speculoos::prelude::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_character() {
|
fn test_parse_character() {
|
||||||
@@ -15,25 +16,25 @@ fn test_parse_character() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for (char, _expected) in test_cases {
|
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]
|
#[test]
|
||||||
fn test_parse_board() {
|
fn test_parse_board() {
|
||||||
let result = MapTileParser::parse_board(RAW_BOARD);
|
let result = MapTileParser::parse_board(RAW_BOARD);
|
||||||
assert!(result.is_ok());
|
assert_that(&result.is_ok()).is_true();
|
||||||
|
|
||||||
let parsed = result.unwrap();
|
let parsed = result.unwrap();
|
||||||
assert_eq!(parsed.tiles.len(), BOARD_CELL_SIZE.x as usize);
|
assert_that(&parsed.tiles.len()).is_equal_to(BOARD_CELL_SIZE.x as usize);
|
||||||
assert_eq!(parsed.tiles[0].len(), BOARD_CELL_SIZE.y as usize);
|
assert_that(&parsed.tiles[0].len()).is_equal_to(BOARD_CELL_SIZE.y as usize);
|
||||||
assert!(parsed.house_door[0].is_some());
|
assert_that(&parsed.house_door[0].is_some()).is_true();
|
||||||
assert!(parsed.house_door[1].is_some());
|
assert_that(&parsed.house_door[1].is_some()).is_true();
|
||||||
assert!(parsed.tunnel_ends[0].is_some());
|
assert_that(&parsed.tunnel_ends[0].is_some()).is_true();
|
||||||
assert!(parsed.tunnel_ends[1].is_some());
|
assert_that(&parsed.tunnel_ends[1].is_some()).is_true();
|
||||||
assert!(parsed.pacman_start.is_some());
|
assert_that(&parsed.pacman_start.is_some()).is_true();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -42,6 +43,6 @@ fn test_parse_board_invalid_character() {
|
|||||||
invalid_board[0] = "###########################Z".to_string();
|
invalid_board[0] = "###########################Z".to_string();
|
||||||
|
|
||||||
let result = MapTileParser::parse_board(invalid_board.each_ref().map(|s| s.as_str()));
|
let result = MapTileParser::parse_board(invalid_board.each_ref().map(|s| s.as_str()));
|
||||||
assert!(result.is_err());
|
assert_that(&result.is_err()).is_true();
|
||||||
assert!(matches!(result.unwrap_err(), ParseError::UnknownCharacter('Z')));
|
assert_that(&matches!(result.unwrap_err(), ParseError::UnknownCharacter('Z'))).is_true();
|
||||||
}
|
}
|
||||||
|
|||||||
108
tests/player.rs
108
tests/player.rs
@@ -10,6 +10,7 @@ use pacman::{
|
|||||||
EntityType, GlobalState, Position, Velocity,
|
EntityType, GlobalState, Position, Velocity,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
use speculoos::prelude::*;
|
||||||
|
|
||||||
mod common;
|
mod common;
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ fn test_can_traverse_player_on_all_edges() {
|
|||||||
traversal_flags: TraversalFlags::ALL,
|
traversal_flags: TraversalFlags::ALL,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(can_traverse(EntityType::Player, edge));
|
assert_that(&can_traverse(EntityType::Player, edge)).is_true();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -34,7 +35,7 @@ fn test_can_traverse_player_on_pacman_only_edges() {
|
|||||||
traversal_flags: TraversalFlags::PACMAN,
|
traversal_flags: TraversalFlags::PACMAN,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(can_traverse(EntityType::Player, edge));
|
assert_that(&can_traverse(EntityType::Player, edge)).is_true();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -46,7 +47,7 @@ fn test_can_traverse_player_blocked_on_ghost_only_edges() {
|
|||||||
traversal_flags: TraversalFlags::GHOST,
|
traversal_flags: TraversalFlags::GHOST,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(!can_traverse(EntityType::Player, edge));
|
assert_that(&can_traverse(EntityType::Player, edge)).is_false();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -58,7 +59,7 @@ fn test_can_traverse_ghost_on_all_edges() {
|
|||||||
traversal_flags: TraversalFlags::ALL,
|
traversal_flags: TraversalFlags::ALL,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(can_traverse(EntityType::Ghost, edge));
|
assert_that(&can_traverse(EntityType::Ghost, edge)).is_true();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -70,7 +71,7 @@ fn test_can_traverse_ghost_on_ghost_only_edges() {
|
|||||||
traversal_flags: TraversalFlags::GHOST,
|
traversal_flags: TraversalFlags::GHOST,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(can_traverse(EntityType::Ghost, edge));
|
assert_that(&can_traverse(EntityType::Ghost, edge)).is_true();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -82,7 +83,7 @@ fn test_can_traverse_ghost_blocked_on_pacman_only_edges() {
|
|||||||
traversal_flags: TraversalFlags::PACMAN,
|
traversal_flags: TraversalFlags::PACMAN,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(!can_traverse(EntityType::Ghost, edge));
|
assert_that(&can_traverse(EntityType::Ghost, edge)).is_false();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -97,21 +98,21 @@ fn test_can_traverse_static_entities_flags() {
|
|||||||
// Static entities have empty traversal flags but can still "traverse"
|
// Static entities have empty traversal flags but can still "traverse"
|
||||||
// in the sense that empty flags are contained in any flag set
|
// in the sense that empty flags are contained in any flag set
|
||||||
// This is the expected behavior since empty ⊆ any set
|
// This is the expected behavior since empty ⊆ any set
|
||||||
assert!(can_traverse(EntityType::Pellet, edge));
|
assert_that(&can_traverse(EntityType::Pellet, edge)).is_true();
|
||||||
assert!(can_traverse(EntityType::PowerPellet, edge));
|
assert_that(&can_traverse(EntityType::PowerPellet, edge)).is_true();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_entity_type_traversal_flags() {
|
fn test_entity_type_traversal_flags() {
|
||||||
assert_eq!(EntityType::Player.traversal_flags(), TraversalFlags::PACMAN);
|
assert_that(&EntityType::Player.traversal_flags()).is_equal_to(TraversalFlags::PACMAN);
|
||||||
assert_eq!(EntityType::Ghost.traversal_flags(), TraversalFlags::GHOST);
|
assert_that(&EntityType::Ghost.traversal_flags()).is_equal_to(TraversalFlags::GHOST);
|
||||||
assert_eq!(EntityType::Pellet.traversal_flags(), TraversalFlags::empty());
|
assert_that(&EntityType::Pellet.traversal_flags()).is_equal_to(TraversalFlags::empty());
|
||||||
assert_eq!(EntityType::PowerPellet.traversal_flags(), TraversalFlags::empty());
|
assert_that(&EntityType::PowerPellet.traversal_flags()).is_equal_to(TraversalFlags::empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_player_control_system_move_command() {
|
fn test_player_control_system_move_command() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let _player = common::spawn_test_player(&mut world, 0);
|
let _player = common::spawn_test_player(&mut world, 0);
|
||||||
|
|
||||||
// Send move command
|
// Send move command
|
||||||
@@ -131,8 +132,8 @@ fn test_player_control_system_move_command() {
|
|||||||
direction,
|
direction,
|
||||||
remaining_time,
|
remaining_time,
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(direction, Direction::Up);
|
assert_that(&direction).is_equal_to(Direction::Up);
|
||||||
assert_eq!(remaining_time, 0.25);
|
assert_that(&remaining_time).is_equal_to(0.25);
|
||||||
}
|
}
|
||||||
BufferedDirection::None => panic!("Expected buffered direction to be set"),
|
BufferedDirection::None => panic!("Expected buffered direction to be set"),
|
||||||
}
|
}
|
||||||
@@ -140,7 +141,7 @@ fn test_player_control_system_move_command() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_player_control_system_exit_command() {
|
fn test_player_control_system_exit_command() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let _player = common::spawn_test_player(&mut world, 0);
|
let _player = common::spawn_test_player(&mut world, 0);
|
||||||
|
|
||||||
// Send exit command
|
// Send exit command
|
||||||
@@ -153,12 +154,12 @@ fn test_player_control_system_exit_command() {
|
|||||||
|
|
||||||
// Check that exit flag was set
|
// Check that exit flag was set
|
||||||
let state = world.resource::<GlobalState>();
|
let state = world.resource::<GlobalState>();
|
||||||
assert!(state.exit);
|
assert_that(&state.exit).is_true();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_player_control_system_toggle_debug() {
|
fn test_player_control_system_toggle_debug() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let _player = common::spawn_test_player(&mut world, 0);
|
let _player = common::spawn_test_player(&mut world, 0);
|
||||||
|
|
||||||
// Send toggle debug command
|
// Send toggle debug command
|
||||||
@@ -171,12 +172,12 @@ fn test_player_control_system_toggle_debug() {
|
|||||||
|
|
||||||
// Check that debug state changed
|
// Check that debug state changed
|
||||||
let debug_state = world.resource::<DebugState>();
|
let debug_state = world.resource::<DebugState>();
|
||||||
assert!(debug_state.enabled);
|
assert_that(&debug_state.enabled).is_true();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_player_control_system_mute_audio() {
|
fn test_player_control_system_mute_audio() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let _player = common::spawn_test_player(&mut world, 0);
|
let _player = common::spawn_test_player(&mut world, 0);
|
||||||
|
|
||||||
// Send mute audio command
|
// Send mute audio command
|
||||||
@@ -189,7 +190,7 @@ fn test_player_control_system_mute_audio() {
|
|||||||
|
|
||||||
// Check that audio was muted
|
// Check that audio was muted
|
||||||
let audio_state = world.resource::<AudioState>();
|
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
|
// Send mute audio command again to unmute - need fresh events
|
||||||
world.resource_mut::<Events<GameEvent>>().clear(); // Clear previous events
|
world.resource_mut::<Events<GameEvent>>().clear(); // Clear previous events
|
||||||
@@ -200,12 +201,12 @@ fn test_player_control_system_mute_audio() {
|
|||||||
|
|
||||||
// Check that audio was unmuted
|
// Check that audio was unmuted
|
||||||
let audio_state = world.resource::<AudioState>();
|
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]
|
#[test]
|
||||||
fn test_player_control_system_no_player_entity() {
|
fn test_player_control_system_no_player_entity() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
// Don't spawn a player entity
|
// Don't spawn a player entity
|
||||||
|
|
||||||
common::send_game_event(&mut world, GameEvent::Command(GameCommand::MovePlayer(Direction::Up)));
|
common::send_game_event(&mut world, GameEvent::Command(GameCommand::MovePlayer(Direction::Up)));
|
||||||
@@ -213,16 +214,14 @@ fn test_player_control_system_no_player_entity() {
|
|||||||
// Run the system - should write an error
|
// Run the system - should write an error
|
||||||
world
|
world
|
||||||
.run_system_once(player_control_system)
|
.run_system_once(player_control_system)
|
||||||
.expect("System should run successfully");
|
.expect("System should run successfully even with no player entity");
|
||||||
|
|
||||||
// Check that an error was written (we can't easily check Events without manual management,
|
// The system should run successfully and simply ignore movement commands when there's no player
|
||||||
// so for this test we just verify the system ran without panicking)
|
|
||||||
// In a real implementation, you might expose error checking through the ECS world
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_player_movement_system_buffered_direction_expires() {
|
fn test_player_movement_system_buffered_direction_expires() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let player = common::spawn_test_player(&mut world, 0);
|
let player = common::spawn_test_player(&mut world, 0);
|
||||||
|
|
||||||
// Set a buffered direction with short time
|
// Set a buffered direction with short time
|
||||||
@@ -232,7 +231,7 @@ fn test_player_movement_system_buffered_direction_expires() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Set delta time to expire the buffered direction
|
// 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
|
// Run the system
|
||||||
world
|
world
|
||||||
@@ -245,17 +244,14 @@ fn test_player_movement_system_buffered_direction_expires() {
|
|||||||
match *buffered_direction {
|
match *buffered_direction {
|
||||||
BufferedDirection::None => {} // Expected - fully expired
|
BufferedDirection::None => {} // Expected - fully expired
|
||||||
BufferedDirection::Some { remaining_time, .. } => {
|
BufferedDirection::Some { remaining_time, .. } => {
|
||||||
assert!(
|
assert_that(&(remaining_time <= 0.0)).is_true();
|
||||||
remaining_time <= 0.0,
|
|
||||||
"Buffered direction should be expired or have non-positive time"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_player_movement_system_start_moving_from_stopped() {
|
fn test_player_movement_system_start_moving_from_stopped() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let _player = common::spawn_test_player(&mut world, 0);
|
let _player = common::spawn_test_player(&mut world, 0);
|
||||||
|
|
||||||
// Player starts at node 0, facing right (towards node 1)
|
// Player starts at node 0, facing right (towards node 1)
|
||||||
@@ -271,7 +267,7 @@ fn test_player_movement_system_start_moving_from_stopped() {
|
|||||||
|
|
||||||
match *position {
|
match *position {
|
||||||
Position::Moving { from, .. } => {
|
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
|
// 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
|
Position::Stopped { .. } => {} // May stay stopped if no valid edge in current direction
|
||||||
@@ -280,7 +276,7 @@ fn test_player_movement_system_start_moving_from_stopped() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_player_movement_system_buffered_direction_change() {
|
fn test_player_movement_system_buffered_direction_change() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let player = common::spawn_test_player(&mut world, 0);
|
let player = common::spawn_test_player(&mut world, 0);
|
||||||
|
|
||||||
// Set a buffered direction to go down (towards node 2)
|
// Set a buffered direction to go down (towards node 2)
|
||||||
@@ -299,8 +295,8 @@ fn test_player_movement_system_buffered_direction_change() {
|
|||||||
|
|
||||||
match *position {
|
match *position {
|
||||||
Position::Moving { from, to, .. } => {
|
Position::Moving { from, to, .. } => {
|
||||||
assert_eq!(from, 0);
|
assert_that(&from).is_equal_to(0);
|
||||||
assert_eq!(to, 2); // Should be moving to node 2 (down)
|
assert_that(&to).is_equal_to(2); // Should be moving to node 2 (down)
|
||||||
}
|
}
|
||||||
Position::Stopped { .. } => panic!("Player should have started moving"),
|
Position::Stopped { .. } => panic!("Player should have started moving"),
|
||||||
}
|
}
|
||||||
@@ -311,7 +307,7 @@ fn test_player_movement_system_buffered_direction_change() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_player_movement_system_no_valid_edge() {
|
fn test_player_movement_system_no_valid_edge() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let player = common::spawn_test_player(&mut world, 0);
|
let player = common::spawn_test_player(&mut world, 0);
|
||||||
|
|
||||||
// Set velocity to direction with no edge
|
// Set velocity to direction with no edge
|
||||||
@@ -329,14 +325,14 @@ fn test_player_movement_system_no_valid_edge() {
|
|||||||
let position = query.single(&world).expect("Player should exist");
|
let position = query.single(&world).expect("Player should exist");
|
||||||
|
|
||||||
match *position {
|
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"),
|
Position::Moving { .. } => panic!("Player shouldn't be able to move without valid edge"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_player_movement_system_continue_moving() {
|
fn test_player_movement_system_continue_moving() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let player = common::spawn_test_player(&mut world, 0);
|
let player = common::spawn_test_player(&mut world, 0);
|
||||||
|
|
||||||
// Set player to already be moving
|
// Set player to already be moving
|
||||||
@@ -356,7 +352,7 @@ fn test_player_movement_system_continue_moving() {
|
|||||||
|
|
||||||
match *position {
|
match *position {
|
||||||
Position::Moving { remaining_distance, .. } => {
|
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 { .. } => {
|
Position::Stopped { .. } => {
|
||||||
// If player reached destination, that's also valid
|
// If player reached destination, that's also valid
|
||||||
@@ -366,7 +362,7 @@ fn test_player_movement_system_continue_moving() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_full_player_input_to_movement_flow() {
|
fn test_full_player_input_to_movement_flow() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let _player = common::spawn_test_player(&mut world, 0);
|
let _player = common::spawn_test_player(&mut world, 0);
|
||||||
|
|
||||||
// Send move command
|
// Send move command
|
||||||
@@ -388,8 +384,8 @@ fn test_full_player_input_to_movement_flow() {
|
|||||||
|
|
||||||
match *position {
|
match *position {
|
||||||
Position::Moving { from, to, .. } => {
|
Position::Moving { from, to, .. } => {
|
||||||
assert_eq!(from, 0);
|
assert_that(&from).is_equal_to(0);
|
||||||
assert_eq!(to, 2); // Moving to node 2 (down)
|
assert_that(&to).is_equal_to(2); // Moving to node 2 (down)
|
||||||
}
|
}
|
||||||
Position::Stopped { .. } => panic!("Player should be moving"),
|
Position::Stopped { .. } => panic!("Player should be moving"),
|
||||||
}
|
}
|
||||||
@@ -400,7 +396,7 @@ fn test_full_player_input_to_movement_flow() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_buffered_direction_timing() {
|
fn test_buffered_direction_timing() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let _player = common::spawn_test_player(&mut world, 0);
|
let _player = common::spawn_test_player(&mut world, 0);
|
||||||
|
|
||||||
// Send move command
|
// Send move command
|
||||||
@@ -410,7 +406,7 @@ fn test_buffered_direction_timing() {
|
|||||||
.expect("System should run successfully");
|
.expect("System should run successfully");
|
||||||
|
|
||||||
// Run movement system multiple times with small delta times
|
// 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
|
// First run - buffered direction should still be active
|
||||||
world
|
world
|
||||||
@@ -421,25 +417,25 @@ fn test_buffered_direction_timing() {
|
|||||||
|
|
||||||
match *buffered_direction {
|
match *buffered_direction {
|
||||||
BufferedDirection::Some { remaining_time, .. } => {
|
BufferedDirection::Some { remaining_time, .. } => {
|
||||||
assert!(remaining_time > 0.0);
|
assert_that(&(remaining_time > 0.0)).is_true();
|
||||||
assert!(remaining_time < 0.25);
|
assert_that(&(remaining_time < 0.25)).is_true();
|
||||||
}
|
}
|
||||||
BufferedDirection::None => panic!("Buffered direction should still be active"),
|
BufferedDirection::None => panic!("Buffered direction should still be active"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run again to fully expire the buffered direction
|
// 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
|
world
|
||||||
.run_system_once(player_movement_system)
|
.run_system_once(player_movement_system)
|
||||||
.expect("System should run successfully");
|
.expect("System should run successfully");
|
||||||
|
|
||||||
let buffered_direction = query.single(&world).expect("Player should exist");
|
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]
|
#[test]
|
||||||
fn test_multiple_rapid_direction_changes() {
|
fn test_multiple_rapid_direction_changes() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let _player = common::spawn_test_player(&mut world, 0);
|
let _player = common::spawn_test_player(&mut world, 0);
|
||||||
|
|
||||||
// Send multiple rapid direction changes
|
// Send multiple rapid direction changes
|
||||||
@@ -464,7 +460,7 @@ fn test_multiple_rapid_direction_changes() {
|
|||||||
|
|
||||||
match *buffered_direction {
|
match *buffered_direction {
|
||||||
BufferedDirection::Some { direction, .. } => {
|
BufferedDirection::Some { direction, .. } => {
|
||||||
assert_eq!(direction, Direction::Left);
|
assert_that(&direction).is_equal_to(Direction::Left);
|
||||||
}
|
}
|
||||||
BufferedDirection::None => panic!("Expected buffered direction"),
|
BufferedDirection::None => panic!("Expected buffered direction"),
|
||||||
}
|
}
|
||||||
@@ -472,7 +468,7 @@ fn test_multiple_rapid_direction_changes() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_player_state_persistence_across_systems() {
|
fn test_player_state_persistence_across_systems() {
|
||||||
let mut world = common::create_test_world();
|
let (mut world, _) = common::create_test_world();
|
||||||
let _player = common::spawn_test_player(&mut world, 0);
|
let _player = common::spawn_test_player(&mut world, 0);
|
||||||
|
|
||||||
// Test that multiple commands can be processed - but need to handle events properly
|
// Test that multiple commands can be processed - but need to handle events properly
|
||||||
@@ -510,8 +506,8 @@ fn test_player_state_persistence_across_systems() {
|
|||||||
let position = *query.single(&world).expect("Player should exist");
|
let position = *query.single(&world).expect("Player should exist");
|
||||||
|
|
||||||
// Check that the state changes persisted individually
|
// Check that the state changes persisted individually
|
||||||
assert!(debug_state_after_toggle.enabled, "Debug state should have toggled");
|
assert_that(&debug_state_after_toggle.enabled).is_true();
|
||||||
assert!(audio_muted_after_toggle, "Audio should be muted");
|
assert_that(&audio_muted_after_toggle).is_true();
|
||||||
|
|
||||||
// Player position depends on actual map connectivity
|
// Player position depends on actual map connectivity
|
||||||
match position {
|
match position {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use pacman::systems::profiling::{SystemId, SystemTimings};
|
use pacman::systems::profiling::{SystemId, SystemTimings};
|
||||||
|
use speculoos::prelude::*;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use strum::IntoEnumIterator;
|
use strum::IntoEnumIterator;
|
||||||
|
|
||||||
@@ -6,15 +7,7 @@ macro_rules! assert_close {
|
|||||||
($actual:expr, $expected:expr, $concern:expr) => {
|
($actual:expr, $expected:expr, $concern:expr) => {
|
||||||
let tolerance = Duration::from_micros(500);
|
let tolerance = Duration::from_micros(500);
|
||||||
let diff = $actual.abs_diff($expected);
|
let diff = $actual.abs_diff($expected);
|
||||||
assert!(
|
assert_that(&(diff < tolerance)).is_true();
|
||||||
diff < tolerance,
|
|
||||||
"Expected {expected:?} ± {tolerance:.0?}, got {actual:?}, off by {diff:?} ({concern})",
|
|
||||||
concern = $concern,
|
|
||||||
expected = $expected,
|
|
||||||
actual = $actual,
|
|
||||||
tolerance = tolerance,
|
|
||||||
diff = diff
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,33 +15,26 @@ macro_rules! assert_close {
|
|||||||
fn test_timing_statistics() {
|
fn test_timing_statistics() {
|
||||||
let timings = SystemTimings::default();
|
let timings = SystemTimings::default();
|
||||||
|
|
||||||
// 10ms average, 2ms std dev
|
// Add consecutive timing measurements (no skipped ticks to avoid zero padding)
|
||||||
timings.add_timing(SystemId::PlayerControls, Duration::from_millis(10));
|
timings.add_timing(SystemId::PlayerControls, Duration::from_millis(10), 1);
|
||||||
timings.add_timing(SystemId::PlayerControls, Duration::from_millis(12));
|
timings.add_timing(SystemId::PlayerControls, Duration::from_millis(12), 2);
|
||||||
timings.add_timing(SystemId::PlayerControls, Duration::from_millis(8));
|
timings.add_timing(SystemId::PlayerControls, Duration::from_millis(8), 3);
|
||||||
|
|
||||||
// 2ms average, 1ms std dev
|
// Add consecutive timing measurements for another system
|
||||||
timings.add_timing(SystemId::Blinking, Duration::from_millis(3));
|
timings.add_timing(SystemId::Blinking, Duration::from_millis(3), 1);
|
||||||
timings.add_timing(SystemId::Blinking, Duration::from_millis(2));
|
timings.add_timing(SystemId::Blinking, Duration::from_millis(2), 2);
|
||||||
timings.add_timing(SystemId::Blinking, Duration::from_millis(1));
|
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();
|
let (avg, std_dev) = stats.get(&SystemId::PlayerControls).unwrap();
|
||||||
|
|
||||||
assert_close!(*avg, Duration::from_millis(10), "PlayerControls average timing");
|
assert_close!(*avg, Duration::from_millis(10), "PlayerControls average timing");
|
||||||
assert_close!(*std_dev, Duration::from_millis(2), "PlayerControls standard deviation timing");
|
assert_close!(*std_dev, Duration::from_millis(2), "PlayerControls standard deviation timing");
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
// Note: get_total_stats() was removed as we now use the Total system directly
|
||||||
let (total_avg, total_std) = timings.get_total_stats();
|
// This test now focuses on individual system statistics
|
||||||
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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -56,22 +42,22 @@ fn test_default_zero_timing_for_unused_systems() {
|
|||||||
let timings = SystemTimings::default();
|
let timings = SystemTimings::default();
|
||||||
|
|
||||||
// Add timing data for only one system
|
// 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
|
// Verify all SystemId variants are present in the stats
|
||||||
let expected_count = SystemId::iter().count();
|
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
|
// Verify that the system with data has non-zero timing
|
||||||
let (avg, std_dev) = stats.get(&SystemId::PlayerControls).unwrap();
|
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!(*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");
|
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() {
|
for id in SystemId::iter() {
|
||||||
if id != SystemId::PlayerControls {
|
if id != SystemId::PlayerControls && id != SystemId::Total {
|
||||||
let (avg, std_dev) = stats.get(&id).unwrap();
|
let (avg, std_dev) = stats.get(&id).unwrap();
|
||||||
assert_close!(
|
assert_close!(
|
||||||
*avg,
|
*avg,
|
||||||
@@ -88,23 +74,19 @@ fn test_default_zero_timing_for_unused_systems() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_pre_populated_timing_entries() {
|
fn test_total_system_timing() {
|
||||||
let timings = SystemTimings::default();
|
let timings = SystemTimings::default();
|
||||||
|
|
||||||
// Verify that we can add timing to any SystemId without panicking
|
// Add some timing data to the Total system
|
||||||
// (this would fail with the old implementation if the entry didn't exist)
|
timings.add_total_timing(Duration::from_millis(16), 1);
|
||||||
for id in SystemId::iter() {
|
timings.add_total_timing(Duration::from_millis(18), 2);
|
||||||
timings.add_timing(id, Duration::from_nanos(1));
|
timings.add_total_timing(Duration::from_millis(14), 3);
|
||||||
}
|
|
||||||
|
|
||||||
// Verify all systems now have non-zero timing
|
let stats = timings.get_stats(3);
|
||||||
let stats = timings.get_stats();
|
let (avg, std_dev) = stats.get(&SystemId::Total).unwrap();
|
||||||
for id in SystemId::iter() {
|
|
||||||
let (avg, _) = stats.get(&id).unwrap();
|
// Should have 16ms average (16+18+14)/3 = 16ms
|
||||||
assert!(
|
assert_close!(*avg, Duration::from_millis(16), "Total system average timing");
|
||||||
*avg > Duration::ZERO,
|
// Should have some standard deviation
|
||||||
"System {:?} should have non-zero timing after add_timing",
|
assert_that(&(*std_dev > Duration::ZERO)).is_true();
|
||||||
id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
use glam::U16Vec2;
|
|
||||||
use pacman::texture::sprite::{AtlasMapper, AtlasTile, MapperFrame};
|
|
||||||
use sdl2::pixels::Color;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
mod common;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_atlas_mapper_frame_lookup() {
|
|
||||||
let mut frames = HashMap::new();
|
|
||||||
frames.insert(
|
|
||||||
"test".to_string(),
|
|
||||||
MapperFrame {
|
|
||||||
pos: U16Vec2::new(10, 20),
|
|
||||||
size: U16Vec2::new(32, 64),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let mapper = AtlasMapper { frames };
|
|
||||||
|
|
||||||
// Test direct frame lookup
|
|
||||||
let frame = mapper.frames.get("test");
|
|
||||||
assert!(frame.is_some());
|
|
||||||
let frame = frame.unwrap();
|
|
||||||
assert_eq!(frame.pos, U16Vec2::new(10, 20));
|
|
||||||
assert_eq!(frame.size, U16Vec2::new(32, 64));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_atlas_mapper_multiple_frames() {
|
|
||||||
let mut frames = HashMap::new();
|
|
||||||
frames.insert(
|
|
||||||
"tile1".to_string(),
|
|
||||||
MapperFrame {
|
|
||||||
pos: U16Vec2::new(0, 0),
|
|
||||||
size: U16Vec2::new(32, 32),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
frames.insert(
|
|
||||||
"tile2".to_string(),
|
|
||||||
MapperFrame {
|
|
||||||
pos: U16Vec2::new(32, 0),
|
|
||||||
size: U16Vec2::new(64, 64),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let mapper = AtlasMapper { frames };
|
|
||||||
|
|
||||||
assert_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"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_atlas_tile_new_and_with_color() {
|
|
||||||
let pos = U16Vec2::new(10, 20);
|
|
||||||
let size = U16Vec2::new(30, 40);
|
|
||||||
let color = Color::RGB(100, 150, 200);
|
|
||||||
|
|
||||||
let tile = AtlasTile::new(pos, size, None);
|
|
||||||
assert_eq!(tile.pos, pos);
|
|
||||||
assert_eq!(tile.size, size);
|
|
||||||
assert_eq!(tile.color, None);
|
|
||||||
|
|
||||||
let tile_with_color = tile.with_color(color);
|
|
||||||
assert_eq!(tile_with_color.color, Some(color));
|
|
||||||
}
|
|
||||||
73
tests/sprites.rs
Normal file
73
tests/sprites.rs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
//! Tests for the sprite path generation.
|
||||||
|
use pacman::{
|
||||||
|
game::ATLAS_FRAMES,
|
||||||
|
map::direction::Direction,
|
||||||
|
systems::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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
use pacman::texture::{sprite::SpriteAtlas, text::TextTexture};
|
use pacman::texture::{sprite::SpriteAtlas, text::TextTexture};
|
||||||
|
use speculoos::prelude::*;
|
||||||
|
|
||||||
mod common;
|
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
|
/// 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) {
|
fn check_char(text_texture: &mut TextTexture, atlas: &mut SpriteAtlas, c: char) {
|
||||||
// Check that the character is not in the char_map yet
|
// Check that the character is not in the char_map yet
|
||||||
assert!(
|
assert_that(&text_texture.get_char_map().contains_key(&c)).is_false();
|
||||||
!text_texture.get_char_map().contains_key(&c),
|
|
||||||
"Character {c} should not yet be in char_map"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get the tile from the atlas, which caches the tile in the char_map
|
// Get the tile from the atlas, which caches the tile in the char_map
|
||||||
let tile = text_texture.get_tile(c, atlas);
|
let tile = text_texture.get_tile(c, atlas);
|
||||||
|
|
||||||
assert!(tile.is_ok(), "Failed to get tile for character {c}");
|
assert_that(&tile.is_ok()).is_true();
|
||||||
assert!(tile.unwrap().is_some(), "Tile for character {c} not found in atlas");
|
assert_that(&tile.unwrap().is_some()).is_true();
|
||||||
|
|
||||||
// Check that the tile is now cached in the char_map
|
// Check that the tile is now cached in the char_map
|
||||||
assert!(
|
assert_that(&text_texture.get_char_map().contains_key(&c)).is_true();
|
||||||
text_texture.get_char_map().contains_key(&c),
|
|
||||||
"Tile for character {c} was not cached in char_map"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -74,8 +69,8 @@ fn test_text_width() -> Result<(), String> {
|
|||||||
let width = text_texture.text_width(&string);
|
let width = text_texture.text_width(&string);
|
||||||
let height = text_texture.text_height();
|
let height = text_texture.text_height();
|
||||||
|
|
||||||
assert!(width > 0, "Width for string {string} should be greater than 0");
|
assert_that(&(width > 0)).is_true();
|
||||||
assert!(height > 0, "Height for string {string} should be greater than 0");
|
assert_that(&(height > 0)).is_true();
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -86,44 +81,20 @@ fn test_text_scale() -> Result<(), String> {
|
|||||||
let string = "ABCDEFG !-/\"";
|
let string = "ABCDEFG !-/\"";
|
||||||
let base_width = (string.len() * 8) as u32;
|
let base_width = (string.len() * 8) as u32;
|
||||||
|
|
||||||
let mut text_texture = TextTexture::new(0.5);
|
let text_texture = TextTexture::new(0.5);
|
||||||
|
assert_that(&text_texture.text_height()).is_equal_to(4);
|
||||||
|
assert_that(&text_texture.text_width("")).is_equal_to(0);
|
||||||
|
assert_that(&text_texture.text_width(string)).is_equal_to(base_width / 2);
|
||||||
|
|
||||||
assert_eq!(text_texture.scale(), 0.5);
|
let text_texture = TextTexture::new(2.0);
|
||||||
assert_eq!(text_texture.text_height(), 4);
|
assert_that(&text_texture.text_height()).is_equal_to(16);
|
||||||
assert_eq!(text_texture.text_width(""), 0);
|
assert_that(&text_texture.text_width(string)).is_equal_to(base_width * 2);
|
||||||
assert_eq!(text_texture.text_width(string), base_width / 2);
|
assert_that(&text_texture.text_width("")).is_equal_to(0);
|
||||||
|
|
||||||
text_texture.set_scale(2.0);
|
let text_texture = TextTexture::new(1.0);
|
||||||
assert_eq!(text_texture.scale(), 2.0);
|
assert_that(&text_texture.text_height()).is_equal_to(8);
|
||||||
assert_eq!(text_texture.text_height(), 16);
|
assert_that(&text_texture.text_width(string)).is_equal_to(base_width);
|
||||||
assert_eq!(text_texture.text_width(string), base_width * 2);
|
assert_that(&text_texture.text_width("")).is_equal_to(0);
|
||||||
assert_eq!(text_texture.text_width(""), 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);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Test setting color
|
|
||||||
let test_color = sdl2::pixels::Color::YELLOW;
|
|
||||||
text_texture.set_color(test_color);
|
|
||||||
assert_eq!(text_texture.color(), 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));
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
115
tests/ttf.rs
Normal file
115
tests/ttf.rs
Normal 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());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user