Compare commits

..

15 Commits

Author SHA1 Message Date
60bbd1f5d6 ci: add retry mechanism for coverage reporting via Coveralls CLI 2025-08-12 18:31:07 -05:00
43ce8a4e01 ci: use justfile for coverage, separate report/generate coverage tasks 2025-08-12 18:00:57 -05:00
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
37 changed files with 1048 additions and 718 deletions

View File

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

View File

@@ -30,7 +30,7 @@ jobs:
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
@@ -42,15 +42,39 @@ 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
- uses: taiki-e/install-action@just
# 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 just coverage
- name: Download Coveralls CLI
run: |
# use GitHub Releases URL instead of coveralls.io because they can't maintain their own files; it 404s
curl -L https://github.com/coverallsapp/coverage-reporter/releases/download/v0.6.15/coveralls-linux-x86_64.tar.gz | tar -xz -C /usr/local/bin
- name: Upload coverage to Coveralls - name: Upload coverage to Coveralls
uses: coverallsapp/github-action@v2 env:
with: COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}
files: ./lcov.info run: |
format: lcov if [ ! -f "lcov.info" ]; then
allow-empty: false echo "Error: lcov.info file not found. Coverage generation may have failed."
exit 1
fi
for i in {1..10}; do
echo "Attempt $i: Uploading coverage to Coveralls..."
if coveralls -n report lcov.info; then
echo "Successfully uploaded coverage report."
exit 0
fi
if [ $i -lt 10 ]; then
delay=$((2**i))
echo "Attempt $i failed. Retrying in $delay seconds..."
sleep $delay
fi
done
echo "Failed to upload coverage report after 10 attempts."
exit 1

View File

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

18
Justfile Normal file
View File

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

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", "report-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
@@ -59,3 +83,4 @@ c = "job:clippy"
alt-c = "job:check" alt-c = "job:check"
ctrl-alt-c = "job:check-all" ctrl-alt-c = "job:check-all"
shift-c = "job:clippy-all" shift-c = "job:clippy-all"
f = "job:coverage"

View File

