mirror of
https://github.com/Xevion/Pac-Man.git
synced 2025-12-06 05:15:49 -06:00
Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27705f1ba2 | |||
| e964adc818 | |||
| c5213320ac | |||
| e0f8443e75 | |||
| 6702b3723a | |||
| f6e7228f75 | |||
| 14cebe4462 | |||
| c39fcaa7d7 | |||
| 1d9499c4f8 | |||
| 61050a5585 | |||
| 85420711df | |||
| 2efa7a4df5 | |||
| 1d018db5e9 | |||
| 023697dcd7 | |||
| 87ee12543e | |||
| b308bc0ef7 | |||
| 9d5ca54234 | |||
| 2ae73c3c58 | |||
| adfa2cc737 | |||
| 7c937df002 | |||
| 9fb9c959a3 | |||
| 61ebc8f317 | |||
| b7f668c58a | |||
| b1021c28b5 | |||
| 7d6f92283a | |||
| 2a295b1daf | |||
| 4398ec2936 | |||
| 324c358672 | |||
| cda8c40195 | |||
| 89b4ba125f | |||
| fcdbe62f99 | |||
| 57c7afcdb4 | |||
| 2e16c2d170 | |||
| f86c106593 | |||
| 04cf8f217f | |||
| 7e0ca4ff3d | |||
| fcc36c8a46 | |||
| 41affcd7ad | |||
| 4ecfded4ac | |||
| 25d5121a28 | |||
| 91095ed2cc | |||
| cbf52bb994 | |||
| d763b9646f | |||
| d7a9e0a304 | |||
| db720edeef | |||
| f241e85d8f | |||
| d18b414536 | |||
| c9bcf32381 | |||
| b45980c172 | |||
| b4e3f383ec | |||
| 532abd1e45 | |||
| 70528b0dcc | |||
| c5ca7302c2 | |||
| a27f85279e | |||
| bea915b5c7 | |||
| d743aee393 | |||
| 59aba9f691 | |||
| 199b4dc939 | |||
| 2edd23cfbb | |||
| 464d6f9ca6 | |||
| 413f9f156f | |||
| 4f87a116d5 | |||
| 86ffc931e8 |
@@ -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
2
.config/nextest.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[profile.default]
|
||||
fail-fast = false
|
||||
27
.github/workflows/audit.yaml
vendored
Normal file
27
.github/workflows/audit.yaml
vendored
Normal 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
|
||||
68
.github/workflows/build.yaml
vendored
68
.github/workflows/build.yaml
vendored
@@ -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 -i 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
57
.github/workflows/coverage.yaml
vendored
Normal 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
54
.github/workflows/test.yaml
vendored
Normal 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
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,7 +1,7 @@
|
||||
/target
|
||||
/dist
|
||||
target/
|
||||
dist/
|
||||
emsdk/
|
||||
.idea
|
||||
*.dll
|
||||
rust-sdl2-emscripten/
|
||||
assets/site/build.css
|
||||
emsdk/
|
||||
tailwindcss-*
|
||||
|
||||
5
Cargo.lock
generated
5
Cargo.lock
generated
@@ -192,6 +192,7 @@ dependencies = [
|
||||
"sdl2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"smallvec",
|
||||
"spin_sleep",
|
||||
"thiserror 1.0.69",
|
||||
"tracing",
|
||||
@@ -392,9 +393,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"
|
||||
|
||||
@@ -17,14 +17,14 @@ 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"
|
||||
smallvec = "1.15.1"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
panic = "abort"
|
||||
panic-strategy = "abort"
|
||||
opt-level = "z"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies.winapi]
|
||||
|
||||
134
README.md
134
README.md
@@ -1,79 +1,87 @@
|
||||
# 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
|
||||
|
||||
## Feature Targets
|
||||
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.
|
||||
|
||||
- Near-perfect replication of logic, scoring, graphics, sound, and behaviors.
|
||||
- Written in Rust, buildable on Windows, Linux, Mac and WebAssembly.
|
||||
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
|
||||
|
||||
This cross-platform implementation is built with SDL2 for graphics, audio, and input handling. It can run on Windows, Linux, macOS, and in web browsers via WebAssembly.
|
||||
|
||||
## Why?
|
||||
|
||||
Just because. And because I wanted to learn more about Rust, inter-operability with C, and compiling to WebAssembly.
|
||||
|
||||
I was inspired by a certain code review video on YouTube; [SOME UNIQUE C++ CODE // Pacman Clone Code Review](https://www.youtube.com/watch?v=OKs_JewEeOo) by The Cherno.
|
||||
|
||||
For some reason, I was inspired to try and replicate it in Rust, and it was uniquely challenging.
|
||||
|
||||
I wanted to hit a log of goals and features, making it a 'perfect' project that I could be proud of.
|
||||
|
||||
- Near-perfect replication of logic, scoring, graphics, sound, and behaviors. No hacks, workarounds, or poor designs.
|
||||
- Written in Rust, buildable on Windows, Linux, Mac and WebAssembly. Statically linked, no runtime dependencies.
|
||||
- Performant, low memory, CPU and GPU usage.
|
||||
- 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
|
||||
- Completely automatic build system with releases for all platforms.
|
||||
- Well documented, well-tested, and maintainable.
|
||||
|
||||
## Experimental Ideas
|
||||
|
||||
- Debug tooling
|
||||
- Game state visualization
|
||||
- Game speed controls + pausing
|
||||
- Log tracing
|
||||
- Performance details
|
||||
- Customized Themes & Colors
|
||||
- Color-blind friendly
|
||||
- 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.
|
||||
- An online axum server with a simple database and OAuth2 authentication.
|
||||
- Integrates with GitHub, Discord, and Google OAuth2 to acquire an email identifier & avatar.
|
||||
- Avatars are optional for score submission and can be disabled, instead using a blank avatar.
|
||||
- Avatars are downscaled to a low resolution pixellated image to maintain the 8-bit aesthetic.
|
||||
- A custom name is used for the score submission, which is checked for potential abusive language.
|
||||
- A max length of 14 characters, and a min length of 3 characters.
|
||||
- Names are checked for potential abusive language via an external API.
|
||||
- The client implementation should require zero configuration, environment variables, or special secrets.
|
||||
- It simply defaults to the pacman server API, or can be overriden manually.
|
||||
|
||||
## Installation
|
||||
## Build Notes
|
||||
|
||||
Besides SDL2, the following extensions are required: Image, Mixer, and TTF.
|
||||
Since this project is still in progress, I'm only going to cover non-obvious build details. By reading the code, build scripts, and copying the online build workflows, you should be able to replicate the build process.
|
||||
|
||||
### 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.
|
||||
- I'm still not sure _why_ 3.1.43 is required, but it is. Perhaps in the future I will attempt to use a more modern version.
|
||||
- Occasionally, the build will fail due to dependencies failing to download. I even have a retry mechanism in the build workflow due to this.
|
||||
- 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))
|
||||
- `web.build.ts` auto installs dependencies, but you may need to pass `-i` or `--install=fallback|force` to install missing packages. My guess is that if you have some packages installed, it won't install any missing ones. If you have no packages installed, it will install all of them.
|
||||
- If you want to have TypeScript resolution for development, you can manually install the dependencies with `bun install` in the `assets/site` folder.
|
||||
|
||||
95
STORY.md
95
STORY.md
@@ -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/
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 90 B |
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB |
@@ -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= */
|
||||
@@ -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,55 @@
|
||||
></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"
|
||||
>← ↑ → ↓ 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 + ↑↓ 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"
|
||||
oncontextmenu="event.preventDefault()"
|
||||
class="block bg-black w-full max-w-[90vw] h-auto 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">← ↑ → ↓</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
28
assets/site/styles.css
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
74
build.sh
74
build.sh
@@ -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
201
build.ts
@@ -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);
|
||||
});
|
||||
155
src/app.rs
Normal file
155
src/app.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use glam::Vec2;
|
||||
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;
|
||||
use crate::platform::get_platform;
|
||||
|
||||
pub struct App<'a> {
|
||||
game: Game,
|
||||
canvas: Canvas<Window>,
|
||||
event_pump: EventPump,
|
||||
backbuffer: Texture<'a>,
|
||||
paused: bool,
|
||||
last_tick: Instant,
|
||||
cursor_pos: Vec2,
|
||||
}
|
||||
|
||||
impl App<'_> {
|
||||
pub fn new() -> Result<Self> {
|
||||
// Initialize platform-specific console
|
||||
get_platform().init_console().map_err(|e| anyhow!(e))?;
|
||||
|
||||
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, glam::Vec2::ZERO)?;
|
||||
|
||||
Ok(Self {
|
||||
game,
|
||||
canvas,
|
||||
event_pump,
|
||||
backbuffer,
|
||||
paused: false,
|
||||
last_tick: Instant::now(),
|
||||
cursor_pos: Vec2::ZERO,
|
||||
})
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
Event::MouseMotion { x, y, .. } => {
|
||||
// Convert window coordinates to logical coordinates
|
||||
self.cursor_pos = Vec2::new(x as f32, y as f32);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
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, self.cursor_pos)
|
||||
{
|
||||
error!("Failed to present backbuffer: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
if start.elapsed() < LOOP_TIME {
|
||||
let time = LOOP_TIME.saturating_sub(start.elapsed());
|
||||
if time != Duration::ZERO {
|
||||
get_platform().sleep(time);
|
||||
}
|
||||
} else {
|
||||
event!(
|
||||
tracing::Level::WARN,
|
||||
"Game loop behind schedule by: {:?}",
|
||||
start.elapsed() - LOOP_TIME
|
||||
);
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src/asset.rs
37
src/asset.rs
@@ -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,48 +36,18 @@ 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "emscripten"))]
|
||||
mod imp {
|
||||
use super::*;
|
||||
macro_rules! asset_bytes_enum {
|
||||
( $asset:expr ) => {
|
||||
match $asset {
|
||||
Asset::Wav1 => Cow::Borrowed(include_bytes!("../assets/game/sound/waka/1.ogg")),
|
||||
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")),
|
||||
}
|
||||
};
|
||||
}
|
||||
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
|
||||
Ok(asset_bytes_enum!(asset))
|
||||
}
|
||||
}
|
||||
use crate::platform::get_platform;
|
||||
|
||||
#[cfg(target_os = "emscripten")]
|
||||
mod imp {
|
||||
use super::*;
|
||||
use sdl2::rwops::RWops;
|
||||
use std::io::Read;
|
||||
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
|
||||
let path = format!("assets/game/{}", asset.path());
|
||||
let mut rwops = RWops::from_file(&path, "rb").map_err(|_| AssetError::NotFound(asset.path().to_string()))?;
|
||||
let len = rwops.len().ok_or_else(|| AssetError::NotFound(asset.path().to_string()))?;
|
||||
let mut buf = vec![0u8; len];
|
||||
rwops
|
||||
.read_exact(&mut buf)
|
||||
.map_err(|e| AssetError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
|
||||
Ok(Cow::Owned(buf))
|
||||
get_platform().get_asset_bytes(asset)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
117
src/audio.rs
117
src/audio.rs
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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#",
|
||||
"###.##.##.########.##.##.###",
|
||||
"###.##.##.########.##.##.###",
|
||||
"#......##....##....##......#",
|
||||
|
||||
73
src/debug.rs
73
src/debug.rs
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
#[allow(dead_code)]
|
||||
#[cfg(target_os = "emscripten")]
|
||||
pub mod emscripten {
|
||||
use std::os::raw::c_uint;
|
||||
|
||||
extern "C" {
|
||||
pub fn emscripten_get_now() -> f64;
|
||||
pub fn emscripten_sleep(ms: c_uint);
|
||||
pub fn emscripten_get_element_css_size(target: *const u8, width: *mut f64, height: *mut f64) -> i32;
|
||||
}
|
||||
|
||||
// milliseconds since start of program
|
||||
pub fn now() -> f64 {
|
||||
unsafe { emscripten_get_now() }
|
||||
}
|
||||
|
||||
pub fn sleep(ms: u32) {
|
||||
unsafe {
|
||||
emscripten_sleep(ms);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_canvas_size() -> (u32, u32) {
|
||||
let mut width = 0.0;
|
||||
let mut height = 0.0;
|
||||
unsafe {
|
||||
emscripten_get_element_css_size("canvas\0".as_ptr(), &mut width, &mut height);
|
||||
}
|
||||
(width as u32, height as u32)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
//! 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)]
|
||||
/// The four cardinal directions.
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum Direction {
|
||||
Up,
|
||||
Down,
|
||||
@@ -13,48 +10,44 @@ 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,
|
||||
}
|
||||
}
|
||||
/// The four cardinal directions.
|
||||
/// This is just a convenience constant for iterating over the directions.
|
||||
pub const DIRECTIONS: [Direction; 4] = [Direction::Up, Direction::Down, Direction::Left, Direction::Right];
|
||||
|
||||
/// Returns the offset of the direction as a tuple of (x, y).
|
||||
pub fn offset(&self) -> IVec2 {
|
||||
/// Returns the opposite direction. Constant time.
|
||||
pub const fn opposite(self) -> Direction {
|
||||
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,
|
||||
/// Returns the direction as an IVec2.
|
||||
pub fn as_ivec2(self) -> IVec2 {
|
||||
self.into()
|
||||
}
|
||||
|
||||
/// Returns the direction as a usize (0-3). Constant time.
|
||||
/// This is useful for indexing into arrays.
|
||||
pub const fn as_usize(self) -> usize {
|
||||
match self {
|
||||
Direction::Up => 0,
|
||||
Direction::Down => 1,
|
||||
Direction::Left => 2,
|
||||
Direction::Right => 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,348 +1,216 @@
|
||||
use rand::rngs::SmallRng;
|
||||
use rand::Rng;
|
||||
use rand::SeedableRng;
|
||||
//! Ghost entity implementation.
|
||||
//!
|
||||
//! This module contains the ghost character logic, including movement,
|
||||
//! animation, and rendering. Ghosts move through the game graph using
|
||||
//! a traverser and display directional animated textures.
|
||||
|
||||
use crate::constants::MapTile;
|
||||
use crate::constants::BOARD_CELL_SIZE;
|
||||
use glam::Vec2;
|
||||
use rand::prelude::*;
|
||||
use smallvec::SmallVec;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::constants::BOARD_PIXEL_OFFSET;
|
||||
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;
|
||||
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::render::{Canvas, RenderTarget};
|
||||
|
||||
/// 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,
|
||||
/// Determines if a ghost can traverse a given edge.
|
||||
///
|
||||
/// Ghosts can move through edges that allow all entities or ghost-only edges.
|
||||
fn can_ghost_traverse(edge: Edge) -> bool {
|
||||
matches!(edge.permissions, EdgePermissions::All | EdgePermissions::GhostsOnly)
|
||||
}
|
||||
|
||||
/// The different ghost personalities
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
/// The four classic ghost types.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GhostType {
|
||||
Blinky, // Red - Shadow
|
||||
Pinky, // Pink - Speedy
|
||||
Inky, // Cyan - Bashful
|
||||
Clyde, // Orange - Pokey
|
||||
Blinky,
|
||||
Pinky,
|
||||
Inky,
|
||||
Clyde,
|
||||
}
|
||||
|
||||
impl GhostType {
|
||||
/// Returns the color of the ghost.
|
||||
pub fn color(&self) -> Color {
|
||||
/// Returns the ghost type name for atlas lookups.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
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,
|
||||
);
|
||||
/// Returns the base movement speed for this ghost type.
|
||||
pub fn base_speed(self) -> f32 {
|
||||
match self {
|
||||
GhostType::Blinky => 1.0,
|
||||
GhostType::Pinky => 0.95,
|
||||
GhostType::Inky => 0.9,
|
||||
GhostType::Clyde => 0.85,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let frightened_texture = BlinkingTexture::new(
|
||||
AnimatedTexture::new(
|
||||
/// A ghost entity that roams the game world.
|
||||
///
|
||||
/// Ghosts move through the game world using a graph-based navigation system
|
||||
/// and display directional animated sprites. They randomly choose directions
|
||||
/// at each intersection.
|
||||
pub struct Ghost {
|
||||
/// Handles movement through the game graph
|
||||
pub traverser: Traverser,
|
||||
/// The type of ghost (affects appearance and speed)
|
||||
pub ghost_type: GhostType,
|
||||
/// Manages directional animated textures for different movement states
|
||||
texture: DirectionalAnimatedTexture,
|
||||
/// Current movement speed
|
||||
speed: f32,
|
||||
}
|
||||
|
||||
impl Ghost {
|
||||
/// Creates a new ghost instance at the specified starting node.
|
||||
///
|
||||
/// Sets up animated textures for all four directions with moving and stopped states.
|
||||
/// The moving animation cycles through two sprite variants.
|
||||
pub fn new(graph: &Graph, start_node: NodeId, ghost_type: GhostType, atlas: &SpriteAtlas) -> Self {
|
||||
let mut textures = [None, None, None, None];
|
||||
let mut stopped_textures = [None, None, None, None];
|
||||
|
||||
for direction in Direction::DIRECTIONS {
|
||||
let moving_prefix = match direction {
|
||||
Direction::Up => "up",
|
||||
Direction::Down => "down",
|
||||
Direction::Left => "left",
|
||||
Direction::Right => "right",
|
||||
};
|
||||
let moving_tiles = vec![
|
||||
SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a")).unwrap(),
|
||||
SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "b")).unwrap(),
|
||||
];
|
||||
|
||||
let stopped_tiles =
|
||||
vec![
|
||||
get_atlas_tile(&atlas, "ghost/frightened/blue_a.png"),
|
||||
get_atlas_tile(&atlas, "ghost/frightened/blue_b.png"),
|
||||
],
|
||||
10,
|
||||
),
|
||||
45,
|
||||
15,
|
||||
);
|
||||
SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a"))
|
||||
.unwrap(),
|
||||
];
|
||||
|
||||
let eyes_get = |dir: &str| get_atlas_tile(&atlas, &format!("ghost/eyes/{}.png", dir));
|
||||
textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.2).expect("Invalid frame duration"));
|
||||
stopped_textures[direction.as_usize()] =
|
||||
Some(AnimatedTexture::new(stopped_tiles, 0.1).expect("Invalid frame duration"));
|
||||
}
|
||||
|
||||
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,
|
||||
Self {
|
||||
traverser: Traverser::new(graph, start_node, Direction::Left, &can_ghost_traverse),
|
||||
ghost_type,
|
||||
pacman,
|
||||
texture,
|
||||
frightened_texture,
|
||||
eyes_texture,
|
||||
texture: DirectionalAnimatedTexture::new(textures, stopped_textures),
|
||||
speed: ghost_type.base_speed(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
/// Updates the ghost's position and animation state.
|
||||
///
|
||||
/// Advances movement through the graph, updates texture animation,
|
||||
/// and chooses random directions at intersections.
|
||||
pub fn tick(&mut self, dt: f32, graph: &Graph) {
|
||||
// Choose random direction when at a node
|
||||
if self.traverser.position.is_at_node() {
|
||||
self.choose_random_direction(graph);
|
||||
}
|
||||
|
||||
self.traverser.advance(graph, dt * 60.0 * self.speed, &can_ghost_traverse);
|
||||
self.texture.tick(dt);
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
/// Chooses a random available direction at the current intersection.
|
||||
fn choose_random_direction(&mut self, graph: &Graph) {
|
||||
let current_node = self.traverser.position.from_node_id();
|
||||
let intersection = &graph.adjacency_list[current_node];
|
||||
|
||||
/// 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);
|
||||
// Collect all available directions
|
||||
let mut available_directions = SmallVec::<[_; 4]>::new();
|
||||
for direction in Direction::DIRECTIONS {
|
||||
if let Some(edge) = intersection.get(direction) {
|
||||
if can_ghost_traverse(edge) {
|
||||
available_directions.push(direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if possible_moves.is_empty() {
|
||||
// No valid moves, must reverse
|
||||
self.base.next_cell(Some(self.base.direction.opposite()))
|
||||
debug!(
|
||||
"Ghost {} at node {}: available directions: {:?}, current direction: {:?}",
|
||||
self.ghost_type.as_str(),
|
||||
current_node,
|
||||
available_directions,
|
||||
self.traverser.direction
|
||||
);
|
||||
|
||||
// Choose a random direction (avoid reversing unless necessary)
|
||||
if !available_directions.is_empty() {
|
||||
let mut rng = SmallRng::from_os_rng();
|
||||
|
||||
// Filter out the opposite direction if possible, but allow it if we have limited options
|
||||
let opposite = self.traverser.direction.opposite();
|
||||
let filtered_directions: Vec<_> = available_directions
|
||||
.iter()
|
||||
.filter(|&&dir| dir != opposite || available_directions.len() <= 2)
|
||||
.collect();
|
||||
|
||||
debug!(
|
||||
"Ghost {}: filtered directions: {:?}, opposite: {:?}",
|
||||
self.ghost_type.as_str(),
|
||||
filtered_directions,
|
||||
opposite
|
||||
);
|
||||
|
||||
if let Some(&random_direction) = filtered_directions.choose(&mut rng) {
|
||||
self.traverser.set_next_direction(*random_direction);
|
||||
debug!("Ghost {} chose direction: {:?}", self.ghost_type.as_str(), random_direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculates the current pixel position in the game world.
|
||||
///
|
||||
/// Converts the graph position to screen coordinates, accounting for
|
||||
/// the board offset and centering the sprite.
|
||||
fn get_pixel_pos(&self, graph: &Graph) -> Vec2 {
|
||||
let pos = match self.traverser.position {
|
||||
Position::AtNode(node_id) => graph.get_node(node_id).unwrap().position,
|
||||
Position::BetweenNodes { from, to, traversed } => {
|
||||
let from_pos = graph.get_node(from).unwrap().position;
|
||||
let to_pos = graph.get_node(to).unwrap().position;
|
||||
let edge = graph.find_edge(from, to).unwrap();
|
||||
from_pos + (to_pos - from_pos) * (traversed / edge.distance)
|
||||
}
|
||||
};
|
||||
|
||||
Vec2::new(pos.x + BOARD_PIXEL_OFFSET.x as f32, pos.y + BOARD_PIXEL_OFFSET.y as f32)
|
||||
}
|
||||
|
||||
/// Renders the ghost at its current position.
|
||||
///
|
||||
/// Draws the appropriate directional sprite based on the ghost's
|
||||
/// current movement state and direction.
|
||||
pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, graph: &Graph) {
|
||||
let pixel_pos = self.get_pixel_pos(graph);
|
||||
let dest = centered_with_size(
|
||||
glam::IVec2::new(pixel_pos.x as i32, pixel_pos.y as i32),
|
||||
glam::UVec2::new(16, 16),
|
||||
);
|
||||
|
||||
if self.traverser.position.is_stopped() {
|
||||
self.texture
|
||||
.render_stopped(canvas, atlas, dest, self.traverser.direction)
|
||||
.expect("Failed to render ghost");
|
||||
} else {
|
||||
// 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)
|
||||
}
|
||||
self.texture
|
||||
.render(canvas, atlas, dest, self.traverser.direction)
|
||||
.expect("Failed to render ghost");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
443
src/entity/graph.rs
Normal file
443
src/entity/graph.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,208 +1,4 @@
|
||||
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;
|
||||
|
||||
// Reset tunnel state if we're not at a tunnel position
|
||||
if !at_left_tunnel && !at_right_tunnel {
|
||||
self.in_tunnel = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// If we're already in a tunnel, stay in tunnel state
|
||||
if self.in_tunnel {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Enter the tunnel and teleport to the other side
|
||||
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<()>;
|
||||
}
|
||||
|
||||
@@ -1,143 +1,130 @@
|
||||
//! 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;
|
||||
//! Pac-Man entity implementation.
|
||||
//!
|
||||
//! This module contains the main player character logic, including movement,
|
||||
//! animation, and rendering. Pac-Man moves through the game graph using
|
||||
//! a traverser and displays directional animated textures.
|
||||
|
||||
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 glam::{UVec2, Vec2};
|
||||
|
||||
/// The Pac-Man entity.
|
||||
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};
|
||||
|
||||
/// Determines if Pac-Man can traverse a given edge.
|
||||
///
|
||||
/// Pac-Man can only move through edges that allow all entities.
|
||||
fn can_pacman_traverse(edge: Edge) -> bool {
|
||||
matches!(edge.permissions, EdgePermissions::All)
|
||||
}
|
||||
|
||||
/// The main player character entity.
|
||||
///
|
||||
/// Pac-Man moves through the game world using a graph-based navigation system
|
||||
/// and displays directional animated sprites based on movement state.
|
||||
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;
|
||||
}
|
||||
/// Handles movement through the game graph
|
||||
pub traverser: Traverser,
|
||||
/// Manages directional animated textures for different movement states
|
||||
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);
|
||||
/// Creates a new Pac-Man instance at the specified starting node.
|
||||
///
|
||||
/// Sets up animated textures for all four directions with moving and stopped states.
|
||||
/// The moving animation cycles through open mouth, closed mouth, and full sprites.
|
||||
pub fn new(graph: &Graph, start_node: NodeId, atlas: &SpriteAtlas) -> Self {
|
||||
let mut textures = [None, None, None, None];
|
||||
let mut stopped_textures = [None, None, None, None];
|
||||
|
||||
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::DIRECTIONS {
|
||||
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[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.08).expect("Invalid frame duration"));
|
||||
stopped_textures[direction.as_usize()] =
|
||||
Some(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)
|
||||
/// Updates Pac-Man's position and animation state.
|
||||
///
|
||||
/// Advances movement through the graph and updates texture animation.
|
||||
/// Movement speed is scaled by 60 FPS and a 1.125 multiplier.
|
||||
pub fn tick(&mut self, dt: f32, graph: &Graph) {
|
||||
self.traverser.advance(graph, dt * 60.0 * 1.125, &can_pacman_traverse);
|
||||
self.texture.tick(dt);
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
<Pacman as Moving>::tick(self);
|
||||
self.texture.tick();
|
||||
/// Handles keyboard input to change Pac-Man's direction.
|
||||
///
|
||||
/// Maps arrow keys to directions and queues the direction change
|
||||
/// for the next valid intersection.
|
||||
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;
|
||||
/// Calculates the current pixel position in the game world.
|
||||
///
|
||||
/// Interpolates between nodes when moving between them.
|
||||
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);
|
||||
/// Renders Pac-Man to the canvas.
|
||||
///
|
||||
/// Calculates screen position, determines if Pac-Man is stopped,
|
||||
/// and renders the appropriate directional texture.
|
||||
pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, graph: &Graph) {
|
||||
let pixel_pos = self.get_pixel_pos(graph).round().as_ivec2() + BOARD_PIXEL_OFFSET.as_ivec2();
|
||||
let dest = centered_with_size(pixel_pos, UVec2::new(16, 16));
|
||||
let is_stopped = self.traverser.position.is_stopped();
|
||||
|
||||
if 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(());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
354
src/game.rs
354
src/game.rs
@@ -1,55 +1,44 @@
|
||||
//! 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 rand::{rngs::SmallRng, Rng, SeedableRng};
|
||||
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::{
|
||||
ghost::{Ghost, GhostType},
|
||||
pacman::Pacman,
|
||||
},
|
||||
map::Map,
|
||||
texture::{
|
||||
sprite::{self, AtlasMapper, AtlasTile, SpriteAtlas},
|
||||
text::TextTexture,
|
||||
},
|
||||
};
|
||||
|
||||
/// The main game state.
|
||||
///
|
||||
/// Contains all the information necessary to run the game, including
|
||||
/// the game state, rendering resources, and audio.
|
||||
pub struct Game {
|
||||
// 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 ghosts: Vec<Ghost>,
|
||||
pub debug_mode: bool,
|
||||
|
||||
// Rendering resources
|
||||
atlas: Rc<RefCell<SpriteAtlas>>,
|
||||
atlas: SpriteAtlas,
|
||||
map_texture: AtlasTile,
|
||||
text_texture: TextTexture,
|
||||
|
||||
@@ -58,13 +47,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 +69,117 @@ 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);
|
||||
|
||||
// Create ghosts at random positions
|
||||
let mut ghosts = Vec::new();
|
||||
let ghost_types = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde];
|
||||
let mut rng = SmallRng::from_os_rng();
|
||||
|
||||
for &ghost_type in &ghost_types {
|
||||
// Find a random node for the ghost to start at
|
||||
let random_node = rng.random_range(0..map.graph.node_count());
|
||||
let ghost = Ghost::new(&map.graph, random_node, ghost_type, &atlas);
|
||||
ghosts.push(ghost);
|
||||
}
|
||||
|
||||
Game {
|
||||
pacman,
|
||||
blinky,
|
||||
edibles,
|
||||
map,
|
||||
score: 0,
|
||||
debug_mode: DebugMode::None,
|
||||
atlas,
|
||||
map,
|
||||
pacman,
|
||||
ghosts,
|
||||
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;
|
||||
self.audio.set_mute(!self.audio.is_muted());
|
||||
}
|
||||
|
||||
// Reset game
|
||||
if keycode == Keycode::R {
|
||||
self.reset();
|
||||
self.reset_game_state();
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds points to the score.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `points` - The number of points to add.
|
||||
pub fn add_score(&mut self, points: u32) {
|
||||
self.score += points;
|
||||
}
|
||||
/// Resets the game state, randomizing ghost positions and resetting Pac-Man
|
||||
fn reset_game_state(&mut self) {
|
||||
// Reset Pac-Man to starting position
|
||||
let pacman_start_pos = self.map.find_starting_position(0).unwrap();
|
||||
let pacman_start_node = *self
|
||||
.map
|
||||
.grid_to_node
|
||||
.get(&glam::IVec2::new(pacman_start_pos.x as i32, pacman_start_pos.y as i32))
|
||||
.expect("Pac-Man starting position not found in graph");
|
||||
|
||||
/// 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;
|
||||
}
|
||||
self.pacman = Pacman::new(&self.map.graph, pacman_start_node, &self.atlas);
|
||||
|
||||
/// 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();
|
||||
// Randomize ghost positions
|
||||
let ghost_types = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde];
|
||||
let mut rng = SmallRng::from_os_rng();
|
||||
|
||||
for (i, ghost) in self.ghosts.iter_mut().enumerate() {
|
||||
let random_node = rng.random_range(0..self.map.graph.node_count());
|
||||
*ghost = Ghost::new(&self.map.graph, random_node, ghost_types[i], &self.atlas);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the score
|
||||
self.score = 0;
|
||||
pub fn tick(&mut self, dt: f32) {
|
||||
self.pacman.tick(dt, &self.map.graph);
|
||||
|
||||
// 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();
|
||||
// Update all ghosts
|
||||
for ghost in &mut self.ghosts {
|
||||
ghost.tick(dt, &self.map.graph);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
pub fn draw<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>, backbuffer: &mut Texture) -> Result<()> {
|
||||
canvas.with_texture_canvas(backbuffer, |canvas| {
|
||||
canvas.set_draw_color(Color::BLACK);
|
||||
canvas.clear();
|
||||
self.map.render(canvas, &mut self.atlas, &mut self.map_texture);
|
||||
|
||||
// Render all ghosts
|
||||
for ghost in &self.ghosts {
|
||||
ghost.render(canvas, &mut self.atlas, &self.map.graph);
|
||||
}
|
||||
|
||||
// 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.pacman.render(canvas, &mut self.atlas, &self.map.graph);
|
||||
})?;
|
||||
|
||||
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),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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,
|
||||
cursor_pos: glam::Vec2,
|
||||
) -> Result<()> {
|
||||
canvas.copy(backbuffer, None, None).map_err(anyhow::Error::msg)?;
|
||||
self.render_ui_on(canvas);
|
||||
if self.debug_mode {
|
||||
self.map
|
||||
.debug_render_with_cursor(canvas, &mut self.text_texture, &mut self.atlas, cursor_pos);
|
||||
}
|
||||
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 +189,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 +209,7 @@ impl Game {
|
||||
// IVec2::new(10, 10),
|
||||
// Color::RGB(255, 255, 0), // Yellow color for FPS display
|
||||
// );
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
107
src/helper.rs
107
src/helper.rs
@@ -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
11
src/helpers.rs
Normal 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
12
src/lib.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
//! Pac-Man game library crate.
|
||||
|
||||
pub mod app;
|
||||
pub mod asset;
|
||||
pub mod audio;
|
||||
pub mod constants;
|
||||
pub mod entity;
|
||||
pub mod game;
|
||||
pub mod helpers;
|
||||
pub mod map;
|
||||
pub mod platform;
|
||||
pub mod texture;
|
||||
250
src/main.rs
250
src/main.rs
@@ -1,98 +1,27 @@
|
||||
#![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;
|
||||
|
||||
#[cfg(windows)]
|
||||
use winapi::{
|
||||
shared::ntdef::NULL,
|
||||
um::{
|
||||
fileapi::{CreateFileA, OPEN_EXISTING},
|
||||
handleapi::INVALID_HANDLE_VALUE,
|
||||
processenv::SetStdHandle,
|
||||
winbase::{STD_ERROR_HANDLE, STD_OUTPUT_HANDLE},
|
||||
wincon::{AttachConsole, GetConsoleWindow},
|
||||
winnt::{FILE_SHARE_READ, FILE_SHARE_WRITE, GENERIC_READ, GENERIC_WRITE},
|
||||
},
|
||||
};
|
||||
|
||||
/// Attaches the process to the parent console on Windows.
|
||||
///
|
||||
/// This allows the application to print to the console when run from a terminal,
|
||||
/// which is useful for debugging purposes. If the application is not run from a
|
||||
/// terminal, this function does nothing.
|
||||
#[cfg(windows)]
|
||||
unsafe fn attach_console() {
|
||||
if !std::ptr::eq(GetConsoleWindow(), std::ptr::null_mut()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if AttachConsole(winapi::um::wincon::ATTACH_PARENT_PROCESS) != 0 {
|
||||
let handle = CreateFileA(
|
||||
c"CONOUT$".as_ptr(),
|
||||
GENERIC_READ | GENERIC_WRITE,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
std::ptr::null_mut(),
|
||||
OPEN_EXISTING,
|
||||
0,
|
||||
NULL,
|
||||
);
|
||||
|
||||
if handle != INVALID_HANDLE_VALUE {
|
||||
SetStdHandle(STD_OUTPUT_HANDLE, handle);
|
||||
SetStdHandle(STD_ERROR_HANDLE, handle);
|
||||
}
|
||||
}
|
||||
// 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 platform;
|
||||
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
|
||||
/// the main game loop.
|
||||
pub fn main() {
|
||||
// Attaches the console on Windows for debugging purposes.
|
||||
#[cfg(windows)]
|
||||
unsafe {
|
||||
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 +31,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;
|
||||
}
|
||||
}
|
||||
|
||||
176
src/map.rs
176
src/map.rs
@@ -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);
|
||||
}
|
||||
}
|
||||
375
src/map/builder.rs
Normal file
375
src/map/builder.rs
Normal file
@@ -0,0 +1,375 @@
|
||||
//! Map construction and building functionality.
|
||||
|
||||
use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE};
|
||||
use crate::entity::direction::Direction;
|
||||
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 Direction::DIRECTIONS {
|
||||
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 Direction::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 with cursor-based highlighting.
|
||||
///
|
||||
/// This function provides interactive debugging by highlighting the nearest node
|
||||
/// to the cursor, showing its ID, and highlighting its connections.
|
||||
pub fn debug_render_with_cursor<T: RenderTarget>(
|
||||
&self,
|
||||
canvas: &mut Canvas<T>,
|
||||
text_renderer: &mut crate::texture::text::TextTexture,
|
||||
atlas: &mut SpriteAtlas,
|
||||
cursor_pos: glam::Vec2,
|
||||
) {
|
||||
MapRenderer::debug_render_with_cursor(&self.graph, canvas, text_renderer, atlas, cursor_pos);
|
||||
}
|
||||
|
||||
/// 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
8
src/map/mod.rs
Normal 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
120
src/map/parser.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
117
src/map/render.rs
Normal file
117
src/map/render.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
//! Map rendering functionality.
|
||||
|
||||
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
|
||||
use crate::texture::text::TextTexture;
|
||||
use glam::Vec2;
|
||||
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 with cursor-based highlighting.
|
||||
///
|
||||
/// This function provides interactive debugging by highlighting the nearest node
|
||||
/// to the cursor, showing its ID, and highlighting its connections.
|
||||
pub fn debug_render_with_cursor<T: RenderTarget>(
|
||||
graph: &crate::entity::graph::Graph,
|
||||
canvas: &mut Canvas<T>,
|
||||
text_renderer: &mut TextTexture,
|
||||
atlas: &mut SpriteAtlas,
|
||||
cursor_pos: Vec2,
|
||||
) {
|
||||
// Find the nearest node to the cursor
|
||||
let nearest_node = Self::find_nearest_node(graph, cursor_pos);
|
||||
|
||||
// Draw all connections in blue
|
||||
canvas.set_draw_color(Color::RGB(0, 0, 128)); // Dark blue for regular connections
|
||||
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();
|
||||
|
||||
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 all nodes in green
|
||||
canvas.set_draw_color(Color::RGB(0, 128, 0)); // Dark green for regular nodes
|
||||
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();
|
||||
|
||||
canvas
|
||||
.fill_rect(Rect::new(0, 0, 3, 3).centered_on(Point::new(pos.x as i32, pos.y as i32)))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Highlight connections from the nearest node in bright blue
|
||||
if let Some(nearest_id) = nearest_node {
|
||||
let nearest_pos = graph.get_node(nearest_id).unwrap().position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
|
||||
|
||||
canvas.set_draw_color(Color::RGB(0, 255, 255)); // Bright cyan for highlighted connections
|
||||
for edge in graph.adjacency_list[nearest_id].edges() {
|
||||
let end_pos = graph.get_node(edge.target).unwrap().position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
|
||||
canvas
|
||||
.draw_line(
|
||||
(nearest_pos.x as i32, nearest_pos.y as i32),
|
||||
(end_pos.x as i32, end_pos.y as i32),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Highlight the nearest node in bright green
|
||||
canvas.set_draw_color(Color::RGB(0, 255, 0)); // Bright green for highlighted node
|
||||
canvas
|
||||
.fill_rect(Rect::new(0, 0, 5, 5).centered_on(Point::new(nearest_pos.x as i32, nearest_pos.y as i32)))
|
||||
.unwrap();
|
||||
|
||||
// Draw node ID text (small, offset to top right)
|
||||
text_renderer.set_scale(0.5); // Small text
|
||||
let id_text = format!("#{}", nearest_id);
|
||||
let text_pos = glam::UVec2::new(
|
||||
(nearest_pos.x + 4.0) as u32, // Offset to the right
|
||||
(nearest_pos.y - 6.0) as u32, // Offset to the top
|
||||
);
|
||||
let _ = text_renderer.render(canvas, atlas, &id_text, text_pos);
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds the nearest node to the given cursor position.
|
||||
pub fn find_nearest_node(graph: &crate::entity::graph::Graph, cursor_pos: Vec2) -> Option<usize> {
|
||||
let mut nearest_id = None;
|
||||
let mut nearest_distance = f32::INFINITY;
|
||||
|
||||
for i in 0..graph.node_count() {
|
||||
let node = graph.get_node(i).unwrap();
|
||||
let node_pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
|
||||
let distance = cursor_pos.distance(node_pos);
|
||||
|
||||
if distance < nearest_distance {
|
||||
nearest_distance = distance;
|
||||
nearest_id = Some(i);
|
||||
}
|
||||
}
|
||||
|
||||
nearest_id
|
||||
}
|
||||
}
|
||||
77
src/platform/desktop.rs
Normal file
77
src/platform/desktop.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
//! Desktop platform implementation.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::asset::{Asset, AssetError};
|
||||
use crate::platform::{Platform, PlatformError};
|
||||
|
||||
/// Desktop platform implementation.
|
||||
pub struct DesktopPlatform;
|
||||
|
||||
impl Platform for DesktopPlatform {
|
||||
fn sleep(&self, duration: Duration) {
|
||||
spin_sleep::sleep(duration);
|
||||
}
|
||||
|
||||
fn get_time(&self) -> f64 {
|
||||
std::time::Instant::now().elapsed().as_secs_f64()
|
||||
}
|
||||
|
||||
fn init_console(&self) -> Result<(), PlatformError> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
unsafe {
|
||||
use winapi::{
|
||||
shared::ntdef::NULL,
|
||||
um::{
|
||||
fileapi::{CreateFileA, OPEN_EXISTING},
|
||||
handleapi::INVALID_HANDLE_VALUE,
|
||||
processenv::SetStdHandle,
|
||||
winbase::{STD_ERROR_HANDLE, STD_OUTPUT_HANDLE},
|
||||
wincon::{AttachConsole, GetConsoleWindow},
|
||||
winnt::{FILE_SHARE_READ, FILE_SHARE_WRITE, GENERIC_READ, GENERIC_WRITE},
|
||||
},
|
||||
};
|
||||
|
||||
if !std::ptr::eq(GetConsoleWindow(), std::ptr::null_mut()) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if AttachConsole(winapi::um::wincon::ATTACH_PARENT_PROCESS) != 0 {
|
||||
let handle = CreateFileA(
|
||||
c"CONOUT$".as_ptr(),
|
||||
GENERIC_READ | GENERIC_WRITE,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
std::ptr::null_mut(),
|
||||
OPEN_EXISTING,
|
||||
0,
|
||||
NULL,
|
||||
);
|
||||
|
||||
if handle != INVALID_HANDLE_VALUE {
|
||||
SetStdHandle(STD_OUTPUT_HANDLE, handle);
|
||||
SetStdHandle(STD_ERROR_HANDLE, handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_canvas_size(&self) -> Option<(u32, u32)> {
|
||||
None // Desktop doesn't need this
|
||||
}
|
||||
|
||||
fn get_asset_bytes(&self, asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
|
||||
match asset {
|
||||
Asset::Wav1 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/1.ogg"))),
|
||||
Asset::Wav2 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/2.ogg"))),
|
||||
Asset::Wav3 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/3.ogg"))),
|
||||
Asset::Wav4 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/4.ogg"))),
|
||||
Asset::Atlas => Ok(Cow::Borrowed(include_bytes!("../../assets/game/atlas.png"))),
|
||||
Asset::AtlasJson => Ok(Cow::Borrowed(include_bytes!("../../assets/game/atlas.json"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
61
src/platform/emscripten.rs
Normal file
61
src/platform/emscripten.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
//! Emscripten platform implementation.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::asset::{Asset, AssetError};
|
||||
use crate::platform::{Platform, PlatformError};
|
||||
|
||||
/// Emscripten platform implementation.
|
||||
pub struct EmscriptenPlatform;
|
||||
|
||||
impl Platform for EmscriptenPlatform {
|
||||
fn sleep(&self, duration: Duration) {
|
||||
unsafe {
|
||||
emscripten_sleep(duration.as_millis() as u32);
|
||||
}
|
||||
}
|
||||
|
||||
fn get_time(&self) -> f64 {
|
||||
unsafe { emscripten_get_now() }
|
||||
}
|
||||
|
||||
fn init_console(&self) -> Result<(), PlatformError> {
|
||||
Ok(()) // No-op for Emscripten
|
||||
}
|
||||
|
||||
fn get_canvas_size(&self) -> Option<(u32, u32)> {
|
||||
Some(unsafe { get_canvas_size() })
|
||||
}
|
||||
|
||||
fn get_asset_bytes(&self, asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
|
||||
use sdl2::rwops::RWops;
|
||||
use std::io::Read;
|
||||
|
||||
let path = format!("assets/game/{}", asset.path());
|
||||
let mut rwops = RWops::from_file(&path, "rb").map_err(|_| AssetError::NotFound(asset.path().to_string()))?;
|
||||
|
||||
let len = rwops.len().ok_or_else(|| AssetError::NotFound(asset.path().to_string()))?;
|
||||
|
||||
let mut buf = vec![0u8; len];
|
||||
rwops
|
||||
.read_exact(&mut buf)
|
||||
.map_err(|e| AssetError::Io(std::io::Error::other(e)))?;
|
||||
|
||||
Ok(Cow::Owned(buf))
|
||||
}
|
||||
}
|
||||
|
||||
// Emscripten FFI functions
|
||||
extern "C" {
|
||||
fn emscripten_get_now() -> f64;
|
||||
fn emscripten_sleep(ms: u32);
|
||||
fn emscripten_get_element_css_size(target: *const u8, width: *mut f64, height: *mut f64) -> i32;
|
||||
}
|
||||
|
||||
unsafe fn get_canvas_size() -> (u32, u32) {
|
||||
let mut width = 0.0;
|
||||
let mut height = 0.0;
|
||||
emscripten_get_element_css_size(c"canvas".as_ptr().cast(), &mut width, &mut height);
|
||||
(width as u32, height as u32)
|
||||
}
|
||||
58
src/platform/mod.rs
Normal file
58
src/platform/mod.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
//! Platform abstraction layer for cross-platform functionality.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::asset::{Asset, AssetError};
|
||||
|
||||
pub mod desktop;
|
||||
pub mod emscripten;
|
||||
|
||||
/// Platform abstraction trait that defines cross-platform functionality.
|
||||
pub trait Platform {
|
||||
/// Sleep for the specified duration using platform-appropriate method.
|
||||
fn sleep(&self, duration: Duration);
|
||||
|
||||
/// Get the current time in seconds since some reference point.
|
||||
/// This is available for future use in timing and performance monitoring.
|
||||
#[allow(dead_code)]
|
||||
fn get_time(&self) -> f64;
|
||||
|
||||
/// Initialize platform-specific console functionality.
|
||||
fn init_console(&self) -> Result<(), PlatformError>;
|
||||
|
||||
/// Get canvas size for platforms that need it (e.g., Emscripten).
|
||||
/// This is available for future use in responsive design.
|
||||
#[allow(dead_code)]
|
||||
fn get_canvas_size(&self) -> Option<(u32, u32)>;
|
||||
|
||||
/// Load asset bytes using platform-appropriate method.
|
||||
fn get_asset_bytes(&self, asset: Asset) -> Result<Cow<'static, [u8]>, AssetError>;
|
||||
}
|
||||
|
||||
/// Platform-specific errors.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[allow(dead_code)]
|
||||
pub enum PlatformError {
|
||||
#[error("Console initialization failed: {0}")]
|
||||
ConsoleInit(String),
|
||||
#[error("Platform-specific error: {0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// Get the current platform implementation.
|
||||
#[allow(dead_code)]
|
||||
pub fn get_platform() -> &'static dyn Platform {
|
||||
static DESKTOP: desktop::DesktopPlatform = desktop::DesktopPlatform;
|
||||
static EMSCRIPTEN: emscripten::EmscriptenPlatform = emscripten::EmscriptenPlatform;
|
||||
|
||||
#[cfg(not(target_os = "emscripten"))]
|
||||
{
|
||||
&DESKTOP
|
||||
}
|
||||
|
||||
#[cfg(target_os = "emscripten")]
|
||||
{
|
||||
&EMSCRIPTEN
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,65 +1,80 @@
|
||||
//! 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 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: [Option<AnimatedTexture>; 4],
|
||||
stopped_textures: [Option<AnimatedTexture>; 4],
|
||||
}
|
||||
|
||||
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: [Option<AnimatedTexture>; 4], stopped_textures: [Option<AnimatedTexture>; 4]) -> 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.iter_mut().flatten() {
|
||||
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[direction.as_usize()] {
|
||||
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[direction.as_usize()] {
|
||||
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[direction.as_usize()].is_some()
|
||||
}
|
||||
|
||||
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[direction.as_usize()].is_some()
|
||||
}
|
||||
|
||||
/// Returns the number of textures.
|
||||
#[allow(dead_code)]
|
||||
pub fn texture_count(&self) -> usize {
|
||||
self.textures.iter().filter(|t| t.is_some()).count()
|
||||
}
|
||||
|
||||
/// Returns the number of stopped textures.
|
||||
#[allow(dead_code)]
|
||||
pub fn stopped_texture_count(&self) -> usize {
|
||||
self.stopped_textures.iter().filter(|t| t.is_some()).count()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
61
tests/animated.rs
Normal 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
49
tests/blinking.rs
Normal 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
28
tests/constants.rs
Normal 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("==")));
|
||||
}
|
||||
34
tests/debug_rendering.rs
Normal file
34
tests/debug_rendering.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use glam::Vec2;
|
||||
use pacman::entity::graph::{Graph, Node};
|
||||
use pacman::map::render::MapRenderer;
|
||||
|
||||
#[test]
|
||||
fn test_find_nearest_node() {
|
||||
let mut graph = Graph::new();
|
||||
|
||||
// Add some test nodes
|
||||
let node1 = graph.add_node(Node {
|
||||
position: Vec2::new(10.0, 10.0),
|
||||
});
|
||||
let node2 = graph.add_node(Node {
|
||||
position: Vec2::new(50.0, 50.0),
|
||||
});
|
||||
let node3 = graph.add_node(Node {
|
||||
position: Vec2::new(100.0, 100.0),
|
||||
});
|
||||
|
||||
// Test cursor near node1
|
||||
let cursor_pos = Vec2::new(12.0, 8.0);
|
||||
let nearest = MapRenderer::find_nearest_node(&graph, cursor_pos);
|
||||
assert_eq!(nearest, Some(node1));
|
||||
|
||||
// Test cursor near node2
|
||||
let cursor_pos = Vec2::new(45.0, 55.0);
|
||||
let nearest = MapRenderer::find_nearest_node(&graph, cursor_pos);
|
||||
assert_eq!(nearest, Some(node2));
|
||||
|
||||
// Test cursor near node3
|
||||
let cursor_pos = Vec2::new(98.0, 102.0);
|
||||
let nearest = MapRenderer::find_nearest_node(&graph, cursor_pos);
|
||||
assert_eq!(nearest, Some(node3));
|
||||
}
|
||||
31
tests/direction.rs
Normal file
31
tests/direction.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
54
tests/directional.rs
Normal file
54
tests/directional.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
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;
|
||||
|
||||
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 = [None, None, None, None];
|
||||
textures[Direction::Up.as_usize()] = Some(mock_animated_texture(1));
|
||||
|
||||
let texture = DirectionalAnimatedTexture::new(textures, [None, None, None, None]);
|
||||
|
||||
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 = [None, None, None, None];
|
||||
let directions = [
|
||||
(Direction::Up, 1),
|
||||
(Direction::Down, 2),
|
||||
(Direction::Left, 3),
|
||||
(Direction::Right, 4),
|
||||
];
|
||||
|
||||
for (direction, id) in directions {
|
||||
textures[direction.as_usize()] = Some(mock_animated_texture(id));
|
||||
}
|
||||
|
||||
let texture = DirectionalAnimatedTexture::new(textures, [None, None, None, None]);
|
||||
|
||||
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
21
tests/game.rs
Normal 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());
|
||||
}
|
||||
48
tests/ghost.rs
Normal file
48
tests/ghost.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use pacman::entity::ghost::{Ghost, GhostType};
|
||||
use pacman::entity::graph::Graph;
|
||||
use pacman::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn create_test_atlas() -> SpriteAtlas {
|
||||
let mut frames = HashMap::new();
|
||||
let directions = ["up", "down", "left", "right"];
|
||||
let ghost_types = ["blinky", "pinky", "inky", "clyde"];
|
||||
|
||||
for ghost_type in &ghost_types {
|
||||
for (i, dir) in directions.iter().enumerate() {
|
||||
frames.insert(
|
||||
format!("ghost/{}/{}_{}.png", ghost_type, dir, "a"),
|
||||
MapperFrame {
|
||||
x: i as u16 * 16,
|
||||
y: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
frames.insert(
|
||||
format!("ghost/{}/{}_{}.png", ghost_type, dir, "b"),
|
||||
MapperFrame {
|
||||
x: i as u16 * 16,
|
||||
y: 16,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mapper = AtlasMapper { frames };
|
||||
let dummy_texture = unsafe { std::mem::zeroed() };
|
||||
SpriteAtlas::new(dummy_texture, mapper)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ghost_creation() {
|
||||
let graph = Graph::new();
|
||||
let atlas = create_test_atlas();
|
||||
|
||||
let ghost = Ghost::new(&graph, 0, GhostType::Blinky, &atlas);
|
||||
|
||||
assert_eq!(ghost.ghost_type, GhostType::Blinky);
|
||||
assert_eq!(ghost.traverser.position.from_node_id(), 0);
|
||||
}
|
||||
149
tests/graph.rs
Normal file
149
tests/graph.rs
Normal 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
19
tests/helpers.rs
Normal 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
86
tests/map_builder.rs
Normal 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
107
tests/pacman.rs
Normal 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
46
tests/parser.rs
Normal 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
78
tests/sprite.rs
Normal 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));
|
||||
}
|
||||
537
web.build.ts
Normal file
537
web.build.ts
Normal file
@@ -0,0 +1,537 @@
|
||||
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";
|
||||
import { configure, getConsoleSink, getLogger } from "@logtape/logtape";
|
||||
|
||||
// Constants
|
||||
const TAILWIND_UPDATE_WINDOW_DAYS = 60; // 2 months
|
||||
|
||||
await configure({
|
||||
sinks: { console: getConsoleSink() },
|
||||
loggers: [
|
||||
{ category: "web", lowestLevel: "debug", sinks: ["console"] },
|
||||
{
|
||||
category: ["logtape", "meta"],
|
||||
lowestLevel: "warning",
|
||||
sinks: ["console"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const logger = getLogger("web");
|
||||
|
||||
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()}`);
|
||||
});
|
||||
|
||||
/**
|
||||
* 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> | null) {
|
||||
logger.info(
|
||||
`Building for 'wasm32-unknown-emscripten' for ${
|
||||
release ? "release" : "debug"
|
||||
}`
|
||||
);
|
||||
await $`cargo build --target=wasm32-unknown-emscripten ${
|
||||
release ? "--release" : ""
|
||||
}`.env(env ?? undefined);
|
||||
|
||||
// Download the Tailwind CSS CLI for rendering the CSS
|
||||
const tailwindExecutable = match(
|
||||
await downloadTailwind(process.cwd(), {
|
||||
version: "latest",
|
||||
force: false,
|
||||
})
|
||||
)
|
||||
.with({ path: P.select() }, (path) => path)
|
||||
.with({ err: P.select() }, (err) => {
|
||||
throw new Error(err);
|
||||
})
|
||||
.exhaustive();
|
||||
|
||||
logger.debug(`Invoking ${tailwindExecutable}...`);
|
||||
await $`${tailwindExecutable} --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))) {
|
||||
logger.debug(`Creating folder ${dir}`);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Copy the files to the dist folder
|
||||
logger.debug("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 }, () => {
|
||||
logger.debug(
|
||||
`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));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the Tailwind CSS CLI to the specified directory.
|
||||
* @param dir - The directory to download the Tailwind CSS CLI to.
|
||||
* @returns The path to the downloaded Tailwind CSS CLI, or an error message if the download fails.
|
||||
*/
|
||||
async function downloadTailwind(
|
||||
dir: string,
|
||||
options?: Partial<{
|
||||
version: string; // The version of Tailwind CSS to download. If not specified, the latest version will be downloaded.
|
||||
force: boolean; // Whether to force the download even if the file already exists.
|
||||
}>
|
||||
): Promise<{ path: string } | { err: string }> {
|
||||
const asset = match(os)
|
||||
.with({ type: "linux" }, () => "tailwindcss-linux-x64")
|
||||
.with({ type: "macos" }, () => "tailwindcss-macos-arm64")
|
||||
.with({ type: "windows" }, () => "tailwindcss-windows-x64.exe")
|
||||
.exhaustive();
|
||||
|
||||
const version = options?.version ?? "latest";
|
||||
const force = options?.force ?? false;
|
||||
|
||||
const url =
|
||||
version === "latest" || version == null
|
||||
? `https://github.com/tailwindlabs/tailwindcss/releases/latest/download/${asset}`
|
||||
: `https://github.com/tailwindlabs/tailwindcss/releases/download/${version}/${asset}`;
|
||||
|
||||
// If the GITHUB_TOKEN environment variable is set, use it for Bearer authentication
|
||||
const headers: Record<string, string> = {};
|
||||
if (process.env.GITHUB_TOKEN) {
|
||||
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
||||
}
|
||||
|
||||
// Check if the file already exists
|
||||
const path = join(dir, asset);
|
||||
const exists = await fs.exists(path);
|
||||
|
||||
// Check if we should download based on timestamps
|
||||
let shouldDownload = force || !exists;
|
||||
|
||||
if (exists && !force) {
|
||||
try {
|
||||
const fileStats = await fs.stat(path);
|
||||
const fileModifiedTime = fileStats.mtime;
|
||||
const now = new Date();
|
||||
|
||||
// Check if file is older than the update window
|
||||
const updateWindowAgo = new Date(
|
||||
now.getTime() - TAILWIND_UPDATE_WINDOW_DAYS * 24 * 60 * 60 * 1000
|
||||
);
|
||||
|
||||
if (fileModifiedTime < updateWindowAgo) {
|
||||
logger.debug(
|
||||
`File is older than ${TAILWIND_UPDATE_WINDOW_DAYS} days, checking for updates...`
|
||||
);
|
||||
shouldDownload = true;
|
||||
} else {
|
||||
logger.debug(
|
||||
`File is recent (${fileModifiedTime.toISOString()}), checking if newer version available...`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`Error checking file timestamp: ${error}, will download anyway`
|
||||
);
|
||||
shouldDownload = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If we need to download, check the server's last-modified header
|
||||
if (shouldDownload) {
|
||||
const response = await fetch(url, {
|
||||
headers,
|
||||
method: "HEAD",
|
||||
redirect: "follow",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const lastModified = response.headers.get("last-modified");
|
||||
if (lastModified) {
|
||||
const serverTime = new Date(lastModified);
|
||||
const now = new Date();
|
||||
|
||||
// If server timestamp is in the future, something is wrong - download anyway
|
||||
if (serverTime > now) {
|
||||
logger.debug(
|
||||
`Server timestamp is in the future (${serverTime.toISOString()}), downloading anyway`
|
||||
);
|
||||
shouldDownload = true;
|
||||
} else if (exists) {
|
||||
// Compare with local file timestamp (both in UTC)
|
||||
const fileStats = await fs.stat(path);
|
||||
const fileModifiedTime = new Date(fileStats.mtime.getTime());
|
||||
|
||||
if (serverTime > fileModifiedTime) {
|
||||
logger.debug(
|
||||
`Server has newer version (${serverTime.toISOString()} vs local ${fileModifiedTime.toISOString()})`
|
||||
);
|
||||
shouldDownload = true;
|
||||
} else {
|
||||
logger.debug(
|
||||
`Local file is up to date (${fileModifiedTime.toISOString()})`
|
||||
);
|
||||
shouldDownload = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.debug(
|
||||
`No last-modified header available, downloading to be safe`
|
||||
);
|
||||
shouldDownload = true;
|
||||
}
|
||||
} else {
|
||||
logger.debug(
|
||||
`Failed to check server headers: ${response.status} ${response.statusText}`
|
||||
);
|
||||
shouldDownload = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (exists && !shouldDownload) {
|
||||
const displayPath = match(relative(process.cwd(), path))
|
||||
// If the path is not a subpath of cwd, display the absolute path
|
||||
.with(P.string.startsWith(".."), (_relative) => path)
|
||||
// Otherwise, display the relative path
|
||||
.otherwise((relative) => relative);
|
||||
|
||||
logger.debug(
|
||||
`Tailwind CSS CLI already exists and is up to date at ${displayPath}`
|
||||
);
|
||||
return { path };
|
||||
}
|
||||
|
||||
if (exists) {
|
||||
const displayPath = match(relative(process.cwd(), path))
|
||||
// If the path is not a subpath of cwd, display the absolute path
|
||||
.with(P.string.startsWith(".."), (_relative) => path)
|
||||
// Otherwise, display the relative path
|
||||
.otherwise((relative) => relative);
|
||||
|
||||
if (force) {
|
||||
logger.debug(`Overwriting Tailwind CSS CLI at ${displayPath}`);
|
||||
} else {
|
||||
logger.debug(`Downloading updated Tailwind CSS CLI to ${displayPath}`);
|
||||
}
|
||||
} else {
|
||||
logger.debug(`Downloading Tailwind CSS CLI to ${path}`);
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug(`Fetching ${url}...`);
|
||||
const response = await fetch(url, { headers });
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
err: `Failed to download Tailwind CSS: ${response.status} ${response.statusText} for '${url}'`,
|
||||
};
|
||||
} else if (!response.body) {
|
||||
return { err: `No response body received for '${url}'` };
|
||||
}
|
||||
|
||||
// Validate Content-Length if available
|
||||
const contentLength = response.headers.get("content-length");
|
||||
if (contentLength) {
|
||||
const expectedSize = parseInt(contentLength, 10);
|
||||
if (isNaN(expectedSize)) {
|
||||
return { err: `Invalid Content-Length header: ${contentLength}` };
|
||||
}
|
||||
logger.debug(`Expected file size: ${expectedSize} bytes`);
|
||||
}
|
||||
|
||||
logger.debug(`Writing to ${path}...`);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
const file = Bun.file(path);
|
||||
const writer = file.writer();
|
||||
|
||||
const reader = response.body.getReader();
|
||||
let downloadedBytes = 0;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
writer.write(value);
|
||||
downloadedBytes += value.length;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
await writer.end();
|
||||
}
|
||||
|
||||
// Validate downloaded file size
|
||||
if (contentLength) {
|
||||
const expectedSize = parseInt(contentLength, 10);
|
||||
const actualSize = downloadedBytes;
|
||||
|
||||
if (actualSize !== expectedSize) {
|
||||
// Clean up the corrupted file
|
||||
try {
|
||||
await fs.unlink(path);
|
||||
} catch (unlinkError) {
|
||||
logger.debug(
|
||||
`Warning: Failed to clean up corrupted file: ${unlinkError}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
err: `File size mismatch: expected ${expectedSize} bytes, got ${actualSize} bytes. File may be corrupted.`,
|
||||
};
|
||||
}
|
||||
|
||||
logger.debug(`File size validation passed: ${actualSize} bytes`);
|
||||
}
|
||||
|
||||
// Make the file executable on Unix-like systems
|
||||
if (os.type !== "windows") {
|
||||
await $`chmod +x ${path}`;
|
||||
}
|
||||
|
||||
// Ensure file is not locked; sometimes the runtime is too fast and the file is executed before the lock is released
|
||||
const timeout = Date.now() + 2500; // 2.5s timeout
|
||||
do {
|
||||
try {
|
||||
if ((await fs.stat(path)).size > 0) break;
|
||||
} catch {
|
||||
// File might not be ready yet
|
||||
logger.debug(`File ${path} is not ready yet, waiting...`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
} while (Date.now() < timeout);
|
||||
|
||||
// All done!
|
||||
return { path };
|
||||
} catch (error) {
|
||||
return {
|
||||
err: `Download failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 actually 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> | null } | { err: string }> {
|
||||
// If the EMSDK environment variable is set already & the path specified exists, return nothing
|
||||
if (process.env.EMSDK && (await fs.exists(resolve(process.env.EMSDK)))) {
|
||||
logger.debug(
|
||||
"Emscripten SDK already activated in environment, using existing configuration"
|
||||
);
|
||||
return { vars: null };
|
||||
}
|
||||
|
||||
// Check if the emsdk directory exists
|
||||
if (!(await fs.exists(emsdkDir))) {
|
||||
return {
|
||||
err: `Emscripten SDK directory not found at ${emsdkDir}. Please install or clone 'emsdk' and try again.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if the emsdk directory 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,
|
||||
},
|
||||
() => {
|
||||
return {
|
||||
err: "Emscripten SDK does not appear to be activated/installed properly.",
|
||||
};
|
||||
}
|
||||
)
|
||||
// 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") },
|
||||
},
|
||||
() => {
|
||||
return {
|
||||
err: "Emscripten SDK appears to be activated for Windows, but is currently running on a *nix OS.",
|
||||
};
|
||||
}
|
||||
)
|
||||
// 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" },
|
||||
},
|
||||
() => {
|
||||
return {
|
||||
err: "Emscripten SDK appears to be activated for *nix, but is currently running on a Windows OS.",
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// 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
|
||||
logger.debug(
|
||||
"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");
|
||||
|
||||
// Activate the Emscripten SDK (returns null if already activated)
|
||||
const vars = match(await activateEmsdk(emsdkDir))
|
||||
.with({ vars: P.select() }, (vars) => vars)
|
||||
.with({ err: P.any }, ({ err }) => {
|
||||
logger.debug("Error activating Emscripten SDK: " + err);
|
||||
process.exit(1);
|
||||
})
|
||||
.exhaustive();
|
||||
|
||||
// Build the application
|
||||
await build(release, vars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point.
|
||||
*/
|
||||
main().catch((err) => {
|
||||
console.error({ msg: "fatal error", error: err });
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user