Compare commits

...

23 Commits

Author SHA1 Message Date
1529a64588 test: add asset path validity tests 2025-08-12 17:24:12 -05:00
be5eec64c9 Add justfile for handling multiple coverage steps, prevent early termination of coverage job 2025-08-12 17:24:12 -05:00
780a33f657 test: add coverage job to bacon.toml, coverage profile for nextest 2025-08-12 16:48:01 -05:00
c1c5dae6f2 refactor: restructure game logic and state management into separate modules
- Moved game logic from `game.rs` to `game/mod.rs` and `game/state.rs` for better organization.
- Updated `App` to utilize the new `Game` struct and its state management.
- Refactored error handling
- Removed unused audio subsystem references
2025-08-12 14:40:48 -05:00
c489f32908 fix: audio and other subsystems being dropped in App::new(), use Box::leak to ensure static ownership 2025-08-12 13:08:08 -05:00
b91f70cf2f ci: add concurrency group to 'wasm' job to prevent concurrent page deployments 2025-08-12 11:56:03 -05:00
24a207be01 chore: use steps.$.outputs in build workflow, document 1.86.0 toolchain version 2025-08-12 11:41:29 -05:00
44e31d9b21 chore: sync lockfile, add lcov.info to .gitignore 2025-08-12 10:31:10 -05:00
dependabot[bot]
b67234765a chore(deps): bump actions/checkout from 4 to 5 in the dependencies group (#1)
Bumps the dependencies group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 4 to 5
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-12 09:27:54 -05:00
dependabot[bot]
d07498c30e chore(deps): bump the dependencies group with 5 updates (#2)
Bumps the dependencies group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [thiserror](https://github.com/dtolnay/thiserror) | `1.0.69` | `2.0.12` |
| [anyhow](https://github.com/dtolnay/anyhow) | `1.0.98` | `1.0.99` |
| [glam](https://github.com/bitshifter/glam-rs) | `0.30.4` | `0.30.5` |
| [serde_json](https://github.com/serde-rs/json) | `1.0.141` | `1.0.142` |
| [libc](https://github.com/rust-lang/libc) | `0.2.174` | `0.2.175` |


Updates `thiserror` from 1.0.69 to 2.0.12
- [Release notes](https://github.com/dtolnay/thiserror/releases)
- [Commits](https://github.com/dtolnay/thiserror/compare/1.0.69...2.0.12)

Updates `anyhow` from 1.0.98 to 1.0.99
- [Release notes](https://github.com/dtolnay/anyhow/releases)
- [Commits](https://github.com/dtolnay/anyhow/compare/1.0.98...1.0.99)

Updates `glam` from 0.30.4 to 0.30.5
- [Changelog](https://github.com/bitshifter/glam-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bitshifter/glam-rs/compare/0.30.4...0.30.5)

Updates `serde_json` from 1.0.141 to 1.0.142
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.141...v1.0.142)

Updates `libc` from 0.2.174 to 0.2.175
- [Release notes](https://github.com/rust-lang/libc/releases)
- [Changelog](https://github.com/rust-lang/libc/blob/0.2.175/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/libc/compare/0.2.174...0.2.175)

---
updated-dependencies:
- dependency-name: thiserror
  dependency-version: 2.0.12
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: dependencies
- dependency-name: anyhow
  dependency-version: 1.0.99
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependencies
- dependency-name: glam
  dependency-version: 0.30.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependencies
- dependency-name: serde_json
  dependency-version: 1.0.142
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependencies
- dependency-name: libc
  dependency-version: 0.2.175
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Xevion <xevion@xevion.dev>
2025-08-12 09:26:46 -05:00
183a432116 test: add tests for collision, items, directional, sprite
enum macros for FruitKind
2025-08-12 09:18:53 -05:00
ead1466b2d chore: specify 'llvm-tools-preview' toolchain component for coverage in toolchain file 2025-08-12 00:22:27 -05:00
8ef09a4e3e test: drop minimal_test_board, use RAW_BOARD constant, item generation tests 2025-08-11 23:26:28 -05:00
33672d8d5a feat: implement collision detection system for entities 2025-08-11 23:24:23 -05:00
1dc8aca373 feat: item collection & collisions, pellet & energizer generation 2025-08-11 22:45:36 -05:00
02089a78da chore: downgrade toolchain to 1.86 on all versions
This is just because managing both 1.86 and 1.88 is really annoying, so
it's better to just be unified. There's no real point to using 1.88
besides more clippy warnings, which are already impeding my work right
now. So we're downgrading.
2025-08-11 22:10:41 -05:00
1f8e7c6d71 fix: resolve clippy warnings, inline format vars, use tracing to log warnings 2025-08-11 22:09:08 -05:00
27079e127d feat!: implement proper error handling, drop most expect() & unwrap() usages 2025-08-11 20:23:39 -05:00
5e9bb3535e ci: add dependabot config 2025-08-11 19:24:52 -05:00
250cf2fc89 fix: avoid rendering path lines between far apart cells 2025-08-11 18:39:01 -05:00
57975495a9 fix: calculate more static, stable offsets for path debug rendering 2025-08-11 16:00:23 -05:00
f3e7a780e2 fix: drop problematic ctrl-c keybind for bacon, reconfigure binds 2025-08-11 15:46:26 -05:00
ee6cb0a670 refactor: implement entity trait, common abstraction for movement & rendering 2025-08-11 15:46:04 -05:00
50 changed files with 1946 additions and 756 deletions

View File

@@ -1,2 +1,5 @@
[profile.default] [profile.default]
fail-fast = false fail-fast = false
[profile.coverage]
status-level = "none"

20
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,20 @@
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "monthly"
groups:
dependencies:
patterns:
- "*"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
groups:
dependencies:
patterns:
- "*"

View File

@@ -14,23 +14,23 @@ jobs:
- os: ubuntu-latest - os: ubuntu-latest
target: x86_64-unknown-linux-gnu target: x86_64-unknown-linux-gnu
artifact_name: pacman artifact_name: pacman
toolchain: 1.88.0 toolchain: 1.86.0
- os: macos-13 - os: macos-13
target: x86_64-apple-darwin target: x86_64-apple-darwin
artifact_name: pacman artifact_name: pacman
toolchain: 1.88.0 toolchain: 1.86.0
- os: macos-latest - os: macos-latest
target: aarch64-apple-darwin target: aarch64-apple-darwin
artifact_name: pacman artifact_name: pacman
toolchain: 1.88.0 toolchain: 1.86.0
- os: windows-latest - os: windows-latest
target: x86_64-pc-windows-gnu target: x86_64-pc-windows-gnu
artifact_name: pacman.exe artifact_name: pacman.exe
toolchain: 1.88.0 toolchain: 1.86.0
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Setup Rust Toolchain - name: Setup Rust Toolchain
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
@@ -64,15 +64,16 @@ jobs:
run: cargo build --release run: cargo build --release
- name: Acquire Package Version - name: Acquire Package Version
shell: bash id: get_version
shell: bash # required to prevent Windows runners from failing
run: | run: |
PACKAGE_VERSION=$(cargo metadata --format-version 1 --no-deps | jq '.packages[0].version' -r) set -euo pipefail # exit on error
echo "PACKAGE_VERSION=${PACKAGE_VERSION}" >> $GITHUB_ENV echo "version=$(cargo metadata --format-version 1 --no-deps | jq '.packages[0].version' -r)" >> $GITHUB_OUTPUT
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: "pacman-${{ env.PACKAGE_VERSION }}-${{ matrix.target }}" name: "pacman-${{ steps.get_version.outputs.version }}-${{ matrix.target }}"
path: ./target/release/${{ matrix.artifact_name }} path: ./target/release/${{ matrix.artifact_name }}
retention-days: 7 retention-days: 7
if-no-files-found: error if-no-files-found: error
@@ -83,10 +84,13 @@ jobs:
permissions: permissions:
pages: write pages: write
id-token: write id-token: write
# concurrency group is used to prevent multiple page deployments from being attempted at the same time
concurrency:
group: ${{ github.workflow }}-wasm
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Setup Emscripten SDK - name: Setup Emscripten SDK
uses: pyodide/setup-emsdk@v15 uses: pyodide/setup-emsdk@v15
@@ -98,7 +102,7 @@ jobs:
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
target: wasm32-unknown-emscripten target: wasm32-unknown-emscripten
toolchain: 1.86.0 # we are unfortunately pinned to 1.86.0 for some reason, bulk-memory-opt related issues toolchain: 1.86.0
- name: Rust Cache - name: Rust Cache
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
@@ -43,7 +43,6 @@ jobs:
- uses: taiki-e/install-action@cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov
- uses: taiki-e/install-action@nextest - uses: taiki-e/install-action@nextest
# Note: We manually link zlib. This should be synchronized with the flags set for Linux in .cargo/config.toml.
- name: Generate coverage report - name: Generate coverage report
run: | run: |
cargo llvm-cov --no-fail-fast --lcov --output-path lcov.info nextest cargo llvm-cov --no-fail-fast --lcov --output-path lcov.info nextest

View File

@@ -4,7 +4,7 @@ on: ["push", "pull_request"]
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
RUST_TOOLCHAIN: 1.88.0 RUST_TOOLCHAIN: 1.86.0
jobs: jobs:
test: test:
@@ -12,7 +12,7 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ emsdk/
rust-sdl2-emscripten/ rust-sdl2-emscripten/
assets/site/build.css assets/site/build.css
tailwindcss-* tailwindcss-*
lcov.info

68
Cargo.lock generated
View File

@@ -13,9 +13,9 @@ dependencies = [
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.98" version = "1.0.99"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100"
[[package]] [[package]]
name = "autocfg" name = "autocfg"
@@ -79,9 +79,9 @@ dependencies = [
[[package]] [[package]]
name = "glam" name = "glam"
version = "0.30.4" version = "0.30.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50a99dbe56b72736564cfa4b85bf9a33079f16ae8b74983ab06af3b1a3696b11" checksum = "f2d1aab06663bdce00d6ca5e5ed586ec8d18033a771906c993a1e3755b368d85"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
@@ -89,6 +89,12 @@ version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.10.0" version = "2.10.0"
@@ -122,9 +128,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.174" version = "0.2.175"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
[[package]] [[package]]
name = "log" name = "log"
@@ -194,7 +200,9 @@ dependencies = [
"serde_json", "serde_json",
"smallvec", "smallvec",
"spin_sleep", "spin_sleep",
"thiserror 1.0.69", "strum",
"strum_macros",
"thiserror",
"tracing", "tracing",
"tracing-error", "tracing-error",
"tracing-subscriber", "tracing-subscriber",
@@ -212,7 +220,7 @@ dependencies = [
"integer-sqrt", "integer-sqrt",
"num-traits", "num-traits",
"rustc-hash", "rustc-hash",
"thiserror 2.0.12", "thiserror",
] ]
[[package]] [[package]]
@@ -372,9 +380,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.141" version = "1.0.142"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7"
dependencies = [ dependencies = [
"itoa", "itoa",
"memchr", "memchr",
@@ -406,6 +414,24 @@ dependencies = [
"windows-sys", "windows-sys",
] ]
[[package]]
name = "strum"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
[[package]]
name = "strum_macros"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.104" version = "2.0.104"
@@ -417,33 +443,13 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.12" version = "2.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
dependencies = [ dependencies = [
"thiserror-impl 2.0.12", "thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
] ]
[[package]] [[package]]

View File

@@ -15,12 +15,14 @@ spin_sleep = "1.3.2"
rand = { version = "0.9.2", default-features = false, features = ["small_rng", "os_rng"] } rand = { version = "0.9.2", default-features = false, features = ["small_rng", "os_rng"] }
pathfinding = "4.14" pathfinding = "4.14"
once_cell = "1.21.3" once_cell = "1.21.3"
thiserror = "1.0" thiserror = "2.0"
anyhow = "1.0" anyhow = "1.0"
glam = { version = "0.30.4", features = [] } glam = { version = "0.30.5", features = [] }
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.141" serde_json = "1.0.142"
smallvec = "1.15.1" smallvec = "1.15.1"
strum = "0.27.2"
strum_macros = "0.27.2"
[profile.release] [profile.release]
lto = true lto = true
@@ -54,4 +56,4 @@ x86_64-apple-darwin = { triplet = "x64-osx" }
aarch64-apple-darwin = { triplet = "arm64-osx" } aarch64-apple-darwin = { triplet = "arm64-osx" }
[target.'cfg(target_os = "emscripten")'.dependencies] [target.'cfg(target_os = "emscripten")'.dependencies]
libc = "0.2.16" libc = "0.2.175"

16
Justfile Normal file
View File

@@ -0,0 +1,16 @@
set shell := ["bash", "-c"]
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
coverage_exclude_pattern := "app.rs|audio.rs"
coverage:
# Run & generate report
cargo llvm-cov \
--ignore-filename-regex "{{ coverage_exclude_pattern }}" \
--output-path lcov.info \
--profile coverage \
--no-fail-fast nextest
# Display report
cargo llvm-cov report \
--ignore-filename-regex "{{ coverage_exclude_pattern }}"

View File

@@ -72,6 +72,8 @@ I wanted to hit a log of goals and features, making it a 'perfect' project that
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.
- We use rustc 1.86.0 for the build, due to bulk-memory-opt related issues on wasm32-unknown-emscripten.
- Technically, we could probably use stable or even nightly on desktop targets, but using different versions for different targets is a pain, mainly because of clippy warnings changing between versions.
- Install `cargo-vcpkg` with `cargo install cargo-vcpkg`, then run `cargo vcpkg build` to build the requisite dependencies via vcpkg. - 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.

View File

@@ -34,6 +34,30 @@ command = [
need_stdout = true need_stdout = true
analyzer = "nextest" analyzer = "nextest"
[jobs.coverage]
command = [
"just", "coverage"
]
need_stdout = true
ignored_lines = [
"info:",
"\\s+Compiling",
"test result: ok",
"^\\s*$",
"running \\d+ test",
"Nextest run ID",
"[─]+",
"test.+ok",
"PASS|START",
"Starting \\d+ test",
"\\s*#",
"\\s*Finished.+in \\d+",
"\\s*Summary\\s+\\[",
"\\s*Blocking",
"Finished report saved to"
]
on_change_strategy = "wait_then_restart"
[jobs.doc] [jobs.doc]
command = ["cargo", "doc", "--no-deps"] command = ["cargo", "doc", "--no-deps"]
need_stdout = false need_stdout = false
@@ -56,6 +80,7 @@ on_change_strategy = "kill_then_restart"
[keybindings] [keybindings]
c = "job:clippy" c = "job:clippy"
shift-c = "job:check" alt-c = "job:check"
ctrl-shift-c = "job:check-all" ctrl-alt-c = "job:check-all"
ctrl-c = "job:clippy-all" shift-c = "job:clippy-all"
f = "job:coverage"

4
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,4 @@
[toolchain]
# we are unfortunately pinned to 1.86.0 for some reason, bulk-memory-opt related issues on wasm32-unknown-emscripten
channel = "1.86.0"
components = ["rustfmt", "llvm-tools-preview", "clippy"]

View File

@@ -1,37 +1,44 @@
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use anyhow::{anyhow, Result};
use glam::Vec2; use glam::Vec2;
use sdl2::event::{Event, WindowEvent}; use sdl2::event::{Event, WindowEvent};
use sdl2::keyboard::Keycode; use sdl2::keyboard::Keycode;
use sdl2::render::{Canvas, ScaleMode, Texture, TextureCreator}; use sdl2::render::{Canvas, ScaleMode, Texture, TextureCreator};
use sdl2::ttf::Sdl2TtfContext;
use sdl2::video::{Window, WindowContext}; use sdl2::video::{Window, WindowContext};
use sdl2::EventPump; use sdl2::{AudioSubsystem, EventPump, Sdl, VideoSubsystem};
use tracing::{error, event}; use tracing::{error, event};
use crate::error::{GameError, GameResult};
use crate::constants::{CANVAS_SIZE, LOOP_TIME, SCALE}; use crate::constants::{CANVAS_SIZE, LOOP_TIME, SCALE};
use crate::game::Game; use crate::game::Game;
use crate::platform::get_platform; use crate::platform::get_platform;
pub struct App<'a> { pub struct App {
game: Game, game: Game,
canvas: Canvas<Window>, canvas: Canvas<Window>,
event_pump: EventPump, event_pump: &'static mut EventPump,
backbuffer: Texture<'a>, backbuffer: Texture<'static>,
paused: bool, paused: bool,
last_tick: Instant, last_tick: Instant,
cursor_pos: Vec2, cursor_pos: Vec2,
} }
impl App<'_> { impl App {
pub fn new() -> Result<Self> { pub fn new() -> GameResult<Self> {
// Initialize platform-specific console let sdl_context: &'static Sdl = Box::leak(Box::new(sdl2::init().map_err(|e| GameError::Sdl(e.to_string()))?));
get_platform().init_console().map_err(|e| anyhow!(e))?; let video_subsystem: &'static VideoSubsystem =
Box::leak(Box::new(sdl_context.video().map_err(|e| GameError::Sdl(e.to_string()))?));
let _audio_subsystem: &'static AudioSubsystem =
Box::leak(Box::new(sdl_context.audio().map_err(|e| GameError::Sdl(e.to_string()))?));
let _ttf_context: &'static Sdl2TtfContext =
Box::leak(Box::new(sdl2::ttf::init().map_err(|e| GameError::Sdl(e.to_string()))?));
let event_pump: &'static mut EventPump =
Box::leak(Box::new(sdl_context.event_pump().map_err(|e| GameError::Sdl(e.to_string()))?));
let sdl_context = sdl2::init().map_err(|e| anyhow!(e))?; // Initialize platform-specific console
let video_subsystem = sdl_context.video().map_err(|e| anyhow!(e))?; get_platform().init_console()?;
let audio_subsystem = sdl_context.audio().map_err(|e| anyhow!(e))?;
let ttf_context = sdl2::ttf::init().map_err(|e| anyhow!(e.to_string()))?;
let window = video_subsystem let window = video_subsystem
.window( .window(
@@ -41,24 +48,29 @@ impl App<'_> {
) )
.resizable() .resizable()
.position_centered() .position_centered()
.build()?; .build()
.map_err(|e| GameError::Sdl(e.to_string()))?;
let mut canvas = window.into_canvas().build()?; let mut canvas = window.into_canvas().build().map_err(|e| GameError::Sdl(e.to_string()))?;
canvas.set_logical_size(CANVAS_SIZE.x, CANVAS_SIZE.y)?; canvas
.set_logical_size(CANVAS_SIZE.x, CANVAS_SIZE.y)
.map_err(|e| GameError::Sdl(e.to_string()))?;
let texture_creator_static: &'static TextureCreator<WindowContext> = Box::leak(Box::new(canvas.texture_creator())); let texture_creator: &'static TextureCreator<WindowContext> = Box::leak(Box::new(canvas.texture_creator()));
let mut game = Game::new(texture_creator_static, &ttf_context, &audio_subsystem); let mut game = Game::new(texture_creator)?;
game.audio.set_mute(cfg!(debug_assertions)); // game.audio.set_mute(cfg!(debug_assertions));
let mut backbuffer = texture_creator_static.create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y)?; let mut backbuffer = texture_creator
.create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y)
.map_err(|e| GameError::Sdl(e.to_string()))?;
backbuffer.set_scale_mode(ScaleMode::Nearest); backbuffer.set_scale_mode(ScaleMode::Nearest);
let event_pump = sdl_context.event_pump().map_err(|e| anyhow!(e))?;
// Initial draw // Initial draw
game.draw(&mut canvas, &mut backbuffer)?; game.draw(&mut canvas, &mut backbuffer)
game.present_backbuffer(&mut canvas, &backbuffer, glam::Vec2::ZERO)?; .map_err(|e| GameError::Sdl(e.to_string()))?;
game.present_backbuffer(&mut canvas, &backbuffer, glam::Vec2::ZERO)
.map_err(|e| GameError::Sdl(e.to_string()))?;
Ok(Self { Ok(Self {
game, game,
@@ -107,10 +119,10 @@ impl App<'_> {
keycode: Some(Keycode::Space), keycode: Some(Keycode::Space),
.. ..
} => { } => {
self.game.debug_mode = !self.game.debug_mode; self.game.toggle_debug_mode();
} }
Event::KeyDown { keycode, .. } => { Event::KeyDown { keycode: Some(key), .. } => {
self.game.keyboard_event(keycode.unwrap()); self.game.keyboard_event(key);
} }
Event::MouseMotion { x, y, .. } => { Event::MouseMotion { x, y, .. } => {
// Convert window coordinates to logical coordinates // Convert window coordinates to logical coordinates
@@ -126,13 +138,13 @@ impl App<'_> {
if !self.paused { if !self.paused {
self.game.tick(dt); self.game.tick(dt);
if let Err(e) = self.game.draw(&mut self.canvas, &mut self.backbuffer) { if let Err(e) = self.game.draw(&mut self.canvas, &mut self.backbuffer) {
error!("Failed to draw game: {e}"); error!("Failed to draw game: {}", e);
} }
if let Err(e) = self if let Err(e) = self
.game .game
.present_backbuffer(&mut self.canvas, &self.backbuffer, self.cursor_pos) .present_backbuffer(&mut self.canvas, &self.backbuffer, self.cursor_pos)
{ {
error!("Failed to present backbuffer: {e}"); error!("Failed to present backbuffer: {}", e);
} }
} }

View File

@@ -3,20 +3,9 @@
//! On desktop, assets are embedded using include_bytes!; on Emscripten, assets are loaded from the filesystem. //! On desktop, assets are embedded using include_bytes!; on Emscripten, assets are loaded from the filesystem.
use std::borrow::Cow; use std::borrow::Cow;
use std::io; use strum_macros::EnumIter;
use thiserror::Error;
#[derive(Error, Debug)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumIter)]
pub enum AssetError {
#[error("IO error: {0}")]
Io(#[from] io::Error),
#[error("Asset not found: {0}")]
NotFound(String),
#[error("Invalid asset format: {0}")]
InvalidFormat(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Asset { pub enum Asset {
Wav1, Wav1,
Wav2, Wav2,
@@ -44,6 +33,7 @@ impl Asset {
mod imp { mod imp {
use super::*; use super::*;
use crate::error::AssetError;
use crate::platform::get_platform; use crate::platform::get_platform;
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> { pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {

128
src/entity/collision.rs Normal file
View File

@@ -0,0 +1,128 @@
use smallvec::SmallVec;
use std::collections::HashMap;
use crate::entity::traversal::Position;
/// Trait for entities that can participate in collision detection.
pub trait Collidable {
/// Returns the current position of this entity.
fn position(&self) -> Position;
/// Checks if this entity is colliding with another entity.
#[allow(dead_code)]
fn is_colliding_with(&self, other: &dyn Collidable) -> bool {
positions_overlap(&self.position(), &other.position())
}
}
/// System for tracking entities by their positions for efficient collision detection.
#[derive(Default)]
pub struct CollisionSystem {
/// Maps node IDs to lists of entity IDs that are at that node
node_entities: HashMap<usize, Vec<EntityId>>,
/// Maps entity IDs to their current positions
entity_positions: HashMap<EntityId, Position>,
/// Next available entity ID
next_id: EntityId,
}
/// Unique identifier for an entity in the collision system
pub type EntityId = u32;
impl CollisionSystem {
/// Registers an entity with the collision system and returns its ID
pub fn register_entity(&mut self, position: Position) -> EntityId {
let id = self.next_id;
self.next_id += 1;
self.entity_positions.insert(id, position);
self.update_node_entities(id, position);
id
}
/// Updates an entity's position
pub fn update_position(&mut self, entity_id: EntityId, new_position: Position) {
if let Some(old_position) = self.entity_positions.get(&entity_id) {
// Remove from old nodes
self.remove_from_nodes(entity_id, *old_position);
}
// Update position and add to new nodes
self.entity_positions.insert(entity_id, new_position);
self.update_node_entities(entity_id, new_position);
}
/// Removes an entity from the collision system
#[allow(dead_code)]
pub fn remove_entity(&mut self, entity_id: EntityId) {
if let Some(position) = self.entity_positions.remove(&entity_id) {
self.remove_from_nodes(entity_id, position);
}
}
/// Gets all entity IDs at a specific node
pub fn entities_at_node(&self, node: usize) -> &[EntityId] {
self.node_entities.get(&node).map(|v| v.as_slice()).unwrap_or(&[])
}
/// Gets all entity IDs that could collide with an entity at the given position
pub fn potential_collisions(&self, position: &Position) -> Vec<EntityId> {
let mut collisions = Vec::new();
let nodes = get_nodes(position);
for node in nodes {
collisions.extend(self.entities_at_node(node));
}
// Remove duplicates
collisions.sort_unstable();
collisions.dedup();
collisions
}
/// Updates the node_entities map when an entity's position changes
fn update_node_entities(&mut self, entity_id: EntityId, position: Position) {
let nodes = get_nodes(&position);
for node in nodes {
self.node_entities.entry(node).or_default().push(entity_id);
}
}
/// Removes an entity from all nodes it was previously at
fn remove_from_nodes(&mut self, entity_id: EntityId, position: Position) {
let nodes = get_nodes(&position);
for node in nodes {
if let Some(entities) = self.node_entities.get_mut(&node) {
entities.retain(|&id| id != entity_id);
if entities.is_empty() {
self.node_entities.remove(&node);
}
}
}
}
}
/// Checks if two positions overlap (entities are at the same location).
fn positions_overlap(a: &Position, b: &Position) -> bool {
let a_nodes = get_nodes(a);
let b_nodes = get_nodes(b);
// Check if any nodes overlap
a_nodes.iter().any(|a_node| b_nodes.contains(a_node))
// TODO: More complex overlap detection, the above is a simple check, but it could become an early filter for more precise calculations later
}
/// Gets all nodes that an entity is currently at or between.
fn get_nodes(pos: &Position) -> SmallVec<[usize; 2]> {
let mut nodes = SmallVec::new();
match pos {
Position::AtNode(node) => nodes.push(*node),
Position::BetweenNodes { from, to, .. } => {
nodes.push(*from);
nodes.push(*to);
}
}
nodes
}

View File

@@ -4,20 +4,23 @@
//! animation, and rendering. Ghosts move through the game graph using //! animation, and rendering. Ghosts move through the game graph using
//! a traverser and display directional animated textures. //! a traverser and display directional animated textures.
use glam::Vec2;
use pathfinding::prelude::dijkstra; use pathfinding::prelude::dijkstra;
use rand::prelude::*; use rand::prelude::*;
use smallvec::SmallVec; use smallvec::SmallVec;
use tracing::error;
use crate::constants::BOARD_PIXEL_OFFSET; use crate::entity::{
use crate::entity::direction::Direction; collision::Collidable,
use crate::entity::graph::{Edge, EdgePermissions, Graph, NodeId}; direction::Direction,
use crate::entity::traversal::{Position, Traverser}; graph::{Edge, EdgePermissions, Graph, NodeId},
use crate::helpers::centered_with_size; r#trait::Entity,
traversal::Traverser,
};
use crate::texture::animated::AnimatedTexture; use crate::texture::animated::AnimatedTexture;
use crate::texture::directional::DirectionalAnimatedTexture; use crate::texture::directional::DirectionalAnimatedTexture;
use crate::texture::sprite::SpriteAtlas; use crate::texture::sprite::SpriteAtlas;
use sdl2::render::{Canvas, RenderTarget};
use crate::error::{EntityError, GameError, GameResult, TextureError};
/// Determines if a ghost can traverse a given edge. /// Determines if a ghost can traverse a given edge.
/// ///
@@ -73,12 +76,50 @@ pub struct Ghost {
speed: f32, speed: f32,
} }
impl Entity for Ghost {
fn traverser(&self) -> &Traverser {
&self.traverser
}
fn traverser_mut(&mut self) -> &mut Traverser {
&mut self.traverser
}
fn texture(&self) -> &DirectionalAnimatedTexture {
&self.texture
}
fn texture_mut(&mut self) -> &mut DirectionalAnimatedTexture {
&mut self.texture
}
fn speed(&self) -> f32 {
self.speed
}
fn can_traverse(&self, edge: Edge) -> bool {
can_ghost_traverse(edge)
}
fn tick(&mut self, dt: f32, graph: &Graph) {
// Choose random direction when at a node
if self.traverser.position.is_at_node() {
self.choose_random_direction(graph);
}
if let Err(e) = self.traverser.advance(graph, dt * 60.0 * self.speed, &can_ghost_traverse) {
error!("Ghost movement error: {}", e);
}
self.texture.tick(dt);
}
}
impl Ghost { impl Ghost {
/// Creates a new ghost instance at the specified starting node. /// Creates a new ghost instance at the specified starting node.
/// ///
/// Sets up animated textures for all four directions with moving and stopped states. /// Sets up animated textures for all four directions with moving and stopped states.
/// The moving animation cycles through two sprite variants. /// The moving animation cycles through two sprite variants.
pub fn new(graph: &Graph, start_node: NodeId, ghost_type: GhostType, atlas: &SpriteAtlas) -> Self { pub fn new(graph: &Graph, start_node: NodeId, ghost_type: GhostType, atlas: &SpriteAtlas) -> GameResult<Self> {
let mut textures = [None, None, None, None]; let mut textures = [None, None, None, None];
let mut stopped_textures = [None, None, None, None]; let mut stopped_textures = [None, None, None, None];
@@ -90,41 +131,49 @@ impl Ghost {
Direction::Right => "right", Direction::Right => "right",
}; };
let moving_tiles = vec![ let moving_tiles = vec![
SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a")).unwrap(), SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a"))
SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "b")).unwrap(), .ok_or_else(|| {
GameError::Texture(TextureError::AtlasTileNotFound(format!(
"ghost/{}/{}_{}.png",
ghost_type.as_str(),
moving_prefix,
"a"
)))
})?,
SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "b"))
.ok_or_else(|| {
GameError::Texture(TextureError::AtlasTileNotFound(format!(
"ghost/{}/{}_{}.png",
ghost_type.as_str(),
moving_prefix,
"b"
)))
})?,
]; ];
let stopped_tiles = let stopped_tiles =
vec![ vec![
SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a")) SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a"))
.unwrap(), .ok_or_else(|| {
GameError::Texture(TextureError::AtlasTileNotFound(format!(
"ghost/{}/{}_{}.png",
ghost_type.as_str(),
moving_prefix,
"a"
)))
})?,
]; ];
textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.2).expect("Invalid frame duration")); textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.2)?);
stopped_textures[direction.as_usize()] = stopped_textures[direction.as_usize()] = Some(AnimatedTexture::new(stopped_tiles, 0.1)?);
Some(AnimatedTexture::new(stopped_tiles, 0.1).expect("Invalid frame duration"));
} }
Self { Ok(Self {
traverser: Traverser::new(graph, start_node, Direction::Left, &can_ghost_traverse), traverser: Traverser::new(graph, start_node, Direction::Left, &can_ghost_traverse),
ghost_type, ghost_type,
texture: DirectionalAnimatedTexture::new(textures, stopped_textures), texture: DirectionalAnimatedTexture::new(textures, stopped_textures),
speed: ghost_type.base_speed(), speed: ghost_type.base_speed(),
} })
}
/// Updates the ghost's position and animation state.
///
/// Advances movement through the graph, updates texture animation,
/// and chooses random directions at intersections.
pub fn tick(&mut self, dt: f32, graph: &Graph) {
// Choose random direction when at a node
if self.traverser.position.is_at_node() {
self.choose_random_direction(graph);
}
self.traverser.advance(graph, dt * 60.0 * self.speed, &can_ghost_traverse);
self.texture.tick(dt);
} }
/// Chooses a random available direction at the current intersection. /// Chooses a random available direction at the current intersection.
@@ -158,29 +207,11 @@ impl Ghost {
} }
} }
/// Calculates the current pixel position in the game world.
///
/// Converts the graph position to screen coordinates, accounting for
/// the board offset and centering the sprite.
fn get_pixel_pos(&self, graph: &Graph) -> Vec2 {
let pos = match self.traverser.position {
Position::AtNode(node_id) => graph.get_node(node_id).unwrap().position,
Position::BetweenNodes { from, to, traversed } => {
let from_pos = graph.get_node(from).unwrap().position;
let to_pos = graph.get_node(to).unwrap().position;
let edge = graph.find_edge(from, to).unwrap();
from_pos + (to_pos - from_pos) * (traversed / edge.distance)
}
};
Vec2::new(pos.x + BOARD_PIXEL_OFFSET.x as f32, pos.y + BOARD_PIXEL_OFFSET.y as f32)
}
/// Calculates the shortest path from the ghost's current position to a target node using Dijkstra's algorithm. /// Calculates the shortest path from the ghost's current position to a target node using Dijkstra's algorithm.
/// ///
/// Returns a vector of NodeIds representing the path, or None if no path exists. /// Returns a vector of NodeIds representing the path, or an error if pathfinding fails.
/// The path includes the current node and the target node. /// The path includes the current node and the target node.
pub fn calculate_path_to_target(&self, graph: &Graph, target: NodeId) -> Option<Vec<NodeId>> { pub fn calculate_path_to_target(&self, graph: &Graph, target: NodeId) -> GameResult<Vec<NodeId>> {
let start_node = self.traverser.position.from_node_id(); let start_node = self.traverser.position.from_node_id();
// Use Dijkstra's algorithm to find the shortest path // Use Dijkstra's algorithm to find the shortest path
@@ -197,7 +228,12 @@ impl Ghost {
|&node_id| node_id == target, |&node_id| node_id == target,
); );
result.map(|(path, _cost)| path) result.map(|(path, _cost)| path).ok_or_else(|| {
GameError::Entity(EntityError::PathfindingFailed(format!(
"No path found from node {} to target {}",
start_node, target
)))
})
} }
/// Returns the ghost's color for debug rendering. /// Returns the ghost's color for debug rendering.
@@ -209,26 +245,10 @@ impl Ghost {
GhostType::Clyde => sdl2::pixels::Color::RGB(255, 182, 85), // Orange GhostType::Clyde => sdl2::pixels::Color::RGB(255, 182, 85), // Orange
} }
} }
}
/// Renders the ghost at its current position. impl Collidable for Ghost {
/// fn position(&self) -> crate::entity::traversal::Position {
/// Draws the appropriate directional sprite based on the ghost's self.traverser.position
/// current movement state and direction.
pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, graph: &Graph) {
let pixel_pos = self.get_pixel_pos(graph);
let dest = centered_with_size(
glam::IVec2::new(pixel_pos.x as i32, pixel_pos.y as i32),
glam::UVec2::new(16, 16),
);
if self.traverser.position.is_stopped() {
self.texture
.render_stopped(canvas, atlas, dest, self.traverser.direction)
.expect("Failed to render ghost");
} else {
self.texture
.render(canvas, atlas, dest, self.traverser.direction)
.expect("Failed to render ghost");
}
} }
} }

117
src/entity/item.rs Normal file
View File

@@ -0,0 +1,117 @@
use crate::{
constants,
entity::{collision::Collidable, graph::Graph},
error::{EntityError, GameResult},
texture::sprite::{Sprite, SpriteAtlas},
};
use sdl2::render::{Canvas, RenderTarget};
use strum_macros::{EnumCount, EnumIter};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ItemType {
Pellet,
Energizer,
#[allow(dead_code)]
Fruit {
kind: FruitKind,
},
}
impl ItemType {
pub fn get_score(self) -> u32 {
match self {
ItemType::Pellet => 10,
ItemType::Energizer => 50,
ItemType::Fruit { kind } => kind.get_score(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, EnumCount)]
#[allow(dead_code)]
pub enum FruitKind {
Apple,
Strawberry,
Orange,
Melon,
Bell,
Key,
Galaxian,
}
impl FruitKind {
#[allow(dead_code)]
pub fn index(self) -> u8 {
match self {
FruitKind::Apple => 0,
FruitKind::Strawberry => 1,
FruitKind::Orange => 2,
FruitKind::Melon => 3,
FruitKind::Bell => 4,
FruitKind::Key => 5,
FruitKind::Galaxian => 6,
}
}
pub fn get_score(self) -> u32 {
match self {
FruitKind::Apple => 100,
FruitKind::Strawberry => 300,
FruitKind::Orange => 500,
FruitKind::Melon => 700,
FruitKind::Bell => 1000,
FruitKind::Key => 2000,
FruitKind::Galaxian => 3000,
}
}
}
pub struct Item {
pub node_index: usize,
pub item_type: ItemType,
pub sprite: Sprite,
pub collected: bool,
}
impl Item {
pub fn new(node_index: usize, item_type: ItemType, sprite: Sprite) -> Self {
Self {
node_index,
item_type,
sprite,
collected: false,
}
}
pub fn is_collected(&self) -> bool {
self.collected
}
pub fn collect(&mut self) {
self.collected = true;
}
pub fn get_score(&self) -> u32 {
self.item_type.get_score()
}
pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, graph: &Graph) -> GameResult<()> {
if self.collected {
return Ok(());
}
let node = graph
.get_node(self.node_index)
.ok_or(EntityError::NodeNotFound(self.node_index))?;
let position = node.position + constants::BOARD_PIXEL_OFFSET.as_vec2();
self.sprite.render(canvas, atlas, position)?;
Ok(())
}
}
impl Collidable for Item {
fn position(&self) -> crate::entity::traversal::Position {
crate::entity::traversal::Position::AtNode(self.node_index)
}
}

View File

@@ -1,5 +1,8 @@
pub mod collision;
pub mod direction; pub mod direction;
pub mod ghost; pub mod ghost;
pub mod graph; pub mod graph;
pub mod item;
pub mod pacman; pub mod pacman;
pub mod r#trait;
pub mod traversal; pub mod traversal;

View File

@@ -4,18 +4,20 @@
//! animation, and rendering. Pac-Man moves through the game graph using //! animation, and rendering. Pac-Man moves through the game graph using
//! a traverser and displays directional animated textures. //! a traverser and displays directional animated textures.
use glam::{UVec2, Vec2}; use crate::entity::{
collision::Collidable,
use crate::constants::BOARD_PIXEL_OFFSET; direction::Direction,
use crate::entity::direction::Direction; graph::{Edge, EdgePermissions, Graph, NodeId},
use crate::entity::graph::{Edge, EdgePermissions, Graph, NodeId}; r#trait::Entity,
use crate::entity::traversal::{Position, Traverser}; traversal::Traverser,
use crate::helpers::centered_with_size; };
use crate::texture::animated::AnimatedTexture; use crate::texture::animated::AnimatedTexture;
use crate::texture::directional::DirectionalAnimatedTexture; use crate::texture::directional::DirectionalAnimatedTexture;
use crate::texture::sprite::SpriteAtlas; use crate::texture::sprite::SpriteAtlas;
use sdl2::keyboard::Keycode; use sdl2::keyboard::Keycode;
use sdl2::render::{Canvas, RenderTarget}; use tracing::error;
use crate::error::{GameError, GameResult, TextureError};
/// Determines if Pac-Man can traverse a given edge. /// Determines if Pac-Man can traverse a given edge.
/// ///
@@ -35,12 +37,45 @@ pub struct Pacman {
texture: DirectionalAnimatedTexture, texture: DirectionalAnimatedTexture,
} }
impl Entity for Pacman {
fn traverser(&self) -> &Traverser {
&self.traverser
}
fn traverser_mut(&mut self) -> &mut Traverser {
&mut self.traverser
}
fn texture(&self) -> &DirectionalAnimatedTexture {
&self.texture
}
fn texture_mut(&mut self) -> &mut DirectionalAnimatedTexture {
&mut self.texture
}
fn speed(&self) -> f32 {
1.125
}
fn can_traverse(&self, edge: Edge) -> bool {
can_pacman_traverse(edge)
}
fn tick(&mut self, dt: f32, graph: &Graph) {
if let Err(e) = self.traverser.advance(graph, dt * 60.0 * 1.125, &can_pacman_traverse) {
error!("Pac-Man movement error: {}", e);
}
self.texture.tick(dt);
}
}
impl Pacman { impl Pacman {
/// Creates a new Pac-Man instance at the specified starting node. /// Creates a new Pac-Man instance at the specified starting node.
/// ///
/// Sets up animated textures for all four directions with moving and stopped states. /// Sets up animated textures for all four directions with moving and stopped states.
/// The moving animation cycles through open mouth, closed mouth, and full sprites. /// The moving animation cycles through open mouth, closed mouth, and full sprites.
pub fn new(graph: &Graph, start_node: NodeId, atlas: &SpriteAtlas) -> Self { pub fn new(graph: &Graph, start_node: NodeId, atlas: &SpriteAtlas) -> GameResult<Self> {
let mut textures = [None, None, None, None]; let mut textures = [None, None, None, None];
let mut stopped_textures = [None, None, None, None]; let mut stopped_textures = [None, None, None, None];
@@ -52,31 +87,25 @@ impl Pacman {
Direction::Right => "pacman/right", Direction::Right => "pacman/right",
}; };
let moving_tiles = vec![ let moving_tiles = vec![
SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_a.png")).unwrap(), SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_a.png"))
SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png")).unwrap(), .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_a.png"))))?,
SpriteAtlas::get_tile(atlas, "pacman/full.png").unwrap(), SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png"))
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_b.png"))))?,
SpriteAtlas::get_tile(atlas, "pacman/full.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?,
]; ];
let stopped_tiles = vec![SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png")).unwrap()]; let stopped_tiles = vec![SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png"))
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_b.png"))))?];
textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.08).expect("Invalid frame duration")); textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.08)?);
stopped_textures[direction.as_usize()] = stopped_textures[direction.as_usize()] = Some(AnimatedTexture::new(stopped_tiles, 0.1)?);
Some(AnimatedTexture::new(stopped_tiles, 0.1).expect("Invalid frame duration"));
} }
Self { Ok(Self {
traverser: Traverser::new(graph, start_node, Direction::Left, &can_pacman_traverse), traverser: Traverser::new(graph, start_node, Direction::Left, &can_pacman_traverse),
texture: DirectionalAnimatedTexture::new(textures, stopped_textures), texture: DirectionalAnimatedTexture::new(textures, stopped_textures),
} })
}
/// Updates Pac-Man's position and animation state.
///
/// Advances movement through the graph and updates texture animation.
/// Movement speed is scaled by 60 FPS and a 1.125 multiplier.
pub fn tick(&mut self, dt: f32, graph: &Graph) {
self.traverser.advance(graph, dt * 60.0 * 1.125, &can_pacman_traverse);
self.texture.tick(dt);
} }
/// Handles keyboard input to change Pac-Man's direction. /// Handles keyboard input to change Pac-Man's direction.
@@ -96,47 +125,10 @@ impl Pacman {
self.traverser.set_next_direction(direction); self.traverser.set_next_direction(direction);
} }
} }
}
/// Calculates the current pixel position in the game world. impl Collidable for Pacman {
/// fn position(&self) -> crate::entity::traversal::Position {
/// Interpolates between nodes when moving between them. self.traverser.position
fn get_pixel_pos(&self, graph: &Graph) -> Vec2 {
match self.traverser.position {
Position::AtNode(node_id) => graph.get_node(node_id).unwrap().position,
Position::BetweenNodes { from, to, traversed } => {
let from_pos = graph.get_node(from).unwrap().position;
let to_pos = graph.get_node(to).unwrap().position;
from_pos.lerp(to_pos, traversed / from_pos.distance(to_pos))
}
}
}
/// Returns the current node ID that Pac-Man is at or moving towards.
///
/// If Pac-Man is at a node, returns that node ID.
/// If Pac-Man is between nodes, returns the node it's moving towards.
pub fn current_node_id(&self) -> NodeId {
match self.traverser.position {
Position::AtNode(node_id) => node_id,
Position::BetweenNodes { to, .. } => to,
}
}
/// Renders Pac-Man to the canvas.
///
/// Calculates screen position, determines if Pac-Man is stopped,
/// and renders the appropriate directional texture.
pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, graph: &Graph) {
let pixel_pos = self.get_pixel_pos(graph).round().as_ivec2() + BOARD_PIXEL_OFFSET.as_ivec2();
let dest = centered_with_size(pixel_pos, UVec2::new(16, 16));
let is_stopped = self.traverser.position.is_stopped();
if is_stopped {
self.texture
.render_stopped(canvas, atlas, dest, self.traverser.direction)
.unwrap();
} else {
self.texture.render(canvas, atlas, dest, self.traverser.direction).unwrap();
}
} }
} }

