mirror of
https://github.com/Xevion/Pac-Man.git
synced 2025-12-06 15:15:48 -06:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 250cf2fc89 | |||
| 57975495a9 | |||
| f3e7a780e2 | |||
| ee6cb0a670 | |||
| b3df34b405 | |||
| dbafa17670 | |||
| d9c8f97903 | |||
| ad2ec35bfb | |||
| 6331ba0b2f | |||
| 3d275b8e85 | |||
| bd61db9aae | |||
| ed8bd07518 | |||
| 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 |
27
.github/workflows/audit.yaml
vendored
27
.github/workflows/audit.yaml
vendored
@@ -1,27 +0,0 @@
|
|||||||
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
|
|
||||||
52
.github/workflows/build.yaml
vendored
52
.github/workflows/build.yaml
vendored
@@ -1,5 +1,4 @@
|
|||||||
name: Build
|
name: Builds
|
||||||
|
|
||||||
on: ["push", "pull_request"]
|
on: ["push", "pull_request"]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -104,27 +103,48 @@ jobs:
|
|||||||
- name: Rust Cache
|
- name: Rust Cache
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Setup Bun
|
||||||
uses: pnpm/action-setup@v3
|
uses: oven-sh/setup-bun@v2
|
||||||
with:
|
with:
|
||||||
version: 8
|
bun-version: latest
|
||||||
run_install: true
|
|
||||||
|
|
||||||
- name: Build with Emscripten
|
- name: Build with Emscripten
|
||||||
|
shell: bash
|
||||||
run: |
|
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
|
for attempt in $(seq 1 $MAX_RETRIES); do
|
||||||
run: |
|
echo "Build attempt $attempt of $MAX_RETRIES"
|
||||||
echo "Generating CSS"
|
|
||||||
pnpx postcss-cli ./assets/site/styles.scss -o ./assets/site/build.css
|
|
||||||
|
|
||||||
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
|
# Check if the failure was due to the specific hash error
|
||||||
cp assets/site/{build.css,favicon.ico,index.html} dist
|
if grep -q "emcc: error: Unexpected hash:" /tmp/build_output.log; then
|
||||||
output_folder="target/wasm32-unknown-emscripten/release"
|
echo "::warning::Detected 'emcc: error: Unexpected hash:' error - will retry (attempt $attempt of $MAX_RETRIES)"
|
||||||
cp $output_folder/pacman.{wasm,js} $output_folder/deps/pacman.data dist
|
|
||||||
|
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
|
- name: Upload Artifact
|
||||||
uses: actions/upload-pages-artifact@v3
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
|||||||
15
.github/workflows/coverage.yaml
vendored
15
.github/workflows/coverage.yaml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: Coverage
|
name: Code Coverage
|
||||||
|
|
||||||
on: ["push", "pull_request"]
|
on: ["push", "pull_request"]
|
||||||
|
|
||||||
@@ -8,7 +8,6 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
coverage:
|
coverage:
|
||||||
name: Code Coverage
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -18,6 +17,7 @@ jobs:
|
|||||||
uses: dtolnay/rust-toolchain@master
|
uses: dtolnay/rust-toolchain@master
|
||||||
with:
|
with:
|
||||||
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||||
|
components: llvm-tools-preview
|
||||||
|
|
||||||
- name: Rust Cache
|
- name: Rust Cache
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: Swatinem/rust-cache@v2
|
||||||
@@ -40,20 +40,17 @@ jobs:
|
|||||||
cargo install cargo-vcpkg
|
cargo install cargo-vcpkg
|
||||||
cargo vcpkg -v build
|
cargo vcpkg -v build
|
||||||
|
|
||||||
- name: Install cargo-tarpaulin
|
- uses: taiki-e/install-action@cargo-llvm-cov
|
||||||
run: cargo install cargo-tarpaulin
|
- 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.
|
# Note: We manually link zlib. This should be synchronized with the flags set for Linux in .cargo/config.toml.
|
||||||
- name: Generate coverage report
|
- name: Generate coverage report
|
||||||
run: |
|
run: |
|
||||||
cargo tarpaulin \
|
cargo llvm-cov --no-fail-fast --lcov --output-path lcov.info nextest
|
||||||
--out Lcov \
|
|
||||||
--output-dir coverage \
|
|
||||||
--rustflags="-C link-arg=-lz"
|
|
||||||
|
|
||||||
- name: Upload coverage to Coveralls
|
- name: Upload coverage to Coveralls
|
||||||
uses: coverallsapp/github-action@v2
|
uses: coverallsapp/github-action@v2
|
||||||
with:
|
with:
|
||||||
files: ./coverage/lcov.info
|
files: ./lcov.info
|
||||||
format: lcov
|
format: lcov
|
||||||
allow-empty: false
|
allow-empty: false
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Test
|
name: Tests & Checks
|
||||||
|
|
||||||
on: ["push", "pull_request"]
|
on: ["push", "pull_request"]
|
||||||
|
|
||||||
@@ -8,7 +8,6 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Test
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -42,11 +41,18 @@ jobs:
|
|||||||
cargo install cargo-vcpkg
|
cargo install cargo-vcpkg
|
||||||
cargo vcpkg -v build
|
cargo vcpkg -v build
|
||||||
|
|
||||||
- name: Run tests
|
- uses: taiki-e/install-action@nextest
|
||||||
run: cargo test --workspace --verbose
|
|
||||||
|
- name: Run nextest
|
||||||
|
run: cargo nextest run --workspace
|
||||||
|
|
||||||
- name: Run clippy
|
- name: Run clippy
|
||||||
run: cargo clippy -- -D warnings
|
run: cargo clippy -- -D warnings
|
||||||
|
|
||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
run: cargo fmt -- --check
|
run: cargo fmt -- --check
|
||||||
|
|
||||||
|
- uses: taiki-e/install-action@cargo-audit
|
||||||
|
|
||||||
|
- name: Run security audit
|
||||||
|
run: cargo audit
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,7 +1,7 @@
|
|||||||
/target
|
target/
|
||||||
/dist
|
dist/
|
||||||
|
emsdk/
|
||||||
.idea
|
.idea
|
||||||
*.dll
|
|
||||||
rust-sdl2-emscripten/
|
rust-sdl2-emscripten/
|
||||||
assets/site/build.css
|
assets/site/build.css
|
||||||
emsdk/
|
tailwindcss-*
|
||||||
|
|||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -192,6 +192,7 @@ dependencies = [
|
|||||||
"sdl2",
|
"sdl2",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"smallvec",
|
||||||
"spin_sleep",
|
"spin_sleep",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ anyhow = "1.0"
|
|||||||
glam = { version = "0.30.4", features = [] }
|
glam = { version = "0.30.4", features = [] }
|
||||||
serde = { version = "1.0.219", features = ["derive"] }
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
serde_json = "1.0.141"
|
serde_json = "1.0.141"
|
||||||
|
smallvec = "1.15.1"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = true
|
lto = true
|
||||||
|
|||||||
134
README.md
134
README.md
@@ -1,79 +1,87 @@
|
|||||||
# Pac-Man
|
# 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
|
[badge-test]: https://github.com/Xevion/Pac-Man/actions/workflows/tests.yaml/badge.svg
|
||||||
at.
|
[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/tests.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.
|
The game includes all the original features you'd expect from Pac-Man:
|
||||||
- Written in Rust, buildable on Windows, Linux, Mac and WebAssembly.
|
|
||||||
|
- [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.
|
- Online demo, playable in a browser.
|
||||||
- Automatic build system, with releases for Windows, Linux, and Mac & Web-Assembly.
|
- Completely automatic build system with releases for all platforms.
|
||||||
- Debug tooling
|
- Well documented, well-tested, and maintainable.
|
||||||
- Game state visualization
|
|
||||||
- Game speed controls + pausing
|
|
||||||
- Log tracing
|
|
||||||
- Performance details
|
|
||||||
|
|
||||||
## Experimental Ideas
|
## Experimental Ideas
|
||||||
|
|
||||||
|
- Debug tooling
|
||||||
|
- Game state visualization
|
||||||
|
- Game speed controls + pausing
|
||||||
|
- Log tracing
|
||||||
|
- Performance details
|
||||||
|
- Customized Themes & Colors
|
||||||
|
- Color-blind friendly
|
||||||
- Perfected Ghost Algorithms
|
- Perfected Ghost Algorithms
|
||||||
- More than 4 ghosts
|
- More than 4 ghosts
|
||||||
- Custom Level Generation
|
- Custom Level Generation
|
||||||
- Multi-map tunnelling
|
- Multi-map tunnelling
|
||||||
- Online Scoreboard
|
- Online Scoreboard
|
||||||
- WebAssembly build contains a special API key for communicating with server.
|
- An online axum server with a simple database and OAuth2 authentication.
|
||||||
- To prevent abuse, the server will only accept scores from the WebAssembly build.
|
- 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
|
- 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`
|
||||||
On Ubuntu, you can install the required packages with the following command:
|
- 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.
|
||||||
sudo apt install libsdl2-dev libsdl2-image-dev libsdl2-mixer-dev libsdl2-ttf-dev
|
- 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/)
|
||||||
### Windows
|
- 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`
|
||||||
On Windows, installation requires either building from source (not covered), or downloading the pre-built binaries.
|
- `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.
|
||||||
The latest releases can be found here:
|
- If you want to have TypeScript resolution for development, you can manually install the dependencies with `bun install` in the `assets/site` folder.
|
||||||
|
|
||||||
- [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
|
|
||||||
```
|
|
||||||
|
|||||||
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>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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" />
|
<link rel="stylesheet" href="build.css" />
|
||||||
<style>
|
<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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-black text-yellow-400 text-center">
|
<body class="bg-black text-yellow-400 text-center min-h-screen">
|
||||||
<a
|
<a
|
||||||
href="https://github.com/Xevion/Pac-Man"
|
href="https://github.com/Xevion/Pac-Man"
|
||||||
class="absolute top-0 right-0"
|
class="absolute top-0 right-0"
|
||||||
@@ -17,7 +30,7 @@
|
|||||||
width="80"
|
width="80"
|
||||||
height="80"
|
height="80"
|
||||||
viewBox="0 0 250 250"
|
viewBox="0 0 250 250"
|
||||||
class="fill-yellow-400 text-black"
|
class="fill-yellow-400 [&>.octo-arm,.octo-body]:fill-black"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path>
|
<path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path>
|
||||||
@@ -31,33 +44,51 @@
|
|||||||
></path>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<h1 class="text-4xl mt-10 scaled-text">Pac-Man Arcade</h1>
|
|
||||||
<p class="text-lg mt-5 scaled-text">
|
<div class="min-h-screen flex flex-col">
|
||||||
Welcome to the Pac-Man Arcade! Use the controls below to play.
|
<main class="flex-1 flex items-center justify-center px-4">
|
||||||
</p>
|
<div class="w-full max-w-5xl">
|
||||||
<canvas
|
<canvas
|
||||||
id="canvas"
|
id="canvas"
|
||||||
class="block mx-auto mt-5"
|
oncontextmenu="event.preventDefault()"
|
||||||
width="800"
|
class="block w-full h-full max-h-[90vh] aspect-square"
|
||||||
height="600"
|
></canvas>
|
||||||
></canvas>
|
|
||||||
<div class="mt-10">
|
<div
|
||||||
<span
|
class="mt-8 flex flex-wrap gap-3 justify-center items-center text-sm"
|
||||||
class="inline-block mx-2 px-4 py-2 bg-yellow-400 text-black rounded scaled-text"
|
>
|
||||||
>← ↑ → ↓ Move</span
|
<span class="code">← ↑ → ↓</span>
|
||||||
>
|
<span class="opacity-70">Move</span>
|
||||||
<span
|
|
||||||
class="inline-block mx-2 px-4 py-2 bg-yellow-400 text-black rounded scaled-text"
|
<span class="mx-2 opacity-30">|</span>
|
||||||
>Space Change Sprite</span
|
|
||||||
>
|
<span class="code">Space</span>
|
||||||
<span
|
<span class="opacity-70">Toggle Debug</span>
|
||||||
class="inline-block mx-2 px-4 py-2 bg-yellow-400 text-black rounded scaled-text"
|
|
||||||
>Shift + ↑↓ Change Volume</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>
|
</div>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
|
const canvas = document.getElementById("canvas");
|
||||||
var Module = {
|
var Module = {
|
||||||
canvas: document.getElementById("canvas"),
|
canvas: canvas,
|
||||||
|
preRun: [
|
||||||
|
() => {
|
||||||
|
[...canvas.classList]
|
||||||
|
.filter((className) => className.includes("shadow-"))
|
||||||
|
.forEach((className) => canvas.classList.remove(className));
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<script type="text/javascript" src="pacman.js"></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;
|
|
||||||
}
|
|
||||||
61
bacon.toml
Normal file
61
bacon.toml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# This is a configuration file for the bacon tool
|
||||||
|
#
|
||||||
|
# Complete help on configuration: https://dystroy.org/bacon/config/
|
||||||
|
#
|
||||||
|
# You may check the current default at
|
||||||
|
# https://github.com/Canop/bacon/blob/main/defaults/default-bacon.toml
|
||||||
|
|
||||||
|
default_job = "check"
|
||||||
|
env.CARGO_TERM_COLOR = "always"
|
||||||
|
|
||||||
|
[jobs.check]
|
||||||
|
command = ["cargo", "check"]
|
||||||
|
need_stdout = false
|
||||||
|
|
||||||
|
[jobs.check-all]
|
||||||
|
command = ["cargo", "check", "--all-targets"]
|
||||||
|
need_stdout = false
|
||||||
|
|
||||||
|
# Run clippy on the default target
|
||||||
|
[jobs.clippy]
|
||||||
|
command = ["cargo", "clippy"]
|
||||||
|
need_stdout = false
|
||||||
|
|
||||||
|
# Run clippy on all targets
|
||||||
|
[jobs.clippy-all]
|
||||||
|
command = ["cargo", "clippy", "--all-targets"]
|
||||||
|
need_stdout = false
|
||||||
|
|
||||||
|
[jobs.test]
|
||||||
|
command = [
|
||||||
|
"cargo", "nextest", "run",
|
||||||
|
"--hide-progress-bar", "--failure-output", "final"
|
||||||
|
]
|
||||||
|
need_stdout = true
|
||||||
|
analyzer = "nextest"
|
||||||
|
|
||||||
|
[jobs.doc]
|
||||||
|
command = ["cargo", "doc", "--no-deps"]
|
||||||
|
need_stdout = false
|
||||||
|
|
||||||
|
# If the doc compiles, then it opens in your browser and bacon switches to the previous job
|
||||||
|
[jobs.doc-open]
|
||||||
|
command = ["cargo", "doc", "--no-deps", "--open"]
|
||||||
|
need_stdout = false
|
||||||
|
on_success = "back" # so that we don't open the browser at each change
|
||||||
|
|
||||||
|
[jobs.run]
|
||||||
|
command = [
|
||||||
|
"cargo", "run",
|
||||||
|
]
|
||||||
|
need_stdout = true
|
||||||
|
allow_warnings = true
|
||||||
|
background = false
|
||||||
|
on_change_strategy = "kill_then_restart"
|
||||||
|
# kill = ["pkill", "-TERM", "-P"]'
|
||||||
|
|
||||||
|
[keybindings]
|
||||||
|
c = "job:clippy"
|
||||||
|
alt-c = "job:check"
|
||||||
|
ctrl-alt-c = "job:check-all"
|
||||||
|
shift-c = "job:clippy-all"
|
||||||
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);
|
|
||||||
});
|
|
||||||
37
src/app.rs
37
src/app.rs
@@ -1,6 +1,7 @@
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
|
use glam::Vec2;
|
||||||
use sdl2::event::{Event, WindowEvent};
|
use sdl2::event::{Event, WindowEvent};
|
||||||
use sdl2::keyboard::Keycode;
|
use sdl2::keyboard::Keycode;
|
||||||
use sdl2::render::{Canvas, ScaleMode, Texture, TextureCreator};
|
use sdl2::render::{Canvas, ScaleMode, Texture, TextureCreator};
|
||||||
@@ -10,19 +11,7 @@ use tracing::{error, event};
|
|||||||
|
|
||||||
use crate::constants::{CANVAS_SIZE, LOOP_TIME, SCALE};
|
use crate::constants::{CANVAS_SIZE, LOOP_TIME, SCALE};
|
||||||
use crate::game::Game;
|
use crate::game::Game;
|
||||||
|
use crate::platform::get_platform;
|
||||||
#[cfg(target_os = "emscripten")]
|
|
||||||
use crate::emscripten;
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "emscripten"))]
|
|
||||||
fn sleep(value: Duration) {
|
|
||||||
spin_sleep::sleep(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "emscripten")]
|
|
||||||
fn sleep(value: Duration) {
|
|
||||||
emscripten::emscripten::sleep(value.as_millis() as u32);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct App<'a> {
|
pub struct App<'a> {
|
||||||
game: Game,
|
game: Game,
|
||||||
@@ -31,10 +20,14 @@ pub struct App<'a> {
|
|||||||
backbuffer: Texture<'a>,
|
backbuffer: Texture<'a>,
|
||||||
paused: bool,
|
paused: bool,
|
||||||
last_tick: Instant,
|
last_tick: Instant,
|
||||||
|
cursor_pos: Vec2,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> App<'a> {
|
impl App<'_> {
|
||||||
pub fn new() -> Result<Self> {
|
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 sdl_context = sdl2::init().map_err(|e| anyhow!(e))?;
|
||||||
let video_subsystem = sdl_context.video().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 audio_subsystem = sdl_context.audio().map_err(|e| anyhow!(e))?;
|
||||||
@@ -65,7 +58,7 @@ impl<'a> App<'a> {
|
|||||||
|
|
||||||
// Initial draw
|
// Initial draw
|
||||||
game.draw(&mut canvas, &mut backbuffer)?;
|
game.draw(&mut canvas, &mut backbuffer)?;
|
||||||
game.present_backbuffer(&mut canvas, &backbuffer)?;
|
game.present_backbuffer(&mut canvas, &backbuffer, glam::Vec2::ZERO)?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
game,
|
game,
|
||||||
@@ -74,6 +67,7 @@ impl<'a> App<'a> {
|
|||||||
backbuffer,
|
backbuffer,
|
||||||
paused: false,
|
paused: false,
|
||||||
last_tick: Instant::now(),
|
last_tick: Instant::now(),
|
||||||
|
cursor_pos: Vec2::ZERO,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +86,8 @@ impl<'a> App<'a> {
|
|||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
|
// It doesn't really make sense to have this available in the browser
|
||||||
|
#[cfg(not(target_os = "emscripten"))]
|
||||||
Event::Quit { .. }
|
Event::Quit { .. }
|
||||||
| Event::KeyDown {
|
| Event::KeyDown {
|
||||||
keycode: Some(Keycode::Escape) | Some(Keycode::Q),
|
keycode: Some(Keycode::Escape) | Some(Keycode::Q),
|
||||||
@@ -116,6 +112,10 @@ impl<'a> App<'a> {
|
|||||||
Event::KeyDown { keycode, .. } => {
|
Event::KeyDown { keycode, .. } => {
|
||||||
self.game.keyboard_event(keycode.unwrap());
|
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);
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,7 +128,10 @@ impl<'a> App<'a> {
|
|||||||
if let Err(e) = self.game.draw(&mut self.canvas, &mut self.backbuffer) {
|
if let Err(e) = self.game.draw(&mut self.canvas, &mut self.backbuffer) {
|
||||||
error!("Failed to draw game: {e}");
|
error!("Failed to draw game: {e}");
|
||||||
}
|
}
|
||||||
if let Err(e) = self.game.present_backbuffer(&mut self.canvas, &self.backbuffer) {
|
if let Err(e) = self
|
||||||
|
.game
|
||||||
|
.present_backbuffer(&mut self.canvas, &self.backbuffer, self.cursor_pos)
|
||||||
|
{
|
||||||
error!("Failed to present backbuffer: {e}");
|
error!("Failed to present backbuffer: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,7 +139,7 @@ impl<'a> App<'a> {
|
|||||||
if start.elapsed() < LOOP_TIME {
|
if start.elapsed() < LOOP_TIME {
|
||||||
let time = LOOP_TIME.saturating_sub(start.elapsed());
|
let time = LOOP_TIME.saturating_sub(start.elapsed());
|
||||||
if time != Duration::ZERO {
|
if time != Duration::ZERO {
|
||||||
sleep(time);
|
get_platform().sleep(time);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
event!(
|
event!(
|
||||||
|
|||||||
32
src/asset.rs
32
src/asset.rs
@@ -42,40 +42,12 @@ impl Asset {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "emscripten"))]
|
|
||||||
mod imp {
|
mod imp {
|
||||||
use super::*;
|
use super::*;
|
||||||
macro_rules! asset_bytes_enum {
|
use crate::platform::get_platform;
|
||||||
( $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::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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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> {
|
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
|
||||||
let path = format!("assets/game/{}", asset.path());
|
get_platform().get_asset_bytes(asset)
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
216
src/audio.rs
216
src/audio.rs
@@ -10,13 +10,15 @@ const SOUND_ASSETS: [Asset; 4] = [Asset::Wav1, Asset::Wav2, Asset::Wav3, Asset::
|
|||||||
/// The audio system for the game.
|
/// The audio system for the game.
|
||||||
///
|
///
|
||||||
/// This struct is responsible for initializing the audio device, loading sounds,
|
/// 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)]
|
#[allow(dead_code)]
|
||||||
pub struct Audio {
|
pub struct Audio {
|
||||||
_mixer_context: mixer::Sdl2MixerContext,
|
_mixer_context: Option<mixer::Sdl2MixerContext>,
|
||||||
sounds: Vec<Chunk>,
|
sounds: Vec<Chunk>,
|
||||||
next_sound_index: usize,
|
next_sound_index: usize,
|
||||||
muted: bool,
|
muted: bool,
|
||||||
|
disabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Audio {
|
impl Default for Audio {
|
||||||
@@ -27,13 +29,27 @@ impl Default for Audio {
|
|||||||
|
|
||||||
impl Audio {
|
impl Audio {
|
||||||
/// Creates a new `Audio` instance.
|
/// 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 {
|
pub fn new() -> Self {
|
||||||
let frequency = 44100;
|
let frequency = 44100;
|
||||||
let format = DEFAULT_FORMAT;
|
let format = DEFAULT_FORMAT;
|
||||||
let channels = 4;
|
let channels = 4;
|
||||||
let chunk_size = 256; // 256 is minimum for emscripten
|
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);
|
mixer::allocate_channels(channels);
|
||||||
|
|
||||||
// set channel volume
|
// set channel volume
|
||||||
@@ -41,31 +57,72 @@ impl Audio {
|
|||||||
mixer::Channel(i).set_volume(32);
|
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
|
// Try to load sounds, but don't panic if any fail
|
||||||
.iter()
|
let mut sounds = Vec::new();
|
||||||
.enumerate()
|
for (i, asset) in SOUND_ASSETS.iter().enumerate() {
|
||||||
.map(|(i, asset)| {
|
match get_asset_bytes(*asset) {
|
||||||
let data = get_asset_bytes(*asset).expect("Failed to load sound asset");
|
Ok(data) => match RWops::from_bytes(&data) {
|
||||||
let rwops = RWops::from_bytes(&data).unwrap_or_else(|_| panic!("Failed to create RWops for sound {}", i + 1));
|
Ok(rwops) => match rwops.load_wav() {
|
||||||
rwops
|
Ok(chunk) => sounds.push(chunk),
|
||||||
.load_wav()
|
Err(e) => {
|
||||||
.unwrap_or_else(|_| panic!("Failed to load sound {} from asset API", i + 1))
|
tracing::warn!("Failed to load sound {} from asset API: {}", i + 1, e);
|
||||||
})
|
}
|
||||||
.collect();
|
},
|
||||||
|
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 {
|
Audio {
|
||||||
_mixer_context: mixer_context,
|
_mixer_context: Some(mixer_context),
|
||||||
sounds,
|
sounds,
|
||||||
next_sound_index: 0,
|
next_sound_index: 0,
|
||||||
muted: false,
|
muted: false,
|
||||||
|
disabled: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Plays the "eat" sound effect.
|
/// Plays the "eat" sound effect.
|
||||||
|
///
|
||||||
|
/// If audio is disabled or muted, this function does nothing.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn eat(&mut self) {
|
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) {
|
if let Some(chunk) = self.sounds.get(self.next_sound_index) {
|
||||||
match mixer::Channel(0).play(chunk, 0) {
|
match mixer::Channel(0).play(chunk, 0) {
|
||||||
Ok(channel) => {
|
Ok(channel) => {
|
||||||
@@ -80,12 +137,17 @@ impl Audio {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Instantly mute or unmute all channels.
|
/// Instantly mute or unmute all channels.
|
||||||
|
///
|
||||||
|
/// If audio is disabled, this function does nothing.
|
||||||
pub fn set_mute(&mut self, mute: bool) {
|
pub fn set_mute(&mut self, mute: bool) {
|
||||||
let channels = 4;
|
if !self.disabled {
|
||||||
let volume = if mute { 0 } else { 32 };
|
let channels = 4;
|
||||||
for i in 0..channels {
|
let volume = if mute { 0 } else { 32 };
|
||||||
mixer::Channel(i).set_volume(volume);
|
for i in 0..channels {
|
||||||
|
mixer::Channel(i).set_volume(volume);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.muted = mute;
|
self.muted = mute;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,116 +155,10 @@ impl Audio {
|
|||||||
pub fn is_muted(&self) -> bool {
|
pub fn is_muted(&self) -> bool {
|
||||||
self.muted
|
self.muted
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
/// Returns `true` if the audio system is disabled.
|
||||||
mod tests {
|
#[allow(dead_code)]
|
||||||
use super::*;
|
pub fn is_disabled(&self) -> bool {
|
||||||
use std::sync::Once;
|
self.disabled
|
||||||
|
|
||||||
static INIT: Once = Once::new();
|
|
||||||
|
|
||||||
fn init_sdl() -> Result<(), String> {
|
|
||||||
INIT.call_once(|| {
|
|
||||||
if let Err(e) = sdl2::init() {
|
|
||||||
eprintln!("Failed to initialize SDL2: {}", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_sound_assets_array() {
|
|
||||||
assert_eq!(SOUND_ASSETS.len(), 4);
|
|
||||||
assert_eq!(SOUND_ASSETS[0], Asset::Wav1);
|
|
||||||
assert_eq!(SOUND_ASSETS[1], Asset::Wav2);
|
|
||||||
assert_eq!(SOUND_ASSETS[2], Asset::Wav3);
|
|
||||||
assert_eq!(SOUND_ASSETS[3], Asset::Wav4);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_audio_asset_paths() {
|
|
||||||
// Test that all sound assets have valid paths
|
|
||||||
for asset in SOUND_ASSETS.iter() {
|
|
||||||
let path = asset.path();
|
|
||||||
assert!(!path.is_empty());
|
|
||||||
assert!(path.contains("sound/waka/"));
|
|
||||||
assert!(path.ends_with(".ogg"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only run SDL2-dependent tests if SDL2 initialization succeeds
|
|
||||||
#[test]
|
|
||||||
fn test_audio_basic_functionality() {
|
|
||||||
if let Err(_) = init_sdl() {
|
|
||||||
eprintln!("Skipping SDL2-dependent tests due to initialization failure");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test basic audio creation
|
|
||||||
let audio = Audio::new();
|
|
||||||
assert_eq!(audio.is_muted(), false);
|
|
||||||
assert_eq!(audio.next_sound_index, 0);
|
|
||||||
assert_eq!(audio.sounds.len(), 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_audio_mute_functionality() {
|
|
||||||
if let Err(_) = init_sdl() {
|
|
||||||
eprintln!("Skipping SDL2-dependent tests due to initialization failure");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut audio = Audio::new();
|
|
||||||
|
|
||||||
// Test mute/unmute
|
|
||||||
assert_eq!(audio.is_muted(), false);
|
|
||||||
audio.set_mute(true);
|
|
||||||
assert_eq!(audio.is_muted(), true);
|
|
||||||
audio.set_mute(false);
|
|
||||||
assert_eq!(audio.is_muted(), false);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_audio_sound_rotation() {
|
|
||||||
if let Err(_) = init_sdl() {
|
|
||||||
eprintln!("Skipping SDL2-dependent tests due to initialization failure");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut audio = Audio::new();
|
|
||||||
let initial_index = audio.next_sound_index;
|
|
||||||
|
|
||||||
// Test sound rotation
|
|
||||||
for i in 0..4 {
|
|
||||||
audio.eat();
|
|
||||||
assert_eq!(audio.next_sound_index, (initial_index + i + 1) % 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_eq!(audio.next_sound_index, initial_index);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_audio_sound_index_bounds() {
|
|
||||||
if let Err(_) = init_sdl() {
|
|
||||||
eprintln!("Skipping SDL2-dependent tests due to initialization failure");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let audio = Audio::new();
|
|
||||||
assert!(audio.next_sound_index < audio.sounds.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_audio_default_impl() {
|
|
||||||
if let Err(_) = init_sdl() {
|
|
||||||
eprintln!("Skipping SDL2-dependent tests due to initialization failure");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let audio = Audio::default();
|
|
||||||
assert_eq!(audio.is_muted(), false);
|
|
||||||
assert_eq!(audio.next_sound_index, 0);
|
|
||||||
assert_eq!(audio.sounds.len(), 4);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
198
src/constants.rs
198
src/constants.rs
@@ -37,8 +37,6 @@ pub enum MapTile {
|
|||||||
Pellet,
|
Pellet,
|
||||||
/// A power pellet.
|
/// A power pellet.
|
||||||
PowerPellet,
|
PowerPellet,
|
||||||
/// A starting position for an entity.
|
|
||||||
StartingPosition(u8),
|
|
||||||
/// A tunnel tile.
|
/// A tunnel tile.
|
||||||
Tunnel,
|
Tunnel,
|
||||||
}
|
}
|
||||||
@@ -68,7 +66,7 @@ pub const RAW_BOARD: [&str; BOARD_CELL_SIZE.y as usize] = [
|
|||||||
"#............##............#",
|
"#............##............#",
|
||||||
"#.####.#####.##.#####.####.#",
|
"#.####.#####.##.#####.####.#",
|
||||||
"#.####.#####.##.#####.####.#",
|
"#.####.#####.##.#####.####.#",
|
||||||
"#o..##.......0 .......##..o#",
|
"#o..##.......X .......##..o#",
|
||||||
"###.##.##.########.##.##.###",
|
"###.##.##.########.##.##.###",
|
||||||
"###.##.##.########.##.##.###",
|
"###.##.##.########.##.##.###",
|
||||||
"#......##....##....##......#",
|
"#......##....##....##......#",
|
||||||
@@ -77,197 +75,3 @@ pub const RAW_BOARD: [&str; BOARD_CELL_SIZE.y as usize] = [
|
|||||||
"#..........................#",
|
"#..........................#",
|
||||||
"############################",
|
"############################",
|
||||||
];
|
];
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_loop_time() {
|
|
||||||
// 60 FPS = 16.67ms per frame
|
|
||||||
let expected_nanos = (1_000_000_000.0 / 60.0) as u64;
|
|
||||||
assert_eq!(LOOP_TIME.as_nanos() as u64, expected_nanos);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_cell_size() {
|
|
||||||
assert_eq!(CELL_SIZE, 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_board_cell_size() {
|
|
||||||
assert_eq!(BOARD_CELL_SIZE.x, 28);
|
|
||||||
assert_eq!(BOARD_CELL_SIZE.y, 31);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_scale() {
|
|
||||||
assert_eq!(SCALE, 2.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_board_cell_offset() {
|
|
||||||
assert_eq!(BOARD_CELL_OFFSET.x, 0);
|
|
||||||
assert_eq!(BOARD_CELL_OFFSET.y, 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_board_pixel_offset() {
|
|
||||||
let expected = UVec2::new(0 * CELL_SIZE, 3 * CELL_SIZE);
|
|
||||||
assert_eq!(BOARD_PIXEL_OFFSET, expected);
|
|
||||||
assert_eq!(BOARD_PIXEL_OFFSET.x, 0);
|
|
||||||
assert_eq!(BOARD_PIXEL_OFFSET.y, 24); // 3 * 8
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_board_pixel_size() {
|
|
||||||
let expected = UVec2::new(28 * CELL_SIZE, 31 * CELL_SIZE);
|
|
||||||
assert_eq!(BOARD_PIXEL_SIZE, expected);
|
|
||||||
assert_eq!(BOARD_PIXEL_SIZE.x, 224); // 28 * 8
|
|
||||||
assert_eq!(BOARD_PIXEL_SIZE.y, 248); // 31 * 8
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_canvas_size() {
|
|
||||||
let expected = UVec2::new((28 + 0) * CELL_SIZE, (31 + 3) * CELL_SIZE);
|
|
||||||
assert_eq!(CANVAS_SIZE, expected);
|
|
||||||
assert_eq!(CANVAS_SIZE.x, 224); // (28 + 0) * 8
|
|
||||||
assert_eq!(CANVAS_SIZE.y, 272); // (31 + 3) * 8
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_map_tile_variants() {
|
|
||||||
assert_ne!(MapTile::Empty, MapTile::Wall);
|
|
||||||
assert_ne!(MapTile::Pellet, MapTile::PowerPellet);
|
|
||||||
assert_ne!(MapTile::StartingPosition(0), MapTile::StartingPosition(1));
|
|
||||||
assert_ne!(MapTile::Tunnel, MapTile::Empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_map_tile_starting_position() {
|
|
||||||
let pos0 = MapTile::StartingPosition(0);
|
|
||||||
let pos1 = MapTile::StartingPosition(1);
|
|
||||||
let pos0_clone = MapTile::StartingPosition(0);
|
|
||||||
|
|
||||||
assert_eq!(pos0, pos0_clone);
|
|
||||||
assert_ne!(pos0, pos1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_map_tile_debug() {
|
|
||||||
let tile = MapTile::Wall;
|
|
||||||
let debug_str = format!("{:?}", tile);
|
|
||||||
assert!(!debug_str.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_map_tile_clone() {
|
|
||||||
let original = MapTile::StartingPosition(5);
|
|
||||||
let cloned = original;
|
|
||||||
assert_eq!(original, cloned);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_raw_board_dimensions() {
|
|
||||||
assert_eq!(RAW_BOARD.len(), BOARD_CELL_SIZE.y as usize);
|
|
||||||
assert_eq!(RAW_BOARD.len(), 31);
|
|
||||||
|
|
||||||
for row in RAW_BOARD.iter() {
|
|
||||||
assert_eq!(row.len(), BOARD_CELL_SIZE.x as usize);
|
|
||||||
assert_eq!(row.len(), 28);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_raw_board_boundaries() {
|
|
||||||
// First row should be all walls
|
|
||||||
assert!(RAW_BOARD[0].chars().all(|c| c == '#'));
|
|
||||||
|
|
||||||
// Last row should be all walls
|
|
||||||
let last_row = RAW_BOARD[RAW_BOARD.len() - 1];
|
|
||||||
assert!(last_row.chars().all(|c| c == '#'));
|
|
||||||
|
|
||||||
// First and last character of each row should be walls (except tunnel rows and rows with spaces)
|
|
||||||
for (i, row) in RAW_BOARD.iter().enumerate() {
|
|
||||||
if i != 14 && !row.starts_with(' ') {
|
|
||||||
// Skip tunnel row and rows that start with spaces
|
|
||||||
assert_eq!(row.chars().next().unwrap(), '#');
|
|
||||||
assert_eq!(row.chars().last().unwrap(), '#');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_raw_board_tunnel_row() {
|
|
||||||
// Row 14 should have tunnel characters 'T' at the edges
|
|
||||||
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_power_pellets() {
|
|
||||||
// Power pellets are represented by 'o'
|
|
||||||
let mut power_pellet_count = 0;
|
|
||||||
for row in RAW_BOARD.iter() {
|
|
||||||
power_pellet_count += row.chars().filter(|&c| c == 'o').count();
|
|
||||||
}
|
|
||||||
assert_eq!(power_pellet_count, 4); // Should have exactly 4 power pellets
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_raw_board_starting_position() {
|
|
||||||
// Should have a starting position '0' for Pac-Man
|
|
||||||
let mut found_starting_position = false;
|
|
||||||
for row in RAW_BOARD.iter() {
|
|
||||||
if row.contains('0') {
|
|
||||||
found_starting_position = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(found_starting_position);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_raw_board_ghost_house() {
|
|
||||||
// The ghost house area should be present (the == characters)
|
|
||||||
let mut found_ghost_house = false;
|
|
||||||
for row in RAW_BOARD.iter() {
|
|
||||||
if row.contains("==") {
|
|
||||||
found_ghost_house = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(found_ghost_house);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_raw_board_symmetry() {
|
|
||||||
// The board should be roughly symmetrical
|
|
||||||
let mid_point = RAW_BOARD[0].len() / 2;
|
|
||||||
|
|
||||||
for row in RAW_BOARD.iter() {
|
|
||||||
let left_half = &row[..mid_point];
|
|
||||||
let right_half = &row[mid_point..];
|
|
||||||
|
|
||||||
// Check that the halves are symmetrical (accounting for the center column)
|
|
||||||
assert_eq!(left_half.len(), right_half.len());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_constants_consistency() {
|
|
||||||
// Verify that derived constants are calculated correctly
|
|
||||||
let calculated_pixel_offset = UVec2::new(BOARD_CELL_OFFSET.x * CELL_SIZE, BOARD_CELL_OFFSET.y * CELL_SIZE);
|
|
||||||
assert_eq!(BOARD_PIXEL_OFFSET, calculated_pixel_offset);
|
|
||||||
|
|
||||||
let calculated_pixel_size = UVec2::new(BOARD_CELL_SIZE.x * CELL_SIZE, BOARD_CELL_SIZE.y * CELL_SIZE);
|
|
||||||
assert_eq!(BOARD_PIXEL_SIZE, calculated_pixel_size);
|
|
||||||
|
|
||||||
let calculated_canvas_size = UVec2::new(
|
|
||||||
(BOARD_CELL_SIZE.x + BOARD_CELL_OFFSET.x) * CELL_SIZE,
|
|
||||||
(BOARD_CELL_SIZE.y + BOARD_CELL_OFFSET.y) * CELL_SIZE,
|
|
||||||
);
|
|
||||||
assert_eq!(CANVAS_SIZE, calculated_canvas_size);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,5 +1,6 @@
|
|||||||
use glam::IVec2;
|
use glam::IVec2;
|
||||||
|
|
||||||
|
/// The four cardinal directions.
|
||||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
|
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
|
||||||
pub enum Direction {
|
pub enum Direction {
|
||||||
Up,
|
Up,
|
||||||
@@ -9,7 +10,12 @@ pub enum Direction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Direction {
|
impl Direction {
|
||||||
pub fn opposite(&self) -> Direction {
|
/// 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 opposite direction. Constant time.
|
||||||
|
pub const fn opposite(self) -> Direction {
|
||||||
match self {
|
match self {
|
||||||
Direction::Up => Direction::Down,
|
Direction::Up => Direction::Down,
|
||||||
Direction::Down => Direction::Up,
|
Direction::Down => Direction::Up,
|
||||||
@@ -18,8 +24,20 @@ impl Direction {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn as_ivec2(&self) -> IVec2 {
|
/// Returns the direction as an IVec2.
|
||||||
(*self).into()
|
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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,69 +51,3 @@ impl From<Direction> for IVec2 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const DIRECTIONS: [Direction; 4] = [Direction::Up, Direction::Down, Direction::Left, Direction::Right];
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_direction_opposite() {
|
|
||||||
assert_eq!(Direction::Up.opposite(), Direction::Down);
|
|
||||||
assert_eq!(Direction::Down.opposite(), Direction::Up);
|
|
||||||
assert_eq!(Direction::Left.opposite(), Direction::Right);
|
|
||||||
assert_eq!(Direction::Right.opposite(), Direction::Left);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_direction_as_ivec2() {
|
|
||||||
assert_eq!(Direction::Up.as_ivec2(), -IVec2::Y);
|
|
||||||
assert_eq!(Direction::Down.as_ivec2(), IVec2::Y);
|
|
||||||
assert_eq!(Direction::Left.as_ivec2(), -IVec2::X);
|
|
||||||
assert_eq!(Direction::Right.as_ivec2(), IVec2::X);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_direction_from_ivec2() {
|
|
||||||
assert_eq!(IVec2::from(Direction::Up), -IVec2::Y);
|
|
||||||
assert_eq!(IVec2::from(Direction::Down), IVec2::Y);
|
|
||||||
assert_eq!(IVec2::from(Direction::Left), -IVec2::X);
|
|
||||||
assert_eq!(IVec2::from(Direction::Right), IVec2::X);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_directions_constant() {
|
|
||||||
assert_eq!(DIRECTIONS.len(), 4);
|
|
||||||
assert!(DIRECTIONS.contains(&Direction::Up));
|
|
||||||
assert!(DIRECTIONS.contains(&Direction::Down));
|
|
||||||
assert!(DIRECTIONS.contains(&Direction::Left));
|
|
||||||
assert!(DIRECTIONS.contains(&Direction::Right));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_direction_equality() {
|
|
||||||
assert_eq!(Direction::Up, Direction::Up);
|
|
||||||
assert_ne!(Direction::Up, Direction::Down);
|
|
||||||
assert_ne!(Direction::Left, Direction::Right);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_direction_clone() {
|
|
||||||
let dir = Direction::Up;
|
|
||||||
let cloned = dir;
|
|
||||||
assert_eq!(dir, cloned);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_direction_hash() {
|
|
||||||
use std::collections::HashMap;
|
|
||||||
let mut map = HashMap::new();
|
|
||||||
map.insert(Direction::Up, "up");
|
|
||||||
map.insert(Direction::Down, "down");
|
|
||||||
|
|
||||||
assert_eq!(map.get(&Direction::Up), Some(&"up"));
|
|
||||||
assert_eq!(map.get(&Direction::Down), Some(&"down"));
|
|
||||||
assert_eq!(map.get(&Direction::Left), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
213
src/entity/ghost.rs
Normal file
213
src/entity/ghost.rs
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
//! 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 pathfinding::prelude::dijkstra;
|
||||||
|
use rand::prelude::*;
|
||||||
|
use smallvec::SmallVec;
|
||||||
|
|
||||||
|
use crate::entity::direction::Direction;
|
||||||
|
use crate::entity::graph::{Edge, EdgePermissions, Graph, NodeId};
|
||||||
|
use crate::entity::r#trait::Entity;
|
||||||
|
use crate::entity::traversal::Traverser;
|
||||||
|
use crate::texture::animated::AnimatedTexture;
|
||||||
|
use crate::texture::directional::DirectionalAnimatedTexture;
|
||||||
|
use crate::texture::sprite::SpriteAtlas;
|
||||||
|
|
||||||
|
/// 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 four classic ghost types.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum GhostType {
|
||||||
|
Blinky,
|
||||||
|
Pinky,
|
||||||
|
Inky,
|
||||||
|
Clyde,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GhostType {
|
||||||
|
/// Returns the ghost type name for atlas lookups.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
GhostType::Blinky => "blinky",
|
||||||
|
GhostType::Pinky => "pinky",
|
||||||
|
GhostType::Inky => "inky",
|
||||||
|
GhostType::Clyde => "clyde",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 Entity for Ghost {
|
||||||
|
fn traverser(&self) -> &Traverser {
|
||||||
|
&self.traverser
|
||||||
|
}
|
||||||
|
|
||||||
|
fn traverser_mut(&mut self) -> &mut Traverser {
|
||||||
|
&mut self.traverser
|
||||||
|
}
|
||||||
|
|
||||||
|
fn texture(&self) -> &DirectionalAnimatedTexture {
|
||||||
|
&self.texture
|
||||||
|
}
|
||||||
|
|
||||||
|
fn texture_mut(&mut self) -> &mut DirectionalAnimatedTexture {
|
||||||
|
&mut self.texture
|
||||||
|
}
|
||||||
|
|
||||||
|
fn speed(&self) -> f32 {
|
||||||
|
self.speed
|
||||||
|
}
|
||||||
|
|
||||||
|
fn can_traverse(&self, edge: Edge) -> bool {
|
||||||
|
can_ghost_traverse(edge)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tick(&mut self, dt: f32, graph: &Graph) {
|
||||||
|
// Choose random direction when at a node
|
||||||
|
if self.traverser.position.is_at_node() {
|
||||||
|
self.choose_random_direction(graph);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.traverser.advance(graph, dt * 60.0 * self.speed, &can_ghost_traverse);
|
||||||
|
self.texture.tick(dt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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![
|
||||||
|
SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a"))
|
||||||
|
.unwrap(),
|
||||||
|
];
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
traverser: Traverser::new(graph, start_node, Direction::Left, &can_ghost_traverse),
|
||||||
|
ghost_type,
|
||||||
|
texture: DirectionalAnimatedTexture::new(textures, stopped_textures),
|
||||||
|
speed: ghost_type.base_speed(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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];
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
if let Some(&random_direction) = filtered_directions.choose(&mut rng) {
|
||||||
|
self.traverser.set_next_direction(*random_direction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates the shortest path from the ghost's current position to a target node using Dijkstra's algorithm.
|
||||||
|
///
|
||||||
|
/// Returns a vector of NodeIds representing the path, or None if no path exists.
|
||||||
|
/// The path includes the current node and the target node.
|
||||||
|
pub fn calculate_path_to_target(&self, graph: &Graph, target: NodeId) -> Option<Vec<NodeId>> {
|
||||||
|
let start_node = self.traverser.position.from_node_id();
|
||||||
|
|
||||||
|
// Use Dijkstra's algorithm to find the shortest path
|
||||||
|
let result = dijkstra(
|
||||||
|
&start_node,
|
||||||
|
|&node_id| {
|
||||||
|
// Get all edges from the current node
|
||||||
|
graph.adjacency_list[node_id]
|
||||||
|
.edges()
|
||||||
|
.filter(|edge| can_ghost_traverse(*edge))
|
||||||
|
.map(|edge| (edge.target, (edge.distance * 100.0) as u32))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
},
|
||||||
|
|&node_id| node_id == target,
|
||||||
|
);
|
||||||
|
|
||||||
|
result.map(|(path, _cost)| path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the ghost's color for debug rendering.
|
||||||
|
pub fn debug_color(&self) -> sdl2::pixels::Color {
|
||||||
|
match self.ghost_type {
|
||||||
|
GhostType::Blinky => sdl2::pixels::Color::RGB(255, 0, 0), // Red
|
||||||
|
GhostType::Pinky => sdl2::pixels::Color::RGB(255, 182, 255), // Pink
|
||||||
|
GhostType::Inky => sdl2::pixels::Color::RGB(0, 255, 255), // Cyan
|
||||||
|
GhostType::Clyde => sdl2::pixels::Color::RGB(255, 182, 85), // Orange
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,16 @@ use super::direction::Direction;
|
|||||||
/// A unique identifier for a node, represented by its index in the graph's storage.
|
/// A unique identifier for a node, represented by its index in the graph's storage.
|
||||||
pub type NodeId = usize;
|
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).
|
/// Represents a directed edge from one node to another with a given weight (e.g., distance).
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct Edge {
|
pub struct Edge {
|
||||||
@@ -14,6 +24,8 @@ pub struct Edge {
|
|||||||
pub distance: f32,
|
pub distance: f32,
|
||||||
/// The cardinal direction of this edge.
|
/// The cardinal direction of this edge.
|
||||||
pub direction: Direction,
|
pub direction: Direction,
|
||||||
|
/// Defines who is allowed to traverse this edge.
|
||||||
|
pub permissions: EdgePermissions,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents a node in the graph, defined by its position.
|
/// Represents a node in the graph, defined by its position.
|
||||||
@@ -99,7 +111,7 @@ impl Graph {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Connects a new node to the graph and adds an edge between the existing node and the new node.
|
/// 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> {
|
pub fn add_connected(&mut self, from: NodeId, direction: Direction, new_node: Node) -> Result<NodeId, &'static str> {
|
||||||
let to = self.add_node(new_node);
|
let to = self.add_node(new_node);
|
||||||
self.connect(from, to, false, None, direction)?;
|
self.connect(from, to, false, None, direction)?;
|
||||||
Ok(to)
|
Ok(to)
|
||||||
@@ -121,8 +133,8 @@ impl Graph {
|
|||||||
return Err("To node does not exist.");
|
return Err("To node does not exist.");
|
||||||
}
|
}
|
||||||
|
|
||||||
let edge_a = self.add_edge(from, to, replace, distance, direction);
|
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());
|
let edge_b = self.add_edge(to, from, replace, distance, direction.opposite(), EdgePermissions::default());
|
||||||
|
|
||||||
if edge_a.is_err() && edge_b.is_err() {
|
if edge_a.is_err() && edge_b.is_err() {
|
||||||
return Err("Failed to connect nodes in both directions.");
|
return Err("Failed to connect nodes in both directions.");
|
||||||
@@ -150,6 +162,7 @@ impl Graph {
|
|||||||
replace: bool,
|
replace: bool,
|
||||||
distance: Option<f32>,
|
distance: Option<f32>,
|
||||||
direction: Direction,
|
direction: Direction,
|
||||||
|
permissions: EdgePermissions,
|
||||||
) -> Result<(), &'static str> {
|
) -> Result<(), &'static str> {
|
||||||
let edge = Edge {
|
let edge = Edge {
|
||||||
target: to,
|
target: to,
|
||||||
@@ -168,6 +181,7 @@ impl Graph {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
direction,
|
direction,
|
||||||
|
permissions,
|
||||||
};
|
};
|
||||||
|
|
||||||
if from >= self.adjacency_list.len() {
|
if from >= self.adjacency_list.len() {
|
||||||
@@ -222,193 +236,3 @@ impl Default for Graph {
|
|||||||
Self::new()
|
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(graph: &Graph, start_node: NodeId, initial_direction: Direction) -> Self {
|
|
||||||
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);
|
|
||||||
|
|
||||||
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(&mut self, graph: &Graph, distance: f32) {
|
|
||||||
// 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) {
|
|
||||||
// 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) {
|
|
||||||
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) {
|
|
||||||
self.position = Position::BetweenNodes {
|
|
||||||
from: to,
|
|
||||||
to: edge.target,
|
|
||||||
traversed: overflow,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
self.position = Position::AtNode(to);
|
|
||||||
self.next_direction = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
pub mod direction;
|
pub mod direction;
|
||||||
|
pub mod ghost;
|
||||||
pub mod graph;
|
pub mod graph;
|
||||||
pub mod pacman;
|
pub mod pacman;
|
||||||
|
pub mod r#trait;
|
||||||
|
pub mod traversal;
|
||||||
|
|||||||
@@ -1,27 +1,77 @@
|
|||||||
use glam::{UVec2, Vec2};
|
//! 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::constants::BOARD_PIXEL_OFFSET;
|
|
||||||
use crate::entity::direction::Direction;
|
use crate::entity::direction::Direction;
|
||||||
use crate::entity::graph::{Graph, NodeId, Position, Traverser};
|
use crate::entity::graph::{Edge, EdgePermissions, Graph, NodeId};
|
||||||
use crate::helpers::centered_with_size;
|
use crate::entity::r#trait::Entity;
|
||||||
|
use crate::entity::traversal::Traverser;
|
||||||
use crate::texture::animated::AnimatedTexture;
|
use crate::texture::animated::AnimatedTexture;
|
||||||
use crate::texture::directional::DirectionalAnimatedTexture;
|
use crate::texture::directional::DirectionalAnimatedTexture;
|
||||||
use crate::texture::sprite::SpriteAtlas;
|
use crate::texture::sprite::SpriteAtlas;
|
||||||
use sdl2::keyboard::Keycode;
|
use sdl2::keyboard::Keycode;
|
||||||
use sdl2::render::{Canvas, RenderTarget};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
|
/// 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 {
|
pub struct Pacman {
|
||||||
|
/// Handles movement through the game graph
|
||||||
pub traverser: Traverser,
|
pub traverser: Traverser,
|
||||||
|
/// Manages directional animated textures for different movement states
|
||||||
texture: DirectionalAnimatedTexture,
|
texture: DirectionalAnimatedTexture,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Pacman {
|
impl Entity for Pacman {
|
||||||
pub fn new(graph: &Graph, start_node: NodeId, atlas: &SpriteAtlas) -> Self {
|
fn traverser(&self) -> &Traverser {
|
||||||
let mut textures = HashMap::new();
|
&self.traverser
|
||||||
let mut stopped_textures = HashMap::new();
|
}
|
||||||
|
|
||||||
for &direction in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] {
|
fn traverser_mut(&mut self) -> &mut Traverser {
|
||||||
|
&mut self.traverser
|
||||||
|
}
|
||||||
|
|
||||||
|
fn texture(&self) -> &DirectionalAnimatedTexture {
|
||||||
|
&self.texture
|
||||||
|
}
|
||||||
|
|
||||||
|
fn texture_mut(&mut self) -> &mut DirectionalAnimatedTexture {
|
||||||
|
&mut self.texture
|
||||||
|
}
|
||||||
|
|
||||||
|
fn speed(&self) -> f32 {
|
||||||
|
1.125
|
||||||
|
}
|
||||||
|
|
||||||
|
fn can_traverse(&self, edge: Edge) -> bool {
|
||||||
|
can_pacman_traverse(edge)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tick(&mut self, dt: f32, graph: &Graph) {
|
||||||
|
self.traverser.advance(graph, dt * 60.0 * 1.125, &can_pacman_traverse);
|
||||||
|
self.texture.tick(dt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pacman {
|
||||||
|
/// 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];
|
||||||
|
|
||||||
|
for direction in Direction::DIRECTIONS {
|
||||||
let moving_prefix = match direction {
|
let moving_prefix = match direction {
|
||||||
Direction::Up => "pacman/up",
|
Direction::Up => "pacman/up",
|
||||||
Direction::Down => "pacman/down",
|
Direction::Down => "pacman/down",
|
||||||
@@ -36,27 +86,21 @@ impl Pacman {
|
|||||||
|
|
||||||
let stopped_tiles = vec![SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png")).unwrap()];
|
let stopped_tiles = vec![SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png")).unwrap()];
|
||||||
|
|
||||||
textures.insert(
|
textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.08).expect("Invalid frame duration"));
|
||||||
direction,
|
stopped_textures[direction.as_usize()] =
|
||||||
AnimatedTexture::new(moving_tiles, 0.08).expect("Invalid frame duration"),
|
Some(AnimatedTexture::new(stopped_tiles, 0.1).expect("Invalid frame duration"));
|
||||||
);
|
|
||||||
stopped_textures.insert(
|
|
||||||
direction,
|
|
||||||
AnimatedTexture::new(stopped_tiles, 0.1).expect("Invalid frame duration"),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
traverser: Traverser::new(graph, start_node, Direction::Left),
|
traverser: Traverser::new(graph, start_node, Direction::Left, &can_pacman_traverse),
|
||||||
texture: DirectionalAnimatedTexture::new(textures, stopped_textures),
|
texture: DirectionalAnimatedTexture::new(textures, stopped_textures),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tick(&mut self, dt: f32, graph: &Graph) {
|
/// Handles keyboard input to change Pac-Man's direction.
|
||||||
self.traverser.advance(graph, dt * 60.0 * 1.125);
|
///
|
||||||
self.texture.tick(dt);
|
/// Maps arrow keys to directions and queues the direction change
|
||||||
}
|
/// for the next valid intersection.
|
||||||
|
|
||||||
pub fn handle_key(&mut self, keycode: Keycode) {
|
pub fn handle_key(&mut self, keycode: Keycode) {
|
||||||
let direction = match keycode {
|
let direction = match keycode {
|
||||||
Keycode::Up => Some(Direction::Up),
|
Keycode::Up => Some(Direction::Up),
|
||||||
@@ -70,29 +114,4 @@ impl Pacman {
|
|||||||
self.traverser.set_next_direction(direction);
|
self.traverser.set_next_direction(direction);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_pixel_pos(&self, graph: &Graph) -> Vec2 {
|
|
||||||
match self.traverser.position {
|
|
||||||
Position::AtNode(node_id) => graph.get_node(node_id).unwrap().position,
|
|
||||||
Position::BetweenNodes { from, to, traversed } => {
|
|
||||||
let from_pos = graph.get_node(from).unwrap().position;
|
|
||||||
let to_pos = graph.get_node(to).unwrap().position;
|
|
||||||
from_pos.lerp(to_pos, traversed / from_pos.distance(to_pos))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, graph: &Graph) {
|
|
||||||
let pixel_pos = self.get_pixel_pos(graph).round().as_ivec2() + BOARD_PIXEL_OFFSET.as_ivec2();
|
|
||||||
let dest = centered_with_size(pixel_pos, UVec2::new(16, 16));
|
|
||||||
let is_stopped = self.traverser.position.is_stopped();
|
|
||||||
|
|
||||||
if is_stopped {
|
|
||||||
self.texture
|
|
||||||
.render_stopped(canvas, atlas, dest, self.traverser.direction)
|
|
||||||
.unwrap();
|
|
||||||
} else {
|
|
||||||
self.texture.render(canvas, atlas, dest, self.traverser.direction).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
108
src/entity/trait.rs
Normal file
108
src/entity/trait.rs
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
//! Entity trait for common movement and rendering functionality.
|
||||||
|
//!
|
||||||
|
//! This module defines a trait that captures the shared behavior between
|
||||||
|
//! different game entities like Ghosts and Pac-Man, including movement,
|
||||||
|
//! rendering, and position calculations.
|
||||||
|
|
||||||
|
use glam::Vec2;
|
||||||
|
use sdl2::render::{Canvas, RenderTarget};
|
||||||
|
|
||||||
|
use crate::entity::direction::Direction;
|
||||||
|
use crate::entity::graph::{Edge, Graph, NodeId};
|
||||||
|
use crate::entity::traversal::{Position, Traverser};
|
||||||
|
use crate::texture::directional::DirectionalAnimatedTexture;
|
||||||
|
use crate::texture::sprite::SpriteAtlas;
|
||||||
|
|
||||||
|
/// Trait defining common functionality for game entities that move through the graph.
|
||||||
|
///
|
||||||
|
/// This trait provides a unified interface for entities that:
|
||||||
|
/// - Move through the game graph using a traverser
|
||||||
|
/// - Render using directional animated textures
|
||||||
|
/// - Have position calculations and movement speed
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub trait Entity {
|
||||||
|
/// Returns a reference to the entity's traverser for movement control.
|
||||||
|
fn traverser(&self) -> &Traverser;
|
||||||
|
|
||||||
|
/// Returns a mutable reference to the entity's traverser for movement control.
|
||||||
|
fn traverser_mut(&mut self) -> &mut Traverser;
|
||||||
|
|
||||||
|
/// Returns a reference to the entity's directional animated texture.
|
||||||
|
fn texture(&self) -> &DirectionalAnimatedTexture;
|
||||||
|
|
||||||
|
/// Returns a mutable reference to the entity's directional animated texture.
|
||||||
|
fn texture_mut(&mut self) -> &mut DirectionalAnimatedTexture;
|
||||||
|
|
||||||
|
/// Returns the movement speed multiplier for this entity.
|
||||||
|
fn speed(&self) -> f32;
|
||||||
|
|
||||||
|
/// Determines if this entity can traverse a given edge.
|
||||||
|
fn can_traverse(&self, edge: Edge) -> bool;
|
||||||
|
|
||||||
|
/// Updates the entity's position and animation state.
|
||||||
|
///
|
||||||
|
/// This method advances movement through the graph and updates texture animation.
|
||||||
|
fn tick(&mut self, dt: f32, graph: &Graph);
|
||||||
|
|
||||||
|
/// Calculates the current pixel position in the game world.
|
||||||
|
///
|
||||||
|
/// Converts the graph position to screen coordinates, accounting for
|
||||||
|
/// the board offset and centering the sprite.
|
||||||
|
fn get_pixel_pos(&self, graph: &Graph) -> 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 + crate::constants::BOARD_PIXEL_OFFSET.x as f32,
|
||||||
|
pos.y + crate::constants::BOARD_PIXEL_OFFSET.y as f32,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current node ID that the entity is at or moving towards.
|
||||||
|
///
|
||||||
|
/// If the entity is at a node, returns that node ID.
|
||||||
|
/// If the entity is between nodes, returns the node it's moving towards.
|
||||||
|
fn current_node_id(&self) -> NodeId {
|
||||||
|
match self.traverser().position {
|
||||||
|
Position::AtNode(node_id) => node_id,
|
||||||
|
Position::BetweenNodes { to, .. } => to,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the next direction for the entity to take.
|
||||||
|
///
|
||||||
|
/// The direction is buffered and will be applied at the next opportunity,
|
||||||
|
/// typically when the entity reaches a new node.
|
||||||
|
fn set_next_direction(&mut self, direction: Direction) {
|
||||||
|
self.traverser_mut().set_next_direction(direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders the entity at its current position.
|
||||||
|
///
|
||||||
|
/// Draws the appropriate directional sprite based on the entity's
|
||||||
|
/// current movement state and direction.
|
||||||
|
fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, graph: &Graph) {
|
||||||
|
let pixel_pos = self.get_pixel_pos(graph);
|
||||||
|
let dest = crate::helpers::centered_with_size(
|
||||||
|
glam::IVec2::new(pixel_pos.x as i32, pixel_pos.y as i32),
|
||||||
|
glam::UVec2::new(16, 16),
|
||||||
|
);
|
||||||
|
|
||||||
|
if self.traverser().position.is_stopped() {
|
||||||
|
self.texture()
|
||||||
|
.render_stopped(canvas, atlas, dest, self.traverser().direction)
|
||||||
|
.expect("Failed to render entity");
|
||||||
|
} else {
|
||||||
|
self.texture()
|
||||||
|
.render(canvas, atlas, dest, self.traverser().direction)
|
||||||
|
.expect("Failed to render entity");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
205
src/entity/traversal.rs
Normal file
205
src/entity/traversal.rs
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
use super::direction::Direction;
|
||||||
|
use super::graph::{Edge, Graph, NodeId};
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
132
src/game.rs
132
src/game.rs
@@ -1,7 +1,8 @@
|
|||||||
//! This module contains the main game logic and state.
|
//! This module contains the main game logic and state.
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use glam::UVec2;
|
use glam::{UVec2, Vec2};
|
||||||
|
use rand::{rngs::SmallRng, Rng, SeedableRng};
|
||||||
use sdl2::{
|
use sdl2::{
|
||||||
image::LoadTexture,
|
image::LoadTexture,
|
||||||
keyboard::Keycode,
|
keyboard::Keycode,
|
||||||
@@ -13,8 +14,12 @@ use sdl2::{
|
|||||||
use crate::{
|
use crate::{
|
||||||
asset::{get_asset_bytes, Asset},
|
asset::{get_asset_bytes, Asset},
|
||||||
audio::Audio,
|
audio::Audio,
|
||||||
constants::RAW_BOARD,
|
constants::{CELL_SIZE, RAW_BOARD},
|
||||||
entity::pacman::Pacman,
|
entity::{
|
||||||
|
ghost::{Ghost, GhostType},
|
||||||
|
pacman::Pacman,
|
||||||
|
r#trait::Entity,
|
||||||
|
},
|
||||||
map::Map,
|
map::Map,
|
||||||
texture::{
|
texture::{
|
||||||
sprite::{self, AtlasMapper, AtlasTile, SpriteAtlas},
|
sprite::{self, AtlasMapper, AtlasTile, SpriteAtlas},
|
||||||
@@ -30,6 +35,7 @@ pub struct Game {
|
|||||||
pub score: u32,
|
pub score: u32,
|
||||||
pub map: Map,
|
pub map: Map,
|
||||||
pub pacman: Pacman,
|
pub pacman: Pacman,
|
||||||
|
pub ghosts: Vec<Ghost>,
|
||||||
pub debug_mode: bool,
|
pub debug_mode: bool,
|
||||||
|
|
||||||
// Rendering resources
|
// Rendering resources
|
||||||
@@ -73,10 +79,23 @@ impl Game {
|
|||||||
let audio = Audio::new();
|
let audio = Audio::new();
|
||||||
let pacman = Pacman::new(&map.graph, pacman_start_node, &atlas);
|
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 {
|
Game {
|
||||||
score: 0,
|
score: 0,
|
||||||
map,
|
map,
|
||||||
pacman,
|
pacman,
|
||||||
|
ghosts,
|
||||||
debug_mode: false,
|
debug_mode: false,
|
||||||
map_texture,
|
map_texture,
|
||||||
text_texture,
|
text_texture,
|
||||||
@@ -91,10 +110,41 @@ impl Game {
|
|||||||
if keycode == Keycode::M {
|
if keycode == Keycode::M {
|
||||||
self.audio.set_mute(!self.audio.is_muted());
|
self.audio.set_mute(!self.audio.is_muted());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if keycode == Keycode::R {
|
||||||
|
self.reset_game_state();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resets the game state, randomizing ghost positions and resetting Pac-Man
|
||||||
|
fn reset_game_state(&mut self) {
|
||||||
|
// Reset Pac-Man to starting position
|
||||||
|
let pacman_start_pos = self.map.find_starting_position(0).unwrap();
|
||||||
|
let pacman_start_node = *self
|
||||||
|
.map
|
||||||
|
.grid_to_node
|
||||||
|
.get(&glam::IVec2::new(pacman_start_pos.x as i32, pacman_start_pos.y as i32))
|
||||||
|
.expect("Pac-Man starting position not found in graph");
|
||||||
|
|
||||||
|
self.pacman = Pacman::new(&self.map.graph, pacman_start_node, &self.atlas);
|
||||||
|
|
||||||
|
// Randomize ghost positions
|
||||||
|
let ghost_types = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde];
|
||||||
|
let mut rng = SmallRng::from_os_rng();
|
||||||
|
|
||||||
|
for (i, ghost) in self.ghosts.iter_mut().enumerate() {
|
||||||
|
let random_node = rng.random_range(0..self.map.graph.node_count());
|
||||||
|
*ghost = Ghost::new(&self.map.graph, random_node, ghost_types[i], &self.atlas);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tick(&mut self, dt: f32) {
|
pub fn tick(&mut self, dt: f32) {
|
||||||
self.pacman.tick(dt, &self.map.graph);
|
self.pacman.tick(dt, &self.map.graph);
|
||||||
|
|
||||||
|
// Update all ghosts
|
||||||
|
for ghost in &mut self.ghosts {
|
||||||
|
ghost.tick(dt, &self.map.graph);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn draw<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>, backbuffer: &mut Texture) -> Result<()> {
|
pub fn draw<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>, backbuffer: &mut Texture) -> Result<()> {
|
||||||
@@ -102,22 +152,94 @@ impl Game {
|
|||||||
canvas.set_draw_color(Color::BLACK);
|
canvas.set_draw_color(Color::BLACK);
|
||||||
canvas.clear();
|
canvas.clear();
|
||||||
self.map.render(canvas, &mut self.atlas, &mut self.map_texture);
|
self.map.render(canvas, &mut self.atlas, &mut self.map_texture);
|
||||||
|
|
||||||
|
// Render all ghosts
|
||||||
|
for ghost in &self.ghosts {
|
||||||
|
ghost.render(canvas, &mut self.atlas, &self.map.graph);
|
||||||
|
}
|
||||||
|
|
||||||
self.pacman.render(canvas, &mut self.atlas, &self.map.graph);
|
self.pacman.render(canvas, &mut self.atlas, &self.map.graph);
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn present_backbuffer<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>, backbuffer: &Texture) -> Result<()> {
|
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)?;
|
canvas.copy(backbuffer, None, None).map_err(anyhow::Error::msg)?;
|
||||||
if self.debug_mode {
|
if self.debug_mode {
|
||||||
self.map.debug_render_nodes(canvas);
|
self.map
|
||||||
|
.debug_render_with_cursor(canvas, &mut self.text_texture, &mut self.atlas, cursor_pos);
|
||||||
|
self.render_pathfinding_debug(canvas)?;
|
||||||
}
|
}
|
||||||
self.draw_hud(canvas)?;
|
self.draw_hud(canvas)?;
|
||||||
canvas.present();
|
canvas.present();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Renders pathfinding debug lines from each ghost to Pac-Man.
|
||||||
|
///
|
||||||
|
/// Each ghost's path is drawn in its respective color with a small offset
|
||||||
|
/// to prevent overlapping lines.
|
||||||
|
fn render_pathfinding_debug<T: RenderTarget>(&self, canvas: &mut Canvas<T>) -> Result<()> {
|
||||||
|
let pacman_node = self.pacman.current_node_id();
|
||||||
|
|
||||||
|
for ghost in self.ghosts.iter() {
|
||||||
|
if let Some(path) = ghost.calculate_path_to_target(&self.map.graph, pacman_node) {
|
||||||
|
if path.len() < 2 {
|
||||||
|
continue; // Skip if path is too short
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the ghost's color
|
||||||
|
canvas.set_draw_color(ghost.debug_color());
|
||||||
|
|
||||||
|
// Calculate offset based on ghost index to prevent overlapping lines
|
||||||
|
// let offset = (i as f32) * 2.0 - 3.0; // Offset range: -3.0 to 3.0
|
||||||
|
|
||||||
|
// Calculate a consistent offset direction for the entire path
|
||||||
|
// let first_node = self.map.graph.get_node(path[0]).unwrap();
|
||||||
|
// let last_node = self.map.graph.get_node(path[path.len() - 1]).unwrap();
|
||||||
|
|
||||||
|
// Use the overall direction from start to end to determine the perpendicular offset
|
||||||
|
let offset = match ghost.ghost_type {
|
||||||
|
GhostType::Blinky => Vec2::new(0.25, 0.5),
|
||||||
|
GhostType::Pinky => Vec2::new(-0.25, -0.25),
|
||||||
|
GhostType::Inky => Vec2::new(0.5, -0.5),
|
||||||
|
GhostType::Clyde => Vec2::new(-0.5, 0.25),
|
||||||
|
} * 5.0;
|
||||||
|
|
||||||
|
// Calculate offset positions for all nodes using the same perpendicular direction
|
||||||
|
let mut offset_positions = Vec::new();
|
||||||
|
for &node_id in &path {
|
||||||
|
let node = self.map.graph.get_node(node_id).unwrap();
|
||||||
|
let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
|
||||||
|
offset_positions.push(pos + offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw lines between the offset positions
|
||||||
|
for window in offset_positions.windows(2) {
|
||||||
|
if let (Some(from), Some(to)) = (window.first(), window.get(1)) {
|
||||||
|
// Skip if the distance is too far (used for preventing lines between tunnel portals)
|
||||||
|
if from.distance_squared(*to) > (CELL_SIZE * 16).pow(2) as f32 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw the line
|
||||||
|
canvas
|
||||||
|
.draw_line((from.x as i32, from.y as i32), (to.x as i32, to.y as i32))
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_hud<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>) -> Result<()> {
|
fn draw_hud<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>) -> Result<()> {
|
||||||
let lives = 3;
|
let lives = 3;
|
||||||
let score_text = format!("{:02}", self.score);
|
let score_text = format!("{:02}", self.score);
|
||||||
|
|||||||
@@ -2,50 +2,9 @@ use glam::{IVec2, UVec2};
|
|||||||
use sdl2::rect::Rect;
|
use sdl2::rect::Rect;
|
||||||
|
|
||||||
pub fn centered_with_size(pixel_pos: IVec2, size: UVec2) -> Rect {
|
pub fn centered_with_size(pixel_pos: IVec2, size: UVec2) -> Rect {
|
||||||
Rect::new(
|
// Ensure the position doesn't cause integer overflow when centering
|
||||||
pixel_pos.x - size.x as i32 / 2,
|
let x = pixel_pos.x.saturating_sub(size.x as i32 / 2);
|
||||||
pixel_pos.y - size.y as i32 / 2,
|
let y = pixel_pos.y.saturating_sub(size.y as i32 / 2);
|
||||||
size.x,
|
|
||||||
size.y,
|
Rect::new(x, y, size.x, size.y)
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_centered_with_size_basic() {
|
|
||||||
let rect = centered_with_size(IVec2::new(100, 100), UVec2::new(50, 30));
|
|
||||||
assert_eq!(rect.origin(), (75, 85));
|
|
||||||
assert_eq!(rect.size(), (50, 30));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_centered_with_size_odd_dimensions() {
|
|
||||||
let rect = centered_with_size(IVec2::new(50, 50), UVec2::new(51, 31));
|
|
||||||
assert_eq!(rect.origin(), (25, 35));
|
|
||||||
assert_eq!(rect.size(), (51, 31));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_centered_with_size_zero_position() {
|
|
||||||
let rect = centered_with_size(IVec2::new(0, 0), UVec2::new(100, 100));
|
|
||||||
assert_eq!(rect.origin(), (-50, -50));
|
|
||||||
assert_eq!(rect.size(), (100, 100));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_centered_with_size_negative_position() {
|
|
||||||
let rect = centered_with_size(IVec2::new(-100, -50), UVec2::new(80, 40));
|
|
||||||
assert_eq!(rect.origin(), (-140, -70));
|
|
||||||
assert_eq!(rect.size(), (80, 40));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_centered_with_size_large_dimensions() {
|
|
||||||
let rect = centered_with_size(IVec2::new(1000, 1000), UVec2::new(1000, 1000));
|
|
||||||
assert_eq!(rect.origin(), (500, 500));
|
|
||||||
assert_eq!(rect.size(), (1000, 1000));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ pub mod app;
|
|||||||
pub mod asset;
|
pub mod asset;
|
||||||
pub mod audio;
|
pub mod audio;
|
||||||
pub mod constants;
|
pub mod constants;
|
||||||
pub mod emscripten;
|
|
||||||
pub mod entity;
|
pub mod entity;
|
||||||
pub mod game;
|
pub mod game;
|
||||||
pub mod helpers;
|
pub mod helpers;
|
||||||
pub mod map;
|
pub mod map;
|
||||||
|
pub mod platform;
|
||||||
pub mod texture;
|
pub mod texture;
|
||||||
|
|||||||
53
src/main.rs
53
src/main.rs
@@ -5,59 +5,16 @@ use tracing::info;
|
|||||||
use tracing_error::ErrorLayer;
|
use tracing_error::ErrorLayer;
|
||||||
use tracing_subscriber::layer::SubscriberExt;
|
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 app;
|
||||||
mod asset;
|
mod asset;
|
||||||
mod audio;
|
mod audio;
|
||||||
mod constants;
|
mod constants;
|
||||||
#[cfg(target_os = "emscripten")]
|
|
||||||
mod emscripten;
|
|
||||||
mod entity;
|
mod entity;
|
||||||
mod game;
|
mod game;
|
||||||
mod helpers;
|
mod helpers;
|
||||||
mod map;
|
mod map;
|
||||||
|
mod platform;
|
||||||
mod texture;
|
mod texture;
|
||||||
|
|
||||||
/// The main entry point of the application.
|
/// The main entry point of the application.
|
||||||
@@ -65,12 +22,6 @@ mod texture;
|
|||||||
/// This function initializes SDL, the window, the game state, and then enters
|
/// This function initializes SDL, the window, the game state, and then enters
|
||||||
/// the main game loop.
|
/// the main game loop.
|
||||||
pub fn main() {
|
pub fn main() {
|
||||||
// Attaches the console on Windows for debugging purposes.
|
|
||||||
#[cfg(windows)]
|
|
||||||
unsafe {
|
|
||||||
attach_console();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup tracing
|
// Setup tracing
|
||||||
let subscriber = tracing_subscriber::fmt()
|
let subscriber = tracing_subscriber::fmt()
|
||||||
.with_ansi(cfg!(not(target_os = "emscripten")))
|
.with_ansi(cfg!(not(target_os = "emscripten")))
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
//! Map construction and building functionality.
|
//! Map construction and building functionality.
|
||||||
|
|
||||||
use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE};
|
use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE};
|
||||||
use crate::entity::direction::{Direction, DIRECTIONS};
|
use crate::entity::direction::Direction;
|
||||||
use crate::entity::graph::{Graph, Node, NodeId};
|
use crate::entity::graph::{EdgePermissions, Graph, Node, NodeId};
|
||||||
use crate::map::parser::MapTileParser;
|
use crate::map::parser::MapTileParser;
|
||||||
use crate::map::render::MapRenderer;
|
use crate::map::render::MapRenderer;
|
||||||
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
|
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
|
||||||
@@ -11,18 +11,30 @@ use sdl2::render::{Canvas, RenderTarget};
|
|||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
/// The game map, responsible for holding the tile-based layout and the navigation graph.
|
/// The starting positions of the entities in the game.
|
||||||
///
|
#[allow(dead_code)]
|
||||||
/// The map is represented as a 2D array of `MapTile`s. It also stores a navigation
|
pub struct NodePositions {
|
||||||
/// `Graph` that entities like Pac-Man and ghosts use for movement. The graph is
|
pub pacman: NodeId,
|
||||||
/// generated from the walkable tiles of the map.
|
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 {
|
pub struct Map {
|
||||||
/// The current state of the map.
|
/// The current state of the map.
|
||||||
|
#[allow(dead_code)]
|
||||||
current: [[MapTile; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize],
|
current: [[MapTile; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize],
|
||||||
/// The node map for entity movement.
|
/// The node map for entity movement.
|
||||||
pub graph: Graph,
|
pub graph: Graph,
|
||||||
/// A mapping from grid positions to node IDs.
|
/// A mapping from grid positions to node IDs.
|
||||||
pub grid_to_node: HashMap<IVec2, NodeId>,
|
pub grid_to_node: HashMap<IVec2, NodeId>,
|
||||||
|
/// A mapping of the starting positions of the entities.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub start_positions: NodePositions,
|
||||||
|
/// Pac-Man's starting position.
|
||||||
|
pacman_start: Option<IVec2>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Map {
|
impl Map {
|
||||||
@@ -41,6 +53,7 @@ impl Map {
|
|||||||
let map = parsed_map.tiles;
|
let map = parsed_map.tiles;
|
||||||
let house_door = parsed_map.house_door;
|
let house_door = parsed_map.house_door;
|
||||||
let tunnel_ends = parsed_map.tunnel_ends;
|
let tunnel_ends = parsed_map.tunnel_ends;
|
||||||
|
let pacman_start = parsed_map.pacman_start;
|
||||||
|
|
||||||
let mut graph = Graph::new();
|
let mut graph = Graph::new();
|
||||||
let mut grid_to_node = HashMap::new();
|
let mut grid_to_node = HashMap::new();
|
||||||
@@ -48,25 +61,7 @@ impl Map {
|
|||||||
let cell_offset = Vec2::splat(CELL_SIZE as f32 / 2.0);
|
let cell_offset = Vec2::splat(CELL_SIZE as f32 / 2.0);
|
||||||
|
|
||||||
// Find a starting point for the graph generation, preferably Pac-Man's position.
|
// Find a starting point for the graph generation, preferably Pac-Man's position.
|
||||||
let start_pos = (0..BOARD_CELL_SIZE.y)
|
let start_pos = pacman_start.expect("Pac-Man's starting position not found");
|
||||||
.flat_map(|y| (0..BOARD_CELL_SIZE.x).map(move |x| IVec2::new(x as i32, y as i32)))
|
|
||||||
.find(|&p| matches!(map[p.x as usize][p.y as usize], MapTile::StartingPosition(0)))
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
// Fallback to any valid walkable tile if Pac-Man's start is not found
|
|
||||||
(0..BOARD_CELL_SIZE.y)
|
|
||||||
.flat_map(|y| (0..BOARD_CELL_SIZE.x).map(move |x| IVec2::new(x as i32, y as i32)))
|
|
||||||
.find(|&p| {
|
|
||||||
matches!(
|
|
||||||
map[p.x as usize][p.y as usize],
|
|
||||||
MapTile::Pellet
|
|
||||||
| MapTile::PowerPellet
|
|
||||||
| MapTile::Empty
|
|
||||||
| MapTile::Tunnel
|
|
||||||
| MapTile::StartingPosition(_)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.expect("No valid starting position found on map for graph generation")
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add the starting position to the graph/queue
|
// Add the starting position to the graph/queue
|
||||||
let mut queue = VecDeque::new();
|
let mut queue = VecDeque::new();
|
||||||
@@ -80,7 +75,7 @@ impl Map {
|
|||||||
|
|
||||||
// Iterate over the queue, adding nodes to the graph and connecting them to their neighbors
|
// Iterate over the queue, adding nodes to the graph and connecting them to their neighbors
|
||||||
while let Some(source_position) = queue.pop_front() {
|
while let Some(source_position) = queue.pop_front() {
|
||||||
for &dir in DIRECTIONS.iter() {
|
for dir in Direction::DIRECTIONS {
|
||||||
let new_position = source_position + dir.as_ivec2();
|
let new_position = source_position + dir.as_ivec2();
|
||||||
|
|
||||||
// Skip if the new position is out of bounds
|
// Skip if the new position is out of bounds
|
||||||
@@ -100,7 +95,7 @@ impl Map {
|
|||||||
// Skip if the new position is not a walkable tile
|
// Skip if the new position is not a walkable tile
|
||||||
if matches!(
|
if matches!(
|
||||||
map[new_position.x as usize][new_position.y as usize],
|
map[new_position.x as usize][new_position.y as usize],
|
||||||
MapTile::Pellet | MapTile::PowerPellet | MapTile::Empty | MapTile::Tunnel | MapTile::StartingPosition(_)
|
MapTile::Pellet | MapTile::PowerPellet | MapTile::Empty | MapTile::Tunnel
|
||||||
) {
|
) {
|
||||||
// Add the new position to the graph/queue
|
// Add the new position to the graph/queue
|
||||||
let pos = Vec2::new(
|
let pos = Vec2::new(
|
||||||
@@ -126,7 +121,7 @@ impl Map {
|
|||||||
|
|
||||||
// While most nodes are already connected to their neighbors, some may not be, so we need to connect them
|
// 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 (grid_pos, &node_id) in &grid_to_node {
|
||||||
for dir in DIRECTIONS {
|
for dir in Direction::DIRECTIONS {
|
||||||
// If the node doesn't have an edge in this direction, look for a neighbor in that direction
|
// 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() {
|
if graph.adjacency_list[node_id].get(dir).is_none() {
|
||||||
let neighbor = grid_pos + dir.as_ivec2();
|
let neighbor = grid_pos + dir.as_ivec2();
|
||||||
@@ -141,15 +136,26 @@ impl Map {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build house structure
|
// Build house structure
|
||||||
Self::build_house(&mut graph, &grid_to_node, &house_door);
|
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
|
// Build tunnel connections
|
||||||
Self::build_tunnels(&mut graph, &grid_to_node, &tunnel_ends);
|
Self::build_tunnels(&mut graph, &grid_to_node, &tunnel_ends);
|
||||||
|
|
||||||
Map {
|
Map {
|
||||||
current: map,
|
current: map,
|
||||||
grid_to_node,
|
|
||||||
graph,
|
graph,
|
||||||
|
grid_to_node,
|
||||||
|
start_positions,
|
||||||
|
pacman_start,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,14 +169,9 @@ impl Map {
|
|||||||
///
|
///
|
||||||
/// The starting position as a grid coordinate (`UVec2`), or `None` if not found.
|
/// The starting position as a grid coordinate (`UVec2`), or `None` if not found.
|
||||||
pub fn find_starting_position(&self, entity_id: u8) -> Option<UVec2> {
|
pub fn find_starting_position(&self, entity_id: u8) -> Option<UVec2> {
|
||||||
for (x, col) in self.current.iter().enumerate().take(BOARD_CELL_SIZE.x as usize) {
|
// For now, only Pac-Man (entity_id 0) is supported
|
||||||
for (y, &cell) in col.iter().enumerate().take(BOARD_CELL_SIZE.y as usize) {
|
if entity_id == 0 {
|
||||||
if let MapTile::StartingPosition(id) = cell {
|
return self.pacman_start.map(|pos| UVec2::new(pos.x as u32, pos.y as u32));
|
||||||
if id == entity_id {
|
|
||||||
return Some(UVec2::new(x as u32, y as u32));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -183,17 +184,26 @@ impl Map {
|
|||||||
MapRenderer::render_map(canvas, atlas, map_texture);
|
MapRenderer::render_map(canvas, atlas, map_texture);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renders a debug visualization of the navigation graph.
|
/// Renders a debug visualization with cursor-based highlighting.
|
||||||
///
|
///
|
||||||
/// This function is intended for development and debugging purposes. It draws the
|
/// This function provides interactive debugging by highlighting the nearest node
|
||||||
/// nodes and edges of the graph on top of the map, allowing for visual
|
/// to the cursor, showing its ID, and highlighting its connections.
|
||||||
/// inspection of the navigation paths.
|
pub fn debug_render_with_cursor<T: RenderTarget>(
|
||||||
pub fn debug_render_nodes<T: RenderTarget>(&self, canvas: &mut Canvas<T>) {
|
&self,
|
||||||
MapRenderer::debug_render_nodes(&self.graph, canvas);
|
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.
|
/// Builds the house structure in the graph.
|
||||||
fn build_house(graph: &mut Graph, grid_to_node: &HashMap<IVec2, NodeId>, house_door: &[Option<IVec2>; 2]) {
|
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
|
// Calculate the position of the house entrance node
|
||||||
let (house_entrance_node_id, house_entrance_node_position) = {
|
let (house_entrance_node_id, house_entrance_node_position) = {
|
||||||
// Translate the grid positions to the actual node ids
|
// Translate the grid positions to the actual node ids
|
||||||
@@ -254,10 +264,29 @@ impl Map {
|
|||||||
// Create the center line
|
// Create the center line
|
||||||
let (center_center_node_id, center_top_node_id) = create_house_line(graph, center_line_center_position);
|
let (center_center_node_id, center_top_node_id) = create_house_line(graph, center_line_center_position);
|
||||||
|
|
||||||
// Connect the house entrance to the top line
|
// Create a ghost-only, two-way connection for the house door.
|
||||||
|
// This prevents Pac-Man from entering or exiting through the door.
|
||||||
graph
|
graph
|
||||||
.connect(house_entrance_node_id, center_top_node_id, false, None, Direction::Down)
|
.add_edge(
|
||||||
.expect("Failed to connect house entrance to top line");
|
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
|
// Create the left line
|
||||||
let (left_center_node_id, _) = create_house_line(
|
let (left_center_node_id, _) = create_house_line(
|
||||||
@@ -283,6 +312,13 @@ impl Map {
|
|||||||
.expect("Failed to connect house entrance to right top line");
|
.expect("Failed to connect house entrance to right top line");
|
||||||
|
|
||||||
debug!("House entrance node id: {house_entrance_node_id}");
|
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.
|
/// Builds the tunnel connections in the graph.
|
||||||
@@ -295,7 +331,7 @@ impl Map {
|
|||||||
.expect("Left tunnel entrance node not found");
|
.expect("Left tunnel entrance node not found");
|
||||||
|
|
||||||
graph
|
graph
|
||||||
.connect_node(
|
.add_connected(
|
||||||
left_tunnel_entrance_node_id,
|
left_tunnel_entrance_node_id,
|
||||||
Direction::Left,
|
Direction::Left,
|
||||||
Node {
|
Node {
|
||||||
@@ -314,7 +350,7 @@ impl Map {
|
|||||||
.expect("Right tunnel entrance node not found");
|
.expect("Right tunnel entrance node not found");
|
||||||
|
|
||||||
graph
|
graph
|
||||||
.connect_node(
|
.add_connected(
|
||||||
right_tunnel_entrance_node_id,
|
right_tunnel_entrance_node_id,
|
||||||
Direction::Right,
|
Direction::Right,
|
||||||
Node {
|
Node {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ pub struct ParsedMap {
|
|||||||
pub house_door: [Option<IVec2>; 2],
|
pub house_door: [Option<IVec2>; 2],
|
||||||
/// The positions of the tunnel end tiles.
|
/// The positions of the tunnel end tiles.
|
||||||
pub tunnel_ends: [Option<IVec2>; 2],
|
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.
|
/// Parser for converting raw board layouts into structured map data.
|
||||||
@@ -44,8 +46,8 @@ impl MapTileParser {
|
|||||||
'o' => Ok(MapTile::PowerPellet),
|
'o' => Ok(MapTile::PowerPellet),
|
||||||
' ' => Ok(MapTile::Empty),
|
' ' => Ok(MapTile::Empty),
|
||||||
'T' => Ok(MapTile::Tunnel),
|
'T' => Ok(MapTile::Tunnel),
|
||||||
c @ '0'..='4' => Ok(MapTile::StartingPosition(c.to_digit(10).unwrap() as u8)),
|
'X' => Ok(MapTile::Empty), // Pac-Man's starting position, treated as empty
|
||||||
'=' => Ok(MapTile::Wall), // House door is represented as a wall tile
|
'=' => Ok(MapTile::Wall), // House door is represented as a wall tile
|
||||||
_ => Err(ParseError::UnknownCharacter(c)),
|
_ => Err(ParseError::UnknownCharacter(c)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,6 +70,7 @@ impl MapTileParser {
|
|||||||
let mut tiles = [[MapTile::Empty; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize];
|
let mut tiles = [[MapTile::Empty; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize];
|
||||||
let mut house_door = [None; 2];
|
let mut house_door = [None; 2];
|
||||||
let mut tunnel_ends = [None; 2];
|
let mut tunnel_ends = [None; 2];
|
||||||
|
let mut pacman_start: Option<IVec2> = None;
|
||||||
|
|
||||||
for (y, line) in raw_board.iter().enumerate().take(BOARD_CELL_SIZE.y 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) {
|
for (x, character) in line.chars().enumerate().take(BOARD_CELL_SIZE.x as usize) {
|
||||||
@@ -92,6 +95,11 @@ impl MapTileParser {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track Pac-Man's starting position
|
||||||
|
if character == 'X' {
|
||||||
|
pacman_start = Some(IVec2::new(x as i32, y as i32));
|
||||||
|
}
|
||||||
|
|
||||||
tiles[x][y] = tile;
|
tiles[x][y] = tile;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,63 +114,7 @@ impl MapTileParser {
|
|||||||
tiles,
|
tiles,
|
||||||
house_door,
|
house_door,
|
||||||
tunnel_ends,
|
tunnel_ends,
|
||||||
|
pacman_start,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::constants::RAW_BOARD;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_character() {
|
|
||||||
assert!(matches!(MapTileParser::parse_character('#').unwrap(), MapTile::Wall));
|
|
||||||
assert!(matches!(MapTileParser::parse_character('.').unwrap(), MapTile::Pellet));
|
|
||||||
assert!(matches!(MapTileParser::parse_character('o').unwrap(), MapTile::PowerPellet));
|
|
||||||
assert!(matches!(MapTileParser::parse_character(' ').unwrap(), MapTile::Empty));
|
|
||||||
assert!(matches!(MapTileParser::parse_character('T').unwrap(), MapTile::Tunnel));
|
|
||||||
assert!(matches!(
|
|
||||||
MapTileParser::parse_character('0').unwrap(),
|
|
||||||
MapTile::StartingPosition(0)
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
MapTileParser::parse_character('4').unwrap(),
|
|
||||||
MapTile::StartingPosition(4)
|
|
||||||
));
|
|
||||||
assert!(matches!(MapTileParser::parse_character('=').unwrap(), MapTile::Wall));
|
|
||||||
|
|
||||||
// Test invalid character
|
|
||||||
assert!(MapTileParser::parse_character('X').is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_board() {
|
|
||||||
let result = MapTileParser::parse_board(RAW_BOARD);
|
|
||||||
assert!(result.is_ok());
|
|
||||||
|
|
||||||
let parsed = result.unwrap();
|
|
||||||
|
|
||||||
// Verify we have tiles
|
|
||||||
assert_eq!(parsed.tiles.len(), BOARD_CELL_SIZE.x as usize);
|
|
||||||
assert_eq!(parsed.tiles[0].len(), BOARD_CELL_SIZE.y as usize);
|
|
||||||
|
|
||||||
// Verify we found house door positions
|
|
||||||
assert!(parsed.house_door[0].is_some());
|
|
||||||
assert!(parsed.house_door[1].is_some());
|
|
||||||
|
|
||||||
// Verify we found tunnel ends
|
|
||||||
assert!(parsed.tunnel_ends[0].is_some());
|
|
||||||
assert!(parsed.tunnel_ends[1].is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_board_invalid_character() {
|
|
||||||
let mut invalid_board = RAW_BOARD.clone();
|
|
||||||
invalid_board[0] = "###########################X";
|
|
||||||
|
|
||||||
let result = MapTileParser::parse_board(invalid_board);
|
|
||||||
assert!(result.is_err());
|
|
||||||
assert!(matches!(result.unwrap_err(), ParseError::UnknownCharacter('X')));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
//! Map rendering functionality.
|
//! Map rendering functionality.
|
||||||
|
|
||||||
use crate::constants::{BOARD_PIXEL_OFFSET, BOARD_PIXEL_SIZE};
|
|
||||||
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
|
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
|
||||||
|
use crate::texture::text::TextTexture;
|
||||||
|
use glam::Vec2;
|
||||||
use sdl2::pixels::Color;
|
use sdl2::pixels::Color;
|
||||||
use sdl2::rect::{Point, Rect};
|
use sdl2::rect::{Point, Rect};
|
||||||
use sdl2::render::{Canvas, RenderTarget};
|
use sdl2::render::{Canvas, RenderTarget};
|
||||||
@@ -16,53 +17,101 @@ impl MapRenderer {
|
|||||||
/// position and scale.
|
/// position and scale.
|
||||||
pub fn render_map<T: RenderTarget>(canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, map_texture: &mut AtlasTile) {
|
pub fn render_map<T: RenderTarget>(canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, map_texture: &mut AtlasTile) {
|
||||||
let dest = Rect::new(
|
let dest = Rect::new(
|
||||||
BOARD_PIXEL_OFFSET.x as i32,
|
crate::constants::BOARD_PIXEL_OFFSET.x as i32,
|
||||||
BOARD_PIXEL_OFFSET.y as i32,
|
crate::constants::BOARD_PIXEL_OFFSET.y as i32,
|
||||||
BOARD_PIXEL_SIZE.x,
|
crate::constants::BOARD_PIXEL_SIZE.x,
|
||||||
BOARD_PIXEL_SIZE.y,
|
crate::constants::BOARD_PIXEL_SIZE.y,
|
||||||
);
|
);
|
||||||
let _ = map_texture.render(canvas, atlas, dest);
|
let _ = map_texture.render(canvas, atlas, dest);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renders a debug visualization of the navigation graph.
|
/// Renders a debug visualization with cursor-based highlighting.
|
||||||
///
|
///
|
||||||
/// This function is intended for development and debugging purposes. It draws the
|
/// This function provides interactive debugging by highlighting the nearest node
|
||||||
/// nodes and edges of the graph on top of the map, allowing for visual
|
/// to the cursor, showing its ID, and highlighting its connections.
|
||||||
/// inspection of the navigation paths.
|
pub fn debug_render_with_cursor<T: RenderTarget>(
|
||||||
pub fn debug_render_nodes<T: RenderTarget>(graph: &crate::entity::graph::Graph, canvas: &mut Canvas<T>) {
|
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() {
|
for i in 0..graph.node_count() {
|
||||||
let node = graph.get_node(i).unwrap();
|
let node = graph.get_node(i).unwrap();
|
||||||
let pos = node.position + BOARD_PIXEL_OFFSET.as_vec2();
|
let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
|
||||||
|
|
||||||
// Draw connections
|
|
||||||
canvas.set_draw_color(Color::BLUE);
|
|
||||||
|
|
||||||
for edge in graph.adjacency_list[i].edges() {
|
for edge in graph.adjacency_list[i].edges() {
|
||||||
let end_pos = graph.get_node(edge.target).unwrap().position + BOARD_PIXEL_OFFSET.as_vec2();
|
let end_pos = graph.get_node(edge.target).unwrap().position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
|
||||||
canvas
|
canvas
|
||||||
.draw_line((pos.x as i32, pos.y as i32), (end_pos.x as i32, end_pos.y as i32))
|
.draw_line((pos.x as i32, pos.y as i32), (end_pos.x as i32, end_pos.y as i32))
|
||||||
.unwrap();
|
.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();
|
||||||
|
|
||||||
// Draw node
|
|
||||||
// let color = if pacman.position.from_node_idx() == i.into() {
|
|
||||||
// Color::GREEN
|
|
||||||
// } else if let Some(to_idx) = pacman.position.to_node_idx() {
|
|
||||||
// if to_idx == i.into() {
|
|
||||||
// Color::CYAN
|
|
||||||
// } else {
|
|
||||||
// Color::RED
|
|
||||||
// }
|
|
||||||
// } else {
|
|
||||||
// Color::RED
|
|
||||||
// };
|
|
||||||
canvas.set_draw_color(Color::GREEN);
|
|
||||||
canvas
|
canvas
|
||||||
.fill_rect(Rect::new(0, 0, 3, 3).centered_on(Point::new(pos.x as i32, pos.y as i32)))
|
.fill_rect(Rect::new(0, 0, 3, 3).centered_on(Point::new(pos.x as i32, pos.y as i32)))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
// Draw node index
|
// Highlight connections from the nearest node in bright blue
|
||||||
// text.render(canvas, atlas, &i.to_string(), pos.as_uvec2()).unwrap();
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,144 +50,27 @@ impl AnimatedTexture {
|
|||||||
tile.render(canvas, atlas, dest)
|
tile.render(canvas, atlas, dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper methods for testing
|
/// Returns the current frame index.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn current_frame(&self) -> usize {
|
pub fn current_frame(&self) -> usize {
|
||||||
self.current_frame
|
self.current_frame
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the time bank.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn time_bank(&self) -> f32 {
|
pub fn time_bank(&self) -> f32 {
|
||||||
self.time_bank
|
self.time_bank
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the frame duration.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn frame_duration(&self) -> f32 {
|
pub fn frame_duration(&self) -> f32 {
|
||||||
self.frame_duration
|
self.frame_duration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the number of tiles in the animation.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn tiles_len(&self) -> usize {
|
pub fn tiles_len(&self) -> usize {
|
||||||
self.tiles.len()
|
self.tiles.len()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use glam::U16Vec2;
|
|
||||||
use sdl2::pixels::Color;
|
|
||||||
|
|
||||||
impl AtlasTile {
|
|
||||||
fn mock(id: u32) -> Self {
|
|
||||||
AtlasTile {
|
|
||||||
pos: U16Vec2::new(0, 0),
|
|
||||||
size: U16Vec2::new(16, 16),
|
|
||||||
color: Some(Color::RGB(id as u8, 0, 0)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_new_animated_texture() {
|
|
||||||
let tiles = vec![AtlasTile::mock(1), AtlasTile::mock(2), AtlasTile::mock(3)];
|
|
||||||
let texture = AnimatedTexture::new(tiles.clone(), 0.1).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(texture.current_frame(), 0);
|
|
||||||
assert_eq!(texture.time_bank(), 0.0);
|
|
||||||
assert_eq!(texture.frame_duration(), 0.1);
|
|
||||||
assert_eq!(texture.tiles_len(), 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_new_animated_texture_zero_duration() {
|
|
||||||
let tiles = vec![AtlasTile::mock(1), AtlasTile::mock(2)];
|
|
||||||
let result = AnimatedTexture::new(tiles, 0.0);
|
|
||||||
assert!(result.is_err());
|
|
||||||
assert!(matches!(result.unwrap_err(), AnimatedTextureError::InvalidFrameDuration(0.0)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_new_animated_texture_negative_duration() {
|
|
||||||
let tiles = vec![AtlasTile::mock(1), AtlasTile::mock(2)];
|
|
||||||
let result = AnimatedTexture::new(tiles, -0.1);
|
|
||||||
assert!(result.is_err());
|
|
||||||
assert!(matches!(
|
|
||||||
result.unwrap_err(),
|
|
||||||
AnimatedTextureError::InvalidFrameDuration(-0.1)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_tick_no_frame_change() {
|
|
||||||
let tiles = vec![AtlasTile::mock(1), AtlasTile::mock(2)];
|
|
||||||
let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap();
|
|
||||||
|
|
||||||
// Tick with less than frame duration
|
|
||||||
texture.tick(0.05);
|
|
||||||
assert_eq!(texture.current_frame(), 0);
|
|
||||||
assert_eq!(texture.time_bank(), 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_tick_single_frame_change() {
|
|
||||||
let tiles = vec![AtlasTile::mock(1), AtlasTile::mock(2)];
|
|
||||||
let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap();
|
|
||||||
|
|
||||||
// Tick with exactly frame duration
|
|
||||||
texture.tick(0.1);
|
|
||||||
assert_eq!(texture.current_frame(), 1);
|
|
||||||
assert_eq!(texture.time_bank(), 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_tick_multiple_frame_changes() {
|
|
||||||
let tiles = vec![AtlasTile::mock(1), AtlasTile::mock(2), AtlasTile::mock(3)];
|
|
||||||
let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap();
|
|
||||||
|
|
||||||
// Tick with 2.5 frame durations
|
|
||||||
texture.tick(0.25);
|
|
||||||
assert_eq!(texture.current_frame(), 2);
|
|
||||||
assert!((texture.time_bank() - 0.05).abs() < 0.001);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_tick_wrap_around() {
|
|
||||||
let tiles = vec![AtlasTile::mock(1), AtlasTile::mock(2)];
|
|
||||||
let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap();
|
|
||||||
|
|
||||||
// Advance to last frame
|
|
||||||
texture.tick(0.1);
|
|
||||||
assert_eq!(texture.current_frame(), 1);
|
|
||||||
|
|
||||||
// Advance again to wrap around
|
|
||||||
texture.tick(0.1);
|
|
||||||
assert_eq!(texture.current_frame(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_current_tile() {
|
|
||||||
let tiles = vec![AtlasTile::mock(1), AtlasTile::mock(2)];
|
|
||||||
let texture = AnimatedTexture::new(tiles, 0.1).unwrap();
|
|
||||||
|
|
||||||
// Should return first tile initially
|
|
||||||
assert_eq!(texture.current_tile().color.unwrap().r, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_current_tile_after_frame_change() {
|
|
||||||
let tiles = vec![AtlasTile::mock(1), AtlasTile::mock(2)];
|
|
||||||
let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap();
|
|
||||||
|
|
||||||
// Advance one frame
|
|
||||||
texture.tick(0.1);
|
|
||||||
assert_eq!(texture.current_tile().color.unwrap().r, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_single_tile_animation() {
|
|
||||||
let tiles = vec![AtlasTile::mock(1)];
|
|
||||||
let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap();
|
|
||||||
|
|
||||||
// Should stay on same frame
|
|
||||||
texture.tick(0.1);
|
|
||||||
assert_eq!(texture.current_frame(), 0);
|
|
||||||
assert_eq!(texture.current_tile().color.unwrap().r, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -44,135 +44,3 @@ impl BlinkingTexture {
|
|||||||
self.blink_duration
|
self.blink_duration
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use glam::U16Vec2;
|
|
||||||
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_new_blinking_texture() {
|
|
||||||
let tile = mock_atlas_tile(1);
|
|
||||||
let texture = BlinkingTexture::new(tile, 0.5);
|
|
||||||
|
|
||||||
assert_eq!(texture.is_on(), true);
|
|
||||||
assert_eq!(texture.time_bank(), 0.0);
|
|
||||||
assert_eq!(texture.blink_duration(), 0.5);
|
|
||||||
assert_eq!(texture.tile().color.unwrap().r, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_tick_no_blink_change() {
|
|
||||||
let tile = mock_atlas_tile(1);
|
|
||||||
let mut texture = BlinkingTexture::new(tile, 0.5);
|
|
||||||
|
|
||||||
// Tick with less than blink duration
|
|
||||||
texture.tick(0.25);
|
|
||||||
assert_eq!(texture.is_on(), true);
|
|
||||||
assert_eq!(texture.time_bank(), 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_tick_single_blink_change() {
|
|
||||||
let tile = mock_atlas_tile(1);
|
|
||||||
let mut texture = BlinkingTexture::new(tile, 0.5);
|
|
||||||
|
|
||||||
// Tick with exactly blink duration
|
|
||||||
texture.tick(0.5);
|
|
||||||
assert_eq!(texture.is_on(), false);
|
|
||||||
assert_eq!(texture.time_bank(), 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_tick_multiple_blink_changes() {
|
|
||||||
let tile = mock_atlas_tile(1);
|
|
||||||
let mut texture = BlinkingTexture::new(tile, 0.5);
|
|
||||||
|
|
||||||
// First blink
|
|
||||||
texture.tick(0.5);
|
|
||||||
assert_eq!(texture.is_on(), false);
|
|
||||||
|
|
||||||
// Second blink (back to on)
|
|
||||||
texture.tick(0.5);
|
|
||||||
assert_eq!(texture.is_on(), true);
|
|
||||||
|
|
||||||
// Third blink (back to off)
|
|
||||||
texture.tick(0.5);
|
|
||||||
assert_eq!(texture.is_on(), false);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_tick_partial_blink_duration() {
|
|
||||||
let tile = mock_atlas_tile(1);
|
|
||||||
let mut texture = BlinkingTexture::new(tile, 0.5);
|
|
||||||
|
|
||||||
// Tick with 1.25 blink durations
|
|
||||||
texture.tick(0.625);
|
|
||||||
assert_eq!(texture.is_on(), false);
|
|
||||||
assert_eq!(texture.time_bank(), 0.125);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_tick_with_zero_duration() {
|
|
||||||
let tile = mock_atlas_tile(1);
|
|
||||||
let mut texture = BlinkingTexture::new(tile, 0.0);
|
|
||||||
|
|
||||||
// Should not cause issues - skip the test if blink_duration is 0
|
|
||||||
if texture.blink_duration() > 0.0 {
|
|
||||||
texture.tick(0.1);
|
|
||||||
assert_eq!(texture.is_on(), true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_tick_with_negative_duration() {
|
|
||||||
let tile = mock_atlas_tile(1);
|
|
||||||
let mut texture = BlinkingTexture::new(tile, -0.5);
|
|
||||||
|
|
||||||
// Should not cause issues - skip the test if blink_duration is negative
|
|
||||||
if texture.blink_duration() > 0.0 {
|
|
||||||
texture.tick(0.1);
|
|
||||||
assert_eq!(texture.is_on(), true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_tick_with_negative_delta_time() {
|
|
||||||
let tile = mock_atlas_tile(1);
|
|
||||||
let mut texture = BlinkingTexture::new(tile, 0.5);
|
|
||||||
|
|
||||||
// Should not cause issues
|
|
||||||
texture.tick(-0.1);
|
|
||||||
assert_eq!(texture.is_on(), true);
|
|
||||||
assert_eq!(texture.time_bank(), -0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_tile_access() {
|
|
||||||
let tile = mock_atlas_tile(42);
|
|
||||||
let texture = BlinkingTexture::new(tile, 0.5);
|
|
||||||
|
|
||||||
assert_eq!(texture.tile().color.unwrap().r, 42);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_clone() {
|
|
||||||
let tile = mock_atlas_tile(1);
|
|
||||||
let texture = BlinkingTexture::new(tile, 0.5);
|
|
||||||
let cloned = texture.clone();
|
|
||||||
|
|
||||||
assert_eq!(texture.is_on(), cloned.is_on());
|
|
||||||
assert_eq!(texture.time_bank(), cloned.time_bank());
|
|
||||||
assert_eq!(texture.blink_duration(), cloned.blink_duration());
|
|
||||||
assert_eq!(texture.tile().color.unwrap().r, cloned.tile().color.unwrap().r);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use sdl2::rect::Rect;
|
use sdl2::rect::Rect;
|
||||||
use sdl2::render::{Canvas, RenderTarget};
|
use sdl2::render::{Canvas, RenderTarget};
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use crate::entity::direction::Direction;
|
use crate::entity::direction::Direction;
|
||||||
use crate::texture::animated::AnimatedTexture;
|
use crate::texture::animated::AnimatedTexture;
|
||||||
@@ -9,12 +8,12 @@ use crate::texture::sprite::SpriteAtlas;
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct DirectionalAnimatedTexture {
|
pub struct DirectionalAnimatedTexture {
|
||||||
textures: HashMap<Direction, AnimatedTexture>,
|
textures: [Option<AnimatedTexture>; 4],
|
||||||
stopped_textures: HashMap<Direction, AnimatedTexture>,
|
stopped_textures: [Option<AnimatedTexture>; 4],
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DirectionalAnimatedTexture {
|
impl DirectionalAnimatedTexture {
|
||||||
pub fn new(textures: HashMap<Direction, AnimatedTexture>, stopped_textures: HashMap<Direction, AnimatedTexture>) -> Self {
|
pub fn new(textures: [Option<AnimatedTexture>; 4], stopped_textures: [Option<AnimatedTexture>; 4]) -> Self {
|
||||||
Self {
|
Self {
|
||||||
textures,
|
textures,
|
||||||
stopped_textures,
|
stopped_textures,
|
||||||
@@ -22,7 +21,7 @@ impl DirectionalAnimatedTexture {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn tick(&mut self, dt: f32) {
|
pub fn tick(&mut self, dt: f32) {
|
||||||
for texture in self.textures.values_mut() {
|
for texture in self.textures.iter_mut().flatten() {
|
||||||
texture.tick(dt);
|
texture.tick(dt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,7 +33,7 @@ impl DirectionalAnimatedTexture {
|
|||||||
dest: Rect,
|
dest: Rect,
|
||||||
direction: Direction,
|
direction: Direction,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if let Some(texture) = self.textures.get(&direction) {
|
if let Some(texture) = &self.textures[direction.as_usize()] {
|
||||||
texture.render(canvas, atlas, dest)
|
texture.render(canvas, atlas, dest)
|
||||||
} else {
|
} else {
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -48,136 +47,34 @@ impl DirectionalAnimatedTexture {
|
|||||||
dest: Rect,
|
dest: Rect,
|
||||||
direction: Direction,
|
direction: Direction,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if let Some(texture) = self.stopped_textures.get(&direction) {
|
if let Some(texture) = &self.stopped_textures[direction.as_usize()] {
|
||||||
texture.render(canvas, atlas, dest)
|
texture.render(canvas, atlas, dest)
|
||||||
} else {
|
} else {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper methods for testing
|
/// Returns true if the texture has a direction.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn has_direction(&self, direction: Direction) -> bool {
|
pub fn has_direction(&self, direction: Direction) -> bool {
|
||||||
self.textures.contains_key(&direction)
|
self.textures[direction.as_usize()].is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns true if the texture has a stopped direction.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn has_stopped_direction(&self, direction: Direction) -> bool {
|
pub fn has_stopped_direction(&self, direction: Direction) -> bool {
|
||||||
self.stopped_textures.contains_key(&direction)
|
self.stopped_textures[direction.as_usize()].is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the number of textures.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn texture_count(&self) -> usize {
|
pub fn texture_count(&self) -> usize {
|
||||||
self.textures.len()
|
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 {
|
pub fn stopped_texture_count(&self) -> usize {
|
||||||
self.stopped_textures.len()
|
self.stopped_textures.iter().filter(|t| t.is_some()).count()
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::texture::sprite::AtlasTile;
|
|
||||||
use glam::U16Vec2;
|
|
||||||
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_new_directional_animated_texture() {
|
|
||||||
let mut textures = HashMap::new();
|
|
||||||
let mut stopped_textures = HashMap::new();
|
|
||||||
|
|
||||||
textures.insert(Direction::Up, mock_animated_texture(1));
|
|
||||||
textures.insert(Direction::Down, mock_animated_texture(2));
|
|
||||||
stopped_textures.insert(Direction::Up, mock_animated_texture(3));
|
|
||||||
stopped_textures.insert(Direction::Down, mock_animated_texture(4));
|
|
||||||
|
|
||||||
let texture = DirectionalAnimatedTexture::new(textures, stopped_textures);
|
|
||||||
|
|
||||||
assert_eq!(texture.texture_count(), 2);
|
|
||||||
assert_eq!(texture.stopped_texture_count(), 2);
|
|
||||||
assert!(texture.has_direction(Direction::Up));
|
|
||||||
assert!(texture.has_direction(Direction::Down));
|
|
||||||
assert!(!texture.has_direction(Direction::Left));
|
|
||||||
assert!(texture.has_stopped_direction(Direction::Up));
|
|
||||||
assert!(texture.has_stopped_direction(Direction::Down));
|
|
||||||
assert!(!texture.has_stopped_direction(Direction::Left));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_tick() {
|
|
||||||
let mut textures = HashMap::new();
|
|
||||||
textures.insert(Direction::Up, mock_animated_texture(1));
|
|
||||||
textures.insert(Direction::Down, mock_animated_texture(2));
|
|
||||||
|
|
||||||
let mut texture = DirectionalAnimatedTexture::new(textures, HashMap::new());
|
|
||||||
|
|
||||||
// Should not panic
|
|
||||||
texture.tick(0.1);
|
|
||||||
assert_eq!(texture.texture_count(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_texture() {
|
|
||||||
let texture = DirectionalAnimatedTexture::new(HashMap::new(), HashMap::new());
|
|
||||||
|
|
||||||
assert_eq!(texture.texture_count(), 0);
|
|
||||||
assert_eq!(texture.stopped_texture_count(), 0);
|
|
||||||
assert!(!texture.has_direction(Direction::Up));
|
|
||||||
assert!(!texture.has_stopped_direction(Direction::Up));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_partial_directions() {
|
|
||||||
let mut textures = HashMap::new();
|
|
||||||
textures.insert(Direction::Up, mock_animated_texture(1));
|
|
||||||
|
|
||||||
let texture = DirectionalAnimatedTexture::new(textures, HashMap::new());
|
|
||||||
|
|
||||||
assert_eq!(texture.texture_count(), 1);
|
|
||||||
assert!(texture.has_direction(Direction::Up));
|
|
||||||
assert!(!texture.has_direction(Direction::Down));
|
|
||||||
assert!(!texture.has_direction(Direction::Left));
|
|
||||||
assert!(!texture.has_direction(Direction::Right));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_clone() {
|
|
||||||
let mut textures = HashMap::new();
|
|
||||||
textures.insert(Direction::Up, mock_animated_texture(1));
|
|
||||||
|
|
||||||
let texture = DirectionalAnimatedTexture::new(textures, HashMap::new());
|
|
||||||
let cloned = texture.clone();
|
|
||||||
|
|
||||||
assert_eq!(texture.texture_count(), cloned.texture_count());
|
|
||||||
assert_eq!(texture.stopped_texture_count(), cloned.stopped_texture_count());
|
|
||||||
assert_eq!(texture.has_direction(Direction::Up), cloned.has_direction(Direction::Up));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_all_directions() {
|
|
||||||
let mut textures = HashMap::new();
|
|
||||||
textures.insert(Direction::Up, mock_animated_texture(1));
|
|
||||||
textures.insert(Direction::Down, mock_animated_texture(2));
|
|
||||||
textures.insert(Direction::Left, mock_animated_texture(3));
|
|
||||||
textures.insert(Direction::Right, mock_animated_texture(4));
|
|
||||||
|
|
||||||
let texture = DirectionalAnimatedTexture::new(textures, HashMap::new());
|
|
||||||
|
|
||||||
assert_eq!(texture.texture_count(), 4);
|
|
||||||
assert!(texture.has_direction(Direction::Up));
|
|
||||||
assert!(texture.has_direction(Direction::Down));
|
|
||||||
assert!(texture.has_direction(Direction::Left));
|
|
||||||
assert!(texture.has_direction(Direction::Right));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,11 +50,14 @@ impl AtlasTile {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper methods for testing
|
/// Creates a new atlas tile.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn new(pos: U16Vec2, size: U16Vec2, color: Option<Color>) -> Self {
|
pub fn new(pos: U16Vec2, size: U16Vec2, color: Option<Color>) -> Self {
|
||||||
Self { pos, size, color }
|
Self { pos, size, color }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets the color of the tile.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn with_color(mut self, color: Color) -> Self {
|
pub fn with_color(mut self, color: Color) -> Self {
|
||||||
self.color = Some(color);
|
self.color = Some(color);
|
||||||
self
|
self
|
||||||
@@ -96,15 +99,20 @@ impl SpriteAtlas {
|
|||||||
&self.texture
|
&self.texture
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper methods for testing
|
/// Returns the number of tiles in the atlas.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn tiles_count(&self) -> usize {
|
pub fn tiles_count(&self) -> usize {
|
||||||
self.tiles.len()
|
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 {
|
pub fn has_tile(&self, name: &str) -> bool {
|
||||||
self.tiles.contains_key(name)
|
self.tiles.contains_key(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the default color of the atlas.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn default_color(&self) -> Option<Color> {
|
pub fn default_color(&self) -> Option<Color> {
|
||||||
self.default_color
|
self.default_color
|
||||||
}
|
}
|
||||||
@@ -126,236 +134,3 @@ impl SpriteAtlas {
|
|||||||
pub unsafe fn texture_to_static(texture: Texture) -> Texture<'static> {
|
pub unsafe fn texture_to_static(texture: Texture) -> Texture<'static> {
|
||||||
std::mem::transmute(texture)
|
std::mem::transmute(texture)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use sdl2::pixels::Color;
|
|
||||||
|
|
||||||
// Mock texture for testing - we'll use a dummy approach since we can't create real SDL2 textures
|
|
||||||
fn mock_texture() -> Texture<'static> {
|
|
||||||
// This is unsafe and only for testing - in real usage this would be a proper texture
|
|
||||||
unsafe { std::mem::transmute(0usize) }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_atlas_tile_new() {
|
|
||||||
let pos = U16Vec2::new(10, 20);
|
|
||||||
let size = U16Vec2::new(32, 32);
|
|
||||||
let tile = AtlasTile::new(pos, size, None);
|
|
||||||
|
|
||||||
assert_eq!(tile.pos, pos);
|
|
||||||
assert_eq!(tile.size, size);
|
|
||||||
assert_eq!(tile.color, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_atlas_tile_with_color() {
|
|
||||||
let pos = U16Vec2::new(10, 20);
|
|
||||||
let size = U16Vec2::new(32, 32);
|
|
||||||
let color = Color::RGB(255, 0, 0);
|
|
||||||
let tile = AtlasTile::new(pos, size, None).with_color(color);
|
|
||||||
|
|
||||||
assert_eq!(tile.pos, pos);
|
|
||||||
assert_eq!(tile.size, size);
|
|
||||||
assert_eq!(tile.color, Some(color));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_mapper_frame() {
|
|
||||||
let frame = MapperFrame {
|
|
||||||
x: 10,
|
|
||||||
y: 20,
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(frame.x, 10);
|
|
||||||
assert_eq!(frame.y, 20);
|
|
||||||
assert_eq!(frame.width, 32);
|
|
||||||
assert_eq!(frame.height, 32);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_atlas_mapper_new() {
|
|
||||||
let mut frames = HashMap::new();
|
|
||||||
frames.insert(
|
|
||||||
"test".to_string(),
|
|
||||||
MapperFrame {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let mapper = AtlasMapper { frames };
|
|
||||||
|
|
||||||
assert_eq!(mapper.frames.len(), 1);
|
|
||||||
assert!(mapper.frames.contains_key("test"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_sprite_atlas_new() {
|
|
||||||
let mut frames = HashMap::new();
|
|
||||||
frames.insert(
|
|
||||||
"test".to_string(),
|
|
||||||
MapperFrame {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let mapper = AtlasMapper { frames };
|
|
||||||
let texture = mock_texture();
|
|
||||||
let atlas = SpriteAtlas::new(texture, mapper);
|
|
||||||
|
|
||||||
assert_eq!(atlas.tiles_count(), 1);
|
|
||||||
assert!(atlas.has_tile("test"));
|
|
||||||
assert_eq!(atlas.default_color(), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_sprite_atlas_get_tile() {
|
|
||||||
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, U16Vec2::new(10, 20));
|
|
||||||
assert_eq!(tile.size, U16Vec2::new(32, 64));
|
|
||||||
assert_eq!(tile.color, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_sprite_atlas_get_tile_nonexistent() {
|
|
||||||
let mapper = AtlasMapper { frames: HashMap::new() };
|
|
||||||
let texture = mock_texture();
|
|
||||||
let atlas = SpriteAtlas::new(texture, mapper);
|
|
||||||
|
|
||||||
let tile = atlas.get_tile("nonexistent");
|
|
||||||
assert!(tile.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_sprite_atlas_set_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));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_sprite_atlas_empty() {
|
|
||||||
let mapper = AtlasMapper { frames: HashMap::new() };
|
|
||||||
let texture = mock_texture();
|
|
||||||
let atlas = SpriteAtlas::new(texture, mapper);
|
|
||||||
|
|
||||||
assert_eq!(atlas.tiles_count(), 0);
|
|
||||||
assert!(!atlas.has_tile("any"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_atlas_tile_clone() {
|
|
||||||
let pos = U16Vec2::new(10, 20);
|
|
||||||
let size = U16Vec2::new(32, 32);
|
|
||||||
let color = Color::RGB(255, 0, 0);
|
|
||||||
let tile = AtlasTile::new(pos, size, Some(color));
|
|
||||||
let cloned = tile;
|
|
||||||
|
|
||||||
assert_eq!(tile.pos, cloned.pos);
|
|
||||||
assert_eq!(tile.size, cloned.size);
|
|
||||||
assert_eq!(tile.color, cloned.color);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_mapper_frame_clone() {
|
|
||||||
let frame = MapperFrame {
|
|
||||||
x: 10,
|
|
||||||
y: 20,
|
|
||||||
width: 32,
|
|
||||||
height: 64,
|
|
||||||
};
|
|
||||||
let cloned = frame;
|
|
||||||
|
|
||||||
assert_eq!(frame.x, cloned.x);
|
|
||||||
assert_eq!(frame.y, cloned.y);
|
|
||||||
assert_eq!(frame.width, cloned.width);
|
|
||||||
assert_eq!(frame.height, cloned.height);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_atlas_mapper_clone() {
|
|
||||||
let mut frames = HashMap::new();
|
|
||||||
frames.insert(
|
|
||||||
"test".to_string(),
|
|
||||||
MapperFrame {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let mapper = AtlasMapper { frames };
|
|
||||||
let cloned = mapper.clone();
|
|
||||||
|
|
||||||
assert_eq!(mapper.frames.len(), cloned.frames.len());
|
|
||||||
assert!(mapper.frames.contains_key("test"));
|
|
||||||
assert!(cloned.frames.contains_key("test"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -151,228 +151,3 @@ impl TextTexture {
|
|||||||
(8.0 * self.scale) as u32
|
(8.0 * self.scale) as u32
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
fn create_mock_atlas() -> SpriteAtlas {
|
|
||||||
let mut frames = HashMap::new();
|
|
||||||
frames.insert(
|
|
||||||
"text/A.png".to_string(),
|
|
||||||
MapperFrame {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
frames.insert(
|
|
||||||
"text/1.png".to_string(),
|
|
||||||
MapperFrame {
|
|
||||||
x: 8,
|
|
||||||
y: 0,
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
frames.insert(
|
|
||||||
"text/!.png".to_string(),
|
|
||||||
MapperFrame {
|
|
||||||
x: 16,
|
|
||||||
y: 0,
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
frames.insert(
|
|
||||||
"text/-.png".to_string(),
|
|
||||||
MapperFrame {
|
|
||||||
x: 24,
|
|
||||||
y: 0,
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
frames.insert(
|
|
||||||
"text/_double_quote.png".to_string(),
|
|
||||||
MapperFrame {
|
|
||||||
x: 32,
|
|
||||||
y: 0,
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
frames.insert(
|
|
||||||
"text/_forward_slash.png".to_string(),
|
|
||||||
MapperFrame {
|
|
||||||
x: 40,
|
|
||||||
y: 0,
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let mapper = AtlasMapper { frames };
|
|
||||||
// Note: In real tests, we'd need a proper texture, but for unit tests we can work around this
|
|
||||||
unsafe { SpriteAtlas::new(std::mem::zeroed(), mapper) }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_text_texture_new() {
|
|
||||||
let text_texture = TextTexture::new(1.0);
|
|
||||||
assert_eq!(text_texture.scale(), 1.0);
|
|
||||||
assert!(text_texture.char_map.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_text_texture_new_with_scale() {
|
|
||||||
let text_texture = TextTexture::new(2.5);
|
|
||||||
assert_eq!(text_texture.scale(), 2.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_char_to_tile_name_letters() {
|
|
||||||
let text_texture = TextTexture::new(1.0);
|
|
||||||
|
|
||||||
assert_eq!(text_texture.char_to_tile_name('A'), Some("text/A.png".to_string()));
|
|
||||||
assert_eq!(text_texture.char_to_tile_name('Z'), Some("text/Z.png".to_string()));
|
|
||||||
assert_eq!(text_texture.char_to_tile_name('a'), None); // lowercase not supported
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_char_to_tile_name_numbers() {
|
|
||||||
let text_texture = TextTexture::new(1.0);
|
|
||||||
|
|
||||||
assert_eq!(text_texture.char_to_tile_name('0'), Some("text/0.png".to_string()));
|
|
||||||
assert_eq!(text_texture.char_to_tile_name('9'), Some("text/9.png".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_char_to_tile_name_special_characters() {
|
|
||||||
let text_texture = TextTexture::new(1.0);
|
|
||||||
|
|
||||||
assert_eq!(text_texture.char_to_tile_name('!'), Some("text/!.png".to_string()));
|
|
||||||
assert_eq!(text_texture.char_to_tile_name('-'), Some("text/-.png".to_string()));
|
|
||||||
assert_eq!(
|
|
||||||
text_texture.char_to_tile_name('"'),
|
|
||||||
Some("text/_double_quote.png".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
text_texture.char_to_tile_name('/'),
|
|
||||||
Some("text/_forward_slash.png".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_char_to_tile_name_unsupported() {
|
|
||||||
let text_texture = TextTexture::new(1.0);
|
|
||||||
|
|
||||||
assert_eq!(text_texture.char_to_tile_name(' '), None);
|
|
||||||
assert_eq!(text_texture.char_to_tile_name('@'), None);
|
|
||||||
assert_eq!(text_texture.char_to_tile_name('a'), None);
|
|
||||||
assert_eq!(text_texture.char_to_tile_name('z'), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_set_scale() {
|
|
||||||
let mut text_texture = TextTexture::new(1.0);
|
|
||||||
assert_eq!(text_texture.scale(), 1.0);
|
|
||||||
|
|
||||||
text_texture.set_scale(3.0);
|
|
||||||
assert_eq!(text_texture.scale(), 3.0);
|
|
||||||
|
|
||||||
text_texture.set_scale(0.5);
|
|
||||||
assert_eq!(text_texture.scale(), 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_text_width_empty_string() {
|
|
||||||
let text_texture = TextTexture::new(1.0);
|
|
||||||
assert_eq!(text_texture.text_width(""), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_text_width_single_character() {
|
|
||||||
let text_texture = TextTexture::new(1.0);
|
|
||||||
assert_eq!(text_texture.text_width("A"), 8); // 8 pixels per character at scale 1.0
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_text_width_multiple_characters() {
|
|
||||||
let text_texture = TextTexture::new(1.0);
|
|
||||||
assert_eq!(text_texture.text_width("ABC"), 24); // 3 * 8 = 24 pixels
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_text_width_with_scale() {
|
|
||||||
let text_texture = TextTexture::new(2.0);
|
|
||||||
assert_eq!(text_texture.text_width("A"), 16); // 8 * 2 = 16 pixels
|
|
||||||
assert_eq!(text_texture.text_width("ABC"), 48); // 3 * 16 = 48 pixels
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_text_width_with_unsupported_characters() {
|
|
||||||
let text_texture = TextTexture::new(1.0);
|
|
||||||
// Only supported characters should be counted
|
|
||||||
assert_eq!(text_texture.text_width("A B"), 16); // A and B only, space ignored
|
|
||||||
assert_eq!(text_texture.text_width("A@B"), 16); // A and B only, @ ignored
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_text_height() {
|
|
||||||
let text_texture = TextTexture::new(1.0);
|
|
||||||
assert_eq!(text_texture.text_height(), 8); // 8 pixels per character at scale 1.0
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_text_height_with_scale() {
|
|
||||||
let text_texture = TextTexture::new(2.0);
|
|
||||||
assert_eq!(text_texture.text_height(), 16); // 8 * 2 = 16 pixels
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_text_height_with_fractional_scale() {
|
|
||||||
let text_texture = TextTexture::new(1.5);
|
|
||||||
assert_eq!(text_texture.text_height(), 12); // 8 * 1.5 = 12 pixels
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_get_char_tile_caching() {
|
|
||||||
let mut text_texture = TextTexture::new(1.0);
|
|
||||||
let atlas = create_mock_atlas();
|
|
||||||
|
|
||||||
// First call should cache the tile
|
|
||||||
let tile1 = text_texture.get_char_tile(&atlas, 'A');
|
|
||||||
assert!(tile1.is_some());
|
|
||||||
|
|
||||||
// Second call should use cached tile
|
|
||||||
let tile2 = text_texture.get_char_tile(&atlas, 'A');
|
|
||||||
assert!(tile2.is_some());
|
|
||||||
|
|
||||||
// Both should be the same tile
|
|
||||||
assert_eq!(tile1.unwrap().pos, tile2.unwrap().pos);
|
|
||||||
assert_eq!(tile1.unwrap().size, tile2.unwrap().size);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_get_char_tile_unsupported_character() {
|
|
||||||
let mut text_texture = TextTexture::new(1.0);
|
|
||||||
let atlas = create_mock_atlas();
|
|
||||||
|
|
||||||
let tile = text_texture.get_char_tile(&atlas, ' ');
|
|
||||||
assert!(tile.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_get_char_tile_missing_from_atlas() {
|
|
||||||
let mut text_texture = TextTexture::new(1.0);
|
|
||||||
let atlas = create_mock_atlas();
|
|
||||||
|
|
||||||
// 'B' is not in our mock atlas
|
|
||||||
let tile = text_texture.get_char_tile(&atlas, 'B');
|
|
||||||
assert!(tile.is_none());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
150
tests/graph.rs
Normal file
150
tests/graph.rs
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
use pacman::entity::direction::Direction;
|
||||||
|
use pacman::entity::graph::{EdgePermissions, Graph, Node};
|
||||||
|
use pacman::entity::traversal::{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')));
|
||||||
|
}
|
||||||
117
tests/pathfinding.rs
Normal file
117
tests/pathfinding.rs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
use pacman::entity::direction::Direction;
|
||||||
|
use pacman::entity::ghost::{Ghost, GhostType};
|
||||||
|
use pacman::entity::graph::{Graph, Node};
|
||||||
|
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_pathfinding() {
|
||||||
|
// Create a simple test graph
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
|
||||||
|
// Add nodes in a simple line: 0 -> 1 -> 2
|
||||||
|
let node0 = graph.add_node(Node {
|
||||||
|
position: glam::Vec2::new(0.0, 0.0),
|
||||||
|
});
|
||||||
|
let node1 = graph.add_node(Node {
|
||||||
|
position: glam::Vec2::new(10.0, 0.0),
|
||||||
|
});
|
||||||
|
let node2 = graph.add_node(Node {
|
||||||
|
position: glam::Vec2::new(20.0, 0.0),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect the nodes
|
||||||
|
graph.connect(node0, node1, false, None, Direction::Right).unwrap();
|
||||||
|
graph.connect(node1, node2, false, None, Direction::Right).unwrap();
|
||||||
|
|
||||||
|
// Create a test atlas for the ghost
|
||||||
|
let atlas = create_test_atlas();
|
||||||
|
|
||||||
|
// Create a ghost at node 0
|
||||||
|
let ghost = Ghost::new(&graph, node0, GhostType::Blinky, &atlas);
|
||||||
|
|
||||||
|
// Test pathfinding from node 0 to node 2
|
||||||
|
let path = ghost.calculate_path_to_target(&graph, node2);
|
||||||
|
|
||||||
|
assert!(path.is_some());
|
||||||
|
let path = path.unwrap();
|
||||||
|
assert_eq!(path, vec![node0, node1, node2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ghost_pathfinding_no_path() {
|
||||||
|
// Create a test graph with disconnected components
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
|
||||||
|
let node0 = graph.add_node(Node {
|
||||||
|
position: glam::Vec2::new(0.0, 0.0),
|
||||||
|
});
|
||||||
|
let node1 = graph.add_node(Node {
|
||||||
|
position: glam::Vec2::new(10.0, 0.0),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Don't connect the nodes
|
||||||
|
let atlas = create_test_atlas();
|
||||||
|
let ghost = Ghost::new(&graph, node0, GhostType::Blinky, &atlas);
|
||||||
|
|
||||||
|
// Test pathfinding when no path exists
|
||||||
|
let path = ghost.calculate_path_to_target(&graph, node1);
|
||||||
|
|
||||||
|
assert!(path.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ghost_debug_colors() {
|
||||||
|
let atlas = create_test_atlas();
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let node = graph.add_node(Node {
|
||||||
|
position: glam::Vec2::new(0.0, 0.0),
|
||||||
|
});
|
||||||
|
|
||||||
|
let blinky = Ghost::new(&graph, node, GhostType::Blinky, &atlas);
|
||||||
|
let pinky = Ghost::new(&graph, node, GhostType::Pinky, &atlas);
|
||||||
|
let inky = Ghost::new(&graph, node, GhostType::Inky, &atlas);
|
||||||
|
let clyde = Ghost::new(&graph, node, GhostType::Clyde, &atlas);
|
||||||
|
|
||||||
|
// Test that each ghost has a different debug color
|
||||||
|
let colors = std::collections::HashSet::from([
|
||||||
|
blinky.debug_color(),
|
||||||
|
pinky.debug_color(),
|
||||||
|
inky.debug_color(),
|
||||||
|
clyde.debug_color(),
|
||||||
|
]);
|
||||||
|
assert_eq!(colors.len(), 4, "All ghost colors should be unique");
|
||||||
|
}
|
||||||
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