Compare commits

...

51 Commits

Author SHA1 Message Date
023697dcd7 fix: use bun and web.build.ts in build workflow, use minify & cwd args for tailwindcss cli 2025-08-08 10:10:57 -05:00
87ee12543e tests: revamp tests, remove more useless tests 2025-08-08 09:07:10 -05:00
b308bc0ef7 refactor: move all tests out of src/ into tests/, remove unnecessary tests 2025-08-08 08:50:52 -05:00
9d5ca54234 fix: improved frontend web interface, use tailwind cli 2025-08-08 00:20:38 -05:00
2ae73c3c58 fix: disable app quit on browser build 2025-08-07 23:44:26 -05:00
adfa2cc737 feat: edge traversal permissions system 2025-08-07 23:39:39 -05:00
7c937df002 docs: add build notes to README 2025-08-07 23:26:48 -05:00
9fb9c959a3 refactor: ensure emsdk dir exists before issuing commands 2025-08-07 23:20:12 -05:00
61ebc8f317 chore: fixup .gitignore 2025-08-07 23:00:03 -05:00
b7f668c58a feat: revamp web build script in bun + typescript, delete old scripts 2025-08-07 22:59:51 -05:00
b1021c28b5 chore: remove unused door/map.png/build.css 2025-08-07 22:58:38 -05:00
7d6f92283a docs: update README with badges, remove unnecessary install details, change workflow names 2025-07-28 21:09:42 -05:00
2a295b1daf test: small test lint fixes 2025-07-28 20:55:45 -05:00
4398ec2936 chore: fix clippy errors, add allow dead_code modifiers 2025-07-28 20:53:01 -05:00
324c358672 refactor: remove StartingPosition MapTile, track pacman start explicitly in parser 2025-07-28 20:49:17 -05:00
cda8c40195 test: allow mute state updates while audio subsystem is disabled 2025-07-28 20:48:13 -05:00
89b4ba125f feat: begin tracking nodes of entity starting positions 2025-07-28 20:41:26 -05:00
fcdbe62f99 feat: allow graceful disabling of audio subsystem in background 2025-07-28 20:38:16 -05:00
57c7afcdb4 ci: emit warnings on retry attempts in emscripten build 2025-07-28 20:25:13 -05:00
2e16c2d170 ci: add retry mechanism for emscripten builds due to dependency hash errors in sdk 2025-07-28 20:18:28 -05:00
f86c106593 test: switch to llvm-cov for coverage, switch to cargo-nextest as test runner 2025-07-28 19:59:40 -05:00
04cf8f217f test: add generic tests for coverage 2025-07-28 19:48:31 -05:00
7e0ca4ff3d test: disable fail-fast by default 2025-07-28 19:43:16 -05:00
fcc36c8a46 test: add tons of tests for all easy submodules 2025-07-28 19:26:36 -05:00
41affcd7ad test: add tests for centered_with_size 2025-07-28 18:52:34 -05:00
4ecfded4ac refactor: center Rect with centered_with_size helper 2025-07-28 18:47:24 -05:00
25d5121a28 ci: correct toolchain matrix args 2025-07-28 18:32:13 -05:00
91095ed2cc ci: switch tarpaulin output to lcov format 2025-07-28 18:28:43 -05:00
cbf52bb994 ci: add 'rustfmt' component for test workflow 2025-07-28 18:13:23 -05:00
d763b9646f chore: 'node' runner on emscripten target 2025-07-28 18:08:47 -05:00
d7a9e0a304 ci: update most toolchains to 1.88, keep emscripten on 1.86.0 2025-07-28 18:08:29 -05:00
db720edeef ci: move comment breaking up 'rustflags' for coverage linking 2025-07-28 18:05:54 -05:00
f241e85d8f ci: set rustflags for cargo-tarpaulin build linking 2025-07-28 17:32:53 -05:00
d18b414536 ci: add 'clippy' component to test workflow 2025-07-28 17:28:47 -05:00
c9bcf32381 chore: fix various clippy warnings, disable trivial warnings in some spot 2025-07-28 17:25:18 -05:00
b45980c172 ci: only deploy to pages on master pushes 2025-07-28 17:09:21 -05:00
b4e3f383ec ci: add audit, test & coverage workflows 2025-07-28 17:09:06 -05:00
532abd1e45 chore: remove unused params for debug_render_nodes func 2025-07-28 16:22:48 -05:00
70528b0dcc refactor: separate map struct into multiple files for building, rendering & parsing 2025-07-28 16:20:24 -05:00
c5ca7302c2 refactor: separate map parsing into MapTileParser, get tests working 2025-07-28 16:10:50 -05:00
a27f85279e feat: working perfect tunnels with offset house positioning nodes 2025-07-28 14:34:24 -05:00
bea915b5c7 docs: post-creation neighbor edges, no ignore result err 2025-07-28 13:25:51 -05:00
d743aee393 refactor: better graph connection functions & creation method, debug render connections 2025-07-28 13:23:12 -05:00
59aba9f691 fix: remove emscripten main_loop_callback targeted code 2025-07-28 12:48:10 -05:00
199b4dc939 refactor: static intersection struct for calculating edges instead of smallvec 2025-07-28 12:44:54 -05:00
2edd23cfbb docs: add all latest developments to STORY.md 2025-07-28 12:25:56 -05:00
464d6f9ca6 refactor: huge refactor into node/graph-based movement system 2025-07-28 12:23:57 -05:00
413f9f156f refactor: continue working on ghost house implementation, other stuff 2025-07-27 12:15:11 -05:00
4f87a116d5 chore: remove unused code, resolve simple stuff 2025-07-26 15:35:50 -05:00
86ffc931e8 fix: re-provide specific blue color to maze texture 2025-07-26 15:31:15 -05:00
d72f47d66c fix: fix tunneling logic 2025-07-26 15:27:17 -05:00
61 changed files with 3114 additions and 2453 deletions

View File

@@ -5,6 +5,7 @@ rustflags = [
"-C", "link-args=-sUSE_SDL=2 -sUSE_SDL_IMAGE=2 -sUSE_SDL_MIXER=2 -sUSE_OGG=1 -sUSE_SDL_GFX=2 -sUSE_SDL_TTF=2 -sSDL2_IMAGE_FORMATS=['png']",
"-C", "link-args=--preload-file assets/game/",
]
runner = "node"
[target.'cfg(target_os = "linux")']
rustflags = [
@@ -13,4 +14,4 @@ rustflags = [
# By adding `-lz` here, we ensure it's passed to the linker after `libpng`,
# which is required for the linker to correctly resolve symbols.
"-C", "link-arg=-lz",
]
]

2
.config/nextest.toml Normal file
View File

@@ -0,0 +1,2 @@
[profile.default]
fail-fast = false

27
.github/workflows/audit.yaml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: Audit
on: ["push", "pull_request"]
env:
CARGO_TERM_COLOR: always
RUST_TOOLCHAIN: 1.88.0
jobs:
audit:
name: Audit
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
- name: Install cargo-audit
run: cargo install cargo-audit
- name: Run security audit
run: cargo audit

View File