@@ -1,2 +1,4 @@
[toolchain] [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" channel = "1.86.0"
components = ["rustfmt", "llvm-tools-preview", "clippy"]

View File

@@ -4,8 +4,9 @@ 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::error::{GameError, GameResult};
@@ -14,26 +15,31 @@ 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() -> GameResult<Self> { pub fn new() -> GameResult<Self> {
let sdl_context: &'static Sdl = Box::leak(Box::new(sdl2::init().map_err(|e| GameError::Sdl(e.to_string()))?));
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()))?));
// Initialize platform-specific console // Initialize platform-specific console
get_platform().init_console()?; get_platform().init_console()?;
let sdl_context = sdl2::init().map_err(|e| GameError::Sdl(e.to_string()))?;
let video_subsystem = sdl_context.video().map_err(|e| GameError::Sdl(e.to_string()))?;
let audio_subsystem = sdl_context.audio().map_err(|e| GameError::Sdl(e.to_string()))?;
let ttf_context = sdl2::ttf::init().map_err(|e| GameError::Sdl(e.to_string()))?;
let window = video_subsystem let window = video_subsystem
.window( .window(
"Pac-Man", "Pac-Man",
@@ -50,18 +56,16 @@ impl App<'_> {
.set_logical_size(CANVAS_SIZE.x, CANVAS_SIZE.y) .set_logical_size(CANVAS_SIZE.x, CANVAS_SIZE.y)
.map_err(|e| GameError::Sdl(e.to_string()))?; .map_err(|e| GameError::Sdl(e.to_string()))?;
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 let mut backbuffer = texture_creator
.create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y) .create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y)
.map_err(|e| GameError::Sdl(e.to_string()))?; .map_err(|e| GameError::Sdl(e.to_string()))?;
backbuffer.set_scale_mode(ScaleMode::Nearest); backbuffer.set_scale_mode(ScaleMode::Nearest);
let event_pump = sdl_context.event_pump().map_err(|e| GameError::Sdl(e.to_string()))?;
// Initial draw // Initial draw
game.draw(&mut canvas, &mut backbuffer) game.draw(&mut canvas, &mut backbuffer)
.map_err(|e| GameError::Sdl(e.to_string()))?; .map_err(|e| GameError::Sdl(e.to_string()))?;
@@ -115,7 +119,7 @@ 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: Some(key), .. } => { Event::KeyDown { keycode: Some(key), .. } => {
self.game.keyboard_event(key); self.game.keyboard_event(key);

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

View File

@@ -164,10 +164,8 @@ impl Ghost {
})?, })?,
]; ];
textures[direction.as_usize()] = textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.2)?);
Some(AnimatedTexture::new(moving_tiles, 0.2).map_err(|e| GameError::Texture(TextureError::Animated(e)))?); stopped_textures[direction.as_usize()] = Some(AnimatedTexture::new(stopped_tiles, 0.1)?);
stopped_textures[direction.as_usize()] =
Some(AnimatedTexture::new(stopped_tiles, 0.1).map_err(|e| GameError::Texture(TextureError::Animated(e)))?);
} }
Ok(Self { Ok(Self {

View File

@@ -1,10 +1,11 @@
use crate::{ use crate::{
constants, constants,
entity::{collision::Collidable, graph::Graph}, entity::{collision::Collidable, graph::Graph},
error::EntityError, error::{EntityError, GameResult},
texture::sprite::{Sprite, SpriteAtlas}, texture::sprite::{Sprite, SpriteAtlas},
}; };
use sdl2::render::{Canvas, RenderTarget}; use sdl2::render::{Canvas, RenderTarget};
use strum_macros::{EnumCount, EnumIter};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ItemType { pub enum ItemType {
@@ -26,7 +27,7 @@ impl ItemType {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, EnumCount)]
#[allow(dead_code)] #[allow(dead_code)]
pub enum FruitKind { pub enum FruitKind {
Apple, Apple,
@@ -39,6 +40,19 @@ pub enum FruitKind {
} }
impl FruitKind { 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 { pub fn get_score(self) -> u32 {
match self { match self {
FruitKind::Apple => 100, FruitKind::Apple => 100,
@@ -81,18 +95,20 @@ impl Item {
self.item_type.get_score() self.item_type.get_score()
} }
pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, graph: &Graph) -> anyhow::Result<()> { pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, graph: &Graph) -> GameResult<()> {
if !self.collected { if self.collected {
return Ok(());
}
let node = graph let node = graph
.get_node(self.node_index) .get_node(self.node_index)
.ok_or(EntityError::NodeNotFound(self.node_index))?; .ok_or(EntityError::NodeNotFound(self.node_index))?;
let position = node.position + constants::BOARD_PIXEL_OFFSET.as_vec2(); let position = node.position + constants::BOARD_PIXEL_OFFSET.as_vec2();
self.sprite.render(canvas, atlas, position)
} else { self.sprite.render(canvas, atlas, position)?;
Ok(()) Ok(())
} }
} }
}
impl Collidable for Item { impl Collidable for Item {
fn position(&self) -> crate::entity::traversal::Position { fn position(&self) -> crate::entity::traversal::Position {

View File

@@ -98,10 +98,8 @@ impl Pacman {
let stopped_tiles = vec![SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png")) 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"))))?]; .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_b.png"))))?];
textures[direction.as_usize()] = textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.08)?);
Some(AnimatedTexture::new(moving_tiles, 0.08).map_err(|e| GameError::Texture(TextureError::Animated(e)))?); stopped_textures[direction.as_usize()] = Some(AnimatedTexture::new(stopped_tiles, 0.1)?);
stopped_textures[direction.as_usize()] =
Some(AnimatedTexture::new(stopped_tiles, 0.1).map_err(|e| GameError::Texture(TextureError::Animated(e)))?);
} }
Ok(Self { Ok(Self {

View File

@@ -3,22 +3,22 @@
//! This module defines all error types used throughout the application, //! This module defines all error types used throughout the application,
//! providing a consistent error handling approach. //! providing a consistent error handling approach.
use thiserror::Error; use std::io;
/// Main error type for the Pac-Man game. /// Main error type for the Pac-Man game.
/// ///
/// This is the primary error type that should be used in public APIs. /// This is the primary error type that should be used in public APIs.
/// It can represent any error that can occur during game operation. /// It can represent any error that can occur during game operation.
#[derive(Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum GameError { pub enum GameError {
#[error("Asset error: {0}")] #[error("Asset error: {0}")]
Asset(#[from] crate::asset::AssetError), Asset(#[from] AssetError),
#[error("Platform error: {0}")] #[error("Platform error: {0}")]
Platform(#[from] crate::platform::PlatformError), Platform(#[from] PlatformError),
#[error("Map parsing error: {0}")] #[error("Map parsing error: {0}")]
MapParse(#[from] crate::map::parser::ParseError), MapParse(#[from] ParseError),
#[error("Map error: {0}")] #[error("Map error: {0}")]
Map(#[from] MapError), Map(#[from] MapError),
@@ -36,26 +36,49 @@ pub enum GameError {
Sdl(String), Sdl(String),
#[error("IO error: {0}")] #[error("IO error: {0}")]
Io(#[from] std::io::Error), Io(#[from] io::Error),
#[error("Serialization error: {0}")] #[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error), Serialization(#[from] serde_json::Error),
#[error("Invalid state: {0}")] #[error("Invalid state: {0}")]
InvalidState(String), InvalidState(String),
}
#[error("Resource not found: {0}")] #[derive(thiserror::Error, Debug)]
pub enum AssetError {
#[error("IO error: {0}")]
Io(#[from] io::Error),
#[error("Asset not found: {0}")]
NotFound(String), NotFound(String),
}
#[error("Configuration error: {0}")] /// Platform-specific errors.
Config(String), #[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. /// Errors related to texture operations.
#[derive(Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum TextureError { pub enum TextureError {
#[error("Animated texture error: {0}")] #[error("Animated texture error: {0}")]
Animated(#[from] crate::texture::animated::AnimatedTextureError), Animated(#[from] AnimatedTextureError),
#[error("Failed to load texture: {0}")] #[error("Failed to load texture: {0}")]
LoadFailed(String), LoadFailed(String),
@@ -70,8 +93,14 @@ pub enum TextureError {
RenderFailed(String), RenderFailed(String),
} }
#[derive(thiserror::Error, Debug)]
pub enum AnimatedTextureError {
#[error("Frame duration must be positive, got {0}")]
InvalidFrameDuration(f32),
}
/// Errors related to entity operations. /// Errors related to entity operations.
#[derive(Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum EntityError { pub enum EntityError {
#[error("Node not found in graph: {0}")] #[error("Node not found in graph: {0}")]
NodeNotFound(usize), NodeNotFound(usize),
@@ -87,11 +116,11 @@ pub enum EntityError {
} }
/// Errors related to game state operations. /// Errors related to game state operations.
#[derive(Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum GameStateError {} pub enum GameStateError {}
/// Errors related to map operations. /// Errors related to map operations.
#[derive(Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum MapError { pub enum MapError {
#[error("Node not found: {0}")] #[error("Node not found: {0}")]
NodeNotFound(usize), NodeNotFound(usize),

View File

@@ -1,437 +0,0 @@
//! This module contains the main game logic and state.
use glam::{UVec2, Vec2};
use rand::{rngs::SmallRng, Rng, SeedableRng};
use sdl2::{
image::LoadTexture,
keyboard::Keycode,
pixels::Color,
render::{Canvas, RenderTarget, Texture, TextureCreator},
video::WindowContext,
};
use crate::error::{EntityError, GameError, GameResult, TextureError};
use crate::{
asset::{get_asset_bytes, Asset},
audio::Audio,
constants::{CELL_SIZE, RAW_BOARD},
entity::{
collision::{Collidable, CollisionSystem, EntityId},
ghost::{Ghost, GhostType},
item::Item,
pacman::Pacman,
r#trait::Entity,
},
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 items: Vec<Item>,
pub debug_mode: bool,
// Collision system
collision_system: CollisionSystem,
pacman_id: EntityId,
ghost_ids: Vec<EntityId>,
item_ids: Vec<EntityId>,
// 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,
) -> GameResult<Game> {
let map = Map::new(RAW_BOARD)?;
let pacman_start_pos = map
.find_starting_position(0)
.ok_or_else(|| GameError::NotFound("Pac-Man starting position".to_string()))?;
let pacman_start_node = *map
.grid_to_node
.get(&glam::IVec2::new(pacman_start_pos.x as i32, pacman_start_pos.y as i32))
.ok_or_else(|| GameError::NotFound("Pac-Man starting position not found in graph".to_string()))?;
let atlas_bytes = get_asset_bytes(Asset::Atlas)?;
let atlas_texture = unsafe {
let 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()))
}
})?;
sprite::texture_to_static(texture)
};
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)?;
// 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();
if map.graph.node_count() == 0 {
return Err(GameError::Config("Game map has no nodes - invalid configuration".to_string()));
// TODO: This is a bug, we should handle this better
}
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);
}
// 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);
}
// Register ghosts
let mut ghost_ids = Vec::new();
for ghost in &ghosts {
let ghost_id = collision_system.register_entity(ghost.position());
ghost_ids.push(ghost_id);
}
Ok(Game {
score: 0,
map,
pacman,
ghosts,
items,
debug_mode: false,
collision_system,
pacman_id,
ghost_ids,
item_ids,
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 {
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<()> {
// Reset Pac-Man to starting position
let pacman_start_pos = self
.map
.find_starting_position(0)
.ok_or_else(|| GameError::NotFound("Pac-Man starting position".to_string()))?;
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))
.ok_or_else(|| GameError::NotFound("Pac-Man starting position not found in graph".to_string()))?;
self.pacman = Pacman::new(&self.map.graph, pacman_start_node, &self.atlas)?;
// Reset items
self.items = self.map.generate_items(&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)?;
}
// Reset collision system
self.collision_system = CollisionSystem::default();
// Re-register Pac-Man
self.pacman_id = self.collision_system.register_entity(self.pacman.position());
// Re-register items
self.item_ids.clear();
for item in &self.items {
let item_id = self.collision_system.register_entity(item.position());
self.item_ids.push(item_id);
}
// Re-register ghosts
self.ghost_ids.clear();
for ghost in &self.ghosts {
let ghost_id = self.collision_system.register_entity(ghost.position());
self.ghost_ids.push(ghost_id);
}
Ok(())
}
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);
}
// Update collision system positions
self.update_collision_positions();
// Check for collisions
self.check_collisions();
}
fn update_collision_positions(&mut self) {
// Update Pac-Man's position
self.collision_system.update_position(self.pacman_id, self.pacman.position());
// Update ghost positions
for (ghost, &ghost_id) in self.ghosts.iter().zip(&self.ghost_ids) {
self.collision_system.update_position(ghost_id, ghost.position());
}
}
fn check_collisions(&mut self) {
// Check Pac-Man vs Items
let potential_collisions = self.collision_system.potential_collisions(&self.pacman.position());
for entity_id in potential_collisions {
if entity_id != self.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.items[item_index];
if !item.is_collected() {
item.collect();
self.score += item.get_score();
// 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.item_ids.iter().position(|&id| id == entity_id)
}
fn find_ghost_by_id(&self, entity_id: EntityId) -> Option<usize> {
self.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.map.render(canvas, &mut self.atlas, &mut self.map_texture);
// Render all items
for item in &self.items {
if let Err(e) = item.render(canvas, &mut self.atlas, &self.map.graph) {
tracing::error!("Failed to render item: {}", e);
}
}
// Render all ghosts
for ghost in &self.ghosts {
if let Err(e) = ghost.render(canvas, &mut self.atlas, &self.map.graph) {
tracing::error!("Failed to render ghost: {}", e);
}
}
if let Err(e) = self.pacman.render(canvas, &mut self.atlas, &self.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.debug_mode {
if let Err(e) = self
.map
.debug_render_with_cursor(canvas, &mut self.text_texture, &mut self.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.pacman.current_node_id();
for ghost in self.ghosts.iter() {
if let Ok(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();
// 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
.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) > (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.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);
if let Err(e) = self.text_texture.render(
canvas,
&mut self.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.text_texture.render(
canvas,
&mut self.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(())
}
}

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

@@ -7,7 +7,7 @@ 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, Sprite, 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;
@@ -15,7 +15,6 @@ use tracing::debug;
use crate::error::{GameResult, MapError}; 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,
@@ -26,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 {
@@ -56,7 +49,6 @@ impl Map {
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();
@@ -64,8 +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 = let start_pos = parsed_map
pacman_start.ok_or_else(|| MapError::InvalidConfig("Pac-Man's starting position not found".to_string()))?; .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();
@@ -155,31 +148,12 @@ impl Map {
Self::build_tunnels(&mut graph, &grid_to_node, &tunnel_ends)?; Self::build_tunnels(&mut graph, &grid_to_node, &tunnel_ends)?;
Ok(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.
/// ///
/// This function draws the static map texture to the screen at the correct /// This function draws the static map texture to the screen at the correct

View File

@@ -1,19 +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),
#[error("Map parsing failed: {0}")]
ParseFailed(String),
}
/// Represents the parsed data from a raw board layout. /// Represents the parsed data from a raw board layout.
#[derive(Debug)] #[derive(Debug)]

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,8 @@ 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. /// A simple sprite for stationary items like pellets and energizers.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Sprite { pub struct Sprite {
@@ -17,13 +19,19 @@ impl Sprite {
Self { atlas_tile } Self { atlas_tile }
} }
pub fn render<C: RenderTarget>(&self, canvas: &mut Canvas<C>, atlas: &mut SpriteAtlas, position: glam::Vec2) -> Result<()> { 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( let dest = crate::helpers::centered_with_size(
glam::IVec2::new(position.x as i32, position.y as i32), 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), glam::UVec2::new(self.atlas_tile.size.x as u32, self.atlas_tile.size.y as u32),
); );
let mut tile = self.atlas_tile; let mut tile = self.atlas_tile;
tile.render(canvas, atlas, dest) tile.render(canvas, atlas, dest)?;
Ok(())
} }
} }
@@ -48,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>(
@@ -59,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) {
@@ -67,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(())
} }
@@ -138,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);
}
}

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).unwrap(); 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).unwrap();
assert!(map.find_starting_position(0).is_some());
} }

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).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());
@@ -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).unwrap();
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).unwrap();
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

@@ -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() {

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);
}