114
src/entity/trait.rs Normal file
View File

@@ -0,0 +1,114 @@
//! Entity trait for common movement and rendering functionality.
//!
//! This module defines a trait that captures the shared behavior between
//! different game entities like Ghosts and Pac-Man, including movement,
//! rendering, and position calculations.
use glam::Vec2;
use sdl2::render::{Canvas, RenderTarget};
use crate::entity::direction::Direction;
use crate::entity::graph::{Edge, Graph, NodeId};
use crate::entity::traversal::{Position, Traverser};
use crate::error::{EntityError, GameError, GameResult, TextureError};
use crate::texture::directional::DirectionalAnimatedTexture;
use crate::texture::sprite::SpriteAtlas;
/// Trait defining common functionality for game entities that move through the graph.
///
/// This trait provides a unified interface for entities that:
/// - Move through the game graph using a traverser
/// - Render using directional animated textures
/// - Have position calculations and movement speed
#[allow(dead_code)]
pub trait Entity {
/// Returns a reference to the entity's traverser for movement control.
fn traverser(&self) -> &Traverser;
/// Returns a mutable reference to the entity's traverser for movement control.
fn traverser_mut(&mut self) -> &mut Traverser;
/// Returns a reference to the entity's directional animated texture.
fn texture(&self) -> &DirectionalAnimatedTexture;
/// Returns a mutable reference to the entity's directional animated texture.
fn texture_mut(&mut self) -> &mut DirectionalAnimatedTexture;
/// Returns the movement speed multiplier for this entity.
fn speed(&self) -> f32;
/// Determines if this entity can traverse a given edge.
fn can_traverse(&self, edge: Edge) -> bool;
/// Updates the entity's position and animation state.
///
/// This method advances movement through the graph and updates texture animation.
fn tick(&mut self, dt: f32, graph: &Graph);
/// Calculates the current pixel position in the game world.
///
/// Converts the graph position to screen coordinates, accounting for
/// the board offset and centering the sprite.
fn get_pixel_pos(&self, graph: &Graph) -> GameResult<Vec2> {
let pos = match self.traverser().position {
Position::AtNode(node_id) => {
let node = graph.get_node(node_id).ok_or(EntityError::NodeNotFound(node_id))?;
node.position
}
Position::BetweenNodes { from, to, traversed } => {
let from_node = graph.get_node(from).ok_or(EntityError::NodeNotFound(from))?;
let to_node = graph.get_node(to).ok_or(EntityError::NodeNotFound(to))?;
let edge = graph.find_edge(from, to).ok_or(EntityError::EdgeNotFound { from, to })?;
from_node.position + (to_node.position - from_node.position) * (traversed / edge.distance)
}
};
Ok(Vec2::new(
pos.x + crate::constants::BOARD_PIXEL_OFFSET.x as f32,
pos.y + crate::constants::BOARD_PIXEL_OFFSET.y as f32,
))
}
/// Returns the current node ID that the entity is at or moving towards.
///
/// If the entity is at a node, returns that node ID.
/// If the entity is between nodes, returns the node it's moving towards.
fn current_node_id(&self) -> NodeId {
match self.traverser().position {
Position::AtNode(node_id) => node_id,
Position::BetweenNodes { to, .. } => to,
}
}
/// Sets the next direction for the entity to take.
///
/// The direction is buffered and will be applied at the next opportunity,
/// typically when the entity reaches a new node.
fn set_next_direction(&mut self, direction: Direction) {
self.traverser_mut().set_next_direction(direction);
}
/// Renders the entity at its current position.
///
/// Draws the appropriate directional sprite based on the entity's
/// current movement state and direction.
fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, graph: &Graph) -> GameResult<()> {
let pixel_pos = self.get_pixel_pos(graph)?;
let dest = crate::helpers::centered_with_size(
glam::IVec2::new(pixel_pos.x as i32, pixel_pos.y as i32),
glam::UVec2::new(16, 16),
);
if self.traverser().position.is_stopped() {
self.texture()
.render_stopped(canvas, atlas, dest, self.traverser().direction)
.map_err(|e| GameError::Texture(TextureError::RenderFailed(e.to_string())))?;
} else {
self.texture()
.render(canvas, atlas, dest, self.traverser().direction)
.map_err(|e| GameError::Texture(TextureError::RenderFailed(e.to_string())))?;
}
Ok(())
}
}

