mirror of
https://github.com/Xevion/Pac-Man.git
synced 2025-12-06 13:15:47 -06:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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,15 +27,31 @@ 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
|
||||||
language: system
|
language: system
|
||||||
types_or: [rust, cargo, cargo-lock]
|
types_or: [rust, cargo, cargo-lock]
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|
||||||
|
- id: bump-version
|
||||||
|
name: bump version based on commit message
|
||||||
|
entry: python scripts/bump-version.py
|
||||||
|
language: system
|
||||||
|
stages: [commit-msg]
|
||||||
|
always_run: true
|
||||||
|
|
||||||
|
- id: tag-version
|
||||||
|
name: tag version based on commit message
|
||||||
|
entry: python scripts/tag-version.py
|
||||||
|
language: system
|
||||||
|
stages: [post-commit]
|
||||||
|
always_run: true
|
||||||
|
|||||||
136
Cargo.lock
generated
136
Cargo.lock
generated
@@ -663,7 +663,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pacman"
|
name = "pacman"
|
||||||
version = "0.2.0"
|
version = "0.78.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bevy_ecs",
|
"bevy_ecs",
|
||||||
@@ -693,7 +693,7 @@ dependencies = [
|
|||||||
"tracing-error",
|
"tracing-error",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"windows",
|
"windows",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -722,7 +722,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"redox_syscall",
|
"redox_syscall",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"windows-targets 0.52.6",
|
"windows-targets",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1051,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]]
|
||||||
@@ -1398,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",
|
||||||
@@ -1411,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",
|
||||||
@@ -1433,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",
|
||||||
@@ -1466,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",
|
||||||
@@ -1482,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",
|
||||||
]
|
]
|
||||||
@@ -1504,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]]
|
||||||
@@ -1522,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",
|
||||||
]
|
]
|
||||||
@@ -1563,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"
|
||||||
|
|||||||
13
Cargo.toml
13
Cargo.toml
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pacman"
|
name = "pacman"
|
||||||
version = "0.2.0"
|
version = "0.78.3"
|
||||||
authors = ["Xevion"]
|
authors = ["Xevion"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.86.0"
|
rust-version = "1.86.0"
|
||||||
@@ -21,7 +21,7 @@ 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"] }
|
time = { version = "0.3.43", features = ["formatting", "macros"] }
|
||||||
@@ -42,15 +42,15 @@ phf = { version = "0.13.1", features = ["macros"] }
|
|||||||
# Windows-specific dependencies
|
# Windows-specific dependencies
|
||||||
[target.'cfg(target_os = "windows")'.dependencies]
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
# Used for customizing console output on Windows; both are required due to the `windows` crate having poor Result handling with `GetStdHandle`.
|
# 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]
|
||||||
@@ -98,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
|
||||||
|
|||||||
111
README.md
111
README.md
@@ -1,80 +1,92 @@
|
|||||||
|
<div align="center">
|
||||||
|
<img src="assets/repo/banner.png" alt="Pac-Man Banner Screenshot">
|
||||||
|
</div>
|
||||||
|
|
||||||
# Pac-Man
|
# 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
|
[banner-image]: assets/repo/banner.png
|
||||||
[badge-last-commit]: https://img.shields.io/github/last-commit/Xevion/Pac-Man
|
[justforfunnoreally]: https://justforfunnoreally.dev
|
||||||
[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 +99,18 @@ Since this project is still in progress, I'm only going to cover non-obvious bui
|
|||||||
- `caddy file-server --root dist` (install with `[sudo apt|brew|choco] install caddy` or [a dozen other ways](https://caddyserver.com/docs/install))
|
- `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
|
||||||
|
|||||||
30
ROADMAP.md
Normal file
30
ROADMAP.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Roadmap
|
||||||
|
|
||||||
|
A list of ideas and features that I might implement in the future.
|
||||||
|
|
||||||
|
## Debug Tooling
|
||||||
|
|
||||||
|
- [ ] Game state visualization
|
||||||
|
- [ ] Game speed controls + pausing
|
||||||
|
- [ ] Log tracing
|
||||||
|
- [x] Performance details
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
- [ ] Themes & Colors
|
||||||
|
- Color-blind friendly options
|
||||||
|
- [ ] Perfected ghost AI algorithms
|
||||||
|
- [ ] Support for >4 ghosts
|
||||||
|
- [ ] Custom level generation with multi-map tunneling
|
||||||
|
|
||||||
|
## Online Features
|
||||||
|
|
||||||
|
- [ ] Scoreboard system
|
||||||
|
- Axum server with database and OAuth2 auth
|
||||||
|
- Authentication via GitHub/Discord/Google
|
||||||
|
- Profile features:
|
||||||
|
- [ ] Optional avatars (downscaled to match 8-bit aesthetic)
|
||||||
|
- Custom names (3-14 chars, filtered for abuse)
|
||||||
|
- Zero-config client implementation
|
||||||
|
- Uses default API endpoint
|
||||||
|
- Manual override available
|
||||||
6
STORY.md
6
STORY.md
@@ -31,7 +31,7 @@ WebAssembly.
|
|||||||
The problem is that much of this work was done for pure-Rust applications - and SDL is C++.
|
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.
|
||||||
|
|
||||||
|
|||||||
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 |
27
bacon.toml
27
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"
|
||||||
|
|
||||||
@@ -66,21 +68,26 @@ need_stdout = false
|
|||||||
[jobs.doc-open]
|
[jobs.doc-open]
|
||||||
command = ["cargo", "doc", "--no-deps", "--open"]
|
command = ["cargo", "doc", "--no-deps", "--open"]
|
||||||
need_stdout = false
|
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"
|
||||||
|
|||||||
143
scripts/bump-version.py
Normal file
143
scripts/bump-version.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Pre-commit hook script to automatically bump Cargo.toml version based on commit message.
|
||||||
|
|
||||||
|
This script parses the commit message for version bump keywords and uses cargo set-version
|
||||||
|
to update the version in Cargo.toml accordingly.
|
||||||
|
|
||||||
|
Supported keywords:
|
||||||
|
- "major" or "breaking": Bump major version (1.0.0 -> 2.0.0)
|
||||||
|
- "minor" or "feature": Bump minor version (1.0.0 -> 1.1.0)
|
||||||
|
- "patch" or "fix" or "bugfix": Bump patch version (1.0.0 -> 1.0.1)
|
||||||
|
|
||||||
|
Usage: python scripts/bump-version.py <commit_message_file>
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_version():
|
||||||
|
"""Get the current version from Cargo.toml."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["cargo", "metadata", "--format-version", "1", "--no-deps"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse the JSON output to get version
|
||||||
|
import json
|
||||||
|
metadata = json.loads(result.stdout)
|
||||||
|
return metadata["packages"][0]["version"]
|
||||||
|
except (subprocess.CalledProcessError, json.JSONDecodeError, KeyError) as e:
|
||||||
|
print(f"Error getting current version: {e}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def bump_version(current_version, bump_type):
|
||||||
|
"""Calculate the new version based on bump type."""
|
||||||
|
try:
|
||||||
|
major, minor, patch = map(int, current_version.split('.'))
|
||||||
|
|
||||||
|
if bump_type == "major":
|
||||||
|
return f"{major + 1}.0.0"
|
||||||
|
elif bump_type == "minor":
|
||||||
|
return f"{major}.{minor + 1}.0"
|
||||||
|
elif bump_type == "patch":
|
||||||
|
return f"{major}.{minor}.{patch + 1}"
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
except ValueError:
|
||||||
|
print(f"Invalid version format: {current_version}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def set_version(new_version):
|
||||||
|
"""Set the new version using cargo set-version."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["cargo", "set-version", new_version],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True
|
||||||
|
)
|
||||||
|
print(f"Successfully bumped version to {new_version}")
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Error setting version: {e}", file=sys.stderr)
|
||||||
|
print(f"stdout: {e.stdout}", file=sys.stderr)
|
||||||
|
print(f"stderr: {e.stderr}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def parse_commit_message(commit_message_file):
|
||||||
|
"""Parse the commit message file for version bump keywords."""
|
||||||
|
try:
|
||||||
|
with open(commit_message_file, 'r', encoding='utf-8') as f:
|
||||||
|
message = f.read().lower()
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"Commit message file not found: {commit_message_file}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error reading commit message: {e}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check for version bump keywords
|
||||||
|
if re.search(r'\b(major|breaking)\b', message):
|
||||||
|
return "major"
|
||||||
|
elif re.search(r'\b(minor|feature)\b', message):
|
||||||
|
return "minor"
|
||||||
|
elif re.search(r'\b(patch|fix|bugfix)\b', message):
|
||||||
|
return "patch"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print("Usage: python scripts/bump-version.py <commit_message_file>", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
commit_message_file = sys.argv[1]
|
||||||
|
|
||||||
|
# Parse commit message for version bump type
|
||||||
|
bump_type = parse_commit_message(commit_message_file)
|
||||||
|
|
||||||
|
if not bump_type:
|
||||||
|
print("No version bump keywords found in commit message")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
print(f"Found version bump type: {bump_type}")
|
||||||
|
|
||||||
|
# Get current version
|
||||||
|
current_version = get_current_version()
|
||||||
|
if not current_version:
|
||||||
|
print("Failed to get current version", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Current version: {current_version}")
|
||||||
|
|
||||||
|
# Calculate new version
|
||||||
|
new_version = bump_version(current_version, bump_type)
|
||||||
|
if not new_version:
|
||||||
|
print("Failed to calculate new version", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"New version: {new_version}")
|
||||||
|
|
||||||
|
# Set the new version
|
||||||
|
if set_version(new_version):
|
||||||
|
print("Version bump completed successfully")
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
print("Version bump failed", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
125
scripts/tag-version.py
Normal file
125
scripts/tag-version.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Post-commit hook script to automatically create git tags based on the version in Cargo.toml.
|
||||||
|
|
||||||
|
This script reads the current version from Cargo.toml and creates a git tag with that version.
|
||||||
|
It's designed to run after the version has been bumped by the bump-version.py script.
|
||||||
|
|
||||||
|
Usage: python scripts/tag-version.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def get_version_from_cargo_toml():
|
||||||
|
"""Get the current version from Cargo.toml."""
|
||||||
|
cargo_toml_path = Path("Cargo.toml")
|
||||||
|
|
||||||
|
if not cargo_toml_path.exists():
|
||||||
|
print("Cargo.toml not found", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(cargo_toml_path, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Look for version = "x.y.z" pattern
|
||||||
|
version_match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
|
||||||
|
|
||||||
|
if version_match:
|
||||||
|
return version_match.group(1)
|
||||||
|
else:
|
||||||
|
print("Could not find version in Cargo.toml", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error reading Cargo.toml: {e}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_existing_tags():
|
||||||
|
"""Get list of existing git tags."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "tag", "--list"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True
|
||||||
|
)
|
||||||
|
return result.stdout.strip().split('\n') if result.stdout.strip() else []
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Error getting git tags: {e}", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def create_git_tag(version):
|
||||||
|
"""Create a git tag with the specified version."""
|
||||||
|
tag_name = f"v{version}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if tag already exists
|
||||||
|
existing_tags = get_existing_tags()
|
||||||
|
if tag_name in existing_tags:
|
||||||
|
print(f"Tag {tag_name} already exists, skipping")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Create the tag
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "tag", tag_name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Successfully created tag: {tag_name}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Error creating git tag: {e}", file=sys.stderr)
|
||||||
|
print(f"stdout: {e.stdout}", file=sys.stderr)
|
||||||
|
print(f"stderr: {e.stderr}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_git_repository():
|
||||||
|
"""Check if we're in a git repository."""
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["git", "rev-parse", "--git-dir"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Check if we're in a git repository
|
||||||
|
if not is_git_repository():
|
||||||
|
print("Not in a git repository, skipping tag creation")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# Get the current version from Cargo.toml
|
||||||
|
version = get_version_from_cargo_toml()
|
||||||
|
|
||||||
|
if not version:
|
||||||
|
print("Could not determine version, skipping tag creation")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
print(f"Current version: {version}")
|
||||||
|
|
||||||
|
# Create the git tag
|
||||||
|
if create_git_tag(version):
|
||||||
|
print("Tag creation completed successfully")
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
print("Tag creation failed", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
27
src/app.rs
27
src/app.rs
@@ -10,7 +10,7 @@ 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.
|
||||||
pub struct App {
|
pub struct App {
|
||||||
@@ -30,12 +30,20 @@ impl App {
|
|||||||
/// 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 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()))?;
|
||||||
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",
|
||||||
@@ -64,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
|
||||||
@@ -76,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()
|
||||||
@@ -88,15 +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();
|
||||||
|
|
||||||
|
info!("Starting game initialization");
|
||||||
let game = Game::new(canvas, ttf_context, texture_creator, event_pump)?;
|
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,
|
||||||
|
|||||||
12
src/asset.rs
12
src/asset.rs
@@ -19,6 +19,8 @@ pub enum Asset {
|
|||||||
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 {
|
||||||
@@ -37,6 +39,7 @@ impl Asset {
|
|||||||
Wav4 => "sound/waka/4.ogg",
|
Wav4 => "sound/waka/4.ogg",
|
||||||
AtlasImage => "atlas.png",
|
AtlasImage => "atlas.png",
|
||||||
Font => "TerminalVector.ttf",
|
Font => "TerminalVector.ttf",
|
||||||
|
DeathSound => "sound/pacman_death.wav",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,6 +48,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 +62,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, path = asset.path(), "Loading game asset");
|
||||||
|
let result = platform::get_asset_bytes(asset);
|
||||||
|
match &result {
|
||||||
|
Ok(bytes) => trace!(asset = ?asset, size_bytes = bytes.len(), "Asset loaded successfully"),
|
||||||
|
Err(e) => trace!(asset = ?asset, error = ?e, "Asset loading failed"),
|
||||||
|
}
|
||||||
|
result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
45
src/audio.rs
45
src/audio.rs
@@ -16,6 +16,7 @@ const SOUND_ASSETS: [Asset; 4] = [Asset::Wav1, Asset::Wav2, Asset::Wav3, Asset::
|
|||||||
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,
|
||||||
@@ -44,6 +45,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,
|
||||||
@@ -65,6 +67,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 +96,33 @@ impl Audio {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let death_sound = match get_asset_bytes(Asset::DeathSound) {
|
||||||
|
Ok(data) => match RWops::from_bytes(&data) {
|
||||||
|
Ok(rwops) => match rwops.load_wav() {
|
||||||
|
Ok(chunk) => Some(chunk),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to load death sound from asset API: {}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to create RWops for death sound: {}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to load death sound asset: {}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// If no sounds loaded successfully, disable audio
|
// If 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 +132,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,
|
||||||
@@ -138,6 +163,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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -45,15 +58,15 @@ pub mod animation {
|
|||||||
}
|
}
|
||||||
/// 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
|
||||||
@@ -132,8 +145,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
|
||||||
|
|||||||
@@ -40,3 +40,9 @@ 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 },
|
||||||
|
}
|
||||||
|
|||||||
255
src/game.rs
255
src/game.rs
@@ -3,38 +3,33 @@
|
|||||||
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 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};
|
use crate::error::{GameError, GameResult};
|
||||||
use crate::events::GameEvent;
|
use crate::events::{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, Timing};
|
|
||||||
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,
|
||||||
TouchState,
|
dirty_render_system, eaten_ghost_system, ghost_collision_system, ghost_movement_system, ghost_state_system,
|
||||||
};
|
hud_render_system, item_system, linear_render_system, player_life_sprite_system, present_system, profile,
|
||||||
use crate::systems::{
|
time_to_live_system, touch_ui_render_system, AudioEvent, AudioResource, AudioState, BackbufferResource, Blinking,
|
||||||
audio_system, blinking_system, collision_system, directional_render_system, dirty_render_system, eaten_ghost_system,
|
BufferedDirection, Collider, DebugState, DebugTextureResource, DeltaTime, DirectionalAnimation, EntityType, Frozen,
|
||||||
ghost_movement_system, ghost_state_system, hud_render_system, item_system, linear_render_system, profile, AudioEvent,
|
GameStage, Ghost, GhostAnimation, GhostAnimations, GhostBundle, GhostCollider, GhostState, GlobalState, Hidden, ItemBundle,
|
||||||
AudioResource, AudioState, BackbufferResource, Collider, DebugState, DebugTextureResource, DeltaTime, DirectionalAnimation,
|
ItemCollider, LastAnimationState, LinearAnimation, MapTextureResource, MovementModifiers, NodeId, PacmanCollider,
|
||||||
EntityType, Frozen, Ghost, GhostAnimations, GhostBundle, GhostCollider, GlobalState, ItemBundle, ItemCollider,
|
PlayerAnimation, PlayerBundle, PlayerControlled, PlayerDeathAnimation, PlayerLives, Position, RenderDirty, Renderable,
|
||||||
MapTextureResource, PacmanCollider, PlayerBundle, PlayerControlled, Renderable, ScoreResource, StartupSequence,
|
ScoreResource, StartupSequence, SystemId, SystemTimings, Timing, TouchState, Velocity,
|
||||||
SystemTimings,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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 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 sdl2::event::EventType;
|
use sdl2::event::EventType;
|
||||||
use sdl2::image::LoadTexture;
|
use sdl2::image::LoadTexture;
|
||||||
@@ -54,7 +49,9 @@ use crate::{
|
|||||||
|
|
||||||
/// 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,
|
||||||
|
}
|
||||||
|
|
||||||
/// Core game state manager built on the Bevy ECS architecture.
|
/// Core game state manager built on the Bevy ECS architecture.
|
||||||
///
|
///
|
||||||
@@ -93,29 +90,45 @@ impl Game {
|
|||||||
texture_creator: TextureCreator<WindowContext>,
|
texture_creator: TextureCreator<WindowContext>,
|
||||||
mut event_pump: EventPump,
|
mut event_pump: EventPump,
|
||||||
) -> GameResult<Game> {
|
) -> GameResult<Game> {
|
||||||
|
info!("Starting game initialization");
|
||||||
|
|
||||||
|
debug!("Disabling unnecessary SDL events");
|
||||||
Self::disable_sdl_events(&mut event_pump);
|
Self::disable_sdl_events(&mut event_pump);
|
||||||
|
|
||||||
|
debug!("Setting up textures and fonts");
|
||||||
let (backbuffer, mut map_texture, debug_texture, ttf_atlas) =
|
let (backbuffer, mut map_texture, debug_texture, ttf_atlas) =
|
||||||
Self::setup_textures_and_fonts(&mut canvas, &texture_creator, ttf_context)?;
|
Self::setup_textures_and_fonts(&mut canvas, &texture_creator, ttf_context)?;
|
||||||
|
|
||||||
|
debug!("Initializing audio subsystem");
|
||||||
let audio = crate::audio::Audio::new();
|
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)?;
|
let (mut atlas, map_tiles) = Self::load_atlas_and_map_tiles(&texture_creator)?;
|
||||||
|
debug!("Rendering static map to texture cache");
|
||||||
canvas
|
canvas
|
||||||
.with_texture_canvas(&mut map_texture, |map_canvas| {
|
.with_texture_canvas(&mut map_texture, |map_canvas| {
|
||||||
MapRenderer::render_map(map_canvas, &mut atlas, &map_tiles);
|
MapRenderer::render_map(map_canvas, &mut atlas, &map_tiles);
|
||||||
})
|
})
|
||||||
.map_err(|e| GameError::Sdl(e.to_string()))?;
|
.map_err(|e| GameError::Sdl(e.to_string()))?;
|
||||||
|
|
||||||
|
debug!("Building navigation graph from map layout");
|
||||||
let map = Map::new(constants::RAW_BOARD)?;
|
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_animation, player_start_sprite) = Self::create_player_animations(&atlas)?;
|
||||||
let player_bundle = Self::create_player_bundle(&map, player_animation, player_start_sprite);
|
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 world = World::default();
|
||||||
let mut schedule = Schedule::default();
|
let mut schedule = Schedule::default();
|
||||||
|
|
||||||
|
debug!("Setting up ECS event registry and observers");
|
||||||
Self::setup_ecs(&mut world);
|
Self::setup_ecs(&mut world);
|
||||||
|
|
||||||
|
debug!("Inserting resources into ECS world");
|
||||||
Self::insert_resources(
|
Self::insert_resources(
|
||||||
&mut world,
|
&mut world,
|
||||||
map,
|
map,
|
||||||
@@ -127,13 +140,20 @@ impl Game {
|
|||||||
map_texture,
|
map_texture,
|
||||||
debug_texture,
|
debug_texture,
|
||||||
ttf_atlas,
|
ttf_atlas,
|
||||||
|
death_animation,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
debug!("Configuring system execution schedule");
|
||||||
Self::configure_schedule(&mut schedule);
|
Self::configure_schedule(&mut schedule);
|
||||||
|
|
||||||
|
debug!("Spawning player entity");
|
||||||
world.spawn(player_bundle).insert((Frozen, Hidden));
|
world.spawn(player_bundle).insert((Frozen, Hidden));
|
||||||
|
|
||||||
|
info!("Spawning game entities");
|
||||||
Self::spawn_ghosts(&mut world)?;
|
Self::spawn_ghosts(&mut world)?;
|
||||||
Self::spawn_items(&mut world)?;
|
Self::spawn_items(&mut world)?;
|
||||||
|
|
||||||
|
info!("Game initialization completed successfully");
|
||||||
Ok(Game { world, schedule })
|
Ok(Game { world, schedule })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +245,7 @@ impl Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn load_atlas_and_map_tiles(texture_creator: &TextureCreator<WindowContext>) -> GameResult<(SpriteAtlas, Vec<AtlasTile>)> {
|
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") {
|
||||||
@@ -236,11 +257,13 @@ 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 atlas = SpriteAtlas::new(atlas_texture, atlas_mapper);
|
let atlas = SpriteAtlas::new(atlas_texture, atlas_mapper);
|
||||||
|
|
||||||
|
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 = GameSprite::Maze(MazeSprite::Tile(i)).to_path();
|
let tile_name = GameSprite::Maze(MazeSprite::Tile(i)).to_path();
|
||||||
@@ -310,6 +333,18 @@ impl Game {
|
|||||||
Ok((player_animation, player_start_sprite))
|
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 {
|
fn create_player_bundle(map: &Map, player_animation: DirectionalAnimation, player_start_sprite: AtlasTile) -> PlayerBundle {
|
||||||
PlayerBundle {
|
PlayerBundle {
|
||||||
player: PlayerControlled,
|
player: PlayerControlled,
|
||||||
@@ -339,6 +374,7 @@ impl Game {
|
|||||||
EventRegistry::register_event::<GameError>(world);
|
EventRegistry::register_event::<GameError>(world);
|
||||||
EventRegistry::register_event::<GameEvent>(world);
|
EventRegistry::register_event::<GameEvent>(world);
|
||||||
EventRegistry::register_event::<AudioEvent>(world);
|
EventRegistry::register_event::<AudioEvent>(world);
|
||||||
|
EventRegistry::register_event::<StageTransition>(world);
|
||||||
|
|
||||||
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>| {
|
||||||
@@ -361,13 +397,18 @@ impl Game {
|
|||||||
map_texture: sdl2::render::Texture,
|
map_texture: sdl2::render::Texture,
|
||||||
debug_texture: sdl2::render::Texture,
|
debug_texture: sdl2::render::Texture,
|
||||||
ttf_atlas: crate::texture::ttf::TtfAtlas,
|
ttf_atlas: crate::texture::ttf::TtfAtlas,
|
||||||
|
death_animation: LinearAnimation,
|
||||||
) -> GameResult<()> {
|
) -> GameResult<()> {
|
||||||
world.insert_non_send_resource(atlas);
|
world.insert_non_send_resource(atlas);
|
||||||
world.insert_resource(Self::create_ghost_animations(world.non_send_resource::<SpriteAtlas>())?);
|
world.insert_resource(Self::create_ghost_animations(world.non_send_resource::<SpriteAtlas>())?);
|
||||||
|
let player_animation = Self::create_player_animations(world.non_send_resource::<SpriteAtlas>())?.0;
|
||||||
|
world.insert_resource(PlayerAnimation(player_animation));
|
||||||
|
world.insert_resource(PlayerDeathAnimation(death_animation));
|
||||||
|
|
||||||
world.insert_resource(BatchedLinesResource::new(&map, constants::LARGE_SCALE));
|
world.insert_resource(BatchedLinesResource::new(&map, constants::LARGE_SCALE));
|
||||||
world.insert_resource(map);
|
world.insert_resource(map);
|
||||||
world.insert_resource(GlobalState { exit: false });
|
world.insert_resource(GlobalState { exit: false });
|
||||||
|
world.insert_resource(PlayerLives::default());
|
||||||
world.insert_resource(ScoreResource(0));
|
world.insert_resource(ScoreResource(0));
|
||||||
world.insert_resource(SystemTimings::default());
|
world.insert_resource(SystemTimings::default());
|
||||||
world.insert_resource(Timing::default());
|
world.insert_resource(Timing::default());
|
||||||
@@ -378,10 +419,9 @@ impl Game {
|
|||||||
world.insert_resource(AudioState::default());
|
world.insert_resource(AudioState::default());
|
||||||
world.insert_resource(CursorPosition::default());
|
world.insert_resource(CursorPosition::default());
|
||||||
world.insert_resource(TouchState::default());
|
world.insert_resource(TouchState::default());
|
||||||
world.insert_resource(StartupSequence::new(
|
world.insert_resource(GameStage::Starting(StartupSequence::TextOnly {
|
||||||
constants::startup::STARTUP_FRAMES,
|
remaining_ticks: constants::startup::STARTUP_FRAMES,
|
||||||
constants::startup::STARTUP_TICKS_PER_FRAME,
|
}));
|
||||||
));
|
|
||||||
|
|
||||||
world.insert_non_send_resource(event_pump);
|
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::<&mut Canvas<Window>>(Box::leak(Box::new(canvas)));
|
||||||
@@ -394,15 +434,14 @@ impl Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn configure_schedule(schedule: &mut Schedule) {
|
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 ghost_collision_system = profile(SystemId::GhostCollision, ghost_collision_system);
|
||||||
|
|
||||||
let item_system = profile(SystemId::Item, item_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);
|
||||||
@@ -410,47 +449,62 @@ impl Game {
|
|||||||
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 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>| {
|
let forced_dirty_system = |mut dirty: ResMut<RenderDirty>| {
|
||||||
dirty.0 = true;
|
dirty.0 = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
schedule.add_systems((
|
schedule.add_systems((forced_dirty_system
|
||||||
forced_dirty_system.run_if(resource_changed::<ScoreResource>.or(resource_changed::<StartupSequence>)),
|
.run_if(|score: Res<ScoreResource>, stage: Res<GameStage>| score.is_changed() || stage.is_changed()),));
|
||||||
(
|
|
||||||
input_system.run_if(|mut local: Local<u8>| {
|
// Input system should always run to prevent SDL event pump from blocking
|
||||||
*local = local.wrapping_add(1u8);
|
let input_systems = (
|
||||||
// run every nth frame
|
input_system.run_if(|mut local: Local<u8>| {
|
||||||
*local % 2 == 0
|
*local = local.wrapping_add(1u8);
|
||||||
}),
|
// run every nth frame
|
||||||
player_control_system,
|
*local % 2 == 0
|
||||||
player_movement_system,
|
}),
|
||||||
startup_stage_system,
|
player_control_system,
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain();
|
||||||
player_tunnel_slowdown_system,
|
|
||||||
ghost_movement_system,
|
let gameplay_systems = (
|
||||||
profile(SystemId::EatenGhost, eaten_ghost_system),
|
(player_movement_system, player_tunnel_slowdown_system, ghost_movement_system).chain(),
|
||||||
unified_ghost_state_system,
|
eaten_ghost_system,
|
||||||
(collision_system, ghost_collision_system, item_system).chain(),
|
(collision_system, ghost_collision_system, item_system).chain(),
|
||||||
audio_system,
|
unified_ghost_state_system,
|
||||||
blinking_system,
|
)
|
||||||
|
.chain()
|
||||||
|
.run_if(|game_state: Res<GameStage>| matches!(*game_state, GameStage::Playing));
|
||||||
|
|
||||||
|
schedule.add_systems((blinking_system, directional_render_system, linear_render_system).in_set(RenderSet::Animation));
|
||||||
|
|
||||||
|
schedule.add_systems((
|
||||||
|
time_to_live_system,
|
||||||
|
stage_system,
|
||||||
|
input_systems,
|
||||||
|
gameplay_systems,
|
||||||
(
|
(
|
||||||
directional_render_system,
|
|
||||||
linear_render_system,
|
|
||||||
dirty_render_system,
|
dirty_render_system,
|
||||||
combined_render_system,
|
combined_render_system,
|
||||||
hud_render_system,
|
hud_render_system,
|
||||||
|
player_life_sprite_system,
|
||||||
touch_ui_render_system,
|
touch_ui_render_system,
|
||||||
present_system,
|
present_system,
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain()
|
||||||
|
.after(RenderSet::Animation),
|
||||||
|
audio_system,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_items(world: &mut World) -> GameResult<()> {
|
fn spawn_items(world: &mut World) -> GameResult<()> {
|
||||||
|
trace!("Loading item sprites from atlas");
|
||||||
let pellet_sprite = SpriteAtlas::get_tile(
|
let pellet_sprite = SpriteAtlas::get_tile(
|
||||||
world.non_send_resource::<SpriteAtlas>(),
|
world.non_send_resource::<SpriteAtlas>(),
|
||||||
&GameSprite::Maze(MazeSprite::Pellet).to_path(),
|
&GameSprite::Maze(MazeSprite::Pellet).to_path(),
|
||||||
@@ -475,6 +529,12 @@ impl Game {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
info!(
|
||||||
|
pellet_count = nodes.iter().filter(|(_, t, _, _)| *t == EntityType::Pellet).count(),
|
||||||
|
power_pellet_count = nodes.iter().filter(|(_, t, _, _)| *t == EntityType::PowerPellet).count(),
|
||||||
|
"Spawning collectible items"
|
||||||
|
);
|
||||||
|
|
||||||
for (id, item_type, sprite, size) in nodes {
|
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 },
|
||||||
@@ -498,6 +558,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>();
|
||||||
@@ -512,7 +573,7 @@ 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();
|
let sprite_path = GameSprite::Ghost(GhostSprite::Normal(ghost_type, Direction::Left, 0)).to_path();
|
||||||
|
|
||||||
@@ -538,9 +599,11 @@ impl Game {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
world.spawn(ghost).insert((Frozen, Hidden));
|
let entity = world.spawn(ghost).insert((Frozen, Hidden)).id();
|
||||||
|
trace!(ghost = ?ghost_type, entity = ?entity, start_node, "Spawned ghost entity");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
info!("All ghost entities spawned successfully");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,7 +620,7 @@ 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();
|
||||||
|
|
||||||
@@ -586,7 +649,7 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -649,6 +712,28 @@ impl Game {
|
|||||||
) {
|
) {
|
||||||
let new_tick = timing.increment_tick();
|
let new_tick = timing.increment_tick();
|
||||||
timings.add_total_timing(total_duration, new_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
|
||||||
@@ -658,68 +743,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(())
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|||||||
14
src/lib.rs
14
src/lib.rs
@@ -1,14 +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;
|
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;
|
||||||
|
|||||||
16
src/main.rs
16
src/main.rs
@@ -1,20 +1,27 @@
|
|||||||
// 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))]
|
||||||
|
|
||||||
use crate::{app::App, constants::LOOP_TIME};
|
use crate::{app::App, constants::LOOP_TIME};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
|
#[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;
|
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;
|
||||||
|
|
||||||
@@ -22,6 +29,7 @@ mod texture;
|
|||||||
///
|
///
|
||||||
/// This function initializes SDL, the window, the game state, and then enters
|
/// This function initializes SDL, the window, the game state, and then enters
|
||||||
/// the main game loop.
|
/// the main game loop.
|
||||||
|
#[cfg_attr(coverage_nightly, coverage(off))]
|
||||||
pub fn main() {
|
pub fn main() {
|
||||||
// On Windows, this connects output streams to the console dynamically
|
// On Windows, this connects output streams to the console dynamically
|
||||||
// On Emscripten, this connects the subscriber to the browser console
|
// On Emscripten, this connects the subscriber to the browser console
|
||||||
|
|||||||
@@ -56,11 +56,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();
|
||||||
@@ -157,8 +163,10 @@ impl Map {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -65,6 +65,7 @@ pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
|
|||||||
Asset::Wav4 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/4.ogg"))),
|
Asset::Wav4 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/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.wav"))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,15 +1,21 @@
|
|||||||
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::{EventReader, EventWriter},
|
||||||
use bevy_ecs::system::{Query, Res, ResMut};
|
query::With,
|
||||||
|
system::{Commands, Query, Res, ResMut, Single},
|
||||||
|
};
|
||||||
|
use tracing::{debug, trace, warn};
|
||||||
|
|
||||||
use crate::error::GameError;
|
use crate::error::GameError;
|
||||||
use crate::events::GameEvent;
|
use crate::events::{GameEvent, StageTransition};
|
||||||
use crate::map::builder::Map;
|
use crate::map::builder::Map;
|
||||||
use crate::systems::movement::Position;
|
use crate::systems::{
|
||||||
use crate::systems::{AudioEvent, Ghost, GhostState, PlayerControlled, ScoreResource};
|
components::GhostState, movement::Position, AudioEvent, DyingSequence, Frozen, GameStage, Ghost, PlayerControlled,
|
||||||
|
ScoreResource,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 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,
|
||||||
@@ -62,6 +68,7 @@ pub fn check_collision(
|
|||||||
///
|
///
|
||||||
/// 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>>,
|
||||||
@@ -76,6 +83,7 @@ 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 {
|
||||||
|
trace!(pacman_entity = ?pacman_entity, item_entity = ?item_entity, "Item collision detected");
|
||||||
events.write(GameEvent::Collision(pacman_entity, item_entity));
|
events.write(GameEvent::Collision(pacman_entity, item_entity));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,6 +101,7 @@ pub fn collision_system(
|
|||||||
match check_collision(pacman_pos, pacman_collider, ghost_pos, ghost_collider, &map) {
|
match check_collision(pacman_pos, pacman_collider, ghost_pos, ghost_collider, &map) {
|
||||||
Ok(colliding) => {
|
Ok(colliding) => {
|
||||||
if colliding {
|
if colliding {
|
||||||
|
trace!(pacman_entity = ?pacman_entity, ghost_entity = ?ghost_entity, "Ghost collision detected");
|
||||||
events.write(GameEvent::Collision(pacman_entity, ghost_entity));
|
events.write(GameEvent::Collision(pacman_entity, ghost_entity));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,10 +116,14 @@ pub fn collision_system(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn ghost_collision_system(
|
pub fn ghost_collision_system(
|
||||||
|
mut commands: Commands,
|
||||||
mut collision_events: EventReader<GameEvent>,
|
mut collision_events: EventReader<GameEvent>,
|
||||||
|
mut stage_events: EventWriter<StageTransition>,
|
||||||
mut score: ResMut<ScoreResource>,
|
mut score: ResMut<ScoreResource>,
|
||||||
pacman_query: Query<(), With<PlayerControlled>>,
|
mut game_state: ResMut<GameStage>,
|
||||||
|
player: Single<Entity, With<PlayerControlled>>,
|
||||||
ghost_query: Query<(Entity, &Ghost), With<GhostCollider>>,
|
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>,
|
||||||
@@ -118,9 +131,9 @@ pub fn ghost_collision_system(
|
|||||||
for event in collision_events.read() {
|
for event in collision_events.read() {
|
||||||
if let GameEvent::Collision(entity1, entity2) = event {
|
if let GameEvent::Collision(entity1, entity2) = event {
|
||||||
// Check if one is Pacman and the other is a ghost
|
// Check if one is Pacman and the other is a ghost
|
||||||
let (_pacman_entity, ghost_entity) = if pacman_query.get(*entity1).is_ok() && ghost_query.get(*entity2).is_ok() {
|
let (pacman_entity, ghost_entity) = if *entity1 == *player && ghost_query.get(*entity2).is_ok() {
|
||||||
(*entity1, *entity2)
|
(*entity1, *entity2)
|
||||||
} else if pacman_query.get(*entity2).is_ok() && ghost_query.get(*entity1).is_ok() {
|
} else if *entity2 == *player && ghost_query.get(*entity1).is_ok() {
|
||||||
(*entity2, *entity1)
|
(*entity2, *entity1)
|
||||||
} else {
|
} else {
|
||||||
continue;
|
continue;
|
||||||
@@ -128,20 +141,29 @@ pub fn ghost_collision_system(
|
|||||||
|
|
||||||
// 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((ghost_ent, _ghost_type)) = ghost_query.get(ghost_entity) {
|
||||||
if let Ok(mut ghost_state) = ghost_state_query.get_mut(ghost_ent) {
|
if let Ok(ghost_state) = ghost_state_query.get_mut(ghost_ent) {
|
||||||
// Check if ghost is in frightened state
|
// 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_entity = ?ghost_ent, score_added = 200, new_score = score.0 + 200, "Pacman ate frightened ghost");
|
||||||
score.0 += 200;
|
score.0 += 200;
|
||||||
|
|
||||||
// Set ghost state to Eyes
|
// Enter short pause to show bonus points, hide ghost, then set Eyes after pause
|
||||||
*ghost_state = GhostState::Eyes;
|
// Request transition via event so stage_system can process it
|
||||||
|
stage_events.write(StageTransition::GhostEatenPause { ghost_entity: ghost_ent });
|
||||||
|
|
||||||
// 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_entity = ?ghost_ent, "Pacman hit by normal ghost, player dies");
|
||||||
|
*game_state = GameStage::PlayerDying(DyingSequence::Frozen { remaining_ticks: 60 });
|
||||||
|
commands.entity(pacman_entity).insert(Frozen);
|
||||||
|
commands.entity(ghost_entity).insert(Frozen);
|
||||||
|
events.write(AudioEvent::StopAll);
|
||||||
} else {
|
} else {
|
||||||
// Pac-Man dies (this would need a death system)
|
trace!(ghost_state = ?*ghost_state, "Ghost collision ignored due to state");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ pub struct Renderable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Directional animation component with shared timing across all directions
|
/// Directional animation component with shared timing across all directions
|
||||||
#[derive(Component, Clone, Copy)]
|
#[derive(Component, Clone)]
|
||||||
pub struct DirectionalAnimation {
|
pub struct DirectionalAnimation {
|
||||||
pub moving_tiles: DirectionalTiles,
|
pub moving_tiles: DirectionalTiles,
|
||||||
pub stopped_tiles: DirectionalTiles,
|
pub stopped_tiles: DirectionalTiles,
|
||||||
@@ -123,13 +123,18 @@ impl DirectionalAnimation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tag component to mark animations that should loop when they reach the end
|
||||||
|
#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Looping;
|
||||||
|
|
||||||
/// Linear animation component for non-directional animations (frightened ghosts)
|
/// Linear animation component for non-directional animations (frightened ghosts)
|
||||||
#[derive(Component, Clone, Copy)]
|
#[derive(Component, Resource, Clone)]
|
||||||
pub struct LinearAnimation {
|
pub struct LinearAnimation {
|
||||||
pub tiles: TileSequence,
|
pub tiles: TileSequence,
|
||||||
pub current_frame: usize,
|
pub current_frame: usize,
|
||||||
pub time_bank: u16,
|
pub time_bank: u16,
|
||||||
pub frame_duration: u16,
|
pub frame_duration: u16,
|
||||||
|
pub finished: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LinearAnimation {
|
impl LinearAnimation {
|
||||||
@@ -140,6 +145,7 @@ impl LinearAnimation {
|
|||||||
current_frame: 0,
|
current_frame: 0,
|
||||||
time_bank: 0,
|
time_bank: 0,
|
||||||
frame_duration,
|
frame_duration,
|
||||||
|
finished: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,6 +224,19 @@ pub struct Frozen;
|
|||||||
#[derive(Component, Debug, Clone, Copy)]
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
pub struct Eaten;
|
pub struct Eaten;
|
||||||
|
|
||||||
|
/// Tag component for Pac-Man during his death animation.
|
||||||
|
/// This is mainly because the Frozen tag would stop both movement and animation, while the Dying tag can signal that the animation should continue despite being frozen.
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
pub struct Dying;
|
||||||
|
|
||||||
|
/// Component for HUD life sprite entities.
|
||||||
|
/// Each life sprite entity has an index indicating its position from left to right (0, 1, 2, etc.).
|
||||||
|
/// This mostly functions as a tag component for sprites.
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
pub struct PlayerLife {
|
||||||
|
pub index: u32,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Component, Debug, Clone, Copy)]
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
pub enum GhostState {
|
pub enum GhostState {
|
||||||
/// Normal ghost behavior - chasing Pac-Man
|
/// Normal ghost behavior - chasing Pac-Man
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
//! Debug rendering system
|
//! Debug rendering system
|
||||||
use std::cmp::Ordering;
|
#[cfg_attr(coverage_nightly, feature(coverage_attribute))]
|
||||||
|
|
||||||
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 +12,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,6 +149,7 @@ 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,
|
||||||
@@ -203,6 +204,7 @@ 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,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use crate::platform;
|
use crate::platform;
|
||||||
use crate::systems::components::{DirectionalAnimation, Frozen, GhostAnimation, GhostState, LastAnimationState, LinearAnimation};
|
use crate::systems::components::{
|
||||||
|
DirectionalAnimation, Frozen, GhostAnimation, GhostState, LastAnimationState, LinearAnimation, Looping,
|
||||||
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
map::{
|
map::{
|
||||||
builder::Map,
|
builder::Map,
|
||||||
@@ -11,6 +13,7 @@ use crate::{
|
|||||||
movement::{Position, Velocity},
|
movement::{Position, Velocity},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
use tracing::{debug, trace, warn};
|
||||||
|
|
||||||
use crate::systems::GhostAnimations;
|
use crate::systems::GhostAnimations;
|
||||||
use bevy_ecs::query::Without;
|
use bevy_ecs::query::Without;
|
||||||
@@ -43,8 +46,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 {
|
||||||
@@ -116,6 +121,7 @@ pub fn eaten_ghost_system(
|
|||||||
// 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 {
|
||||||
@@ -192,24 +198,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;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use bevy_ecs::{
|
|||||||
query::With,
|
query::With,
|
||||||
system::{Commands, Query, ResMut},
|
system::{Commands, Query, ResMut},
|
||||||
};
|
};
|
||||||
|
use tracing::{debug, trace};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
constants::animation::FRIGHTENED_FLASH_START_TICKS,
|
constants::animation::FRIGHTENED_FLASH_START_TICKS,
|
||||||
@@ -45,6 +46,7 @@ pub fn item_system(
|
|||||||
// Get the item type and update score
|
// Get the item type and update score
|
||||||
if let Ok((item_ent, entity_type)) = item_query.get(item_entity) {
|
if let Ok((item_ent, entity_type)) = item_query.get(item_entity) {
|
||||||
if let Some(score_value) = entity_type.score_value() {
|
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;
|
score.0 += score_value;
|
||||||
|
|
||||||
// Remove the collected item
|
// Remove the collected item
|
||||||
@@ -59,13 +61,17 @@ pub fn item_system(
|
|||||||
if *entity_type == EntityType::PowerPellet {
|
if *entity_type == EntityType::PowerPellet {
|
||||||
// Convert seconds to frames (assumes 60 FPS)
|
// Convert seconds to frames (assumes 60 FPS)
|
||||||
let total_ticks = 60 * 5; // 5 seconds total
|
let total_ticks = 60 * 5; // 5 seconds total
|
||||||
|
debug!(duration_ticks = total_ticks, "Power pellet collected, frightening ghosts");
|
||||||
|
|
||||||
// Set all ghosts to frightened state, except those in Eyes state
|
// Set all ghosts to frightened state, except those in Eyes state
|
||||||
|
let mut frightened_count = 0;
|
||||||
for mut ghost_state in ghost_query.iter_mut() {
|
for mut ghost_state in ghost_query.iter_mut() {
|
||||||
if !matches!(*ghost_state, GhostState::Eyes) {
|
if !matches!(*ghost_state, GhostState::Eyes) {
|
||||||
*ghost_state = GhostState::new_frightened(total_ticks, FRIGHTENED_FLASH_START_TICKS);
|
*ghost_state = GhostState::new_frightened(total_ticks, FRIGHTENED_FLASH_START_TICKS);
|
||||||
|
frightened_count += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
debug!(frightened_count, "Ghosts set to frightened state");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
33
src/systems/lifetime.rs
Normal file
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::components::DeltaTime;
|
||||||
|
|
||||||
|
/// Component for entities that should be automatically deleted after a certain number of ticks
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
pub struct TimeToLive {
|
||||||
|
pub remaining_ticks: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TimeToLive {
|
||||||
|
pub fn new(ticks: u32) -> Self {
|
||||||
|
Self { remaining_ticks: ticks }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System that manages entities with TimeToLive components, decrementing their remaining ticks
|
||||||
|
/// and despawning them when they expire
|
||||||
|
pub fn time_to_live_system(mut commands: Commands, dt: Res<DeltaTime>, mut query: Query<(Entity, &mut TimeToLive)>) {
|
||||||
|
for (entity, mut ttl) in query.iter_mut() {
|
||||||
|
if ttl.remaining_ticks <= dt.ticks {
|
||||||
|
// Entity has expired, despawn it
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
} else {
|
||||||
|
// Decrement remaining time
|
||||||
|
ttl.remaining_ticks = ttl.remaining_ticks.saturating_sub(dt.ticks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,26 @@
|
|||||||
//! 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.
|
|
||||||
|
|
||||||
|
#[cfg_attr(coverage_nightly, coverage(off))]
|
||||||
pub mod audio;
|
pub mod audio;
|
||||||
|
#[cfg_attr(coverage_nightly, coverage(off))]
|
||||||
|
pub mod debug;
|
||||||
|
#[cfg_attr(coverage_nightly, coverage(off))]
|
||||||
|
pub mod profiling;
|
||||||
|
#[cfg_attr(coverage_nightly, coverage(off))]
|
||||||
|
pub mod render;
|
||||||
|
|
||||||
pub mod blinking;
|
pub mod blinking;
|
||||||
pub mod collision;
|
pub mod collision;
|
||||||
pub mod components;
|
pub mod components;
|
||||||
pub mod debug;
|
|
||||||
pub mod ghost;
|
pub mod ghost;
|
||||||
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::audio::*;
|
pub use self::audio::*;
|
||||||
pub use self::blinking::*;
|
pub use self::blinking::*;
|
||||||
@@ -25,8 +30,9 @@ pub use self::debug::*;
|
|||||||
pub use self::ghost::*;
|
pub use self::ghost::*;
|
||||||
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,11 +1,11 @@
|
|||||||
use bevy_ecs::{
|
use bevy_ecs::{
|
||||||
event::{EventReader, EventWriter},
|
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::{
|
||||||
@@ -32,30 +32,21 @@ pub fn player_control_system(
|
|||||||
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 {
|
if 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!(
|
direction: *direction,
|
||||||
"No/multiple entities queried for player system: {}",
|
remaining_time: 0.25,
|
||||||
e
|
};
|
||||||
)));
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
*buffered_direction = BufferedDirection::Some {
|
|
||||||
direction: *direction,
|
|
||||||
remaining_time: 0.25,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
GameCommand::Exit => {
|
GameCommand::Exit => {
|
||||||
state.exit = true;
|
state.exit = true;
|
||||||
@@ -86,6 +77,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,6 +87,7 @@ 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 {
|
||||||
@@ -115,6 +108,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 +124,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 +135,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 +157,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);
|
||||||
modifiers.tunnel_slowdown_active = in_tunnel;
|
|
||||||
modifiers.speed_multiplier = if in_tunnel { 0.6 } else { 1.0 };
|
if modifiers.tunnel_slowdown_active != in_tunnel {
|
||||||
|
trace!(
|
||||||
|
node,
|
||||||
|
in_tunnel,
|
||||||
|
speed_multiplier = if in_tunnel { 0.6 } else { 1.0 },
|
||||||
|
"Player tunnel slowdown state changed"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
modifiers.tunnel_slowdown_active = in_tunnel;
|
||||||
|
modifiers.speed_multiplier = if in_tunnel { 0.6 } else { 1.0 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ impl TimingBuffer {
|
|||||||
self.last_tick = current_tick;
|
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.
|
/// Gets statistics for this timing buffer.
|
||||||
///
|
///
|
||||||
/// # Panics
|
/// # Panics
|
||||||
@@ -157,6 +162,7 @@ pub enum SystemId {
|
|||||||
Stage,
|
Stage,
|
||||||
GhostStateAnimation,
|
GhostStateAnimation,
|
||||||
EatenGhost,
|
EatenGhost,
|
||||||
|
TimeToLive,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Display for SystemId {
|
impl Display for SystemId {
|
||||||
@@ -247,6 +253,61 @@ impl SystemTimings {
|
|||||||
// 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)
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
use crate::constants::CANVAS_SIZE;
|
|
||||||
use crate::error::{GameError, TextureError};
|
|
||||||
use crate::map::builder::Map;
|
use crate::map::builder::Map;
|
||||||
|
use crate::map::direction::Direction;
|
||||||
use crate::systems::input::TouchState;
|
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, DeltaTime,
|
||||||
DirectionalAnimation, LinearAnimation, Position, Renderable, ScoreResource, StartupSequence, SystemId, SystemTimings,
|
DirectionalAnimation, Dying, Frozen, GameStage, LinearAnimation, Looping, PlayerLife, PlayerLives, Position, Renderable,
|
||||||
TtfAtlasResource, Velocity,
|
ScoreResource, StartupSequence, SystemId, SystemTimings, TtfAtlasResource, Velocity,
|
||||||
};
|
};
|
||||||
use crate::texture::sprite::SpriteAtlas;
|
use crate::texture::sprite::SpriteAtlas;
|
||||||
|
use crate::texture::sprites::{GameSprite, PacmanSprite};
|
||||||
use crate::texture::text::TextTexture;
|
use crate::texture::text::TextTexture;
|
||||||
|
use crate::{
|
||||||
|
constants::{BOARD_BOTTOM_PIXEL_OFFSET, CANVAS_SIZE, CELL_SIZE},
|
||||||
|
error::{GameError, TextureError},
|
||||||
|
};
|
||||||
use bevy_ecs::component::Component;
|
use bevy_ecs::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, Has, Or, With, Without};
|
||||||
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::{Commands, NonSendMut, Query, Res, ResMut};
|
||||||
|
use glam::Vec2;
|
||||||
use sdl2::pixels::Color;
|
use sdl2::pixels::Color;
|
||||||
use sdl2::rect::{Point, Rect};
|
use sdl2::rect::{Point, Rect};
|
||||||
use sdl2::render::{BlendMode, Canvas, Texture};
|
use sdl2::render::{BlendMode, Canvas, Texture};
|
||||||
@@ -42,7 +47,11 @@ pub fn dirty_render_system(
|
|||||||
removed_hidden: RemovedComponents<Hidden>,
|
removed_hidden: RemovedComponents<Hidden>,
|
||||||
removed_renderables: RemovedComponents<Renderable>,
|
removed_renderables: RemovedComponents<Renderable>,
|
||||||
) {
|
) {
|
||||||
if !changed.is_empty() || !removed_hidden.is_empty() || !removed_renderables.is_empty() {
|
let changed_count = changed.iter().count();
|
||||||
|
let removed_hidden_count = removed_hidden.len();
|
||||||
|
let removed_renderables_count = removed_renderables.len();
|
||||||
|
|
||||||
|
if changed_count > 0 || removed_hidden_count > 0 || removed_renderables_count > 0 {
|
||||||
dirty.0 = true;
|
dirty.0 = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,7 +62,7 @@ pub fn dirty_render_system(
|
|||||||
/// All directions share the same frame timing to ensure perfect synchronization.
|
/// All directions share the same frame timing to ensure perfect synchronization.
|
||||||
pub fn directional_render_system(
|
pub fn directional_render_system(
|
||||||
dt: Res<DeltaTime>,
|
dt: Res<DeltaTime>,
|
||||||
mut query: Query<(&Position, &Velocity, &mut DirectionalAnimation, &mut Renderable)>,
|
mut query: Query<(&Position, &Velocity, &mut DirectionalAnimation, &mut Renderable), Without<Frozen>>,
|
||||||
) {
|
) {
|
||||||
let ticks = (dt.seconds * 60.0).round() as u16; // Convert from seconds to ticks at 60 ticks/sec
|
let ticks = (dt.seconds * 60.0).round() as u16; // Convert from seconds to ticks at 60 ticks/sec
|
||||||
|
|
||||||
@@ -86,29 +95,116 @@ pub fn directional_render_system(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates linear animated entities (used for non-directional animations like frightened ghosts).
|
/// System that updates `Renderable` sprites for entities with `LinearAnimation`.
|
||||||
///
|
#[allow(clippy::type_complexity)]
|
||||||
/// This system handles entities that use LinearAnimation component for simple frame cycling.
|
pub fn linear_render_system(
|
||||||
pub fn linear_render_system(dt: Res<DeltaTime>, mut query: Query<(&mut LinearAnimation, &mut Renderable)>) {
|
dt: Res<DeltaTime>,
|
||||||
let ticks = (dt.seconds * 60.0).round() as u16; // Convert from seconds to ticks at 60 ticks/sec
|
mut query: Query<(&mut LinearAnimation, &mut Renderable, Has<Looping>), Or<(Without<Frozen>, With<Dying>)>>,
|
||||||
|
) {
|
||||||
for (mut anim, mut renderable) in query.iter_mut() {
|
for (mut anim, mut renderable, looping) in query.iter_mut() {
|
||||||
// Tick animation
|
if anim.finished {
|
||||||
anim.time_bank += ticks;
|
continue;
|
||||||
while anim.time_bank >= anim.frame_duration {
|
|
||||||
anim.time_bank -= anim.frame_duration;
|
|
||||||
anim.current_frame += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !anim.tiles.is_empty() {
|
anim.time_bank += dt.ticks as u16;
|
||||||
let new_tile = anim.tiles.get_tile(anim.current_frame);
|
let frames_to_advance = (anim.time_bank / anim.frame_duration) as usize;
|
||||||
if renderable.sprite != new_tile {
|
|
||||||
renderable.sprite = new_tile;
|
if frames_to_advance == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_frames = anim.tiles.len();
|
||||||
|
|
||||||
|
if !looping && anim.current_frame + frames_to_advance >= total_frames {
|
||||||
|
anim.finished = true;
|
||||||
|
anim.current_frame = total_frames - 1;
|
||||||
|
} else {
|
||||||
|
anim.current_frame += frames_to_advance;
|
||||||
|
}
|
||||||
|
|
||||||
|
anim.time_bank %= anim.frame_duration;
|
||||||
|
renderable.sprite = anim.tiles.get_tile(anim.current_frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System that manages player life sprite entities.
|
||||||
|
/// Spawns and despawns life sprite entities based on changes to PlayerLives resource.
|
||||||
|
/// Each life sprite is positioned based on its index (0, 1, 2, etc. from left to right).
|
||||||
|
pub fn player_life_sprite_system(
|
||||||
|
mut commands: Commands,
|
||||||
|
atlas: NonSendMut<SpriteAtlas>,
|
||||||
|
current_life_sprites: Query<(Entity, &PlayerLife)>,
|
||||||
|
player_lives: Res<PlayerLives>,
|
||||||
|
mut errors: EventWriter<GameError>,
|
||||||
|
) {
|
||||||
|
let displayed_lives = player_lives.0.saturating_sub(1);
|
||||||
|
|
||||||
|
// Get current life sprite entities, sorted by index
|
||||||
|
let mut current_sprites: Vec<_> = current_life_sprites.iter().collect();
|
||||||
|
current_sprites.sort_by_key(|(_, life)| life.index);
|
||||||
|
let current_count = current_sprites.len() as u8;
|
||||||
|
|
||||||
|
// Calculate the difference
|
||||||
|
let diff = (displayed_lives as i8) - (current_count as i8);
|
||||||
|
|
||||||
|
if diff > 0 {
|
||||||
|
// Spawn new life sprites
|
||||||
|
let life_sprite = match atlas.get_tile(&GameSprite::Pacman(PacmanSprite::Moving(Direction::Left, 1)).to_path()) {
|
||||||
|
Ok(sprite) => sprite,
|
||||||
|
Err(e) => {
|
||||||
|
errors.write(e.into());
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for i in 0..diff.abs() {
|
||||||
|
let position = calculate_life_sprite_position(i as u32);
|
||||||
|
|
||||||
|
commands.spawn((
|
||||||
|
PlayerLife { index: i as u32 },
|
||||||
|
Renderable {
|
||||||
|
sprite: life_sprite,
|
||||||
|
layer: 255, // High layer to render on top
|
||||||
|
},
|
||||||
|
PixelPosition {
|
||||||
|
pixel_position: position,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else if diff < 0 {
|
||||||
|
// Remove excess life sprites (highest indices first)
|
||||||
|
let to_remove = diff.abs() as usize;
|
||||||
|
let sprites_to_remove: Vec<_> = current_sprites
|
||||||
|
.iter()
|
||||||
|
.rev() // Start from highest index
|
||||||
|
.take(to_remove as usize)
|
||||||
|
.map(|(entity, _)| *entity)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for entity in sprites_to_remove {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Component for Renderables to store an exact pixel position
|
||||||
|
#[derive(Component)]
|
||||||
|
pub struct PixelPosition {
|
||||||
|
pub pixel_position: Vec2,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates the pixel position for a life sprite based on its index
|
||||||
|
fn calculate_life_sprite_position(index: u32) -> Vec2 {
|
||||||
|
let start_x = CELL_SIZE * 2; // 2 cells from left
|
||||||
|
let start_y = CANVAS_SIZE.y - BOARD_BOTTOM_PIXEL_OFFSET.y + (CELL_SIZE / 2) + 1; // In bottom area
|
||||||
|
let sprite_spacing = CELL_SIZE + CELL_SIZE / 2; // 1.5 cells between sprites
|
||||||
|
|
||||||
|
let x = start_x + ((index as f32) * (sprite_spacing as f32 * 1.5)).round() as u32;
|
||||||
|
let y = start_y - CELL_SIZE / 2;
|
||||||
|
|
||||||
|
Vec2::new((x + CELL_SIZE) as f32, (y + CELL_SIZE) as f32)
|
||||||
|
}
|
||||||
|
|
||||||
/// A non-send resource for the map texture. This just wraps the texture with a type so it can be differentiated when exposed as a resource.
|
/// A non-send resource for the map texture. This just wraps the texture with a type so it can be differentiated when exposed as a resource.
|
||||||
pub struct MapTextureResource(pub Texture);
|
pub struct MapTextureResource(pub Texture);
|
||||||
|
|
||||||
@@ -189,23 +285,23 @@ pub fn touch_ui_render_system(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Renders the HUD (score, lives, etc.) on top of the game.
|
/// Renders the HUD (score, lives, etc.) on top of the game.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn hud_render_system(
|
pub fn hud_render_system(
|
||||||
mut backbuffer: NonSendMut<BackbufferResource>,
|
mut backbuffer: NonSendMut<BackbufferResource>,
|
||||||
mut canvas: NonSendMut<&mut Canvas<Window>>,
|
mut canvas: NonSendMut<&mut Canvas<Window>>,
|
||||||
mut atlas: NonSendMut<SpriteAtlas>,
|
mut atlas: NonSendMut<SpriteAtlas>,
|
||||||
score: Res<ScoreResource>,
|
score: Res<ScoreResource>,
|
||||||
startup: Res<StartupSequence>,
|
stage: Res<GameStage>,
|
||||||
mut errors: EventWriter<GameError>,
|
mut errors: EventWriter<GameError>,
|
||||||
) {
|
) {
|
||||||
let _ = canvas.with_texture_canvas(&mut backbuffer.0, |canvas| {
|
let _ = canvas.with_texture_canvas(&mut backbuffer.0, |canvas| {
|
||||||
let mut text_renderer = TextTexture::new(1.0);
|
let mut text_renderer = TextTexture::new(1.0);
|
||||||
|
|
||||||
// Render lives and high score text in white
|
// Render lives and high score text in white
|
||||||
let lives = 3; // TODO: Get from actual lives resource
|
let lives_text = "1UP HIGH SCORE ";
|
||||||
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
|
let lives_position = glam::UVec2::new(4 + 8 * 3, 2); // x_offset + lives_offset * 8, y_offset
|
||||||
|
|
||||||
if let Err(e) = text_renderer.render(canvas, &mut atlas, &lives_text, lives_position) {
|
if let Err(e) = text_renderer.render(canvas, &mut atlas, lives_text, lives_position) {
|
||||||
errors.write(TextureError::RenderFailed(format!("Failed to render lives text: {}", e)).into());
|
errors.write(TextureError::RenderFailed(format!("Failed to render lives text: {}", e)).into());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,10 +322,21 @@ pub fn hud_render_system(
|
|||||||
errors.write(TextureError::RenderFailed(format!("Failed to render high score text: {}", e)).into());
|
errors.write(TextureError::RenderFailed(format!("Failed to render high score text: {}", e)).into());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render GAME OVER text
|
||||||
|
if matches!(*stage, GameStage::GameOver) {
|
||||||
|
let game_over_text = "GAME OVER";
|
||||||
|
let game_over_width = text_renderer.text_width(game_over_text);
|
||||||
|
let game_over_position = glam::UVec2::new((CANVAS_SIZE.x - game_over_width) / 2, 160);
|
||||||
|
if let Err(e) = text_renderer.render_with_color(canvas, &mut atlas, game_over_text, game_over_position, Color::RED) {
|
||||||
|
errors.write(TextureError::RenderFailed(format!("Failed to render GAME OVER text: {}", e)).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Render text based on StartupSequence stage
|
// Render text based on StartupSequence stage
|
||||||
if matches!(
|
if matches!(
|
||||||
*startup,
|
*stage,
|
||||||
StartupSequence::TextOnly { .. } | StartupSequence::CharactersVisible { .. }
|
GameStage::Starting(StartupSequence::TextOnly { .. })
|
||||||
|
| GameStage::Starting(StartupSequence::CharactersVisible { .. })
|
||||||
) {
|
) {
|
||||||
let ready_text = "READY!";
|
let ready_text = "READY!";
|
||||||
let ready_width = text_renderer.text_width(ready_text);
|
let ready_width = text_renderer.text_width(ready_text);
|
||||||
@@ -238,7 +345,7 @@ pub fn hud_render_system(
|
|||||||
errors.write(TextureError::RenderFailed(format!("Failed to render READY text: {}", e)).into());
|
errors.write(TextureError::RenderFailed(format!("Failed to render READY text: {}", e)).into());
|
||||||
}
|
}
|
||||||
|
|
||||||
if matches!(*startup, StartupSequence::TextOnly { .. }) {
|
if matches!(*stage, GameStage::Starting(StartupSequence::TextOnly { .. })) {
|
||||||
let player_one_text = "PLAYER ONE";
|
let player_one_text = "PLAYER ONE";
|
||||||
let player_one_width = text_renderer.text_width(player_one_text);
|
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);
|
let player_one_position = glam::UVec2::new((CANVAS_SIZE.x - player_one_width) / 2, 113);
|
||||||
@@ -260,7 +367,10 @@ pub fn render_system(
|
|||||||
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>),
|
||||||
|
(Without<Hidden>, Or<(With<Position>, With<PixelPosition>)>),
|
||||||
|
>,
|
||||||
errors: &mut EventWriter<GameError>,
|
errors: &mut EventWriter<GameError>,
|
||||||
) {
|
) {
|
||||||
if !dirty.0 {
|
if !dirty.0 {
|
||||||
@@ -277,12 +387,21 @@ pub fn render_system(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Render all entities to the backbuffer
|
// Render all entities to the backbuffer
|
||||||
for (_, renderable, position) in renderables
|
for (_entity, renderable, position, pixel_position) in renderables
|
||||||
.iter()
|
.iter()
|
||||||
.sort_by_key::<(Entity, &Renderable, &Position), _>(|(_, renderable, _)| renderable.layer)
|
.sort_by_key::<(Entity, &Renderable, Option<&Position>, Option<&PixelPosition>), _>(|(_, renderable, _, _)| {
|
||||||
|
renderable.layer
|
||||||
|
})
|
||||||
.rev()
|
.rev()
|
||||||
{
|
{
|
||||||
let pos = position.get_pixel_position(&map.graph);
|
let pos = if let Some(position) = position {
|
||||||
|
position.get_pixel_position(&map.graph)
|
||||||
|
} else {
|
||||||
|
Ok(pixel_position
|
||||||
|
.expect("Pixel position should be present via query filtering, but got None on both")
|
||||||
|
.pixel_position)
|
||||||
|
};
|
||||||
|
|
||||||
match pos {
|
match pos {
|
||||||
Ok(pos) => {
|
Ok(pos) => {
|
||||||
let dest = Rect::from_center(
|
let dest = Rect::from_center(
|
||||||
@@ -320,7 +439,10 @@ pub fn combined_render_system(
|
|||||||
timing: Res<crate::systems::profiling::Timing>,
|
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>),
|
||||||
|
(Without<Hidden>, 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>,
|
||||||
|
|||||||
@@ -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>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
368
src/systems/state.rs
Normal file
368
src/systems/state.rs
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
use std::mem::discriminant;
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
use crate::events::StageTransition;
|
||||||
|
use crate::{
|
||||||
|
map::builder::Map,
|
||||||
|
systems::{
|
||||||
|
AudioEvent, Blinking, DirectionalAnimation, Dying, Eaten, Frozen, Ghost, GhostCollider, GhostState, Hidden,
|
||||||
|
LinearAnimation, Looping, NodeId, PlayerControlled, Position, Renderable, TimeToLive,
|
||||||
|
},
|
||||||
|
texture::{animated::TileSequence, sprite::SpriteAtlas},
|
||||||
|
};
|
||||||
|
use bevy_ecs::{
|
||||||
|
entity::Entity,
|
||||||
|
event::{EventReader, EventWriter},
|
||||||
|
query::{With, Without},
|
||||||
|
resource::Resource,
|
||||||
|
system::{Commands, NonSendMut, Query, Res, ResMut, 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,
|
||||||
|
node: NodeId,
|
||||||
|
},
|
||||||
|
/// The player has died and the death sequence is in progress.
|
||||||
|
PlayerDying(DyingSequence),
|
||||||
|
/// The level is restarting after a death.
|
||||||
|
LevelRestarting,
|
||||||
|
/// The game has ended.
|
||||||
|
GameOver,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A resource that manages the multi-stage startup sequence of the game.
|
||||||
|
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||||
|
pub enum StartupSequence {
|
||||||
|
/// Stage 1: Text-only stage
|
||||||
|
/// - Player & ghosts are hidden
|
||||||
|
/// - READY! and PLAYER ONE text are shown
|
||||||
|
/// - Energizers do not blink
|
||||||
|
TextOnly {
|
||||||
|
/// Remaining ticks in this stage
|
||||||
|
remaining_ticks: u32,
|
||||||
|
},
|
||||||
|
/// Stage 2: Characters visible stage
|
||||||
|
/// - PLAYER ONE text is hidden, READY! text remains
|
||||||
|
/// - Ghosts and Pac-Man are now shown
|
||||||
|
CharactersVisible {
|
||||||
|
/// Remaining ticks in this stage
|
||||||
|
remaining_ticks: u32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GameStage {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Playing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The state machine for the multi-stage death sequence.
|
||||||
|
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||||
|
pub enum DyingSequence {
|
||||||
|
/// Initial stage: entities are frozen, waiting for a delay.
|
||||||
|
Frozen { remaining_ticks: u32 },
|
||||||
|
/// Second stage: Pac-Man's death animation is playing.
|
||||||
|
Animating { remaining_ticks: u32 },
|
||||||
|
/// Third stage: Pac-Man is now gone, waiting a moment before the level restarts.
|
||||||
|
Hidden { remaining_ticks: u32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A resource to store the number of player lives.
|
||||||
|
#[derive(Resource, Debug)]
|
||||||
|
pub struct PlayerLives(pub u8);
|
||||||
|
|
||||||
|
impl Default for PlayerLives {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles startup sequence transitions and component management
|
||||||
|
/// Maps sprite index to the corresponding effect sprite path
|
||||||
|
fn sprite_index_to_path(index: u8) -> &'static str {
|
||||||
|
match index {
|
||||||
|
0 => "effects/100.png",
|
||||||
|
1 => "effects/200.png",
|
||||||
|
2 => "effects/300.png",
|
||||||
|
3 => "effects/400.png",
|
||||||
|
4 => "effects/700.png",
|
||||||
|
5 => "effects/800.png",
|
||||||
|
6 => "effects/1000.png",
|
||||||
|
7 => "effects/1600.png",
|
||||||
|
8 => "effects/2000.png",
|
||||||
|
9 => "effects/3000.png",
|
||||||
|
10 => "effects/5000.png",
|
||||||
|
_ => "effects/200.png", // fallback to index 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
pub fn stage_system(
|
||||||
|
mut game_state: ResMut<GameStage>,
|
||||||
|
player_death_animation: Res<PlayerDeathAnimation>,
|
||||||
|
player_animation: Res<PlayerAnimation>,
|
||||||
|
mut player_lives: ResMut<PlayerLives>,
|
||||||
|
map: Res<Map>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut audio_events: EventWriter<AudioEvent>,
|
||||||
|
mut stage_event_reader: EventReader<StageTransition>,
|
||||||
|
mut blinking_query: Query<Entity, With<Blinking>>,
|
||||||
|
player: Single<(Entity, &mut Position), With<PlayerControlled>>,
|
||||||
|
mut ghost_query: Query<(Entity, &Ghost, &mut Position), (With<GhostCollider>, Without<PlayerControlled>)>,
|
||||||
|
atlas: NonSendMut<SpriteAtlas>,
|
||||||
|
) {
|
||||||
|
let old_state = *game_state;
|
||||||
|
let mut new_state: Option<GameStage> = None;
|
||||||
|
|
||||||
|
// Handle stage transition requests before normal ticking
|
||||||
|
for event in stage_event_reader.read() {
|
||||||
|
let StageTransition::GhostEatenPause { ghost_entity } = *event;
|
||||||
|
let pac_node = player.1.current_node();
|
||||||
|
|
||||||
|
debug!(ghost_entity = ?ghost_entity, node = pac_node, "Ghost eaten, entering pause state");
|
||||||
|
new_state = Some(GameStage::GhostEatenPause {
|
||||||
|
remaining_ticks: 30,
|
||||||
|
ghost_entity,
|
||||||
|
node: pac_node,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_state: GameStage = match new_state.unwrap_or(*game_state) {
|
||||||
|
GameStage::Starting(startup) => match startup {
|
||||||
|
StartupSequence::TextOnly { remaining_ticks } => {
|
||||||
|
if remaining_ticks > 0 {
|
||||||
|
GameStage::Starting(StartupSequence::TextOnly {
|
||||||
|
remaining_ticks: remaining_ticks - 1,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
debug!("Transitioning from text-only to characters visible startup stage");
|
||||||
|
GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StartupSequence::CharactersVisible { remaining_ticks } => {
|
||||||
|
if remaining_ticks > 0 {
|
||||||
|
GameStage::Starting(StartupSequence::CharactersVisible {
|
||||||
|
remaining_ticks: remaining_ticks - 1,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
info!("Startup sequence completed, beginning gameplay");
|
||||||
|
GameStage::Playing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
GameStage::Playing => GameStage::Playing,
|
||||||
|
GameStage::GhostEatenPause {
|
||||||
|
remaining_ticks,
|
||||||
|
ghost_entity,
|
||||||
|
node,
|
||||||
|
} => {
|
||||||
|
if remaining_ticks > 0 {
|
||||||
|
GameStage::GhostEatenPause {
|
||||||
|
remaining_ticks: remaining_ticks.saturating_sub(1),
|
||||||
|
ghost_entity,
|
||||||
|
node,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debug!("Ghost eaten pause ended, resuming gameplay");
|
||||||
|
GameStage::Playing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GameStage::PlayerDying(dying) => match dying {
|
||||||
|
DyingSequence::Frozen { remaining_ticks } => {
|
||||||
|
if remaining_ticks > 0 {
|
||||||
|
GameStage::PlayerDying(DyingSequence::Frozen {
|
||||||
|
remaining_ticks: remaining_ticks - 1,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
let death_animation = &player_death_animation.0;
|
||||||
|
let remaining_ticks = (death_animation.tiles.len() * death_animation.frame_duration as usize) as u32;
|
||||||
|
debug!(animation_frames = remaining_ticks, "Starting player death animation");
|
||||||
|
GameStage::PlayerDying(DyingSequence::Animating { remaining_ticks })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DyingSequence::Animating { remaining_ticks } => {
|
||||||
|
if remaining_ticks > 0 {
|
||||||
|
GameStage::PlayerDying(DyingSequence::Animating {
|
||||||
|
remaining_ticks: remaining_ticks - 1,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
GameStage::PlayerDying(DyingSequence::Hidden { remaining_ticks: 60 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DyingSequence::Hidden { remaining_ticks } => {
|
||||||
|
if remaining_ticks > 0 {
|
||||||
|
GameStage::PlayerDying(DyingSequence::Hidden {
|
||||||
|
remaining_ticks: remaining_ticks - 1,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
player_lives.0 = player_lives.0.saturating_sub(1);
|
||||||
|
|
||||||
|
if player_lives.0 > 0 {
|
||||||
|
info!(remaining_lives = player_lives.0, "Player died, restarting level");
|
||||||
|
GameStage::LevelRestarting
|
||||||
|
} else {
|
||||||
|
warn!("All lives lost, game over");
|
||||||
|
GameStage::GameOver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
GameStage::LevelRestarting => {
|
||||||
|
debug!("Level restart complete, returning to startup sequence");
|
||||||
|
GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 })
|
||||||
|
}
|
||||||
|
GameStage::GameOver => GameStage::GameOver,
|
||||||
|
};
|
||||||
|
|
||||||
|
if old_state == new_state {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match (old_state, new_state) {
|
||||||
|
(GameStage::Playing, GameStage::GhostEatenPause { ghost_entity, node, .. }) => {
|
||||||
|
// Freeze the player & ghosts
|
||||||
|
commands.entity(player.0).insert(Frozen);
|
||||||
|
for (entity, _, _) in ghost_query.iter_mut() {
|
||||||
|
commands.entity(entity).insert(Frozen);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the player & eaten ghost
|
||||||
|
commands.entity(player.0).insert(Hidden);
|
||||||
|
commands.entity(ghost_entity).insert(Hidden);
|
||||||
|
|
||||||
|
// Spawn bonus points entity at Pac-Man's position
|
||||||
|
let sprite_index = 1; // Index 1 = 200 points (default for ghost eating)
|
||||||
|
let sprite_path = sprite_index_to_path(sprite_index);
|
||||||
|
|
||||||
|
if let Ok(sprite_tile) = SpriteAtlas::get_tile(&atlas, sprite_path) {
|
||||||
|
let tile_sequence = TileSequence::single(sprite_tile);
|
||||||
|
let animation = LinearAnimation::new(tile_sequence, 1);
|
||||||
|
|
||||||
|
commands.spawn((
|
||||||
|
Position::Stopped { node },
|
||||||
|
Renderable {
|
||||||
|
sprite: sprite_tile,
|
||||||
|
layer: 2, // Above other entities
|
||||||
|
},
|
||||||
|
animation,
|
||||||
|
TimeToLive::new(30),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(GameStage::GhostEatenPause { ghost_entity, .. }, GameStage::Playing) => {
|
||||||
|
// Unfreeze and reveal the player & all ghosts
|
||||||
|
commands.entity(player.0).remove::<(Frozen, Hidden)>();
|
||||||
|
for (entity, _, _) in ghost_query.iter_mut() {
|
||||||
|
commands.entity(entity).remove::<(Frozen, Hidden)>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reveal the eaten ghost and switch it to Eyes state
|
||||||
|
commands.entity(ghost_entity).insert(GhostState::Eyes);
|
||||||
|
}
|
||||||
|
(GameStage::Playing, GameStage::PlayerDying(DyingSequence::Frozen { .. })) => {
|
||||||
|
// Freeze the player & ghosts
|
||||||
|
commands.entity(player.0).insert(Frozen);
|
||||||
|
for (entity, _, _) in ghost_query.iter_mut() {
|
||||||
|
commands.entity(entity).insert(Frozen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(GameStage::PlayerDying(DyingSequence::Frozen { .. }), GameStage::PlayerDying(DyingSequence::Animating { .. })) => {
|
||||||
|
// Hide the ghosts
|
||||||
|
for (entity, _, _) in ghost_query.iter_mut() {
|
||||||
|
commands.entity(entity).insert(Hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start Pac-Man's death animation
|
||||||
|
commands.entity(player.0).insert((Dying, player_death_animation.0.clone()));
|
||||||
|
|
||||||
|
// Play the death sound
|
||||||
|
audio_events.write(AudioEvent::PlayDeath);
|
||||||
|
}
|
||||||
|
(GameStage::PlayerDying(DyingSequence::Animating { .. }), GameStage::PlayerDying(DyingSequence::Hidden { .. })) => {
|
||||||
|
// Hide the player
|
||||||
|
commands.entity(player.0).insert(Hidden);
|
||||||
|
}
|
||||||
|
(_, GameStage::LevelRestarting) => {
|
||||||
|
let (player_entity, mut pos) = player.into_inner();
|
||||||
|
*pos = Position::Stopped {
|
||||||
|
node: map.start_positions.pacman,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Freeze the blinking, force them to be visible (if they were hidden by blinking)
|
||||||
|
for entity in blinking_query.iter_mut() {
|
||||||
|
commands.entity(entity).insert(Frozen).remove::<Hidden>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the player animation
|
||||||
|
commands
|
||||||
|
.entity(player_entity)
|
||||||
|
.remove::<(Frozen, Dying, LinearAnimation, Looping)>()
|
||||||
|
.insert(player_animation.0.clone());
|
||||||
|
|
||||||
|
// Reset ghost positions and state
|
||||||
|
for (ghost_entity, ghost, mut ghost_pos) in ghost_query.iter_mut() {
|
||||||
|
*ghost_pos = Position::Stopped {
|
||||||
|
node: match ghost {
|
||||||
|
Ghost::Blinky => map.start_positions.blinky,
|
||||||
|
Ghost::Pinky => map.start_positions.pinky,
|
||||||
|
Ghost::Inky => map.start_positions.inky,
|
||||||
|
Ghost::Clyde => map.start_positions.clyde,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
commands
|
||||||
|
.entity(ghost_entity)
|
||||||
|
.remove::<(Frozen, Hidden, Eaten)>()
|
||||||
|
.insert(GhostState::Normal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(_, GameStage::Starting(StartupSequence::CharactersVisible { .. })) => {
|
||||||
|
// Unhide the player & ghosts
|
||||||
|
commands.entity(player.0).remove::<Hidden>();
|
||||||
|
for (entity, _, _) in ghost_query.iter_mut() {
|
||||||
|
commands.entity(entity).remove::<Hidden>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(GameStage::Starting(StartupSequence::CharactersVisible { .. }), GameStage::Playing) => {
|
||||||
|
// Unfreeze the player & ghosts & blinking
|
||||||
|
commands.entity(player.0).remove::<Frozen>();
|
||||||
|
for (entity, _, _) in ghost_query.iter_mut() {
|
||||||
|
commands.entity(entity).remove::<Frozen>();
|
||||||
|
}
|
||||||
|
for entity in blinking_query.iter_mut() {
|
||||||
|
commands.entity(entity).remove::<Frozen>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(GameStage::PlayerDying(..), GameStage::GameOver) => {
|
||||||
|
// Freeze blinking
|
||||||
|
for entity in blinking_query.iter_mut() {
|
||||||
|
commands.entity(entity).insert(Frozen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let different = discriminant(&old_state) != discriminant(&new_state);
|
||||||
|
if different {
|
||||||
|
tracing::warn!(
|
||||||
|
new_state = ?new_state,
|
||||||
|
old_state = ?old_state,
|
||||||
|
"Unhandled game stage transition");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*game_state = new_state;
|
||||||
|
}
|
||||||
@@ -1,53 +1,50 @@
|
|||||||
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);
|
/// Creates a tile sequence with a single tile.
|
||||||
tile_array[..count].copy_from_slice(&tiles[..count]);
|
pub fn single(tile: AtlasTile) -> Self {
|
||||||
|
Self { tiles: vec![tile] }
|
||||||
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.count]
|
|
||||||
}
|
}
|
||||||
|
self.tiles[frame % self.tiles.len()]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if this sequence has no tiles
|
pub fn len(&self) -> usize {
|
||||||
|
self.tiles.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if the sequence contains any tiles.
|
||||||
pub fn is_empty(&self) -> bool {
|
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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -90,8 +91,10 @@ 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();
|
let tiles = mapper.frames.into_iter().collect();
|
||||||
|
|
||||||
|
debug!(tile_count, "Created sprite atlas");
|
||||||
Self {
|
Self {
|
||||||
texture,
|
texture,
|
||||||
tiles,
|
tiles,
|
||||||
@@ -107,10 +110,10 @@ impl SpriteAtlas {
|
|||||||
/// 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) -> Result<AtlasTile, TextureError> {
|
pub fn get_tile(&self, name: &str) -> Result<AtlasTile, TextureError> {
|
||||||
let frame = self
|
let frame = self.tiles.get(name).ok_or_else(|| {
|
||||||
.tiles
|
debug!(tile_name = name, "Atlas tile not found");
|
||||||
.get(name)
|
TextureError::AtlasTileNotFound(name.to_string())
|
||||||
.ok_or_else(|| TextureError::AtlasTileNotFound(name.to_string()))?;
|
})?;
|
||||||
Ok(AtlasTile {
|
Ok(AtlasTile {
|
||||||
pos: frame.pos,
|
pos: frame.pos,
|
||||||
size: frame.size,
|
size: frame.size,
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ pub enum PacmanSprite {
|
|||||||
Moving(Direction, u8),
|
Moving(Direction, u8),
|
||||||
/// The full, closed-mouth Pac-Man sprite.
|
/// The full, closed-mouth Pac-Man sprite.
|
||||||
Full,
|
Full,
|
||||||
|
/// A single frame of the dying animation.
|
||||||
|
Dying(u8),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents the color of a frightened ghost.
|
/// Represents the color of a frightened ghost.
|
||||||
@@ -60,45 +62,50 @@ impl GameSprite {
|
|||||||
/// This path corresponds to the filename in the texture atlas JSON file.
|
/// This path corresponds to the filename in the texture atlas JSON file.
|
||||||
pub fn to_path(self) -> String {
|
pub fn to_path(self) -> String {
|
||||||
match self {
|
match self {
|
||||||
GameSprite::Pacman(sprite) => match sprite {
|
GameSprite::Pacman(PacmanSprite::Moving(dir, frame)) => format!(
|
||||||
PacmanSprite::Moving(dir, frame) => {
|
"pacman/{}_{}.png",
|
||||||
let frame_char = match frame {
|
dir.as_ref(),
|
||||||
0 => 'a',
|
match frame {
|
||||||
1 => 'b',
|
0 => "a",
|
||||||
_ => panic!("Invalid animation frame"),
|
1 => "b",
|
||||||
};
|
_ => panic!("Invalid animation frame"),
|
||||||
format!("pacman/{}_{}.png", dir.as_ref().to_lowercase(), frame_char)
|
|
||||||
}
|
}
|
||||||
PacmanSprite::Full => "pacman/full.png".to_string(),
|
),
|
||||||
},
|
GameSprite::Pacman(PacmanSprite::Full) => "pacman/full.png".to_string(),
|
||||||
GameSprite::Ghost(sprite) => match sprite {
|
GameSprite::Pacman(PacmanSprite::Dying(frame)) => format!("pacman/death/{}.png", frame),
|
||||||
GhostSprite::Normal(ghost, dir, frame) => {
|
|
||||||
let frame_char = match frame {
|
// Ghost sprites
|
||||||
0 => 'a',
|
GameSprite::Ghost(GhostSprite::Normal(ghost_type, dir, frame)) => {
|
||||||
1 => 'b',
|
let frame_char = match frame {
|
||||||
_ => panic!("Invalid animation frame"),
|
0 => 'a',
|
||||||
};
|
1 => 'b',
|
||||||
format!("ghost/{}/{}_{}.png", ghost.as_str(), dir.as_ref().to_lowercase(), frame_char)
|
_ => panic!("Invalid animation frame"),
|
||||||
}
|
};
|
||||||
GhostSprite::Frightened(color, frame) => {
|
format!(
|
||||||
let frame_char = match frame {
|
"ghost/{}/{}_{}.png",
|
||||||
0 => 'a',
|
ghost_type.as_str(),
|
||||||
1 => 'b',
|
dir.as_ref().to_lowercase(),
|
||||||
_ => panic!("Invalid animation frame"),
|
frame_char
|
||||||
};
|
)
|
||||||
let color_str = match color {
|
}
|
||||||
FrightenedColor::Blue => "blue",
|
GameSprite::Ghost(GhostSprite::Frightened(color, frame)) => {
|
||||||
FrightenedColor::White => "white",
|
let frame_char = match frame {
|
||||||
};
|
0 => 'a',
|
||||||
format!("ghost/frightened/{}_{}.png", color_str, frame_char)
|
1 => 'b',
|
||||||
}
|
_ => panic!("Invalid animation frame"),
|
||||||
GhostSprite::Eyes(dir) => format!("ghost/eyes/{}.png", dir.as_ref().to_lowercase()),
|
};
|
||||||
},
|
let color_str = match color {
|
||||||
GameSprite::Maze(sprite) => match sprite {
|
FrightenedColor::Blue => "blue",
|
||||||
MazeSprite::Tile(index) => format!("maze/tiles/{}.png", index),
|
FrightenedColor::White => "white",
|
||||||
MazeSprite::Pellet => "maze/pellet.png".to_string(),
|
};
|
||||||
MazeSprite::Energizer => "maze/energizer.png".to_string(),
|
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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,11 +214,9 @@ 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]
|
||||||
|
|||||||
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::components::Ghost,
|
||||||
|
texture::sprites::{FrightenedColor, GameSprite, GhostSprite, MazeSprite, PacmanSprite},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_all_sprite_paths_exist() {
|
||||||
|
let mut sprites_to_test = Vec::new();
|
||||||
|
|
||||||
|
// Pac-Man sprites
|
||||||
|
for &dir in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] {
|
||||||
|
for frame in 0..2 {
|
||||||
|
sprites_to_test.push(GameSprite::Pacman(PacmanSprite::Moving(dir, frame)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sprites_to_test.push(GameSprite::Pacman(PacmanSprite::Full));
|
||||||
|
for frame in 0..=10 {
|
||||||
|
sprites_to_test.push(GameSprite::Pacman(PacmanSprite::Dying(frame)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ghost sprites
|
||||||
|
for &ghost in &[Ghost::Blinky, Ghost::Pinky, Ghost::Inky, Ghost::Clyde] {
|
||||||
|
for &dir in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] {
|
||||||
|
for frame in 0..2 {
|
||||||
|
sprites_to_test.push(GameSprite::Ghost(GhostSprite::Normal(ghost, dir, frame)));
|
||||||
|
}
|
||||||
|
sprites_to_test.push(GameSprite::Ghost(GhostSprite::Eyes(dir)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for &color in &[FrightenedColor::Blue, FrightenedColor::White] {
|
||||||
|
for frame in 0..2 {
|
||||||
|
sprites_to_test.push(GameSprite::Ghost(GhostSprite::Frightened(color, frame)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maze sprites
|
||||||
|
for i in 0..=34 {
|
||||||
|
sprites_to_test.push(GameSprite::Maze(MazeSprite::Tile(i)));
|
||||||
|
}
|
||||||
|
sprites_to_test.push(GameSprite::Maze(MazeSprite::Pellet));
|
||||||
|
sprites_to_test.push(GameSprite::Maze(MazeSprite::Energizer));
|
||||||
|
|
||||||
|
for sprite in sprites_to_test {
|
||||||
|
let path = sprite.to_path();
|
||||||
|
assert!(
|
||||||
|
ATLAS_FRAMES.contains_key(&path),
|
||||||
|
"Sprite path '{}' does not exist in the atlas.",
|
||||||
|
path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_sprite_paths_do_not_exist() {
|
||||||
|
let invalid_sprites = vec![
|
||||||
|
// An invalid Pac-Man dying frame
|
||||||
|
GameSprite::Pacman(PacmanSprite::Dying(99)),
|
||||||
|
// An invalid maze tile
|
||||||
|
GameSprite::Maze(MazeSprite::Tile(99)),
|
||||||
|
];
|
||||||
|
|
||||||
|
for sprite in invalid_sprites {
|
||||||
|
let path = sprite.to_path();
|
||||||
|
assert!(
|
||||||
|
!ATLAS_FRAMES.contains_key(&path),
|
||||||
|
"Invalid sprite path '{}' was found in the atlas, but it should not exist.",
|
||||||
|
path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user