@@ -1,13 +1,10 @@
name: Build
name: Builds
on: [push]
on: ["push", "pull_request"]
permissions:
contents: write
env:
RUST_TOOLCHAIN: 1.86.0
jobs:
build:
name: Build (${{ matrix.target }})
@@ -18,15 +15,19 @@ jobs:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
artifact_name: pacman
toolchain: 1.88.0
- os: macos-13
target: x86_64-apple-darwin
artifact_name: pacman
toolchain: 1.88.0
- os: macos-latest
target: aarch64-apple-darwin
artifact_name: pacman
toolchain: 1.88.0
- os: windows-latest
target: x86_64-pc-windows-gnu
artifact_name: pacman.exe
toolchain: 1.88.0
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
@@ -36,7 +37,7 @@ jobs:
uses: dtolnay/rust-toolchain@master
with:
target: ${{ matrix.target }}
toolchain: ${{ env.RUST_TOOLCHAIN }}
toolchain: ${{ matrix.toolchain }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
@@ -92,44 +93,67 @@ jobs:
uses: pyodide/setup-emsdk@v15
with:
version: 3.1.43
actions-cache-folder: "emsdk-cache"
actions-cache-folder: "emsdk-cache-b"
- name: Setup Rust (WASM32 Emscripten)
uses: dtolnay/rust-toolchain@master
with:
target: wasm32-unknown-emscripten
toolchain: ${{ env.RUST_TOOLCHAIN }}
toolchain: 1.86.0 # we are unfortunately pinned to 1.86.0 for some reason, bulk-memory-opt related issues
- name: Rust Cache
uses: Swatinem/rust-cache@v2
- name: Install pnpm
uses: pnpm/action-setup@v3
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
version: 8
run_install: true
bun-version: latest
- name: Build with Emscripten
shell: bash
run: |
cargo build --target=wasm32-unknown-emscripten --release
# Retry mechanism for Emscripten build - only retry on specific hash errors
MAX_RETRIES=3
RETRY_DELAY=30
- name: Assemble
run: |
echo "Generating CSS"
pnpx postcss-cli ./assets/site/styles.scss -o ./assets/site/build.css
for attempt in $(seq 1 $MAX_RETRIES); do
echo "Build attempt $attempt of $MAX_RETRIES"
echo "Copying WASM files"
# Capture output and check for specific error while preserving real-time output
if bun run web.build.ts 2>&1 | tee /tmp/build_output.log; then
echo "Build successful on attempt $attempt"
break
else
echo "Build failed on attempt $attempt"
mkdir -p dist
cp assets/site/{build.css,favicon.ico,index.html} dist
output_folder="target/wasm32-unknown-emscripten/release"
cp $output_folder/pacman.{wasm,js} $output_folder/deps/pacman.data dist
# Check if the failure was due to the specific hash error
if grep -q "emcc: error: Unexpected hash:" /tmp/build_output.log; then
echo "::warning::Detected 'emcc: error: Unexpected hash:' error - will retry (attempt $attempt of $MAX_RETRIES)"
if [ $attempt -eq $MAX_RETRIES ]; then
echo "::error::All retry attempts failed. Exiting with error."
exit 1
fi
echo "Waiting $RETRY_DELAY seconds before retry..."
sleep $RETRY_DELAY
# Exponential backoff: double the delay for next attempt
RETRY_DELAY=$((RETRY_DELAY * 2))
else
echo "Build failed but not due to hash error - not retrying"
exit 1
fi
fi
done
- name: Upload Artifact
uses: actions/upload-pages-artifact@v3
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
with:
path: "./dist/"
retention-days: 7
- name: Deploy
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
uses: actions/deploy-pages@v4

57
.github/workflows/coverage.yaml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: Coverage
on: ["push", "pull_request"]
env:
CARGO_TERM_COLOR: always
RUST_TOOLCHAIN: 1.86.0
jobs:
coverage:
name: Code Coverage
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
components: llvm-tools-preview
- name: Rust Cache
uses: Swatinem/rust-cache@v2
- name: Cache vcpkg
uses: actions/cache@v4
with:
path: target/vcpkg
key: A-vcpkg-${{ runner.os }}-${{ hashFiles('Cargo.toml', 'Cargo.lock') }}
restore-keys: |
A-vcpkg-${{ runner.os }}-
- name: Vcpkg Linux Dependencies
run: |
sudo apt-get update
sudo apt-get install -y libltdl-dev
- name: Vcpkg
run: |
cargo install cargo-vcpkg
cargo vcpkg -v build
- uses: taiki-e/install-action@cargo-llvm-cov
- 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
run: |
cargo llvm-cov --no-fail-fast --lcov --output-path lcov.info nextest
- name: Upload coverage to Coveralls
uses: coverallsapp/github-action@v2
with:
files: ./lcov.info
format: lcov
allow-empty: false

54
.github/workflows/test.yaml vendored Normal file
View File

@@ -0,0 +1,54 @@
name: Tests
on: ["push", "pull_request"]
env:
CARGO_TERM_COLOR: always
RUST_TOOLCHAIN: 1.88.0
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
components: clippy, rustfmt
- name: Rust Cache
uses: Swatinem/rust-cache@v2
- name: Cache vcpkg
uses: actions/cache@v4
with:
path: target/vcpkg
key: A-vcpkg-${{ runner.os }}-${{ hashFiles('Cargo.toml', 'Cargo.lock') }}
restore-keys: |
A-vcpkg-${{ runner.os }}-
- name: Vcpkg Linux Dependencies
run: |
sudo apt-get update
sudo apt-get install -y libltdl-dev
- name: Vcpkg
run: |
cargo install cargo-vcpkg
cargo vcpkg -v build
- uses: taiki-e/install-action@nextest
- name: Run nextest
run: cargo nextest run --workspace
- name: Run clippy
run: cargo clippy -- -D warnings
- name: Check formatting
run: cargo fmt -- --check

7
.gitignore vendored
View File

@@ -1,7 +1,6 @@
/target
/dist
target/
dist/
emsdk/
.idea
*.dll
rust-sdl2-emscripten/
assets/site/build.css
emsdk/

4
Cargo.lock generated
View File

@@ -392,9 +392,9 @@ dependencies = [
[[package]]
name = "smallvec"
version = "1.11.0"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "spin_sleep"

View File

@@ -17,14 +17,13 @@ pathfinding = "4.14"
once_cell = "1.21.3"
thiserror = "1.0"
anyhow = "1.0"
glam = "0.30.4"
glam = { version = "0.30.4", features = [] }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.141"
[profile.release]
lto = true
panic = "abort"
panic-strategy = "abort"
opt-level = "z"
[target.'cfg(target_os = "windows")'.dependencies.winapi]

103
README.md
View File

@@ -1,9 +1,33 @@
# Pac-Man
If the title doesn't clue you in, I'm remaking Pac-Man with SDL and Rust.
[![Tests Status][badge-test]][test] [![Build Status][badge-build]][build] [![Code Coverage][badge-coverage]][coverage] [![Online Demo][badge-online-demo]][demo] [![Last Commit][badge-last-commit]][commits]
The project is _extremely_ early in development, but check back in a week, and maybe I'll have something cool to look
at.
[badge-test]: https://github.com/Xevion/Pac-Man/actions/workflows/test.yaml/badge.svg
[badge-build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml/badge.svg
[badge-coverage]: https://coveralls.io/repos/github/Xevion/Pac-Man/badge.svg?branch=master
[badge-demo]: https://img.shields.io/github/deployments/Xevion/Pac-Man/github-pages?label=GitHub%20Pages
[badge-online-demo]: https://img.shields.io/badge/GitHub%20Pages-Demo-brightgreen
[badge-last-commit]: https://img.shields.io/github/last-commit/Xevion/Pac-Man
[build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml
[test]: https://github.com/Xevion/Pac-Man/actions/workflows/test.yaml
[coverage]: https://coveralls.io/github/Xevion/Pac-Man?branch=master
[demo]: https://xevion.github.io/Pac-Man/
[commits]: https://github.com/Xevion/Pac-Man/commits/master
## Description
A faithful recreation of the classic Pac-Man arcade game written in Rust. This project aims to replicate the original game's mechanics, graphics, sound, and behavior as accurately as possible while providing modern development features like cross-platform compatibility and WebAssembly support.
The game includes all the original features you'd expect from Pac-Man:
- [x] Classic maze navigation and dot collection
- [ ] Four ghosts with their unique AI behaviors (Blinky, Pinky, Inky, and Clyde)
- [ ] Power pellets that allow Pac-Man to eat ghosts
- [ ] Fruit bonuses that appear periodically
- [ ] Progressive difficulty with faster ghosts and shorter power pellet duration
- [x] Authentic sound effects and sprites
Built with SDL2 for cross-platform graphics and audio, this implementation can run on Windows, Linux, macOS, and in web browsers via WebAssembly.
## Feature Targets
@@ -12,68 +36,29 @@ at.
- Online demo, playable in a browser.
- Automatic build system, with releases for Windows, Linux, and Mac & Web-Assembly.
- Debug tooling
- Game state visualization
- Game speed controls + pausing
- Log tracing
- Performance details
- Game state visualization
- Game speed controls + pausing
- Log tracing
- Performance details
## Experimental Ideas
- Perfected Ghost Algorithms
- More than 4 ghosts
- Custom Level Generation
- Multi-map tunnelling
- Multi-map tunnelling
- Online Scoreboard
- WebAssembly build contains a special API key for communicating with server.
- To prevent abuse, the server will only accept scores from the WebAssembly build.
- WebAssembly build contains a special API key for communicating with server.
- To prevent abuse, the server will only accept scores from the WebAssembly build.
## Installation
## Build Notes
Besides SDL2, the following extensions are required: Image, Mixer, and TTF.
### Ubuntu
On Ubuntu, you can install the required packages with the following command:
```
sudo apt install libsdl2-dev libsdl2-image-dev libsdl2-mixer-dev libsdl2-ttf-dev
```
### Windows
On Windows, installation requires either building from source (not covered), or downloading the pre-built binaries.
The latest releases can be found here:
- [SDL2](https://github.com/libsdl-org/SDL/releases/latest/)
- [SDL2_image](https://github.com/libsdl-org/SDL_image/releases/latest/)
- [SDL2_mixer](https://github.com/libsdl-org/SDL_mixer/releases/latest/)
- [SDL2_ttf](https://github.com/libsdl-org/SDL_ttf/releases/latest/)
Download each for your architecture, and locate the appropriately named DLL within. Move said DLL to root of this project.
In total, you should have the following DLLs in the root of the project:
- SDL2.dll
- SDL2_mixer.dll
- SDL2_ttf.dll
- SDL2_image.dll
- libpngX-X.dll
- Not sure on what specific version is to be used, or if naming matters. `libpng16-16.dll` is what I had used.
- zlib1.dll
## Building
To build the project, run the following command:
```
cargo build
```
During development, you can easily run the project with:
```
cargo run
cargo run -q # Quiet mode, no logging
cargo run --release # Release mode, optimized
```
- Install `cargo-vcpkg` with `cargo install cargo-vcpkg`, then run `cargo vcpkg build` to build the requisite dependencies via vcpkg.
- For the WASM build, you need to have the Emscripten SDK cloned; you can do so with `git clone https://github.com/emscripten-core/emsdk.git`
- The first time you clone, you'll need to install the appropriate SDK version with `./emsdk install 3.1.43` and then activate it with `./emsdk activate 3.1.43`. On Windows, use `./emsdk/emsdk.ps1` instead.
- You can then activate the Emscripten SDK with `source ./emsdk/emsdk_env.sh` or `./emsdk/emsdk_env.ps1` or `./emsdk/emsdk_env.bat` depending on your OS/terminal.
- While using the `web.build.ts` is not technically required, it simplifies the build process and is very helpful.
- It is intended to be run with `bun`, which you can acquire at [bun.sh](https://bun.sh/)
- Tip: You can launch a fileserver with `python` or `caddy` to serve the files in the `dist` folder.
- `python3 -m http.server 8080 -d dist`
- `caddy file-server --root dist` (install with `[sudo apt|brew|choco] install caddy` or [a dozen other ways](https://caddyserver.com/docs/install))

View File

@@ -32,6 +32,7 @@ The problem is that much of this work was done for pure-Rust applications - and
This requires a C++ WebAssembly compiler such as Emscripten; and it's a pain to get working.
Luckily though, someone else has done this before, and they fully documented it - [RuggRouge][ruggrouge].
- Built with Rust
- Uses SDL2
- Compiling for WebAssembly with Emscripten
@@ -46,7 +47,6 @@ The issue presented with some keys never being sent to the application.
To confirm, enter safe mode or switch to a different browser without said extensions.
If the issue disappears, it's because of an extension in your browser stealing keys in a way that is incompatible with the batshit insanity of Emscripten.
## A Long Break
After hitting a wall with an issue with Emscripten where the tab would freeze after switching tabs (making it into a background tab), I decided to take a break from the project. A couple months went by without anything going on.
@@ -78,7 +78,7 @@ But this did help me narrow my search even more for a good example. I needed to
I found [one such repository](https://github.com/KyleMiles/Rust-SDL-Emscripten-Template/), and interestingly, it used `latest` Emscripten (not a specific target like 1.39.20), and was new enough (2 years old, but still new enough) to be relevant.
Even more interesting, it didn't use the `main` loop closure, but instead used Emscripten's *Asyncify* feature to handle the main loop.
Even more interesting, it didn't use the `main` loop closure, but instead used Emscripten's _Asyncify_ feature to handle the main loop.
But, unlike my original project which called `std::thread::sleep` directly, it used bindings into Emscripten's functions like `emscripten_sleep`.
@@ -124,6 +124,7 @@ While working on the next extension of SDL2 for my test repository, SDL2-TTF had
Luckily, I had a recently updated repository to copy off of, and the working fix was to lower the EMSDK version to `3.1.43`.
[Source](https://github.com/aelred/tetris/blob/0ad88153db1ca7962b42277504c0f7f9f3c675a9/tetris-sdl/src/main.rs#L34)
```rust
static FONT_DATA: &[u8] = include_bytes!("../assets/TerminalVector.ttf");
@@ -176,7 +177,7 @@ But I also didn't want to include some big framework on this, like Astro, so I l
After fiddling and failing to find Hugo suitable, I stuck to plain HTML & the PostCSS method, which worked great. It's definitely not that fast for rapid development, but it works well enough.
The only thing I'm unsatisfied with is why `postcss-cli` wasn't working when executed from `pnpm`. It works just fine from `pnpx`, but it has to download and setup the whole package on *every single invocation*, which is super slow. And probably expensive, in the long run.
The only thing I'm unsatisfied with is why `postcss-cli` wasn't working when executed from `pnpm`. It works just fine from `pnpx`, but it has to download and setup the whole package on _every single invocation_, which is super slow. And probably expensive, in the long run.
## Cross-platform Builds
@@ -253,6 +254,7 @@ After a couple attempts with various test commits, I couldn't find it, and just
> Note: VCPKG is annoying to install, the executable provided by Visual Studio Community does not permit classic-mode usage, so you'll still need to clone and bootstrap VCPKG (instructions in the repository README).
As it happens, they were placed in
- `$VCPKG_ROOT\packages\sdl2-gfx_x64-windows-release\bin\SDL2_gfx.dll` and
- `$VCPKG_ROOT\packages\sdl2-gfx_x64-windows-release\lib\SDL2_gfx.lib` respectively.
@@ -324,10 +326,95 @@ I was thinking of a github-pages artifact name that aligns with the others, but
Perhaps at the least I'll look into a 32-bit build for Windows, just for demonstration purposes.
## My Return to Pac-Man
It's been 15 months since I last touched the demo codebase, and much longer since I've touched the core Pac-Man project, and I got inspired to look back into it recently. I'm finally touching up on the story document, so if this reads a bit disjointed, that's why.
- I switched the dependency linking to use the internal statically-linked `vcpkg` feature, which is a lot easier to maintain. It's not perfect, but it's much better than the manual downloads and the dynamically linked `.dll` files I was doing before. With caching, it also tends to be far quicker.
- I switched all of the commits to use conventional commit messages, which is easier to read and understand.
- I integrated the demo project's emscripten workflow, updated sdl2 and started poking around in the project. I got into adding fonts, adding a reset button, a debug mode, score tracking, pellet consumption, etc.
- I spent a lot of time working on the audio timing, getting it to work flawlessly and compare really well with the original Pac-Man; the sound is incredibly important to the game, so I wanted to get it right.
## Pathfinding and Tunnelling
Pathfinding was very easy to get working, although tunnelling was a bit more difficult, and unfortunately I never got it working with the way I was doing things at the time. A lot of issues were happening with trying to get the transition between the tunnels to work, I could only get Pac-Man to teleport from one tunnel to the other, but moving smoothly between them was nigh impossible.
I did however get pathfinding to work between the tunnels, which was very satisfying to see using the debug visuals.
I ended up using the `pathfinding` crate and it was a breeze to use.
## Atlas Tiles
When I was looking around for Pac-Man sprites, I kept coming across atlas images, and I had been noticing for some time how my sprites were not correctly sized, and some of them just didn't match the original Pac-Man. I had been spending a lot of time making this Pac-Man project as close to the original as possible, and I felt like if I didn't use the original sprites, I wasn't doing it justice.
This had me thinking about how asset loading was a real pain in this project, and how I wanted to look into atlas tiles.
The arguments for copying between a texture and a canvas/surface/texture were very obviously rigged to allow for this, given that you had to specify the source `Rect`, meaning you could target a specific area of the texture. Such as tiles on an atlas image.
It didn't take long for me to get it working, I chose an existing crate called `clutterd` which provided a CLI for building atlas images with an metadata file describing the positions and sizes of the tiles.
Doing so required a full re-work of the animation and texture system, and I ended up making a breakthrough on how I managed lifetimes: lifetime annotations were plaguing the codebase, literally everywhere, and it was super annoying to keep writing and dealing with them.
So, I ended up using `unsafe` to forcibly cast the lifetimes to `'static`, which was a bit of a gamble, but given that they essentially behave as `'static` in practice, there wasn't much risk as I see it. I might re-look into my understanding of lifetimes and this in the future, but for the time being, it's a good solution that makes the codebase far easier to work with.
## Cross-platform Builds
Since the original `rust-sdl2-emscripten` demo project had cross-platform builds, I was ready to get it working for this project. For the most part, it wasn't hard, things tended to click into place, but unfortunately, the `emscripten` os target and somehow, the `linux` os target were both failing.
I'm still not sure what exactly causes it, but `emscripten` strongly prefers to be built on 1.86 (1.88 does not work, 1.87 might though).
Changing the toolchain to 1.86 fixed the issue when it was failing.
It did turn out though, that despite me getting the `emscripten` target building, it did not mean the application was functioning properly.
- Upon launch, it was immediately crashing due to issues with the audio subsystem; this was fixed with a simple increase to the audio buffer chunksize, apparently it has a minimum size of 256.
- Then, it was failing due to issues with the main loop, referencing the `ASYNCIFY_STACK_SIZE` variable in `.cargo/config.toml`, asking for it to be increased. I really didn't like the idea of increasing it for whatever reason, so I ended up looking into the `emscripten_main_loop` method of looping again, but nothing worked all that well, just like the last time I tried. So I increased the variable, doubling it from the default of 4096 to 8192. Things immediately worked, and the browser build was working.
Linux however was a far more annoying task, as it was failing to due the `cargo-vcpkg` build step (which built the SDL2 libraries necessary for static linking and building the project). It was hard to pin down at first, but packages seemed to be failing due to system dependencies not being available, so after adding a couple `apt` packages to the steps, things started to work.
Eventually though, it kept failing at the `sdl2` package, which was failing to build due to the `libpng` package not being able to find a bunch of symbols related to `zlib`. Almost nothing was written about this online, except for one issue on GitHub which hadn't been updated in 2 years.
I won't lie, Gemini helped me out here, suggesting adding `"-C", "link-arg=-lz",` to the `rustflags` section of `.cargo/config.toml`.
It seems like it moved the `zlib` library to the front of the link order, and things started magically working both locally and on the GitHub Actions runner.
I also added an ARM64 build for MacOS, which worked without any issues. Surprisingly, MacOS is the only platform that I've been able to get working without any issues. At least, I hope it's working; I don't really have a way to test it myself.
## Caching
I spent a bit of time after this improving the build process to take advantage of caching so that most builds would fly. The `cargo-vcpkg` was by far the most expensive step, and it unfortunately, despite being in the `target` directory (which is supposed to be cached by the `Swatinem/rust-cache@v2` action), was not being cached.
I played with the parameters for a bit before giving up and just manually adding a cache step to the workflow. It's expensive, uploading 300MB of artifact data to GitHub, but it works well, and I'm really doubtful it will change that much.
I also ended up improving the build process to use `cargo metadata` to get the package version, which means I could drop the `toml-cli` dependency and just use the `cargo` command + `jq` (which is already installed on the runner).
## Atlas Text
At some point, I wanted to use the original text from the game, so I created a text texture type for rendering text using the existing sprite atlas, which means I wasn't using the `ttf` feature at all. I'm stil unsure whether or not I'll use it, I might keep it because it seems like more hassle to remove it at this point. Perhaps I'll still use normal ttf fonts like Arial for debug-related displays, or maybe I'll create/use a custom font.
## Node Graph Positioning
After getting all this working, I was really excited to finally get closer to actually finishing the project. I felt like I had finally started checking a bunch of important boxes, so I started actually working on the 'ghost house' part of Pac-Man.
The ghost house is very different from the rest of the game as it doesn't render the tiles in the same way, on a static grid.
It's actually offset by 8 pixels, and the ghosts exit the house between two tiles, requiring a lot more customization and flexibility in my
rendering system.
I spent a fair bit of time trying to implement hacks into this to get it working, but I eventually gave up after realizing that there's no solution here using my existing system.
I remembered how I was having trouble with the transition states between the two tunnels (still not resolved), and it felt quite similar to my current situation; the inflexibility of my integer grid system was the main cause of the issue.
I started thinking of different ways to approach movement, and realized that the Pac-Man and Ghost's movement is quite limited and simple like railroad tracks, like nodes on a graph. Both problems could be solved by switching to a graph - most of the maze would look like a grid, each cell connected to eachother.
By representing one's position as a distance from the start node towards an end node, I could achieve smooth linear movement between nodes
that, for the most part, appears to use a cell-based grid, which also allowing more customized offsets.
The bigger downside was that I had to toss out almost all the existing code for the game, only keeping the audio and most of the texturing system, as well as the initialization code. It also meant I was using floating points for a lot of internal state, which is not ideal.
This ended up being okay though, as I was able to clean up a lot of gross code, and the system ended up being easier to work with by comparison.
[code-review-video]: https://www.youtube.com/watch?v=OKs_JewEeOo
[code-review-thumbnail]: https://img.youtube.com/vi/OKs_JewEeOo/hqdefault.jpg
[fighting-lifetimes-1]: https://devcry.heiho.net/html/2022/20220709-rust-and-sdl2-fighting-with-lifetimes.html
[fighting-lifetimes-2]: https://devcry.heiho.net/html/2022/20220716-rust-and-sdl2-fighting-with-lifetimes-2.html
[fighting-lifetimes-3]: https://devcry.heiho.net/html/2022/20220724-rust-and-sdl2-fighting-with-lifetimes-3.html
[ruggrogue]: https://tung.github.io/ruggrogue/
[ruggrogue]: https://tung.github.io/ruggrogue/

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -1,23 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@font-face {
font-family: "Liberation Mono";
src:
url("LiberationMono.woff2") format("woff2"),
url("LiberationMono.woff") format("woff");
font-weight: normal;
font-style: normal;
font-display: swap;
}
canvas {
@apply w-full h-[65vh] min-h-[200px] block mx-auto bg-black;
}
.code {
@apply px-1 rounded font-mono bg-zinc-900 border border-zinc-700 lowercase;
}
/*# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbInN0eWxlcy5zY3NzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBIiwiZmlsZSI6ImJ1aWxkLmNzcyIsInNvdXJjZXNDb250ZW50IjpbIkB0YWlsd2luZCBiYXNlO1xuQHRhaWx3aW5kIGNvbXBvbmVudHM7XG5AdGFpbHdpbmQgdXRpbGl0aWVzO1xuXG5AZm9udC1mYWNlIHtcbiAgICBmb250LWZhbWlseTogXCJMaWJlcmF0aW9uIE1vbm9cIjtcbiAgICBzcmM6XG4gICAgICAgIHVybChcIkxpYmVyYXRpb25Nb25vLndvZmYyXCIpIGZvcm1hdChcIndvZmYyXCIpLFxuICAgICAgICB1cmwoXCJMaWJlcmF0aW9uTW9uby53b2ZmXCIpIGZvcm1hdChcIndvZmZcIik7XG4gICAgZm9udC13ZWlnaHQ6IG5vcm1hbDtcbiAgICBmb250LXN0eWxlOiBub3JtYWw7XG4gICAgZm9udC1kaXNwbGF5OiBzd2FwO1xufVxuXG5jYW52YXMge1xuICAgIEBhcHBseSB3LWZ1bGwgaC1bNjV2aF0gbWluLWgtWzIwMHB4XSBibG9jayBteC1hdXRvIGJnLWJsYWNrO1xufVxuXG4uY29kZSB7XG4gICAgQGFwcGx5IHB4LTEgcm91bmRlZCBmb250LW1vbm8gYmctemluYy05MDAgYm9yZGVyIGJvcmRlci16aW5jLTcwMCBsb3dlcmNhc2U7XG59XG4iXX0= */

View File

@@ -2,12 +2,25 @@
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pac-Man Arcade</title>
<title>Pac-Man in Rust</title>
<link rel="stylesheet" href="build.css" />
<style>
/* Minimal fallback to prevent white flash and canvas pop-in before CSS loads */
html,
body {
background: #000;
color: #facc15;
margin: 0;
text-align: center;
}
#canvas {
display: block;
margin: 1.5rem auto;
background: #000;
}
</style>
</head>
<body class="bg-black text-yellow-400 text-center">
<body class="bg-black text-yellow-400 text-center min-h-screen">
<a
href="https://github.com/Xevion/Pac-Man"
class="absolute top-0 right-0"
@@ -17,7 +30,7 @@
width="80"
height="80"
viewBox="0 0 250 250"
class="fill-yellow-400 text-black"
class="fill-yellow-400 text-white"
aria-hidden="true"
>
<path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path>
@@ -31,33 +44,54 @@
></path>
</svg>
</a>
<h1 class="text-4xl mt-10 scaled-text">Pac-Man Arcade</h1>
<p class="text-lg mt-5 scaled-text">
Welcome to the Pac-Man Arcade! Use the controls below to play.
</p>
<canvas
id="canvas"
class="block mx-auto mt-5"
width="800"
height="600"
></canvas>
<div class="mt-10">
<span
class="inline-block mx-2 px-4 py-2 bg-yellow-400 text-black rounded scaled-text"
>&larr; &uarr; &rarr; &darr; Move</span
>
<span
class="inline-block mx-2 px-4 py-2 bg-yellow-400 text-black rounded scaled-text"
>Space Change Sprite</span
>
<span
class="inline-block mx-2 px-4 py-2 bg-yellow-400 text-black rounded scaled-text"
>Shift + &uarr;&darr; Change Volume</span
>
<div class="min-h-screen flex flex-col">
<header class="pt-10">
<h1 class="text-4xl arcade-title scaled-text">Pac-Man in Rust</h1>
</header>
<main class="flex-1 flex items-center justify-center px-4">
<div class="w-full max-w-5xl">
<canvas
id="canvas"
class="block mx-auto bg-black w-full max-w-[90vw] h-auto mt-5 rounded-xl shadow-[inset_0_0_0_2px_rgba(255,255,255,0.12),0_10px_30px_rgba(0,0,0,0.8)]"
></canvas>
<div
class="mt-8 flex flex-wrap gap-3 justify-center items-center text-sm"
>
<span class="code">&larr; &uarr; &rarr; &darr;</span>
<span class="opacity-70">Move</span>
<span class="mx-2 opacity-30">|</span>
<span class="code">Space</span>
<span class="opacity-70">Toggle Debug</span>
<span class="mx-2 opacity-30">|</span>
<span class="code">P</span>
<span class="opacity-70">Pause / Unpause</span>
<span class="mx-2 opacity-30">|</span>
<span class="code">M</span>
<span class="opacity-70">Mute / Unmute</span>
</div>
</div>
</main>
</div>
<script type="text/javascript">
const canvas = document.getElementById("canvas");
var Module = {
canvas: document.getElementById("canvas"),
canvas: canvas,
preRun: [
() => {
[...canvas.classList]
.filter((className) => className.includes("shadow-"))
.forEach((className) => canvas.classList.remove(className));
},
],
};
</script>
<script type="text/javascript" src="pacman.js"></script>

28
assets/site/styles.css Normal file
View File

@@ -0,0 +1,28 @@
@import "tailwindcss";
@font-face {
font-family: "TerminalVector";
src: url("TerminalVector.ttf");
font-weight: normal;
font-style: normal;
font-display: swap;
}
/* Key badge styling */
.code {
@apply px-3 py-1 rounded-md font-mono text-[0.9em] lowercase inline-block align-middle;
background: rgba(250, 204, 21, 0.08); /* yellow-400 at low opacity */
border: 1px solid rgba(250, 204, 21, 0.25);
color: #fde68a; /* lighter yellow for readability */
font-family: "TerminalVector", ui-monospace, Consolas, "Courier New",
monospace;
}
/* Title styling */
.arcade-title {
font-family: "TerminalVector", ui-monospace, Consolas, "Courier New",
monospace;
letter-spacing: 0.08em;
text-shadow: 0 0 18px rgba(250, 204, 21, 0.15),
0 0 2px rgba(255, 255, 255, 0.25);
}

View File

@@ -1,21 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@font-face {
font-family: "Liberation Mono";
src:
url("LiberationMono.woff2") format("woff2"),
url("LiberationMono.woff") format("woff");
font-weight: normal;
font-style: normal;
font-display: swap;
}
canvas {
@apply w-full h-[65vh] min-h-[200px] block mx-auto bg-black;
}
.code {
@apply px-1 rounded font-mono bg-zinc-900 border border-zinc-700 lowercase;
}

View File

@@ -1,74 +0,0 @@
#!/bin/bash
set -eu
release='false'
serve='false'
skip_emsdk='false'
clean='false'
print_usage() {
printf "Usage: -erdsc\n"
printf " -e: Skip EMSDK setup (GitHub workflow only)\n"
printf " -r: Build in release mode\n"
printf " -d: Build in debug mode\n"
printf " -s: Serve the WASM files once built\n"
printf " -c: Clean the target/dist directory\n"
}
while getopts 'erdsc' flag; do
case "${flag}" in
e) skip_emsdk='true' ;;
r) release='true' ;;
d) release='false' ;; # doesn't actually do anything, but last flag wins
s) serve='true' ;;
c) clean='true' ;;
*)
print_usage
exit 1
;;
esac
done
if [ "$clean" = 'true' ]; then
echo "Cleaning target directory"
cargo clean
rm -rf ./dist/
fi
if [ "$skip_emsdk" = 'false' ]; then
echo "Activating Emscripten"
# SDL2-TTF requires 3.1.43, fails to build on latest
../emsdk/emsdk activate 3.1.43
source ../emsdk/emsdk_env.sh
fi
echo "Building WASM with Emscripten"
build_type='debug'
if [ "$release" = 'true' ]; then
cargo build --target=wasm32-unknown-emscripten --release
build_type='release'
else
cargo build --target=wasm32-unknown-emscripten
fi
echo "Generating CSS"
pnpx postcss-cli ./assets/site/styles.scss -o ./assets/site/build.css
echo "Copying WASM files"
mkdir -p dist
output_folder="target/wasm32-unknown-emscripten/$build_type"
cp assets/site/{build.css,favicon.ico,index.html} dist
cp $output_folder/pacman.{wasm,js} dist
if [ -f $output_folder/deps/pacman.data ]; then
cp $output_folder/deps/pacman.data dist
fi
if [ -f $output_folder/pacman.wasm.map ]; then
cp $output_folder/pacman.wasm.map dist
fi
if [ "$serve" = 'true' ]; then
echo "Serving WASM with Emscripten"
python3 -m http.server -d ./dist/ 8080
fi

201
build.ts
View File

@@ -1,201 +0,0 @@
import { $ } from "bun";
// This is a bun script, run with `bun run build.ts`
import * as path from "path";
import * as fs from "fs/promises";
async function clean() {
console.log("Cleaning...");
await $`cargo clean`;
await $`rm -rf ./dist/`;
console.log("Cleaned...");
}
async function setupEmscripten() {
const emsdkDir = "./emsdk";
const emsdkExists = await fs
.access(emsdkDir)
.then(() => true)
.catch(() => false);
if (!emsdkExists) {
console.log("Cloning Emscripten SDK...");
await $`git clone https://github.com/emscripten-core/emsdk.git`;
} else {
console.log("Emscripten SDK already exists, skipping clone.");
}
const emscriptenToolchainPath = path.join(emsdkDir, "upstream", "emscripten");
const toolchainInstalled = await fs
.access(emscriptenToolchainPath)
.then(() => true)
.catch(() => false);
if (!toolchainInstalled) {
console.log("Installing Emscripten toolchain...");
await $`./emsdk/emsdk install 3.1.43`;
} else {
console.log(
"Emscripten toolchain 3.1.43 already installed, skipping install."
);
}
console.log("Activating Emscripten...");
await $`./emsdk/emsdk activate 3.1.43`;
console.log("Emscripten activated.");
// Set EMSDK environment variable for subsequent commands
process.env.EMSDK = path.resolve(emsdkDir);
const emsdkPython = path.join(path.resolve(emsdkDir), "python");
const emsdkNode = path.join(path.resolve(emsdkDir), "node", "16.20.0_64bit"); // Adjust node version if needed
const emsdkBin = path.join(path.resolve(emsdkDir), "upstream", "emscripten");
process.env.PATH = `${emsdkPython}:${emsdkNode}:${emsdkBin}:${process.env.PATH}`;
}
async function buildWeb(release: boolean) {
console.log("Building WASM with Emscripten...");
const rustcFlags = [
"-C",
"link-arg=--preload-file",
"-C",
"link-arg=assets",
].join(" ");
if (release) {
await $`env RUSTFLAGS=${rustcFlags} cargo build --target=wasm32-unknown-emscripten --release`;
} else {
await $`env RUSTFLAGS=${rustcFlags} cargo build --target=wasm32-unknown-emscripten`;
}
console.log("Generating CSS...");
await $`pnpx postcss-cli ./assets/site/styles.scss -o ./assets/site/build.css`;
console.log("Copying WASM files...");
const buildType = release ? "release" : "debug";
const outputFolder = `target/wasm32-unknown-emscripten/${buildType}`;
await $`mkdir -p dist`;
await $`cp assets/site/index.html dist`;
await $`cp assets/site/*.woff* dist`;
await $`cp assets/site/build.css dist`;
await $`cp assets/site/favicon.ico dist`;
await $`cp ${outputFolder}/pacman.wasm dist`;
await $`cp ${outputFolder}/pacman.js dist`;
// Check if .data file exists before copying
try {
await fs.access(`${outputFolder}/pacman.data`);
await $`cp ${outputFolder}/pacman.data dist`;
} catch (e) {
console.log("No pacman.data file found, skipping copy.");
}
// Check if .map file exists before copying
try {
await fs.access(`${outputFolder}/pacman.wasm.map`);
await $`cp ${outputFolder}/pacman.wasm.map dist`;
} catch (e) {
console.log("No pacman.wasm.map file found, skipping copy.");
}
console.log("WASM files copied.");
}
async function serve() {
console.log("Serving WASM with Emscripten...");
await $`python3 -m http.server -d ./dist/ 8080`;
}
async function main() {
const args = process.argv.slice(2);
let release = false;
let serveFiles = false;
let skipEmscriptenSetup = false;
let cleanProject = false;
let target = "web"; // Default target
for (const arg of args) {
switch (arg) {
case "-r":
release = true;
break;
case "-s":
serveFiles = true;
break;
case "-e":
skipEmscriptenSetup = true;
break;
case "-c":
cleanProject = true;
break;
case "--target=linux":
target = "linux";
break;
case "--target=windows":
target = "windows";
break;
case "--target=web":
target = "web";
break;
case "-h":
case "--help":
console.log(`
Usage: ts-node build.ts [options]
Options:
-r Build in release mode
-s Serve the WASM files once built (for web target)
-e Skip EMSDK setup (GitHub workflow only)
-c Clean the target/dist directory
--target=[web|linux|windows] Specify target platform (default: web)
-h, --help Show this help message
`);
return;
}
}
if (cleanProject) {
await clean();
}
if (!skipEmscriptenSetup && target === "web") {
await setupEmscripten();
}
switch (target) {
case "web":
await buildWeb(release);
if (serveFiles) {
await serve();
}
break;
case "linux":
console.log("Building for Linux...");
if (release) {
await $`cargo build --release`;
} else {
await $`cargo build`;
}
console.log("Linux build complete.");
break;
case "windows":
console.log("Building for Windows...");
if (release) {
await $`cargo build --release --target=x86_64-pc-windows-msvc`; // Assuming MSVC toolchain
} else {
await $`cargo build --target=x86_64-pc-windows-msvc`;
}
console.log("Windows build complete.");
break;
default:
console.error("Invalid target specified.");
process.exit(1);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

154
src/app.rs Normal file
View File

@@ -0,0 +1,154 @@
use std::time::{Duration, Instant};
use anyhow::{anyhow, Result};
use sdl2::event::{Event, WindowEvent};
use sdl2::keyboard::Keycode;
use sdl2::render::{Canvas, ScaleMode, Texture, TextureCreator};
use sdl2::video::{Window, WindowContext};
use sdl2::EventPump;
use tracing::{error, event};
use crate::constants::{CANVAS_SIZE, LOOP_TIME, SCALE};
use crate::game::Game;
#[cfg(target_os = "emscripten")]
use crate::emscripten;
#[cfg(not(target_os = "emscripten"))]
fn sleep(value: Duration) {
spin_sleep::sleep(value);
}
#[cfg(target_os = "emscripten")]
fn sleep(value: Duration) {
emscripten::emscripten::sleep(value.as_millis() as u32);
}
pub struct App<'a> {
game: Game,
canvas: Canvas<Window>,
event_pump: EventPump,
backbuffer: Texture<'a>,
paused: bool,
last_tick: Instant,
}
impl App<'_> {
pub fn new() -> Result<Self> {
let sdl_context = sdl2::init().map_err(|e| anyhow!(e))?;
let video_subsystem = sdl_context.video().map_err(|e| anyhow!(e))?;
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
.window(
"Pac-Man",
(CANVAS_SIZE.x as f32 * SCALE).round() as u32,
(CANVAS_SIZE.y as f32 * SCALE).round() as u32,
)
.resizable()
.position_centered()
.build()?;
let mut canvas = window.into_canvas().build()?;
canvas.set_logical_size(CANVAS_SIZE.x, CANVAS_SIZE.y)?;
let texture_creator_static: &'static TextureCreator<WindowContext> = Box::leak(Box::new(canvas.texture_creator()));
let mut game = Game::new(texture_creator_static, &ttf_context, &audio_subsystem);
game.audio.set_mute(cfg!(debug_assertions));
let mut backbuffer = texture_creator_static.create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y)?;
backbuffer.set_scale_mode(ScaleMode::Nearest);
let event_pump = sdl_context.event_pump().map_err(|e| anyhow!(e))?;
// Initial draw
game.draw(&mut canvas, &mut backbuffer)?;
game.present_backbuffer(&mut canvas, &backbuffer)?;
Ok(Self {
game,
canvas,
event_pump,
backbuffer,
paused: false,
last_tick: Instant::now(),
})
}
pub fn run(&mut self) -> bool {
{
let start = Instant::now();
for event in self.event_pump.poll_iter() {
match event {
Event::Window { win_event, .. } => match win_event {
WindowEvent::Hidden => {
event!(tracing::Level::DEBUG, "Window hidden");
}
WindowEvent::Shown => {
event!(tracing::Level::DEBUG, "Window shown");
}
_ => {}
},
// It doesn't really make sense to have this available in the browser
#[cfg(not(target_os = "emscripten"))]
Event::Quit { .. }
| Event::KeyDown {
keycode: Some(Keycode::Escape) | Some(Keycode::Q),
..
} => {
event!(tracing::Level::INFO, "Exit requested. Exiting...");
return false;
}
Event::KeyDown {
keycode: Some(Keycode::P),
..
} => {
self.paused = !self.paused;
event!(tracing::Level::INFO, "{}", if self.paused { "Paused" } else { "Unpaused" });
}
Event::KeyDown {
keycode: Some(Keycode::Space),
..
} => {
self.game.debug_mode = !self.game.debug_mode;
}
Event::KeyDown { keycode, .. } => {
self.game.keyboard_event(keycode.unwrap());
}
_ => {}
}
}
let dt = self.last_tick.elapsed().as_secs_f32();
self.last_tick = Instant::now();
if !self.paused {
self.game.tick(dt);
if let Err(e) = self.game.draw(&mut self.canvas, &mut self.backbuffer) {
error!("Failed to draw game: {e}");
}
if let Err(e) = self.game.present_backbuffer(&mut self.canvas, &self.backbuffer) {
error!("Failed to present backbuffer: {e}");
}
}
if start.elapsed() < LOOP_TIME {
let time = LOOP_TIME.saturating_sub(start.elapsed());
if time != Duration::ZERO {
sleep(time);
}
} else {
event!(
tracing::Level::WARN,
"Game loop behind schedule by: {:?}",
start.elapsed() - LOOP_TIME
);
}
true
}
}
}

View File

@@ -1,3 +1,4 @@
#![allow(dead_code)]
//! Cross-platform asset loading abstraction.
//! On desktop, assets are embedded using include_bytes!; on Emscripten, assets are loaded from the filesystem.
@@ -5,7 +6,6 @@ use std::borrow::Cow;
use std::io;
use thiserror::Error;
#[allow(dead_code)]
#[derive(Error, Debug)]
pub enum AssetError {
#[error("IO error: {0}")]
@@ -22,7 +22,6 @@ pub enum Asset {
Wav2,
Wav3,
Wav4,
FontKonami,
Atlas,
AtlasJson,
// Add more as needed
@@ -37,7 +36,6 @@ impl Asset {
Wav2 => "sound/waka/2.ogg",
Wav3 => "sound/waka/3.ogg",
Wav4 => "sound/waka/4.ogg",
FontKonami => "konami.ttf",
Atlas => "atlas.png",
AtlasJson => "atlas.json",
}
@@ -54,7 +52,6 @@ mod imp {
Asset::Wav2 => Cow::Borrowed(include_bytes!("../assets/game/sound/waka/2.ogg")),
Asset::Wav3 => Cow::Borrowed(include_bytes!("../assets/game/sound/waka/3.ogg")),
Asset::Wav4 => Cow::Borrowed(include_bytes!("../assets/game/sound/waka/4.ogg")),
Asset::FontKonami => Cow::Borrowed(include_bytes!("../assets/game/konami.ttf")),
Asset::Atlas => Cow::Borrowed(include_bytes!("../assets/game/atlas.png")),
Asset::AtlasJson => Cow::Borrowed(include_bytes!("../assets/game/atlas.json")),
}

View File

@@ -10,23 +10,46 @@ const SOUND_ASSETS: [Asset; 4] = [Asset::Wav1, Asset::Wav2, Asset::Wav3, Asset::
/// The audio system for the game.
///
/// This struct is responsible for initializing the audio device, loading sounds,
/// and playing them.
/// and playing them. If audio fails to initialize, it will be disabled and all
/// functions will silently do nothing.
#[allow(dead_code)]
pub struct Audio {
_mixer_context: mixer::Sdl2MixerContext,
_mixer_context: Option<mixer::Sdl2MixerContext>,
sounds: Vec<Chunk>,
next_sound_index: usize,
muted: bool,
disabled: bool,
}
impl Default for Audio {
fn default() -> Self {
Self::new()
}
}
impl Audio {
/// Creates a new `Audio` instance.
///
/// If audio fails to initialize, the audio system will be disabled and
/// all functions will silently do nothing.
pub fn new() -> Self {
let frequency = 44100;
let format = DEFAULT_FORMAT;
let channels = 4;
let chunk_size = 256; // 256 is minimum for emscripten
mixer::open_audio(frequency, format, 1, chunk_size).expect("Failed to open audio");
// Try to open audio, but don't panic if it fails
if let Err(e) = mixer::open_audio(frequency, format, 1, chunk_size) {
tracing::warn!("Failed to open audio: {}. Audio will be disabled.", e);
return Self {
_mixer_context: None,
sounds: Vec::new(),
next_sound_index: 0,
muted: false,
disabled: true,
};
}
mixer::allocate_channels(channels);
// set channel volume
@@ -34,30 +57,72 @@ impl Audio {
mixer::Channel(i).set_volume(32);
}
let mixer_context = mixer::init(InitFlag::OGG).expect("Failed to initialize SDL2_mixer");
// Try to initialize mixer, but don't panic if it fails
let mixer_context = match mixer::init(InitFlag::OGG) {
Ok(ctx) => ctx,
Err(e) => {
tracing::warn!("Failed to initialize SDL2_mixer: {}. Audio will be disabled.", e);
return Self {
_mixer_context: None,
sounds: Vec::new(),
next_sound_index: 0,
muted: false,
disabled: true,
};
}
};
let sounds: Vec<Chunk> = SOUND_ASSETS
.iter()
.enumerate()
.map(|(i, asset)| {
let data = get_asset_bytes(*asset).expect("Failed to load sound asset");
let rwops = RWops::from_bytes(&data).unwrap_or_else(|_| panic!("Failed to create RWops for sound {}", i + 1));
rwops
.load_wav()
.unwrap_or_else(|_| panic!("Failed to load sound {} from asset API", i + 1))
})
.collect();
// Try to load sounds, but don't panic if any fail
let mut sounds = Vec::new();
for (i, asset) in SOUND_ASSETS.iter().enumerate() {
match get_asset_bytes(*asset) {
Ok(data) => match RWops::from_bytes(&data) {
Ok(rwops) => match rwops.load_wav() {
Ok(chunk) => sounds.push(chunk),
Err(e) => {
tracing::warn!("Failed to load sound {} from asset API: {}", i + 1, e);
}
},
Err(e) => {
tracing::warn!("Failed to create RWops for sound {}: {}", i + 1, e);
}
},
Err(e) => {
tracing::warn!("Failed to load sound asset {}: {}", i + 1, e);
}
}
}
// If no sounds loaded successfully, disable audio
if sounds.is_empty() {
tracing::warn!("No sounds loaded successfully. Audio will be disabled.");
return Self {
_mixer_context: Some(mixer_context),
sounds: Vec::new(),
next_sound_index: 0,
muted: false,
disabled: true,
};
}
Audio {
_mixer_context: mixer_context,
_mixer_context: Some(mixer_context),
sounds,
next_sound_index: 0,
muted: false,
disabled: false,
}
}
/// Plays the "eat" sound effect.
///
/// If audio is disabled or muted, this function does nothing.
#[allow(dead_code)]
pub fn eat(&mut self) {
if self.disabled || self.muted || self.sounds.is_empty() {
return;
}
if let Some(chunk) = self.sounds.get(self.next_sound_index) {
match mixer::Channel(0).play(chunk, 0) {
Ok(channel) => {
@@ -72,16 +137,28 @@ impl Audio {
}
/// Instantly mute or unmute all channels.
///
/// If audio is disabled, this function does nothing.
pub fn set_mute(&mut self, mute: bool) {
let channels = 4;
let volume = if mute { 0 } else { 32 };
for i in 0..channels {
mixer::Channel(i).set_volume(volume);
if !self.disabled {
let channels = 4;
let volume = if mute { 0 } else { 32 };
for i in 0..channels {
mixer::Channel(i).set_volume(volume);
}
}
self.muted = mute;
}
/// Returns `true` if the audio is muted.
pub fn is_muted(&self) -> bool {
self.muted
}
/// Returns `true` if the audio system is disabled.
#[allow(dead_code)]
pub fn is_disabled(&self) -> bool {
self.disabled
}
}

View File

@@ -1,7 +1,11 @@
//! This module contains all the constants used in the game.
use std::time::Duration;
use glam::UVec2;
pub const LOOP_TIME: Duration = Duration::from_nanos((1_000_000_000.0 / 60.0) as u64);
/// The size of each cell, in pixels.
pub const CELL_SIZE: u32 = 8;
/// The size of the game board, in cells.
@@ -33,64 +37,10 @@ pub enum MapTile {
Pellet,
/// A power pellet.
PowerPellet,
/// A starting position for an entity.
StartingPosition(u8),
/// A tunnel tile.
Tunnel,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u8)]
pub enum FruitType {
Cherry,
Strawberry,
Orange,
Apple,
Melon,
Galaxian,
Bell,
Key,
}
impl FruitType {
pub const ALL: [FruitType; 8] = [
FruitType::Cherry,
FruitType::Strawberry,
FruitType::Orange,
FruitType::Apple,
FruitType::Melon,
FruitType::Galaxian,
FruitType::Bell,
FruitType::Key,
];
pub fn score(self) -> u32 {
match self {
FruitType::Cherry => 100,
FruitType::Strawberry => 300,
FruitType::Orange => 500,
FruitType::Apple => 700,
FruitType::Melon => 1000,
FruitType::Galaxian => 2000,
FruitType::Bell => 3000,
FruitType::Key => 5000,
}
}
pub fn index(self) -> usize {
match self {
FruitType::Cherry => 0,
FruitType::Strawberry => 1,
FruitType::Orange => 2,
FruitType::Apple => 3,
FruitType::Melon => 4,
FruitType::Galaxian => 5,
FruitType::Bell => 6,
FruitType::Key => 7,
}
}
}
/// The raw layout of the game board, as a 2D array of characters.
pub const RAW_BOARD: [&str; BOARD_CELL_SIZE.y as usize] = [
"############################",
@@ -104,11 +54,11 @@ pub const RAW_BOARD: [&str; BOARD_CELL_SIZE.y as usize] = [
"#......##....##....##......#",
"######.##### ## #####.######",
" #.##### ## #####.# ",
" #.## 1 ##.# ",
" #.## ###==### ##.# ",
"######.## # # ##.######",
"T . #2 3 4 # . T",
"######.## # # ##.######",
" #.## == ##.# ",
" #.## ######## ##.# ",
"######.## ######## ##.######",
"T . ######## . T",
"######.## ######## ##.######",
" #.## ######## ##.# ",
" #.## ##.# ",
" #.## ######## ##.# ",
@@ -116,7 +66,7 @@ pub const RAW_BOARD: [&str; BOARD_CELL_SIZE.y as usize] = [
"#............##............#",
"#.####.#####.##.#####.####.#",
"#.####.#####.##.#####.####.#",
"#o..##.......0 .......##..o#",
"#o..##.......X .......##..o#",
"###.##.##.########.##.##.###",
"###.##.##.########.##.##.###",
"#......##....##....##......#",

View File

@@ -1,73 +0,0 @@
//! Debug rendering utilities for Pac-Man.
use crate::{
constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE},
entity::blinky::Blinky,
map::Map,
};
use glam::{IVec2, UVec2};
use sdl2::{pixels::Color, render::Canvas, video::Window};
#[derive(PartialEq, Eq, Clone, Copy)]
pub enum DebugMode {
None,
Grid,
Pathfinding,
ValidPositions,
}
pub struct DebugRenderer;
impl DebugRenderer {
pub fn draw_cell(canvas: &mut Canvas<Window>, _map: &Map, cell: UVec2, color: Color) {
let position = Map::cell_to_pixel(cell);
canvas.set_draw_color(color);
canvas
.draw_rect(sdl2::rect::Rect::new(position.x, position.y, CELL_SIZE, CELL_SIZE))
.expect("Could not draw rectangle");
}
pub fn draw_debug_grid(canvas: &mut Canvas<Window>, map: &Map, pacman_cell: UVec2) {
for x in 0..BOARD_CELL_SIZE.x {
for y in 0..BOARD_CELL_SIZE.y {
let tile = map.get_tile(IVec2::new(x as i32, y as i32)).unwrap_or(MapTile::Empty);
let cell = UVec2::new(x, y);
let mut color = None;
if cell == pacman_cell {
Self::draw_cell(canvas, map, cell, Color::CYAN);
} else {
color = match tile {
MapTile::Empty => None,
MapTile::Wall => Some(Color::BLUE),
MapTile::Pellet => Some(Color::RED),
MapTile::PowerPellet => Some(Color::MAGENTA),
MapTile::StartingPosition(_) => Some(Color::GREEN),
MapTile::Tunnel => Some(Color::CYAN),
};
}
if let Some(color) = color {
Self::draw_cell(canvas, map, cell, color);
}
}
}
}
pub fn draw_next_cell(canvas: &mut Canvas<Window>, map: &Map, next_cell: UVec2) {
Self::draw_cell(canvas, map, next_cell, Color::YELLOW);
}
pub fn draw_valid_positions(canvas: &mut Canvas<Window>, map: &mut Map) {
let valid_positions_vec = map.get_valid_playable_positions().clone();
for &pos in &valid_positions_vec {
Self::draw_cell(canvas, map, pos, Color::RGB(255, 140, 0));
}
}
pub fn draw_pathfinding(canvas: &mut Canvas<Window>, blinky: &Blinky, map: &Map) {
let target = blinky.get_target_tile();
if let Some((path, _)) = blinky.get_path_to_target(target.as_uvec2()) {
for pos in &path {
Self::draw_cell(canvas, map, *pos, Color::YELLOW);
}
}
}
}

View File

@@ -1,95 +0,0 @@
use std::cell::RefCell;
use std::rc::Rc;
use crate::entity::direction::Direction;
use crate::entity::ghost::{Ghost, GhostMode, GhostType};
use crate::entity::pacman::Pacman;
use crate::entity::{Entity, Moving, Renderable, StaticEntity};
use crate::map::Map;
use crate::texture::sprite::SpriteAtlas;
use anyhow::Result;
use glam::{IVec2, UVec2};
use sdl2::render::WindowCanvas;
pub struct Blinky {
ghost: Ghost,
}
impl Blinky {
pub fn new(
starting_position: UVec2,
atlas: Rc<RefCell<SpriteAtlas>>,
map: Rc<RefCell<Map>>,
pacman: Rc<RefCell<Pacman>>,
) -> Blinky {
Blinky {
ghost: Ghost::new(GhostType::Blinky, starting_position, atlas, map, pacman),
}
}
/// Gets Blinky's chase target - directly targets Pac-Man's current position
pub fn get_chase_target(&self) -> IVec2 {
let pacman = self.ghost.pacman.borrow();
let cell = pacman.base().cell_position;
IVec2::new(cell.x as i32, cell.y as i32)
}
pub fn set_mode(&mut self, mode: GhostMode) {
self.ghost.set_mode(mode);
}
pub fn tick(&mut self) {
self.ghost.tick();
}
}
impl Entity for Blinky {
fn base(&self) -> &StaticEntity {
self.ghost.base.base()
}
}
impl Renderable for Blinky {
fn render(&mut self, canvas: &mut WindowCanvas) -> Result<()> {
self.ghost.render(canvas)
}
}
impl Moving for Blinky {
fn tick_movement(&mut self) {
self.ghost.tick_movement();
}
fn update_cell_position(&mut self) {
self.ghost.update_cell_position();
}
fn next_cell(&self, direction: Option<Direction>) -> IVec2 {
self.ghost.next_cell(direction)
}
fn is_wall_ahead(&self, direction: Option<Direction>) -> bool {
self.ghost.is_wall_ahead(direction)
}
fn handle_tunnel(&mut self) -> bool {
self.ghost.handle_tunnel()
}
fn is_grid_aligned(&self) -> bool {
self.ghost.is_grid_aligned()
}
fn set_direction_if_valid(&mut self, new_direction: Direction) -> bool {
self.ghost.set_direction_if_valid(new_direction)
}
}
// Allow direct access to ghost fields
impl std::ops::Deref for Blinky {
type Target = Ghost;
fn deref(&self) -> &Self::Target {
&self.ghost
}
}
impl std::ops::DerefMut for Blinky {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.ghost
}
}

View File

@@ -1,10 +1,6 @@
//! This module defines the `Direction` enum, which is used to represent the
//! direction of an entity.
use glam::IVec2;
use sdl2::keyboard::Keycode;
/// An enum representing the direction of an entity.
#[derive(Debug, Copy, Clone, PartialEq)]
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
pub enum Direction {
Up,
Down,
@@ -13,48 +9,29 @@ pub enum Direction {
}
impl Direction {
/// Returns the angle of the direction in degrees.
pub fn angle(&self) -> f64 {
match self {
Direction::Right => 0f64,
Direction::Down => 90f64,
Direction::Left => 180f64,
Direction::Up => 270f64,
}
}
/// Returns the offset of the direction as a tuple of (x, y).
pub fn offset(&self) -> IVec2 {
match self {
Direction::Right => IVec2::new(1, 0),
Direction::Down => IVec2::new(0, 1),
Direction::Left => IVec2::new(-1, 0),
Direction::Up => IVec2::new(0, -1),
}
}
/// Returns the opposite direction.
pub fn opposite(&self) -> Direction {
match self {
Direction::Right => Direction::Left,
Direction::Up => Direction::Down,
Direction::Down => Direction::Up,
Direction::Left => Direction::Right,
Direction::Up => Direction::Down,
Direction::Right => Direction::Left,
}
}
/// Creates a `Direction` from a `Keycode`.
///
/// # Arguments
///
/// * `keycode` - The keycode to convert.
pub fn from_keycode(keycode: Keycode) -> Option<Direction> {
match keycode {
Keycode::D | Keycode::Right => Some(Direction::Right),
Keycode::A | Keycode::Left => Some(Direction::Left),
Keycode::W | Keycode::Up => Some(Direction::Up),
Keycode::S | Keycode::Down => Some(Direction::Down),
_ => None,
pub fn as_ivec2(&self) -> IVec2 {
(*self).into()
}
}
impl From<Direction> for IVec2 {
fn from(dir: Direction) -> Self {
match dir {
Direction::Up => -IVec2::Y,
Direction::Down => IVec2::Y,
Direction::Left => -IVec2::X,
Direction::Right => IVec2::X,
}
}
}
pub const DIRECTIONS: [Direction; 4] = [Direction::Up, Direction::Down, Direction::Left, Direction::Right];

View File

@@ -1,110 +0,0 @@
//! Edible entity for Pac-Man: pellets, power pellets, and fruits.
use crate::constants::{FruitType, MapTile, BOARD_CELL_SIZE};
use crate::entity::{Entity, Renderable, StaticEntity};
use crate::map::Map;
use crate::texture::animated::AnimatedTexture;
use crate::texture::blinking::BlinkingTexture;
use anyhow::Result;
use glam::{IVec2, UVec2};
use sdl2::render::WindowCanvas;
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EdibleKind {
Pellet,
PowerPellet,
Fruit(FruitType),
}
pub enum EdibleSprite {
Pellet(AnimatedTexture),
PowerPellet(BlinkingTexture),
}
pub struct Edible {
pub base: StaticEntity,
pub kind: EdibleKind,
pub sprite: EdibleSprite,
}
impl Edible {
pub fn new_pellet(cell_position: UVec2, sprite: AnimatedTexture) -> Self {
let pixel_position = Map::cell_to_pixel(cell_position);
Edible {
base: StaticEntity::new(pixel_position, cell_position),
kind: EdibleKind::Pellet,
sprite: EdibleSprite::Pellet(sprite),
}
}
pub fn new_power_pellet(cell_position: UVec2, sprite: BlinkingTexture) -> Self {
let pixel_position = Map::cell_to_pixel(cell_position);
Edible {
base: StaticEntity::new(pixel_position, cell_position),
kind: EdibleKind::PowerPellet,
sprite: EdibleSprite::PowerPellet(sprite),
}
}
/// Checks collision with Pac-Man (or any entity)
pub fn collide(&self, pacman: &dyn Entity) -> bool {
self.base.cell_position == pacman.base().cell_position
}
}
impl Entity for Edible {
fn base(&self) -> &StaticEntity {
&self.base
}
}
impl Renderable for Edible {
fn render(&mut self, canvas: &mut WindowCanvas) -> Result<()> {
let pos = self.base.pixel_position;
let dest = match &mut self.sprite {
EdibleSprite::Pellet(sprite) => {
let tile = sprite.current_tile();
let x = pos.x + ((crate::constants::CELL_SIZE as i32 - tile.size.x as i32) / 2);
let y = pos.y + ((crate::constants::CELL_SIZE as i32 - tile.size.y as i32) / 2);
sdl2::rect::Rect::new(x, y, tile.size.x as u32, tile.size.y as u32)
}
EdibleSprite::PowerPellet(sprite) => {
let tile = sprite.animation.current_tile();
let x = pos.x + ((crate::constants::CELL_SIZE as i32 - tile.size.x as i32) / 2);
let y = pos.y + ((crate::constants::CELL_SIZE as i32 - tile.size.y as i32) / 2);
sdl2::rect::Rect::new(x, y, tile.size.x as u32, tile.size.y as u32)
}
};
match &mut self.sprite {
EdibleSprite::Pellet(sprite) => sprite.render(canvas, dest),
EdibleSprite::PowerPellet(sprite) => sprite.render(canvas, dest),
}
}
}
/// Reconstruct all edibles from the original map layout
pub fn reconstruct_edibles(
map: Rc<RefCell<Map>>,
pellet_sprite: AnimatedTexture,
power_pellet_sprite: BlinkingTexture,
_fruit_sprite: AnimatedTexture,
) -> Vec<Edible> {
let mut edibles = Vec::new();
for x in 0..BOARD_CELL_SIZE.x {
for y in 0..BOARD_CELL_SIZE.y {
let tile = map.borrow().get_tile(IVec2::new(x as i32, y as i32));
match tile {
Some(MapTile::Pellet) => {
edibles.push(Edible::new_pellet(UVec2::new(x, y), pellet_sprite.clone()));
}
Some(MapTile::PowerPellet) => {
edibles.push(Edible::new_power_pellet(UVec2::new(x, y), power_pellet_sprite.clone()));
}
// Fruits can be added here if you have fruit positions
_ => {}
}
}
}
edibles
}

View File

@@ -1,348 +0,0 @@
use rand::rngs::SmallRng;
use rand::Rng;
use rand::SeedableRng;
use crate::constants::MapTile;
use crate::constants::BOARD_CELL_SIZE;
use crate::entity::direction::Direction;
use crate::entity::pacman::Pacman;
use crate::entity::speed::SimpleTickModulator;
use crate::entity::{Entity, MovableEntity, Moving, Renderable};
use crate::map::Map;
use crate::texture::{
animated::AnimatedTexture, blinking::BlinkingTexture, directional::DirectionalAnimatedTexture, get_atlas_tile,
sprite::SpriteAtlas,
};
use anyhow::Result;
use glam::{IVec2, UVec2};
use sdl2::pixels::Color;
use sdl2::render::WindowCanvas;
use std::cell::RefCell;
use std::rc::Rc;
/// The different modes a ghost can be in
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum GhostMode {
/// Chase mode - ghost actively pursues Pac-Man using its unique strategy
Chase,
/// Scatter mode - ghost heads to its home corner
Scatter,
/// Frightened mode - ghost moves randomly and can be eaten
Frightened,
/// Eyes mode - ghost returns to the ghost house after being eaten
Eyes,
/// House mode - ghost is in the ghost house, waiting to exit
House,
}
/// The different ghost personalities
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum GhostType {
Blinky, // Red - Shadow
Pinky, // Pink - Speedy
Inky, // Cyan - Bashful
Clyde, // Orange - Pokey
}
impl GhostType {
/// Returns the color of the ghost.
pub fn color(&self) -> Color {
match self {
GhostType::Blinky => Color::RGB(255, 0, 0),
GhostType::Pinky => Color::RGB(255, 184, 255),
GhostType::Inky => Color::RGB(0, 255, 255),
GhostType::Clyde => Color::RGB(255, 184, 82),
}
}
}
/// Base ghost struct that contains common functionality
pub struct Ghost {
/// Shared movement and position fields.
pub base: MovableEntity,
/// The current mode of the ghost
pub mode: GhostMode,
/// The type/personality of this ghost
pub ghost_type: GhostType,
/// Reference to Pac-Man for targeting
pub pacman: Rc<RefCell<Pacman>>,
pub texture: DirectionalAnimatedTexture,
pub frightened_texture: BlinkingTexture,
pub eyes_texture: DirectionalAnimatedTexture,
}
impl Ghost {
/// Creates a new ghost instance
pub fn new(
ghost_type: GhostType,
starting_position: UVec2,
atlas: Rc<RefCell<SpriteAtlas>>,
map: Rc<RefCell<Map>>,
pacman: Rc<RefCell<Pacman>>,
) -> Ghost {
let pixel_position = Map::cell_to_pixel(starting_position);
let name = match ghost_type {
GhostType::Blinky => "blinky",
GhostType::Pinky => "pinky",
GhostType::Inky => "inky",
GhostType::Clyde => "clyde",
};
let get = |dir: &str, suffix: &str| get_atlas_tile(&atlas, &format!("ghost/{}/{}_{}.png", name, dir, suffix));
let texture = DirectionalAnimatedTexture::new(
vec![get("up", "a"), get("up", "b")],
vec![get("down", "a"), get("down", "b")],
vec![get("left", "a"), get("left", "b")],
vec![get("right", "a"), get("right", "b")],
25,
);
let frightened_texture = BlinkingTexture::new(
AnimatedTexture::new(
vec![
get_atlas_tile(&atlas, "ghost/frightened/blue_a.png"),
get_atlas_tile(&atlas, "ghost/frightened/blue_b.png"),
],
10,
),
45,
15,
);
let eyes_get = |dir: &str| get_atlas_tile(&atlas, &format!("ghost/eyes/{}.png", dir));
let eyes_texture = DirectionalAnimatedTexture::new(
vec![eyes_get("up")],
vec![eyes_get("down")],
vec![eyes_get("left")],
vec![eyes_get("right")],
0,
);
Ghost {
base: MovableEntity::new(
pixel_position,
starting_position,
Direction::Left,
SimpleTickModulator::new(0.9375),
map,
),
mode: GhostMode::Chase,
ghost_type,
pacman,
texture,
frightened_texture,
eyes_texture,
}
}
/// Gets the target tile for this ghost based on its current mode
pub fn get_target_tile(&self) -> IVec2 {
match self.mode {
GhostMode::Scatter => self.get_scatter_target(),
GhostMode::Chase => self.get_chase_target(),
GhostMode::Frightened => self.get_random_target(),
GhostMode::Eyes => self.get_house_target(),
GhostMode::House => self.get_house_exit_target(),
}
}
/// Gets this ghost's home corner target for scatter mode
fn get_scatter_target(&self) -> IVec2 {
match self.ghost_type {
GhostType::Blinky => IVec2::new(25, 0), // Top right
GhostType::Pinky => IVec2::new(2, 0), // Top left
GhostType::Inky => IVec2::new(27, 35), // Bottom right
GhostType::Clyde => IVec2::new(0, 35), // Bottom left
}
}
/// Gets a random adjacent tile for frightened mode
fn get_random_target(&self) -> IVec2 {
let mut rng = SmallRng::from_os_rng();
let mut possible_moves = Vec::new();
// Check all four directions
for dir in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] {
// Don't allow reversing direction
if *dir == self.base.direction.opposite() {
continue;
}
let next_cell = self.base.next_cell(Some(*dir));
if !matches!(self.base.map.borrow().get_tile(next_cell), Some(MapTile::Wall)) {
possible_moves.push(next_cell);
}
}
if possible_moves.is_empty() {
// No valid moves, must reverse
self.base.next_cell(Some(self.base.direction.opposite()))
} else {
// Choose a random valid move
possible_moves[rng.random_range(0..possible_moves.len())]
}
}
/// Gets the ghost house target for returning eyes
fn get_house_target(&self) -> IVec2 {
IVec2::new(13, 14) // Center of ghost house
}
/// Gets the exit point target when leaving house
fn get_house_exit_target(&self) -> IVec2 {
IVec2::new(13, 11) // Just above ghost house
}
/// Gets this ghost's chase mode target (to be implemented by each ghost type)
fn get_chase_target(&self) -> IVec2 {
let pacman = self.pacman.borrow();
let cell = pacman.base().cell_position;
IVec2::new(cell.x as i32, cell.y as i32)
}
/// Calculates the path to the target tile using the A* algorithm.
pub fn get_path_to_target(&self, target: UVec2) -> Option<(Vec<UVec2>, u32)> {
let start = self.base.base.cell_position;
let map = self.base.map.borrow();
use pathfinding::prelude::dijkstra;
dijkstra(
&start,
|&p| {
let mut successors = vec![];
let tile = map.get_tile(IVec2::new(p.x as i32, p.y as i32));
// Tunnel wrap: if currently in a tunnel, add the opposite exit as a neighbor
if let Some(MapTile::Tunnel) = tile {
if p.x == 0 {
successors.push((UVec2::new(BOARD_CELL_SIZE.x - 2, p.y), 1));
} else if p.x == BOARD_CELL_SIZE.x - 1 {
successors.push((UVec2::new(1, p.y), 1));
}
}
for dir in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] {
let offset = dir.offset();
let next_p = IVec2::new(p.x as i32 + offset.x, p.y as i32 + offset.y);
if let Some(tile) = map.get_tile(next_p) {
if tile == MapTile::Wall {
continue;
}
let next_u = UVec2::new(next_p.x as u32, next_p.y as u32);
successors.push((next_u, 1));
}
}
successors
},
|&p| p == target,
)
}
/// Changes the ghost's mode and handles direction reversal
pub fn set_mode(&mut self, new_mode: GhostMode) {
// Don't reverse if going to/from frightened or if in house
let should_reverse =
self.mode != GhostMode::House && new_mode != GhostMode::Frightened && self.mode != GhostMode::Frightened;
self.mode = new_mode;
self.base.speed.set_speed(match new_mode {
GhostMode::Chase => 0.9375,
GhostMode::Scatter => 0.85,
GhostMode::Frightened => 0.7,
GhostMode::Eyes => 1.5,
GhostMode::House => 0f32,
});
if should_reverse {
self.base.set_direction_if_valid(self.base.direction.opposite());
}
}
pub fn tick(&mut self) {
if self.mode == GhostMode::House {
// For now, do nothing in the house
return;
}
if self.base.is_grid_aligned() {
self.base.update_cell_position();
if !self.base.handle_tunnel() {
// Pathfinding logic (only if not in tunnel)
let target_tile = self.get_target_tile();
if let Some((path, _)) = self.get_path_to_target(target_tile.as_uvec2()) {
if path.len() > 1 {
let next_move = path[1];
let x = self.base.base.cell_position.x;
let y = self.base.base.cell_position.y;
let dx = next_move.x as i32 - x as i32;
let dy = next_move.y as i32 - y as i32;
let new_direction = if dx > 0 {
Direction::Right
} else if dx < 0 {
Direction::Left
} else if dy > 0 {
Direction::Down
} else {
Direction::Up
};
self.base.set_direction_if_valid(new_direction);
}
}
}
}
self.base.tick(); // Handles wall collision and movement
self.texture.tick();
self.frightened_texture.tick();
self.eyes_texture.tick();
}
}
impl Moving for Ghost {
fn tick_movement(&mut self) {
self.base.tick_movement();
}
fn tick(&mut self) {
self.base.tick();
}
fn update_cell_position(&mut self) {
self.base.update_cell_position();
}
fn next_cell(&self, direction: Option<Direction>) -> IVec2 {
self.base.next_cell(direction)
}
fn is_wall_ahead(&self, direction: Option<Direction>) -> bool {
self.base.is_wall_ahead(direction)
}
fn handle_tunnel(&mut self) -> bool {
self.base.handle_tunnel()
}
fn is_grid_aligned(&self) -> bool {
self.base.is_grid_aligned()
}
fn set_direction_if_valid(&mut self, new_direction: Direction) -> bool {
self.base.set_direction_if_valid(new_direction)
}
}
impl Renderable for Ghost {
fn render(&mut self, canvas: &mut WindowCanvas) -> Result<()> {
let pos = self.base.base.pixel_position;
let dir = self.base.direction;
match self.mode {
GhostMode::Frightened => {
let tile = self.frightened_texture.animation.current_tile();
let dest = sdl2::rect::Rect::new(pos.x - 4, pos.y - 4, tile.size.x as u32, tile.size.y as u32);
self.frightened_texture.render(canvas, dest)
}
GhostMode::Eyes => {
let tile = self.eyes_texture.up.get(0).unwrap();
let dest = sdl2::rect::Rect::new(pos.x - 4, pos.y - 4, tile.size.x as u32, tile.size.y as u32);
self.eyes_texture.render(canvas, dest, dir)
}
_ => {
let tile = self.texture.up.get(0).unwrap();
let dest = sdl2::rect::Rect::new(pos.x - 4, pos.y - 4, tile.size.x as u32, tile.size.y as u32);
self.texture.render(canvas, dest, dir)
}
}
}
}

443
src/entity/graph.rs Normal file
View File

@@ -0,0 +1,443 @@
use glam::Vec2;
use super::direction::Direction;
/// A unique identifier for a node, represented by its index in the graph's storage.
pub type NodeId = usize;
/// Defines who can traverse a given edge.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EdgePermissions {
/// Anyone can use this edge.
#[default]
All,
/// Only ghosts can use this edge.
GhostsOnly,
}
/// Represents a directed edge from one node to another with a given weight (e.g., distance).
#[derive(Debug, Clone, Copy)]
pub struct Edge {
/// The destination node of this edge.
pub target: NodeId,
/// The length of the edge.
pub distance: f32,
/// The cardinal direction of this edge.
pub direction: Direction,
/// Defines who is allowed to traverse this edge.
pub permissions: EdgePermissions,
}
/// Represents a node in the graph, defined by its position.
#[derive(Debug)]
pub struct Node {
/// The 2D coordinates of the node.
pub position: Vec2,
}
/// Represents the four possible directions from a node in the graph.
///
/// Each field contains an optional edge leading in that direction.
/// This structure is used to represent the adjacency list for each node,
/// providing O(1) access to edges in any cardinal direction.
#[derive(Debug, Default)]
pub struct Intersection {
/// Edge leading upward from this node, if it exists.
pub up: Option<Edge>,
/// Edge leading downward from this node, if it exists.
pub down: Option<Edge>,
/// Edge leading leftward from this node, if it exists.
pub left: Option<Edge>,
/// Edge leading rightward from this node, if it exists.
pub right: Option<Edge>,
}
impl Intersection {
/// Returns an iterator over all edges from this intersection.
///
/// This iterator yields only the edges that exist (non-None values).
pub fn edges(&self) -> impl Iterator<Item = Edge> {
[self.up, self.down, self.left, self.right].into_iter().flatten()
}
/// Retrieves the edge in the specified direction, if it exists.
pub fn get(&self, direction: Direction) -> Option<Edge> {
match direction {
Direction::Up => self.up,
Direction::Down => self.down,
Direction::Left => self.left,
Direction::Right => self.right,
}
}
/// Sets the edge in the specified direction.
///
/// This will overwrite any existing edge in that direction.
pub fn set(&mut self, direction: Direction, edge: Edge) {
match direction {
Direction::Up => self.up = Some(edge),
Direction::Down => self.down = Some(edge),
Direction::Left => self.left = Some(edge),
Direction::Right => self.right = Some(edge),
}
}
}
/// A directed graph structure using an adjacency list representation.
///
/// Nodes are stored in a vector, and their indices serve as their `NodeId`.
/// This design provides fast, O(1) lookups for node data. Edges are stored
/// in an adjacency list, where each node has a list of outgoing edges.
pub struct Graph {
nodes: Vec<Node>,
pub adjacency_list: Vec<Intersection>,
}
impl Graph {
/// Creates a new, empty graph.
pub fn new() -> Self {
Graph {
nodes: Vec::new(),
adjacency_list: Vec::new(),
}
}
/// Adds a new node with the given data to the graph and returns its ID.
pub fn add_node(&mut self, data: Node) -> NodeId {
let id = self.nodes.len();
self.nodes.push(data);
self.adjacency_list.push(Intersection::default());
id
}
/// Connects a new node to the graph and adds an edge between the existing node and the new node.
pub fn connect_node(&mut self, from: NodeId, direction: Direction, new_node: Node) -> Result<NodeId, &'static str> {
let to = self.add_node(new_node);
self.connect(from, to, false, None, direction)?;
Ok(to)
}
/// Connects two existing nodes with an edge.
pub fn connect(
&mut self,
from: NodeId,
to: NodeId,
replace: bool,
distance: Option<f32>,
direction: Direction,
) -> Result<(), &'static str> {
if from >= self.adjacency_list.len() {
return Err("From node does not exist.");
}
if to >= self.adjacency_list.len() {
return Err("To node does not exist.");
}
let edge_a = self.add_edge(from, to, replace, distance, direction, EdgePermissions::default());
let edge_b = self.add_edge(to, from, replace, distance, direction.opposite(), EdgePermissions::default());
if edge_a.is_err() && edge_b.is_err() {
return Err("Failed to connect nodes in both directions.");
}
Ok(())
}
/// Adds a directed edge between two nodes.
///
/// If `distance` is `None`, it will be calculated automatically based on the
/// Euclidean distance between the two nodes.
///
/// # Errors
///
/// Returns an error if:
/// - The `from` node does not exist
/// - An edge already exists in the specified direction
/// - An edge already exists to the target node
/// - The provided distance is not positive
pub fn add_edge(
&mut self,
from: NodeId,
to: NodeId,
replace: bool,
distance: Option<f32>,
direction: Direction,
permissions: EdgePermissions,
) -> Result<(), &'static str> {
let edge = Edge {
target: to,
distance: match distance {
Some(distance) => {
if distance < 0.0 {
return Err("Edge distance must be on-negative.");
}
distance
}
None => {
// If no distance is provided, calculate it based on the positions of the nodes
let from_pos = self.nodes[from].position;
let to_pos = self.nodes[to].position;
from_pos.distance(to_pos)
}
},
direction,
permissions,
};
if from >= self.adjacency_list.len() {
return Err("From node does not exist.");
}
let adjacency_list = &mut self.adjacency_list[from];
// Check if the edge already exists in this direction or to the same target
if let Some(err) = adjacency_list.edges().find_map(|e| {
// If we're not replacing the edge, we don't want to replace an edge that already exists in this direction
if !replace && e.direction == direction {
Some(Err("Edge already exists in this direction."))
} else if e.target == to {
Some(Err("Edge already exists."))
} else {
None
}
}) {
return err;
}
adjacency_list.set(direction, edge);
Ok(())
}
/// Retrieves an immutable reference to a node's data.
pub fn get_node(&self, id: NodeId) -> Option<&Node> {
self.nodes.get(id)
}
/// Returns the total number of nodes in the graph.
pub fn node_count(&self) -> usize {
self.nodes.len()
}
/// Finds a specific edge from a source node to a target node.
pub fn find_edge(&self, from: NodeId, to: NodeId) -> Option<Edge> {
self.adjacency_list.get(from)?.edges().find(|edge| edge.target == to)
}
/// Finds an edge originating from a given node that follows a specific direction.
pub fn find_edge_in_direction(&self, from: NodeId, direction: Direction) -> Option<Edge> {
self.adjacency_list.get(from)?.get(direction)
}
}
// Default implementation for creating an empty graph.
impl Default for Graph {
fn default() -> Self {
Self::new()
}
}
// --- Traversal State and Logic ---
/// Represents the current position of an entity traversing the graph.
///
/// This enum allows for precise tracking of whether an entity is exactly at a node
/// or moving along an edge between two nodes.
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum Position {
/// The traverser is located exactly at a node.
AtNode(NodeId),
/// The traverser is on an edge between two nodes.
BetweenNodes {
from: NodeId,
to: NodeId,
/// The floating-point distance traversed along the edge from the `from` node.
traversed: f32,
},
}
#[allow(dead_code)]
impl Position {
/// Returns `true` if the position is exactly at a node.
pub fn is_at_node(&self) -> bool {
matches!(self, Position::AtNode(_))
}
/// Returns the `NodeId` of the current or most recently departed node.
#[allow(clippy::wrong_self_convention)]
pub fn from_node_id(&self) -> NodeId {
match self {
Position::AtNode(id) => *id,
Position::BetweenNodes { from, .. } => *from,
}
}
/// Returns the `NodeId` of the destination node, if currently on an edge.
#[allow(clippy::wrong_self_convention)]
pub fn to_node_id(&self) -> Option<NodeId> {
match self {
Position::AtNode(_) => None,
Position::BetweenNodes { to, .. } => Some(*to),
}
}
/// Returns `true` if the traverser is stopped at a node.
pub fn is_stopped(&self) -> bool {
matches!(self, Position::AtNode(_))
}
}
/// Manages an entity's movement through the graph.
///
/// A `Traverser` encapsulates the state of an entity's position and direction,
/// providing a way to advance along the graph's paths based on a given distance.
/// It also handles direction changes, buffering the next intended direction.
pub struct Traverser {
/// The current position of the traverser in the graph.
pub position: Position,
/// The current direction of movement.
pub direction: Direction,
/// Buffered direction change with remaining frame count for timing.
///
/// The `u8` value represents the number of frames remaining before
/// the buffered direction expires. This allows for responsive controls
/// by storing direction changes for a limited time.
pub next_direction: Option<(Direction, u8)>,
}
impl Traverser {
/// Creates a new traverser starting at the given node ID.
///
/// The traverser will immediately attempt to start moving in the initial direction.
pub fn new<F>(graph: &Graph, start_node: NodeId, initial_direction: Direction, can_traverse: &F) -> Self
where
F: Fn(Edge) -> bool,
{
let mut traverser = Traverser {
position: Position::AtNode(start_node),
direction: initial_direction,
next_direction: Some((initial_direction, 1)),
};
// This will kickstart the traverser into motion
traverser.advance(graph, 0.0, can_traverse);
traverser
}
/// Sets the next direction for the traverser to take.
///
/// The direction is buffered and will be applied at the next opportunity,
/// typically when the traverser reaches a new node. This allows for responsive
/// controls, as the new direction is stored for a limited time.
pub fn set_next_direction(&mut self, new_direction: Direction) {
if self.direction != new_direction {
self.next_direction = Some((new_direction, 30));
}
}
/// Advances the traverser along the graph by a specified distance.
///
/// This method updates the traverser's position based on its current state
/// and the distance to travel.
///
/// - If at a node, it checks for a buffered direction to start moving.
/// - If between nodes, it moves along the current edge.
/// - If it reaches a node, it attempts to transition to a new edge based on
/// the buffered direction or by continuing straight.
/// - If no valid move is possible, it stops at the node.
pub fn advance<F>(&mut self, graph: &Graph, distance: f32, can_traverse: &F)
where
F: Fn(Edge) -> bool,
{
// Decrement the remaining frames for the next direction
if let Some((direction, remaining)) = self.next_direction {
if remaining > 0 {
self.next_direction = Some((direction, remaining - 1));
} else {
self.next_direction = None;
}
}
match self.position {
Position::AtNode(node_id) => {
// We're not moving, but a buffered direction is available.
if let Some((next_direction, _)) = self.next_direction {
if let Some(edge) = graph.find_edge_in_direction(node_id, next_direction) {
if can_traverse(edge) {
// Start moving in that direction
self.position = Position::BetweenNodes {
from: node_id,
to: edge.target,
traversed: distance.max(0.0),
};
self.direction = next_direction;
}
}
self.next_direction = None; // Consume the buffered direction regardless of whether we started moving with it
}
}
Position::BetweenNodes { from, to, traversed } => {
// There is no point in any of the next logic if we don't travel at all
if distance <= 0.0 {
return;
}
let edge = graph
.find_edge(from, to)
.expect("Inconsistent state: Traverser is on a non-existent edge.");
let new_traversed = traversed + distance;
if new_traversed < edge.distance {
// Still on the same edge, just update the distance.
self.position = Position::BetweenNodes {
from,
to,
traversed: new_traversed,
};
} else {
let overflow = new_traversed - edge.distance;
let mut moved = false;
// If we buffered a direction, try to find an edge in that direction
if let Some((next_dir, _)) = self.next_direction {
if let Some(edge) = graph.find_edge_in_direction(to, next_dir) {
if can_traverse(edge) {
self.position = Position::BetweenNodes {
from: to,
to: edge.target,
traversed: overflow,
};
self.direction = next_dir; // Remember our new direction
self.next_direction = None; // Consume the buffered direction
moved = true;
}
}
}
// If we didn't move, try to continue in the current direction
if !moved {
if let Some(edge) = graph.find_edge_in_direction(to, self.direction) {
if can_traverse(edge) {
self.position = Position::BetweenNodes {
from: to,
to: edge.target,
traversed: overflow,
};
} else {
self.position = Position::AtNode(to);
self.next_direction = None;
}
} else {
self.position = Position::AtNode(to);
self.next_direction = None;
}
}
}
}
}
}
}

View File

@@ -1,203 +1,3 @@
pub mod blinky;
pub mod direction;
pub mod edible;
pub mod ghost;
pub mod graph;
pub mod pacman;
pub mod speed;
use crate::{
constants::{MapTile, BOARD_CELL_OFFSET, BOARD_CELL_SIZE, CELL_SIZE},
entity::{direction::Direction, speed::SimpleTickModulator},
map::Map,
};
use anyhow::Result;
use glam::{IVec2, UVec2};
use sdl2::render::WindowCanvas;
use std::cell::RefCell;
use std::rc::Rc;
/// A trait for game objects that can be moved and rendered.
pub trait Entity {
/// Returns a reference to the base entity (position, etc).
fn base(&self) -> &StaticEntity;
/// Returns true if the entity is colliding with the other entity.
fn is_colliding(&self, other: &dyn Entity) -> bool {
let a = self.base().pixel_position;
let b = other.base().pixel_position;
a == b
}
}
/// A trait for entities that can move and interact with the map.
pub trait Moving {
fn tick(&mut self) {
self.base_tick();
}
fn base_tick(&mut self) {
if self.is_grid_aligned() {
self.on_grid_aligned();
}
self.tick_movement();
}
/// Called when the entity is grid-aligned. Default does nothing.
fn on_grid_aligned(&mut self) {}
/// Handles movement and wall collision. Default uses tick logic from MovableEntity.
fn tick_movement(&mut self);
fn update_cell_position(&mut self);
fn next_cell(&self, direction: Option<Direction>) -> IVec2;
fn is_wall_ahead(&self, direction: Option<Direction>) -> bool;
fn handle_tunnel(&mut self) -> bool;
fn is_grid_aligned(&self) -> bool;
fn set_direction_if_valid(&mut self, new_direction: Direction) -> bool;
}
/// Trait for entities that support queued direction changes.
pub trait QueuedDirection: Moving {
fn next_direction(&self) -> Option<Direction>;
fn set_next_direction(&mut self, dir: Option<Direction>);
/// Handles a requested direction change if possible.
fn handle_direction_change(&mut self) -> bool {
if let Some(next_direction) = self.next_direction() {
if self.set_direction_if_valid(next_direction) {
self.set_next_direction(None);
return true;
}
}
false
}
}
/// A struct for static (non-moving) entities with position only.
pub struct StaticEntity {
pub pixel_position: IVec2,
pub cell_position: UVec2,
}
impl StaticEntity {
pub fn new(pixel_position: IVec2, cell_position: UVec2) -> Self {
Self {
pixel_position,
cell_position,
}
}
}
/// A struct for movable game entities with position, direction, speed, and modulation.
pub struct MovableEntity {
pub base: StaticEntity,
pub direction: Direction,
pub speed: SimpleTickModulator,
pub in_tunnel: bool,
pub map: Rc<RefCell<Map>>,
}
impl MovableEntity {
pub fn new(
pixel_position: IVec2,
cell_position: UVec2,
direction: Direction,
speed: SimpleTickModulator,
map: Rc<RefCell<Map>>,
) -> Self {
Self {
base: StaticEntity::new(pixel_position, cell_position),
direction,
speed,
in_tunnel: false,
map,
}
}
/// Returns the position within the current cell, in pixels.
pub fn internal_position(&self) -> UVec2 {
UVec2::new(
(self.base.pixel_position.x as u32) % CELL_SIZE,
(self.base.pixel_position.y as u32) % CELL_SIZE,
)
}
}
impl Entity for MovableEntity {
fn base(&self) -> &StaticEntity {
&self.base
}
}
impl Moving for MovableEntity {
fn tick_movement(&mut self) {
if self.speed.next() {
if !self.is_wall_ahead(None) {
match self.direction {
Direction::Right => self.base.pixel_position.x += 1,
Direction::Left => self.base.pixel_position.x -= 1,
Direction::Up => self.base.pixel_position.y -= 1,
Direction::Down => self.base.pixel_position.y += 1,
}
if self.is_grid_aligned() {
self.update_cell_position();
}
}
}
if self.is_grid_aligned() {
self.update_cell_position();
}
}
fn update_cell_position(&mut self) {
self.base.cell_position = UVec2::new(
(self.base.pixel_position.x as u32 / CELL_SIZE) - BOARD_CELL_OFFSET.x,
(self.base.pixel_position.y as u32 / CELL_SIZE) - BOARD_CELL_OFFSET.y,
);
}
fn next_cell(&self, direction: Option<Direction>) -> IVec2 {
let IVec2 { x, y } = direction.unwrap_or(self.direction).offset();
IVec2::new(self.base.cell_position.x as i32 + x, self.base.cell_position.y as i32 + y)
}
fn is_wall_ahead(&self, direction: Option<Direction>) -> bool {
let next_cell = self.next_cell(direction);
matches!(self.map.borrow().get_tile(next_cell), Some(MapTile::Wall))
}
fn handle_tunnel(&mut self) -> bool {
let x = self.base.cell_position.x;
let at_left_tunnel = x == 0;
let at_right_tunnel = x == BOARD_CELL_SIZE.x - 1;
if !at_left_tunnel && !at_right_tunnel {
return false;
}
if self.in_tunnel {
return true;
}
let new_x = if at_left_tunnel { BOARD_CELL_SIZE.x - 2 } else { 1 };
self.base.cell_position.x = new_x;
self.base.pixel_position = Map::cell_to_pixel(self.base.cell_position);
self.in_tunnel = true;
true
}
fn is_grid_aligned(&self) -> bool {
self.internal_position() == UVec2::ZERO
}
fn set_direction_if_valid(&mut self, new_direction: Direction) -> bool {
if new_direction == self.direction {
return false;
}
if self.is_wall_ahead(Some(new_direction)) {
return false;
}
self.direction = new_direction;
true
}
}
impl Entity for StaticEntity {
fn base(&self) -> &StaticEntity {
self
}
}
/// A trait for entities that can be rendered to the screen.
pub trait Renderable {
fn render(&mut self, canvas: &mut WindowCanvas) -> Result<()>;
}

View File

@@ -1,143 +1,102 @@
//! This module defines the Pac-Man entity, including its behavior and rendering.
use anyhow::Result;
use glam::{IVec2, UVec2};
use sdl2::render::WindowCanvas;
use std::cell::RefCell;
use std::rc::Rc;
use glam::{UVec2, Vec2};
use crate::{
entity::speed::SimpleTickModulator,
entity::{direction::Direction, Entity, MovableEntity, Moving, QueuedDirection, Renderable, StaticEntity},
map::Map,
texture::{animated::AnimatedTexture, directional::DirectionalAnimatedTexture, get_atlas_tile, sprite::SpriteAtlas},
};
use crate::constants::BOARD_PIXEL_OFFSET;
use crate::entity::direction::Direction;
use crate::entity::graph::{Edge, EdgePermissions, Graph, NodeId, Position, Traverser};
use crate::helpers::centered_with_size;
use crate::texture::animated::AnimatedTexture;
use crate::texture::directional::DirectionalAnimatedTexture;
use crate::texture::sprite::SpriteAtlas;
use sdl2::keyboard::Keycode;
use sdl2::render::{Canvas, RenderTarget};
use std::collections::HashMap;
fn can_pacman_traverse(edge: Edge) -> bool {
matches!(edge.permissions, EdgePermissions::All)
}
/// The Pac-Man entity.
pub struct Pacman {
/// Shared movement and position fields.
pub base: MovableEntity,
/// The next direction of Pac-Man, which will be applied when Pac-Man is next aligned with the grid.
pub next_direction: Option<Direction>,
/// Whether Pac-Man is currently stopped.
pub stopped: bool,
pub skip_move_tick: bool,
pub texture: DirectionalAnimatedTexture,
pub death_animation: AnimatedTexture,
}
impl Entity for Pacman {
fn base(&self) -> &StaticEntity {
&self.base.base
}
}
impl Moving for Pacman {
fn tick_movement(&mut self) {
if self.skip_move_tick {
self.skip_move_tick = false;
return;
}
self.base.tick_movement();
}
fn update_cell_position(&mut self) {
self.base.update_cell_position();
}
fn next_cell(&self, direction: Option<Direction>) -> IVec2 {
self.base.next_cell(direction)
}
fn is_wall_ahead(&self, direction: Option<Direction>) -> bool {
self.base.is_wall_ahead(direction)
}
fn handle_tunnel(&mut self) -> bool {
self.base.handle_tunnel()
}
fn is_grid_aligned(&self) -> bool {
self.base.is_grid_aligned()
}
fn set_direction_if_valid(&mut self, new_direction: Direction) -> bool {
self.base.set_direction_if_valid(new_direction)
}
fn on_grid_aligned(&mut self) {
Pacman::update_cell_position(self);
if !<Pacman as Moving>::handle_tunnel(self) {
<Pacman as QueuedDirection>::handle_direction_change(self);
if !self.stopped && <Pacman as Moving>::is_wall_ahead(self, None) {
self.stopped = true;
} else if self.stopped && !<Pacman as Moving>::is_wall_ahead(self, None) {
self.stopped = false;
}
}
}
}
impl QueuedDirection for Pacman {
fn next_direction(&self) -> Option<Direction> {
self.next_direction
}
fn set_next_direction(&mut self, dir: Option<Direction>) {
self.next_direction = dir;
}
pub traverser: Traverser,
texture: DirectionalAnimatedTexture,
}
impl Pacman {
/// Creates a new `Pacman` instance.
pub fn new(starting_position: UVec2, atlas: Rc<RefCell<SpriteAtlas>>, map: Rc<RefCell<Map>>) -> Pacman {
let pixel_position = Map::cell_to_pixel(starting_position);
let get = |name: &str| get_atlas_tile(&atlas, name);
pub fn new(graph: &Graph, start_node: NodeId, atlas: &SpriteAtlas) -> Self {
let mut textures = HashMap::new();
let mut stopped_textures = HashMap::new();
Pacman {
base: MovableEntity::new(
pixel_position,
starting_position,
Direction::Right,
SimpleTickModulator::new(1f32),
map,
),
next_direction: None,
stopped: false,
skip_move_tick: false,
texture: DirectionalAnimatedTexture::new(
vec![get("pacman/up_a.png"), get("pacman/up_b.png"), get("pacman/full.png")],
vec![get("pacman/down_a.png"), get("pacman/down_b.png"), get("pacman/full.png")],
vec![get("pacman/left_a.png"), get("pacman/left_b.png"), get("pacman/full.png")],
vec![get("pacman/right_a.png"), get("pacman/right_b.png"), get("pacman/full.png")],
8,
),
death_animation: AnimatedTexture::new(
(0..=10)
.map(|i| get_atlas_tile(&atlas, &format!("pacman/death/{}.png", i)))
.collect(),
5,
),
for &direction in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] {
let moving_prefix = match direction {
Direction::Up => "pacman/up",
Direction::Down => "pacman/down",
Direction::Left => "pacman/left",
Direction::Right => "pacman/right",
};
let moving_tiles = vec![
SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_a.png")).unwrap(),
SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png")).unwrap(),
SpriteAtlas::get_tile(atlas, "pacman/full.png").unwrap(),
];
let stopped_tiles = vec![SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png")).unwrap()];
textures.insert(
direction,
AnimatedTexture::new(moving_tiles, 0.08).expect("Invalid frame duration"),
);
stopped_textures.insert(
direction,
AnimatedTexture::new(stopped_tiles, 0.1).expect("Invalid frame duration"),
);
}
Self {
traverser: Traverser::new(graph, start_node, Direction::Left, &can_pacman_traverse),
texture: DirectionalAnimatedTexture::new(textures, stopped_textures),
}
}
/// Returns the internal position of Pac-Man, rounded down to the nearest even number.
fn internal_position_even(&self) -> UVec2 {
let pos = self.base.internal_position();
UVec2::new((pos.x / 2) * 2, (pos.y / 2) * 2)
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);
}
pub fn tick(&mut self) {
<Pacman as Moving>::tick(self);
self.texture.tick();
pub fn handle_key(&mut self, keycode: Keycode) {
let direction = match keycode {
Keycode::Up => Some(Direction::Up),
Keycode::Down => Some(Direction::Down),
Keycode::Left => Some(Direction::Left),
Keycode::Right => Some(Direction::Right),
_ => None,
};
if let Some(direction) = direction {
self.traverser.set_next_direction(direction);
}
}
}
impl Renderable for Pacman {
fn render(&mut self, canvas: &mut WindowCanvas) -> Result<()> {
let pos = self.base.base.pixel_position;
let dir = self.base.direction;
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))
}
}
}
// Center the 16x16 sprite on the 8x8 cell by offsetting by -4
let dest = sdl2::rect::Rect::new(pos.x - 4, pos.y - 4, 16, 16);
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 self.stopped {
// When stopped, show the full sprite (mouth open)
self.texture.render_stopped(canvas, dest, dir)?;
if is_stopped {
self.texture
.render_stopped(canvas, atlas, dest, self.traverser.direction)
.unwrap();
} else {
self.texture.render(canvas, dest, dir)?;
self.texture.render(canvas, atlas, dest, self.traverser.direction).unwrap();
}
return Ok(());
}
}

View File

@@ -1,56 +0,0 @@
//! This module provides a tick modulator, which can be used to slow down
//! operations by a percentage.
/// A tick modulator allows you to slow down operations by a percentage.
///
/// Unfortunately, switching to floating point numbers for entities can induce floating point errors, slow down calculations
/// and make the game less deterministic. This is why we use a speed modulator instead.
/// Additionally, with small integers, lowering the speed by a percentage is not possible. For example, if we have a speed of 2,
/// and we want to slow it down by 10%, we would need to slow it down by 0.2. However, since we are using integers, we can't.
/// The only amount you can slow it down by is 1, which is 50% of the speed.
///
/// The basic principle of the Speed Modulator is to instead 'skip' movement ticks every now and then.
/// At 60 ticks per second, skips could happen several times per second, or once every few seconds.
/// Whatever it be, as long as the tick rate is high enough, the human eye will not be able to tell the difference.
///
/// For example, if we want to slow down the speed by 10%, we would need to skip every 10th tick.
pub trait TickModulator {
/// Creates a new tick modulator.
///
/// # Arguments
///
/// * `percent` - The percentage to slow down by, from 0.0 to 1.0.
fn new(percent: f32) -> Self;
/// Returns whether or not the operation should be performed on this tick.
fn next(&mut self) -> bool;
fn set_speed(&mut self, speed: f32);
}
/// A simple tick modulator that skips every Nth tick.
pub struct SimpleTickModulator {
accumulator: f32,
pixels_per_tick: f32,
}
// TODO: Add tests for the tick modulator to ensure that it is working correctly.
// TODO: Look into average precision and binary code modulation strategies to see
// if they would be a better fit for this use case.
impl SimpleTickModulator {
pub fn new(pixels_per_tick: f32) -> Self {
Self {
accumulator: 0f32,
pixels_per_tick: pixels_per_tick * 0.47,
}
}
pub fn set_speed(&mut self, pixels_per_tick: f32) {
self.pixels_per_tick = pixels_per_tick;
}
pub fn next(&mut self) -> bool {
self.accumulator += self.pixels_per_tick;
if self.accumulator >= 1f32 {
self.accumulator -= 1f32;
true
} else {
false
}
}
}

View File

@@ -1,55 +1,39 @@
//! This module contains the main game logic and state.
use std::cell::RefCell;
use std::ops::Not;
use std::rc::Rc;
use anyhow::Result;
use glam::UVec2;
use rand::rngs::SmallRng;
use rand::seq::IteratorRandom;
use rand::SeedableRng;
use sdl2::image::LoadTexture;
use sdl2::keyboard::Keycode;
use sdl2::{
image::LoadTexture,
keyboard::Keycode,
pixels::Color,
render::{Canvas, RenderTarget, Texture, TextureCreator},
video::WindowContext,
};
use sdl2::render::{Texture, TextureCreator};
use sdl2::video::WindowContext;
use sdl2::{pixels::Color, render::Canvas, video::Window};
use crate::asset::{get_asset_bytes, Asset};
use crate::audio::Audio;
use crate::constants::RAW_BOARD;
use crate::debug::{DebugMode, DebugRenderer};
use crate::entity::blinky::Blinky;
use crate::entity::direction::Direction;
use crate::entity::edible::{reconstruct_edibles, Edible, EdibleKind};
use crate::entity::pacman::Pacman;
use crate::entity::Renderable;
use crate::map::Map;
use crate::texture::animated::AnimatedTexture;
use crate::texture::blinking::BlinkingTexture;
use crate::texture::sprite::{AtlasMapper, AtlasTile, SpriteAtlas};
use crate::texture::text::TextTexture;
use crate::texture::{get_atlas_tile, sprite};
use crate::{
asset::{get_asset_bytes, Asset},
audio::Audio,
constants::RAW_BOARD,
entity::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 {
// Game state
pacman: Rc<RefCell<Pacman>>,
blinky: Blinky,
edibles: Vec<Edible>,
map: Rc<RefCell<Map>>,
score: u32,
debug_mode: DebugMode,
// FPS tracking
fps_1s: f64,
fps_10s: f64,
pub score: u32,
pub map: Map,
pub pacman: Pacman,
pub debug_mode: bool,
// Rendering resources
atlas: Rc<RefCell<SpriteAtlas>>,
atlas: SpriteAtlas,
map_texture: AtlasTile,
text_texture: TextTexture,
@@ -58,13 +42,19 @@ pub struct Game {
}
impl Game {
/// Creates a new `Game` instance.
pub fn new(
texture_creator: &TextureCreator<WindowContext>,
ttf_context: &sdl2::ttf::Sdl2TtfContext,
_ttf_context: &sdl2::ttf::Sdl2TtfContext,
_audio_subsystem: &sdl2::AudioSubsystem,
) -> Game {
let map = Rc::new(RefCell::new(Map::new(RAW_BOARD)));
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
@@ -74,232 +64,61 @@ impl Game {
};
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 = Rc::new(RefCell::new(SpriteAtlas::new(atlas_texture, atlas_mapper)));
let pacman = Rc::new(RefCell::new(Pacman::new(
UVec2::new(1, 1),
Rc::clone(&atlas),
Rc::clone(&map),
)));
let blinky = Blinky::new(UVec2::new(13, 11), Rc::clone(&atlas), Rc::clone(&map), Rc::clone(&pacman));
let map_texture = get_atlas_tile(&atlas, "maze/full.png");
let edibles = reconstruct_edibles(
Rc::clone(&map),
AnimatedTexture::new(vec![get_atlas_tile(&atlas, "maze/pellet.png")], 0),
BlinkingTexture::new(
AnimatedTexture::new(vec![get_atlas_tile(&atlas, "maze/energizer.png")], 0),
17,
17,
),
AnimatedTexture::new(vec![get_atlas_tile(&atlas, "edible/cherry.png")], 0),
);
let text_texture = TextTexture::new(Rc::clone(&atlas), 1.0);
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);
Game {
pacman,
blinky,
edibles,
map,
score: 0,
debug_mode: DebugMode::None,
atlas,
map,
pacman,
debug_mode: false,
map_texture,
text_texture,
audio,
fps_1s: 0.0,
fps_10s: 0.0,
atlas,
}
}
/// Handles a keyboard event.
pub fn keyboard_event(&mut self, keycode: Keycode) {
// Change direction
let direction = Direction::from_keycode(keycode);
if direction.is_some() {
self.pacman.borrow_mut().next_direction = direction;
return;
}
self.pacman.handle_key(keycode);
// Toggle debug mode
if keycode == Keycode::Space {
self.debug_mode = match self.debug_mode {
DebugMode::None => DebugMode::Grid,
DebugMode::Grid => DebugMode::Pathfinding,
DebugMode::Pathfinding => DebugMode::ValidPositions,
DebugMode::ValidPositions => DebugMode::None,
};
return;
}
// Toggle mute
if keycode == Keycode::M {
self.audio.set_mute(self.audio.is_muted().not());
return;
}
// Reset game
if keycode == Keycode::R {
self.reset();
self.audio.set_mute(!self.audio.is_muted());
}
}
/// Adds points to the score.
///
/// # Arguments
///
/// * `points` - The number of points to add.
pub fn add_score(&mut self, points: u32) {
self.score += points;
pub fn tick(&mut self, dt: f32) {
self.pacman.tick(dt, &self.map.graph);
}
/// Updates the FPS tracking values.
pub fn update_fps(&mut self, fps_1s: f64, fps_10s: f64) {
self.fps_1s = fps_1s;
self.fps_10s = fps_10s;
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);
self.pacman.render(canvas, &mut self.atlas, &self.map.graph);
})?;
Ok(())
}
/// Resets the game to its initial state.
pub fn reset(&mut self) {
// Reset the map to restore all pellets
{
let mut map = self.map.borrow_mut();
map.reset();
}
// Reset the score
self.score = 0;
// Get valid positions from the cached flood fill and randomize positions in a single block
{
let mut map = self.map.borrow_mut();
let valid_positions = map.get_valid_playable_positions();
let mut rng = SmallRng::from_os_rng();
// Randomize Pac-Man position
if let Some(pos) = valid_positions.iter().choose(&mut rng) {
let mut pacman = self.pacman.borrow_mut();
pacman.base.base.pixel_position = Map::cell_to_pixel(*pos);
pacman.base.base.cell_position = *pos;
pacman.base.in_tunnel = false;
pacman.base.direction = Direction::Right;
pacman.next_direction = None;
pacman.stopped = false;
}
// Randomize ghost position
if let Some(pos) = valid_positions.iter().choose(&mut rng) {
self.blinky.base.base.pixel_position = Map::cell_to_pixel(*pos);
self.blinky.base.base.cell_position = *pos;
self.blinky.base.in_tunnel = false;
self.blinky.base.direction = Direction::Left;
self.blinky.mode = crate::entity::ghost::GhostMode::Chase;
}
}
self.edibles = reconstruct_edibles(
Rc::clone(&self.map),
AnimatedTexture::new(vec![get_atlas_tile(&self.atlas, "maze/pellet.png")], 0),
BlinkingTexture::new(
AnimatedTexture::new(vec![get_atlas_tile(&self.atlas, "maze/energizer.png")], 0),
12,
12,
),
AnimatedTexture::new(vec![get_atlas_tile(&self.atlas, "edible/cherry.png")], 0),
);
}
/// Advances the game by one tick.
pub fn tick(&mut self) {
self.tick_entities();
self.handle_edible_collisions();
self.tick_entities();
}
fn tick_entities(&mut self) {
self.pacman.borrow_mut().tick();
self.blinky.tick();
for edible in self.edibles.iter_mut() {
if let EdibleKind::PowerPellet = edible.kind {
if let crate::entity::edible::EdibleSprite::PowerPellet(texture) = &mut edible.sprite {
texture.tick();
}
}
}
}
fn handle_edible_collisions(&mut self) {
let pacman = self.pacman.borrow();
let mut eaten_indices = vec![];
for (i, edible) in self.edibles.iter().enumerate() {
if edible.collide(&*pacman) {
eaten_indices.push(i);
}
}
drop(pacman);
for &i in eaten_indices.iter().rev() {
let edible = &self.edibles[i];
match edible.kind {
EdibleKind::Pellet => {
self.add_score(10);
self.audio.eat();
}
EdibleKind::PowerPellet => {
self.add_score(50);
self.audio.eat();
}
EdibleKind::Fruit(_fruit) => {
self.add_score(100);
self.audio.eat();
}
}
self.edibles.remove(i);
// Set Pac-Man to skip the next movement tick
self.pacman.borrow_mut().skip_move_tick = true;
}
}
/// Draws the entire game to the canvas using a backbuffer.
pub fn draw(&mut self, window_canvas: &mut Canvas<Window>, backbuffer: &mut Texture) -> Result<()> {
window_canvas
.with_texture_canvas(backbuffer, |texture_canvas| {
let this = self as *mut Self;
let this = unsafe { &mut *this };
texture_canvas.set_draw_color(Color::BLACK);
texture_canvas.clear();
this.map.borrow_mut().render(texture_canvas, &mut this.map_texture);
for edible in this.edibles.iter_mut() {
let _ = edible.render(texture_canvas);
}
let _ = this.pacman.borrow_mut().render(texture_canvas);
let _ = this.blinky.render(texture_canvas);
match this.debug_mode {
DebugMode::Grid => {
DebugRenderer::draw_debug_grid(
texture_canvas,
&this.map.borrow(),
this.pacman.borrow().base.base.cell_position,
);
let next_cell = <Pacman as crate::entity::Moving>::next_cell(&*this.pacman.borrow(), None);
DebugRenderer::draw_next_cell(texture_canvas, &this.map.borrow(), next_cell.as_uvec2());
}
DebugMode::ValidPositions => {
DebugRenderer::draw_valid_positions(texture_canvas, &mut this.map.borrow_mut());
}
DebugMode::Pathfinding => {
DebugRenderer::draw_pathfinding(texture_canvas, &this.blinky, &this.map.borrow());
}
DebugMode::None => {}
}
})
.map_err(|e| anyhow::anyhow!(format!("Failed to render to backbuffer: {e}")))
}
pub fn present_backbuffer(&mut self, canvas: &mut Canvas<Window>, backbuffer: &Texture) -> Result<()> {
canvas.set_draw_color(Color::BLACK);
canvas.clear();
pub fn present_backbuffer<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>, backbuffer: &Texture) -> Result<()> {
canvas.copy(backbuffer, None, None).map_err(anyhow::Error::msg)?;
self.render_ui_on(canvas);
if self.debug_mode {
self.map.debug_render_nodes(canvas);
}
self.draw_hud(canvas)?;
canvas.present();
Ok(())
}
fn render_ui_on<C: sdl2::render::RenderTarget>(&mut self, canvas: &mut sdl2::render::Canvas<C>) {
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;
@@ -309,15 +128,15 @@ impl Game {
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),
Color::WHITE,
);
let _ = self.text_texture.render(
canvas,
&mut self.atlas,
&score_text,
UVec2::new(8 * score_offset as u32 + x_offset, 8 + y_offset),
Color::WHITE,
);
// Display FPS information in top-left corner
@@ -329,5 +148,7 @@ impl Game {
// IVec2::new(10, 10),
// Color::RGB(255, 255, 0), // Yellow color for FPS display
// );
Ok(())
}
}

View File

@@ -1,107 +0,0 @@
//! This module contains helper functions that are used throughout the game.
use glam::UVec2;
/// Checks if two grid positions are adjacent to each other
///
/// # Arguments
/// * `a` - First position as (x, y) coordinates
/// * `b` - Second position as (x, y) coordinates
/// * `diagonal` - Whether to consider diagonal adjacency (true) or only orthogonal (false)
///
/// # Returns
/// * `true` if positions are adjacent according to the diagonal parameter
/// * `false` otherwise
pub fn is_adjacent(a: UVec2, b: UVec2, diagonal: bool) -> bool {
let dx = a.x.abs_diff(b.x);
let dy = a.y.abs_diff(b.y);
if diagonal {
dx <= 1 && dy <= 1 && (dx != 0 || dy != 0)
} else {
(dx == 1 && dy == 0) || (dx == 0 && dy == 1)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_orthogonal_adjacency() {
// Test orthogonal adjacency (diagonal = false)
// Same position should not be adjacent
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(0, 0), false));
// Adjacent positions should be true
assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(1, 0), false)); // Right
assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(0, 1), false)); // Down
assert!(is_adjacent(UVec2::new(1, 1), UVec2::new(0, 1), false)); // Left
assert!(is_adjacent(UVec2::new(1, 1), UVec2::new(1, 0), false)); // Up
// Diagonal positions should be false
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(1, 1), false));
assert!(!is_adjacent(UVec2::new(0, 1), UVec2::new(1, 0), false));
// Positions more than 1 step away should be false
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(2, 0), false));
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(0, 2), false));
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(2, 2), false));
}
#[test]
fn test_diagonal_adjacency() {
// Test diagonal adjacency (diagonal = true)
// Same position should not be adjacent
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(0, 0), true));
// Orthogonal adjacent positions should be true
assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(1, 0), true)); // Right
assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(0, 1), true)); // Down
assert!(is_adjacent(UVec2::new(1, 1), UVec2::new(0, 1), true)); // Left
assert!(is_adjacent(UVec2::new(1, 1), UVec2::new(1, 0), true)); // Up
// Diagonal adjacent positions should be true
assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(1, 1), true)); // Down-right
assert!(is_adjacent(UVec2::new(1, 0), UVec2::new(0, 1), true)); // Down-left
assert!(is_adjacent(UVec2::new(0, 1), UVec2::new(1, 0), true)); // Up-right
assert!(is_adjacent(UVec2::new(1, 1), UVec2::new(0, 0), true)); // Up-left
// Positions more than 1 step away should be false
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(2, 0), true));
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(0, 2), true));
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(2, 2), true));
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(1, 2), true));
}
#[test]
fn test_edge_cases() {
// Test with larger coordinates
assert!(is_adjacent(UVec2::new(100, 100), UVec2::new(101, 100), false));
assert!(is_adjacent(UVec2::new(100, 100), UVec2::new(100, 101), false));
assert!(!is_adjacent(UVec2::new(100, 100), UVec2::new(102, 100), false));
assert!(is_adjacent(UVec2::new(100, 100), UVec2::new(101, 101), true));
assert!(!is_adjacent(UVec2::new(100, 100), UVec2::new(102, 102), true));
// Test with zero coordinates
assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(1, 0), false));
assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(0, 1), false));
assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(1, 1), true));
}
#[test]
fn test_commutative_property() {
// The function should work the same regardless of parameter order
assert_eq!(
is_adjacent(UVec2::new(1, 2), UVec2::new(2, 2), false),
is_adjacent(UVec2::new(2, 2), UVec2::new(1, 2), false)
);
assert_eq!(
is_adjacent(UVec2::new(1, 2), UVec2::new(2, 3), true),
is_adjacent(UVec2::new(2, 3), UVec2::new(1, 2), true)
);
}
}