View File

@@ -1,3 +1,7 @@
use tracing::error;
use crate::error::GameResult;
use super::direction::Direction; use super::direction::Direction;
use super::graph::{Edge, Graph, NodeId}; use super::graph::{Edge, Graph, NodeId};
@@ -82,7 +86,9 @@ impl Traverser {
}; };
// This will kickstart the traverser into motion // This will kickstart the traverser into motion
traverser.advance(graph, 0.0, can_traverse); if let Err(e) = traverser.advance(graph, 0.0, can_traverse) {
error!("Traverser initialization error: {}", e);
}
traverser traverser
} }
@@ -108,7 +114,9 @@ impl Traverser {
/// - If it reaches a node, it attempts to transition to a new edge based on /// - If it reaches a node, it attempts to transition to a new edge based on
/// the buffered direction or by continuing straight. /// the buffered direction or by continuing straight.
/// - If no valid move is possible, it stops at the node. /// - If no valid move is possible, it stops at the node.
pub fn advance<F>(&mut self, graph: &Graph, distance: f32, can_traverse: &F) ///
/// Returns an error if the movement is invalid (e.g., trying to move in an impossible direction).
pub fn advance<F>(&mut self, graph: &Graph, distance: f32, can_traverse: &F) -> GameResult<()>
where where
F: Fn(Edge) -> bool, F: Fn(Edge) -> bool,
{ {
@@ -134,7 +142,18 @@ impl Traverser {
traversed: distance.max(0.0), traversed: distance.max(0.0),
}; };
self.direction = next_direction; self.direction = next_direction;
} else {
return Err(crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement(
format!(
"Cannot traverse edge from {} to {} in direction {:?}",
node_id, edge.target, next_direction
),
)));
} }
} else {
return Err(crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement(
format!("No edge found in direction {:?} from node {}", next_direction, node_id),
)));
} }
self.next_direction = None; // Consume the buffered direction regardless of whether we started moving with it self.next_direction = None; // Consume the buffered direction regardless of whether we started moving with it
@@ -143,12 +162,15 @@ impl Traverser {
Position::BetweenNodes { from, to, traversed } => { Position::BetweenNodes { from, to, traversed } => {
// There is no point in any of the next logic if we don't travel at all // There is no point in any of the next logic if we don't travel at all
if distance <= 0.0 { if distance <= 0.0 {
return; return Ok(());
} }
let edge = graph let edge = graph.find_edge(from, to).ok_or_else(|| {
.find_edge(from, to) crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement(format!(
.expect("Inconsistent state: Traverser is on a non-existent edge."); "Inconsistent state: Traverser is on a non-existent edge from {} to {}.",
from, to
)))
})?;
let new_traversed = traversed + distance; let new_traversed = traversed + distance;
@@ -201,5 +223,7 @@ impl Traverser {
} }
} }
} }
Ok(())
} }
} }

185
src/error.rs Normal file
View File

@@ -0,0 +1,185 @@
//! Centralized error types for the Pac-Man game.
//!
//! This module defines all error types used throughout the application,
//! providing a consistent error handling approach.
use std::io;
/// Main error type for the Pac-Man game.
///
/// This is the primary error type that should be used in public APIs.
/// It can represent any error that can occur during game operation.
#[derive(thiserror::Error, Debug)]
pub enum GameError {
#[error("Asset error: {0}")]
Asset(#[from] AssetError),
#[error("Platform error: {0}")]
Platform(#[from] PlatformError),
#[error("Map parsing error: {0}")]
MapParse(#[from] ParseError),
#[error("Map error: {0}")]
Map(#[from] MapError),
#[error("Texture error: {0}")]
Texture(#[from] TextureError),
#[error("Entity error: {0}")]
Entity(#[from] EntityError),
#[error("Game state error: {0}")]
GameState(#[from] GameStateError),
#[error("SDL error: {0}")]
Sdl(String),
#[error("IO error: {0}")]
Io(#[from] io::Error),
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("Invalid state: {0}")]
InvalidState(String),
}
#[derive(thiserror::Error, Debug)]
pub enum AssetError {
#[error("IO error: {0}")]
Io(#[from] io::Error),
#[error("Asset not found: {0}")]
NotFound(String),
}
/// Platform-specific errors.
#[derive(thiserror::Error, Debug)]
#[allow(dead_code)]
pub enum PlatformError {
#[error("Console initialization failed: {0}")]
ConsoleInit(String),
#[error("Platform-specific error: {0}")]
Other(String),
}
/// Error type for map parsing operations.
#[derive(thiserror::Error, Debug)]
pub enum ParseError {
#[error("Unknown character in board: {0}")]
UnknownCharacter(char),
#[error("House door must have exactly 2 positions, found {0}")]
InvalidHouseDoorCount(usize),
#[error("Map parsing failed: {0}")]
ParseFailed(String),
}
/// Errors related to texture operations.
#[derive(thiserror::Error, Debug)]
pub enum TextureError {
#[error("Animated texture error: {0}")]
Animated(#[from] AnimatedTextureError),
#[error("Failed to load texture: {0}")]
LoadFailed(String),
#[error("Texture not found in atlas: {0}")]
AtlasTileNotFound(String),
#[error("Invalid texture format: {0}")]
InvalidFormat(String),
#[error("Rendering failed: {0}")]
RenderFailed(String),
}
#[derive(thiserror::Error, Debug)]
pub enum AnimatedTextureError {
#[error("Frame duration must be positive, got {0}")]
InvalidFrameDuration(f32),
}
/// Errors related to entity operations.
#[derive(thiserror::Error, Debug)]
pub enum EntityError {
#[error("Node not found in graph: {0}")]
NodeNotFound(usize),
#[error("Edge not found: from {from} to {to}")]
EdgeNotFound { from: usize, to: usize },
#[error("Invalid movement: {0}")]
InvalidMovement(String),
#[error("Pathfinding failed: {0}")]
PathfindingFailed(String),
}
/// Errors related to game state operations.
#[derive(thiserror::Error, Debug)]
pub enum GameStateError {}
/// Errors related to map operations.
#[derive(thiserror::Error, Debug)]
pub enum MapError {
#[error("Node not found: {0}")]
NodeNotFound(usize),
#[error("Invalid map configuration: {0}")]
InvalidConfig(String),
}
/// Result type for game operations.
pub type GameResult<T> = Result<T, GameError>;
/// Helper trait for converting other error types to GameError.
pub trait IntoGameError<T> {
#[allow(dead_code)]
fn into_game_error(self) -> GameResult<T>;
}
impl<T, E> IntoGameError<T> for Result<T, E>
where
E: std::error::Error + Send + Sync + 'static,
{
fn into_game_error(self) -> GameResult<T> {
self.map_err(|e| GameError::InvalidState(e.to_string()))
}
}
/// Helper trait for converting Option to GameResult with a custom error.
pub trait OptionExt<T> {
#[allow(dead_code)]
fn ok_or_game_error<F>(self, f: F) -> GameResult<T>
where
F: FnOnce() -> GameError;
}
impl<T> OptionExt<T> for Option<T> {
fn ok_or_game_error<F>(self, f: F) -> GameResult<T>
where
F: FnOnce() -> GameError,
{
self.ok_or_else(f)
}
}
/// Helper trait for converting Result to GameResult with context.
pub trait ResultExt<T, E> {
#[allow(dead_code)]
fn with_context<F>(self, f: F) -> GameResult<T>
where
F: FnOnce(&E) -> GameError;
}
impl<T, E> ResultExt<T, E> for Result<T, E>
where
E: std::error::Error + Send + Sync + 'static,
{
fn with_context<F>(self, f: F) -> GameResult<T>
where
F: FnOnce(&E) -> GameError,
{
self.map_err(|e| f(&e))
}
}

View File

@@ -1,268 +0,0 @@
//! This module contains the main game logic and state.
use anyhow::Result;
use glam::UVec2;
use rand::{rngs::SmallRng, Rng, SeedableRng};
use sdl2::{
image::LoadTexture,
keyboard::Keycode,
pixels::Color,
render::{Canvas, RenderTarget, Texture, TextureCreator},
video::WindowContext,
};
use crate::{
asset::{get_asset_bytes, Asset},
audio::Audio,
constants::RAW_BOARD,
entity::{
ghost::{Ghost, GhostType},
pacman::Pacman,
},
map::Map,
texture::{
sprite::{self, AtlasMapper, AtlasTile, SpriteAtlas},
text::TextTexture,
},
};
/// The main game state.
///
/// Contains all the information necessary to run the game, including
/// the game state, rendering resources, and audio.
pub struct Game {
pub score: u32,
pub map: Map,
pub pacman: Pacman,
pub ghosts: Vec<Ghost>,
pub debug_mode: bool,
// Rendering resources
atlas: SpriteAtlas,
map_texture: AtlasTile,
text_texture: TextTexture,
// Audio
pub audio: Audio,
}
impl Game {
pub fn new(
texture_creator: &TextureCreator<WindowContext>,
_ttf_context: &sdl2::ttf::Sdl2TtfContext,
_audio_subsystem: &sdl2::AudioSubsystem,
) -> Game {
let map = Map::new(RAW_BOARD);
let pacman_start_pos = map.find_starting_position(0).unwrap();
let pacman_start_node = *map
.grid_to_node
.get(&glam::IVec2::new(pacman_start_pos.x as i32, pacman_start_pos.y as i32))
.expect("Pac-Man starting position not found in graph");
let atlas_bytes = get_asset_bytes(Asset::Atlas).expect("Failed to load asset");
let atlas_texture = unsafe {
let texture = texture_creator
.load_texture_bytes(&atlas_bytes)
.expect("Could not load atlas texture from asset API");
sprite::texture_to_static(texture)
};
let atlas_json = get_asset_bytes(Asset::AtlasJson).expect("Failed to load asset");
let atlas_mapper: AtlasMapper = serde_json::from_slice(&atlas_json).expect("Could not parse atlas JSON");
let atlas = SpriteAtlas::new(atlas_texture, atlas_mapper);
let mut map_texture = SpriteAtlas::get_tile(&atlas, "maze/full.png").expect("Failed to load map tile");
map_texture.color = Some(Color::RGB(0x20, 0x20, 0xf9));
let text_texture = TextTexture::new(1.0);
let audio = Audio::new();
let pacman = Pacman::new(&map.graph, pacman_start_node, &atlas);
// Create ghosts at random positions
let mut ghosts = Vec::new();
let ghost_types = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde];
let mut rng = SmallRng::from_os_rng();
for &ghost_type in &ghost_types {
// Find a random node for the ghost to start at
let random_node = rng.random_range(0..map.graph.node_count());
let ghost = Ghost::new(&map.graph, random_node, ghost_type, &atlas);
ghosts.push(ghost);
}
Game {
score: 0,
map,
pacman,
ghosts,
debug_mode: false,
map_texture,
text_texture,
audio,
atlas,
}
}
pub fn keyboard_event(&mut self, keycode: Keycode) {
self.pacman.handle_key(keycode);
if keycode == Keycode::M {
self.audio.set_mute(!self.audio.is_muted());
}
if keycode == Keycode::R {
self.reset_game_state();
}
}
/// Resets the game state, randomizing ghost positions and resetting Pac-Man
fn reset_game_state(&mut self) {
// Reset Pac-Man to starting position
let pacman_start_pos = self.map.find_starting_position(0).unwrap();
let pacman_start_node = *self
.map
.grid_to_node
.get(&glam::IVec2::new(pacman_start_pos.x as i32, pacman_start_pos.y as i32))
.expect("Pac-Man starting position not found in graph");
self.pacman = Pacman::new(&self.map.graph, pacman_start_node, &self.atlas);
// Randomize ghost positions
let ghost_types = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde];
let mut rng = SmallRng::from_os_rng();
for (i, ghost) in self.ghosts.iter_mut().enumerate() {
let random_node = rng.random_range(0..self.map.graph.node_count());
*ghost = Ghost::new(&self.map.graph, random_node, ghost_types[i], &self.atlas);
}
}
pub fn tick(&mut self, dt: f32) {
self.pacman.tick(dt, &self.map.graph);
// Update all ghosts
for ghost in &mut self.ghosts {
ghost.tick(dt, &self.map.graph);
}
}
pub fn draw<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>, backbuffer: &mut Texture) -> Result<()> {
canvas.with_texture_canvas(backbuffer, |canvas| {
canvas.set_draw_color(Color::BLACK);
canvas.clear();
self.map.render(canvas, &mut self.atlas, &mut self.map_texture);
// Render all ghosts
for ghost in &self.ghosts {
ghost.render(canvas, &mut self.atlas, &self.map.graph);
}
self.pacman.render(canvas, &mut self.atlas, &self.map.graph);
})?;
Ok(())
}
pub fn present_backbuffer<T: RenderTarget>(
&mut self,
canvas: &mut Canvas<T>,
backbuffer: &Texture,
cursor_pos: glam::Vec2,
) -> Result<()> {
canvas.copy(backbuffer, None, None).map_err(anyhow::Error::msg)?;
if self.debug_mode {
self.map
.debug_render_with_cursor(canvas, &mut self.text_texture, &mut self.atlas, cursor_pos);
self.render_pathfinding_debug(canvas)?;
}
self.draw_hud(canvas)?;
canvas.present();
Ok(())
}
/// 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: RenderTarget>(&self, canvas: &mut Canvas<T>) -> Result<()> {
let pacman_node = self.pacman.current_node_id();
for (i, ghost) in self.ghosts.iter().enumerate() {
if let Some(path) = ghost.calculate_path_to_target(&self.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();
let first_pos = first_node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
let last_pos = last_node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
// Use the overall direction from start to end to determine the perpendicular offset
let overall_dir = (last_pos - first_pos).normalize();
let perp_dir = glam::Vec2::new(-overall_dir.y, overall_dir.x);
// 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.map.graph.get_node(node_id).unwrap();
let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
offset_positions.push(pos + perp_dir * offset);
}
// Draw lines between the offset positions
for window in offset_positions.windows(2) {
canvas
.draw_line(
(window[0].x as i32, window[0].y as i32),
(window[1].x as i32, window[1].y as i32),
)
.map_err(anyhow::Error::msg)?;
}
}
}
Ok(())
}
fn draw_hud<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>) -> Result<()> {
let lives = 3;
let score_text = format!("{:02}", self.score);
let x_offset = 4;
let y_offset = 2;
let lives_offset = 3;
let score_offset = 7 - (score_text.len() as i32);
self.text_texture.set_scale(1.0);
let _ = self.text_texture.render(
canvas,
&mut self.atlas,
&format!("{lives}UP HIGH SCORE "),
UVec2::new(8 * lives_offset as u32 + x_offset, y_offset),
);
let _ = self.text_texture.render(
canvas,
&mut self.atlas,
&score_text,
UVec2::new(8 * score_offset as u32 + x_offset, 8 + y_offset),
);
// Display FPS information in top-left corner
// let fps_text = format!("FPS: {:.1} (1s) / {:.1} (10s)", self.fps_1s, self.fps_10s);
// self.render_text_on(
// canvas,
// &*texture_creator,
// &fps_text,
// IVec2::new(10, 10),
// Color::RGB(255, 255, 0), // Yellow color for FPS display
// );
Ok(())
}
}

327
src/game/mod.rs Normal file
View File

@@ -0,0 +1,327 @@
//! This module contains the main game logic and state.
use glam::{UVec2, Vec2};
use rand::{rngs::SmallRng, Rng, SeedableRng};
use sdl2::{
keyboard::Keycode,
pixels::Color,
render::{Canvas, RenderTarget, Texture, TextureCreator},
video::WindowContext,
};
use crate::error::{EntityError, GameError, GameResult};
use crate::entity::{
collision::{Collidable, CollisionSystem, EntityId},
ghost::{Ghost, GhostType},
pacman::Pacman,
r#trait::Entity,
};
pub mod state;
use state::GameState;
/// The `Game` struct is the main entry point for the game.
///
/// It contains the game's state and logic, and is responsible for
/// handling user input, updating the game state, and rendering the game.
pub struct Game {
state: GameState,
}
impl Game {
pub fn new(texture_creator: &'static TextureCreator<WindowContext>) -> GameResult<Game> {
let state = GameState::new(texture_creator)?;
Ok(Game { state })
}
pub fn keyboard_event(&mut self, keycode: Keycode) {
self.state.pacman.handle_key(keycode);
if keycode == Keycode::M {
self.state.audio.set_mute(!self.state.audio.is_muted());
}
if keycode == Keycode::R {
if let Err(e) = self.reset_game_state() {
tracing::error!("Failed to reset game state: {}", e);
}
}
}
/// Resets the game state, randomizing ghost positions and resetting Pac-Man
fn reset_game_state(&mut self) -> GameResult<()> {
let pacman_start_node = self.state.map.start_positions.pacman;
self.state.pacman = Pacman::new(&self.state.map.graph, pacman_start_node, &self.state.atlas)?;
// Reset items
self.state.items = self.state.map.generate_items(&self.state.atlas)?;
// Randomize ghost positions
let ghost_types = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde];
let mut rng = SmallRng::from_os_rng();
for (i, ghost) in self.state.ghosts.iter_mut().enumerate() {
let random_node = rng.random_range(0..self.state.map.graph.node_count());
*ghost = Ghost::new(&self.state.map.graph, random_node, ghost_types[i], &self.state.atlas)?;
}
// Reset collision system
self.state.collision_system = CollisionSystem::default();
// Re-register Pac-Man
self.state.pacman_id = self.state.collision_system.register_entity(self.state.pacman.position());
// Re-register items
self.state.item_ids.clear();
for item in &self.state.items {
let item_id = self.state.collision_system.register_entity(item.position());
self.state.item_ids.push(item_id);
}
// Re-register ghosts
self.state.ghost_ids.clear();
for ghost in &self.state.ghosts {
let ghost_id = self.state.collision_system.register_entity(ghost.position());
self.state.ghost_ids.push(ghost_id);
}
Ok(())
}
pub fn tick(&mut self, dt: f32) {
self.state.pacman.tick(dt, &self.state.map.graph);
// Update all ghosts
for ghost in &mut self.state.ghosts {
ghost.tick(dt, &self.state.map.graph);
}
// Update collision system positions
self.update_collision_positions();
// Check for collisions
self.check_collisions();
}
/// Toggles the debug mode on and off.
///
/// When debug mode is enabled, the game will render additional information
/// that is useful for debugging, such as the collision grid and entity paths.
pub fn toggle_debug_mode(&mut self) {
self.state.debug_mode = !self.state.debug_mode;
}
fn update_collision_positions(&mut self) {
// Update Pac-Man's position
self.state
.collision_system
.update_position(self.state.pacman_id, self.state.pacman.position());
// Update ghost positions
for (ghost, &ghost_id) in self.state.ghosts.iter().zip(&self.state.ghost_ids) {
self.state.collision_system.update_position(ghost_id, ghost.position());
}
}
fn check_collisions(&mut self) {
// Check Pac-Man vs Items
let potential_collisions = self
.state
.collision_system
.potential_collisions(&self.state.pacman.position());
for entity_id in potential_collisions {
if entity_id != self.state.pacman_id {
// Check if this is an item collision
if let Some(item_index) = self.find_item_by_id(entity_id) {
let item = &mut self.state.items[item_index];
if !item.is_collected() {
item.collect();
self.state.score += item.get_score();
self.state.audio.eat();
// Handle energizer effects
if matches!(item.item_type, crate::entity::item::ItemType::Energizer) {
// TODO: Make ghosts frightened
tracing::info!("Energizer collected! Ghosts should become frightened.");
}
}
}
// Check if this is a ghost collision
if let Some(_ghost_index) = self.find_ghost_by_id(entity_id) {
// TODO: Handle Pac-Man being eaten by ghost
tracing::info!("Pac-Man collided with ghost!");
}
}
}
}
fn find_item_by_id(&self, entity_id: EntityId) -> Option<usize> {
self.state.item_ids.iter().position(|&id| id == entity_id)
}
fn find_ghost_by_id(&self, entity_id: EntityId) -> Option<usize> {
self.state.ghost_ids.iter().position(|&id| id == entity_id)
}
pub fn draw<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>, backbuffer: &mut Texture) -> GameResult<()> {
canvas
.with_texture_canvas(backbuffer, |canvas| {
canvas.set_draw_color(Color::BLACK);
canvas.clear();
self.state
.map
.render(canvas, &mut self.state.atlas, &mut self.state.map_texture);
// Render all items
for item in &self.state.items {
if let Err(e) = item.render(canvas, &mut self.state.atlas, &self.state.map.graph) {
tracing::error!("Failed to render item: {}", e);
}
}
// Render all ghosts
for ghost in &self.state.ghosts {
if let Err(e) = ghost.render(canvas, &mut self.state.atlas, &self.state.map.graph) {
tracing::error!("Failed to render ghost: {}", e);
}
}
if let Err(e) = self.state.pacman.render(canvas, &mut self.state.atlas, &self.state.map.graph) {
tracing::error!("Failed to render pacman: {}", e);
}
})
.map_err(|e| GameError::Sdl(e.to_string()))?;
Ok(())
}
pub fn present_backbuffer<T: RenderTarget>(
&mut self,
canvas: &mut Canvas<T>,
backbuffer: &Texture,
cursor_pos: glam::Vec2,
) -> GameResult<()> {
canvas
.copy(backbuffer, None, None)
.map_err(|e| GameError::Sdl(e.to_string()))?;
if self.state.debug_mode {
if let Err(e) =
self.state
.map
.debug_render_with_cursor(canvas, &mut self.state.text_texture, &mut self.state.atlas, cursor_pos)
{
tracing::error!("Failed to render debug cursor: {}", e);
}
self.render_pathfinding_debug(canvas)?;
}
self.draw_hud(canvas)?;
canvas.present();
Ok(())
}
/// 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: 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 => Vec2::new(0.25, 0.5),
GhostType::Pinky => Vec2::new(-0.25, -0.25),
GhostType::Inky => Vec2::new(0.5, -0.5),
GhostType::Clyde => 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(GameError::Entity(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| GameError::Sdl(e.to_string()))?;
}
}
}
}
Ok(())
}
fn draw_hud<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>) -> GameResult<()> {
let lives = 3;
let score_text = format!("{:02}", self.state.score);
let x_offset = 4;
let y_offset = 2;
let lives_offset = 3;
let score_offset = 7 - (score_text.len() as i32);
self.state.text_texture.set_scale(1.0);
if let Err(e) = self.state.text_texture.render(
canvas,
&mut self.state.atlas,
&format!("{lives}UP HIGH SCORE "),
UVec2::new(8 * lives_offset as u32 + x_offset, y_offset),
) {
tracing::error!("Failed to render HUD text: {}", e);
}
if let Err(e) = self.state.text_texture.render(
canvas,
&mut self.state.atlas,
&score_text,
UVec2::new(8 * score_offset as u32 + x_offset, 8 + y_offset),
) {
tracing::error!("Failed to render score text: {}", e);
}
// Display FPS information in top-left corner
// let fps_text = format!("FPS: {:.1} (1s) / {:.1} (10s)", self.fps_1s, self.fps_10s);
// self.render_text_on(
// canvas,
// &*texture_creator,
// &fps_text,
// IVec2::new(10, 10),
// Color::RGB(255, 255, 0), // Yellow color for FPS display
// );
Ok(())
}
}