11
src/helpers.rs Normal file
View File

@@ -0,0 +1,11 @@
use glam::{IVec2, UVec2};
use sdl2::rect::Rect;
pub fn centered_with_size(pixel_pos: IVec2, size: UVec2) -> Rect {
Rect::new(
pixel_pos.x - size.x as i32 / 2,
pixel_pos.y - size.y as i32 / 2,
size.x,
size.y,
)
}

12
src/lib.rs Normal file
View File

@@ -0,0 +1,12 @@
//! Pac-Man game library crate.
pub mod app;
pub mod asset;
pub mod audio;
pub mod constants;
pub mod emscripten;
pub mod entity;
pub mod game;
pub mod helpers;
pub mod map;
pub mod texture;

View File

@@ -1,11 +1,7 @@
#![windows_subsystem = "windows"]
use crate::constants::{CANVAS_SIZE, SCALE};
use crate::game::Game;
use sdl2::event::{Event, WindowEvent};
use sdl2::keyboard::Keycode;
use std::time::{Duration, Instant};
use tracing::event;
use crate::{app::App, constants::LOOP_TIME};
use tracing::info;
use tracing_error::ErrorLayer;
use tracing_subscriber::layer::SubscriberExt;
@@ -52,28 +48,18 @@ unsafe fn attach_console() {
// Do NOT call AllocConsole here - we don't want a console when launched from Explorer
}
mod app;
mod asset;
mod audio;
mod constants;
mod debug;
#[cfg(target_os = "emscripten")]
mod emscripten;
mod entity;
mod game;
mod helper;
mod helpers;
mod map;
mod texture;
#[cfg(not(target_os = "emscripten"))]
fn sleep(value: Duration) {
spin_sleep::sleep(value);
}
#[cfg(target_os = "emscripten")]
fn sleep(value: Duration) {
emscripten::emscripten::sleep(value.as_millis() as u32);
}
/// The main entry point of the application.
///
/// This function initializes SDL, the window, the game state, and then enters
@@ -85,14 +71,6 @@ pub fn main() {
attach_console();
}
let sdl_context = sdl2::init().unwrap();
let video_subsystem = sdl_context.video().unwrap();
let audio_subsystem = sdl_context.audio().unwrap();
let ttf_context = sdl2::ttf::init().unwrap();
// Set nearest-neighbor scaling for pixelated rendering
sdl2::hint::set("SDL_RENDER_SCALE_QUALITY", "nearest");
// Setup tracing
let subscriber = tracing_subscriber::fmt()
.with_ansi(cfg!(not(target_os = "emscripten")))
@@ -102,173 +80,12 @@ pub fn main() {
tracing::subscriber::set_global_default(subscriber).expect("Could not set global default");
let window = video_subsystem
.window(
"Pac-Man",
(CANVAS_SIZE.x as f32 * SCALE).round() as u32,
(CANVAS_SIZE.y as f32 * SCALE).round() as u32,
)
.resizable()
.position_centered()
.build()
.expect("Could not initialize window");
let mut app = App::new().expect("Could not create app");
let mut canvas = window.into_canvas().build().expect("Could not build canvas");
canvas
.set_logical_size(CANVAS_SIZE.x, CANVAS_SIZE.y)
.expect("Could not set logical size");
let texture_creator = canvas.texture_creator();
let texture_creator_static: &'static sdl2::render::TextureCreator<sdl2::video::WindowContext> =
Box::leak(Box::new(texture_creator));
let mut game = Game::new(texture_creator_static, &ttf_context, &audio_subsystem);
game.audio.set_mute(cfg!(debug_assertions));
// Create a backbuffer texture for drawing
let mut backbuffer = texture_creator_static
.create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y)
.expect("Could not create backbuffer texture");
let mut event_pump = sdl_context.event_pump().expect("Could not get SDL EventPump");
// Initial draw and tick
if let Err(e) = game.draw(&mut canvas, &mut backbuffer) {
eprintln!("Initial draw failed: {}", e);
}
if let Err(e) = game.present_backbuffer(&mut canvas, &backbuffer) {
eprintln!("Initial present failed: {}", e);
}
game.tick();
// The target time for each frame of the game loop (60 FPS).
let loop_time = Duration::from_secs(1) / 60;
let mut paused = false;
// Whether the window is currently shown.
let mut shown = false;
// FPS tracking
let mut frame_times_1s = Vec::new();
let mut frame_times_10s = Vec::new();
let mut last_frame_time = Instant::now();
event!(tracing::Level::INFO, "Starting game loop ({:?})", loop_time);
let mut main_loop = move || {
let start = Instant::now();
let current_frame_time = Instant::now();
let frame_duration = current_frame_time.duration_since(last_frame_time);
last_frame_time = current_frame_time;
// Update FPS tracking
frame_times_1s.push(frame_duration);
frame_times_10s.push(frame_duration);
// Keep only last 1 second of data (assuming 60 FPS = ~60 frames)
while frame_times_1s.len() > 60 {
frame_times_1s.remove(0);
}
// Keep only last 10 seconds of data
while frame_times_10s.len() > 600 {
frame_times_10s.remove(0);
}
// Calculate FPS averages
let fps_1s = if !frame_times_1s.is_empty() {
let total_time: Duration = frame_times_1s.iter().sum();
if total_time > Duration::ZERO {
frame_times_1s.len() as f64 / total_time.as_secs_f64()
} else {
0.0
}
} else {
0.0
};
let fps_10s = if !frame_times_10s.is_empty() {
let total_time: Duration = frame_times_10s.iter().sum();
if total_time > Duration::ZERO {
frame_times_10s.len() as f64 / total_time.as_secs_f64()
} else {
0.0
}
} else {
0.0
};
// TODO: Fix key repeat delay issues by using a queue for keyboard events.
// This would allow for instant key repeat without being affected by the
// main loop's tick rate.
for event in event_pump.poll_iter() {
match event {
Event::Window { win_event, .. } => match win_event {
WindowEvent::Hidden => {
event!(tracing::Level::DEBUG, "Window hidden");
shown = false;
}
WindowEvent::Shown => {
event!(tracing::Level::DEBUG, "Window shown");
shown = true;
}
_ => {}
},
// Handle quitting keys or window close
Event::Quit { .. }
| Event::KeyDown {
keycode: Some(Keycode::Escape) | Some(Keycode::Q),
..
} => {
event!(tracing::Level::INFO, "Exit requested. Exiting...");
return false;
}
Event::KeyDown {
keycode: Some(Keycode::P),
..
} => {
paused = !paused;
event!(tracing::Level::INFO, "{}", if paused { "Paused" } else { "Unpaused" });
}
Event::KeyDown { keycode, .. } => {
game.keyboard_event(keycode.unwrap());
}
_ => {}
}
}
// TODO: Implement a proper pausing mechanism that does not interfere with
// statistic gathering and other background tasks.
if !paused {
game.tick();
if let Err(e) = game.draw(&mut canvas, &mut backbuffer) {
eprintln!("Failed to draw game: {}", e);
}
if let Err(e) = game.present_backbuffer(&mut canvas, &backbuffer) {
eprintln!("Failed to present backbuffer: {}", e);
}
}
// Update game with FPS data
game.update_fps(fps_1s, fps_10s);
if start.elapsed() < loop_time {
let time = loop_time.saturating_sub(start.elapsed());
if time != Duration::ZERO {
sleep(time);
}
} else {
event!(
tracing::Level::WARN,
"Game loop behind schedule by: {:?}",
start.elapsed() - loop_time
);
}
true
};
info!("Starting game loop ({:?})", LOOP_TIME);
loop {
if !main_loop() {
if !app.run() {
break;
}
}

View File

@@ -1,176 +0,0 @@
//! This module defines the game map and provides functions for interacting with it.
use rand::rngs::SmallRng;
use rand::seq::IteratorRandom;
use rand::SeedableRng;
use crate::constants::{MapTile, BOARD_CELL_OFFSET, BOARD_CELL_SIZE, BOARD_PIXEL_OFFSET, BOARD_PIXEL_SIZE, CELL_SIZE};
use crate::texture::sprite::AtlasTile;
use glam::{IVec2, UVec2};
use once_cell::sync::OnceCell;
use sdl2::rect::Rect;
use sdl2::render::Canvas;
use sdl2::video::Window;
use std::collections::{HashSet, VecDeque};
/// The game map.
///
/// The map is represented as a 2D array of `MapTile`s. It also stores a copy of
/// the original map, which can be used to reset the map to its initial state.
pub struct Map {
/// The current state of the map.
current: [[MapTile; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize],
/// The default state of the map.
default: [[MapTile; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize],
}
impl Map {
/// Creates a new `Map` instance from a raw board layout.
///
/// # Arguments
///
/// * `raw_board` - A 2D array of characters representing the board layout.
pub fn new(raw_board: [&str; BOARD_CELL_SIZE.y as usize]) -> Map {
let mut map = [[MapTile::Empty; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize];
for (y, line) in raw_board.iter().enumerate().take(BOARD_CELL_SIZE.y as usize) {
for (x, character) in line.chars().enumerate().take(BOARD_CELL_SIZE.x as usize) {
let tile = match character {
'#' => MapTile::Wall,
'.' => MapTile::Pellet,
'o' => MapTile::PowerPellet,
' ' => MapTile::Empty,
'T' => MapTile::Tunnel,
c @ '0' | c @ '1' | c @ '2' | c @ '3' | c @ '4' => MapTile::StartingPosition(c.to_digit(10).unwrap() as u8),
'=' => MapTile::Empty,
_ => panic!("Unknown character in board: {character}"),
};
map[x][y] = tile;
}
}
Map {
current: map,
default: map,
}
}
/// Resets the map to its original state.
pub fn reset(&mut self) {
// Restore the map to its original state
for (x, col) in self.current.iter_mut().enumerate().take(BOARD_CELL_SIZE.x as usize) {
for (y, cell) in col.iter_mut().enumerate().take(BOARD_CELL_SIZE.y as usize) {
*cell = self.default[x][y];
}
}
}
/// Returns the tile at the given cell coordinates.
///
/// # Arguments
///
/// * `cell` - The cell coordinates, in grid coordinates.
pub fn get_tile(&self, cell: IVec2) -> Option<MapTile> {
let x = cell.x as usize;
let y = cell.y as usize;
if x >= BOARD_CELL_SIZE.x as usize || y >= BOARD_CELL_SIZE.y as usize {
return None;
}
Some(self.current[x][y])
}
/// Sets the tile at the given cell coordinates.
///
/// # Arguments
///
/// * `cell` - The cell coordinates, in grid coordinates.
/// * `tile` - The tile to set.
pub fn set_tile(&mut self, cell: IVec2, tile: MapTile) -> bool {
let x = cell.x as usize;
let y = cell.y as usize;
if x >= BOARD_CELL_SIZE.x as usize || y >= BOARD_CELL_SIZE.y as usize {
return false;
}
self.current[x][y] = tile;
true
}
/// Converts cell coordinates to pixel coordinates.
///
/// # Arguments
///
/// * `cell` - The cell coordinates, in grid coordinates.
pub fn cell_to_pixel(cell: UVec2) -> IVec2 {
IVec2::new(
(cell.x * CELL_SIZE) as i32,
((cell.y + BOARD_CELL_OFFSET.y) * CELL_SIZE) as i32,
)
}
/// Returns a reference to a cached vector of all valid playable positions in the maze.
/// This is computed once using a flood fill from a random pellet, and then cached.
pub fn get_valid_playable_positions(&mut self) -> &Vec<UVec2> {
use MapTile::*;
static CACHE: OnceCell<Vec<UVec2>> = OnceCell::new();
if let Some(cached) = CACHE.get() {
return cached;
}
// Find a random starting pellet
let mut pellet_positions = vec![];
for (x, col) in self.current.iter().enumerate().take(BOARD_CELL_SIZE.x as usize) {
for (y, &cell) in col.iter().enumerate().take(BOARD_CELL_SIZE.y as usize) {
match cell {
Pellet | PowerPellet => pellet_positions.push(UVec2::new(x as u32, y as u32)),
_ => {}
}
}
}
let mut rng = SmallRng::from_os_rng();
let &start = pellet_positions
.iter()
.choose(&mut rng)
.expect("No pellet found for flood fill");
// Flood fill
let mut visited = HashSet::new();
let mut queue = VecDeque::new();
queue.push_back(start);
while let Some(pos) = queue.pop_front() {
if !visited.insert(pos) {
continue;
}
match self.current[pos.x as usize][pos.y as usize] {
Empty | Pellet | PowerPellet => {
for offset in [IVec2::new(-1, 0), IVec2::new(1, 0), IVec2::new(0, -1), IVec2::new(0, 1)] {
let neighbor = (pos.as_ivec2() + offset).as_uvec2();
if neighbor.x < BOARD_CELL_SIZE.x && neighbor.y < BOARD_CELL_SIZE.y {
let neighbor_tile = self.current[neighbor.x as usize][neighbor.y as usize];
if matches!(neighbor_tile, Empty | Pellet | PowerPellet) {
queue.push_back(neighbor);
}
}
}
}
StartingPosition(_) | Wall | Tunnel => {}
}
}
let mut result: Vec<UVec2> = visited.into_iter().collect();
result.sort_unstable_by_key(|v| (v.x, v.y));
CACHE.get_or_init(|| result)
}
/// Renders the map to the given canvas using the provided map texture.
pub fn render(&self, canvas: &mut Canvas<Window>, map_texture: &mut AtlasTile) {
let dest = Rect::new(
BOARD_PIXEL_OFFSET.x as i32,
BOARD_PIXEL_OFFSET.y as i32,
BOARD_PIXEL_SIZE.x,
BOARD_PIXEL_SIZE.y,
);
let _ = map_texture.render(canvas, dest);
}
}

370
src/map/builder.rs Normal file
View File

@@ -0,0 +1,370 @@
//! Map construction and building functionality.
use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE};
use crate::entity::direction::{Direction, DIRECTIONS};
use crate::entity::graph::{EdgePermissions, Graph, Node, NodeId};
use crate::map::parser::MapTileParser;
use crate::map::render::MapRenderer;
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
use glam::{IVec2, UVec2, Vec2};
use sdl2::render::{Canvas, RenderTarget};
use std::collections::{HashMap, VecDeque};
use tracing::debug;
/// The starting positions of the entities in the game.
#[allow(dead_code)]
pub struct NodePositions {
pub pacman: NodeId,
pub blinky: NodeId,
pub pinky: NodeId,
pub inky: NodeId,
pub clyde: NodeId,
}
/// The main map structure containing the game board and navigation graph.
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.
pub graph: Graph,
/// A mapping from grid positions to node IDs.
pub grid_to_node: HashMap<IVec2, NodeId>,
/// A mapping of the starting positions of the entities.
#[allow(dead_code)]
pub start_positions: NodePositions,
/// Pac-Man's starting position.
pacman_start: Option<IVec2>,
}
impl Map {
/// Creates a new `Map` instance from a raw board layout.
///
/// This constructor initializes the map tiles based on the provided character layout
/// and then generates a navigation graph from the walkable areas.
///
/// # Panics
///
/// This function will panic if the board layout contains unknown characters or if
/// the house door is not defined by exactly two '=' characters.
pub fn new(raw_board: [&str; BOARD_CELL_SIZE.y as usize]) -> Map {
let parsed_map = MapTileParser::parse_board(raw_board).expect("Failed to parse board layout");
let map = parsed_map.tiles;
let house_door = parsed_map.house_door;
let tunnel_ends = parsed_map.tunnel_ends;
let pacman_start = parsed_map.pacman_start;
let mut graph = Graph::new();
let mut grid_to_node = HashMap::new();
let cell_offset = Vec2::splat(CELL_SIZE as f32 / 2.0);
// 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");
// Add the starting position to the graph/queue
let mut queue = VecDeque::new();
queue.push_back(start_pos);
let pos = Vec2::new(
(start_pos.x * CELL_SIZE as i32) as f32,
(start_pos.y * CELL_SIZE as i32) as f32,
) + cell_offset;
let node_id = graph.add_node(Node { position: pos });
grid_to_node.insert(start_pos, node_id);
// Iterate over the queue, adding nodes to the graph and connecting them to their neighbors
while let Some(source_position) = queue.pop_front() {
for &dir in DIRECTIONS.iter() {
let new_position = source_position + dir.as_ivec2();
// Skip if the new position is out of bounds
if new_position.x < 0
|| new_position.x >= BOARD_CELL_SIZE.x as i32
|| new_position.y < 0
|| new_position.y >= BOARD_CELL_SIZE.y as i32
{
continue;
}
// Skip if the new position is already in the graph
if grid_to_node.contains_key(&new_position) {
continue;
}
// Skip if the new position is not a walkable tile
if matches!(
map[new_position.x as usize][new_position.y as usize],
MapTile::Pellet | MapTile::PowerPellet | MapTile::Empty | MapTile::Tunnel
) {
// Add the new position to the graph/queue
let pos = Vec2::new(
(new_position.x * CELL_SIZE as i32) as f32,
(new_position.y * CELL_SIZE as i32) as f32,
) + cell_offset;
let new_node_id = graph.add_node(Node { position: pos });
grid_to_node.insert(new_position, new_node_id);
queue.push_back(new_position);
// Connect the new node to the source node
let source_node_id = grid_to_node
.get(&source_position)
.unwrap_or_else(|| panic!("Source node not found for {source_position}"));
// Connect the new node to the source node
graph
.connect(*source_node_id, new_node_id, false, None, dir)
.expect("Failed to add edge");
}
}
}
// While most nodes are already connected to their neighbors, some may not be, so we need to connect them
for (grid_pos, &node_id) in &grid_to_node {
for dir in DIRECTIONS {
// If the node doesn't have an edge in this direction, look for a neighbor in that direction
if graph.adjacency_list[node_id].get(dir).is_none() {
let neighbor = grid_pos + dir.as_ivec2();
// If the neighbor exists, connect the node to it
if let Some(&neighbor_id) = grid_to_node.get(&neighbor) {
graph
.connect(node_id, neighbor_id, false, None, dir)
.expect("Failed to add edge");
}
}
}
}
// Build house structure
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);
let start_positions = NodePositions {
pacman: grid_to_node[&start_pos],
blinky: house_entrance_node_id,
pinky: left_center_node_id,
inky: right_center_node_id,
clyde: center_center_node_id,
};
// Build tunnel connections
Self::build_tunnels(&mut graph, &grid_to_node, &tunnel_ends);
Map {
current: map,
graph,
grid_to_node,
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.
///
/// This function draws the static map texture to the screen at the correct
/// position and scale.
pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, map_texture: &mut AtlasTile) {
MapRenderer::render_map(canvas, atlas, map_texture);
}
/// Renders a debug visualization of the navigation graph.
///
/// This function is intended for development and debugging purposes. It draws the
/// nodes and edges of the graph on top of the map, allowing for visual
/// inspection of the navigation paths.
pub fn debug_render_nodes<T: RenderTarget>(&self, canvas: &mut Canvas<T>) {
MapRenderer::debug_render_nodes(&self.graph, canvas);
}
/// Builds the house structure in the graph.
fn build_house(
graph: &mut Graph,
grid_to_node: &HashMap<IVec2, NodeId>,
house_door: &[Option<IVec2>; 2],
) -> (usize, usize, usize, usize) {
// Calculate the position of the house entrance node
let (house_entrance_node_id, house_entrance_node_position) = {
// Translate the grid positions to the actual node ids
let left_node = grid_to_node
.get(&(house_door[0].expect("First house door position not acquired") + Direction::Left.as_ivec2()))
.expect("Left house door node not found");
let right_node = grid_to_node
.get(&(house_door[1].expect("Second house door position not acquired") + Direction::Right.as_ivec2()))
.expect("Right house door node not found");
// Calculate the position of the house node
let (node_id, node_position) = {
let left_pos = graph.get_node(*left_node).unwrap().position;
let right_pos = graph.get_node(*right_node).unwrap().position;
let house_node = graph.add_node(Node {
position: left_pos.lerp(right_pos, 0.5),
});
(house_node, left_pos.lerp(right_pos, 0.5))
};
// Connect the house door to the left and right nodes
graph
.connect(node_id, *left_node, true, None, Direction::Left)
.expect("Failed to connect house door to left node");
graph
.connect(node_id, *right_node, true, None, Direction::Right)
.expect("Failed to connect house door to right node");
(node_id, node_position)
};
// 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) {
// Place the nodes at, above, and below the center position
let center_node_id = graph.add_node(Node { position: center_pos });
let top_node_id = graph.add_node(Node {
position: center_pos + (Direction::Up.as_ivec2() * (CELL_SIZE as i32 / 2)).as_vec2(),
});
let bottom_node_id = graph.add_node(Node {
position: center_pos + (Direction::Down.as_ivec2() * (CELL_SIZE as i32 / 2)).as_vec2(),
});
// Connect the center node to the top and bottom nodes
graph
.connect(center_node_id, top_node_id, false, None, Direction::Up)
.expect("Failed to connect house line to left node");
graph
.connect(center_node_id, bottom_node_id, false, None, Direction::Down)
.expect("Failed to connect house line to right node");
(center_node_id, top_node_id)
};
// Calculate the position of the center line's center node
let center_line_center_position =
house_entrance_node_position + (Direction::Down.as_ivec2() * (3 * CELL_SIZE as i32)).as_vec2();
// Create the center line
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.
// This prevents Pac-Man from entering or exiting through the door.
graph
.add_edge(
house_entrance_node_id,
center_top_node_id,
false,
None,
Direction::Down,
EdgePermissions::GhostsOnly,
)
.expect("Failed to create ghost-only entrance to house");
graph
.add_edge(
center_top_node_id,
house_entrance_node_id,
false,
None,
Direction::Up,
EdgePermissions::GhostsOnly,
)
.expect("Failed to create ghost-only exit from house");
// Create the left line
let (left_center_node_id, _) = create_house_line(
graph,
center_line_center_position + (Direction::Left.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
);
// Create the right line
let (right_center_node_id, _) = create_house_line(
graph,
center_line_center_position + (Direction::Right.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
);
debug!("Left center node id: {left_center_node_id}");
// Connect the center line to the left and right lines
graph
.connect(center_center_node_id, left_center_node_id, false, None, Direction::Left)
.expect("Failed to connect house entrance to left top line");
graph
.connect(center_center_node_id, right_center_node_id, false, None, Direction::Right)
.expect("Failed to connect house entrance to right top line");
debug!("House entrance node id: {house_entrance_node_id}");
(
house_entrance_node_id,
left_center_node_id,
center_center_node_id,
right_center_node_id,
)
}
/// Builds the tunnel connections in the graph.
fn build_tunnels(graph: &mut Graph, grid_to_node: &HashMap<IVec2, NodeId>, tunnel_ends: &[Option<IVec2>; 2]) {
// Create the hidden tunnel nodes
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 = graph
.get_node(left_tunnel_entrance_node_id)
.expect("Left tunnel entrance node not found");
graph
.connect_node(
left_tunnel_entrance_node_id,
Direction::Left,
Node {
position: left_tunnel_entrance_node.position
+ (Direction::Left.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
},
)
.expect("Failed to connect left tunnel entrance to left tunnel hidden node")
};
// Create the right tunnel nodes
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 = graph
.get_node(right_tunnel_entrance_node_id)
.expect("Right tunnel entrance node not found");
graph
.connect_node(
right_tunnel_entrance_node_id,
Direction::Right,
Node {
position: right_tunnel_entrance_node.position
+ (Direction::Right.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
},
)
.expect("Failed to connect right tunnel entrance to right tunnel hidden node")
};
// Connect the left tunnel hidden node to the right tunnel hidden node
graph
.connect(
left_tunnel_hidden_node_id,
right_tunnel_hidden_node_id,
false,
Some(0.0),
Direction::Left,
)
.expect("Failed to connect left tunnel hidden node to right tunnel hidden node");
}
}

8
src/map/mod.rs Normal file
View File

@@ -0,0 +1,8 @@
//! This module defines the game map and provides functions for interacting with it.
pub mod builder;
pub mod parser;
pub mod render;
// Re-export main types for convenience
pub use builder::Map;

120
src/map/parser.rs Normal file
View File

@@ -0,0 +1,120 @@
//! Map parsing functionality for converting raw board layouts into structured data.
use crate::constants::{MapTile, BOARD_CELL_SIZE};
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.
#[derive(Debug)]
pub struct ParsedMap {
/// The parsed tile layout.
pub tiles: [[MapTile; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize],
/// The positions of the house door tiles.
pub house_door: [Option<IVec2>; 2],
/// The positions of the tunnel end tiles.
pub tunnel_ends: [Option<IVec2>; 2],
/// Pac-Man's starting position.
pub pacman_start: Option<IVec2>,
}
/// Parser for converting raw board layouts into structured map data.
pub struct MapTileParser;
impl MapTileParser {
/// Parses a single character into a map tile.
///
/// # Arguments
///
/// * `c` - The character to parse
///
/// # Returns
///
/// The parsed map tile, or an error if the character is unknown.
pub fn parse_character(c: char) -> Result<MapTile, ParseError> {
match c {
'#' => Ok(MapTile::Wall),
'.' => Ok(MapTile::Pellet),
'o' => Ok(MapTile::PowerPellet),
' ' => Ok(MapTile::Empty),
'T' => Ok(MapTile::Tunnel),
'X' => Ok(MapTile::Empty), // Pac-Man's starting position, treated as empty
'=' => Ok(MapTile::Wall), // House door is represented as a wall tile
_ => Err(ParseError::UnknownCharacter(c)),
}
}
/// Parses a raw board layout into structured map data.
///
/// # Arguments
///
/// * `raw_board` - The raw board layout as an array of strings
///
/// # Returns
///
/// The parsed map data, or an error if parsing fails.
///
/// # Errors
///
/// Returns an error if the board contains unknown characters or if the house door
/// is not properly defined by exactly two '=' characters.
pub fn parse_board(raw_board: [&str; BOARD_CELL_SIZE.y as usize]) -> Result<ParsedMap, ParseError> {
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 tunnel_ends = [None; 2];
let mut pacman_start: Option<IVec2> = None;
for (y, line) in raw_board.iter().enumerate().take(BOARD_CELL_SIZE.y as usize) {
for (x, character) in line.chars().enumerate().take(BOARD_CELL_SIZE.x as usize) {
let tile = Self::parse_character(character)?;
// Track special positions
match tile {
MapTile::Tunnel => {
if tunnel_ends[0].is_none() {
tunnel_ends[0] = Some(IVec2::new(x as i32, y as i32));
} else {
tunnel_ends[1] = Some(IVec2::new(x as i32, y as i32));
}
}
MapTile::Wall if character == '=' => {
if house_door[0].is_none() {
house_door[0] = Some(IVec2::new(x as i32, y as i32));
} else {
house_door[1] = Some(IVec2::new(x as i32, y as i32));
}
}
_ => {}
}
// Track Pac-Man's starting position
if character == 'X' {
pacman_start = Some(IVec2::new(x as i32, y as i32));
}
tiles[x][y] = tile;
}
}
// Validate house door configuration
let house_door_count = house_door.iter().filter(|x| x.is_some()).count();
if house_door_count != 2 {
return Err(ParseError::InvalidHouseDoorCount(house_door_count));
}
Ok(ParsedMap {
tiles,
house_door,
tunnel_ends,
pacman_start,
})
}
}

67
src/map/render.rs Normal file
View File

@@ -0,0 +1,67 @@
//! Map rendering functionality.
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
use sdl2::pixels::Color;
use sdl2::rect::{Point, Rect};
use sdl2::render::{Canvas, RenderTarget};
/// Handles rendering operations for the map.
pub struct MapRenderer;
impl MapRenderer {
/// Renders the map to the given canvas.
///
/// This function draws the static map texture to the screen at the correct
/// position and scale.
pub fn render_map<T: RenderTarget>(canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, map_texture: &mut AtlasTile) {
let dest = Rect::new(
crate::constants::BOARD_PIXEL_OFFSET.x as i32,
crate::constants::BOARD_PIXEL_OFFSET.y as i32,
crate::constants::BOARD_PIXEL_SIZE.x,
crate::constants::BOARD_PIXEL_SIZE.y,
);
let _ = map_texture.render(canvas, atlas, dest);
}
/// Renders a debug visualization of the navigation graph.
///
/// This function is intended for development and debugging purposes. It draws the
/// nodes and edges of the graph on top of the map, allowing for visual
/// inspection of the navigation paths.
pub fn debug_render_nodes<T: RenderTarget>(graph: &crate::entity::graph::Graph, canvas: &mut Canvas<T>) {
for i in 0..graph.node_count() {
let node = graph.get_node(i).unwrap();
let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
// Draw connections
canvas.set_draw_color(Color::BLUE);
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();
canvas
.draw_line((pos.x as i32, pos.y as i32), (end_pos.x as i32, end_pos.y as i32))
.unwrap();
}
// Draw node
// let color = if pacman.position.from_node_idx() == i.into() {
// Color::GREEN
// } else if let Some(to_idx) = pacman.position.to_node_idx() {
// if to_idx == i.into() {
// Color::CYAN
// } else {
// Color::RED
// }
// } else {
// Color::RED
// };
canvas.set_draw_color(Color::GREEN);
canvas
.fill_rect(Rect::new(0, 0, 3, 3).centered_on(Point::new(pos.x as i32, pos.y as i32)))
.unwrap();
// Draw node index
// text.render(canvas, atlas, &i.to_string(), pos.as_uvec2()).unwrap();
}
}
}

View File

@@ -1,49 +1,76 @@
//! This module provides a simple animation and atlas system for textures.
use anyhow::Result;
use sdl2::render::WindowCanvas;
use sdl2::rect::Rect;
use sdl2::render::{Canvas, RenderTarget};
use thiserror::Error;
use crate::texture::sprite::AtlasTile;
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
/// An animated texture using a texture atlas.
#[derive(Clone)]
#[derive(Error, Debug)]
pub enum AnimatedTextureError {
#[error("Frame duration must be positive, got {0}")]
InvalidFrameDuration(f32),
}
#[derive(Debug, Clone)]
pub struct AnimatedTexture {
pub frames: Vec<AtlasTile>,
pub ticks_per_frame: u32,
pub ticker: u32,
pub reversed: bool,
pub paused: bool,
tiles: Vec<AtlasTile>,
frame_duration: f32,
current_frame: usize,
time_bank: f32,
}
impl AnimatedTexture {
pub fn new(frames: Vec<AtlasTile>, ticks_per_frame: u32) -> Self {
AnimatedTexture {
frames,
ticks_per_frame,
ticker: 0,
reversed: false,
paused: false,
pub fn new(tiles: Vec<AtlasTile>, frame_duration: f32) -> Result<Self, AnimatedTextureError> {
if frame_duration <= 0.0 {
return Err(AnimatedTextureError::InvalidFrameDuration(frame_duration));
}
Ok(Self {
tiles,
frame_duration,
current_frame: 0,
time_bank: 0.0,
})
}
pub fn tick(&mut self, dt: f32) {
self.time_bank += dt;
while self.time_bank >= self.frame_duration {
self.time_bank -= self.frame_duration;
self.current_frame = (self.current_frame + 1) % self.tiles.len();
}
}
/// Advances the animation by one tick, unless paused.
pub fn tick(&mut self) {
if self.paused || self.ticks_per_frame == 0 {
return;
}
self.ticker += 1;
pub fn current_tile(&self) -> &AtlasTile {
&self.tiles[self.current_frame]
}
pub fn current_tile(&mut self) -> &mut AtlasTile {
if self.ticks_per_frame == 0 {
return &mut self.frames[0];
}
let frame_index = (self.ticker / self.ticks_per_frame) as usize % self.frames.len();
&mut self.frames[frame_index]
pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, dest: Rect) -> Result<()> {
let mut tile = *self.current_tile();
tile.render(canvas, atlas, dest)
}
pub fn render(&mut self, canvas: &mut WindowCanvas, dest: sdl2::rect::Rect) -> Result<()> {
let tile = self.current_tile();
tile.render(canvas, dest)
/// Returns the current frame index.
#[allow(dead_code)]
pub fn current_frame(&self) -> usize {
self.current_frame
}
/// Returns the time bank.
#[allow(dead_code)]
pub fn time_bank(&self) -> f32 {
self.time_bank
}
/// Returns the frame duration.
#[allow(dead_code)]
pub fn frame_duration(&self) -> f32 {
self.frame_duration
}
/// Returns the number of tiles in the animation.
#[allow(dead_code)]
pub fn tiles_len(&self) -> usize {
self.tiles.len()
}
}

View File

@@ -1,48 +1,46 @@
//! A texture that blinks on/off for a specified number of ticks.
use anyhow::Result;
use sdl2::render::WindowCanvas;
use crate::texture::animated::AnimatedTexture;
#![allow(dead_code)]
use crate::texture::sprite::AtlasTile;
#[derive(Clone)]
pub struct BlinkingTexture {
pub animation: AnimatedTexture,
pub on_ticks: u32,
pub off_ticks: u32,
pub ticker: u32,
pub visible: bool,
tile: AtlasTile,
blink_duration: f32,
time_bank: f32,
is_on: bool,
}
impl BlinkingTexture {
pub fn new(animation: AnimatedTexture, on_ticks: u32, off_ticks: u32) -> Self {
BlinkingTexture {
animation,
on_ticks,
off_ticks,
ticker: 0,
visible: true,
pub fn new(tile: AtlasTile, blink_duration: f32) -> Self {
Self {
tile,
blink_duration,
time_bank: 0.0,
is_on: true,
}
}
/// Advances the blinking state by one tick.
pub fn tick(&mut self) {
self.animation.tick();
self.ticker += 1;
if self.visible && self.ticker >= self.on_ticks {
self.visible = false;
self.ticker = 0;
} else if !self.visible && self.ticker >= self.off_ticks {
self.visible = true;
self.ticker = 0;
pub fn tick(&mut self, dt: f32) {
self.time_bank += dt;
if self.time_bank >= self.blink_duration {
self.time_bank -= self.blink_duration;
self.is_on = !self.is_on;
}
}
/// Renders the blinking texture.
pub fn render(&mut self, canvas: &mut WindowCanvas, dest: sdl2::rect::Rect) -> Result<()> {
if self.visible {
self.animation.render(canvas, dest)
} else {
Ok(())
}
pub fn is_on(&self) -> bool {
self.is_on
}
pub fn tile(&self) -> &AtlasTile {
&self.tile
}
// Helper methods for testing
pub fn time_bank(&self) -> f32 {
self.time_bank
}
pub fn blink_duration(&self) -> f32 {
self.blink_duration
}
}

View File

@@ -1,65 +1,81 @@
//! A texture that changes based on the direction of an entity.
use crate::entity::direction::Direction;
use crate::texture::sprite::AtlasTile;
use anyhow::Result;
use sdl2::render::WindowCanvas;
use sdl2::rect::Rect;
use sdl2::render::{Canvas, RenderTarget};
use std::collections::HashMap;
use crate::entity::direction::Direction;
use crate::texture::animated::AnimatedTexture;
use crate::texture::sprite::SpriteAtlas;
#[derive(Clone)]
pub struct DirectionalAnimatedTexture {
pub up: Vec<AtlasTile>,
pub down: Vec<AtlasTile>,
pub left: Vec<AtlasTile>,
pub right: Vec<AtlasTile>,
pub ticker: u32,
pub ticks_per_frame: u32,
textures: HashMap<Direction, AnimatedTexture>,
stopped_textures: HashMap<Direction, AnimatedTexture>,
}
impl DirectionalAnimatedTexture {
pub fn new(
up: Vec<AtlasTile>,
down: Vec<AtlasTile>,
left: Vec<AtlasTile>,
right: Vec<AtlasTile>,
ticks_per_frame: u32,
) -> Self {
pub fn new(textures: HashMap<Direction, AnimatedTexture>, stopped_textures: HashMap<Direction, AnimatedTexture>) -> Self {
Self {
up,
down,
left,
right,
ticker: 0,
ticks_per_frame,
textures,
stopped_textures,
}
}
pub fn tick(&mut self) {
self.ticker += 1;
pub fn tick(&mut self, dt: f32) {
for texture in self.textures.values_mut() {
texture.tick(dt);
}
}
pub fn render(&mut self, canvas: &mut WindowCanvas, dest: sdl2::rect::Rect, direction: Direction) -> Result<()> {
let frames = match direction {
Direction::Up => &mut self.up,
Direction::Down => &mut self.down,
Direction::Left => &mut self.left,
Direction::Right => &mut self.right,
};
let frame_index = (self.ticker / self.ticks_per_frame) as usize % frames.len();
let tile = &mut frames[frame_index];
tile.render(canvas, dest)
pub fn render<T: RenderTarget>(
&self,
canvas: &mut Canvas<T>,
atlas: &mut SpriteAtlas,
dest: Rect,
direction: Direction,
) -> Result<()> {
if let Some(texture) = self.textures.get(&direction) {
texture.render(canvas, atlas, dest)
} else {
Ok(())
}
}
pub fn render_stopped(&mut self, canvas: &mut WindowCanvas, dest: sdl2::rect::Rect, direction: Direction) -> Result<()> {
let frames = match direction {
Direction::Up => &mut self.up,
Direction::Down => &mut self.down,
Direction::Left => &mut self.left,
Direction::Right => &mut self.right,
};
pub fn render_stopped<T: RenderTarget>(
&self,
canvas: &mut Canvas<T>,
atlas: &mut SpriteAtlas,
dest: Rect,
direction: Direction,
) -> Result<()> {
if let Some(texture) = self.stopped_textures.get(&direction) {
texture.render(canvas, atlas, dest)
} else {
Ok(())
}
}
// Show the last frame (full sprite) when stopped
let tile = &mut frames[1];
/// Returns true if the texture has a direction.
#[allow(dead_code)]
pub fn has_direction(&self, direction: Direction) -> bool {
self.textures.contains_key(&direction)
}
tile.render(canvas, dest)
/// Returns true if the texture has a stopped direction.
#[allow(dead_code)]
pub fn has_stopped_direction(&self, direction: Direction) -> bool {
self.stopped_textures.contains_key(&direction)
}
/// Returns the number of textures.
#[allow(dead_code)]
pub fn texture_count(&self) -> usize {
self.textures.len()
}
/// Returns the number of stopped textures.
#[allow(dead_code)]
pub fn stopped_texture_count(&self) -> usize {
self.stopped_textures.len()
}
}

View File

@@ -1,14 +1,5 @@
use std::cell::RefCell;
use std::rc::Rc;
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
pub mod animated;
pub mod blinking;
pub mod directional;
pub mod sprite;
pub mod text;
pub fn get_atlas_tile(atlas: &Rc<RefCell<SpriteAtlas>>, name: &str) -> AtlasTile {
SpriteAtlas::get_tile(atlas, name).unwrap_or_else(|| panic!("Could not find tile {}", name))
}

View File

@@ -4,9 +4,7 @@ use sdl2::pixels::Color;
use sdl2::rect::Rect;
use sdl2::render::{Canvas, RenderTarget, Texture};
use serde::Deserialize;
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
#[derive(Clone, Debug, Deserialize)]
pub struct AtlasMapper {
@@ -21,26 +19,28 @@ pub struct MapperFrame {
pub height: u16,
}
#[derive(Clone)]
#[derive(Copy, Clone, Debug)]
pub struct AtlasTile {
pub atlas: Rc<RefCell<SpriteAtlas>>,
pub pos: U16Vec2,
pub size: U16Vec2,
pub color: Option<Color>,
}
impl AtlasTile {
pub fn render<C: RenderTarget>(&mut self, canvas: &mut Canvas<C>, dest: Rect) -> Result<()> {
let color = self
.color
.unwrap_or(self.atlas.borrow().default_color.unwrap_or(Color::WHITE));
self.render_with_color(canvas, dest, color)
pub fn render<C: RenderTarget>(&mut self, canvas: &mut Canvas<C>, atlas: &mut SpriteAtlas, dest: Rect) -> Result<()> {
let color = self.color.unwrap_or(atlas.default_color.unwrap_or(Color::WHITE));
self.render_with_color(canvas, atlas, dest, color)
}
pub fn render_with_color<C: RenderTarget>(&mut self, canvas: &mut Canvas<C>, dest: Rect, color: Color) -> Result<()> {
pub fn render_with_color<C: RenderTarget>(
&mut self,
canvas: &mut Canvas<C>,
atlas: &mut SpriteAtlas,
dest: Rect,
color: Color,
) -> Result<()> {
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 mut atlas = self.atlas.borrow_mut();
if atlas.last_modulation != Some(color) {
atlas.texture.set_color_mod(color.r, color.g, color.b);
atlas.last_modulation = Some(color);
@@ -49,6 +49,19 @@ impl AtlasTile {
canvas.copy(&atlas.texture, src, dest).map_err(anyhow::Error::msg)?;
Ok(())
}
/// Creates a new atlas tile.
#[allow(dead_code)]
pub fn new(pos: U16Vec2, size: U16Vec2, color: Option<Color>) -> Self {
Self { pos, size, color }
}
/// Sets the color of the tile.
#[allow(dead_code)]
pub fn with_color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
}
pub struct SpriteAtlas {
@@ -68,25 +81,56 @@ impl SpriteAtlas {
}
}
pub fn get_tile(atlas: &Rc<RefCell<SpriteAtlas>>, name: &str) -> Option<AtlasTile> {
let atlas_ref = atlas.borrow();
atlas_ref.tiles.get(name).map(|frame| AtlasTile {
atlas: Rc::clone(atlas),
pub fn get_tile(&self, name: &str) -> Option<AtlasTile> {
self.tiles.get(name).map(|frame| AtlasTile {
pos: U16Vec2::new(frame.x, frame.y),
size: U16Vec2::new(frame.width, frame.height),
color: None,
})
}
#[allow(dead_code)]
pub fn set_color(&mut self, color: Color) {
self.default_color = Some(color);
}
#[allow(dead_code)]
pub fn texture(&self) -> &Texture<'static> {
&self.texture
}
/// Returns the number of tiles in the atlas.
#[allow(dead_code)]
pub fn tiles_count(&self) -> usize {
self.tiles.len()
}
/// Returns true if the atlas has a tile with the given name.
#[allow(dead_code)]
pub fn has_tile(&self, name: &str) -> bool {
self.tiles.contains_key(name)
}
/// Returns the default color of the atlas.
#[allow(dead_code)]
pub fn default_color(&self) -> Option<Color> {
self.default_color
}
}
pub unsafe fn texture_to_static<'a>(texture: Texture<'a>) -> Texture<'static> {
/// 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,3 +1,5 @@
#![allow(dead_code)]
//! This module provides text rendering using the texture atlas.
//!
//! The TextTexture system renders text from the atlas using character mapping.
@@ -7,18 +9,13 @@
//! # Example Usage
//!
//! ```rust
//! use crate::texture::text::TextTexture;
//! use std::rc::Rc;
//! use pacman::texture::text::TextTexture;
//!
//! // Create a text texture with 1.0 scale (8x8 pixels per character)
//! let mut text_renderer = TextTexture::new(atlas.clone(), 1.0);
//! let mut text_renderer = TextTexture::new(1.0);
//!
//! // Render text at position (100, 50)
//! text_renderer.render(canvas, "PAC-MAN", glam::UVec2::new(100, 50))?;
//!
//! // Change scale for larger text
//! // Set scale for larger text
//! text_renderer.set_scale(2.0);
//! text_renderer.render(canvas, "SCORE: 1000", glam::UVec2::new(50, 100))?;
//!
//! // Calculate text width for positioning
//! let width = text_renderer.text_width("GAME OVER");
@@ -48,40 +45,36 @@
use anyhow::Result;
use glam::UVec2;
use sdl2::pixels::Color;
use sdl2::render::{Canvas, RenderTarget};
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
/// A text texture that renders characters from the atlas.
pub struct TextTexture {
atlas: Rc<RefCell<SpriteAtlas>>,
char_map: HashMap<char, AtlasTile>,
scale: f32,
}
impl TextTexture {
/// Creates a new text texture with the given atlas and scale.
pub fn new(atlas: Rc<RefCell<SpriteAtlas>>, scale: f32) -> Self {
pub fn new(scale: f32) -> Self {
Self {
atlas,
char_map: HashMap::new(),
scale,
}
}
/// Maps a character to its atlas tile, handling special characters.
fn get_char_tile(&mut self, c: char) -> Option<AtlasTile> {
fn get_char_tile(&mut self, atlas: &SpriteAtlas, c: char) -> Option<AtlasTile> {
if let Some(tile) = self.char_map.get(&c) {
return Some(tile.clone());
return Some(*tile);
}
let tile_name = self.char_to_tile_name(c)?;
let tile = SpriteAtlas::get_tile(&self.atlas, &tile_name)?;
self.char_map.insert(c, tile.clone());
let tile = atlas.get_tile(&tile_name)?;
self.char_map.insert(c, tile);
Some(tile)
}
@@ -89,9 +82,7 @@ impl TextTexture {
fn char_to_tile_name(&self, c: char) -> Option<String> {
let name = match c {
// Letters A-Z
'A'..='Z' => format!("text/{}.png", c),
// Numbers 0-9
'0'..='9' => format!("text/{}.png", c),
'A'..='Z' | '0'..='9' => format!("text/{c}.png"),
// Special characters
'!' => "text/!.png".to_string(),
'-' => "text/-.png".to_string(),
@@ -108,15 +99,21 @@ impl TextTexture {
}
/// Renders a string of text at the given position.
pub fn render<C: RenderTarget>(&mut self, canvas: &mut Canvas<C>, text: &str, position: UVec2, color: Color) -> Result<()> {
pub fn render<C: RenderTarget>(
&mut self,
canvas: &mut Canvas<C>,
atlas: &mut SpriteAtlas,
text: &str,
position: UVec2,
) -> Result<()> {
let mut x_offset = 0;
let char_width = (8.0 * self.scale) as u32;
let char_height = (8.0 * self.scale) as u32;
for c in text.chars() {
if let Some(mut tile) = self.get_char_tile(c) {
if let Some(mut tile) = self.get_char_tile(atlas, c) {
let dest = sdl2::rect::Rect::new((position.x + x_offset) as i32, position.y as i32, char_width, char_height);
tile.render(canvas, dest)?;
tile.render(canvas, atlas, dest)?;
}
// Always advance x_offset for all characters (including spaces)
x_offset += char_width;
@@ -136,7 +133,7 @@ impl TextTexture {
}
/// Calculates the width of a string in pixels at the current scale.
pub fn text_width(&mut self, text: &str) -> u32 {
pub fn text_width(&self, text: &str) -> u32 {
let char_width = (8.0 * self.scale) as u32;
let mut width = 0;

61
tests/animated.rs Normal file
View File

@@ -0,0 +1,61 @@
use glam::U16Vec2;
use pacman::texture::animated::{AnimatedTexture, AnimatedTextureError};
use pacman::texture::sprite::AtlasTile;
use sdl2::pixels::Color;
fn mock_atlas_tile(id: u32) -> AtlasTile {
AtlasTile {
pos: U16Vec2::new(0, 0),
size: U16Vec2::new(16, 16),
color: Some(Color::RGB(id as u8, 0, 0)),
}
}
#[test]
fn test_animated_texture_creation_errors() {
let tiles = vec![mock_atlas_tile(1), mock_atlas_tile(2)];
assert!(matches!(
AnimatedTexture::new(tiles.clone(), 0.0).unwrap_err(),
AnimatedTextureError::InvalidFrameDuration(0.0)
));
assert!(matches!(
AnimatedTexture::new(tiles, -0.1).unwrap_err(),
AnimatedTextureError::InvalidFrameDuration(-0.1)
));
}
#[test]
fn test_animated_texture_advancement() {
let tiles = vec![mock_atlas_tile(1), mock_atlas_tile(2), mock_atlas_tile(3)];
let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap();
assert_eq!(texture.current_frame(), 0);
texture.tick(0.25);
assert_eq!(texture.current_frame(), 2);
assert!((texture.time_bank() - 0.05).abs() < 0.001);
}
#[test]
fn test_animated_texture_wrap_around() {
let tiles = vec![mock_atlas_tile(1), mock_atlas_tile(2)];
let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap();
texture.tick(0.1);
assert_eq!(texture.current_frame(), 1);
texture.tick(0.1);
assert_eq!(texture.current_frame(), 0);
}
#[test]
fn test_animated_texture_single_frame() {
let tiles = vec![mock_atlas_tile(1)];
let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap();
texture.tick(0.1);
assert_eq!(texture.current_frame(), 0);
assert_eq!(texture.current_tile().color.unwrap().r, 1);
}

49
tests/blinking.rs Normal file
View File

@@ -0,0 +1,49 @@
use glam::U16Vec2;
use pacman::texture::blinking::BlinkingTexture;
use pacman::texture::sprite::AtlasTile;
use sdl2::pixels::Color;
fn mock_atlas_tile(id: u32) -> AtlasTile {
AtlasTile {
pos: U16Vec2::new(0, 0),
size: U16Vec2::new(16, 16),
color: Some(Color::RGB(id as u8, 0, 0)),
}
}
#[test]
fn test_blinking_texture() {
let tile = mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 0.5);
assert_eq!(texture.is_on(), true);
texture.tick(0.5);
assert_eq!(texture.is_on(), false);
texture.tick(0.5);
assert_eq!(texture.is_on(), true);
texture.tick(0.5);
assert_eq!(texture.is_on(), false);
}
#[test]
fn test_blinking_texture_partial_duration() {
let tile = mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 0.5);
texture.tick(0.625);
assert_eq!(texture.is_on(), false);
assert_eq!(texture.time_bank(), 0.125);
}
#[test]
fn test_blinking_texture_negative_time() {
let tile = mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 0.5);
texture.tick(-0.1);
assert_eq!(texture.is_on(), true);
assert_eq!(texture.time_bank(), -0.1);
}

28
tests/constants.rs Normal file
View File

@@ -0,0 +1,28 @@
use pacman::constants::*;
#[test]
fn test_raw_board_structure() {
assert_eq!(RAW_BOARD.len(), BOARD_CELL_SIZE.y as usize);
for row in RAW_BOARD.iter() {
assert_eq!(row.len(), BOARD_CELL_SIZE.x as usize);
}
// Test boundaries
assert!(RAW_BOARD[0].chars().all(|c| c == '#'));
assert!(RAW_BOARD[RAW_BOARD.len() - 1].chars().all(|c| c == '#'));
// Test tunnel row
let tunnel_row = RAW_BOARD[14];
assert_eq!(tunnel_row.chars().next().unwrap(), 'T');
assert_eq!(tunnel_row.chars().last().unwrap(), 'T');
}
#[test]
fn test_raw_board_content() {
let power_pellet_count = RAW_BOARD.iter().flat_map(|row| row.chars()).filter(|&c| c == 'o').count();
assert_eq!(power_pellet_count, 4);
assert!(RAW_BOARD.iter().any(|row| row.contains('X')));
assert!(RAW_BOARD.iter().any(|row| row.contains("==")));
}

31
tests/direction.rs Normal file
View File

@@ -0,0 +1,31 @@
use glam::IVec2;
use pacman::entity::direction::*;
#[test]
fn test_direction_opposite() {
let test_cases = [
(Direction::Up, Direction::Down),
(Direction::Down, Direction::Up),
(Direction::Left, Direction::Right),
(Direction::Right, Direction::Left),
];
for (dir, expected) in test_cases {
assert_eq!(dir.opposite(), expected);
}
}
#[test]
fn test_direction_as_ivec2() {
let test_cases = [
(Direction::Up, -IVec2::Y),
(Direction::Down, IVec2::Y),
(Direction::Left, -IVec2::X),
(Direction::Right, IVec2::X),
];
for (dir, expected) in test_cases {
assert_eq!(dir.as_ivec2(), expected);
assert_eq!(IVec2::from(dir), expected);
}
}

55
tests/directional.rs Normal file
View File

@@ -0,0 +1,55 @@
use glam::U16Vec2;
use pacman::entity::direction::Direction;
use pacman::texture::animated::AnimatedTexture;
use pacman::texture::directional::DirectionalAnimatedTexture;
use pacman::texture::sprite::AtlasTile;
use sdl2::pixels::Color;
use std::collections::HashMap;
fn mock_atlas_tile(id: u32) -> AtlasTile {
AtlasTile {
pos: U16Vec2::new(0, 0),
size: U16Vec2::new(16, 16),
color: Some(Color::RGB(id as u8, 0, 0)),
}
}
fn mock_animated_texture(id: u32) -> AnimatedTexture {
AnimatedTexture::new(vec![mock_atlas_tile(id)], 0.1).expect("Invalid frame duration")
}
#[test]
fn test_directional_texture_partial_directions() {
let mut textures = HashMap::new();
textures.insert(Direction::Up, mock_animated_texture(1));
let texture = DirectionalAnimatedTexture::new(textures, HashMap::new());
assert_eq!(texture.texture_count(), 1);
assert!(texture.has_direction(Direction::Up));
assert!(!texture.has_direction(Direction::Down));
assert!(!texture.has_direction(Direction::Left));
assert!(!texture.has_direction(Direction::Right));
}
#[test]
fn test_directional_texture_all_directions() {
let mut textures = HashMap::new();
let directions = [
(Direction::Up, 1),
(Direction::Down, 2),
(Direction::Left, 3),
(Direction::Right, 4),
];
for (direction, id) in directions {
textures.insert(direction, mock_animated_texture(id));
}
let texture = DirectionalAnimatedTexture::new(textures, HashMap::new());
assert_eq!(texture.texture_count(), 4);
for direction in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] {
assert!(texture.has_direction(*direction));
}
}

21
tests/game.rs Normal file
View File

@@ -0,0 +1,21 @@
use pacman::constants::RAW_BOARD;
use pacman::map::Map;
#[test]
fn test_game_map_creation() {
let map = Map::new(RAW_BOARD);
assert!(map.graph.node_count() > 0);
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());
}

149
tests/graph.rs Normal file
View File

@@ -0,0 +1,149 @@
use pacman::entity::direction::Direction;
use pacman::entity::graph::{EdgePermissions, Graph, Node, Position, Traverser};
fn create_test_graph() -> Graph {
let mut graph = Graph::new();
let node1 = graph.add_node(Node {
position: glam::Vec2::new(0.0, 0.0),
});
let node2 = graph.add_node(Node {
position: glam::Vec2::new(16.0, 0.0),
});
let node3 = graph.add_node(Node {
position: glam::Vec2::new(0.0, 16.0),
});
graph.connect(node1, node2, false, None, Direction::Right).unwrap();
graph.connect(node1, node3, false, None, Direction::Down).unwrap();
graph
}
#[test]
fn test_graph_basic_operations() {
let mut graph = Graph::new();
let node1 = graph.add_node(Node {
position: glam::Vec2::new(0.0, 0.0),
});
let node2 = graph.add_node(Node {
position: glam::Vec2::new(16.0, 0.0),
});
assert_eq!(graph.node_count(), 2);
assert!(graph.get_node(node1).is_some());
assert!(graph.get_node(node2).is_some());
assert!(graph.get_node(999).is_none());
}
#[test]
fn test_graph_connect() {
let mut graph = Graph::new();
let node1 = graph.add_node(Node {
position: glam::Vec2::new(0.0, 0.0),
});
let node2 = graph.add_node(Node {
position: glam::Vec2::new(16.0, 0.0),
});
assert!(graph.connect(node1, node2, false, None, Direction::Right).is_ok());
let edge1 = graph.find_edge_in_direction(node1, Direction::Right);
let edge2 = graph.find_edge_in_direction(node2, Direction::Left);
assert!(edge1.is_some());
assert!(edge2.is_some());
assert_eq!(edge1.unwrap().target, node2);
assert_eq!(edge2.unwrap().target, node1);
}
#[test]
fn test_graph_connect_errors() {
let mut graph = Graph::new();
let node1 = graph.add_node(Node {
position: glam::Vec2::new(0.0, 0.0),
});
assert!(graph.connect(node1, 999, false, None, Direction::Right).is_err());
assert!(graph.connect(999, node1, false, None, Direction::Right).is_err());
}
#[test]
fn test_graph_edge_permissions() {
let mut graph = Graph::new();
let node1 = graph.add_node(Node {
position: glam::Vec2::new(0.0, 0.0),
});
let node2 = graph.add_node(Node {
position: glam::Vec2::new(16.0, 0.0),
});
graph
.add_edge(node1, node2, false, None, Direction::Right, EdgePermissions::GhostsOnly)
.unwrap();
let edge = graph.find_edge_in_direction(node1, Direction::Right).unwrap();
assert_eq!(edge.permissions, EdgePermissions::GhostsOnly);
}
#[test]
fn test_traverser_basic() {
let graph = create_test_graph();
let mut traverser = Traverser::new(&graph, 0, Direction::Left, &|_| true);
traverser.set_next_direction(Direction::Up);
assert!(traverser.next_direction.is_some());
assert_eq!(traverser.next_direction.unwrap().0, Direction::Up);
}
#[test]
fn test_traverser_advance() {
let graph = create_test_graph();
let mut traverser = Traverser::new(&graph, 0, Direction::Right, &|_| true);
traverser.advance(&graph, 5.0, &|_| true);
match traverser.position {
Position::BetweenNodes { from, to, traversed } => {
assert_eq!(from, 0);
assert_eq!(to, 1);
assert_eq!(traversed, 5.0);
}
_ => panic!("Expected to be between nodes"),
}
traverser.advance(&graph, 3.0, &|_| true);
match traverser.position {
Position::BetweenNodes { from, to, traversed } => {
assert_eq!(from, 0);
assert_eq!(to, 1);
assert_eq!(traversed, 8.0);
}
_ => panic!("Expected to be between nodes"),
}
}
#[test]
fn test_traverser_with_permissions() {
let mut graph = Graph::new();
let node1 = graph.add_node(Node {
position: glam::Vec2::new(0.0, 0.0),
});
let node2 = graph.add_node(Node {
position: glam::Vec2::new(16.0, 0.0),
});
graph
.add_edge(node1, node2, false, None, Direction::Right, EdgePermissions::GhostsOnly)
.unwrap();
// Pacman can't traverse ghost-only edges
let mut traverser = Traverser::new(&graph, node1, Direction::Right, &|edge| {
matches!(edge.permissions, EdgePermissions::All)
});
traverser.advance(&graph, 5.0, &|edge| matches!(edge.permissions, EdgePermissions::All));
// Should still be at the node since it can't traverse
assert!(traverser.position.is_at_node());
}

19
tests/helpers.rs Normal file
View File

@@ -0,0 +1,19 @@
use glam::{IVec2, UVec2};
use pacman::helpers::centered_with_size;
#[test]
fn test_centered_with_size() {
let test_cases = [
((100, 100), (50, 30), (75, 85)),
((50, 50), (51, 31), (25, 35)),
((0, 0), (100, 100), (-50, -50)),
((-100, -50), (80, 40), (-140, -70)),
((1000, 1000), (1000, 1000), (500, 500)),
];
for ((pos_x, pos_y), (size_x, size_y), (expected_x, expected_y)) in test_cases {
let rect = centered_with_size(IVec2::new(pos_x, pos_y), UVec2::new(size_x, size_y));
assert_eq!(rect.origin(), (expected_x, expected_y));
assert_eq!(rect.size(), (size_x, size_y));
}
}

86
tests/map_builder.rs Normal file
View File

@@ -0,0 +1,86 @@
use glam::Vec2;
use pacman::constants::{BOARD_CELL_SIZE, CELL_SIZE};
use pacman::map::Map;
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]
fn test_map_creation() {
let board = create_minimal_test_board();
let map = Map::new(board);
assert!(map.graph.node_count() > 0);
assert!(!map.grid_to_node.is_empty());
// Check that some connections were made
let mut has_connections = false;
for intersection in &map.graph.adjacency_list {
if intersection.edges().next().is_some() {
has_connections = true;
break;
}
}
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]
fn test_map_node_positions() {
let board = create_minimal_test_board();
let map = Map::new(board);
for (grid_pos, &node_id) in &map.grid_to_node {
let node = map.graph.get_node(node_id).unwrap();
let expected_pos = Vec2::new((grid_pos.x * CELL_SIZE as i32) as f32, (grid_pos.y * CELL_SIZE as i32) as f32)
+ Vec2::splat(CELL_SIZE as f32 / 2.0);
assert_eq!(node.position, expected_pos);
}
}

107
tests/pacman.rs Normal file
View File

@@ -0,0 +1,107 @@
use pacman::entity::direction::Direction;
use pacman::entity::graph::{Graph, Node};
use pacman::entity::pacman::Pacman;
use pacman::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas};
use sdl2::keyboard::Keycode;
use std::collections::HashMap;
fn create_test_graph() -> Graph {
let mut graph = Graph::new();
let node1 = graph.add_node(Node {
position: glam::Vec2::new(0.0, 0.0),
});
let node2 = graph.add_node(Node {
position: glam::Vec2::new(16.0, 0.0),
});
let node3 = graph.add_node(Node {
position: glam::Vec2::new(0.0, 16.0),
});
graph.connect(node1, node2, false, None, Direction::Right).unwrap();
graph.connect(node1, node3, false, None, Direction::Down).unwrap();
graph
}
fn create_test_atlas() -> SpriteAtlas {
let mut frames = HashMap::new();
let directions = ["up", "down", "left", "right"];
for (i, dir) in directions.iter().enumerate() {
frames.insert(
format!("pacman/{dir}_a.png"),
MapperFrame {
x: i as u16 * 16,
y: 0,
width: 16,
height: 16,
},
);
frames.insert(
format!("pacman/{dir}_b.png"),
MapperFrame {
x: i as u16 * 16,
y: 16,
width: 16,
height: 16,
},
);
}
frames.insert(
"pacman/full.png".to_string(),
MapperFrame {
x: 64,
y: 0,
width: 16,
height: 16,
},
);
let mapper = AtlasMapper { frames };
let dummy_texture = unsafe { std::mem::zeroed() };
SpriteAtlas::new(dummy_texture, mapper)
}
#[test]
fn test_pacman_creation() {
let graph = create_test_graph();
let atlas = create_test_atlas();
let pacman = Pacman::new(&graph, 0, &atlas);
assert!(pacman.traverser.position.is_at_node());
assert_eq!(pacman.traverser.direction, Direction::Left);
}
#[test]
fn test_pacman_key_handling() {
let graph = create_test_graph();
let atlas = create_test_atlas();
let mut pacman = Pacman::new(&graph, 0, &atlas);
let test_cases = [
(Keycode::Up, Direction::Up),
(Keycode::Down, Direction::Down),
(Keycode::Left, Direction::Left),
(Keycode::Right, Direction::Right),
];
for (key, expected_direction) in test_cases {
pacman.handle_key(key);
assert!(pacman.traverser.next_direction.is_some() || pacman.traverser.direction == expected_direction);
}
}
#[test]
fn test_pacman_invalid_key() {
let graph = create_test_graph();
let atlas = create_test_atlas();
let mut pacman = Pacman::new(&graph, 0, &atlas);
let original_direction = pacman.traverser.direction;
let original_next_direction = pacman.traverser.next_direction;
pacman.handle_key(Keycode::Space);
assert_eq!(pacman.traverser.direction, original_direction);
assert_eq!(pacman.traverser.next_direction, original_next_direction);
}

46
tests/parser.rs Normal file
View File

@@ -0,0 +1,46 @@
use pacman::constants::{BOARD_CELL_SIZE, RAW_BOARD};
use pacman::map::parser::{MapTileParser, ParseError};
#[test]
fn test_parse_character() {
let test_cases = [
('#', pacman::constants::MapTile::Wall),
('.', pacman::constants::MapTile::Pellet),
('o', pacman::constants::MapTile::PowerPellet),
(' ', pacman::constants::MapTile::Empty),
('T', pacman::constants::MapTile::Tunnel),
('X', pacman::constants::MapTile::Empty),
('=', pacman::constants::MapTile::Wall),
];
for (char, _expected) in test_cases {
assert!(matches!(MapTileParser::parse_character(char).unwrap(), _expected));
}
assert!(MapTileParser::parse_character('Z').is_err());
}
#[test]
fn test_parse_board() {
let result = MapTileParser::parse_board(RAW_BOARD);
assert!(result.is_ok());
let parsed = result.unwrap();
assert_eq!(parsed.tiles.len(), BOARD_CELL_SIZE.x as usize);
assert_eq!(parsed.tiles[0].len(), BOARD_CELL_SIZE.y as usize);
assert!(parsed.house_door[0].is_some());
assert!(parsed.house_door[1].is_some());
assert!(parsed.tunnel_ends[0].is_some());
assert!(parsed.tunnel_ends[1].is_some());
assert!(parsed.pacman_start.is_some());
}
#[test]
fn test_parse_board_invalid_character() {
let mut invalid_board = RAW_BOARD.clone();
invalid_board[0] = "###########################Z";
let result = MapTileParser::parse_board(invalid_board);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ParseError::UnknownCharacter('Z')));
}

78
tests/sprite.rs Normal file
View File

@@ -0,0 +1,78 @@
use pacman::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas};
use sdl2::pixels::Color;
use std::collections::HashMap;
fn mock_texture() -> sdl2::render::Texture<'static> {
unsafe { std::mem::transmute(0usize) }
}
#[test]
fn test_sprite_atlas_basic() {
let mut frames = HashMap::new();
frames.insert(
"test".to_string(),
MapperFrame {
x: 10,
y: 20,
width: 32,
height: 64,
},
);
let mapper = AtlasMapper { frames };
let texture = mock_texture();
let atlas = SpriteAtlas::new(texture, mapper);
let tile = atlas.get_tile("test");
assert!(tile.is_some());
let tile = tile.unwrap();
assert_eq!(tile.pos, glam::U16Vec2::new(10, 20));
assert_eq!(tile.size, glam::U16Vec2::new(32, 64));
assert_eq!(tile.color, None);
}
#[test]
fn test_sprite_atlas_multiple_tiles() {
let mut frames = HashMap::new();
frames.insert(
"tile1".to_string(),
MapperFrame {
x: 0,
y: 0,
width: 32,
height: 32,
},
);
frames.insert(
"tile2".to_string(),
MapperFrame {
x: 32,
y: 0,
width: 64,
height: 64,
},
);
let mapper = AtlasMapper { frames };
let texture = mock_texture();
let atlas = SpriteAtlas::new(texture, mapper);
assert_eq!(atlas.tiles_count(), 2);
assert!(atlas.has_tile("tile1"));
assert!(atlas.has_tile("tile2"));
assert!(!atlas.has_tile("tile3"));
assert!(atlas.get_tile("nonexistent").is_none());
}
#[test]
fn test_sprite_atlas_color() {
let mapper = AtlasMapper { frames: HashMap::new() };
let texture = mock_texture();
let mut atlas = SpriteAtlas::new(texture, mapper);
assert_eq!(atlas.default_color(), None);
let color = Color::RGB(255, 0, 0);
atlas.set_color(color);
assert_eq!(atlas.default_color(), Some(color));
}

262
web.build.ts Normal file
View File

@@ -0,0 +1,262 @@
import { $ } from "bun";
import { existsSync, promises as fs } from "fs";
import { platform } from "os";
import { dirname, join, relative, resolve } from "path";
import { match, P } from "ts-pattern";
type Os =
| { type: "linux"; wsl: boolean }
| { type: "windows" }
| { type: "macos" };
const os: Os = match(platform())
.with("win32", () => ({ type: "windows" as const }))
.with("linux", () => ({
type: "linux" as const,
// We detect WSL by checking for the presence of the WSLInterop file.
// This is a semi-standard method of detecting WSL, which is more than workable for this already hacky script.
wsl: existsSync("/proc/sys/fs/binfmt_misc/WSLInterop"),
}))
.with("darwin", () => ({ type: "macos" as const }))
.otherwise(() => {
throw new Error(`Unsupported platform: ${platform()}`);
});
function log(msg: string) {
console.log(`[web.build] ${msg}`);
}
/**
* Build the application with Emscripten, generate the CSS, and copy the files into 'dist'.
*
* @param release - Whether to build in release mode.
* @param env - The environment variables to inject into build commands.
*/
async function build(release: boolean, env: Record<string, string>) {
log(
`Building for 'wasm32-unknown-emscripten' for ${
release ? "release" : "debug"
}`
);
await $`cargo build --target=wasm32-unknown-emscripten ${
release ? "--release" : ""
}`.env(env);
log("Invoking @tailwindcss/cli");
// unfortunately, bunx doesn't seem to work with @tailwindcss/cli, so we have to use npx directly
await $`npx --yes @tailwindcss/cli --minify --input styles.css --output build.css --cwd assets/site`;
const buildType = release ? "release" : "debug";
const siteFolder = resolve("assets/site");
const outputFolder = resolve(`target/wasm32-unknown-emscripten/${buildType}`);
const dist = resolve("dist");
// The files to copy into 'dist'
const files = [
...["index.html", "favicon.ico", "build.css", "TerminalVector.ttf"].map(
(file) => ({
src: join(siteFolder, file),
dest: join(dist, file),
optional: false,
})
),
...["pacman.wasm", "pacman.js", "deps/pacman.data"].map((file) => ({
src: join(outputFolder, file),
dest: join(dist, file.split("/").pop() || file),
optional: false,
})),
{
src: join(outputFolder, "pacman.wasm.map"),
dest: join(dist, "pacman.wasm.map"),
optional: true,
},
];
// Create required destination folders
await Promise.all(
// Get the dirname of files, remove duplicates
[...new Set(files.map(({ dest }) => dirname(dest)))]
// Create the folders
.map(async (dir) => {
// If the folder doesn't exist, create it
if (!(await fs.exists(dir))) {
log(`Creating folder ${dir}`);
await fs.mkdir(dir, { recursive: true });
}
})
);
// Copy the files to the dist folder
log("Copying files into dist");
await Promise.all(
files.map(async ({ optional, src, dest }) => {
match({ optional, exists: await fs.exists(src) })
// If optional and doesn't exist, skip
.with({ optional: true, exists: false }, () => {
log(
`Optional file ${os.type === "windows" ? "\\" : "/"}${relative(
process.cwd(),
src
)} does not exist, skipping...`
);
})
// If not optional and doesn't exist, throw an error
.with({ optional: false, exists: false }, () => {
throw new Error(`Required file ${src} does not exist`);
})
// Otherwise, copy the file
.otherwise(async () => await fs.copyFile(src, dest));
})
);
}
/**
* Checks to see if the Emscripten SDK is activated for a Windows or *nix machine by looking for a .exe file and the equivalent file on Linux/macOS. Returns both results for handling.
* @param emsdkDir - The directory containing the Emscripten SDK.
* @returns A record of environment variables.
*/
async function checkEmsdkType(
emsdkDir: string
): Promise<{ windows: boolean; nix: boolean }> {
const binary = resolve(join(emsdkDir, "upstream", "bin", "clang"));
return {
windows: await fs.exists(binary + ".exe"),
nix: await fs.exists(binary),
};
}
/**
* Activate the Emscripten SDK environment variables.
* Technically, this doesn't actaully activate the environment variables for the current shell,
* it just runs the environment sourcing script and returns the environment variables for future command invocations.
* @param emsdkDir - The directory containing the Emscripten SDK.
* @returns A record of environment variables.
*/
async function activateEmsdk(
emsdkDir: string
): Promise<{ vars: Record<string, string> } | { err: string }> {
// Determine the environment script to use based on the OS
const envScript = match(os)
.with({ type: "windows" }, () => join(emsdkDir, "emsdk_env.bat"))
.with({ type: P.union("linux", "macos") }, () =>
join(emsdkDir, "emsdk_env.sh")
)
.exhaustive();
// Run the environment script and capture the output
const { stdout, stderr, exitCode } = await match(os)
.with({ type: "windows" }, () =>
// run the script, ignore it's output ('>nul'), then print the environment variables ('set')
$`cmd /c "${envScript} >nul && set"`.quiet()
)
.with({ type: P.union("linux", "macos") }, () =>
// run the script with bash, ignore it's output ('> /dev/null'), then print the environment variables ('env')
$`bash -c "source '${envScript}' && env"`.quiet()
)
.exhaustive();
if (exitCode !== 0) {
return { err: stderr.toString() };
}
// Parse the output into a record of environment variables
const vars = Object.fromEntries(
stdout
.toString()
.split(os.type === "windows" ? /\r?\n/ : "\n") // Split output into lines, handling Windows CRLF vs *nix LF
.map((line) => line.split("=", 2)) // Parse each line as KEY=VALUE (limit to 2 parts)
.filter(([k, v]) => k && v) // Keep only valid key-value pairs (both parts exist)
);
return { vars };
}
async function main() {
// Print the OS detected
log(
"OS Detected: " +
match(os)
.with({ type: "windows" }, () => "Windows")
.with({ type: "linux" }, ({ wsl: isWsl }) =>
isWsl ? "Linux (via WSL)" : "Linux"
)
.with({ type: "macos" }, () => "macOS")
.exhaustive()
);
const release = process.env.RELEASE !== "0";
const emsdkDir = resolve("./emsdk");
// Ensure the emsdk directory exists before attempting to activate or use it
if (!(await fs.exists(emsdkDir))) {
log(
`Emscripten SDK directory not found at ${emsdkDir}. Please install or clone 'emsdk' and try again.`
);
process.exit(1);
}
const vars = match(await activateEmsdk(emsdkDir)) // result handling
.with({ vars: P.select() }, (vars) => vars)
.with({ err: P.any }, ({ err }) => {
log("Error activating Emscripten SDK: " + err);
process.exit(1);
})
.exhaustive();
// Check if the Emscripten SDK is activated/installed properly for the current OS
match({
os: os,
...(await checkEmsdkType(emsdkDir)),
})
// If the Emscripten SDK is not activated/installed properly, exit with an error
.with(
{
nix: false,
windows: false,
},
() => {
log(
"Emscripten SDK does not appear to be activated/installed properly."
);
process.exit(1);
}
)
// If the Emscripten SDK is activated for Windows, but is currently running on a *nix OS, exit with an error
.with(
{
nix: false,
windows: true,
os: { type: P.not("windows") },
},
() => {
log(
"Emscripten SDK appears to be activated for Windows, but is currently running on a *nix OS."
);
process.exit(1);
}
)
// If the Emscripten SDK is activated for *nix, but is currently running on a Windows OS, exit with an error
.with(
{
nix: true,
windows: false,
os: { type: "windows" },
},
() => {
log(
"Emscripten SDK appears to be activated for *nix, but is currently running on a Windows OS."
);
process.exit(1);
}
);
// Build the application
await build(release, vars);
}
/**
* Main entry point.
*/
main().catch((err) => {
console.error("[web.build] Error:", err);
process.exit(1);
});