135
src/game/state.rs Normal file
View File

@@ -0,0 +1,135 @@
use sdl2::{image::LoadTexture, pixels::Color, render::TextureCreator, video::WindowContext};
use smallvec::SmallVec;
use crate::{
asset::{get_asset_bytes, Asset},
audio::Audio,
constants::RAW_BOARD,
entity::{
collision::{Collidable, CollisionSystem, EntityId},
ghost::{Ghost, GhostType},
item::Item,
pacman::Pacman,
},
error::{GameError, GameResult, TextureError},
map::Map,
texture::{
sprite::{AtlasMapper, AtlasTile, SpriteAtlas},
text::TextTexture,
},
};
/// The `GameState` struct holds all the essential data for the game.
///
/// This includes the score, map, entities (Pac-Man, ghosts, items),
/// collision system, and rendering resources. By centralizing the game's state,
/// we can cleanly separate it from the game's logic, making it easier to manage
/// and reason about.
pub struct GameState {
pub score: u32,
pub map: Map,
pub pacman: Pacman,
pub ghosts: SmallVec<[Ghost; 4]>,
pub items: Vec<Item>,
pub debug_mode: bool,
// Collision system
pub(crate) collision_system: CollisionSystem,
pub(crate) pacman_id: EntityId,
pub(crate) ghost_ids: SmallVec<[EntityId; 4]>,
pub(crate) item_ids: Vec<EntityId>,
// Rendering resources
pub(crate) atlas: SpriteAtlas,
pub(crate) map_texture: AtlasTile,
pub(crate) text_texture: TextTexture,
// Audio
pub audio: Audio,
}
impl GameState {
/// Creates a new `GameState` by initializing all the game's data.
///
/// This function sets up the map, Pac-Man, ghosts, items, collision system,
/// and all rendering resources required to start the game. It returns a `GameResult`
/// to handle any potential errors during initialization.
pub fn new(texture_creator: &'static TextureCreator<WindowContext>) -> GameResult<Self> {
let map = Map::new(RAW_BOARD)?;
let pacman_start_node = map.start_positions.pacman;
let atlas_bytes = get_asset_bytes(Asset::Atlas)?;
let atlas_texture = texture_creator.load_texture_bytes(&atlas_bytes).map_err(|e| {
if e.to_string().contains("format") || e.to_string().contains("unsupported") {
GameError::Texture(TextureError::InvalidFormat(format!("Unsupported texture format: {e}")))
} else {
GameError::Texture(TextureError::LoadFailed(e.to_string()))
}
})?;
let atlas_json = get_asset_bytes(Asset::AtlasJson)?;
let atlas_mapper: AtlasMapper = serde_json::from_slice(&atlas_json)?;
let atlas = SpriteAtlas::new(atlas_texture, atlas_mapper);
let mut map_texture = SpriteAtlas::get_tile(&atlas, "maze/full.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("maze/full.png".to_string())))?;
map_texture.color = Some(Color::RGB(0x20, 0x20, 0xf9));
let text_texture = TextTexture::new(1.0);
let audio = Audio::new();
let pacman = Pacman::new(&map.graph, pacman_start_node, &atlas)?;
// Generate items (pellets and energizers)
let items = map.generate_items(&atlas)?;
// Initialize collision system
let mut collision_system = CollisionSystem::default();
// Register Pac-Man
let pacman_id = collision_system.register_entity(pacman.position());
// Register items
let mut item_ids = Vec::new();
for item in &items {
let item_id = collision_system.register_entity(item.position());
item_ids.push(item_id);
}
// Create and register ghosts
let ghosts = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde]
.iter()
.zip(
[
map.start_positions.blinky,
map.start_positions.pinky,
map.start_positions.inky,
map.start_positions.clyde,
]
.iter(),
)
.map(|(ghost_type, start_node)| Ghost::new(&map.graph, *start_node, *ghost_type, &atlas))
.collect::<GameResult<SmallVec<[_; 4]>>>()?;
let ghost_ids = ghosts
.iter()
.map(|ghost| collision_system.register_entity(ghost.position()))
.collect::<SmallVec<[_; 4]>>();
Ok(Self {
score: 0,
map,
pacman,
ghosts,
items,
debug_mode: false,
collision_system,
pacman_id,
ghost_ids,
item_ids,
map_texture,
text_texture,
audio,
atlas,
})
}
}

View File

@@ -5,6 +5,7 @@ pub mod asset;
pub mod audio; pub mod audio;
pub mod constants; pub mod constants;
pub mod entity; pub mod entity;
pub mod error;
pub mod game; pub mod game;
pub mod helpers; pub mod helpers;
pub mod map; pub mod map;

View File

@@ -11,6 +11,7 @@ mod audio;
mod constants; mod constants;
mod entity; mod entity;
mod error;
mod game; mod game;
mod helpers; mod helpers;
mod map; mod map;

View File

@@ -1,18 +1,20 @@
//! Map construction and building functionality. //! Map construction and building functionality.
use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE}; use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE, RAW_BOARD};
use crate::entity::direction::Direction; use crate::entity::direction::Direction;
use crate::entity::graph::{EdgePermissions, Graph, Node, NodeId}; use crate::entity::graph::{EdgePermissions, Graph, Node, NodeId};
use crate::entity::item::{Item, ItemType};
use crate::map::parser::MapTileParser; use crate::map::parser::MapTileParser;
use crate::map::render::MapRenderer; use crate::map::render::MapRenderer;
use crate::texture::sprite::{AtlasTile, SpriteAtlas}; use crate::texture::sprite::{AtlasTile, Sprite, SpriteAtlas};
use glam::{IVec2, UVec2, Vec2}; use glam::{IVec2, Vec2};
use sdl2::render::{Canvas, RenderTarget}; use sdl2::render::{Canvas, RenderTarget};
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, VecDeque};
use tracing::debug; use tracing::debug;
use crate::error::{GameResult, MapError};
/// The starting positions of the entities in the game. /// The starting positions of the entities in the game.
#[allow(dead_code)]
pub struct NodePositions { pub struct NodePositions {
pub pacman: NodeId, pub pacman: NodeId,
pub blinky: NodeId, pub blinky: NodeId,
@@ -23,18 +25,12 @@ pub struct NodePositions {
/// The main map structure containing the game board and navigation graph. /// The main map structure containing the game board and navigation graph.
pub struct Map { pub struct Map {
/// The current state of the map.
#[allow(dead_code)]
current: [[MapTile; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize],
/// The node map for entity movement. /// The node map for entity movement.
pub graph: Graph, pub graph: Graph,
/// A mapping from grid positions to node IDs. /// A mapping from grid positions to node IDs.
pub grid_to_node: HashMap<IVec2, NodeId>, pub grid_to_node: HashMap<IVec2, NodeId>,
/// A mapping of the starting positions of the entities. /// A mapping of the starting positions of the entities.
#[allow(dead_code)]
pub start_positions: NodePositions, pub start_positions: NodePositions,
/// Pac-Man's starting position.
pacman_start: Option<IVec2>,
} }
impl Map { impl Map {
@@ -47,13 +43,12 @@ 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]) -> Map { pub fn new(raw_board: [&str; BOARD_CELL_SIZE.y as usize]) -> GameResult<Map> {
let parsed_map = MapTileParser::parse_board(raw_board).expect("Failed to parse board layout"); 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;
let pacman_start = parsed_map.pacman_start;
let mut graph = Graph::new(); let mut graph = Graph::new();
let mut grid_to_node = HashMap::new(); let mut grid_to_node = HashMap::new();
@@ -61,7 +56,9 @@ impl Map {
let cell_offset = Vec2::splat(CELL_SIZE as f32 / 2.0); let cell_offset = Vec2::splat(CELL_SIZE as f32 / 2.0);
// Find a starting point for the graph generation, preferably Pac-Man's position. // Find a starting point for the graph generation, preferably Pac-Man's position.
let start_pos = pacman_start.expect("Pac-Man's starting position not found"); let start_pos = parsed_map
.pacman_start
.ok_or_else(|| MapError::InvalidConfig("Pac-Man's starting position not found".to_string()))?;
// Add the starting position to the graph/queue // Add the starting position to the graph/queue
let mut queue = VecDeque::new(); let mut queue = VecDeque::new();
@@ -114,7 +111,7 @@ impl Map {
// Connect the new node to the source node // Connect the new node to the source node
graph graph
.connect(*source_node_id, new_node_id, false, None, dir) .connect(*source_node_id, new_node_id, false, None, dir)
.expect("Failed to add edge"); .map_err(|e| MapError::InvalidConfig(format!("Failed to add edge: {e}")))?;
} }
} }
} }
@@ -129,7 +126,7 @@ impl Map {
if let Some(&neighbor_id) = grid_to_node.get(&neighbor) { if let Some(&neighbor_id) = grid_to_node.get(&neighbor) {
graph graph
.connect(node_id, neighbor_id, false, None, dir) .connect(node_id, neighbor_id, false, None, dir)
.expect("Failed to add edge"); .map_err(|e| MapError::InvalidConfig(format!("Failed to add edge: {e}")))?;
} }
} }
} }
@@ -137,7 +134,7 @@ impl Map {
// Build house structure // Build house structure
let (house_entrance_node_id, left_center_node_id, center_center_node_id, right_center_node_id) = let (house_entrance_node_id, left_center_node_id, center_center_node_id, right_center_node_id) =
Self::build_house(&mut graph, &grid_to_node, &house_door); Self::build_house(&mut graph, &grid_to_node, &house_door)?;
let start_positions = NodePositions { let start_positions = NodePositions {
pacman: grid_to_node[&start_pos], pacman: grid_to_node[&start_pos],
@@ -148,32 +145,13 @@ impl Map {
}; };
// Build tunnel connections // Build tunnel connections
Self::build_tunnels(&mut graph, &grid_to_node, &tunnel_ends); Self::build_tunnels(&mut graph, &grid_to_node, &tunnel_ends)?;
Map { Ok(Map {
current: map,
graph, graph,
grid_to_node, grid_to_node,
start_positions, start_positions,
pacman_start, })
}
}
/// Finds the starting position for a given entity ID.
///
/// # Arguments
///
/// * `entity_id` - The entity ID (0 for Pac-Man, 1-4 for ghosts)
///
/// # Returns
///
/// The starting position as a grid coordinate (`UVec2`), or `None` if not found.
pub fn find_starting_position(&self, entity_id: u8) -> Option<UVec2> {
// For now, only Pac-Man (entity_id 0) is supported
if entity_id == 0 {
return self.pacman_start.map(|pos| UVec2::new(pos.x as u32, pos.y as u32));
}
None
} }
/// Renders the map to the given canvas. /// Renders the map to the given canvas.
@@ -184,6 +162,44 @@ impl Map {
MapRenderer::render_map(canvas, atlas, map_texture); MapRenderer::render_map(canvas, atlas, map_texture);
} }
/// Generates Item entities for pellets and energizers from the parsed map.
pub fn generate_items(&self, atlas: &SpriteAtlas) -> GameResult<Vec<Item>> {
// Pre-load sprites to avoid repeated texture lookups
let pellet_sprite = SpriteAtlas::get_tile(atlas, "maze/pellet.png")
.ok_or_else(|| MapError::InvalidConfig("Pellet texture not found".to_string()))?;
let energizer_sprite = SpriteAtlas::get_tile(atlas, "maze/energizer.png")
.ok_or_else(|| MapError::InvalidConfig("Energizer texture not found".to_string()))?;
// Pre-allocate with estimated capacity (typical Pac-Man maps have ~240 pellets + 4 energizers)
let mut items = Vec::with_capacity(250);
// Parse the raw board once
let parsed_map = MapTileParser::parse_board(RAW_BOARD)?;
let map = parsed_map.tiles;
// Iterate through the map and collect items more efficiently
for (x, row) in map.iter().enumerate() {
for (y, tile) in row.iter().enumerate() {
match tile {
MapTile::Pellet | MapTile::PowerPellet => {
let grid_pos = IVec2::new(x as i32, y as i32);
if let Some(&node_id) = self.grid_to_node.get(&grid_pos) {
let (item_type, sprite) = match tile {
MapTile::Pellet => (ItemType::Pellet, Sprite::new(pellet_sprite)),
MapTile::PowerPellet => (ItemType::Energizer, Sprite::new(energizer_sprite)),
_ => unreachable!(), // We already filtered for these types
};
items.push(Item::new(node_id, item_type, sprite));
}
}
_ => {}
}
}
}
Ok(items)
}
/// Renders a debug visualization with cursor-based highlighting. /// Renders a debug visualization with cursor-based highlighting.
/// ///
/// This function provides interactive debugging by highlighting the nearest node /// This function provides interactive debugging by highlighting the nearest node
@@ -194,8 +210,8 @@ impl Map {
text_renderer: &mut crate::texture::text::TextTexture, text_renderer: &mut crate::texture::text::TextTexture,
atlas: &mut SpriteAtlas, atlas: &mut SpriteAtlas,
cursor_pos: glam::Vec2, cursor_pos: glam::Vec2,
) { ) -> GameResult<()> {
MapRenderer::debug_render_with_cursor(&self.graph, canvas, text_renderer, atlas, cursor_pos); MapRenderer::debug_render_with_cursor(&self.graph, canvas, text_renderer, atlas, cursor_pos)
} }
/// Builds the house structure in the graph. /// Builds the house structure in the graph.
@@ -203,21 +219,32 @@ impl Map {
graph: &mut Graph, graph: &mut Graph,
grid_to_node: &HashMap<IVec2, NodeId>, grid_to_node: &HashMap<IVec2, NodeId>,
house_door: &[Option<IVec2>; 2], house_door: &[Option<IVec2>; 2],
) -> (usize, usize, usize, usize) { ) -> GameResult<(usize, usize, usize, usize)> {
// Calculate the position of the house entrance node // Calculate the position of the house entrance node
let (house_entrance_node_id, house_entrance_node_position) = { let (house_entrance_node_id, house_entrance_node_position) = {
// Translate the grid positions to the actual node ids // Translate the grid positions to the actual node ids
let left_node = grid_to_node let left_node = grid_to_node
.get(&(house_door[0].expect("First house door position not acquired") + Direction::Left.as_ivec2())) .get(
.expect("Left house door node not found"); &(house_door[0]
.ok_or_else(|| MapError::InvalidConfig("First house door position not acquired".to_string()))?
+ Direction::Left.as_ivec2()),
)
.ok_or_else(|| MapError::InvalidConfig("Left house door node not found".to_string()))?;
let right_node = grid_to_node let right_node = grid_to_node
.get(&(house_door[1].expect("Second house door position not acquired") + Direction::Right.as_ivec2())) .get(
.expect("Right house door node not found"); &(house_door[1]
.ok_or_else(|| MapError::InvalidConfig("Second house door position not acquired".to_string()))?
+ Direction::Right.as_ivec2()),
)
.ok_or_else(|| MapError::InvalidConfig("Right house door node not found".to_string()))?;
// Calculate the position of the house node // Calculate the position of the house node
let (node_id, node_position) = { let (node_id, node_position) = {
let left_pos = graph.get_node(*left_node).unwrap().position; let left_pos = graph.get_node(*left_node).ok_or(MapError::NodeNotFound(*left_node))?.position;
let right_pos = graph.get_node(*right_node).unwrap().position; let right_pos = graph
.get_node(*right_node)
.ok_or(MapError::NodeNotFound(*right_node))?
.position;
let house_node = graph.add_node(Node { let house_node = graph.add_node(Node {
position: left_pos.lerp(right_pos, 0.5), position: left_pos.lerp(right_pos, 0.5),
}); });
@@ -227,16 +254,16 @@ impl Map {
// Connect the house door to the left and right nodes // Connect the house door to the left and right nodes
graph graph
.connect(node_id, *left_node, true, None, Direction::Left) .connect(node_id, *left_node, true, None, Direction::Left)
.expect("Failed to connect house door to left node"); .map_err(|e| MapError::InvalidConfig(format!("Failed to connect house door to left node: {e}")))?;
graph graph
.connect(node_id, *right_node, true, None, Direction::Right) .connect(node_id, *right_node, true, None, Direction::Right)
.expect("Failed to connect house door to right node"); .map_err(|e| MapError::InvalidConfig(format!("Failed to connect house door to right node: {e}")))?;
(node_id, node_position) (node_id, node_position)
}; };
// A helper function to help create the various 'lines' of nodes within the house // A helper function to help create the various 'lines' of nodes within the house
let create_house_line = |graph: &mut Graph, center_pos: Vec2| -> (NodeId, NodeId) { let create_house_line = |graph: &mut Graph, center_pos: Vec2| -> GameResult<(NodeId, NodeId)> {
// Place the nodes at, above, and below the center position // Place the nodes at, above, and below the center position
let center_node_id = graph.add_node(Node { position: center_pos }); let center_node_id = graph.add_node(Node { position: center_pos });
let top_node_id = graph.add_node(Node { let top_node_id = graph.add_node(Node {
@@ -249,12 +276,12 @@ impl Map {
// Connect the center node to the top and bottom nodes // Connect the center node to the top and bottom nodes
graph graph
.connect(center_node_id, top_node_id, false, None, Direction::Up) .connect(center_node_id, top_node_id, false, None, Direction::Up)
.expect("Failed to connect house line to left node"); .map_err(|e| MapError::InvalidConfig(format!("Failed to connect house line to top node: {e}")))?;
graph graph
.connect(center_node_id, bottom_node_id, false, None, Direction::Down) .connect(center_node_id, bottom_node_id, false, None, Direction::Down)
.expect("Failed to connect house line to right node"); .map_err(|e| MapError::InvalidConfig(format!("Failed to connect house line to bottom node: {e}")))?;
(center_node_id, top_node_id) Ok((center_node_id, top_node_id))
}; };
// Calculate the position of the center line's center node // Calculate the position of the center line's center node
@@ -262,7 +289,7 @@ impl Map {
house_entrance_node_position + (Direction::Down.as_ivec2() * (3 * CELL_SIZE as i32)).as_vec2(); house_entrance_node_position + (Direction::Down.as_ivec2() * (3 * CELL_SIZE as i32)).as_vec2();
// Create the center line // Create the center line
let (center_center_node_id, center_top_node_id) = create_house_line(graph, center_line_center_position); let (center_center_node_id, center_top_node_id) = create_house_line(graph, center_line_center_position)?;
// Create a ghost-only, two-way connection for the house door. // Create a ghost-only, two-way connection for the house door.
// This prevents Pac-Man from entering or exiting through the door. // This prevents Pac-Man from entering or exiting through the door.
@@ -275,7 +302,7 @@ impl Map {
Direction::Down, Direction::Down,
EdgePermissions::GhostsOnly, EdgePermissions::GhostsOnly,
) )
.expect("Failed to create ghost-only entrance to house"); .map_err(|e| MapError::InvalidConfig(format!("Failed to create ghost-only entrance to house: {e}")))?;
graph graph
.add_edge( .add_edge(
@@ -286,49 +313,54 @@ impl Map {
Direction::Up, Direction::Up,
EdgePermissions::GhostsOnly, EdgePermissions::GhostsOnly,
) )
.expect("Failed to create ghost-only exit from house"); .map_err(|e| MapError::InvalidConfig(format!("Failed to create ghost-only exit from house: {e}")))?;
// Create the left line // Create the left line
let (left_center_node_id, _) = create_house_line( let (left_center_node_id, _) = create_house_line(
graph, graph,
center_line_center_position + (Direction::Left.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(), center_line_center_position + (Direction::Left.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
); )?;
// Create the right line // Create the right line
let (right_center_node_id, _) = create_house_line( let (right_center_node_id, _) = create_house_line(
graph, graph,
center_line_center_position + (Direction::Right.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(), center_line_center_position + (Direction::Right.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
); )?;
debug!("Left center node id: {left_center_node_id}"); debug!("Left center node id: {left_center_node_id}");
// Connect the center line to the left and right lines // Connect the center line to the left and right lines
graph graph
.connect(center_center_node_id, left_center_node_id, false, None, Direction::Left) .connect(center_center_node_id, left_center_node_id, false, None, Direction::Left)
.expect("Failed to connect house entrance to left top line"); .map_err(|e| MapError::InvalidConfig(format!("Failed to connect house entrance to left top line: {e}")))?;
graph graph
.connect(center_center_node_id, right_center_node_id, false, None, Direction::Right) .connect(center_center_node_id, right_center_node_id, false, None, Direction::Right)
.expect("Failed to connect house entrance to right top line"); .map_err(|e| MapError::InvalidConfig(format!("Failed to connect house entrance to right top line: {e}")))?;
debug!("House entrance node id: {house_entrance_node_id}"); debug!("House entrance node id: {house_entrance_node_id}");
( Ok((
house_entrance_node_id, house_entrance_node_id,
left_center_node_id, left_center_node_id,
center_center_node_id, center_center_node_id,
right_center_node_id, right_center_node_id,
) ))
} }
/// Builds the tunnel connections in the graph. /// Builds the tunnel connections in the graph.
fn build_tunnels(graph: &mut Graph, grid_to_node: &HashMap<IVec2, NodeId>, tunnel_ends: &[Option<IVec2>; 2]) { fn build_tunnels(
graph: &mut Graph,
grid_to_node: &HashMap<IVec2, NodeId>,
tunnel_ends: &[Option<IVec2>; 2],
) -> GameResult<()> {
// Create the hidden tunnel nodes // Create the hidden tunnel nodes
let left_tunnel_hidden_node_id = { let left_tunnel_hidden_node_id = {
let left_tunnel_entrance_node_id = grid_to_node[&tunnel_ends[0].expect("Left tunnel end not found")]; let left_tunnel_entrance_node_id =
grid_to_node[&tunnel_ends[0].ok_or_else(|| MapError::InvalidConfig("Left tunnel end not found".to_string()))?];
let left_tunnel_entrance_node = graph let left_tunnel_entrance_node = graph
.get_node(left_tunnel_entrance_node_id) .get_node(left_tunnel_entrance_node_id)
.expect("Left tunnel entrance node not found"); .ok_or_else(|| MapError::InvalidConfig("Left tunnel entrance node not found".to_string()))?;
graph graph
.add_connected( .add_connected(
@@ -339,15 +371,21 @@ impl Map {
+ (Direction::Left.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(), + (Direction::Left.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
}, },
) )
.expect("Failed to connect left tunnel entrance to left tunnel hidden node") .map_err(|e| {
MapError::InvalidConfig(format!(
"Failed to connect left tunnel entrance to left tunnel hidden node: {}",
e
))
})?
}; };
// Create the right tunnel nodes // Create the right tunnel nodes
let right_tunnel_hidden_node_id = { let right_tunnel_hidden_node_id = {
let right_tunnel_entrance_node_id = grid_to_node[&tunnel_ends[1].expect("Right tunnel end not found")]; let right_tunnel_entrance_node_id =
grid_to_node[&tunnel_ends[1].ok_or_else(|| MapError::InvalidConfig("Right tunnel end not found".to_string()))?];
let right_tunnel_entrance_node = graph let right_tunnel_entrance_node = graph
.get_node(right_tunnel_entrance_node_id) .get_node(right_tunnel_entrance_node_id)
.expect("Right tunnel entrance node not found"); .ok_or_else(|| MapError::InvalidConfig("Right tunnel entrance node not found".to_string()))?;
graph graph
.add_connected( .add_connected(
@@ -358,7 +396,12 @@ impl Map {
+ (Direction::Right.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(), + (Direction::Right.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
}, },
) )
.expect("Failed to connect right tunnel entrance to right tunnel hidden node") .map_err(|e| {
MapError::InvalidConfig(format!(
"Failed to connect right tunnel entrance to right tunnel hidden node: {}",
e
))
})?
}; };
// Connect the left tunnel hidden node to the right tunnel hidden node // Connect the left tunnel hidden node to the right tunnel hidden node
@@ -370,6 +413,13 @@ impl Map {
Some(0.0), Some(0.0),
Direction::Left, Direction::Left,
) )
.expect("Failed to connect left tunnel hidden node to right tunnel hidden node"); .map_err(|e| {
MapError::InvalidConfig(format!(
"Failed to connect left tunnel hidden node to right tunnel hidden node: {}",
e
))
})?;
Ok(())
} }
} }

View File

@@ -1,17 +1,8 @@
//! Map parsing functionality for converting raw board layouts into structured data. //! Map parsing functionality for converting raw board layouts into structured data.
use crate::constants::{MapTile, BOARD_CELL_SIZE}; use crate::constants::{MapTile, BOARD_CELL_SIZE};
use crate::error::ParseError;
use glam::IVec2; use glam::IVec2;
use thiserror::Error;
/// Error type for map parsing operations.
#[derive(Debug, Error)]
pub enum ParseError {
#[error("Unknown character in board: {0}")]
UnknownCharacter(char),
#[error("House door must have exactly 2 positions, found {0}")]
InvalidHouseDoorCount(usize),
}
/// Represents the parsed data from a raw board layout. /// Represents the parsed data from a raw board layout.
#[derive(Debug)] #[derive(Debug)]
@@ -67,6 +58,25 @@ impl MapTileParser {
/// Returns an error if the board contains unknown characters or if the house door /// Returns an error if the board contains unknown characters or if the house door
/// is not properly defined by exactly two '=' characters. /// is not properly defined by exactly two '=' characters.
pub fn parse_board(raw_board: [&str; BOARD_CELL_SIZE.y as usize]) -> Result<ParsedMap, ParseError> { pub fn parse_board(raw_board: [&str; BOARD_CELL_SIZE.y as usize]) -> Result<ParsedMap, ParseError> {
// Validate board dimensions
if raw_board.len() != BOARD_CELL_SIZE.y as usize {
return Err(ParseError::ParseFailed(format!(
"Invalid board height: expected {}, got {}",
BOARD_CELL_SIZE.y,
raw_board.len()
)));
}
for (i, line) in raw_board.iter().enumerate() {
if line.len() != BOARD_CELL_SIZE.x as usize {
return Err(ParseError::ParseFailed(format!(
"Invalid board width at line {}: expected {}, got {}",
i,
BOARD_CELL_SIZE.x,
line.len()
)));
}
}
let mut tiles = [[MapTile::Empty; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize]; let mut tiles = [[MapTile::Empty; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize];
let mut house_door = [None; 2]; let mut house_door = [None; 2];
let mut tunnel_ends = [None; 2]; let mut tunnel_ends = [None; 2];

View File

@@ -7,6 +7,8 @@ use sdl2::pixels::Color;
use sdl2::rect::{Point, Rect}; use sdl2::rect::{Point, Rect};
use sdl2::render::{Canvas, RenderTarget}; use sdl2::render::{Canvas, RenderTarget};
use crate::error::{EntityError, GameError, GameResult};
/// Handles rendering operations for the map. /// Handles rendering operations for the map.
pub struct MapRenderer; pub struct MapRenderer;
@@ -22,7 +24,9 @@ impl MapRenderer {
crate::constants::BOARD_PIXEL_SIZE.x, crate::constants::BOARD_PIXEL_SIZE.x,
crate::constants::BOARD_PIXEL_SIZE.y, crate::constants::BOARD_PIXEL_SIZE.y,
); );
let _ = map_texture.render(canvas, atlas, dest); if let Err(e) = map_texture.render(canvas, atlas, dest) {
tracing::error!("Failed to render map: {}", e);
}
} }
/// Renders a debug visualization with cursor-based highlighting. /// Renders a debug visualization with cursor-based highlighting.
@@ -35,55 +39,67 @@ impl MapRenderer {
text_renderer: &mut TextTexture, text_renderer: &mut TextTexture,
atlas: &mut SpriteAtlas, atlas: &mut SpriteAtlas,
cursor_pos: Vec2, cursor_pos: Vec2,
) { ) -> GameResult<()> {
// Find the nearest node to the cursor // Find the nearest node to the cursor
let nearest_node = Self::find_nearest_node(graph, cursor_pos); let nearest_node = Self::find_nearest_node(graph, cursor_pos);
// Draw all connections in blue // Draw all connections in blue
canvas.set_draw_color(Color::RGB(0, 0, 128)); // Dark blue for regular connections canvas.set_draw_color(Color::RGB(0, 0, 128)); // Dark blue for regular connections
for i in 0..graph.node_count() { for i in 0..graph.node_count() {
let node = graph.get_node(i).unwrap(); let node = graph.get_node(i).ok_or(GameError::Entity(EntityError::NodeNotFound(i)))?;
let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2(); let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
for edge in graph.adjacency_list[i].edges() { for edge in graph.adjacency_list[i].edges() {
let end_pos = graph.get_node(edge.target).unwrap().position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2(); let end_pos = graph
.get_node(edge.target)
.ok_or(GameError::Entity(EntityError::NodeNotFound(edge.target)))?
.position
+ crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
canvas canvas
.draw_line((pos.x as i32, pos.y as i32), (end_pos.x as i32, end_pos.y as i32)) .draw_line((pos.x as i32, pos.y as i32), (end_pos.x as i32, end_pos.y as i32))
.unwrap(); .map_err(|e| GameError::Sdl(e.to_string()))?;
} }
} }
// Draw all nodes in green // Draw all nodes in green
canvas.set_draw_color(Color::RGB(0, 128, 0)); // Dark green for regular nodes canvas.set_draw_color(Color::RGB(0, 128, 0)); // Dark green for regular nodes
for i in 0..graph.node_count() { for i in 0..graph.node_count() {
let node = graph.get_node(i).unwrap(); let node = graph.get_node(i).ok_or(GameError::Entity(EntityError::NodeNotFound(i)))?;
let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2(); let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
canvas canvas
.fill_rect(Rect::new(0, 0, 3, 3).centered_on(Point::new(pos.x as i32, pos.y as i32))) .fill_rect(Rect::new(0, 0, 3, 3).centered_on(Point::new(pos.x as i32, pos.y as i32)))
.unwrap(); .map_err(|e| GameError::Sdl(e.to_string()))?;
} }
// Highlight connections from the nearest node in bright blue // Highlight connections from the nearest node in bright blue
if let Some(nearest_id) = nearest_node { if let Some(nearest_id) = nearest_node {
let nearest_pos = graph.get_node(nearest_id).unwrap().position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2(); let nearest_pos = graph
.get_node(nearest_id)
.ok_or(GameError::Entity(EntityError::NodeNotFound(nearest_id)))?
.position
+ crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
canvas.set_draw_color(Color::RGB(0, 255, 255)); // Bright cyan for highlighted connections canvas.set_draw_color(Color::RGB(0, 255, 255)); // Bright cyan for highlighted connections
for edge in graph.adjacency_list[nearest_id].edges() { for edge in graph.adjacency_list[nearest_id].edges() {
let end_pos = graph.get_node(edge.target).unwrap().position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2(); let end_pos = graph
.get_node(edge.target)
.ok_or(GameError::Entity(EntityError::NodeNotFound(edge.target)))?
.position
+ crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
canvas canvas
.draw_line( .draw_line(
(nearest_pos.x as i32, nearest_pos.y as i32), (nearest_pos.x as i32, nearest_pos.y as i32),
(end_pos.x as i32, end_pos.y as i32), (end_pos.x as i32, end_pos.y as i32),
) )
.unwrap(); .map_err(|e| GameError::Sdl(e.to_string()))?;
} }
// Highlight the nearest node in bright green // Highlight the nearest node in bright green
canvas.set_draw_color(Color::RGB(0, 255, 0)); // Bright green for highlighted node canvas.set_draw_color(Color::RGB(0, 255, 0)); // Bright green for highlighted node
canvas canvas
.fill_rect(Rect::new(0, 0, 5, 5).centered_on(Point::new(nearest_pos.x as i32, nearest_pos.y as i32))) .fill_rect(Rect::new(0, 0, 5, 5).centered_on(Point::new(nearest_pos.x as i32, nearest_pos.y as i32)))
.unwrap(); .map_err(|e| GameError::Sdl(e.to_string()))?;
// Draw node ID text (small, offset to top right) // Draw node ID text (small, offset to top right)
text_renderer.set_scale(0.5); // Small text text_renderer.set_scale(0.5); // Small text
@@ -92,8 +108,12 @@ impl MapRenderer {
(nearest_pos.x + 4.0) as u32, // Offset to the right (nearest_pos.x + 4.0) as u32, // Offset to the right
(nearest_pos.y - 6.0) as u32, // Offset to the top (nearest_pos.y - 6.0) as u32, // Offset to the top
); );
let _ = text_renderer.render(canvas, atlas, &id_text, text_pos); if let Err(e) = text_renderer.render(canvas, atlas, &id_text, text_pos) {
tracing::error!("Failed to render node ID text: {}", e);
}
} }
Ok(())
} }
/// Finds the nearest node to the given cursor position. /// Finds the nearest node to the given cursor position.
@@ -102,13 +122,14 @@ impl MapRenderer {
let mut nearest_distance = f32::INFINITY; let mut nearest_distance = f32::INFINITY;
for i in 0..graph.node_count() { for i in 0..graph.node_count() {
let node = graph.get_node(i).unwrap(); if let Some(node) = graph.get_node(i) {
let node_pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2(); let node_pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
let distance = cursor_pos.distance(node_pos); let distance = cursor_pos.distance(node_pos);
if distance < nearest_distance { if distance < nearest_distance {
nearest_distance = distance; nearest_distance = distance;
nearest_id = Some(i); nearest_id = Some(i);
}
} }
} }

View File

@@ -3,8 +3,9 @@
use std::borrow::Cow; use std::borrow::Cow;
use std::time::Duration; use std::time::Duration;
use crate::asset::{Asset, AssetError}; use crate::asset::Asset;
use crate::platform::{Platform, PlatformError}; use crate::error::{AssetError, PlatformError};
use crate::platform::Platform;
/// Desktop platform implementation. /// Desktop platform implementation.
pub struct DesktopPlatform; pub struct DesktopPlatform;

View File

@@ -3,8 +3,9 @@
use std::borrow::Cow; use std::borrow::Cow;
use std::time::Duration; use std::time::Duration;
use crate::asset::{Asset, AssetError}; use crate::asset::Asset;
use crate::platform::{Platform, PlatformError}; use crate::error::{AssetError, PlatformError};
use crate::platform::Platform;
/// Emscripten platform implementation. /// Emscripten platform implementation.
pub struct EmscriptenPlatform; pub struct EmscriptenPlatform;

View File

@@ -1,10 +1,10 @@
//! Platform abstraction layer for cross-platform functionality. //! Platform abstraction layer for cross-platform functionality.
use crate::asset::Asset;
use crate::error::{AssetError, PlatformError};
use std::borrow::Cow; use std::borrow::Cow;
use std::time::Duration; use std::time::Duration;
use crate::asset::{Asset, AssetError};
pub mod desktop; pub mod desktop;
pub mod emscripten; pub mod emscripten;
@@ -30,16 +30,6 @@ pub trait Platform {
fn get_asset_bytes(&self, asset: Asset) -> Result<Cow<'static, [u8]>, AssetError>; fn get_asset_bytes(&self, asset: Asset) -> Result<Cow<'static, [u8]>, AssetError>;
} }
/// Platform-specific errors.
#[derive(Debug, thiserror::Error)]
#[allow(dead_code)]
pub enum PlatformError {
#[error("Console initialization failed: {0}")]
ConsoleInit(String),
#[error("Platform-specific error: {0}")]
Other(String),
}
/// Get the current platform implementation. /// Get the current platform implementation.
#[allow(dead_code)] #[allow(dead_code)]
pub fn get_platform() -> &'static dyn Platform { pub fn get_platform() -> &'static dyn Platform {

View File

@@ -1,16 +1,9 @@
use anyhow::Result;
use sdl2::rect::Rect; use sdl2::rect::Rect;
use sdl2::render::{Canvas, RenderTarget}; use sdl2::render::{Canvas, RenderTarget};
use thiserror::Error;
use crate::error::{AnimatedTextureError, GameError, GameResult, TextureError};
use crate::texture::sprite::{AtlasTile, SpriteAtlas}; use crate::texture::sprite::{AtlasTile, SpriteAtlas};
#[derive(Error, Debug)]
pub enum AnimatedTextureError {
#[error("Frame duration must be positive, got {0}")]
InvalidFrameDuration(f32),
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AnimatedTexture { pub struct AnimatedTexture {
tiles: Vec<AtlasTile>, tiles: Vec<AtlasTile>,
@@ -20,9 +13,11 @@ pub struct AnimatedTexture {
} }
impl AnimatedTexture { impl AnimatedTexture {
pub fn new(tiles: Vec<AtlasTile>, frame_duration: f32) -> Result<Self, AnimatedTextureError> { pub fn new(tiles: Vec<AtlasTile>, frame_duration: f32) -> GameResult<Self> {
if frame_duration <= 0.0 { if frame_duration <= 0.0 {
return Err(AnimatedTextureError::InvalidFrameDuration(frame_duration)); return Err(GameError::Texture(TextureError::Animated(
AnimatedTextureError::InvalidFrameDuration(frame_duration),
)));
} }
Ok(Self { Ok(Self {
@@ -45,9 +40,10 @@ impl AnimatedTexture {
&self.tiles[self.current_frame] &self.tiles[self.current_frame]
} }
pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, dest: Rect) -> Result<()> { pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, dest: Rect) -> GameResult<()> {
let mut tile = *self.current_tile(); let mut tile = *self.current_tile();
tile.render(canvas, atlas, dest) tile.render(canvas, atlas, dest)?;
Ok(())
} }
/// Returns the current frame index. /// Returns the current frame index.

View File

@@ -1,8 +1,8 @@
use anyhow::Result;
use sdl2::rect::Rect; use sdl2::rect::Rect;
use sdl2::render::{Canvas, RenderTarget}; use sdl2::render::{Canvas, RenderTarget};
use crate::entity::direction::Direction; use crate::entity::direction::Direction;
use crate::error::GameResult;
use crate::texture::animated::AnimatedTexture; use crate::texture::animated::AnimatedTexture;
use crate::texture::sprite::SpriteAtlas; use crate::texture::sprite::SpriteAtlas;
@@ -32,7 +32,7 @@ impl DirectionalAnimatedTexture {
atlas: &mut SpriteAtlas, atlas: &mut SpriteAtlas,
dest: Rect, dest: Rect,
direction: Direction, direction: Direction,
) -> Result<()> { ) -> GameResult<()> {
if let Some(texture) = &self.textures[direction.as_usize()] { if let Some(texture) = &self.textures[direction.as_usize()] {
texture.render(canvas, atlas, dest) texture.render(canvas, atlas, dest)
} else { } else {
@@ -46,7 +46,7 @@ impl DirectionalAnimatedTexture {
atlas: &mut SpriteAtlas, atlas: &mut SpriteAtlas,
dest: Rect, dest: Rect,
direction: Direction, direction: Direction,
) -> Result<()> { ) -> GameResult<()> {
if let Some(texture) = &self.stopped_textures[direction.as_usize()] { if let Some(texture) = &self.stopped_textures[direction.as_usize()] {
texture.render(canvas, atlas, dest) texture.render(canvas, atlas, dest)
} else { } else {

View File

@@ -6,6 +6,35 @@ use sdl2::render::{Canvas, RenderTarget, Texture};
use serde::Deserialize; use serde::Deserialize;
use std::collections::HashMap; use std::collections::HashMap;
use crate::error::TextureError;
/// A simple sprite for stationary items like pellets and energizers.
#[derive(Clone, Debug)]
pub struct Sprite {
pub atlas_tile: AtlasTile,
}
impl Sprite {
pub fn new(atlas_tile: AtlasTile) -> Self {
Self { atlas_tile }
}
pub fn render<C: RenderTarget>(
&self,
canvas: &mut Canvas<C>,
atlas: &mut SpriteAtlas,
position: glam::Vec2,
) -> Result<(), TextureError> {
let dest = crate::helpers::centered_with_size(
glam::IVec2::new(position.x as i32, position.y as i32),
glam::UVec2::new(self.atlas_tile.size.x as u32, self.atlas_tile.size.y as u32),
);
let mut tile = self.atlas_tile;
tile.render(canvas, atlas, dest)?;
Ok(())
}
}
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub struct AtlasMapper { pub struct AtlasMapper {
pub frames: HashMap<String, MapperFrame>, pub frames: HashMap<String, MapperFrame>,
@@ -27,9 +56,15 @@ pub struct AtlasTile {
} }
impl AtlasTile { impl AtlasTile {
pub fn render<C: RenderTarget>(&mut self, canvas: &mut Canvas<C>, atlas: &mut SpriteAtlas, dest: Rect) -> Result<()> { pub fn render<C: RenderTarget>(
&mut self,
canvas: &mut Canvas<C>,
atlas: &mut SpriteAtlas,
dest: Rect,
) -> Result<(), TextureError> {
let color = self.color.unwrap_or(atlas.default_color.unwrap_or(Color::WHITE)); let color = self.color.unwrap_or(atlas.default_color.unwrap_or(Color::WHITE));
self.render_with_color(canvas, atlas, dest, color) self.render_with_color(canvas, atlas, dest, color)?;
Ok(())
} }
pub fn render_with_color<C: RenderTarget>( pub fn render_with_color<C: RenderTarget>(
@@ -38,7 +73,7 @@ impl AtlasTile {
atlas: &mut SpriteAtlas, atlas: &mut SpriteAtlas,
dest: Rect, dest: Rect,
color: Color, color: Color,
) -> Result<()> { ) -> Result<(), TextureError> {
let src = Rect::new(self.pos.x as i32, self.pos.y as i32, self.size.x as u32, self.size.y as u32); let src = Rect::new(self.pos.x as i32, self.pos.y as i32, self.size.x as u32, self.size.y as u32);
if atlas.last_modulation != Some(color) { if atlas.last_modulation != Some(color) {
@@ -46,7 +81,7 @@ impl AtlasTile {
atlas.last_modulation = Some(color); atlas.last_modulation = Some(color);
} }
canvas.copy(&atlas.texture, src, dest).map_err(anyhow::Error::msg)?; canvas.copy(&atlas.texture, src, dest).map_err(TextureError::RenderFailed)?;
Ok(()) Ok(())
} }
@@ -117,20 +152,3 @@ impl SpriteAtlas {
self.default_color self.default_color
} }
} }
/// Converts a `Texture` to a `Texture<'static>` using transmute.
///
/// # Safety
///
/// This function is unsafe because it uses `std::mem::transmute` to change the lifetime
/// of the texture from the original lifetime to `'static`. The caller must ensure that:
///
/// - The original `Texture` will live for the entire duration of the program
/// - No references to the original texture exist that could become invalid
/// - The texture is not dropped while still being used as a `'static` reference
///
/// This is typically used when you have a texture that you know will live for the entire
/// program duration and need to store it in a structure that requires a `'static` lifetime.
pub unsafe fn texture_to_static(texture: Texture) -> Texture<'static> {
std::mem::transmute(texture)
}

View File

@@ -1,5 +1,6 @@
use glam::U16Vec2; use glam::U16Vec2;
use pacman::texture::animated::{AnimatedTexture, AnimatedTextureError}; use pacman::error::{AnimatedTextureError, GameError, TextureError};
use pacman::texture::animated::AnimatedTexture;
use pacman::texture::sprite::AtlasTile; use pacman::texture::sprite::AtlasTile;
use sdl2::pixels::Color; use sdl2::pixels::Color;
@@ -17,12 +18,12 @@ fn test_animated_texture_creation_errors() {
assert!(matches!( assert!(matches!(
AnimatedTexture::new(tiles.clone(), 0.0).unwrap_err(), AnimatedTexture::new(tiles.clone(), 0.0).unwrap_err(),
AnimatedTextureError::InvalidFrameDuration(0.0) GameError::Texture(TextureError::Animated(AnimatedTextureError::InvalidFrameDuration(0.0)))
)); ));
assert!(matches!( assert!(matches!(
AnimatedTexture::new(tiles, -0.1).unwrap_err(), AnimatedTexture::new(tiles, -0.1).unwrap_err(),
AnimatedTextureError::InvalidFrameDuration(-0.1) GameError::Texture(TextureError::Animated(AnimatedTextureError::InvalidFrameDuration(-0.1)))
)); ));
} }

14
tests/asset.rs Normal file
View File

@@ -0,0 +1,14 @@
use pacman::asset::Asset;
use std::path::Path;
use strum::IntoEnumIterator;
#[test]
fn test_asset_paths_valid() {
let base_path = Path::new("assets/game/");
for asset in Asset::iter() {
let path = base_path.join(asset.path());
assert!(path.exists(), "Asset path does not exist: {:?}", path);
assert!(path.is_file(), "Asset path is not a file: {:?}", path);
}
}

View File

@@ -16,16 +16,16 @@ fn test_blinking_texture() {
let tile = mock_atlas_tile(1); let tile = mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 0.5); let mut texture = BlinkingTexture::new(tile, 0.5);
assert_eq!(texture.is_on(), true); assert!(texture.is_on());
texture.tick(0.5); texture.tick(0.5);
assert_eq!(texture.is_on(), false); assert!(!texture.is_on());
texture.tick(0.5); texture.tick(0.5);
assert_eq!(texture.is_on(), true); assert!(texture.is_on());
texture.tick(0.5); texture.tick(0.5);
assert_eq!(texture.is_on(), false); assert!(!texture.is_on());
} }
#[test] #[test]
@@ -34,7 +34,7 @@ fn test_blinking_texture_partial_duration() {
let mut texture = BlinkingTexture::new(tile, 0.5); let mut texture = BlinkingTexture::new(tile, 0.5);
texture.tick(0.625); texture.tick(0.625);
assert_eq!(texture.is_on(), false); assert!(!texture.is_on());
assert_eq!(texture.time_bank(), 0.125); assert_eq!(texture.time_bank(), 0.125);
} }
@@ -44,6 +44,6 @@ fn test_blinking_texture_negative_time() {
let mut texture = BlinkingTexture::new(tile, 0.5); let mut texture = BlinkingTexture::new(tile, 0.5);
texture.tick(-0.1); texture.tick(-0.1);
assert_eq!(texture.is_on(), true); assert!(texture.is_on());
assert_eq!(texture.time_bank(), -0.1); assert_eq!(texture.time_bank(), -0.1);
} }

119
tests/collision.rs Normal file
View File

@@ -0,0 +1,119 @@
use pacman::entity::collision::{Collidable, CollisionSystem};
use pacman::entity::traversal::Position;
struct MockCollidable {
pos: Position,
}
impl Collidable for MockCollidable {
fn position(&self) -> Position {
self.pos
}
}
#[test]
fn test_is_colliding_with() {
let entity1 = MockCollidable {
pos: Position::AtNode(1),
};
let entity2 = MockCollidable {
pos: Position::AtNode(1),
};
let entity3 = MockCollidable {
pos: Position::AtNode(2),
};
let entity4 = MockCollidable {
pos: Position::BetweenNodes {
from: 1,
to: 2,
traversed: 0.5,
},
};
assert!(entity1.is_colliding_with(&entity2));
assert!(!entity1.is_colliding_with(&entity3));
assert!(entity1.is_colliding_with(&entity4));
assert!(entity3.is_colliding_with(&entity4));
}
#[test]
fn test_collision_system_register_and_query() {
let mut collision_system = CollisionSystem::default();
let pos1 = Position::AtNode(1);
let entity1 = collision_system.register_entity(pos1);
let pos2 = Position::BetweenNodes {
from: 1,
to: 2,
traversed: 0.5,
};
let entity2 = collision_system.register_entity(pos2);
let pos3 = Position::AtNode(3);
let entity3 = collision_system.register_entity(pos3);
// Test entities_at_node
assert_eq!(collision_system.entities_at_node(1), &[entity1, entity2]);
assert_eq!(collision_system.entities_at_node(2), &[entity2]);
assert_eq!(collision_system.entities_at_node(3), &[entity3]);
assert_eq!(collision_system.entities_at_node(4), &[] as &[u32]);
// Test potential_collisions
let mut collisions1 = collision_system.potential_collisions(&pos1);
collisions1.sort_unstable();
assert_eq!(collisions1, vec![entity1, entity2]);
let mut collisions2 = collision_system.potential_collisions(&pos2);
collisions2.sort_unstable();
assert_eq!(collisions2, vec![entity1, entity2]);
let mut collisions3 = collision_system.potential_collisions(&pos3);
collisions3.sort_unstable();
assert_eq!(collisions3, vec![entity3]);
}
#[test]
fn test_collision_system_update() {
let mut collision_system = CollisionSystem::default();
let entity1 = collision_system.register_entity(Position::AtNode(1));
assert_eq!(collision_system.entities_at_node(1), &[entity1]);
assert_eq!(collision_system.entities_at_node(2), &[] as &[u32]);
collision_system.update_position(entity1, Position::AtNode(2));
assert_eq!(collision_system.entities_at_node(1), &[] as &[u32]);
assert_eq!(collision_system.entities_at_node(2), &[entity1]);
collision_system.update_position(
entity1,
Position::BetweenNodes {
from: 2,
to: 3,
traversed: 0.1,
},
);
assert_eq!(collision_system.entities_at_node(1), &[] as &[u32]);
assert_eq!(collision_system.entities_at_node(2), &[entity1]);
assert_eq!(collision_system.entities_at_node(3), &[entity1]);
}
#[test]
fn test_collision_system_remove() {
let mut collision_system = CollisionSystem::default();
let entity1 = collision_system.register_entity(Position::AtNode(1));
let entity2 = collision_system.register_entity(Position::AtNode(1));
assert_eq!(collision_system.entities_at_node(1), &[entity1, entity2]);
collision_system.remove_entity(entity1);
assert_eq!(collision_system.entities_at_node(1), &[entity2]);
collision_system.remove_entity(entity2);
assert_eq!(collision_system.entities_at_node(1), &[] as &[u32]);
}

View File

@@ -52,3 +52,26 @@ fn test_directional_texture_all_directions() {
assert!(texture.has_direction(*direction)); assert!(texture.has_direction(*direction));
} }
} }
#[test]
fn test_directional_texture_stopped() {
let mut stopped_textures = [None, None, None, None];
stopped_textures[Direction::Up.as_usize()] = Some(mock_animated_texture(1));
let texture = DirectionalAnimatedTexture::new([None, None, None, None], stopped_textures);
assert_eq!(texture.stopped_texture_count(), 1);
assert!(texture.has_stopped_direction(Direction::Up));
assert!(!texture.has_stopped_direction(Direction::Down));
}
#[test]
fn test_directional_texture_tick() {
let mut textures = [None, None, None, None];
textures[Direction::Up.as_usize()] = Some(mock_animated_texture(1));
let mut texture = DirectionalAnimatedTexture::new(textures, [None, None, None, None]);
// This is a bit of a placeholder, since we can't inspect the inner state easily.
// We're just ensuring the tick method runs without panicking.
texture.tick(0.1);
}

View File

@@ -1,21 +1,13 @@
use pacman::constants::RAW_BOARD; use pacman::constants::RAW_BOARD;
use pacman::map::Map; use pacman::map::Map;
mod collision;
mod item;
#[test] #[test]
fn test_game_map_creation() { fn test_game_map_creation() {
let map = Map::new(RAW_BOARD); let map = Map::new(RAW_BOARD).unwrap();
assert!(map.graph.node_count() > 0); assert!(map.graph.node_count() > 0);
assert!(!map.grid_to_node.is_empty()); assert!(!map.grid_to_node.is_empty());
// Should find Pac-Man's starting position
let pacman_pos = map.find_starting_position(0);
assert!(pacman_pos.is_some());
}
#[test]
fn test_game_score_initialization() {
// This would require creating a full Game instance, but we can test the concept
let map = Map::new(RAW_BOARD);
assert!(map.find_starting_position(0).is_some());
} }

View File

@@ -41,7 +41,7 @@ fn test_ghost_creation() {
let graph = Graph::new(); let graph = Graph::new();
let atlas = create_test_atlas(); let atlas = create_test_atlas();
let ghost = Ghost::new(&graph, 0, GhostType::Blinky, &atlas); let ghost = Ghost::new(&graph, 0, GhostType::Blinky, &atlas).unwrap();
assert_eq!(ghost.ghost_type, GhostType::Blinky); assert_eq!(ghost.ghost_type, GhostType::Blinky);
assert_eq!(ghost.traverser.position.from_node_id(), 0); assert_eq!(ghost.traverser.position.from_node_id(), 0);

View File

@@ -101,7 +101,7 @@ fn test_traverser_advance() {
let graph = create_test_graph(); let graph = create_test_graph();
let mut traverser = Traverser::new(&graph, 0, Direction::Right, &|_| true); let mut traverser = Traverser::new(&graph, 0, Direction::Right, &|_| true);
traverser.advance(&graph, 5.0, &|_| true); traverser.advance(&graph, 5.0, &|_| true).unwrap();
match traverser.position { match traverser.position {
Position::BetweenNodes { from, to, traversed } => { Position::BetweenNodes { from, to, traversed } => {
@@ -112,7 +112,7 @@ fn test_traverser_advance() {
_ => panic!("Expected to be between nodes"), _ => panic!("Expected to be between nodes"),
} }
traverser.advance(&graph, 3.0, &|_| true); traverser.advance(&graph, 3.0, &|_| true).unwrap();
match traverser.position { match traverser.position {
Position::BetweenNodes { from, to, traversed } => { Position::BetweenNodes { from, to, traversed } => {
@@ -143,7 +143,9 @@ fn test_traverser_with_permissions() {
matches!(edge.permissions, EdgePermissions::All) matches!(edge.permissions, EdgePermissions::All)
}); });
traverser.advance(&graph, 5.0, &|edge| matches!(edge.permissions, EdgePermissions::All)); traverser
.advance(&graph, 5.0, &|edge| matches!(edge.permissions, EdgePermissions::All))
.unwrap();
// Should still be at the node since it can't traverse // Should still be at the node since it can't traverse
assert!(traverser.position.is_at_node()); assert!(traverser.position.is_at_node());

53
tests/item.rs Normal file
View File

@@ -0,0 +1,53 @@
use glam::U16Vec2;
use pacman::{
entity::{
collision::Collidable,
item::{FruitKind, Item, ItemType},
},
texture::sprite::{AtlasTile, Sprite},
};
use strum::{EnumCount, IntoEnumIterator};
#[test]
fn test_item_type_get_score() {
assert_eq!(ItemType::Pellet.get_score(), 10);
assert_eq!(ItemType::Energizer.get_score(), 50);
let fruit = ItemType::Fruit { kind: FruitKind::Apple };
assert_eq!(fruit.get_score(), 100);
}
#[test]
fn test_fruit_kind_increasing_score() {
// Build a list of fruit kinds, sorted by their index
let mut kinds = FruitKind::iter()
.map(|kind| (kind.index(), kind.get_score()))
.collect::<Vec<_>>();
kinds.sort_unstable_by_key(|(index, _)| *index);
assert_eq!(kinds.len(), FruitKind::COUNT);
// Check that the score increases as expected
for window in kinds.windows(2) {
let ((_, prev), (_, next)) = (window[0], window[1]);
assert!(prev < next, "Fruits should have increasing scores, but {prev:?} < {next:?}");
}
}
#[test]
fn test_item_creation_and_collection() {
let atlas_tile = AtlasTile {
pos: U16Vec2::new(0, 0),
size: U16Vec2::new(16, 16),
color: None,
};
let sprite = Sprite::new(atlas_tile);
let mut item = Item::new(0, ItemType::Pellet, sprite);
assert!(!item.is_collected());
assert_eq!(item.get_score(), 10);
assert_eq!(item.position().from_node_id(), 0);
item.collect();
assert!(item.is_collected());
}

View File

@@ -1,47 +1,11 @@
use glam::Vec2; use glam::Vec2;
use pacman::constants::{BOARD_CELL_SIZE, CELL_SIZE}; use pacman::constants::{CELL_SIZE, RAW_BOARD};
use pacman::map::Map; use pacman::map::Map;
use sdl2::render::Texture;
fn create_minimal_test_board() -> [&'static str; BOARD_CELL_SIZE.y as usize] {
let mut board = [""; BOARD_CELL_SIZE.y as usize];
board[0] = "############################";
board[1] = "#............##............#";
board[2] = "#.####.#####.##.#####.####.#";
board[3] = "#o####.#####.##.#####.####o#";
board[4] = "#.####.#####.##.#####.####.#";
board[5] = "#..........................#";
board[6] = "#.####.##.########.##.####.#";
board[7] = "#.####.##.########.##.####.#";
board[8] = "#......##....##....##......#";
board[9] = "######.##### ## #####.######";
board[10] = " #.##### ## #####.# ";
board[11] = " #.## == ##.# ";
board[12] = " #.## ######## ##.# ";
board[13] = "######.## ######## ##.######";
board[14] = "T . ######## . T";
board[15] = "######.## ######## ##.######";
board[16] = " #.## ######## ##.# ";
board[17] = " #.## ##.# ";
board[18] = " #.## ######## ##.# ";
board[19] = "######.## ######## ##.######";
board[20] = "#............##............#";
board[21] = "#.####.#####.##.#####.####.#";
board[22] = "#.####.#####.##.#####.####.#";
board[23] = "#o..##.......X .......##..o#";
board[24] = "###.##.##.########.##.##.###";
board[25] = "###.##.##.########.##.##.###";
board[26] = "#......##....##....##......#";
board[27] = "#.##########.##.##########.#";
board[28] = "#.##########.##.##########.#";
board[29] = "#..........................#";
board[30] = "############################";
board
}
#[test] #[test]
fn test_map_creation() { fn test_map_creation() {
let board = create_minimal_test_board(); let map = Map::new(RAW_BOARD).unwrap();
let map = Map::new(board);
assert!(map.graph.node_count() > 0); assert!(map.graph.node_count() > 0);
assert!(!map.grid_to_node.is_empty()); assert!(!map.grid_to_node.is_empty());
@@ -57,24 +21,9 @@ fn test_map_creation() {
assert!(has_connections); assert!(has_connections);
} }
#[test]
fn test_map_starting_positions() {
let board = create_minimal_test_board();
let map = Map::new(board);
let pacman_pos = map.find_starting_position(0);
assert!(pacman_pos.is_some());
assert!(pacman_pos.unwrap().x < BOARD_CELL_SIZE.x);
assert!(pacman_pos.unwrap().y < BOARD_CELL_SIZE.y);
let nonexistent_pos = map.find_starting_position(99);
assert_eq!(nonexistent_pos, None);
}
#[test] #[test]
fn test_map_node_positions() { fn test_map_node_positions() {
let board = create_minimal_test_board(); let map = Map::new(RAW_BOARD).unwrap();
let map = Map::new(board);
for (grid_pos, &node_id) in &map.grid_to_node { for (grid_pos, &node_id) in &map.grid_to_node {
let node = map.graph.get_node(node_id).unwrap(); let node = map.graph.get_node(node_id).unwrap();
@@ -84,3 +33,61 @@ fn test_map_node_positions() {
assert_eq!(node.position, expected_pos); assert_eq!(node.position, expected_pos);
} }
} }
#[test]
fn test_generate_items() {
use pacman::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas};
use std::collections::HashMap;
let map = Map::new(RAW_BOARD).unwrap();
// Create a minimal atlas for testing
let mut frames = HashMap::new();
frames.insert(
"maze/pellet.png".to_string(),
MapperFrame {
x: 0,
y: 0,
width: 8,
height: 8,
},
);
frames.insert(
"maze/energizer.png".to_string(),
MapperFrame {
x: 8,
y: 0,
width: 8,
height: 8,
},
);
let mapper = AtlasMapper { frames };
let texture = unsafe { std::mem::transmute::<usize, Texture<'static>>(0usize) };
let atlas = SpriteAtlas::new(texture, mapper);
let items = map.generate_items(&atlas).unwrap();
// Verify we have items
assert!(!items.is_empty());
// Count different types
let pellet_count = items
.iter()
.filter(|item| matches!(item.item_type, pacman::entity::item::ItemType::Pellet))
.count();
let energizer_count = items
.iter()
.filter(|item| matches!(item.item_type, pacman::entity::item::ItemType::Energizer))
.count();
// Should have both types
assert_eq!(pellet_count, 240);
assert_eq!(energizer_count, 4);
// All items should be uncollected initially
assert!(items.iter().all(|item| !item.is_collected()));
// All items should have valid node indices
assert!(items.iter().all(|item| item.node_index < map.graph.node_count()));
}

View File

@@ -67,7 +67,7 @@ fn create_test_atlas() -> SpriteAtlas {
fn test_pacman_creation() { fn test_pacman_creation() {
let graph = create_test_graph(); let graph = create_test_graph();
let atlas = create_test_atlas(); let atlas = create_test_atlas();
let pacman = Pacman::new(&graph, 0, &atlas); let pacman = Pacman::new(&graph, 0, &atlas).unwrap();
assert!(pacman.traverser.position.is_at_node()); assert!(pacman.traverser.position.is_at_node());
assert_eq!(pacman.traverser.direction, Direction::Left); assert_eq!(pacman.traverser.direction, Direction::Left);
@@ -77,7 +77,7 @@ fn test_pacman_creation() {
fn test_pacman_key_handling() { fn test_pacman_key_handling() {
let graph = create_test_graph(); let graph = create_test_graph();
let atlas = create_test_atlas(); let atlas = create_test_atlas();
let mut pacman = Pacman::new(&graph, 0, &atlas); let mut pacman = Pacman::new(&graph, 0, &atlas).unwrap();
let test_cases = [ let test_cases = [
(Keycode::Up, Direction::Up), (Keycode::Up, Direction::Up),
@@ -96,7 +96,7 @@ fn test_pacman_key_handling() {
fn test_pacman_invalid_key() { fn test_pacman_invalid_key() {
let graph = create_test_graph(); let graph = create_test_graph();
let atlas = create_test_atlas(); let atlas = create_test_atlas();
let mut pacman = Pacman::new(&graph, 0, &atlas); let mut pacman = Pacman::new(&graph, 0, &atlas).unwrap();
let original_direction = pacman.traverser.direction; let original_direction = pacman.traverser.direction;
let original_next_direction = pacman.traverser.next_direction; let original_next_direction = pacman.traverser.next_direction;

View File

@@ -1,5 +1,6 @@
use pacman::constants::{BOARD_CELL_SIZE, RAW_BOARD}; use pacman::constants::{BOARD_CELL_SIZE, RAW_BOARD};
use pacman::map::parser::{MapTileParser, ParseError}; use pacman::error::ParseError;
use pacman::map::parser::MapTileParser;
#[test] #[test]
fn test_parse_character() { fn test_parse_character() {
@@ -37,10 +38,10 @@ fn test_parse_board() {
#[test] #[test]
fn test_parse_board_invalid_character() { fn test_parse_board_invalid_character() {
let mut invalid_board = RAW_BOARD.clone(); let mut invalid_board = RAW_BOARD.map(|s| s.to_string());
invalid_board[0] = "###########################Z"; invalid_board[0] = "###########################Z".to_string();
let result = MapTileParser::parse_board(invalid_board); let result = MapTileParser::parse_board(invalid_board.each_ref().map(|s| s.as_str()));
assert!(result.is_err()); assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ParseError::UnknownCharacter('Z'))); assert!(matches!(result.unwrap_err(), ParseError::UnknownCharacter('Z')));
} }

View File

@@ -61,14 +61,17 @@ fn test_ghost_pathfinding() {
let atlas = create_test_atlas(); let atlas = create_test_atlas();
// Create a ghost at node 0 // Create a ghost at node 0
let ghost = Ghost::new(&graph, node0, GhostType::Blinky, &atlas); let ghost = Ghost::new(&graph, node0, GhostType::Blinky, &atlas).unwrap();
// Test pathfinding from node 0 to node 2 // Test pathfinding from node 0 to node 2
let path = ghost.calculate_path_to_target(&graph, node2); let path = ghost.calculate_path_to_target(&graph, node2);
assert!(path.is_some()); assert!(path.is_ok());
let path = path.unwrap(); let path = path.unwrap();
assert_eq!(path, vec![node0, node1, node2]); assert!(
path == vec![node0, node1, node2] || path == vec![node2, node1, node0],
"Path was not what was expected"
);
} }
#[test] #[test]
@@ -85,12 +88,12 @@ fn test_ghost_pathfinding_no_path() {
// Don't connect the nodes // Don't connect the nodes
let atlas = create_test_atlas(); let atlas = create_test_atlas();
let ghost = Ghost::new(&graph, node0, GhostType::Blinky, &atlas); let ghost = Ghost::new(&graph, node0, GhostType::Blinky, &atlas).unwrap();
// Test pathfinding when no path exists // Test pathfinding when no path exists
let path = ghost.calculate_path_to_target(&graph, node1); let path = ghost.calculate_path_to_target(&graph, node1);
assert!(path.is_none()); assert!(path.is_err());
} }
#[test] #[test]
@@ -101,10 +104,10 @@ fn test_ghost_debug_colors() {
position: glam::Vec2::new(0.0, 0.0), position: glam::Vec2::new(0.0, 0.0),
}); });
let blinky = Ghost::new(&graph, node, GhostType::Blinky, &atlas); let blinky = Ghost::new(&graph, node, GhostType::Blinky, &atlas).unwrap();
let pinky = Ghost::new(&graph, node, GhostType::Pinky, &atlas); let pinky = Ghost::new(&graph, node, GhostType::Pinky, &atlas).unwrap();
let inky = Ghost::new(&graph, node, GhostType::Inky, &atlas); let inky = Ghost::new(&graph, node, GhostType::Inky, &atlas).unwrap();
let clyde = Ghost::new(&graph, node, GhostType::Clyde, &atlas); let clyde = Ghost::new(&graph, node, GhostType::Clyde, &atlas).unwrap();
// Test that each ghost has a different debug color // Test that each ghost has a different debug color
let colors = std::collections::HashSet::from([ let colors = std::collections::HashSet::from([

View File

@@ -1,4 +1,5 @@
use pacman::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas}; use glam::U16Vec2;
use pacman::texture::sprite::{AtlasMapper, AtlasTile, MapperFrame, Sprite, SpriteAtlas};
use sdl2::pixels::Color; use sdl2::pixels::Color;
use std::collections::HashMap; use std::collections::HashMap;
@@ -76,3 +77,27 @@ fn test_sprite_atlas_color() {
atlas.set_color(color); atlas.set_color(color);
assert_eq!(atlas.default_color(), Some(color)); assert_eq!(atlas.default_color(), Some(color));
} }
#[test]
fn test_atlas_tile_new_and_with_color() {
let pos = U16Vec2::new(10, 20);
let size = U16Vec2::new(30, 40);
let color = Color::RGB(100, 150, 200);
let tile = AtlasTile::new(pos, size, None);
assert_eq!(tile.pos, pos);
assert_eq!(tile.size, size);
assert_eq!(tile.color, None);
let tile_with_color = tile.with_color(color);
assert_eq!(tile_with_color.color, Some(color));
}
#[test]
fn test_sprite_new() {
let atlas_tile = AtlasTile::new(U16Vec2::new(0, 0), U16Vec2::new(16, 16), None);
let sprite = Sprite::new(atlas_tile);
assert_eq!(sprite.atlas_tile.pos, atlas_tile.pos);
assert_eq!(sprite.atlas_tile.size, atlas_tile.size);
}