mirror of
https://github.com/Xevion/Pac-Man.git
synced 2025-12-06 11:15:46 -06:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cda8c40195 | |||
| 89b4ba125f | |||
| fcdbe62f99 | |||
| 57c7afcdb4 | |||
| 2e16c2d170 | |||
| f86c106593 | |||
| 04cf8f217f | |||
| 7e0ca4ff3d | |||
| fcc36c8a46 | |||
| 41affcd7ad | |||
| 4ecfded4ac | |||
| 25d5121a28 | |||
| 91095ed2cc | |||
| cbf52bb994 | |||
| d763b9646f | |||
| d7a9e0a304 | |||
| db720edeef | |||
| f241e85d8f | |||
| d18b414536 | |||
| c9bcf32381 | |||
| b45980c172 | |||
| b4e3f383ec | |||
| 532abd1e45 | |||
| 70528b0dcc | |||
| c5ca7302c2 | |||
| a27f85279e | |||
| bea915b5c7 | |||
| d743aee393 | |||
| 59aba9f691 | |||
| 199b4dc939 | |||
| 2edd23cfbb | |||
| 464d6f9ca6 | |||
| 413f9f156f | |||
| 4f87a116d5 | |||
| 86ffc931e8 | |||
| d72f47d66c | |||
| 7a6182cb85 |
@@ -5,6 +5,7 @@ rustflags = [
|
||||
"-C", "link-args=-sUSE_SDL=2 -sUSE_SDL_IMAGE=2 -sUSE_SDL_MIXER=2 -sUSE_OGG=1 -sUSE_SDL_GFX=2 -sUSE_SDL_TTF=2 -sSDL2_IMAGE_FORMATS=['png']",
|
||||
"-C", "link-args=--preload-file assets/game/",
|
||||
]
|
||||
runner = "node"
|
||||
|
||||
[target.'cfg(target_os = "linux")']
|
||||
rustflags = [
|
||||
@@ -13,4 +14,4 @@ rustflags = [
|
||||
# By adding `-lz` here, we ensure it's passed to the linker after `libpng`,
|
||||
# which is required for the linker to correctly resolve symbols.
|
||||
"-C", "link-arg=-lz",
|
||||
]
|
||||
]
|
||||
|
||||
2
.config/nextest.toml
Normal file
2
.config/nextest.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[profile.default]
|
||||
fail-fast = false
|
||||
27
.github/workflows/audit.yaml
vendored
Normal file
27
.github/workflows/audit.yaml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Audit
|
||||
|
||||
on: ["push", "pull_request"]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_TOOLCHAIN: 1.88.0
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
name: Audit
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||
|
||||
- name: Install cargo-audit
|
||||
run: cargo install cargo-audit
|
||||
|
||||
- name: Run security audit
|
||||
run: cargo audit
|
||||
53
.github/workflows/build.yaml
vendored
53
.github/workflows/build.yaml
vendored
@@ -1,13 +1,10 @@
|
||||
name: Build
|
||||
|
||||
on: [push]
|
||||
on: ["push", "pull_request"]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
RUST_TOOLCHAIN: 1.86.0
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build (${{ matrix.target }})
|
||||
@@ -18,15 +15,19 @@ jobs:
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
artifact_name: pacman
|
||||
toolchain: 1.88.0
|
||||
- os: macos-13
|
||||
target: x86_64-apple-darwin
|
||||
artifact_name: pacman
|
||||
toolchain: 1.88.0
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
artifact_name: pacman
|
||||
toolchain: 1.88.0
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-gnu
|
||||
artifact_name: pacman.exe
|
||||
toolchain: 1.88.0
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -36,7 +37,7 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||
toolchain: ${{ matrix.toolchain }}
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
@@ -92,13 +93,13 @@ jobs:
|
||||
uses: pyodide/setup-emsdk@v15
|
||||
with:
|
||||
version: 3.1.43
|
||||
actions-cache-folder: "emsdk-cache"
|
||||
actions-cache-folder: "emsdk-cache-b"
|
||||
|
||||
- name: Setup Rust (WASM32 Emscripten)
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
target: wasm32-unknown-emscripten
|
||||
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||
toolchain: 1.86.0 # we are unfortunately pinned to 1.86.0 for some reason, bulk-memory-opt related issues
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
@@ -110,8 +111,42 @@ jobs:
|
||||
run_install: true
|
||||
|
||||
- name: Build with Emscripten
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --target=wasm32-unknown-emscripten --release
|
||||
# Retry mechanism for Emscripten build - only retry on specific hash errors
|
||||
MAX_RETRIES=3
|
||||
RETRY_DELAY=30
|
||||
|
||||
for attempt in $(seq 1 $MAX_RETRIES); do
|
||||
echo "Build attempt $attempt of $MAX_RETRIES"
|
||||
|
||||
# Capture output and check for specific error while preserving real-time output
|
||||
if cargo build --target=wasm32-unknown-emscripten --release 2>&1 | tee /tmp/build_output.log; then
|
||||
echo "Build successful on attempt $attempt"
|
||||
break
|
||||
else
|
||||
echo "Build failed on attempt $attempt"
|
||||
|
||||
# Check if the failure was due to the specific hash error
|
||||
if grep -q "emcc: error: Unexpected hash:" /tmp/build_output.log; then
|
||||
echo "::warning::Detected 'emcc: error: Unexpected hash:' error - will retry (attempt $attempt of $MAX_RETRIES)"
|
||||
|
||||
if [ $attempt -eq $MAX_RETRIES ]; then
|
||||
echo "::error::All retry attempts failed. Exiting with error."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Waiting $RETRY_DELAY seconds before retry..."
|
||||
sleep $RETRY_DELAY
|
||||
|
||||
# Exponential backoff: double the delay for next attempt
|
||||
RETRY_DELAY=$((RETRY_DELAY * 2))
|
||||
else
|
||||
echo "Build failed but not due to hash error - not retrying"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Assemble
|
||||
run: |
|
||||
@@ -127,9 +162,11 @@ jobs:
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
|
||||
with:
|
||||
path: "./dist/"
|
||||
retention-days: 7
|
||||
|
||||
- name: Deploy
|
||||
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
57
.github/workflows/coverage.yaml
vendored
Normal file
57
.github/workflows/coverage.yaml
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
name: Coverage
|
||||
|
||||
on: ["push", "pull_request"]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_TOOLCHAIN: 1.86.0
|
||||
|
||||
jobs:
|
||||
coverage:
|
||||
name: Code Coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||
components: llvm-tools-preview
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Cache vcpkg
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: target/vcpkg
|
||||
key: A-vcpkg-${{ runner.os }}-${{ hashFiles('Cargo.toml', 'Cargo.lock') }}
|
||||
restore-keys: |
|
||||
A-vcpkg-${{ runner.os }}-
|
||||
|
||||
- name: Vcpkg Linux Dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libltdl-dev
|
||||
|
||||
- name: Vcpkg
|
||||
run: |
|
||||
cargo install cargo-vcpkg
|
||||
cargo vcpkg -v build
|
||||
|
||||
- uses: taiki-e/install-action@cargo-llvm-cov
|
||||
- uses: taiki-e/install-action@nextest
|
||||
|
||||
# Note: We manually link zlib. This should be synchronized with the flags set for Linux in .cargo/config.toml.
|
||||
- name: Generate coverage report
|
||||
run: |
|
||||
cargo llvm-cov --no-fail-fast --lcov --output-path lcov.info nextest
|
||||
|
||||
- name: Upload coverage to Coveralls
|
||||
uses: coverallsapp/github-action@v2
|
||||
with:
|
||||
files: ./lcov.info
|
||||
format: lcov
|
||||
allow-empty: false
|
||||
54
.github/workflows/test.yaml
vendored
Normal file
54
.github/workflows/test.yaml
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
name: Test
|
||||
|
||||
on: ["push", "pull_request"]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_TOOLCHAIN: 1.88.0
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Cache vcpkg
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: target/vcpkg
|
||||
key: A-vcpkg-${{ runner.os }}-${{ hashFiles('Cargo.toml', 'Cargo.lock') }}
|
||||
restore-keys: |
|
||||
A-vcpkg-${{ runner.os }}-
|
||||
|
||||
- name: Vcpkg Linux Dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libltdl-dev
|
||||
|
||||
- name: Vcpkg
|
||||
run: |
|
||||
cargo install cargo-vcpkg
|
||||
cargo vcpkg -v build
|
||||
|
||||
- uses: taiki-e/install-action@nextest
|
||||
|
||||
- name: Run nextest
|
||||
run: cargo nextest run --workspace
|
||||
|
||||
- name: Run clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt -- --check
|
||||
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -392,9 +392,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.11.0"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "spin_sleep"
|
||||
|
||||
@@ -17,14 +17,13 @@ pathfinding = "4.14"
|
||||
once_cell = "1.21.3"
|
||||
thiserror = "1.0"
|
||||
anyhow = "1.0"
|
||||
glam = "0.30.4"
|
||||
glam = { version = "0.30.4", features = [] }
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = "1.0.141"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
panic = "abort"
|
||||
panic-strategy = "abort"
|
||||
opt-level = "z"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies.winapi]
|
||||
|
||||
95
STORY.md
95
STORY.md
@@ -32,6 +32,7 @@ The problem is that much of this work was done for pure-Rust applications - and
|
||||
This requires a C++ WebAssembly compiler such as Emscripten; and it's a pain to get working.
|
||||
|
||||
Luckily though, someone else has done this before, and they fully documented it - [RuggRouge][ruggrouge].
|
||||
|
||||
- Built with Rust
|
||||
- Uses SDL2
|
||||
- Compiling for WebAssembly with Emscripten
|
||||
@@ -46,7 +47,6 @@ The issue presented with some keys never being sent to the application.
|
||||
To confirm, enter safe mode or switch to a different browser without said extensions.
|
||||
If the issue disappears, it's because of an extension in your browser stealing keys in a way that is incompatible with the batshit insanity of Emscripten.
|
||||
|
||||
|
||||
## A Long Break
|
||||
|
||||
After hitting a wall with an issue with Emscripten where the tab would freeze after switching tabs (making it into a background tab), I decided to take a break from the project. A couple months went by without anything going on.
|
||||
@@ -78,7 +78,7 @@ But this did help me narrow my search even more for a good example. I needed to
|
||||
|
||||
I found [one such repository](https://github.com/KyleMiles/Rust-SDL-Emscripten-Template/), and interestingly, it used `latest` Emscripten (not a specific target like 1.39.20), and was new enough (2 years old, but still new enough) to be relevant.
|
||||
|
||||
Even more interesting, it didn't use the `main` loop closure, but instead used Emscripten's *Asyncify* feature to handle the main loop.
|
||||
Even more interesting, it didn't use the `main` loop closure, but instead used Emscripten's _Asyncify_ feature to handle the main loop.
|
||||
|
||||
But, unlike my original project which called `std::thread::sleep` directly, it used bindings into Emscripten's functions like `emscripten_sleep`.
|
||||
|
||||
@@ -124,6 +124,7 @@ While working on the next extension of SDL2 for my test repository, SDL2-TTF had
|
||||
Luckily, I had a recently updated repository to copy off of, and the working fix was to lower the EMSDK version to `3.1.43`.
|
||||
|
||||
[Source](https://github.com/aelred/tetris/blob/0ad88153db1ca7962b42277504c0f7f9f3c675a9/tetris-sdl/src/main.rs#L34)
|
||||
|
||||
```rust
|
||||
static FONT_DATA: &[u8] = include_bytes!("../assets/TerminalVector.ttf");
|
||||
|
||||
@@ -176,7 +177,7 @@ But I also didn't want to include some big framework on this, like Astro, so I l
|
||||
|
||||
After fiddling and failing to find Hugo suitable, I stuck to plain HTML & the PostCSS method, which worked great. It's definitely not that fast for rapid development, but it works well enough.
|
||||
|
||||
The only thing I'm unsatisfied with is why `postcss-cli` wasn't working when executed from `pnpm`. It works just fine from `pnpx`, but it has to download and setup the whole package on *every single invocation*, which is super slow. And probably expensive, in the long run.
|
||||
The only thing I'm unsatisfied with is why `postcss-cli` wasn't working when executed from `pnpm`. It works just fine from `pnpx`, but it has to download and setup the whole package on _every single invocation_, which is super slow. And probably expensive, in the long run.
|
||||
|
||||
## Cross-platform Builds
|
||||
|
||||
@@ -253,6 +254,7 @@ After a couple attempts with various test commits, I couldn't find it, and just
|
||||
> Note: VCPKG is annoying to install, the executable provided by Visual Studio Community does not permit classic-mode usage, so you'll still need to clone and bootstrap VCPKG (instructions in the repository README).
|
||||
|
||||
As it happens, they were placed in
|
||||
|
||||
- `$VCPKG_ROOT\packages\sdl2-gfx_x64-windows-release\bin\SDL2_gfx.dll` and
|
||||
- `$VCPKG_ROOT\packages\sdl2-gfx_x64-windows-release\lib\SDL2_gfx.lib` respectively.
|
||||
|
||||
@@ -324,10 +326,95 @@ I was thinking of a github-pages artifact name that aligns with the others, but
|
||||
|
||||
Perhaps at the least I'll look into a 32-bit build for Windows, just for demonstration purposes.
|
||||
|
||||
## My Return to Pac-Man
|
||||
|
||||
It's been 15 months since I last touched the demo codebase, and much longer since I've touched the core Pac-Man project, and I got inspired to look back into it recently. I'm finally touching up on the story document, so if this reads a bit disjointed, that's why.
|
||||
|
||||
- I switched the dependency linking to use the internal statically-linked `vcpkg` feature, which is a lot easier to maintain. It's not perfect, but it's much better than the manual downloads and the dynamically linked `.dll` files I was doing before. With caching, it also tends to be far quicker.
|
||||
- I switched all of the commits to use conventional commit messages, which is easier to read and understand.
|
||||
- I integrated the demo project's emscripten workflow, updated sdl2 and started poking around in the project. I got into adding fonts, adding a reset button, a debug mode, score tracking, pellet consumption, etc.
|
||||
- I spent a lot of time working on the audio timing, getting it to work flawlessly and compare really well with the original Pac-Man; the sound is incredibly important to the game, so I wanted to get it right.
|
||||
|
||||
## Pathfinding and Tunnelling
|
||||
|
||||
Pathfinding was very easy to get working, although tunnelling was a bit more difficult, and unfortunately I never got it working with the way I was doing things at the time. A lot of issues were happening with trying to get the transition between the tunnels to work, I could only get Pac-Man to teleport from one tunnel to the other, but moving smoothly between them was nigh impossible.
|
||||
|
||||
I did however get pathfinding to work between the tunnels, which was very satisfying to see using the debug visuals.
|
||||
|
||||
I ended up using the `pathfinding` crate and it was a breeze to use.
|
||||
|
||||
## Atlas Tiles
|
||||
|
||||
When I was looking around for Pac-Man sprites, I kept coming across atlas images, and I had been noticing for some time how my sprites were not correctly sized, and some of them just didn't match the original Pac-Man. I had been spending a lot of time making this Pac-Man project as close to the original as possible, and I felt like if I didn't use the original sprites, I wasn't doing it justice.
|
||||
|
||||
This had me thinking about how asset loading was a real pain in this project, and how I wanted to look into atlas tiles.
|
||||
|
||||
The arguments for copying between a texture and a canvas/surface/texture were very obviously rigged to allow for this, given that you had to specify the source `Rect`, meaning you could target a specific area of the texture. Such as tiles on an atlas image.
|
||||
|
||||
It didn't take long for me to get it working, I chose an existing crate called `clutterd` which provided a CLI for building atlas images with an metadata file describing the positions and sizes of the tiles.
|
||||
|
||||
Doing so required a full re-work of the animation and texture system, and I ended up making a breakthrough on how I managed lifetimes: lifetime annotations were plaguing the codebase, literally everywhere, and it was super annoying to keep writing and dealing with them.
|
||||
|
||||
So, I ended up using `unsafe` to forcibly cast the lifetimes to `'static`, which was a bit of a gamble, but given that they essentially behave as `'static` in practice, there wasn't much risk as I see it. I might re-look into my understanding of lifetimes and this in the future, but for the time being, it's a good solution that makes the codebase far easier to work with.
|
||||
|
||||
## Cross-platform Builds
|
||||
|
||||
Since the original `rust-sdl2-emscripten` demo project had cross-platform builds, I was ready to get it working for this project. For the most part, it wasn't hard, things tended to click into place, but unfortunately, the `emscripten` os target and somehow, the `linux` os target were both failing.
|
||||
|
||||
I'm still not sure what exactly causes it, but `emscripten` strongly prefers to be built on 1.86 (1.88 does not work, 1.87 might though).
|
||||
Changing the toolchain to 1.86 fixed the issue when it was failing.
|
||||
|
||||
It did turn out though, that despite me getting the `emscripten` target building, it did not mean the application was functioning properly.
|
||||
|
||||
- Upon launch, it was immediately crashing due to issues with the audio subsystem; this was fixed with a simple increase to the audio buffer chunksize, apparently it has a minimum size of 256.
|
||||
- Then, it was failing due to issues with the main loop, referencing the `ASYNCIFY_STACK_SIZE` variable in `.cargo/config.toml`, asking for it to be increased. I really didn't like the idea of increasing it for whatever reason, so I ended up looking into the `emscripten_main_loop` method of looping again, but nothing worked all that well, just like the last time I tried. So I increased the variable, doubling it from the default of 4096 to 8192. Things immediately worked, and the browser build was working.
|
||||
|
||||
Linux however was a far more annoying task, as it was failing to due the `cargo-vcpkg` build step (which built the SDL2 libraries necessary for static linking and building the project). It was hard to pin down at first, but packages seemed to be failing due to system dependencies not being available, so after adding a couple `apt` packages to the steps, things started to work.
|
||||
|
||||
Eventually though, it kept failing at the `sdl2` package, which was failing to build due to the `libpng` package not being able to find a bunch of symbols related to `zlib`. Almost nothing was written about this online, except for one issue on GitHub which hadn't been updated in 2 years.
|
||||
|
||||
I won't lie, Gemini helped me out here, suggesting adding `"-C", "link-arg=-lz",` to the `rustflags` section of `.cargo/config.toml`.
|
||||
It seems like it moved the `zlib` library to the front of the link order, and things started magically working both locally and on the GitHub Actions runner.
|
||||
|
||||
I also added an ARM64 build for MacOS, which worked without any issues. Surprisingly, MacOS is the only platform that I've been able to get working without any issues. At least, I hope it's working; I don't really have a way to test it myself.
|
||||
|
||||
## Caching
|
||||
|
||||
I spent a bit of time after this improving the build process to take advantage of caching so that most builds would fly. The `cargo-vcpkg` was by far the most expensive step, and it unfortunately, despite being in the `target` directory (which is supposed to be cached by the `Swatinem/rust-cache@v2` action), was not being cached.
|
||||
|
||||
I played with the parameters for a bit before giving up and just manually adding a cache step to the workflow. It's expensive, uploading 300MB of artifact data to GitHub, but it works well, and I'm really doubtful it will change that much.
|
||||
|
||||
I also ended up improving the build process to use `cargo metadata` to get the package version, which means I could drop the `toml-cli` dependency and just use the `cargo` command + `jq` (which is already installed on the runner).
|
||||
|
||||
## Atlas Text
|
||||
|
||||
At some point, I wanted to use the original text from the game, so I created a text texture type for rendering text using the existing sprite atlas, which means I wasn't using the `ttf` feature at all. I'm stil unsure whether or not I'll use it, I might keep it because it seems like more hassle to remove it at this point. Perhaps I'll still use normal ttf fonts like Arial for debug-related displays, or maybe I'll create/use a custom font.
|
||||
|
||||
## Node Graph Positioning
|
||||
|
||||
After getting all this working, I was really excited to finally get closer to actually finishing the project. I felt like I had finally started checking a bunch of important boxes, so I started actually working on the 'ghost house' part of Pac-Man.
|
||||
|
||||
The ghost house is very different from the rest of the game as it doesn't render the tiles in the same way, on a static grid.
|
||||
|
||||
It's actually offset by 8 pixels, and the ghosts exit the house between two tiles, requiring a lot more customization and flexibility in my
|
||||
rendering system.
|
||||
|
||||
I spent a fair bit of time trying to implement hacks into this to get it working, but I eventually gave up after realizing that there's no solution here using my existing system.
|
||||
|
||||
I remembered how I was having trouble with the transition states between the two tunnels (still not resolved), and it felt quite similar to my current situation; the inflexibility of my integer grid system was the main cause of the issue.
|
||||
|
||||
I started thinking of different ways to approach movement, and realized that the Pac-Man and Ghost's movement is quite limited and simple like railroad tracks, like nodes on a graph. Both problems could be solved by switching to a graph - most of the maze would look like a grid, each cell connected to eachother.
|
||||
|
||||
By representing one's position as a distance from the start node towards an end node, I could achieve smooth linear movement between nodes
|
||||
that, for the most part, appears to use a cell-based grid, which also allowing more customized offsets.
|
||||
|
||||
The bigger downside was that I had to toss out almost all the existing code for the game, only keeping the audio and most of the texturing system, as well as the initialization code. It also meant I was using floating points for a lot of internal state, which is not ideal.
|
||||
|
||||
This ended up being okay though, as I was able to clean up a lot of gross code, and the system ended up being easier to work with by comparison.
|
||||
|
||||
[code-review-video]: https://www.youtube.com/watch?v=OKs_JewEeOo
|
||||
[code-review-thumbnail]: https://img.youtube.com/vi/OKs_JewEeOo/hqdefault.jpg
|
||||
[fighting-lifetimes-1]: https://devcry.heiho.net/html/2022/20220709-rust-and-sdl2-fighting-with-lifetimes.html
|
||||
[fighting-lifetimes-2]: https://devcry.heiho.net/html/2022/20220716-rust-and-sdl2-fighting-with-lifetimes-2.html
|
||||
[fighting-lifetimes-3]: https://devcry.heiho.net/html/2022/20220724-rust-and-sdl2-fighting-with-lifetimes-3.html
|
||||
[ruggrogue]: https://tung.github.io/ruggrogue/
|
||||
[ruggrogue]: https://tung.github.io/ruggrogue/
|
||||
|
||||
152
src/app.rs
Normal file
152
src/app.rs
Normal file
@@ -0,0 +1,152 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use sdl2::event::{Event, WindowEvent};
|
||||
use sdl2::keyboard::Keycode;
|
||||
use sdl2::render::{Canvas, ScaleMode, Texture, TextureCreator};
|
||||
use sdl2::video::{Window, WindowContext};
|
||||
use sdl2::EventPump;
|
||||
use tracing::{error, event};
|
||||
|
||||
use crate::constants::{CANVAS_SIZE, LOOP_TIME, SCALE};
|
||||
use crate::game::Game;
|
||||
|
||||
#[cfg(target_os = "emscripten")]
|
||||
use crate::emscripten;
|
||||
|
||||
#[cfg(not(target_os = "emscripten"))]
|
||||
fn sleep(value: Duration) {
|
||||
spin_sleep::sleep(value);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "emscripten")]
|
||||
fn sleep(value: Duration) {
|
||||
emscripten::emscripten::sleep(value.as_millis() as u32);
|
||||
}
|
||||
|
||||
pub struct App<'a> {
|
||||
game: Game,
|
||||
canvas: Canvas<Window>,
|
||||
event_pump: EventPump,
|
||||
backbuffer: Texture<'a>,
|
||||
paused: bool,
|
||||
last_tick: Instant,
|
||||
}
|
||||
|
||||
impl<'a> App<'a> {
|
||||
pub fn new() -> Result<Self> {
|
||||
let sdl_context = sdl2::init().map_err(|e| anyhow!(e))?;
|
||||
let video_subsystem = sdl_context.video().map_err(|e| anyhow!(e))?;
|
||||
let audio_subsystem = sdl_context.audio().map_err(|e| anyhow!(e))?;
|
||||
let ttf_context = sdl2::ttf::init().map_err(|e| anyhow!(e.to_string()))?;
|
||||
|
||||
let window = video_subsystem
|
||||
.window(
|
||||
"Pac-Man",
|
||||
(CANVAS_SIZE.x as f32 * SCALE).round() as u32,
|
||||
(CANVAS_SIZE.y as f32 * SCALE).round() as u32,
|
||||
)
|
||||
.resizable()
|
||||
.position_centered()
|
||||
.build()?;
|
||||
|
||||
let mut canvas = window.into_canvas().build()?;
|
||||
canvas.set_logical_size(CANVAS_SIZE.x, CANVAS_SIZE.y)?;
|
||||
|
||||
let texture_creator_static: &'static TextureCreator<WindowContext> = Box::leak(Box::new(canvas.texture_creator()));
|
||||
|
||||
let mut game = Game::new(texture_creator_static, &ttf_context, &audio_subsystem);
|
||||
game.audio.set_mute(cfg!(debug_assertions));
|
||||
|
||||
let mut backbuffer = texture_creator_static.create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y)?;
|
||||
backbuffer.set_scale_mode(ScaleMode::Nearest);
|
||||
|
||||
let event_pump = sdl_context.event_pump().map_err(|e| anyhow!(e))?;
|
||||
|
||||
// Initial draw
|
||||
game.draw(&mut canvas, &mut backbuffer)?;
|
||||
game.present_backbuffer(&mut canvas, &backbuffer)?;
|
||||
|
||||
Ok(Self {
|
||||
game,
|
||||
canvas,
|
||||
event_pump,
|
||||
backbuffer,
|
||||
paused: false,
|
||||
last_tick: Instant::now(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn run(&mut self) -> bool {
|
||||
{
|
||||
let start = Instant::now();
|
||||
|
||||
for event in self.event_pump.poll_iter() {
|
||||
match event {
|
||||
Event::Window { win_event, .. } => match win_event {
|
||||
WindowEvent::Hidden => {
|
||||
event!(tracing::Level::DEBUG, "Window hidden");
|
||||
}
|
||||
WindowEvent::Shown => {
|
||||
event!(tracing::Level::DEBUG, "Window shown");
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Event::Quit { .. }
|
||||
| Event::KeyDown {
|
||||
keycode: Some(Keycode::Escape) | Some(Keycode::Q),
|
||||
..
|
||||
} => {
|
||||
event!(tracing::Level::INFO, "Exit requested. Exiting...");
|
||||
return false;
|
||||
}
|
||||
Event::KeyDown {
|
||||
keycode: Some(Keycode::P),
|
||||
..
|
||||
} => {
|
||||
self.paused = !self.paused;
|
||||
event!(tracing::Level::INFO, "{}", if self.paused { "Paused" } else { "Unpaused" });
|
||||
}
|
||||
Event::KeyDown {
|
||||
keycode: Some(Keycode::Space),
|
||||
..
|
||||
} => {
|
||||
self.game.debug_mode = !self.game.debug_mode;
|
||||
}
|
||||
Event::KeyDown { keycode, .. } => {
|
||||
self.game.keyboard_event(keycode.unwrap());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let dt = self.last_tick.elapsed().as_secs_f32();
|
||||
self.last_tick = Instant::now();
|
||||
|
||||
if !self.paused {
|
||||
self.game.tick(dt);
|
||||
if let Err(e) = self.game.draw(&mut self.canvas, &mut self.backbuffer) {
|
||||
error!("Failed to draw game: {e}");
|
||||
}
|
||||
if let Err(e) = self.game.present_backbuffer(&mut self.canvas, &self.backbuffer) {
|
||||
error!("Failed to present backbuffer: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
if start.elapsed() < LOOP_TIME {
|
||||
let time = LOOP_TIME.saturating_sub(start.elapsed());
|
||||
if time != Duration::ZERO {
|
||||
sleep(time);
|
||||
}
|
||||
} else {
|
||||
event!(
|
||||
tracing::Level::WARN,
|
||||
"Game loop behind schedule by: {:?}",
|
||||
start.elapsed() - LOOP_TIME
|
||||
);
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
#![allow(dead_code)]
|
||||
//! Cross-platform asset loading abstraction.
|
||||
//! On desktop, assets are embedded using include_bytes!; on Emscripten, assets are loaded from the filesystem.
|
||||
|
||||
@@ -5,7 +6,6 @@ use std::borrow::Cow;
|
||||
use std::io;
|
||||
use thiserror::Error;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AssetError {
|
||||
#[error("IO error: {0}")]
|
||||
@@ -22,7 +22,6 @@ pub enum Asset {
|
||||
Wav2,
|
||||
Wav3,
|
||||
Wav4,
|
||||
FontKonami,
|
||||
Atlas,
|
||||
AtlasJson,
|
||||
// Add more as needed
|
||||
@@ -37,7 +36,6 @@ impl Asset {
|
||||
Wav2 => "sound/waka/2.ogg",
|
||||
Wav3 => "sound/waka/3.ogg",
|
||||
Wav4 => "sound/waka/4.ogg",
|
||||
FontKonami => "konami.ttf",
|
||||
Atlas => "atlas.png",
|
||||
AtlasJson => "atlas.json",
|
||||
}
|
||||
@@ -54,7 +52,6 @@ mod imp {
|
||||
Asset::Wav2 => Cow::Borrowed(include_bytes!("../assets/game/sound/waka/2.ogg")),
|
||||
Asset::Wav3 => Cow::Borrowed(include_bytes!("../assets/game/sound/waka/3.ogg")),
|
||||
Asset::Wav4 => Cow::Borrowed(include_bytes!("../assets/game/sound/waka/4.ogg")),
|
||||
Asset::FontKonami => Cow::Borrowed(include_bytes!("../assets/game/konami.ttf")),
|
||||
Asset::Atlas => Cow::Borrowed(include_bytes!("../assets/game/atlas.png")),
|
||||
Asset::AtlasJson => Cow::Borrowed(include_bytes!("../assets/game/atlas.json")),
|
||||
}
|
||||
|
||||
269
src/audio.rs
269
src/audio.rs
@@ -10,23 +10,46 @@ const SOUND_ASSETS: [Asset; 4] = [Asset::Wav1, Asset::Wav2, Asset::Wav3, Asset::
|
||||
/// The audio system for the game.
|
||||
///
|
||||
/// This struct is responsible for initializing the audio device, loading sounds,
|
||||
/// and playing them.
|
||||
/// and playing them. If audio fails to initialize, it will be disabled and all
|
||||
/// functions will silently do nothing.
|
||||
#[allow(dead_code)]
|
||||
pub struct Audio {
|
||||
_mixer_context: mixer::Sdl2MixerContext,
|
||||
_mixer_context: Option<mixer::Sdl2MixerContext>,
|
||||
sounds: Vec<Chunk>,
|
||||
next_sound_index: usize,
|
||||
muted: bool,
|
||||
disabled: bool,
|
||||
}
|
||||
|
||||
impl Default for Audio {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Audio {
|
||||
/// Creates a new `Audio` instance.
|
||||
///
|
||||
/// If audio fails to initialize, the audio system will be disabled and
|
||||
/// all functions will silently do nothing.
|
||||
pub fn new() -> Self {
|
||||
let frequency = 44100;
|
||||
let format = DEFAULT_FORMAT;
|
||||
let channels = 4;
|
||||
let chunk_size = 256; // 256 is minimum for emscripten
|
||||
|
||||
mixer::open_audio(frequency, format, 1, chunk_size).expect("Failed to open audio");
|
||||
// Try to open audio, but don't panic if it fails
|
||||
if let Err(e) = mixer::open_audio(frequency, format, 1, chunk_size) {
|
||||
tracing::warn!("Failed to open audio: {}. Audio will be disabled.", e);
|
||||
return Self {
|
||||
_mixer_context: None,
|
||||
sounds: Vec::new(),
|
||||
next_sound_index: 0,
|
||||
muted: false,
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
mixer::allocate_channels(channels);
|
||||
|
||||
// set channel volume
|
||||
@@ -34,30 +57,72 @@ impl Audio {
|
||||
mixer::Channel(i).set_volume(32);
|
||||
}
|
||||
|
||||
let mixer_context = mixer::init(InitFlag::OGG).expect("Failed to initialize SDL2_mixer");
|
||||
// Try to initialize mixer, but don't panic if it fails
|
||||
let mixer_context = match mixer::init(InitFlag::OGG) {
|
||||
Ok(ctx) => ctx,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to initialize SDL2_mixer: {}. Audio will be disabled.", e);
|
||||
return Self {
|
||||
_mixer_context: None,
|
||||
sounds: Vec::new(),
|
||||
next_sound_index: 0,
|
||||
muted: false,
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let sounds: Vec<Chunk> = SOUND_ASSETS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, asset)| {
|
||||
let data = get_asset_bytes(*asset).expect("Failed to load sound asset");
|
||||
let rwops = RWops::from_bytes(&data).unwrap_or_else(|_| panic!("Failed to create RWops for sound {}", i + 1));
|
||||
rwops
|
||||
.load_wav()
|
||||
.unwrap_or_else(|_| panic!("Failed to load sound {} from asset API", i + 1))
|
||||
})
|
||||
.collect();
|
||||
// Try to load sounds, but don't panic if any fail
|
||||
let mut sounds = Vec::new();
|
||||
for (i, asset) in SOUND_ASSETS.iter().enumerate() {
|
||||
match get_asset_bytes(*asset) {
|
||||
Ok(data) => match RWops::from_bytes(&data) {
|
||||
Ok(rwops) => match rwops.load_wav() {
|
||||
Ok(chunk) => sounds.push(chunk),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to load sound {} from asset API: {}", i + 1, e);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to create RWops for sound {}: {}", i + 1, e);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to load sound asset {}: {}", i + 1, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no sounds loaded successfully, disable audio
|
||||
if sounds.is_empty() {
|
||||
tracing::warn!("No sounds loaded successfully. Audio will be disabled.");
|
||||
return Self {
|
||||
_mixer_context: Some(mixer_context),
|
||||
sounds: Vec::new(),
|
||||
next_sound_index: 0,
|
||||
muted: false,
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
Audio {
|
||||
_mixer_context: mixer_context,
|
||||
_mixer_context: Some(mixer_context),
|
||||
sounds,
|
||||
next_sound_index: 0,
|
||||
muted: false,
|
||||
disabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays the "eat" sound effect.
|
||||
///
|
||||
/// If audio is disabled or muted, this function does nothing.
|
||||
#[allow(dead_code)]
|
||||
pub fn eat(&mut self) {
|
||||
if self.disabled || self.muted || self.sounds.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(chunk) = self.sounds.get(self.next_sound_index) {
|
||||
match mixer::Channel(0).play(chunk, 0) {
|
||||
Ok(channel) => {
|
||||
@@ -72,16 +137,180 @@ impl Audio {
|
||||
}
|
||||
|
||||
/// Instantly mute or unmute all channels.
|
||||
///
|
||||
/// If audio is disabled, this function does nothing.
|
||||
pub fn set_mute(&mut self, mute: bool) {
|
||||
let channels = 4;
|
||||
let volume = if mute { 0 } else { 32 };
|
||||
for i in 0..channels {
|
||||
mixer::Channel(i).set_volume(volume);
|
||||
if !self.disabled {
|
||||
let channels = 4;
|
||||
let volume = if mute { 0 } else { 32 };
|
||||
for i in 0..channels {
|
||||
mixer::Channel(i).set_volume(volume);
|
||||
}
|
||||
}
|
||||
|
||||
self.muted = mute;
|
||||
}
|
||||
|
||||
/// Returns `true` if the audio is muted.
|
||||
pub fn is_muted(&self) -> bool {
|
||||
self.muted
|
||||
}
|
||||
|
||||
/// Returns `true` if the audio system is disabled.
|
||||
pub fn is_disabled(&self) -> bool {
|
||||
self.disabled
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Once;
|
||||
|
||||
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);
|
||||
|
||||
// Audio might be disabled if initialization failed
|
||||
if !audio.is_disabled() {
|
||||
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();
|
||||
|
||||
// Skip test if audio is disabled
|
||||
if audio.is_disabled() {
|
||||
eprintln!("Skipping sound rotation test due to disabled audio");
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// Skip test if audio is disabled
|
||||
if audio.is_disabled() {
|
||||
eprintln!("Skipping sound index bounds test due to disabled audio");
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Audio might be disabled if initialization failed
|
||||
if !audio.is_disabled() {
|
||||
assert_eq!(audio.sounds.len(), 4);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_audio_disabled_state() {
|
||||
if let Err(_) = init_sdl() {
|
||||
eprintln!("Skipping SDL2-dependent tests due to initialization failure");
|
||||
return;
|
||||
}
|
||||
|
||||
// Test that disabled audio doesn't crash when calling functions
|
||||
let mut audio = Audio::new();
|
||||
|
||||
// These should not panic even if audio is disabled
|
||||
audio.eat();
|
||||
audio.set_mute(true);
|
||||
audio.set_mute(false);
|
||||
|
||||
// Test that we can check the disabled state
|
||||
let _is_disabled = audio.is_disabled();
|
||||
}
|
||||
}
|
||||
|
||||
269
src/constants.rs
269
src/constants.rs
@@ -1,7 +1,11 @@
|
||||
//! This module contains all the constants used in the game.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use glam::UVec2;
|
||||
|
||||
pub const LOOP_TIME: Duration = Duration::from_nanos((1_000_000_000.0 / 60.0) as u64);
|
||||
|
||||
/// The size of each cell, in pixels.
|
||||
pub const CELL_SIZE: u32 = 8;
|
||||
/// The size of the game board, in cells.
|
||||
@@ -11,9 +15,16 @@ pub const BOARD_CELL_SIZE: UVec2 = UVec2::new(28, 31);
|
||||
pub const SCALE: f32 = 2.6;
|
||||
|
||||
/// The offset of the game board from the top-left corner of the window, in cells.
|
||||
pub const BOARD_OFFSET: UVec2 = UVec2::new(0, 0);
|
||||
pub const BOARD_CELL_OFFSET: UVec2 = UVec2::new(0, 3);
|
||||
/// The offset of the game board from the top-left corner of the window, in pixels.
|
||||
pub const BOARD_PIXEL_OFFSET: UVec2 = UVec2::new(BOARD_CELL_OFFSET.x * CELL_SIZE, BOARD_CELL_OFFSET.y * CELL_SIZE);
|
||||
/// The size of the game board, in pixels.
|
||||
pub const BOARD_PIXEL_SIZE: UVec2 = UVec2::new(BOARD_CELL_SIZE.x * CELL_SIZE, BOARD_CELL_SIZE.y * CELL_SIZE);
|
||||
/// The size of the canvas, in pixels.
|
||||
pub const CANVAS_SIZE: UVec2 = UVec2::new(
|
||||
(BOARD_CELL_SIZE.x + BOARD_CELL_OFFSET.x) * CELL_SIZE,
|
||||
(BOARD_CELL_SIZE.y + BOARD_CELL_OFFSET.y) * CELL_SIZE,
|
||||
);
|
||||
|
||||
/// An enum representing the different types of tiles on the map.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
@@ -32,58 +43,6 @@ pub enum MapTile {
|
||||
Tunnel,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[repr(u8)]
|
||||
pub enum FruitType {
|
||||
Cherry,
|
||||
Strawberry,
|
||||
Orange,
|
||||
Apple,
|
||||
Melon,
|
||||
Galaxian,
|
||||
Bell,
|
||||
Key,
|
||||
}
|
||||
|
||||
impl FruitType {
|
||||
pub const ALL: [FruitType; 8] = [
|
||||
FruitType::Cherry,
|
||||
FruitType::Strawberry,
|
||||
FruitType::Orange,
|
||||
FruitType::Apple,
|
||||
FruitType::Melon,
|
||||
FruitType::Galaxian,
|
||||
FruitType::Bell,
|
||||
FruitType::Key,
|
||||
];
|
||||
|
||||
pub fn score(self) -> u32 {
|
||||
match self {
|
||||
FruitType::Cherry => 100,
|
||||
FruitType::Strawberry => 300,
|
||||
FruitType::Orange => 500,
|
||||
FruitType::Apple => 700,
|
||||
FruitType::Melon => 1000,
|
||||
FruitType::Galaxian => 2000,
|
||||
FruitType::Bell => 3000,
|
||||
FruitType::Key => 5000,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn index(self) -> usize {
|
||||
match self {
|
||||
FruitType::Cherry => 0,
|
||||
FruitType::Strawberry => 1,
|
||||
FruitType::Orange => 2,
|
||||
FruitType::Apple => 3,
|
||||
FruitType::Melon => 4,
|
||||
FruitType::Galaxian => 5,
|
||||
FruitType::Bell => 6,
|
||||
FruitType::Key => 7,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The raw layout of the game board, as a 2D array of characters.
|
||||
pub const RAW_BOARD: [&str; BOARD_CELL_SIZE.y as usize] = [
|
||||
"############################",
|
||||
@@ -97,11 +56,11 @@ pub const RAW_BOARD: [&str; BOARD_CELL_SIZE.y as usize] = [
|
||||
"#......##....##....##......#",
|
||||
"######.##### ## #####.######",
|
||||
" #.##### ## #####.# ",
|
||||
" #.## 1 ##.# ",
|
||||
" #.## ###==### ##.# ",
|
||||
"######.## # # ##.######",
|
||||
"T . #2 3 4 # . T",
|
||||
"######.## # # ##.######",
|
||||
" #.## == ##.# ",
|
||||
" #.## ######## ##.# ",
|
||||
"######.## ######## ##.######",
|
||||
"T . ######## . T",
|
||||
"######.## ######## ##.######",
|
||||
" #.## ######## ##.# ",
|
||||
" #.## ##.# ",
|
||||
" #.## ######## ##.# ",
|
||||
@@ -118,3 +77,197 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
73
src/debug.rs
73
src/debug.rs
@@ -1,73 +0,0 @@
|
||||
//! Debug rendering utilities for Pac-Man.
|
||||
use crate::{
|
||||
constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE},
|
||||
entity::blinky::Blinky,
|
||||
map::Map,
|
||||
};
|
||||
use glam::{IVec2, UVec2};
|
||||
use sdl2::{pixels::Color, render::Canvas, video::Window};
|
||||
|
||||
#[derive(PartialEq, Eq, Clone, Copy)]
|
||||
pub enum DebugMode {
|
||||
None,
|
||||
Grid,
|
||||
Pathfinding,
|
||||
ValidPositions,
|
||||
}
|
||||
|
||||
pub struct DebugRenderer;
|
||||
|
||||
impl DebugRenderer {
|
||||
pub fn draw_cell(canvas: &mut Canvas<Window>, _map: &Map, cell: UVec2, color: Color) {
|
||||
let position = Map::cell_to_pixel(cell);
|
||||
canvas.set_draw_color(color);
|
||||
canvas
|
||||
.draw_rect(sdl2::rect::Rect::new(position.x, position.y, CELL_SIZE, CELL_SIZE))
|
||||
.expect("Could not draw rectangle");
|
||||
}
|
||||
|
||||
pub fn draw_debug_grid(canvas: &mut Canvas<Window>, map: &Map, pacman_cell: UVec2) {
|
||||
for x in 0..BOARD_CELL_SIZE.x {
|
||||
for y in 0..BOARD_CELL_SIZE.y {
|
||||
let tile = map.get_tile(IVec2::new(x as i32, y as i32)).unwrap_or(MapTile::Empty);
|
||||
let cell = UVec2::new(x, y);
|
||||
let mut color = None;
|
||||
if cell == pacman_cell {
|
||||
Self::draw_cell(canvas, map, cell, Color::CYAN);
|
||||
} else {
|
||||
color = match tile {
|
||||
MapTile::Empty => None,
|
||||
MapTile::Wall => Some(Color::BLUE),
|
||||
MapTile::Pellet => Some(Color::RED),
|
||||
MapTile::PowerPellet => Some(Color::MAGENTA),
|
||||
MapTile::StartingPosition(_) => Some(Color::GREEN),
|
||||
MapTile::Tunnel => Some(Color::CYAN),
|
||||
};
|
||||
}
|
||||
if let Some(color) = color {
|
||||
Self::draw_cell(canvas, map, cell, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw_next_cell(canvas: &mut Canvas<Window>, map: &Map, next_cell: UVec2) {
|
||||
Self::draw_cell(canvas, map, next_cell, Color::YELLOW);
|
||||
}
|
||||
|
||||
pub fn draw_valid_positions(canvas: &mut Canvas<Window>, map: &mut Map) {
|
||||
let valid_positions_vec = map.get_valid_playable_positions().clone();
|
||||
for &pos in &valid_positions_vec {
|
||||
Self::draw_cell(canvas, map, pos, Color::RGB(255, 140, 0));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw_pathfinding(canvas: &mut Canvas<Window>, blinky: &Blinky, map: &Map) {
|
||||
let target = blinky.get_target_tile();
|
||||
if let Some((path, _)) = blinky.get_path_to_target(target.as_uvec2()) {
|
||||
for pos in &path {
|
||||
Self::draw_cell(canvas, map, *pos, Color::YELLOW);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::entity::direction::Direction;
|
||||
use crate::entity::ghost::{Ghost, GhostMode, GhostType};
|
||||
use crate::entity::pacman::Pacman;
|
||||
use crate::entity::{Entity, Moving, Renderable, StaticEntity};
|
||||
use crate::map::Map;
|
||||
use crate::texture::sprite::SpriteAtlas;
|
||||
use anyhow::Result;
|
||||
use glam::{IVec2, UVec2};
|
||||
use sdl2::render::WindowCanvas;
|
||||
|
||||
pub struct Blinky {
|
||||
ghost: Ghost,
|
||||
}
|
||||
|
||||
impl Blinky {
|
||||
pub fn new(
|
||||
starting_position: UVec2,
|
||||
atlas: Rc<RefCell<SpriteAtlas>>,
|
||||
map: Rc<RefCell<Map>>,
|
||||
pacman: Rc<RefCell<Pacman>>,
|
||||
) -> Blinky {
|
||||
Blinky {
|
||||
ghost: Ghost::new(GhostType::Blinky, starting_position, atlas, map, pacman),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets Blinky's chase target - directly targets Pac-Man's current position
|
||||
pub fn get_chase_target(&self) -> IVec2 {
|
||||
let pacman = self.ghost.pacman.borrow();
|
||||
let cell = pacman.base().cell_position;
|
||||
IVec2::new(cell.x as i32, cell.y as i32)
|
||||
}
|
||||
|
||||
pub fn set_mode(&mut self, mode: GhostMode) {
|
||||
self.ghost.set_mode(mode);
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
self.ghost.tick();
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for Blinky {
|
||||
fn base(&self) -> &StaticEntity {
|
||||
self.ghost.base.base()
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for Blinky {
|
||||
fn render(&mut self, canvas: &mut WindowCanvas) -> Result<()> {
|
||||
self.ghost.render(canvas)
|
||||
}
|
||||
}
|
||||
|
||||
impl Moving for Blinky {
|
||||
fn tick_movement(&mut self) {
|
||||
self.ghost.tick_movement();
|
||||
}
|
||||
fn update_cell_position(&mut self) {
|
||||
self.ghost.update_cell_position();
|
||||
}
|
||||
fn next_cell(&self, direction: Option<Direction>) -> IVec2 {
|
||||
self.ghost.next_cell(direction)
|
||||
}
|
||||
fn is_wall_ahead(&self, direction: Option<Direction>) -> bool {
|
||||
self.ghost.is_wall_ahead(direction)
|
||||
}
|
||||
fn handle_tunnel(&mut self) -> bool {
|
||||
self.ghost.handle_tunnel()
|
||||
}
|
||||
fn is_grid_aligned(&self) -> bool {
|
||||
self.ghost.is_grid_aligned()
|
||||
}
|
||||
fn set_direction_if_valid(&mut self, new_direction: Direction) -> bool {
|
||||
self.ghost.set_direction_if_valid(new_direction)
|
||||
}
|
||||
}
|
||||
|
||||
// Allow direct access to ghost fields
|
||||
impl std::ops::Deref for Blinky {
|
||||
type Target = Ghost;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.ghost
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::DerefMut for Blinky {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.ghost
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
//! This module defines the `Direction` enum, which is used to represent the
|
||||
//! direction of an entity.
|
||||
use glam::IVec2;
|
||||
use sdl2::keyboard::Keycode;
|
||||
|
||||
/// An enum representing the direction of an entity.
|
||||
#[derive(Debug, Copy, Clone, PartialEq)]
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum Direction {
|
||||
Up,
|
||||
Down,
|
||||
@@ -13,48 +9,93 @@ pub enum Direction {
|
||||
}
|
||||
|
||||
impl Direction {
|
||||
/// Returns the angle of the direction in degrees.
|
||||
pub fn angle(&self) -> f64 {
|
||||
match self {
|
||||
Direction::Right => 0f64,
|
||||
Direction::Down => 90f64,
|
||||
Direction::Left => 180f64,
|
||||
Direction::Up => 270f64,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the offset of the direction as a tuple of (x, y).
|
||||
pub fn offset(&self) -> IVec2 {
|
||||
match self {
|
||||
Direction::Right => IVec2::new(1, 0),
|
||||
Direction::Down => IVec2::new(0, 1),
|
||||
Direction::Left => IVec2::new(-1, 0),
|
||||
Direction::Up => IVec2::new(0, -1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the opposite direction.
|
||||
pub fn opposite(&self) -> Direction {
|
||||
match self {
|
||||
Direction::Right => Direction::Left,
|
||||
Direction::Up => Direction::Down,
|
||||
Direction::Down => Direction::Up,
|
||||
Direction::Left => Direction::Right,
|
||||
Direction::Up => Direction::Down,
|
||||
Direction::Right => Direction::Left,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a `Direction` from a `Keycode`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `keycode` - The keycode to convert.
|
||||
pub fn from_keycode(keycode: Keycode) -> Option<Direction> {
|
||||
match keycode {
|
||||
Keycode::D | Keycode::Right => Some(Direction::Right),
|
||||
Keycode::A | Keycode::Left => Some(Direction::Left),
|
||||
Keycode::W | Keycode::Up => Some(Direction::Up),
|
||||
Keycode::S | Keycode::Down => Some(Direction::Down),
|
||||
_ => None,
|
||||
pub fn as_ivec2(&self) -> IVec2 {
|
||||
(*self).into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Direction> for IVec2 {
|
||||
fn from(dir: Direction) -> Self {
|
||||
match dir {
|
||||
Direction::Up => -IVec2::Y,
|
||||
Direction::Down => IVec2::Y,
|
||||
Direction::Left => -IVec2::X,
|
||||
Direction::Right => IVec2::X,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const DIRECTIONS: [Direction; 4] = [Direction::Up, Direction::Down, Direction::Left, Direction::Right];
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
//! Edible entity for Pac-Man: pellets, power pellets, and fruits.
|
||||
use crate::constants::{FruitType, MapTile, BOARD_CELL_SIZE};
|
||||
use crate::entity::{Entity, Renderable, StaticEntity};
|
||||
use crate::map::Map;
|
||||
use crate::texture::animated::AnimatedTexture;
|
||||
use crate::texture::blinking::BlinkingTexture;
|
||||
use anyhow::Result;
|
||||
use glam::{IVec2, UVec2};
|
||||
use sdl2::render::WindowCanvas;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EdibleKind {
|
||||
Pellet,
|
||||
PowerPellet,
|
||||
Fruit(FruitType),
|
||||
}
|
||||
|
||||
pub enum EdibleSprite {
|
||||
Pellet(AnimatedTexture),
|
||||
PowerPellet(BlinkingTexture),
|
||||
}
|
||||
|
||||
pub struct Edible {
|
||||
pub base: StaticEntity,
|
||||
pub kind: EdibleKind,
|
||||
pub sprite: EdibleSprite,
|
||||
}
|
||||
|
||||
impl Edible {
|
||||
pub fn new_pellet(cell_position: UVec2, sprite: AnimatedTexture) -> Self {
|
||||
let pixel_position = Map::cell_to_pixel(cell_position);
|
||||
Edible {
|
||||
base: StaticEntity::new(pixel_position, cell_position),
|
||||
kind: EdibleKind::Pellet,
|
||||
sprite: EdibleSprite::Pellet(sprite),
|
||||
}
|
||||
}
|
||||
pub fn new_power_pellet(cell_position: UVec2, sprite: BlinkingTexture) -> Self {
|
||||
let pixel_position = Map::cell_to_pixel(cell_position);
|
||||
Edible {
|
||||
base: StaticEntity::new(pixel_position, cell_position),
|
||||
kind: EdibleKind::PowerPellet,
|
||||
sprite: EdibleSprite::PowerPellet(sprite),
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks collision with Pac-Man (or any entity)
|
||||
pub fn collide(&self, pacman: &dyn Entity) -> bool {
|
||||
self.base.cell_position == pacman.base().cell_position
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for Edible {
|
||||
fn base(&self) -> &StaticEntity {
|
||||
&self.base
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for Edible {
|
||||
fn render(&mut self, canvas: &mut WindowCanvas) -> Result<()> {
|
||||
let pos = self.base.pixel_position;
|
||||
let dest = match &mut self.sprite {
|
||||
EdibleSprite::Pellet(sprite) => {
|
||||
let mut tile = sprite.current_tile();
|
||||
let x = pos.x + ((crate::constants::CELL_SIZE as i32 - tile.size.x as i32) / 2);
|
||||
let y = pos.y + ((crate::constants::CELL_SIZE as i32 - tile.size.y as i32) / 2);
|
||||
sdl2::rect::Rect::new(x, y, tile.size.x as u32, tile.size.y as u32)
|
||||
}
|
||||
EdibleSprite::PowerPellet(sprite) => {
|
||||
let mut tile = sprite.animation.current_tile();
|
||||
let x = pos.x + ((crate::constants::CELL_SIZE as i32 - tile.size.x as i32) / 2);
|
||||
let y = pos.y + ((crate::constants::CELL_SIZE as i32 - tile.size.y as i32) / 2);
|
||||
sdl2::rect::Rect::new(x, y, tile.size.x as u32, tile.size.y as u32)
|
||||
}
|
||||
};
|
||||
|
||||
match &mut self.sprite {
|
||||
EdibleSprite::Pellet(sprite) => sprite.render(canvas, dest),
|
||||
EdibleSprite::PowerPellet(sprite) => sprite.render(canvas, dest),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstruct all edibles from the original map layout
|
||||
pub fn reconstruct_edibles(
|
||||
map: Rc<RefCell<Map>>,
|
||||
pellet_sprite: AnimatedTexture,
|
||||
power_pellet_sprite: BlinkingTexture,
|
||||
_fruit_sprite: AnimatedTexture,
|
||||
) -> Vec<Edible> {
|
||||
let mut edibles = Vec::new();
|
||||
for x in 0..BOARD_CELL_SIZE.x {
|
||||
for y in 0..BOARD_CELL_SIZE.y {
|
||||
let tile = map.borrow().get_tile(IVec2::new(x as i32, y as i32));
|
||||
match tile {
|
||||
Some(MapTile::Pellet) => {
|
||||
edibles.push(Edible::new_pellet(UVec2::new(x, y), pellet_sprite.clone()));
|
||||
}
|
||||
Some(MapTile::PowerPellet) => {
|
||||
edibles.push(Edible::new_power_pellet(UVec2::new(x, y), power_pellet_sprite.clone()));
|
||||
}
|
||||
// Fruits can be added here if you have fruit positions
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
edibles
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
use rand::rngs::SmallRng;
|
||||
use rand::Rng;
|
||||
use rand::SeedableRng;
|
||||
|
||||
use crate::constants::MapTile;
|
||||
use crate::constants::BOARD_CELL_SIZE;
|
||||
use crate::entity::direction::Direction;
|
||||
use crate::entity::pacman::Pacman;
|
||||
use crate::entity::speed::SimpleTickModulator;
|
||||
use crate::entity::{Entity, MovableEntity, Moving, Renderable};
|
||||
use crate::map::Map;
|
||||
use crate::texture::{
|
||||
animated::AnimatedTexture, blinking::BlinkingTexture, directional::DirectionalAnimatedTexture, get_atlas_tile,
|
||||
sprite::SpriteAtlas,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use glam::{IVec2, UVec2};
|
||||
use sdl2::pixels::Color;
|
||||
use sdl2::render::WindowCanvas;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// The different modes a ghost can be in
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum GhostMode {
|
||||
/// Chase mode - ghost actively pursues Pac-Man using its unique strategy
|
||||
Chase,
|
||||
/// Scatter mode - ghost heads to its home corner
|
||||
Scatter,
|
||||
/// Frightened mode - ghost moves randomly and can be eaten
|
||||
Frightened,
|
||||
/// Eyes mode - ghost returns to the ghost house after being eaten
|
||||
Eyes,
|
||||
/// House mode - ghost is in the ghost house, waiting to exit
|
||||
House,
|
||||
}
|
||||
|
||||
/// The different ghost personalities
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum GhostType {
|
||||
Blinky, // Red - Shadow
|
||||
Pinky, // Pink - Speedy
|
||||
Inky, // Cyan - Bashful
|
||||
Clyde, // Orange - Pokey
|
||||
}
|
||||
|
||||
impl GhostType {
|
||||
/// Returns the color of the ghost.
|
||||
pub fn color(&self) -> Color {
|
||||
match self {
|
||||
GhostType::Blinky => Color::RGB(255, 0, 0),
|
||||
GhostType::Pinky => Color::RGB(255, 184, 255),
|
||||
GhostType::Inky => Color::RGB(0, 255, 255),
|
||||
GhostType::Clyde => Color::RGB(255, 184, 82),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Base ghost struct that contains common functionality
|
||||
pub struct Ghost {
|
||||
/// Shared movement and position fields.
|
||||
pub base: MovableEntity,
|
||||
/// The current mode of the ghost
|
||||
pub mode: GhostMode,
|
||||
/// The type/personality of this ghost
|
||||
pub ghost_type: GhostType,
|
||||
/// Reference to Pac-Man for targeting
|
||||
pub pacman: Rc<RefCell<Pacman>>,
|
||||
pub texture: DirectionalAnimatedTexture,
|
||||
pub frightened_texture: BlinkingTexture,
|
||||
pub eyes_texture: DirectionalAnimatedTexture,
|
||||
}
|
||||
|
||||
impl Ghost {
|
||||
/// Creates a new ghost instance
|
||||
pub fn new(
|
||||
ghost_type: GhostType,
|
||||
starting_position: UVec2,
|
||||
atlas: Rc<RefCell<SpriteAtlas>>,
|
||||
map: Rc<RefCell<Map>>,
|
||||
pacman: Rc<RefCell<Pacman>>,
|
||||
) -> Ghost {
|
||||
let pixel_position = Map::cell_to_pixel(starting_position);
|
||||
let name = match ghost_type {
|
||||
GhostType::Blinky => "blinky",
|
||||
GhostType::Pinky => "pinky",
|
||||
GhostType::Inky => "inky",
|
||||
GhostType::Clyde => "clyde",
|
||||
};
|
||||
let get = |dir: &str, suffix: &str| get_atlas_tile(&atlas, &format!("ghost/{}/{}_{}.png", name, dir, suffix));
|
||||
|
||||
let texture = DirectionalAnimatedTexture::new(
|
||||
vec![get("up", "a"), get("up", "b")],
|
||||
vec![get("down", "a"), get("down", "b")],
|
||||
vec![get("left", "a"), get("left", "b")],
|
||||
vec![get("right", "a"), get("right", "b")],
|
||||
25,
|
||||
);
|
||||
|
||||
let frightened_texture = BlinkingTexture::new(
|
||||
AnimatedTexture::new(
|
||||
vec![
|
||||
get_atlas_tile(&atlas, "ghost/frightened/blue_a.png"),
|
||||
get_atlas_tile(&atlas, "ghost/frightened/blue_b.png"),
|
||||
],
|
||||
10,
|
||||
),
|
||||
45,
|
||||
15,
|
||||
);
|
||||
|
||||
let eyes_get = |dir: &str| get_atlas_tile(&atlas, &format!("ghost/eyes/{}.png", dir));
|
||||
|
||||
let eyes_texture = DirectionalAnimatedTexture::new(
|
||||
vec![eyes_get("up")],
|
||||
vec![eyes_get("down")],
|
||||
vec![eyes_get("left")],
|
||||
vec![eyes_get("right")],
|
||||
0,
|
||||
);
|
||||
|
||||
Ghost {
|
||||
base: MovableEntity::new(
|
||||
pixel_position,
|
||||
starting_position,
|
||||
Direction::Left,
|
||||
SimpleTickModulator::new(0.9375),
|
||||
map,
|
||||
),
|
||||
mode: GhostMode::Chase,
|
||||
ghost_type,
|
||||
pacman,
|
||||
texture,
|
||||
frightened_texture,
|
||||
eyes_texture,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the target tile for this ghost based on its current mode
|
||||
pub fn get_target_tile(&self) -> IVec2 {
|
||||
match self.mode {
|
||||
GhostMode::Scatter => self.get_scatter_target(),
|
||||
GhostMode::Chase => self.get_chase_target(),
|
||||
GhostMode::Frightened => self.get_random_target(),
|
||||
GhostMode::Eyes => self.get_house_target(),
|
||||
GhostMode::House => self.get_house_exit_target(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets this ghost's home corner target for scatter mode
|
||||
fn get_scatter_target(&self) -> IVec2 {
|
||||
match self.ghost_type {
|
||||
GhostType::Blinky => IVec2::new(25, 0), // Top right
|
||||
GhostType::Pinky => IVec2::new(2, 0), // Top left
|
||||
GhostType::Inky => IVec2::new(27, 35), // Bottom right
|
||||
GhostType::Clyde => IVec2::new(0, 35), // Bottom left
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets a random adjacent tile for frightened mode
|
||||
fn get_random_target(&self) -> IVec2 {
|
||||
let mut rng = SmallRng::from_os_rng();
|
||||
let mut possible_moves = Vec::new();
|
||||
|
||||
// Check all four directions
|
||||
for dir in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] {
|
||||
// Don't allow reversing direction
|
||||
if *dir == self.base.direction.opposite() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let next_cell = self.base.next_cell(Some(*dir));
|
||||
if !matches!(self.base.map.borrow().get_tile(next_cell), Some(MapTile::Wall)) {
|
||||
possible_moves.push(next_cell);
|
||||
}
|
||||
}
|
||||
|
||||
if possible_moves.is_empty() {
|
||||
// No valid moves, must reverse
|
||||
self.base.next_cell(Some(self.base.direction.opposite()))
|
||||
} else {
|
||||
// Choose a random valid move
|
||||
possible_moves[rng.random_range(0..possible_moves.len())]
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the ghost house target for returning eyes
|
||||
fn get_house_target(&self) -> IVec2 {
|
||||
IVec2::new(13, 14) // Center of ghost house
|
||||
}
|
||||
|
||||
/// Gets the exit point target when leaving house
|
||||
fn get_house_exit_target(&self) -> IVec2 {
|
||||
IVec2::new(13, 11) // Just above ghost house
|
||||
}
|
||||
|
||||
/// Gets this ghost's chase mode target (to be implemented by each ghost type)
|
||||
fn get_chase_target(&self) -> IVec2 {
|
||||
let pacman = self.pacman.borrow();
|
||||
let cell = pacman.base().cell_position;
|
||||
IVec2::new(cell.x as i32, cell.y as i32)
|
||||
}
|
||||
|
||||
/// Calculates the path to the target tile using the A* algorithm.
|
||||
pub fn get_path_to_target(&self, target: UVec2) -> Option<(Vec<UVec2>, u32)> {
|
||||
let start = self.base.base.cell_position;
|
||||
let map = self.base.map.borrow();
|
||||
use pathfinding::prelude::dijkstra;
|
||||
dijkstra(
|
||||
&start,
|
||||
|&p| {
|
||||
let mut successors = vec![];
|
||||
let tile = map.get_tile(IVec2::new(p.x as i32, p.y as i32));
|
||||
// Tunnel wrap: if currently in a tunnel, add the opposite exit as a neighbor
|
||||
if let Some(MapTile::Tunnel) = tile {
|
||||
if p.x == 0 {
|
||||
successors.push((UVec2::new(BOARD_CELL_SIZE.x - 2, p.y), 1));
|
||||
} else if p.x == BOARD_CELL_SIZE.x - 1 {
|
||||
successors.push((UVec2::new(1, p.y), 1));
|
||||
}
|
||||
}
|
||||
for dir in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] {
|
||||
let offset = dir.offset();
|
||||
let next_p = IVec2::new(p.x as i32 + offset.x, p.y as i32 + offset.y);
|
||||
if let Some(tile) = map.get_tile(next_p) {
|
||||
if tile == MapTile::Wall {
|
||||
continue;
|
||||
}
|
||||
let next_u = UVec2::new(next_p.x as u32, next_p.y as u32);
|
||||
successors.push((next_u, 1));
|
||||
}
|
||||
}
|
||||
successors
|
||||
},
|
||||
|&p| p == target,
|
||||
)
|
||||
}
|
||||
|
||||
/// Changes the ghost's mode and handles direction reversal
|
||||
pub fn set_mode(&mut self, new_mode: GhostMode) {
|
||||
// Don't reverse if going to/from frightened or if in house
|
||||
let should_reverse =
|
||||
self.mode != GhostMode::House && new_mode != GhostMode::Frightened && self.mode != GhostMode::Frightened;
|
||||
|
||||
self.mode = new_mode;
|
||||
|
||||
self.base.speed.set_speed(match new_mode {
|
||||
GhostMode::Chase => 0.9375,
|
||||
GhostMode::Scatter => 0.85,
|
||||
GhostMode::Frightened => 0.7,
|
||||
GhostMode::Eyes => 1.5,
|
||||
GhostMode::House => 0f32,
|
||||
});
|
||||
|
||||
if should_reverse {
|
||||
self.base.set_direction_if_valid(self.base.direction.opposite());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
if self.mode == GhostMode::House {
|
||||
// For now, do nothing in the house
|
||||
return;
|
||||
}
|
||||
if self.base.is_grid_aligned() {
|
||||
self.base.update_cell_position();
|
||||
if !self.base.handle_tunnel() {
|
||||
// Pathfinding logic (only if not in tunnel)
|
||||
let target_tile = self.get_target_tile();
|
||||
if let Some((path, _)) = self.get_path_to_target(target_tile.as_uvec2()) {
|
||||
if path.len() > 1 {
|
||||
let next_move = path[1];
|
||||
let x = self.base.base.cell_position.x;
|
||||
let y = self.base.base.cell_position.y;
|
||||
let dx = next_move.x as i32 - x as i32;
|
||||
let dy = next_move.y as i32 - y as i32;
|
||||
let new_direction = if dx > 0 {
|
||||
Direction::Right
|
||||
} else if dx < 0 {
|
||||
Direction::Left
|
||||
} else if dy > 0 {
|
||||
Direction::Down
|
||||
} else {
|
||||
Direction::Up
|
||||
};
|
||||
self.base.set_direction_if_valid(new_direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.base.tick(); // Handles wall collision and movement
|
||||
self.texture.tick();
|
||||
self.frightened_texture.tick();
|
||||
self.eyes_texture.tick();
|
||||
}
|
||||
}
|
||||
|
||||
impl Moving for Ghost {
|
||||
fn tick_movement(&mut self) {
|
||||
self.base.tick_movement();
|
||||
}
|
||||
fn tick(&mut self) {
|
||||
self.base.tick();
|
||||
}
|
||||
fn update_cell_position(&mut self) {
|
||||
self.base.update_cell_position();
|
||||
}
|
||||
fn next_cell(&self, direction: Option<Direction>) -> IVec2 {
|
||||
self.base.next_cell(direction)
|
||||
}
|
||||
fn is_wall_ahead(&self, direction: Option<Direction>) -> bool {
|
||||
self.base.is_wall_ahead(direction)
|
||||
}
|
||||
fn handle_tunnel(&mut self) -> bool {
|
||||
self.base.handle_tunnel()
|
||||
}
|
||||
fn is_grid_aligned(&self) -> bool {
|
||||
self.base.is_grid_aligned()
|
||||
}
|
||||
fn set_direction_if_valid(&mut self, new_direction: Direction) -> bool {
|
||||
self.base.set_direction_if_valid(new_direction)
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for Ghost {
|
||||
fn render(&mut self, canvas: &mut WindowCanvas) -> Result<()> {
|
||||
let pos = self.base.base.pixel_position;
|
||||
let dir = self.base.direction;
|
||||
|
||||
match self.mode {
|
||||
GhostMode::Frightened => {
|
||||
let tile = self.frightened_texture.animation.current_tile();
|
||||
let dest = sdl2::rect::Rect::new(pos.x - 4, pos.y - 4, tile.size.x as u32, tile.size.y as u32);
|
||||
self.frightened_texture.render(canvas, dest)
|
||||
}
|
||||
GhostMode::Eyes => {
|
||||
let tile = self.eyes_texture.up.get(0).unwrap();
|
||||
let dest = sdl2::rect::Rect::new(pos.x - 4, pos.y - 4, tile.size.x as u32, tile.size.y as u32);
|
||||
self.eyes_texture.render(canvas, dest, dir)
|
||||
}
|
||||
_ => {
|
||||
let tile = self.texture.up.get(0).unwrap();
|
||||
let dest = sdl2::rect::Rect::new(pos.x - 4, pos.y - 4, tile.size.x as u32, tile.size.y as u32);
|
||||
self.texture.render(canvas, dest, dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
772
src/entity/graph.rs
Normal file
772
src/entity/graph.rs
Normal file
@@ -0,0 +1,772 @@
|
||||
use glam::Vec2;
|
||||
|
||||
use super::direction::Direction;
|
||||
|
||||
/// A unique identifier for a node, represented by its index in the graph's storage.
|
||||
pub type NodeId = usize;
|
||||
|
||||
/// Represents a directed edge from one node to another with a given weight (e.g., distance).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Edge {
|
||||
/// The destination node of this edge.
|
||||
pub target: NodeId,
|
||||
/// The length of the edge.
|
||||
pub distance: f32,
|
||||
/// The cardinal direction of this edge.
|
||||
pub direction: Direction,
|
||||
}
|
||||
|
||||
/// Represents a node in the graph, defined by its position.
|
||||
#[derive(Debug)]
|
||||
pub struct Node {
|
||||
/// The 2D coordinates of the node.
|
||||
pub position: Vec2,
|
||||
}
|
||||
|
||||
/// Represents the four possible directions from a node in the graph.
|
||||
///
|
||||
/// Each field contains an optional edge leading in that direction.
|
||||
/// This structure is used to represent the adjacency list for each node,
|
||||
/// providing O(1) access to edges in any cardinal direction.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Intersection {
|
||||
/// Edge leading upward from this node, if it exists.
|
||||
pub up: Option<Edge>,
|
||||
/// Edge leading downward from this node, if it exists.
|
||||
pub down: Option<Edge>,
|
||||
/// Edge leading leftward from this node, if it exists.
|
||||
pub left: Option<Edge>,
|
||||
/// Edge leading rightward from this node, if it exists.
|
||||
pub right: Option<Edge>,
|
||||
}
|
||||
|
||||
impl Intersection {
|
||||
/// Returns an iterator over all edges from this intersection.
|
||||
///
|
||||
/// This iterator yields only the edges that exist (non-None values).
|
||||
pub fn edges(&self) -> impl Iterator<Item = Edge> {
|
||||
[self.up, self.down, self.left, self.right].into_iter().flatten()
|
||||
}
|
||||
|
||||
/// Retrieves the edge in the specified direction, if it exists.
|
||||
pub fn get(&self, direction: Direction) -> Option<Edge> {
|
||||
match direction {
|
||||
Direction::Up => self.up,
|
||||
Direction::Down => self.down,
|
||||
Direction::Left => self.left,
|
||||
Direction::Right => self.right,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the edge in the specified direction.
|
||||
///
|
||||
/// This will overwrite any existing edge in that direction.
|
||||
pub fn set(&mut self, direction: Direction, edge: Edge) {
|
||||
match direction {
|
||||
Direction::Up => self.up = Some(edge),
|
||||
Direction::Down => self.down = Some(edge),
|
||||
Direction::Left => self.left = Some(edge),
|
||||
Direction::Right => self.right = Some(edge),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A directed graph structure using an adjacency list representation.
|
||||
///
|
||||
/// Nodes are stored in a vector, and their indices serve as their `NodeId`.
|
||||
/// This design provides fast, O(1) lookups for node data. Edges are stored
|
||||
/// in an adjacency list, where each node has a list of outgoing edges.
|
||||
pub struct Graph {
|
||||
nodes: Vec<Node>,
|
||||
pub adjacency_list: Vec<Intersection>,
|
||||
}
|
||||
|
||||
impl Graph {
|
||||
/// Creates a new, empty graph.
|
||||
pub fn new() -> Self {
|
||||
Graph {
|
||||
nodes: Vec::new(),
|
||||
adjacency_list: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a new node with the given data to the graph and returns its ID.
|
||||
pub fn add_node(&mut self, data: Node) -> NodeId {
|
||||
let id = self.nodes.len();
|
||||
self.nodes.push(data);
|
||||
self.adjacency_list.push(Intersection::default());
|
||||
id
|
||||
}
|
||||
|
||||
/// Connects a new node to the graph and adds an edge between the existing node and the new node.
|
||||
pub fn connect_node(&mut self, from: NodeId, direction: Direction, new_node: Node) -> Result<NodeId, &'static str> {
|
||||
let to = self.add_node(new_node);
|
||||
self.connect(from, to, false, None, direction)?;
|
||||
Ok(to)
|
||||
}
|
||||
|
||||
/// Connects two existing nodes with an edge.
|
||||
pub fn connect(
|
||||
&mut self,
|
||||
from: NodeId,
|
||||
to: NodeId,
|
||||
replace: bool,
|
||||
distance: Option<f32>,
|
||||
direction: Direction,
|
||||
) -> Result<(), &'static str> {
|
||||
if from >= self.adjacency_list.len() {
|
||||
return Err("From node does not exist.");
|
||||
}
|
||||
if to >= self.adjacency_list.len() {
|
||||
return Err("To node does not exist.");
|
||||
}
|
||||
|
||||
let edge_a = self.add_edge(from, to, replace, distance, direction);
|
||||
let edge_b = self.add_edge(to, from, replace, distance, direction.opposite());
|
||||
|
||||
if edge_a.is_err() && edge_b.is_err() {
|
||||
return Err("Failed to connect nodes in both directions.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adds a directed edge between two nodes.
|
||||
///
|
||||
/// If `distance` is `None`, it will be calculated automatically based on the
|
||||
/// Euclidean distance between the two nodes.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if:
|
||||
/// - The `from` node does not exist
|
||||
/// - An edge already exists in the specified direction
|
||||
/// - An edge already exists to the target node
|
||||
/// - The provided distance is not positive
|
||||
pub fn add_edge(
|
||||
&mut self,
|
||||
from: NodeId,
|
||||
to: NodeId,
|
||||
replace: bool,
|
||||
distance: Option<f32>,
|
||||
direction: Direction,
|
||||
) -> Result<(), &'static str> {
|
||||
let edge = Edge {
|
||||
target: to,
|
||||
distance: match distance {
|
||||
Some(distance) => {
|
||||
if distance < 0.0 {
|
||||
return Err("Edge distance must be on-negative.");
|
||||
}
|
||||
distance
|
||||
}
|
||||
None => {
|
||||
// If no distance is provided, calculate it based on the positions of the nodes
|
||||
let from_pos = self.nodes[from].position;
|
||||
let to_pos = self.nodes[to].position;
|
||||
from_pos.distance(to_pos)
|
||||
}
|
||||
},
|
||||
direction,
|
||||
};
|
||||
|
||||
if from >= self.adjacency_list.len() {
|
||||
return Err("From node does not exist.");
|
||||
}
|
||||
|
||||
let adjacency_list = &mut self.adjacency_list[from];
|
||||
|
||||
// Check if the edge already exists in this direction or to the same target
|
||||
if let Some(err) = adjacency_list.edges().find_map(|e| {
|
||||
// If we're not replacing the edge, we don't want to replace an edge that already exists in this direction
|
||||
if !replace && e.direction == direction {
|
||||
Some(Err("Edge already exists in this direction."))
|
||||
} else if e.target == to {
|
||||
Some(Err("Edge already exists."))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
return err;
|
||||
}
|
||||
|
||||
adjacency_list.set(direction, edge);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieves an immutable reference to a node's data.
|
||||
pub fn get_node(&self, id: NodeId) -> Option<&Node> {
|
||||
self.nodes.get(id)
|
||||
}
|
||||
|
||||
/// Returns the total number of nodes in the graph.
|
||||
pub fn node_count(&self) -> usize {
|
||||
self.nodes.len()
|
||||
}
|
||||
|
||||
/// Finds a specific edge from a source node to a target node.
|
||||
pub fn find_edge(&self, from: NodeId, to: NodeId) -> Option<Edge> {
|
||||
self.adjacency_list.get(from)?.edges().find(|edge| edge.target == to)
|
||||
}
|
||||
|
||||
/// Finds an edge originating from a given node that follows a specific direction.
|
||||
pub fn find_edge_in_direction(&self, from: NodeId, direction: Direction) -> Option<Edge> {
|
||||
self.adjacency_list.get(from)?.get(direction)
|
||||
}
|
||||
}
|
||||
|
||||
// Default implementation for creating an empty graph.
|
||||
impl Default for Graph {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Traversal State and Logic ---
|
||||
|
||||
/// Represents the current position of an entity traversing the graph.
|
||||
///
|
||||
/// This enum allows for precise tracking of whether an entity is exactly at a node
|
||||
/// or moving along an edge between two nodes.
|
||||
#[derive(Debug, PartialEq, Clone, Copy)]
|
||||
pub enum Position {
|
||||
/// The traverser is located exactly at a node.
|
||||
AtNode(NodeId),
|
||||
/// The traverser is on an edge between two nodes.
|
||||
BetweenNodes {
|
||||
from: NodeId,
|
||||
to: NodeId,
|
||||
/// The floating-point distance traversed along the edge from the `from` node.
|
||||
traversed: f32,
|
||||
},
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Position {
|
||||
/// Returns `true` if the position is exactly at a node.
|
||||
pub fn is_at_node(&self) -> bool {
|
||||
matches!(self, Position::AtNode(_))
|
||||
}
|
||||
|
||||
/// Returns the `NodeId` of the current or most recently departed node.
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
pub fn from_node_id(&self) -> NodeId {
|
||||
match self {
|
||||
Position::AtNode(id) => *id,
|
||||
Position::BetweenNodes { from, .. } => *from,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the `NodeId` of the destination node, if currently on an edge.
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
pub fn to_node_id(&self) -> Option<NodeId> {
|
||||
match self {
|
||||
Position::AtNode(_) => None,
|
||||
Position::BetweenNodes { to, .. } => Some(*to),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the traverser is stopped at a node.
|
||||
pub fn is_stopped(&self) -> bool {
|
||||
matches!(self, Position::AtNode(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages an entity's movement through the graph.
|
||||
///
|
||||
/// A `Traverser` encapsulates the state of an entity's position and direction,
|
||||
/// providing a way to advance along the graph's paths based on a given distance.
|
||||
/// It also handles direction changes, buffering the next intended direction.
|
||||
pub struct Traverser {
|
||||
/// The current position of the traverser in the graph.
|
||||
pub position: Position,
|
||||
/// The current direction of movement.
|
||||
pub direction: Direction,
|
||||
/// Buffered direction change with remaining frame count for timing.
|
||||
///
|
||||
/// The `u8` value represents the number of frames remaining before
|
||||
/// the buffered direction expires. This allows for responsive controls
|
||||
/// by storing direction changes for a limited time.
|
||||
pub next_direction: Option<(Direction, u8)>,
|
||||
}
|
||||
|
||||
impl Traverser {
|
||||
/// Creates a new traverser starting at the given node ID.
|
||||
///
|
||||
/// The traverser will immediately attempt to start moving in the initial direction.
|
||||
pub fn new(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::entity::direction::Direction;
|
||||
|
||||
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_new() {
|
||||
let graph = Graph::new();
|
||||
assert_eq!(graph.node_count(), 0);
|
||||
assert!(graph.adjacency_list.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_graph_add_node() {
|
||||
let mut graph = Graph::new();
|
||||
let node = Node {
|
||||
position: glam::Vec2::new(10.0, 20.0),
|
||||
};
|
||||
let id = graph.add_node(node);
|
||||
|
||||
assert_eq!(id, 0);
|
||||
assert_eq!(graph.node_count(), 1);
|
||||
assert_eq!(graph.adjacency_list.len(), 1);
|
||||
|
||||
let retrieved_node = graph.get_node(id).unwrap();
|
||||
assert_eq!(retrieved_node.position, glam::Vec2::new(10.0, 20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_graph_node_count() {
|
||||
let mut graph = Graph::new();
|
||||
assert_eq!(graph.node_count(), 0);
|
||||
|
||||
graph.add_node(Node {
|
||||
position: glam::Vec2::new(0.0, 0.0),
|
||||
});
|
||||
assert_eq!(graph.node_count(), 1);
|
||||
|
||||
graph.add_node(Node {
|
||||
position: glam::Vec2::new(1.0, 1.0),
|
||||
});
|
||||
assert_eq!(graph.node_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_graph_get_node() {
|
||||
let mut graph = Graph::new();
|
||||
let node = Node {
|
||||
position: glam::Vec2::new(5.0, 10.0),
|
||||
};
|
||||
let id = graph.add_node(node);
|
||||
|
||||
let retrieved = graph.get_node(id).unwrap();
|
||||
assert_eq!(retrieved.position, glam::Vec2::new(5.0, 10.0));
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
let result = graph.connect(node1, node2, false, None, Direction::Right);
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Check that edges were added in both directions
|
||||
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_invalid_nodes() {
|
||||
let mut graph = Graph::new();
|
||||
let node1 = graph.add_node(Node {
|
||||
position: glam::Vec2::new(0.0, 0.0),
|
||||
});
|
||||
|
||||
// Try to connect to non-existent node
|
||||
let result = graph.connect(node1, 999, false, None, Direction::Right);
|
||||
assert!(result.is_err());
|
||||
|
||||
// Try to connect from non-existent node
|
||||
let result = graph.connect(999, node1, false, None, Direction::Right);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_graph_find_edge() {
|
||||
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.connect(node1, node2, false, None, Direction::Right).unwrap();
|
||||
|
||||
let edge = graph.find_edge(node1, node2);
|
||||
assert!(edge.is_some());
|
||||
assert_eq!(edge.unwrap().target, node2);
|
||||
|
||||
// Test non-existent edge
|
||||
assert!(graph.find_edge(node1, 999).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_graph_find_edge_in_direction() {
|
||||
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.connect(node1, node2, false, None, Direction::Right).unwrap();
|
||||
|
||||
let edge = graph.find_edge_in_direction(node1, Direction::Right);
|
||||
assert!(edge.is_some());
|
||||
assert_eq!(edge.unwrap().target, node2);
|
||||
|
||||
// Test non-existent direction
|
||||
assert!(graph.find_edge_in_direction(node1, Direction::Up).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersection_edges() {
|
||||
let mut intersection = Intersection::default();
|
||||
intersection.set(
|
||||
Direction::Up,
|
||||
Edge {
|
||||
target: 1,
|
||||
distance: 10.0,
|
||||
direction: Direction::Up,
|
||||
},
|
||||
);
|
||||
intersection.set(
|
||||
Direction::Right,
|
||||
Edge {
|
||||
target: 2,
|
||||
distance: 15.0,
|
||||
direction: Direction::Right,
|
||||
},
|
||||
);
|
||||
|
||||
let edges: Vec<_> = intersection.edges().collect();
|
||||
assert_eq!(edges.len(), 2);
|
||||
|
||||
let up_edge = edges.iter().find(|e| e.direction == Direction::Up).unwrap();
|
||||
let right_edge = edges.iter().find(|e| e.direction == Direction::Right).unwrap();
|
||||
|
||||
assert_eq!(up_edge.target, 1);
|
||||
assert_eq!(up_edge.distance, 10.0);
|
||||
assert_eq!(right_edge.target, 2);
|
||||
assert_eq!(right_edge.distance, 15.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersection_get() {
|
||||
let mut intersection = Intersection::default();
|
||||
let edge = Edge {
|
||||
target: 1,
|
||||
distance: 10.0,
|
||||
direction: Direction::Up,
|
||||
};
|
||||
intersection.set(Direction::Up, edge);
|
||||
|
||||
let retrieved = intersection.get(Direction::Up);
|
||||
assert!(retrieved.is_some());
|
||||
assert_eq!(retrieved.unwrap().target, 1);
|
||||
|
||||
assert!(intersection.get(Direction::Down).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intersection_set() {
|
||||
let mut intersection = Intersection::default();
|
||||
let edge = Edge {
|
||||
target: 1,
|
||||
distance: 10.0,
|
||||
direction: Direction::Left,
|
||||
};
|
||||
|
||||
intersection.set(Direction::Left, edge);
|
||||
let retrieved = intersection.get(Direction::Left);
|
||||
assert!(retrieved.is_some());
|
||||
assert_eq!(retrieved.unwrap().target, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_position_is_at_node() {
|
||||
let pos = Position::AtNode(5);
|
||||
assert!(pos.is_at_node());
|
||||
|
||||
let pos = Position::BetweenNodes {
|
||||
from: 1,
|
||||
to: 2,
|
||||
traversed: 5.0,
|
||||
};
|
||||
assert!(!pos.is_at_node());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_position_from_node_id() {
|
||||
let pos = Position::AtNode(5);
|
||||
assert_eq!(pos.from_node_id(), 5);
|
||||
|
||||
let pos = Position::BetweenNodes {
|
||||
from: 1,
|
||||
to: 2,
|
||||
traversed: 5.0,
|
||||
};
|
||||
assert_eq!(pos.from_node_id(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_position_to_node_id() {
|
||||
let pos = Position::AtNode(5);
|
||||
assert_eq!(pos.to_node_id(), None);
|
||||
|
||||
let pos = Position::BetweenNodes {
|
||||
from: 1,
|
||||
to: 2,
|
||||
traversed: 5.0,
|
||||
};
|
||||
assert_eq!(pos.to_node_id(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_position_is_stopped() {
|
||||
let pos = Position::AtNode(5);
|
||||
assert!(pos.is_stopped());
|
||||
|
||||
let pos = Position::BetweenNodes {
|
||||
from: 1,
|
||||
to: 2,
|
||||
traversed: 5.0,
|
||||
};
|
||||
assert!(!pos.is_stopped());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_traverser_new() {
|
||||
let graph = create_test_graph();
|
||||
let traverser = Traverser::new(&graph, 0, Direction::Left);
|
||||
|
||||
assert_eq!(traverser.direction, Direction::Left);
|
||||
// The next_direction might be consumed immediately when the traverser starts moving
|
||||
// So we just check that the direction is set correctly
|
||||
assert_eq!(traverser.direction, Direction::Left);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_traverser_set_next_direction() {
|
||||
let graph = create_test_graph();
|
||||
let mut traverser = Traverser::new(&graph, 0, Direction::Left);
|
||||
|
||||
traverser.set_next_direction(Direction::Up);
|
||||
assert!(traverser.next_direction.is_some());
|
||||
assert_eq!(traverser.next_direction.unwrap().0, Direction::Up);
|
||||
|
||||
// Setting same direction should not change anything
|
||||
traverser.set_next_direction(Direction::Up);
|
||||
assert_eq!(traverser.next_direction.unwrap().0, Direction::Up);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_traverser_advance_at_node() {
|
||||
let graph = create_test_graph();
|
||||
let mut traverser = Traverser::new(&graph, 0, Direction::Right);
|
||||
|
||||
// Should start moving in the initial direction
|
||||
traverser.advance(&graph, 5.0);
|
||||
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_traverser_advance_between_nodes() {
|
||||
let graph = create_test_graph();
|
||||
let mut traverser = Traverser::new(&graph, 0, Direction::Right);
|
||||
|
||||
// Move to between nodes
|
||||
traverser.advance(&graph, 5.0);
|
||||
|
||||
// Advance further
|
||||
traverser.advance(&graph, 3.0);
|
||||
|
||||
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_edge_structure() {
|
||||
let edge = Edge {
|
||||
target: 5,
|
||||
distance: 10.5,
|
||||
direction: Direction::Up,
|
||||
};
|
||||
|
||||
assert_eq!(edge.target, 5);
|
||||
assert_eq!(edge.distance, 10.5);
|
||||
assert_eq!(edge.direction, Direction::Up);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_node_structure() {
|
||||
let node = Node {
|
||||
position: glam::Vec2::new(10.0, 20.0),
|
||||
};
|
||||
|
||||
assert_eq!(node.position, glam::Vec2::new(10.0, 20.0));
|
||||
}
|
||||
}
|
||||
@@ -1,203 +1,3 @@
|
||||
pub mod blinky;
|
||||
pub mod direction;
|
||||
pub mod edible;
|
||||
pub mod ghost;
|
||||
pub mod graph;
|
||||
pub mod pacman;
|
||||
pub mod speed;
|
||||
|
||||
use crate::{
|
||||
constants::{MapTile, BOARD_CELL_SIZE, BOARD_OFFSET, CELL_SIZE},
|
||||
entity::{direction::Direction, speed::SimpleTickModulator},
|
||||
map::Map,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use glam::{IVec2, UVec2};
|
||||
use sdl2::render::WindowCanvas;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// A trait for game objects that can be moved and rendered.
|
||||
pub trait Entity {
|
||||
/// Returns a reference to the base entity (position, etc).
|
||||
fn base(&self) -> &StaticEntity;
|
||||
|
||||
/// Returns true if the entity is colliding with the other entity.
|
||||
fn is_colliding(&self, other: &dyn Entity) -> bool {
|
||||
let a = self.base().pixel_position;
|
||||
let b = other.base().pixel_position;
|
||||
a == b
|
||||
}
|
||||
}
|
||||
|
||||
/// A trait for entities that can move and interact with the map.
|
||||
pub trait Moving {
|
||||
fn tick(&mut self) {
|
||||
self.base_tick();
|
||||
}
|
||||
fn base_tick(&mut self) {
|
||||
if self.is_grid_aligned() {
|
||||
self.on_grid_aligned();
|
||||
}
|
||||
self.tick_movement();
|
||||
}
|
||||
/// Called when the entity is grid-aligned. Default does nothing.
|
||||
fn on_grid_aligned(&mut self) {}
|
||||
/// Handles movement and wall collision. Default uses tick logic from MovableEntity.
|
||||
fn tick_movement(&mut self);
|
||||
fn update_cell_position(&mut self);
|
||||
fn next_cell(&self, direction: Option<Direction>) -> IVec2;
|
||||
fn is_wall_ahead(&self, direction: Option<Direction>) -> bool;
|
||||
fn handle_tunnel(&mut self) -> bool;
|
||||
fn is_grid_aligned(&self) -> bool;
|
||||
fn set_direction_if_valid(&mut self, new_direction: Direction) -> bool;
|
||||
}
|
||||
|
||||
/// Trait for entities that support queued direction changes.
|
||||
pub trait QueuedDirection: Moving {
|
||||
fn next_direction(&self) -> Option<Direction>;
|
||||
fn set_next_direction(&mut self, dir: Option<Direction>);
|
||||
/// Handles a requested direction change if possible.
|
||||
fn handle_direction_change(&mut self) -> bool {
|
||||
if let Some(next_direction) = self.next_direction() {
|
||||
if self.set_direction_if_valid(next_direction) {
|
||||
self.set_next_direction(None);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// A struct for static (non-moving) entities with position only.
|
||||
pub struct StaticEntity {
|
||||
pub pixel_position: IVec2,
|
||||
pub cell_position: UVec2,
|
||||
}
|
||||
|
||||
impl StaticEntity {
|
||||
pub fn new(pixel_position: IVec2, cell_position: UVec2) -> Self {
|
||||
Self {
|
||||
pixel_position,
|
||||
cell_position,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A struct for movable game entities with position, direction, speed, and modulation.
|
||||
pub struct MovableEntity {
|
||||
pub base: StaticEntity,
|
||||
pub direction: Direction,
|
||||
pub speed: SimpleTickModulator,
|
||||
pub in_tunnel: bool,
|
||||
pub map: Rc<RefCell<Map>>,
|
||||
}
|
||||
|
||||
impl MovableEntity {
|
||||
pub fn new(
|
||||
pixel_position: IVec2,
|
||||
cell_position: UVec2,
|
||||
direction: Direction,
|
||||
speed: SimpleTickModulator,
|
||||
map: Rc<RefCell<Map>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
base: StaticEntity::new(pixel_position, cell_position),
|
||||
direction,
|
||||
speed,
|
||||
in_tunnel: false,
|
||||
map,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the position within the current cell, in pixels.
|
||||
pub fn internal_position(&self) -> UVec2 {
|
||||
UVec2::new(
|
||||
(self.base.pixel_position.x as u32) % CELL_SIZE,
|
||||
(self.base.pixel_position.y as u32) % CELL_SIZE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for MovableEntity {
|
||||
fn base(&self) -> &StaticEntity {
|
||||
&self.base
|
||||
}
|
||||
}
|
||||
|
||||
impl Moving for MovableEntity {
|
||||
fn tick_movement(&mut self) {
|
||||
if self.speed.next() {
|
||||
if !self.is_wall_ahead(None) {
|
||||
match self.direction {
|
||||
Direction::Right => self.base.pixel_position.x += 1,
|
||||
Direction::Left => self.base.pixel_position.x -= 1,
|
||||
Direction::Up => self.base.pixel_position.y -= 1,
|
||||
Direction::Down => self.base.pixel_position.y += 1,
|
||||
}
|
||||
if self.is_grid_aligned() {
|
||||
self.update_cell_position();
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.is_grid_aligned() {
|
||||
self.update_cell_position();
|
||||
}
|
||||
}
|
||||
fn update_cell_position(&mut self) {
|
||||
self.base.cell_position = UVec2::new(
|
||||
(self.base.pixel_position.x as u32 / CELL_SIZE) - BOARD_OFFSET.x,
|
||||
(self.base.pixel_position.y as u32 / CELL_SIZE) - BOARD_OFFSET.y,
|
||||
);
|
||||
}
|
||||
fn next_cell(&self, direction: Option<Direction>) -> IVec2 {
|
||||
let IVec2 { x, y } = direction.unwrap_or(self.direction).offset();
|
||||
IVec2::new(self.base.cell_position.x as i32 + x, self.base.cell_position.y as i32 + y)
|
||||
}
|
||||
fn is_wall_ahead(&self, direction: Option<Direction>) -> bool {
|
||||
let next_cell = self.next_cell(direction);
|
||||
matches!(self.map.borrow().get_tile(next_cell), Some(MapTile::Wall))
|
||||
}
|
||||
fn handle_tunnel(&mut self) -> bool {
|
||||
let x = self.base.cell_position.x;
|
||||
let at_left_tunnel = x == 0;
|
||||
let at_right_tunnel = x == BOARD_CELL_SIZE.x - 1;
|
||||
|
||||
if !at_left_tunnel && !at_right_tunnel {
|
||||
return false;
|
||||
}
|
||||
if self.in_tunnel {
|
||||
return true;
|
||||
}
|
||||
|
||||
let new_x = if at_left_tunnel { BOARD_CELL_SIZE.x - 2 } else { 1 };
|
||||
self.base.cell_position.x = new_x;
|
||||
self.base.pixel_position = Map::cell_to_pixel(self.base.cell_position);
|
||||
|
||||
self.in_tunnel = true;
|
||||
true
|
||||
}
|
||||
fn is_grid_aligned(&self) -> bool {
|
||||
self.internal_position() == UVec2::ZERO
|
||||
}
|
||||
fn set_direction_if_valid(&mut self, new_direction: Direction) -> bool {
|
||||
if new_direction == self.direction {
|
||||
return false;
|
||||
}
|
||||
if self.is_wall_ahead(Some(new_direction)) {
|
||||
return false;
|
||||
}
|
||||
self.direction = new_direction;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for StaticEntity {
|
||||
fn base(&self) -> &StaticEntity {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A trait for entities that can be rendered to the screen.
|
||||
pub trait Renderable {
|
||||
fn render(&mut self, canvas: &mut WindowCanvas) -> Result<()>;
|
||||
}
|
||||
|
||||
@@ -1,143 +1,314 @@
|
||||
//! This module defines the Pac-Man entity, including its behavior and rendering.
|
||||
use anyhow::Result;
|
||||
use glam::{IVec2, UVec2};
|
||||
use sdl2::render::WindowCanvas;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use glam::{UVec2, Vec2};
|
||||
|
||||
use crate::{
|
||||
entity::speed::SimpleTickModulator,
|
||||
entity::{direction::Direction, Entity, MovableEntity, Moving, QueuedDirection, Renderable, StaticEntity},
|
||||
map::Map,
|
||||
texture::{animated::AnimatedTexture, directional::DirectionalAnimatedTexture, get_atlas_tile, sprite::SpriteAtlas},
|
||||
};
|
||||
use crate::constants::BOARD_PIXEL_OFFSET;
|
||||
use crate::entity::direction::Direction;
|
||||
use crate::entity::graph::{Graph, NodeId, Position, Traverser};
|
||||
use crate::helpers::centered_with_size;
|
||||
use crate::texture::animated::AnimatedTexture;
|
||||
use crate::texture::directional::DirectionalAnimatedTexture;
|
||||
use crate::texture::sprite::SpriteAtlas;
|
||||
use sdl2::keyboard::Keycode;
|
||||
use sdl2::render::{Canvas, RenderTarget};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// The Pac-Man entity.
|
||||
pub struct Pacman {
|
||||
/// Shared movement and position fields.
|
||||
pub base: MovableEntity,
|
||||
/// The next direction of Pac-Man, which will be applied when Pac-Man is next aligned with the grid.
|
||||
pub next_direction: Option<Direction>,
|
||||
/// Whether Pac-Man is currently stopped.
|
||||
pub stopped: bool,
|
||||
pub skip_move_tick: bool,
|
||||
pub texture: DirectionalAnimatedTexture,
|
||||
pub death_animation: AnimatedTexture,
|
||||
}
|
||||
|
||||
impl Entity for Pacman {
|
||||
fn base(&self) -> &StaticEntity {
|
||||
&self.base.base
|
||||
}
|
||||
}
|
||||
|
||||
impl Moving for Pacman {
|
||||
fn tick_movement(&mut self) {
|
||||
if self.skip_move_tick {
|
||||
self.skip_move_tick = false;
|
||||
return;
|
||||
}
|
||||
self.base.tick_movement();
|
||||
}
|
||||
fn update_cell_position(&mut self) {
|
||||
self.base.update_cell_position();
|
||||
}
|
||||
fn next_cell(&self, direction: Option<Direction>) -> IVec2 {
|
||||
self.base.next_cell(direction)
|
||||
}
|
||||
fn is_wall_ahead(&self, direction: Option<Direction>) -> bool {
|
||||
self.base.is_wall_ahead(direction)
|
||||
}
|
||||
fn handle_tunnel(&mut self) -> bool {
|
||||
self.base.handle_tunnel()
|
||||
}
|
||||
fn is_grid_aligned(&self) -> bool {
|
||||
self.base.is_grid_aligned()
|
||||
}
|
||||
fn set_direction_if_valid(&mut self, new_direction: Direction) -> bool {
|
||||
self.base.set_direction_if_valid(new_direction)
|
||||
}
|
||||
fn on_grid_aligned(&mut self) {
|
||||
Pacman::update_cell_position(self);
|
||||
if !<Pacman as Moving>::handle_tunnel(self) {
|
||||
<Pacman as QueuedDirection>::handle_direction_change(self);
|
||||
if !self.stopped && <Pacman as Moving>::is_wall_ahead(self, None) {
|
||||
self.stopped = true;
|
||||
} else if self.stopped && !<Pacman as Moving>::is_wall_ahead(self, None) {
|
||||
self.stopped = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl QueuedDirection for Pacman {
|
||||
fn next_direction(&self) -> Option<Direction> {
|
||||
self.next_direction
|
||||
}
|
||||
fn set_next_direction(&mut self, dir: Option<Direction>) {
|
||||
self.next_direction = dir;
|
||||
}
|
||||
pub traverser: Traverser,
|
||||
texture: DirectionalAnimatedTexture,
|
||||
}
|
||||
|
||||
impl Pacman {
|
||||
/// Creates a new `Pacman` instance.
|
||||
pub fn new(starting_position: UVec2, atlas: Rc<RefCell<SpriteAtlas>>, map: Rc<RefCell<Map>>) -> Pacman {
|
||||
let pixel_position = Map::cell_to_pixel(starting_position);
|
||||
let get = |name: &str| get_atlas_tile(&atlas, name);
|
||||
pub fn new(graph: &Graph, start_node: NodeId, atlas: &SpriteAtlas) -> Self {
|
||||
let mut textures = HashMap::new();
|
||||
let mut stopped_textures = HashMap::new();
|
||||
|
||||
Pacman {
|
||||
base: MovableEntity::new(
|
||||
pixel_position,
|
||||
starting_position,
|
||||
Direction::Right,
|
||||
SimpleTickModulator::new(1f32),
|
||||
map,
|
||||
),
|
||||
next_direction: None,
|
||||
stopped: false,
|
||||
skip_move_tick: false,
|
||||
texture: DirectionalAnimatedTexture::new(
|
||||
vec![get("pacman/up_a.png"), get("pacman/up_b.png"), get("pacman/full.png")],
|
||||
vec![get("pacman/down_a.png"), get("pacman/down_b.png"), get("pacman/full.png")],
|
||||
vec![get("pacman/left_a.png"), get("pacman/left_b.png"), get("pacman/full.png")],
|
||||
vec![get("pacman/right_a.png"), get("pacman/right_b.png"), get("pacman/full.png")],
|
||||
8,
|
||||
),
|
||||
death_animation: AnimatedTexture::new(
|
||||
(0..=10)
|
||||
.map(|i| get_atlas_tile(&atlas, &format!("pacman/death/{}.png", i)))
|
||||
.collect(),
|
||||
5,
|
||||
),
|
||||
for &direction in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] {
|
||||
let moving_prefix = match direction {
|
||||
Direction::Up => "pacman/up",
|
||||
Direction::Down => "pacman/down",
|
||||
Direction::Left => "pacman/left",
|
||||
Direction::Right => "pacman/right",
|
||||
};
|
||||
let moving_tiles = vec![
|
||||
SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_a.png")).unwrap(),
|
||||
SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png")).unwrap(),
|
||||
SpriteAtlas::get_tile(atlas, "pacman/full.png").unwrap(),
|
||||
];
|
||||
|
||||
let stopped_tiles = vec![SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png")).unwrap()];
|
||||
|
||||
textures.insert(
|
||||
direction,
|
||||
AnimatedTexture::new(moving_tiles, 0.08).expect("Invalid frame duration"),
|
||||
);
|
||||
stopped_textures.insert(
|
||||
direction,
|
||||
AnimatedTexture::new(stopped_tiles, 0.1).expect("Invalid frame duration"),
|
||||
);
|
||||
}
|
||||
|
||||
Self {
|
||||
traverser: Traverser::new(graph, start_node, Direction::Left),
|
||||
texture: DirectionalAnimatedTexture::new(textures, stopped_textures),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the internal position of Pac-Man, rounded down to the nearest even number.
|
||||
fn internal_position_even(&self) -> UVec2 {
|
||||
let pos = self.base.internal_position();
|
||||
UVec2::new((pos.x / 2) * 2, (pos.y / 2) * 2)
|
||||
pub fn tick(&mut self, dt: f32, graph: &Graph) {
|
||||
self.traverser.advance(graph, dt * 60.0 * 1.125);
|
||||
self.texture.tick(dt);
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
<Pacman as Moving>::tick(self);
|
||||
self.texture.tick();
|
||||
pub fn handle_key(&mut self, keycode: Keycode) {
|
||||
let direction = match keycode {
|
||||
Keycode::Up => Some(Direction::Up),
|
||||
Keycode::Down => Some(Direction::Down),
|
||||
Keycode::Left => Some(Direction::Left),
|
||||
Keycode::Right => Some(Direction::Right),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(direction) = direction {
|
||||
self.traverser.set_next_direction(direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for Pacman {
|
||||
fn render(&mut self, canvas: &mut WindowCanvas) -> Result<()> {
|
||||
let pos = self.base.base.pixel_position;
|
||||
let dir = self.base.direction;
|
||||
fn get_pixel_pos(&self, graph: &Graph) -> Vec2 {
|
||||
match self.traverser.position {
|
||||
Position::AtNode(node_id) => graph.get_node(node_id).unwrap().position,
|
||||
Position::BetweenNodes { from, to, traversed } => {
|
||||
let from_pos = graph.get_node(from).unwrap().position;
|
||||
let to_pos = graph.get_node(to).unwrap().position;
|
||||
from_pos.lerp(to_pos, traversed / from_pos.distance(to_pos))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Center the 16x16 sprite on the 8x8 cell by offsetting by -4
|
||||
let dest = sdl2::rect::Rect::new(pos.x - 4, pos.y - 4, 16, 16);
|
||||
pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, graph: &Graph) {
|
||||
let pixel_pos = self.get_pixel_pos(graph).round().as_ivec2() + BOARD_PIXEL_OFFSET.as_ivec2();
|
||||
let dest = centered_with_size(pixel_pos, UVec2::new(16, 16));
|
||||
let is_stopped = self.traverser.position.is_stopped();
|
||||
|
||||
if self.stopped {
|
||||
// When stopped, show the full sprite (mouth open)
|
||||
self.texture.render_stopped(canvas, dest, dir)?;
|
||||
if is_stopped {
|
||||
self.texture
|
||||
.render_stopped(canvas, atlas, dest, self.traverser.direction)
|
||||
.unwrap();
|
||||
} else {
|
||||
self.texture.render(canvas, dest, dir)?;
|
||||
self.texture.render(canvas, atlas, dest, self.traverser.direction).unwrap();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::entity::graph::{Graph, Node};
|
||||
use crate::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 {
|
||||
// Create a minimal test atlas with required tiles
|
||||
let mut frames = HashMap::new();
|
||||
frames.insert(
|
||||
"pacman/up_a.png".to_string(),
|
||||
MapperFrame {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
frames.insert(
|
||||
"pacman/up_b.png".to_string(),
|
||||
MapperFrame {
|
||||
x: 16,
|
||||
y: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
frames.insert(
|
||||
"pacman/down_a.png".to_string(),
|
||||
MapperFrame {
|
||||
x: 32,
|
||||
y: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
frames.insert(
|
||||
"pacman/down_b.png".to_string(),
|
||||
MapperFrame {
|
||||
x: 48,
|
||||
y: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
frames.insert(
|
||||
"pacman/left_a.png".to_string(),
|
||||
MapperFrame {
|
||||
x: 64,
|
||||
y: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
frames.insert(
|
||||
"pacman/left_b.png".to_string(),
|
||||
MapperFrame {
|
||||
x: 80,
|
||||
y: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
frames.insert(
|
||||
"pacman/right_a.png".to_string(),
|
||||
MapperFrame {
|
||||
x: 96,
|
||||
y: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
frames.insert(
|
||||
"pacman/right_b.png".to_string(),
|
||||
MapperFrame {
|
||||
x: 112,
|
||||
y: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
frames.insert(
|
||||
"pacman/full.png".to_string(),
|
||||
MapperFrame {
|
||||
x: 128,
|
||||
y: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
|
||||
let mapper = AtlasMapper { frames };
|
||||
// Create a dummy texture (we won't actually render, just test the logic)
|
||||
let dummy_texture = unsafe { std::mem::zeroed() };
|
||||
SpriteAtlas::new(dummy_texture, mapper)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pacman_new() {
|
||||
let graph = create_test_graph();
|
||||
let atlas = create_test_atlas();
|
||||
|
||||
let pacman = Pacman::new(&graph, 0, &atlas);
|
||||
|
||||
assert_eq!(pacman.traverser.direction, Direction::Left);
|
||||
assert!(matches!(pacman.traverser.position, crate::entity::graph::Position::AtNode(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_key_valid_directions() {
|
||||
let graph = create_test_graph();
|
||||
let atlas = create_test_atlas();
|
||||
let mut pacman = Pacman::new(&graph, 0, &atlas);
|
||||
|
||||
// Test that direction keys are handled correctly
|
||||
// The traverser might consume next_direction immediately, so we check the actual direction
|
||||
pacman.handle_key(Keycode::Up);
|
||||
// Check that the direction was set (either in next_direction or current direction)
|
||||
assert!(pacman.traverser.next_direction.is_some() || pacman.traverser.direction == Direction::Up);
|
||||
|
||||
pacman.handle_key(Keycode::Down);
|
||||
assert!(pacman.traverser.next_direction.is_some() || pacman.traverser.direction == Direction::Down);
|
||||
|
||||
pacman.handle_key(Keycode::Left);
|
||||
assert!(pacman.traverser.next_direction.is_some() || pacman.traverser.direction == Direction::Left);
|
||||
|
||||
pacman.handle_key(Keycode::Right);
|
||||
assert!(pacman.traverser.next_direction.is_some() || pacman.traverser.direction == Direction::Right);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_key_invalid_direction() {
|
||||
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;
|
||||
|
||||
// Test invalid key
|
||||
pacman.handle_key(Keycode::Space);
|
||||
|
||||
// Should not change direction
|
||||
assert_eq!(pacman.traverser.direction, original_direction);
|
||||
assert_eq!(pacman.traverser.next_direction, original_next_direction);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_pixel_pos_at_node() {
|
||||
let graph = create_test_graph();
|
||||
let atlas = create_test_atlas();
|
||||
let pacman = Pacman::new(&graph, 0, &atlas);
|
||||
|
||||
let pos = pacman.get_pixel_pos(&graph);
|
||||
assert_eq!(pos, glam::Vec2::new(0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_pixel_pos_between_nodes() {
|
||||
let graph = create_test_graph();
|
||||
let atlas = create_test_atlas();
|
||||
let mut pacman = Pacman::new(&graph, 0, &atlas);
|
||||
|
||||
// Move pacman between nodes - need to advance with a larger distance to ensure movement
|
||||
pacman.traverser.advance(&graph, 5.0); // Larger advance to ensure movement
|
||||
|
||||
let pos = pacman.get_pixel_pos(&graph);
|
||||
// Should be between (0,0) and (16,0), but not exactly at (8,0) due to advance distance
|
||||
assert!(pos.x >= 0.0 && pos.x <= 16.0);
|
||||
assert_eq!(pos.y, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tick_updates_texture() {
|
||||
let graph = create_test_graph();
|
||||
let atlas = create_test_atlas();
|
||||
let mut pacman = Pacman::new(&graph, 0, &atlas);
|
||||
|
||||
// Test that tick doesn't panic
|
||||
pacman.tick(0.016, &graph); // 60 FPS frame time
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pacman_initial_direction() {
|
||||
let graph = create_test_graph();
|
||||
let atlas = create_test_atlas();
|
||||
let pacman = Pacman::new(&graph, 0, &atlas);
|
||||
|
||||
// Pacman should start with the initial direction (Left)
|
||||
assert_eq!(pacman.traverser.direction, Direction::Left);
|
||||
// The next_direction might be consumed immediately when the traverser starts moving
|
||||
// So we just check that the direction is set correctly
|
||||
assert_eq!(pacman.traverser.direction, Direction::Left);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
//! This module provides a tick modulator, which can be used to slow down
|
||||
//! operations by a percentage.
|
||||
/// A tick modulator allows you to slow down operations by a percentage.
|
||||
///
|
||||
/// Unfortunately, switching to floating point numbers for entities can induce floating point errors, slow down calculations
|
||||
/// and make the game less deterministic. This is why we use a speed modulator instead.
|
||||
/// Additionally, with small integers, lowering the speed by a percentage is not possible. For example, if we have a speed of 2,
|
||||
/// and we want to slow it down by 10%, we would need to slow it down by 0.2. However, since we are using integers, we can't.
|
||||
/// The only amount you can slow it down by is 1, which is 50% of the speed.
|
||||
///
|
||||
/// The basic principle of the Speed Modulator is to instead 'skip' movement ticks every now and then.
|
||||
/// At 60 ticks per second, skips could happen several times per second, or once every few seconds.
|
||||
/// Whatever it be, as long as the tick rate is high enough, the human eye will not be able to tell the difference.
|
||||
///
|
||||
/// For example, if we want to slow down the speed by 10%, we would need to skip every 10th tick.
|
||||
pub trait TickModulator {
|
||||
/// Creates a new tick modulator.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `percent` - The percentage to slow down by, from 0.0 to 1.0.
|
||||
fn new(percent: f32) -> Self;
|
||||
/// Returns whether or not the operation should be performed on this tick.
|
||||
fn next(&mut self) -> bool;
|
||||
fn set_speed(&mut self, speed: f32);
|
||||
}
|
||||
|
||||
/// A simple tick modulator that skips every Nth tick.
|
||||
pub struct SimpleTickModulator {
|
||||
accumulator: f32,
|
||||
pixels_per_tick: f32,
|
||||
}
|
||||
|
||||
// TODO: Add tests for the tick modulator to ensure that it is working correctly.
|
||||
// TODO: Look into average precision and binary code modulation strategies to see
|
||||
// if they would be a better fit for this use case.
|
||||
impl SimpleTickModulator {
|
||||
pub fn new(pixels_per_tick: f32) -> Self {
|
||||
Self {
|
||||
accumulator: 0f32,
|
||||
pixels_per_tick: pixels_per_tick * 0.47,
|
||||
}
|
||||
}
|
||||
pub fn set_speed(&mut self, pixels_per_tick: f32) {
|
||||
self.pixels_per_tick = pixels_per_tick;
|
||||
}
|
||||
pub fn next(&mut self) -> bool {
|
||||
self.accumulator += self.pixels_per_tick;
|
||||
if self.accumulator >= 1f32 {
|
||||
self.accumulator -= 1f32;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
557
src/game.rs
557
src/game.rs
@@ -1,54 +1,39 @@
|
||||
//! This module contains the main game logic and state.
|
||||
use std::cell::RefCell;
|
||||
use std::ops::Not;
|
||||
use std::rc::Rc;
|
||||
|
||||
use anyhow::Result;
|
||||
use glam::{IVec2, UVec2};
|
||||
use rand::rngs::SmallRng;
|
||||
use rand::seq::IteratorRandom;
|
||||
use rand::SeedableRng;
|
||||
use sdl2::image::LoadTexture;
|
||||
use sdl2::keyboard::Keycode;
|
||||
use sdl2::render::{Texture, TextureCreator};
|
||||
use sdl2::video::WindowContext;
|
||||
use sdl2::{pixels::Color, render::Canvas, video::Window};
|
||||
use glam::UVec2;
|
||||
use sdl2::{
|
||||
image::LoadTexture,
|
||||
keyboard::Keycode,
|
||||
pixels::Color,
|
||||
render::{Canvas, RenderTarget, Texture, TextureCreator},
|
||||
video::WindowContext,
|
||||
};
|
||||
|
||||
use crate::asset::{get_asset_bytes, Asset};
|
||||
use crate::audio::Audio;
|
||||
use crate::constants::RAW_BOARD;
|
||||
use crate::debug::{DebugMode, DebugRenderer};
|
||||
use crate::entity::blinky::Blinky;
|
||||
use crate::entity::direction::Direction;
|
||||
use crate::entity::edible::{reconstruct_edibles, Edible, EdibleKind};
|
||||
use crate::entity::pacman::Pacman;
|
||||
use crate::entity::Renderable;
|
||||
use crate::map::Map;
|
||||
use crate::texture::animated::AnimatedTexture;
|
||||
use crate::texture::blinking::BlinkingTexture;
|
||||
use crate::texture::sprite::{AtlasMapper, AtlasTile, SpriteAtlas};
|
||||
use crate::texture::text::TextTexture;
|
||||
use crate::texture::{get_atlas_tile, sprite};
|
||||
use crate::{
|
||||
asset::{get_asset_bytes, Asset},
|
||||
audio::Audio,
|
||||
constants::RAW_BOARD,
|
||||
entity::pacman::Pacman,
|
||||
map::Map,
|
||||
texture::{
|
||||
sprite::{self, AtlasMapper, AtlasTile, SpriteAtlas},
|
||||
text::TextTexture,
|
||||
},
|
||||
};
|
||||
|
||||
/// The main game state.
|
||||
///
|
||||
/// Contains all the information necessary to run the game, including
|
||||
/// the game state, rendering resources, and audio.
|
||||
pub struct Game {
|
||||
// Game state
|
||||
pacman: Rc<RefCell<Pacman>>,
|
||||
blinky: Blinky,
|
||||
edibles: Vec<Edible>,
|
||||
map: Rc<RefCell<Map>>,
|
||||
score: u32,
|
||||
debug_mode: DebugMode,
|
||||
|
||||
// FPS tracking
|
||||
fps_1s: f64,
|
||||
fps_10s: f64,
|
||||
pub score: u32,
|
||||
pub map: Map,
|
||||
pub pacman: Pacman,
|
||||
pub debug_mode: bool,
|
||||
|
||||
// Rendering resources
|
||||
atlas: Rc<RefCell<SpriteAtlas>>,
|
||||
atlas: SpriteAtlas,
|
||||
map_texture: AtlasTile,
|
||||
text_texture: TextTexture,
|
||||
|
||||
@@ -57,13 +42,19 @@ pub struct Game {
|
||||
}
|
||||
|
||||
impl Game {
|
||||
/// Creates a new `Game` instance.
|
||||
pub fn new(
|
||||
texture_creator: &TextureCreator<WindowContext>,
|
||||
ttf_context: &sdl2::ttf::Sdl2TtfContext,
|
||||
_ttf_context: &sdl2::ttf::Sdl2TtfContext,
|
||||
_audio_subsystem: &sdl2::AudioSubsystem,
|
||||
) -> Game {
|
||||
let map = Rc::new(RefCell::new(Map::new(RAW_BOARD)));
|
||||
let map = Map::new(RAW_BOARD);
|
||||
|
||||
let pacman_start_pos = map.find_starting_position(0).unwrap();
|
||||
let pacman_start_node = *map
|
||||
.grid_to_node
|
||||
.get(&glam::IVec2::new(pacman_start_pos.x as i32, pacman_start_pos.y as i32))
|
||||
.expect("Pac-Man starting position not found in graph");
|
||||
|
||||
let atlas_bytes = get_asset_bytes(Asset::Atlas).expect("Failed to load asset");
|
||||
let atlas_texture = unsafe {
|
||||
let texture = texture_creator
|
||||
@@ -73,251 +64,79 @@ impl Game {
|
||||
};
|
||||
let atlas_json = get_asset_bytes(Asset::AtlasJson).expect("Failed to load asset");
|
||||
let atlas_mapper: AtlasMapper = serde_json::from_slice(&atlas_json).expect("Could not parse atlas JSON");
|
||||
let atlas = Rc::new(RefCell::new(SpriteAtlas::new(atlas_texture, atlas_mapper)));
|
||||
let pacman = Rc::new(RefCell::new(Pacman::new(
|
||||
UVec2::new(1, 1),
|
||||
Rc::clone(&atlas),
|
||||
Rc::clone(&map),
|
||||
)));
|
||||
let blinky = Blinky::new(UVec2::new(13, 11), Rc::clone(&atlas), Rc::clone(&map), Rc::clone(&pacman));
|
||||
let map_texture = get_atlas_tile(&atlas, "maze/full.png");
|
||||
let edibles = reconstruct_edibles(
|
||||
Rc::clone(&map),
|
||||
AnimatedTexture::new(vec![get_atlas_tile(&atlas, "maze/pellet.png")], 0),
|
||||
BlinkingTexture::new(
|
||||
AnimatedTexture::new(vec![get_atlas_tile(&atlas, "maze/energizer.png")], 0),
|
||||
17,
|
||||
17,
|
||||
),
|
||||
AnimatedTexture::new(vec![get_atlas_tile(&atlas, "edible/cherry.png")], 0),
|
||||
);
|
||||
let text_texture = TextTexture::new(Rc::clone(&atlas), 1.0);
|
||||
let atlas = SpriteAtlas::new(atlas_texture, atlas_mapper);
|
||||
|
||||
let mut map_texture = SpriteAtlas::get_tile(&atlas, "maze/full.png").expect("Failed to load map tile");
|
||||
map_texture.color = Some(Color::RGB(0x20, 0x20, 0xf9));
|
||||
|
||||
let text_texture = TextTexture::new(1.0);
|
||||
let audio = Audio::new();
|
||||
let pacman = Pacman::new(&map.graph, pacman_start_node, &atlas);
|
||||
|
||||
Game {
|
||||
pacman,
|
||||
blinky,
|
||||
edibles,
|
||||
map,
|
||||
score: 0,
|
||||
debug_mode: DebugMode::None,
|
||||
atlas,
|
||||
map,
|
||||
pacman,
|
||||
debug_mode: false,
|
||||
map_texture,
|
||||
text_texture,
|
||||
audio,
|
||||
fps_1s: 0.0,
|
||||
fps_10s: 0.0,
|
||||
atlas,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a keyboard event.
|
||||
pub fn keyboard_event(&mut self, keycode: Keycode) {
|
||||
// Change direction
|
||||
let direction = Direction::from_keycode(keycode);
|
||||
if direction.is_some() {
|
||||
self.pacman.borrow_mut().next_direction = direction;
|
||||
return;
|
||||
}
|
||||
self.pacman.handle_key(keycode);
|
||||
|
||||
// Toggle debug mode
|
||||
if keycode == Keycode::Space {
|
||||
self.debug_mode = match self.debug_mode {
|
||||
DebugMode::None => DebugMode::Grid,
|
||||
DebugMode::Grid => DebugMode::Pathfinding,
|
||||
DebugMode::Pathfinding => DebugMode::ValidPositions,
|
||||
DebugMode::ValidPositions => DebugMode::None,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle mute
|
||||
if keycode == Keycode::M {
|
||||
self.audio.set_mute(self.audio.is_muted().not());
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset game
|
||||
if keycode == Keycode::R {
|
||||
self.reset();
|
||||
self.audio.set_mute(!self.audio.is_muted());
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds points to the score.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `points` - The number of points to add.
|
||||
pub fn add_score(&mut self, points: u32) {
|
||||
self.score += points;
|
||||
pub fn tick(&mut self, dt: f32) {
|
||||
self.pacman.tick(dt, &self.map.graph);
|
||||
}
|
||||
|
||||
/// Updates the FPS tracking values.
|
||||
pub fn update_fps(&mut self, fps_1s: f64, fps_10s: f64) {
|
||||
self.fps_1s = fps_1s;
|
||||
self.fps_10s = fps_10s;
|
||||
pub fn draw<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>, backbuffer: &mut Texture) -> Result<()> {
|
||||
canvas.with_texture_canvas(backbuffer, |canvas| {
|
||||
canvas.set_draw_color(Color::BLACK);
|
||||
canvas.clear();
|
||||
self.map.render(canvas, &mut self.atlas, &mut self.map_texture);
|
||||
self.pacman.render(canvas, &mut self.atlas, &self.map.graph);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resets the game to its initial state.
|
||||
pub fn reset(&mut self) {
|
||||
// Reset the map to restore all pellets
|
||||
{
|
||||
let mut map = self.map.borrow_mut();
|
||||
map.reset();
|
||||
}
|
||||
|
||||
// Reset the score
|
||||
self.score = 0;
|
||||
|
||||
// Get valid positions from the cached flood fill and randomize positions in a single block
|
||||
{
|
||||
let mut map = self.map.borrow_mut();
|
||||
let valid_positions = map.get_valid_playable_positions();
|
||||
let mut rng = SmallRng::from_os_rng();
|
||||
|
||||
// Randomize Pac-Man position
|
||||
if let Some(pos) = valid_positions.iter().choose(&mut rng) {
|
||||
let mut pacman = self.pacman.borrow_mut();
|
||||
pacman.base.base.pixel_position = Map::cell_to_pixel(*pos);
|
||||
pacman.base.base.cell_position = *pos;
|
||||
pacman.base.in_tunnel = false;
|
||||
pacman.base.direction = Direction::Right;
|
||||
pacman.next_direction = None;
|
||||
pacman.stopped = false;
|
||||
}
|
||||
|
||||
// Randomize ghost position
|
||||
if let Some(pos) = valid_positions.iter().choose(&mut rng) {
|
||||
self.blinky.base.base.pixel_position = Map::cell_to_pixel(*pos);
|
||||
self.blinky.base.base.cell_position = *pos;
|
||||
self.blinky.base.in_tunnel = false;
|
||||
self.blinky.base.direction = Direction::Left;
|
||||
self.blinky.mode = crate::entity::ghost::GhostMode::Chase;
|
||||
}
|
||||
}
|
||||
|
||||
self.edibles = reconstruct_edibles(
|
||||
Rc::clone(&self.map),
|
||||
AnimatedTexture::new(vec![get_atlas_tile(&self.atlas, "maze/pellet.png")], 0),
|
||||
BlinkingTexture::new(
|
||||
AnimatedTexture::new(vec![get_atlas_tile(&self.atlas, "maze/energizer.png")], 0),
|
||||
12,
|
||||
12,
|
||||
),
|
||||
AnimatedTexture::new(vec![get_atlas_tile(&self.atlas, "edible/cherry.png")], 0),
|
||||
);
|
||||
}
|
||||
|
||||
/// Advances the game by one tick.
|
||||
pub fn tick(&mut self) {
|
||||
self.tick_entities();
|
||||
self.handle_edible_collisions();
|
||||
self.tick_entities();
|
||||
}
|
||||
fn tick_entities(&mut self) {
|
||||
self.pacman.borrow_mut().tick();
|
||||
self.blinky.tick();
|
||||
for edible in self.edibles.iter_mut() {
|
||||
if let EdibleKind::PowerPellet = edible.kind {
|
||||
if let crate::entity::edible::EdibleSprite::PowerPellet(texture) = &mut edible.sprite {
|
||||
texture.tick();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn handle_edible_collisions(&mut self) {
|
||||
let pacman = self.pacman.borrow();
|
||||
let mut eaten_indices = vec![];
|
||||
for (i, edible) in self.edibles.iter().enumerate() {
|
||||
if edible.collide(&*pacman) {
|
||||
eaten_indices.push(i);
|
||||
}
|
||||
}
|
||||
drop(pacman);
|
||||
for &i in eaten_indices.iter().rev() {
|
||||
let edible = &self.edibles[i];
|
||||
match edible.kind {
|
||||
EdibleKind::Pellet => {
|
||||
self.add_score(10);
|
||||
self.audio.eat();
|
||||
}
|
||||
EdibleKind::PowerPellet => {
|
||||
self.add_score(50);
|
||||
self.audio.eat();
|
||||
}
|
||||
EdibleKind::Fruit(_fruit) => {
|
||||
self.add_score(100);
|
||||
self.audio.eat();
|
||||
}
|
||||
}
|
||||
self.edibles.remove(i);
|
||||
// Set Pac-Man to skip the next movement tick
|
||||
self.pacman.borrow_mut().skip_move_tick = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the entire game to the canvas using a backbuffer.
|
||||
pub fn draw(&mut self, window_canvas: &mut Canvas<Window>, backbuffer: &mut Texture) -> Result<()> {
|
||||
window_canvas
|
||||
.with_texture_canvas(backbuffer, |texture_canvas| {
|
||||
let this = self as *mut Self;
|
||||
let this = unsafe { &mut *this };
|
||||
texture_canvas.set_draw_color(Color::BLACK);
|
||||
texture_canvas.clear();
|
||||
this.map.borrow_mut().render(texture_canvas, &mut this.map_texture);
|
||||
for edible in this.edibles.iter_mut() {
|
||||
let _ = edible.render(texture_canvas);
|
||||
}
|
||||
let _ = this.pacman.borrow_mut().render(texture_canvas);
|
||||
let _ = this.blinky.render(texture_canvas);
|
||||
this.render_ui_on(texture_canvas);
|
||||
match this.debug_mode {
|
||||
DebugMode::Grid => {
|
||||
DebugRenderer::draw_debug_grid(
|
||||
texture_canvas,
|
||||
&this.map.borrow(),
|
||||
this.pacman.borrow().base.base.cell_position,
|
||||
);
|
||||
let next_cell = <Pacman as crate::entity::Moving>::next_cell(&*this.pacman.borrow(), None);
|
||||
DebugRenderer::draw_next_cell(texture_canvas, &this.map.borrow(), next_cell.as_uvec2());
|
||||
}
|
||||
DebugMode::ValidPositions => {
|
||||
DebugRenderer::draw_valid_positions(texture_canvas, &mut this.map.borrow_mut());
|
||||
}
|
||||
DebugMode::Pathfinding => {
|
||||
DebugRenderer::draw_pathfinding(texture_canvas, &this.blinky, &this.map.borrow());
|
||||
}
|
||||
DebugMode::None => {}
|
||||
}
|
||||
})
|
||||
.map_err(|e| anyhow::anyhow!(format!("Failed to render to backbuffer: {e}")))
|
||||
}
|
||||
pub fn present_backbuffer(&self, canvas: &mut Canvas<Window>, backbuffer: &Texture) -> Result<()> {
|
||||
canvas.set_draw_color(Color::BLACK);
|
||||
canvas.clear();
|
||||
pub fn present_backbuffer<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>, backbuffer: &Texture) -> Result<()> {
|
||||
canvas.copy(backbuffer, None, None).map_err(anyhow::Error::msg)?;
|
||||
if self.debug_mode {
|
||||
self.map.debug_render_nodes(canvas);
|
||||
}
|
||||
self.draw_hud(canvas)?;
|
||||
canvas.present();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_ui_on<C: sdl2::render::RenderTarget>(&mut self, canvas: &mut sdl2::render::Canvas<C>) {
|
||||
fn draw_hud<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>) -> Result<()> {
|
||||
let lives = 3;
|
||||
let score_text = format!("{:02}", self.score);
|
||||
let x_offset = 12;
|
||||
let x_offset = 4;
|
||||
let y_offset = 2;
|
||||
let lives_offset = 3;
|
||||
let score_offset = 7 - (score_text.len() as i32);
|
||||
let gap_offset = 6;
|
||||
self.text_texture.set_scale(2.0);
|
||||
self.text_texture.render(
|
||||
self.text_texture.set_scale(1.0);
|
||||
let _ = self.text_texture.render(
|
||||
canvas,
|
||||
&mut self.atlas,
|
||||
&format!("{lives}UP HIGH SCORE "),
|
||||
UVec2::new(24 * lives_offset as u32 + x_offset, y_offset),
|
||||
Color::WHITE,
|
||||
UVec2::new(8 * lives_offset as u32 + x_offset, y_offset),
|
||||
);
|
||||
self.text_texture.render(
|
||||
let _ = self.text_texture.render(
|
||||
canvas,
|
||||
&mut self.atlas,
|
||||
&score_text,
|
||||
UVec2::new(24 * score_offset as u32 + x_offset, 24 + y_offset + gap_offset),
|
||||
Color::WHITE,
|
||||
UVec2::new(8 * score_offset as u32 + x_offset, 8 + y_offset),
|
||||
);
|
||||
|
||||
// Display FPS information in top-left corner
|
||||
@@ -329,5 +148,237 @@ impl Game {
|
||||
// IVec2::new(10, 10),
|
||||
// Color::RGB(255, 255, 0), // Yellow color for FPS display
|
||||
// );
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sdl2::keyboard::Keycode;
|
||||
use sdl2::pixels::Color;
|
||||
|
||||
fn create_test_game() -> Game {
|
||||
// Create a minimal test game without SDL dependencies
|
||||
// This is a simplified version for testing basic logic
|
||||
let map = Map::new(RAW_BOARD);
|
||||
let pacman_start_pos = map.find_starting_position(0).unwrap();
|
||||
let pacman_start_node = *map
|
||||
.grid_to_node
|
||||
.get(&glam::IVec2::new(pacman_start_pos.x as i32, pacman_start_pos.y as i32))
|
||||
.expect("Pac-Man starting position not found in graph");
|
||||
|
||||
// Create a dummy atlas for testing
|
||||
let mut mapper = std::collections::HashMap::new();
|
||||
mapper.insert(
|
||||
"pacman/up_a.png".to_string(),
|
||||
crate::texture::sprite::MapperFrame {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
mapper.insert(
|
||||
"pacman/up_b.png".to_string(),
|
||||
crate::texture::sprite::MapperFrame {
|
||||
x: 16,
|
||||
y: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
mapper.insert(
|
||||
"pacman/down_a.png".to_string(),
|
||||
crate::texture::sprite::MapperFrame {
|
||||
x: 32,
|
||||
y: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
mapper.insert(
|
||||
"pacman/down_b.png".to_string(),
|
||||
crate::texture::sprite::MapperFrame {
|
||||
x: 48,
|
||||
y: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
mapper.insert(
|
||||
"pacman/left_a.png".to_string(),
|
||||
crate::texture::sprite::MapperFrame {
|
||||
x: 64,
|
||||
y: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
mapper.insert(
|
||||
"pacman/left_b.png".to_string(),
|
||||
crate::texture::sprite::MapperFrame {
|
||||
x: 80,
|
||||
y: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
mapper.insert(
|
||||
"pacman/right_a.png".to_string(),
|
||||
crate::texture::sprite::MapperFrame {
|
||||
x: 96,
|
||||
y: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
mapper.insert(
|
||||
"pacman/right_b.png".to_string(),
|
||||
crate::texture::sprite::MapperFrame {
|
||||
x: 112,
|
||||
y: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
mapper.insert(
|
||||
"pacman/full.png".to_string(),
|
||||
crate::texture::sprite::MapperFrame {
|
||||
x: 128,
|
||||
y: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
mapper.insert(
|
||||
"maze/full.png".to_string(),
|
||||
crate::texture::sprite::MapperFrame {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 224,
|
||||
height: 248,
|
||||
},
|
||||
);
|
||||
|
||||
let atlas_mapper = crate::texture::sprite::AtlasMapper { frames: mapper };
|
||||
let dummy_texture = unsafe { std::mem::zeroed() };
|
||||
let atlas = crate::texture::sprite::SpriteAtlas::new(dummy_texture, atlas_mapper);
|
||||
|
||||
let mut map_texture = crate::texture::sprite::SpriteAtlas::get_tile(&atlas, "maze/full.png").unwrap();
|
||||
map_texture.color = Some(Color::RGB(0x20, 0x20, 0xf9));
|
||||
|
||||
let text_texture = TextTexture::new(1.0);
|
||||
let audio = Audio::new();
|
||||
let pacman = Pacman::new(&map.graph, pacman_start_node, &atlas);
|
||||
|
||||
Game {
|
||||
score: 0,
|
||||
map,
|
||||
pacman,
|
||||
debug_mode: false,
|
||||
map_texture,
|
||||
text_texture,
|
||||
audio,
|
||||
atlas,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_game_keyboard_event_direction_keys() {
|
||||
let mut game = create_test_game();
|
||||
|
||||
// Test that direction keys are handled
|
||||
game.keyboard_event(Keycode::Up);
|
||||
game.keyboard_event(Keycode::Down);
|
||||
game.keyboard_event(Keycode::Left);
|
||||
game.keyboard_event(Keycode::Right);
|
||||
|
||||
// Should not panic
|
||||
assert!(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_game_keyboard_event_mute_toggle() {
|
||||
let mut game = create_test_game();
|
||||
|
||||
let initial_mute_state = game.audio.is_muted();
|
||||
|
||||
// Toggle mute
|
||||
game.keyboard_event(Keycode::M);
|
||||
|
||||
// Mute state should have changed
|
||||
assert_eq!(game.audio.is_muted(), !initial_mute_state);
|
||||
|
||||
// Toggle again
|
||||
game.keyboard_event(Keycode::M);
|
||||
|
||||
// Should be back to original state
|
||||
assert_eq!(game.audio.is_muted(), initial_mute_state);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_game_tick() {
|
||||
let mut game = create_test_game();
|
||||
|
||||
// Test that tick doesn't panic
|
||||
game.tick(0.016); // 60 FPS frame time
|
||||
|
||||
assert!(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_game_initial_state() {
|
||||
let game = create_test_game();
|
||||
|
||||
assert_eq!(game.score, 0);
|
||||
assert!(!game.debug_mode);
|
||||
assert!(game.map.graph.node_count() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_game_debug_mode_toggle() {
|
||||
let mut game = create_test_game();
|
||||
|
||||
assert!(!game.debug_mode);
|
||||
|
||||
// Toggle debug mode (this would normally be done via Space key in the app)
|
||||
game.debug_mode = !game.debug_mode;
|
||||
|
||||
assert!(game.debug_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_game_score_increment() {
|
||||
let mut game = create_test_game();
|
||||
|
||||
let initial_score = game.score;
|
||||
game.score += 10;
|
||||
|
||||
assert_eq!(game.score, initial_score + 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_game_pacman_initialization() {
|
||||
let game = create_test_game();
|
||||
|
||||
// Check that Pac-Man was initialized
|
||||
assert_eq!(game.pacman.traverser.direction, crate::entity::direction::Direction::Left);
|
||||
// The traverser might start moving immediately, so we just check the direction
|
||||
assert_eq!(game.pacman.traverser.direction, crate::entity::direction::Direction::Left);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_game_map_initialization() {
|
||||
let game = create_test_game();
|
||||
|
||||
// Check that map was initialized
|
||||
assert!(game.map.graph.node_count() > 0);
|
||||
assert!(!game.map.grid_to_node.is_empty());
|
||||
|
||||
// Check that Pac-Man's starting position exists
|
||||
let pacman_pos = game.map.find_starting_position(0);
|
||||
assert!(pacman_pos.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
107
src/helper.rs
107
src/helper.rs
@@ -1,107 +0,0 @@
|
||||
//! This module contains helper functions that are used throughout the game.
|
||||
|
||||
use glam::UVec2;
|
||||
|
||||
/// Checks if two grid positions are adjacent to each other
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `a` - First position as (x, y) coordinates
|
||||
/// * `b` - Second position as (x, y) coordinates
|
||||
/// * `diagonal` - Whether to consider diagonal adjacency (true) or only orthogonal (false)
|
||||
///
|
||||
/// # Returns
|
||||
/// * `true` if positions are adjacent according to the diagonal parameter
|
||||
/// * `false` otherwise
|
||||
pub fn is_adjacent(a: UVec2, b: UVec2, diagonal: bool) -> bool {
|
||||
let dx = a.x.abs_diff(b.x);
|
||||
let dy = a.y.abs_diff(b.y);
|
||||
if diagonal {
|
||||
dx <= 1 && dy <= 1 && (dx != 0 || dy != 0)
|
||||
} else {
|
||||
(dx == 1 && dy == 0) || (dx == 0 && dy == 1)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_orthogonal_adjacency() {
|
||||
// Test orthogonal adjacency (diagonal = false)
|
||||
|
||||
// Same position should not be adjacent
|
||||
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(0, 0), false));
|
||||
|
||||
// Adjacent positions should be true
|
||||
assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(1, 0), false)); // Right
|
||||
assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(0, 1), false)); // Down
|
||||
assert!(is_adjacent(UVec2::new(1, 1), UVec2::new(0, 1), false)); // Left
|
||||
assert!(is_adjacent(UVec2::new(1, 1), UVec2::new(1, 0), false)); // Up
|
||||
|
||||
// Diagonal positions should be false
|
||||
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(1, 1), false));
|
||||
assert!(!is_adjacent(UVec2::new(0, 1), UVec2::new(1, 0), false));
|
||||
|
||||
// Positions more than 1 step away should be false
|
||||
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(2, 0), false));
|
||||
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(0, 2), false));
|
||||
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(2, 2), false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diagonal_adjacency() {
|
||||
// Test diagonal adjacency (diagonal = true)
|
||||
|
||||
// Same position should not be adjacent
|
||||
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(0, 0), true));
|
||||
|
||||
// Orthogonal adjacent positions should be true
|
||||
assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(1, 0), true)); // Right
|
||||
assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(0, 1), true)); // Down
|
||||
assert!(is_adjacent(UVec2::new(1, 1), UVec2::new(0, 1), true)); // Left
|
||||
assert!(is_adjacent(UVec2::new(1, 1), UVec2::new(1, 0), true)); // Up
|
||||
|
||||
// Diagonal adjacent positions should be true
|
||||
assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(1, 1), true)); // Down-right
|
||||
assert!(is_adjacent(UVec2::new(1, 0), UVec2::new(0, 1), true)); // Down-left
|
||||
assert!(is_adjacent(UVec2::new(0, 1), UVec2::new(1, 0), true)); // Up-right
|
||||
assert!(is_adjacent(UVec2::new(1, 1), UVec2::new(0, 0), true)); // Up-left
|
||||
|
||||
// Positions more than 1 step away should be false
|
||||
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(2, 0), true));
|
||||
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(0, 2), true));
|
||||
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(2, 2), true));
|
||||
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(1, 2), true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edge_cases() {
|
||||
// Test with larger coordinates
|
||||
assert!(is_adjacent(UVec2::new(100, 100), UVec2::new(101, 100), false));
|
||||
assert!(is_adjacent(UVec2::new(100, 100), UVec2::new(100, 101), false));
|
||||
assert!(!is_adjacent(UVec2::new(100, 100), UVec2::new(102, 100), false));
|
||||
|
||||
assert!(is_adjacent(UVec2::new(100, 100), UVec2::new(101, 101), true));
|
||||
assert!(!is_adjacent(UVec2::new(100, 100), UVec2::new(102, 102), true));
|
||||
|
||||
// Test with zero coordinates
|
||||
assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(1, 0), false));
|
||||
assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(0, 1), false));
|
||||
assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(1, 1), true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commutative_property() {
|
||||
// The function should work the same regardless of parameter order
|
||||
assert_eq!(
|
||||
is_adjacent(UVec2::new(1, 2), UVec2::new(2, 2), false),
|
||||
is_adjacent(UVec2::new(2, 2), UVec2::new(1, 2), false)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
is_adjacent(UVec2::new(1, 2), UVec2::new(2, 3), true),
|
||||
is_adjacent(UVec2::new(2, 3), UVec2::new(1, 2), true)
|
||||
);
|
||||
}
|
||||
}
|
||||
51
src/helpers.rs
Normal file
51
src/helpers.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use glam::{IVec2, UVec2};
|
||||
use sdl2::rect::Rect;
|
||||
|
||||
pub fn centered_with_size(pixel_pos: IVec2, size: UVec2) -> Rect {
|
||||
Rect::new(
|
||||
pixel_pos.x - size.x as i32 / 2,
|
||||
pixel_pos.y - size.y as i32 / 2,
|
||||
size.x,
|
||||
size.y,
|
||||
)
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
12
src/lib.rs
Normal file
12
src/lib.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
//! Pac-Man game library crate.
|
||||
|
||||
pub mod app;
|
||||
pub mod asset;
|
||||
pub mod audio;
|
||||
pub mod constants;
|
||||
pub mod emscripten;
|
||||
pub mod entity;
|
||||
pub mod game;
|
||||
pub mod helpers;
|
||||
pub mod map;
|
||||
pub mod texture;
|
||||
197
src/main.rs
197
src/main.rs
@@ -1,11 +1,7 @@
|
||||
#![windows_subsystem = "windows"]
|
||||
|
||||
use crate::constants::{BOARD_PIXEL_SIZE, SCALE};
|
||||
use crate::game::Game;
|
||||
use sdl2::event::{Event, WindowEvent};
|
||||
use sdl2::keyboard::Keycode;
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::event;
|
||||
use crate::{app::App, constants::LOOP_TIME};
|
||||
use tracing::info;
|
||||
use tracing_error::ErrorLayer;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
|
||||
@@ -52,28 +48,18 @@ unsafe fn attach_console() {
|
||||
// Do NOT call AllocConsole here - we don't want a console when launched from Explorer
|
||||
}
|
||||
|
||||
mod app;
|
||||
mod asset;
|
||||
mod audio;
|
||||
mod constants;
|
||||
mod debug;
|
||||
#[cfg(target_os = "emscripten")]
|
||||
mod emscripten;
|
||||
mod entity;
|
||||
mod game;
|
||||
mod helper;
|
||||
mod helpers;
|
||||
mod map;
|
||||
mod texture;
|
||||
|
||||
#[cfg(not(target_os = "emscripten"))]
|
||||
fn sleep(value: Duration) {
|
||||
spin_sleep::sleep(value);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "emscripten")]
|
||||
fn sleep(value: Duration) {
|
||||
emscripten::emscripten::sleep(value.as_millis() as u32);
|
||||
}
|
||||
|
||||
/// The main entry point of the application.
|
||||
///
|
||||
/// This function initializes SDL, the window, the game state, and then enters
|
||||
@@ -85,14 +71,6 @@ pub fn main() {
|
||||
attach_console();
|
||||
}
|
||||
|
||||
let sdl_context = sdl2::init().unwrap();
|
||||
let video_subsystem = sdl_context.video().unwrap();
|
||||
let audio_subsystem = sdl_context.audio().unwrap();
|
||||
let ttf_context = sdl2::ttf::init().unwrap();
|
||||
|
||||
// Set nearest-neighbor scaling for pixelated rendering
|
||||
sdl2::hint::set("SDL_RENDER_SCALE_QUALITY", "nearest");
|
||||
|
||||
// Setup tracing
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_ansi(cfg!(not(target_os = "emscripten")))
|
||||
@@ -102,173 +80,12 @@ pub fn main() {
|
||||
|
||||
tracing::subscriber::set_global_default(subscriber).expect("Could not set global default");
|
||||
|
||||
let window = video_subsystem
|
||||
.window(
|
||||
"Pac-Man",
|
||||
(BOARD_PIXEL_SIZE.x as f32 * SCALE).round() as u32,
|
||||
(BOARD_PIXEL_SIZE.y as f32 * SCALE).round() as u32,
|
||||
)
|
||||
.resizable()
|
||||
.position_centered()
|
||||
.build()
|
||||
.expect("Could not initialize window");
|
||||
let mut app = App::new().expect("Could not create app");
|
||||
|
||||
let mut canvas = window.into_canvas().build().expect("Could not build canvas");
|
||||
|
||||
canvas
|
||||
.set_logical_size(BOARD_PIXEL_SIZE.x, BOARD_PIXEL_SIZE.y)
|
||||
.expect("Could not set logical size");
|
||||
|
||||
let texture_creator = canvas.texture_creator();
|
||||
let texture_creator_static: &'static sdl2::render::TextureCreator<sdl2::video::WindowContext> =
|
||||
Box::leak(Box::new(texture_creator));
|
||||
let mut game = Game::new(texture_creator_static, &ttf_context, &audio_subsystem);
|
||||
game.audio.set_mute(cfg!(debug_assertions));
|
||||
|
||||
// Create a backbuffer texture for drawing
|
||||
let mut backbuffer = texture_creator_static
|
||||
.create_texture_target(None, BOARD_PIXEL_SIZE.x, BOARD_PIXEL_SIZE.y)
|
||||
.expect("Could not create backbuffer texture");
|
||||
|
||||
let mut event_pump = sdl_context.event_pump().expect("Could not get SDL EventPump");
|
||||
|
||||
// Initial draw and tick
|
||||
if let Err(e) = game.draw(&mut canvas, &mut backbuffer) {
|
||||
eprintln!("Initial draw failed: {}", e);
|
||||
}
|
||||
if let Err(e) = game.present_backbuffer(&mut canvas, &backbuffer) {
|
||||
eprintln!("Initial present failed: {}", e);
|
||||
}
|
||||
game.tick();
|
||||
|
||||
// The target time for each frame of the game loop (60 FPS).
|
||||
let loop_time = Duration::from_secs(1) / 60;
|
||||
|
||||
let mut paused = false;
|
||||
// Whether the window is currently shown.
|
||||
let mut shown = false;
|
||||
|
||||
// FPS tracking
|
||||
let mut frame_times_1s = Vec::new();
|
||||
let mut frame_times_10s = Vec::new();
|
||||
let mut last_frame_time = Instant::now();
|
||||
|
||||
event!(tracing::Level::INFO, "Starting game loop ({:?})", loop_time);
|
||||
let mut main_loop = || {
|
||||
let start = Instant::now();
|
||||
let current_frame_time = Instant::now();
|
||||
let frame_duration = current_frame_time.duration_since(last_frame_time);
|
||||
last_frame_time = current_frame_time;
|
||||
|
||||
// Update FPS tracking
|
||||
frame_times_1s.push(frame_duration);
|
||||
frame_times_10s.push(frame_duration);
|
||||
|
||||
// Keep only last 1 second of data (assuming 60 FPS = ~60 frames)
|
||||
while frame_times_1s.len() > 60 {
|
||||
frame_times_1s.remove(0);
|
||||
}
|
||||
|
||||
// Keep only last 10 seconds of data
|
||||
while frame_times_10s.len() > 600 {
|
||||
frame_times_10s.remove(0);
|
||||
}
|
||||
|
||||
// Calculate FPS averages
|
||||
let fps_1s = if !frame_times_1s.is_empty() {
|
||||
let total_time: Duration = frame_times_1s.iter().sum();
|
||||
if total_time > Duration::ZERO {
|
||||
frame_times_1s.len() as f64 / total_time.as_secs_f64()
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let fps_10s = if !frame_times_10s.is_empty() {
|
||||
let total_time: Duration = frame_times_10s.iter().sum();
|
||||
if total_time > Duration::ZERO {
|
||||
frame_times_10s.len() as f64 / total_time.as_secs_f64()
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// TODO: Fix key repeat delay issues by using a queue for keyboard events.
|
||||
// This would allow for instant key repeat without being affected by the
|
||||
// main loop's tick rate.
|
||||
for event in event_pump.poll_iter() {
|
||||
match event {
|
||||
Event::Window { win_event, .. } => match win_event {
|
||||
WindowEvent::Hidden => {
|
||||
event!(tracing::Level::DEBUG, "Window hidden");
|
||||
shown = false;
|
||||
}
|
||||
WindowEvent::Shown => {
|
||||
event!(tracing::Level::DEBUG, "Window shown");
|
||||
shown = true;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
// Handle quitting keys or window close
|
||||
Event::Quit { .. }
|
||||
| Event::KeyDown {
|
||||
keycode: Some(Keycode::Escape) | Some(Keycode::Q),
|
||||
..
|
||||
} => {
|
||||
event!(tracing::Level::INFO, "Exit requested. Exiting...");
|
||||
return false;
|
||||
}
|
||||
Event::KeyDown {
|
||||
keycode: Some(Keycode::P),
|
||||
..
|
||||
} => {
|
||||
paused = !paused;
|
||||
event!(tracing::Level::INFO, "{}", if paused { "Paused" } else { "Unpaused" });
|
||||
}
|
||||
Event::KeyDown { keycode, .. } => {
|
||||
game.keyboard_event(keycode.unwrap());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Implement a proper pausing mechanism that does not interfere with
|
||||
// statistic gathering and other background tasks.
|
||||
if !paused {
|
||||
game.tick();
|
||||
if let Err(e) = game.draw(&mut canvas, &mut backbuffer) {
|
||||
eprintln!("Failed to draw game: {}", e);
|
||||
}
|
||||
if let Err(e) = game.present_backbuffer(&mut canvas, &backbuffer) {
|
||||
eprintln!("Failed to present backbuffer: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Update game with FPS data
|
||||
game.update_fps(fps_1s, fps_10s);
|
||||
|
||||
if start.elapsed() < loop_time {
|
||||
let time = loop_time.saturating_sub(start.elapsed());
|
||||
if time != Duration::ZERO {
|
||||
sleep(time);
|
||||
}
|
||||
} else {
|
||||
event!(
|
||||
tracing::Level::WARN,
|
||||
"Game loop behind schedule by: {:?}",
|
||||
start.elapsed() - loop_time
|
||||
);
|
||||
}
|
||||
|
||||
true
|
||||
};
|
||||
info!("Starting game loop ({:?})", LOOP_TIME);
|
||||
|
||||
loop {
|
||||
if !main_loop() {
|
||||
if !app.run() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
168
src/map.rs
168
src/map.rs
@@ -1,168 +0,0 @@
|
||||
//! This module defines the game map and provides functions for interacting with it.
|
||||
use rand::rngs::SmallRng;
|
||||
use rand::seq::IteratorRandom;
|
||||
use rand::SeedableRng;
|
||||
|
||||
use crate::constants::{MapTile, BOARD_CELL_SIZE, BOARD_OFFSET, CELL_SIZE};
|
||||
use crate::texture::sprite::AtlasTile;
|
||||
use glam::{IVec2, UVec2};
|
||||
use once_cell::sync::OnceCell;
|
||||
use sdl2::rect::Rect;
|
||||
use sdl2::render::Canvas;
|
||||
use sdl2::video::Window;
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
|
||||
/// The game map.
|
||||
///
|
||||
/// The map is represented as a 2D array of `MapTile`s. It also stores a copy of
|
||||
/// the original map, which can be used to reset the map to its initial state.
|
||||
pub struct Map {
|
||||
/// The current state of the map.
|
||||
current: [[MapTile; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize],
|
||||
/// The default state of the map.
|
||||
default: [[MapTile; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize],
|
||||
}
|
||||
|
||||
impl Map {
|
||||
/// Creates a new `Map` instance from a raw board layout.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `raw_board` - A 2D array of characters representing the board layout.
|
||||
pub fn new(raw_board: [&str; BOARD_CELL_SIZE.y as usize]) -> Map {
|
||||
let mut map = [[MapTile::Empty; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize];
|
||||
|
||||
for (y, line) in raw_board.iter().enumerate().take(BOARD_CELL_SIZE.y as usize) {
|
||||
for (x, character) in line.chars().enumerate().take(BOARD_CELL_SIZE.x as usize) {
|
||||
let tile = match character {
|
||||
'#' => MapTile::Wall,
|
||||
'.' => MapTile::Pellet,
|
||||
'o' => MapTile::PowerPellet,
|
||||
' ' => MapTile::Empty,
|
||||
'T' => MapTile::Tunnel,
|
||||
c @ '0' | c @ '1' | c @ '2' | c @ '3' | c @ '4' => MapTile::StartingPosition(c.to_digit(10).unwrap() as u8),
|
||||
'=' => MapTile::Empty,
|
||||
_ => panic!("Unknown character in board: {character}"),
|
||||
};
|
||||
map[x][y] = tile;
|
||||
}
|
||||
}
|
||||
|
||||
Map {
|
||||
current: map,
|
||||
default: map,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resets the map to its original state.
|
||||
pub fn reset(&mut self) {
|
||||
// Restore the map to its original state
|
||||
for (x, col) in self.current.iter_mut().enumerate().take(BOARD_CELL_SIZE.x as usize) {
|
||||
for (y, cell) in col.iter_mut().enumerate().take(BOARD_CELL_SIZE.y as usize) {
|
||||
*cell = self.default[x][y];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the tile at the given cell coordinates.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cell` - The cell coordinates, in grid coordinates.
|
||||
pub fn get_tile(&self, cell: IVec2) -> Option<MapTile> {
|
||||
let x = cell.x as usize;
|
||||
let y = cell.y as usize;
|
||||
|
||||
if x >= BOARD_CELL_SIZE.x as usize || y >= BOARD_CELL_SIZE.y as usize {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(self.current[x][y])
|
||||
}
|
||||
|
||||
/// Sets the tile at the given cell coordinates.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cell` - The cell coordinates, in grid coordinates.
|
||||
/// * `tile` - The tile to set.
|
||||
pub fn set_tile(&mut self, cell: IVec2, tile: MapTile) -> bool {
|
||||
let x = cell.x as usize;
|
||||
let y = cell.y as usize;
|
||||
|
||||
if x >= BOARD_CELL_SIZE.x as usize || y >= BOARD_CELL_SIZE.y as usize {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.current[x][y] = tile;
|
||||
true
|
||||
}
|
||||
|
||||
/// Converts cell coordinates to pixel coordinates.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cell` - The cell coordinates, in grid coordinates.
|
||||
pub fn cell_to_pixel(cell: UVec2) -> IVec2 {
|
||||
IVec2::new((cell.x * CELL_SIZE) as i32, ((cell.y + BOARD_OFFSET.y) * CELL_SIZE) as i32)
|
||||
}
|
||||
|
||||
/// Returns a reference to a cached vector of all valid playable positions in the maze.
|
||||
/// This is computed once using a flood fill from a random pellet, and then cached.
|
||||
pub fn get_valid_playable_positions(&mut self) -> &Vec<UVec2> {
|
||||
use MapTile::*;
|
||||
static CACHE: OnceCell<Vec<UVec2>> = OnceCell::new();
|
||||
if let Some(cached) = CACHE.get() {
|
||||
return cached;
|
||||
}
|
||||
// Find a random starting pellet
|
||||
let mut pellet_positions = vec![];
|
||||
for (x, col) in self.current.iter().enumerate().take(BOARD_CELL_SIZE.x as usize) {
|
||||
for (y, &cell) in col.iter().enumerate().take(BOARD_CELL_SIZE.y as usize) {
|
||||
match cell {
|
||||
Pellet | PowerPellet => pellet_positions.push(UVec2::new(x as u32, y as u32)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut rng = SmallRng::from_os_rng();
|
||||
let &start = pellet_positions
|
||||
.iter()
|
||||
.choose(&mut rng)
|
||||
.expect("No pellet found for flood fill");
|
||||
// Flood fill
|
||||
let mut visited = HashSet::new();
|
||||
let mut queue = VecDeque::new();
|
||||
|
||||
queue.push_back(start);
|
||||
while let Some(pos) = queue.pop_front() {
|
||||
if !visited.insert(pos) {
|
||||
continue;
|
||||
}
|
||||
|
||||
match self.current[pos.x as usize][pos.y as usize] {
|
||||
Empty | Pellet | PowerPellet => {
|
||||
for offset in [IVec2::new(-1, 0), IVec2::new(1, 0), IVec2::new(0, -1), IVec2::new(0, 1)] {
|
||||
let neighbor = (pos.as_ivec2() + offset).as_uvec2();
|
||||
if neighbor.x < BOARD_CELL_SIZE.x && neighbor.y < BOARD_CELL_SIZE.y {
|
||||
let neighbor_tile = self.current[neighbor.x as usize][neighbor.y as usize];
|
||||
if matches!(neighbor_tile, Empty | Pellet | PowerPellet) {
|
||||
queue.push_back(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
StartingPosition(_) | Wall | Tunnel => {}
|
||||
}
|
||||
}
|
||||
let mut result: Vec<UVec2> = visited.into_iter().collect();
|
||||
result.sort_unstable_by_key(|v| (v.x, v.y));
|
||||
CACHE.get_or_init(|| result)
|
||||
}
|
||||
|
||||
/// Renders the map to the given canvas using the provided map texture.
|
||||
pub fn render(&self, canvas: &mut Canvas<Window>, map_texture: &mut AtlasTile) {
|
||||
let dest = Rect::new(0, 0, CELL_SIZE * BOARD_CELL_SIZE.x, CELL_SIZE * BOARD_CELL_SIZE.y);
|
||||
let _ = map_texture.render(canvas, dest);
|
||||
}
|
||||
}
|
||||
529
src/map/builder.rs
Normal file
529
src/map/builder.rs
Normal file
@@ -0,0 +1,529 @@
|
||||
//! Map construction and building functionality.
|
||||
|
||||
use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE};
|
||||
use crate::entity::direction::{Direction, DIRECTIONS};
|
||||
use crate::entity::graph::{Graph, Node, NodeId};
|
||||
use crate::map::parser::MapTileParser;
|
||||
use crate::map::render::MapRenderer;
|
||||
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
|
||||
use glam::{IVec2, UVec2, Vec2};
|
||||
use sdl2::render::{Canvas, RenderTarget};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use tracing::debug;
|
||||
|
||||
pub struct NodePositions {
|
||||
pub pacman: NodeId,
|
||||
pub blinky: NodeId,
|
||||
pub pinky: NodeId,
|
||||
pub inky: NodeId,
|
||||
pub clyde: NodeId,
|
||||
}
|
||||
|
||||
/// The game map, responsible for holding the tile-based layout and the navigation graph.
|
||||
///
|
||||
/// The map is represented as a 2D array of `MapTile`s. It also stores a navigation
|
||||
/// `Graph` that entities like Pac-Man and ghosts use for movement. The graph is
|
||||
/// generated from the walkable tiles of the map.
|
||||
pub struct Map {
|
||||
/// The current state of the map.
|
||||
current: [[MapTile; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize],
|
||||
/// The node map for entity movement.
|
||||
pub graph: Graph,
|
||||
/// A mapping from grid positions to node IDs.
|
||||
pub grid_to_node: HashMap<IVec2, NodeId>,
|
||||
/// A mapping of the starting positions of the entities.
|
||||
pub start_positions: NodePositions,
|
||||
}
|
||||
|
||||
impl Map {
|
||||
/// Creates a new `Map` instance from a raw board layout.
|
||||
///
|
||||
/// This constructor initializes the map tiles based on the provided character layout
|
||||
/// and then generates a navigation graph from the walkable areas.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// This function will panic if the board layout contains unknown characters or if
|
||||
/// the house door is not defined by exactly two '=' characters.
|
||||
pub fn new(raw_board: [&str; BOARD_CELL_SIZE.y as usize]) -> Map {
|
||||
let parsed_map = MapTileParser::parse_board(raw_board).expect("Failed to parse board layout");
|
||||
|
||||
let map = parsed_map.tiles;
|
||||
let house_door = parsed_map.house_door;
|
||||
let tunnel_ends = parsed_map.tunnel_ends;
|
||||
|
||||
let mut graph = Graph::new();
|
||||
let mut grid_to_node = HashMap::new();
|
||||
|
||||
let cell_offset = Vec2::splat(CELL_SIZE as f32 / 2.0);
|
||||
|
||||
// Find a starting point for the graph generation, preferably Pac-Man's position.
|
||||
let start_pos = (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::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
|
||||
let mut queue = VecDeque::new();
|
||||
queue.push_back(start_pos);
|
||||
let pos = Vec2::new(
|
||||
(start_pos.x * CELL_SIZE as i32) as f32,
|
||||
(start_pos.y * CELL_SIZE as i32) as f32,
|
||||
) + cell_offset;
|
||||
let node_id = graph.add_node(Node { position: pos });
|
||||
grid_to_node.insert(start_pos, node_id);
|
||||
|
||||
// Iterate over the queue, adding nodes to the graph and connecting them to their neighbors
|
||||
while let Some(source_position) = queue.pop_front() {
|
||||
for &dir in DIRECTIONS.iter() {
|
||||
let new_position = source_position + dir.as_ivec2();
|
||||
|
||||
// Skip if the new position is out of bounds
|
||||
if new_position.x < 0
|
||||
|| new_position.x >= BOARD_CELL_SIZE.x as i32
|
||||
|| new_position.y < 0
|
||||
|| new_position.y >= BOARD_CELL_SIZE.y as i32
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if the new position is already in the graph
|
||||
if grid_to_node.contains_key(&new_position) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if the new position is not a walkable tile
|
||||
if matches!(
|
||||
map[new_position.x as usize][new_position.y as usize],
|
||||
MapTile::Pellet | MapTile::PowerPellet | MapTile::Empty | MapTile::Tunnel | MapTile::StartingPosition(_)
|
||||
) {
|
||||
// Add the new position to the graph/queue
|
||||
let pos = Vec2::new(
|
||||
(new_position.x * CELL_SIZE as i32) as f32,
|
||||
(new_position.y * CELL_SIZE as i32) as f32,
|
||||
) + cell_offset;
|
||||
let new_node_id = graph.add_node(Node { position: pos });
|
||||
grid_to_node.insert(new_position, new_node_id);
|
||||
queue.push_back(new_position);
|
||||
|
||||
// Connect the new node to the source node
|
||||
let source_node_id = grid_to_node
|
||||
.get(&source_position)
|
||||
.unwrap_or_else(|| panic!("Source node not found for {source_position}"));
|
||||
|
||||
// Connect the new node to the source node
|
||||
graph
|
||||
.connect(*source_node_id, new_node_id, false, None, dir)
|
||||
.expect("Failed to add edge");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// While most nodes are already connected to their neighbors, some may not be, so we need to connect them
|
||||
for (grid_pos, &node_id) in &grid_to_node {
|
||||
for dir in DIRECTIONS {
|
||||
// If the node doesn't have an edge in this direction, look for a neighbor in that direction
|
||||
if graph.adjacency_list[node_id].get(dir).is_none() {
|
||||
let neighbor = grid_pos + dir.as_ivec2();
|
||||
// If the neighbor exists, connect the node to it
|
||||
if let Some(&neighbor_id) = grid_to_node.get(&neighbor) {
|
||||
graph
|
||||
.connect(node_id, neighbor_id, false, None, dir)
|
||||
.expect("Failed to add edge");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build house structure
|
||||
let (house_entrance_node_id, left_center_node_id, center_center_node_id, right_center_node_id) =
|
||||
Self::build_house(&mut graph, &grid_to_node, &house_door);
|
||||
|
||||
let start_positions = NodePositions {
|
||||
pacman: grid_to_node[&start_pos],
|
||||
blinky: house_entrance_node_id,
|
||||
pinky: left_center_node_id,
|
||||
inky: right_center_node_id,
|
||||
clyde: center_center_node_id,
|
||||
};
|
||||
|
||||
// Build tunnel connections
|
||||
Self::build_tunnels(&mut graph, &grid_to_node, &tunnel_ends);
|
||||
|
||||
Map {
|
||||
current: map,
|
||||
grid_to_node,
|
||||
graph,
|
||||
start_positions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds the starting position for a given entity ID.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `entity_id` - The entity ID (0 for Pac-Man, 1-4 for ghosts)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The starting position as a grid coordinate (`UVec2`), or `None` if not found.
|
||||
pub fn find_starting_position(&self, entity_id: u8) -> Option<UVec2> {
|
||||
for (x, col) in self.current.iter().enumerate().take(BOARD_CELL_SIZE.x as usize) {
|
||||
for (y, &cell) in col.iter().enumerate().take(BOARD_CELL_SIZE.y as usize) {
|
||||
if let MapTile::StartingPosition(id) = cell {
|
||||
if id == entity_id {
|
||||
return Some(UVec2::new(x as u32, y as u32));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Renders the map to the given canvas.
|
||||
///
|
||||
/// This function draws the static map texture to the screen at the correct
|
||||
/// position and scale.
|
||||
pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, map_texture: &mut AtlasTile) {
|
||||
MapRenderer::render_map(canvas, atlas, map_texture);
|
||||
}
|
||||
|
||||
/// Renders a debug visualization of the navigation graph.
|
||||
///
|
||||
/// This function is intended for development and debugging purposes. It draws the
|
||||
/// nodes and edges of the graph on top of the map, allowing for visual
|
||||
/// inspection of the navigation paths.
|
||||
pub fn debug_render_nodes<T: RenderTarget>(&self, canvas: &mut Canvas<T>) {
|
||||
MapRenderer::debug_render_nodes(&self.graph, canvas);
|
||||
}
|
||||
|
||||
/// Builds the house structure in the graph.
|
||||
fn build_house(
|
||||
graph: &mut Graph,
|
||||
grid_to_node: &HashMap<IVec2, NodeId>,
|
||||
house_door: &[Option<IVec2>; 2],
|
||||
) -> (usize, usize, usize, usize) {
|
||||
// Calculate the position of the house entrance node
|
||||
let (house_entrance_node_id, house_entrance_node_position) = {
|
||||
// Translate the grid positions to the actual node ids
|
||||
let left_node = grid_to_node
|
||||
.get(&(house_door[0].expect("First house door position not acquired") + Direction::Left.as_ivec2()))
|
||||
.expect("Left house door node not found");
|
||||
let right_node = grid_to_node
|
||||
.get(&(house_door[1].expect("Second house door position not acquired") + Direction::Right.as_ivec2()))
|
||||
.expect("Right house door node not found");
|
||||
|
||||
// Calculate the position of the house node
|
||||
let (node_id, node_position) = {
|
||||
let left_pos = graph.get_node(*left_node).unwrap().position;
|
||||
let right_pos = graph.get_node(*right_node).unwrap().position;
|
||||
let house_node = graph.add_node(Node {
|
||||
position: left_pos.lerp(right_pos, 0.5),
|
||||
});
|
||||
(house_node, left_pos.lerp(right_pos, 0.5))
|
||||
};
|
||||
|
||||
// Connect the house door to the left and right nodes
|
||||
graph
|
||||
.connect(node_id, *left_node, true, None, Direction::Left)
|
||||
.expect("Failed to connect house door to left node");
|
||||
graph
|
||||
.connect(node_id, *right_node, true, None, Direction::Right)
|
||||
.expect("Failed to connect house door to right node");
|
||||
|
||||
(node_id, node_position)
|
||||
};
|
||||
|
||||
// A helper function to help create the various 'lines' of nodes within the house
|
||||
let create_house_line = |graph: &mut Graph, center_pos: Vec2| -> (NodeId, NodeId) {
|
||||
// Place the nodes at, above, and below the center position
|
||||
let center_node_id = graph.add_node(Node { position: center_pos });
|
||||
let top_node_id = graph.add_node(Node {
|
||||
position: center_pos + (Direction::Up.as_ivec2() * (CELL_SIZE as i32 / 2)).as_vec2(),
|
||||
});
|
||||
let bottom_node_id = graph.add_node(Node {
|
||||
position: center_pos + (Direction::Down.as_ivec2() * (CELL_SIZE as i32 / 2)).as_vec2(),
|
||||
});
|
||||
|
||||
// Connect the center node to the top and bottom nodes
|
||||
graph
|
||||
.connect(center_node_id, top_node_id, false, None, Direction::Up)
|
||||
.expect("Failed to connect house line to left node");
|
||||
graph
|
||||
.connect(center_node_id, bottom_node_id, false, None, Direction::Down)
|
||||
.expect("Failed to connect house line to right node");
|
||||
|
||||
(center_node_id, top_node_id)
|
||||
};
|
||||
|
||||
// Calculate the position of the center line's center node
|
||||
let center_line_center_position =
|
||||
house_entrance_node_position + (Direction::Down.as_ivec2() * (3 * CELL_SIZE as i32)).as_vec2();
|
||||
|
||||
// Create the center line
|
||||
let (center_center_node_id, center_top_node_id) = create_house_line(graph, center_line_center_position);
|
||||
|
||||
// Connect the house entrance to the top line
|
||||
graph
|
||||
.connect(house_entrance_node_id, center_top_node_id, false, None, Direction::Down)
|
||||
.expect("Failed to connect house entrance to top line");
|
||||
|
||||
// Create the left line
|
||||
let (left_center_node_id, _) = create_house_line(
|
||||
graph,
|
||||
center_line_center_position + (Direction::Left.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
|
||||
);
|
||||
|
||||
// Create the right line
|
||||
let (right_center_node_id, _) = create_house_line(
|
||||
graph,
|
||||
center_line_center_position + (Direction::Right.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
|
||||
);
|
||||
|
||||
debug!("Left center node id: {left_center_node_id}");
|
||||
|
||||
// Connect the center line to the left and right lines
|
||||
graph
|
||||
.connect(center_center_node_id, left_center_node_id, false, None, Direction::Left)
|
||||
.expect("Failed to connect house entrance to left top line");
|
||||
|
||||
graph
|
||||
.connect(center_center_node_id, right_center_node_id, false, None, Direction::Right)
|
||||
.expect("Failed to connect house entrance to right top line");
|
||||
|
||||
debug!("House entrance node id: {house_entrance_node_id}");
|
||||
|
||||
(
|
||||
house_entrance_node_id,
|
||||
left_center_node_id,
|
||||
center_center_node_id,
|
||||
right_center_node_id,
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds the tunnel connections in the graph.
|
||||
fn build_tunnels(graph: &mut Graph, grid_to_node: &HashMap<IVec2, NodeId>, tunnel_ends: &[Option<IVec2>; 2]) {
|
||||
// Create the hidden tunnel nodes
|
||||
let left_tunnel_hidden_node_id = {
|
||||
let left_tunnel_entrance_node_id = grid_to_node[&tunnel_ends[0].expect("Left tunnel end not found")];
|
||||
let left_tunnel_entrance_node = graph
|
||||
.get_node(left_tunnel_entrance_node_id)
|
||||
.expect("Left tunnel entrance node not found");
|
||||
|
||||
graph
|
||||
.connect_node(
|
||||
left_tunnel_entrance_node_id,
|
||||
Direction::Left,
|
||||
Node {
|
||||
position: left_tunnel_entrance_node.position
|
||||
+ (Direction::Left.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
|
||||
},
|
||||
)
|
||||
.expect("Failed to connect left tunnel entrance to left tunnel hidden node")
|
||||
};
|
||||
|
||||
// Create the right tunnel nodes
|
||||
let right_tunnel_hidden_node_id = {
|
||||
let right_tunnel_entrance_node_id = grid_to_node[&tunnel_ends[1].expect("Right tunnel end not found")];
|
||||
let right_tunnel_entrance_node = graph
|
||||
.get_node(right_tunnel_entrance_node_id)
|
||||
.expect("Right tunnel entrance node not found");
|
||||
|
||||
graph
|
||||
.connect_node(
|
||||
right_tunnel_entrance_node_id,
|
||||
Direction::Right,
|
||||
Node {
|
||||
position: right_tunnel_entrance_node.position
|
||||
+ (Direction::Right.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
|
||||
},
|
||||
)
|
||||
.expect("Failed to connect right tunnel entrance to right tunnel hidden node")
|
||||
};
|
||||
|
||||
// Connect the left tunnel hidden node to the right tunnel hidden node
|
||||
graph
|
||||
.connect(
|
||||
left_tunnel_hidden_node_id,
|
||||
right_tunnel_hidden_node_id,
|
||||
false,
|
||||
Some(0.0),
|
||||
Direction::Left,
|
||||
)
|
||||
.expect("Failed to connect left tunnel hidden node to right tunnel hidden node");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::constants::{BOARD_CELL_SIZE, CELL_SIZE};
|
||||
use glam::{IVec2, UVec2, Vec2};
|
||||
|
||||
fn create_minimal_test_board() -> [&'static str; BOARD_CELL_SIZE.y as usize] {
|
||||
let mut board = [""; BOARD_CELL_SIZE.y as usize];
|
||||
// Create a minimal valid board with house doors
|
||||
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..##.......0 .......##..o#";
|
||||
board[24] = "###.##.##.########.##.##.###";
|
||||
board[25] = "###.##.##.########.##.##.###";
|
||||
board[26] = "#......##....##....##......#";
|
||||
board[27] = "#.##########.##.##########.#";
|
||||
board[28] = "#.##########.##.##########.#";
|
||||
board[29] = "#..........................#";
|
||||
board[30] = "############################";
|
||||
board
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_new() {
|
||||
let board = create_minimal_test_board();
|
||||
let map = Map::new(board);
|
||||
|
||||
assert!(map.graph.node_count() > 0);
|
||||
assert!(!map.grid_to_node.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_starting_position_pacman() {
|
||||
let board = create_minimal_test_board();
|
||||
let map = Map::new(board);
|
||||
|
||||
let pacman_pos = map.find_starting_position(0);
|
||||
assert!(pacman_pos.is_some());
|
||||
|
||||
let pos = pacman_pos.unwrap();
|
||||
// Pacman should be found somewhere in the board
|
||||
assert!(pos.x < BOARD_CELL_SIZE.x);
|
||||
assert!(pos.y < BOARD_CELL_SIZE.y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_starting_position_ghost() {
|
||||
let board = create_minimal_test_board();
|
||||
let map = Map::new(board);
|
||||
|
||||
// Test for ghost 1 (might not exist in this board)
|
||||
let ghost_pos = map.find_starting_position(1);
|
||||
// Ghost 1 might not exist, so this could be None
|
||||
if let Some(pos) = ghost_pos {
|
||||
assert!(pos.x < BOARD_CELL_SIZE.x);
|
||||
assert!(pos.y < BOARD_CELL_SIZE.y);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_starting_position_nonexistent() {
|
||||
let board = create_minimal_test_board();
|
||||
let map = Map::new(board);
|
||||
|
||||
let pos = map.find_starting_position(99); // Non-existent entity
|
||||
assert!(pos.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_graph_construction() {
|
||||
let board = create_minimal_test_board();
|
||||
let map = Map::new(board);
|
||||
|
||||
// Check that nodes were created
|
||||
assert!(map.graph.node_count() > 0);
|
||||
|
||||
// Check that grid_to_node mapping was created
|
||||
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_grid_to_node_mapping() {
|
||||
let board = create_minimal_test_board();
|
||||
let map = Map::new(board);
|
||||
|
||||
// Check that Pac-Man's position is mapped
|
||||
let pacman_pos = map.find_starting_position(0).unwrap();
|
||||
let grid_pos = IVec2::new(pacman_pos.x as i32, pacman_pos.y as i32);
|
||||
|
||||
assert!(map.grid_to_node.contains_key(&grid_pos));
|
||||
let node_id = map.grid_to_node[&grid_pos];
|
||||
assert!(map.graph.get_node(node_id).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_node_positions() {
|
||||
let board = create_minimal_test_board();
|
||||
let map = Map::new(board);
|
||||
|
||||
// Check that node positions are correctly calculated
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_adjacent_connections() {
|
||||
let board = create_minimal_test_board();
|
||||
let map = Map::new(board);
|
||||
|
||||
// Check that adjacent walkable tiles are connected
|
||||
// Find any node that has connections
|
||||
let mut found_connected_node = false;
|
||||
for (grid_pos, &node_id) in &map.grid_to_node {
|
||||
let intersection = &map.graph.adjacency_list[node_id];
|
||||
if intersection.edges().next().is_some() {
|
||||
found_connected_node = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(found_connected_node);
|
||||
}
|
||||
}
|
||||
8
src/map/mod.rs
Normal file
8
src/map/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
//! This module defines the game map and provides functions for interacting with it.
|
||||
|
||||
pub mod builder;
|
||||
pub mod parser;
|
||||
pub mod render;
|
||||
|
||||
// Re-export main types for convenience
|
||||
pub use builder::Map;
|
||||
168
src/map/parser.rs
Normal file
168
src/map/parser.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
//! Map parsing functionality for converting raw board layouts into structured data.
|
||||
|
||||
use crate::constants::{MapTile, BOARD_CELL_SIZE};
|
||||
use glam::IVec2;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Error type for map parsing operations.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ParseError {
|
||||
#[error("Unknown character in board: {0}")]
|
||||
UnknownCharacter(char),
|
||||
#[error("House door must have exactly 2 positions, found {0}")]
|
||||
InvalidHouseDoorCount(usize),
|
||||
}
|
||||
|
||||
/// Represents the parsed data from a raw board layout.
|
||||
#[derive(Debug)]
|
||||
pub struct ParsedMap {
|
||||
/// The parsed tile layout.
|
||||
pub tiles: [[MapTile; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize],
|
||||
/// The positions of the house door tiles.
|
||||
pub house_door: [Option<IVec2>; 2],
|
||||
/// The positions of the tunnel end tiles.
|
||||
pub tunnel_ends: [Option<IVec2>; 2],
|
||||
}
|
||||
|
||||
/// Parser for converting raw board layouts into structured map data.
|
||||
pub struct MapTileParser;
|
||||
|
||||
impl MapTileParser {
|
||||
/// Parses a single character into a map tile.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `c` - The character to parse
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The parsed map tile, or an error if the character is unknown.
|
||||
pub fn parse_character(c: char) -> Result<MapTile, ParseError> {
|
||||
match c {
|
||||
'#' => Ok(MapTile::Wall),
|
||||
'.' => Ok(MapTile::Pellet),
|
||||
'o' => Ok(MapTile::PowerPellet),
|
||||
' ' => Ok(MapTile::Empty),
|
||||
'T' => Ok(MapTile::Tunnel),
|
||||
c @ '0'..='4' => Ok(MapTile::StartingPosition(c.to_digit(10).unwrap() as u8)),
|
||||
'=' => Ok(MapTile::Wall), // House door is represented as a wall tile
|
||||
_ => Err(ParseError::UnknownCharacter(c)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a raw board layout into structured map data.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `raw_board` - The raw board layout as an array of strings
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The parsed map data, or an error if parsing fails.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the board contains unknown characters or if the house door
|
||||
/// is not properly defined by exactly two '=' characters.
|
||||
pub fn parse_board(raw_board: [&str; BOARD_CELL_SIZE.y as usize]) -> Result<ParsedMap, ParseError> {
|
||||
let mut tiles = [[MapTile::Empty; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize];
|
||||
let mut house_door = [None; 2];
|
||||
let mut tunnel_ends = [None; 2];
|
||||
|
||||
for (y, line) in raw_board.iter().enumerate().take(BOARD_CELL_SIZE.y as usize) {
|
||||
for (x, character) in line.chars().enumerate().take(BOARD_CELL_SIZE.x as usize) {
|
||||
let tile = Self::parse_character(character)?;
|
||||
|
||||
// Track special positions
|
||||
match tile {
|
||||
MapTile::Tunnel => {
|
||||
if tunnel_ends[0].is_none() {
|
||||
tunnel_ends[0] = Some(IVec2::new(x as i32, y as i32));
|
||||
} else {
|
||||
tunnel_ends[1] = Some(IVec2::new(x as i32, y as i32));
|
||||
}
|
||||
}
|
||||
MapTile::Wall if character == '=' => {
|
||||
if house_door[0].is_none() {
|
||||
house_door[0] = Some(IVec2::new(x as i32, y as i32));
|
||||
} else {
|
||||
house_door[1] = Some(IVec2::new(x as i32, y as i32));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
tiles[x][y] = tile;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate house door configuration
|
||||
let house_door_count = house_door.iter().filter(|x| x.is_some()).count();
|
||||
if house_door_count != 2 {
|
||||
return Err(ParseError::InvalidHouseDoorCount(house_door_count));
|
||||
}
|
||||
|
||||
Ok(ParsedMap {
|
||||
tiles,
|
||||
house_door,
|
||||
tunnel_ends,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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')));
|
||||
}
|
||||
}
|
||||
144
src/map/render.rs
Normal file
144
src/map/render.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
//! Map rendering functionality.
|
||||
|
||||
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
|
||||
use sdl2::pixels::Color;
|
||||
use sdl2::rect::{Point, Rect};
|
||||
use sdl2::render::{Canvas, RenderTarget};
|
||||
|
||||
/// Handles rendering operations for the map.
|
||||
pub struct MapRenderer;
|
||||
|
||||
impl MapRenderer {
|
||||
/// Renders the map to the given canvas.
|
||||
///
|
||||
/// This function draws the static map texture to the screen at the correct
|
||||
/// position and scale.
|
||||
pub fn render_map<T: RenderTarget>(canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, map_texture: &mut AtlasTile) {
|
||||
let dest = Rect::new(
|
||||
crate::constants::BOARD_PIXEL_OFFSET.x as i32,
|
||||
crate::constants::BOARD_PIXEL_OFFSET.y as i32,
|
||||
crate::constants::BOARD_PIXEL_SIZE.x,
|
||||
crate::constants::BOARD_PIXEL_SIZE.y,
|
||||
);
|
||||
let _ = map_texture.render(canvas, atlas, dest);
|
||||
}
|
||||
|
||||
/// Renders a debug visualization of the navigation graph.
|
||||
///
|
||||
/// This function is intended for development and debugging purposes. It draws the
|
||||
/// nodes and edges of the graph on top of the map, allowing for visual
|
||||
/// inspection of the navigation paths.
|
||||
pub fn debug_render_nodes<T: RenderTarget>(graph: &crate::entity::graph::Graph, canvas: &mut Canvas<T>) {
|
||||
for i in 0..graph.node_count() {
|
||||
let node = graph.get_node(i).unwrap();
|
||||
let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
|
||||
|
||||
// Draw connections
|
||||
canvas.set_draw_color(Color::BLUE);
|
||||
|
||||
for edge in graph.adjacency_list[i].edges() {
|
||||
let end_pos = graph.get_node(edge.target).unwrap().position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
|
||||
canvas
|
||||
.draw_line((pos.x as i32, pos.y as i32), (end_pos.x as i32, end_pos.y as i32))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Draw node
|
||||
// let color = if pacman.position.from_node_idx() == i.into() {
|
||||
// Color::GREEN
|
||||
// } else if let Some(to_idx) = pacman.position.to_node_idx() {
|
||||
// if to_idx == i.into() {
|
||||
// Color::CYAN
|
||||
// } else {
|
||||
// Color::RED
|
||||
// }
|
||||
// } else {
|
||||
// Color::RED
|
||||
// };
|
||||
canvas.set_draw_color(Color::GREEN);
|
||||
canvas
|
||||
.fill_rect(Rect::new(0, 0, 3, 3).centered_on(Point::new(pos.x as i32, pos.y as i32)))
|
||||
.unwrap();
|
||||
|
||||
// Draw node index
|
||||
// text.render(canvas, atlas, &i.to_string(), pos.as_uvec2()).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::entity::graph::{Graph, Node};
|
||||
use crate::texture::sprite::{AtlasMapper, MapperFrame};
|
||||
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, crate::entity::direction::Direction::Right)
|
||||
.unwrap();
|
||||
graph
|
||||
.connect(node1, node3, false, None, crate::entity::direction::Direction::Down)
|
||||
.unwrap();
|
||||
|
||||
graph
|
||||
}
|
||||
|
||||
fn create_test_atlas() -> SpriteAtlas {
|
||||
let mut frames = HashMap::new();
|
||||
frames.insert(
|
||||
"maze/full.png".to_string(),
|
||||
MapperFrame {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 224,
|
||||
height: 248,
|
||||
},
|
||||
);
|
||||
let mapper = AtlasMapper { frames };
|
||||
let dummy_texture = unsafe { std::mem::zeroed() };
|
||||
SpriteAtlas::new(dummy_texture, mapper)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_map_does_not_panic() {
|
||||
// This test just ensures the function doesn't panic
|
||||
// We can't easily test the actual rendering without SDL context
|
||||
let atlas = create_test_atlas();
|
||||
let _map_texture = SpriteAtlas::get_tile(&atlas, "maze/full.png").unwrap();
|
||||
|
||||
// The function should not panic even with dummy data
|
||||
// Note: We can't actually call render_map without a canvas, but we can test the logic
|
||||
assert!(true); // Placeholder test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debug_render_nodes_does_not_panic() {
|
||||
// This test just ensures the function doesn't panic
|
||||
// We can't easily test the actual rendering without SDL context
|
||||
let _graph = create_test_graph();
|
||||
|
||||
// The function should not panic even with dummy data
|
||||
// Note: We can't actually call debug_render_nodes without a canvas, but we can test the logic
|
||||
assert!(true); // Placeholder test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_renderer_structure() {
|
||||
// Test that MapRenderer is a unit struct
|
||||
let _renderer = MapRenderer;
|
||||
// This should compile and not panic
|
||||
assert!(true);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +1,193 @@
|
||||
//! This module provides a simple animation and atlas system for textures.
|
||||
use anyhow::Result;
|
||||
use sdl2::{pixels::Color, render::WindowCanvas};
|
||||
use sdl2::rect::Rect;
|
||||
use sdl2::render::{Canvas, RenderTarget};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::texture::sprite::AtlasTile;
|
||||
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
|
||||
|
||||
/// An animated texture using a texture atlas.
|
||||
#[derive(Clone)]
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AnimatedTextureError {
|
||||
#[error("Frame duration must be positive, got {0}")]
|
||||
InvalidFrameDuration(f32),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AnimatedTexture {
|
||||
pub frames: Vec<AtlasTile>,
|
||||
pub ticks_per_frame: u32,
|
||||
pub ticker: u32,
|
||||
pub reversed: bool,
|
||||
pub paused: bool,
|
||||
tiles: Vec<AtlasTile>,
|
||||
frame_duration: f32,
|
||||
current_frame: usize,
|
||||
time_bank: f32,
|
||||
}
|
||||
|
||||
impl AnimatedTexture {
|
||||
pub fn new(frames: Vec<AtlasTile>, ticks_per_frame: u32) -> Self {
|
||||
AnimatedTexture {
|
||||
frames,
|
||||
ticks_per_frame,
|
||||
ticker: 0,
|
||||
reversed: false,
|
||||
paused: false,
|
||||
pub fn new(tiles: Vec<AtlasTile>, frame_duration: f32) -> Result<Self, AnimatedTextureError> {
|
||||
if frame_duration <= 0.0 {
|
||||
return Err(AnimatedTextureError::InvalidFrameDuration(frame_duration));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
tiles,
|
||||
frame_duration,
|
||||
current_frame: 0,
|
||||
time_bank: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, dt: f32) {
|
||||
self.time_bank += dt;
|
||||
while self.time_bank >= self.frame_duration {
|
||||
self.time_bank -= self.frame_duration;
|
||||
self.current_frame = (self.current_frame + 1) % self.tiles.len();
|
||||
}
|
||||
}
|
||||
|
||||
/// Advances the animation by one tick, unless paused.
|
||||
pub fn tick(&mut self) {
|
||||
if self.paused || self.ticks_per_frame == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
self.ticker += 1;
|
||||
pub fn current_tile(&self) -> &AtlasTile {
|
||||
&self.tiles[self.current_frame]
|
||||
}
|
||||
|
||||
pub fn current_tile(&mut self) -> &mut AtlasTile {
|
||||
if self.ticks_per_frame == 0 {
|
||||
return &mut self.frames[0];
|
||||
}
|
||||
let frame_index = (self.ticker / self.ticks_per_frame) as usize % self.frames.len();
|
||||
&mut self.frames[frame_index]
|
||||
pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, dest: Rect) -> Result<()> {
|
||||
let mut tile = *self.current_tile();
|
||||
tile.render(canvas, atlas, dest)
|
||||
}
|
||||
|
||||
pub fn render(&mut self, canvas: &mut WindowCanvas, dest: sdl2::rect::Rect) -> Result<()> {
|
||||
let mut tile = self.current_tile();
|
||||
tile.render(canvas, dest)
|
||||
// Helper methods for testing
|
||||
pub fn current_frame(&self) -> usize {
|
||||
self.current_frame
|
||||
}
|
||||
|
||||
pub fn time_bank(&self) -> f32 {
|
||||
self.time_bank
|
||||
}
|
||||
|
||||
pub fn frame_duration(&self) -> f32 {
|
||||
self.frame_duration
|
||||
}
|
||||
|
||||
pub fn tiles_len(&self) -> usize {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +1,178 @@
|
||||
//! A texture that blinks on/off for a specified number of ticks.
|
||||
use anyhow::Result;
|
||||
use sdl2::render::WindowCanvas;
|
||||
|
||||
use crate::texture::animated::AnimatedTexture;
|
||||
#![allow(dead_code)]
|
||||
use crate::texture::sprite::AtlasTile;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BlinkingTexture {
|
||||
pub animation: AnimatedTexture,
|
||||
pub on_ticks: u32,
|
||||
pub off_ticks: u32,
|
||||
pub ticker: u32,
|
||||
pub visible: bool,
|
||||
tile: AtlasTile,
|
||||
blink_duration: f32,
|
||||
time_bank: f32,
|
||||
is_on: bool,
|
||||
}
|
||||
|
||||
impl BlinkingTexture {
|
||||
pub fn new(animation: AnimatedTexture, on_ticks: u32, off_ticks: u32) -> Self {
|
||||
BlinkingTexture {
|
||||
animation,
|
||||
on_ticks,
|
||||
off_ticks,
|
||||
ticker: 0,
|
||||
visible: true,
|
||||
pub fn new(tile: AtlasTile, blink_duration: f32) -> Self {
|
||||
Self {
|
||||
tile,
|
||||
blink_duration,
|
||||
time_bank: 0.0,
|
||||
is_on: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Advances the blinking state by one tick.
|
||||
pub fn tick(&mut self) {
|
||||
self.animation.tick();
|
||||
self.ticker += 1;
|
||||
if self.visible && self.ticker >= self.on_ticks {
|
||||
self.visible = false;
|
||||
self.ticker = 0;
|
||||
} else if !self.visible && self.ticker >= self.off_ticks {
|
||||
self.visible = true;
|
||||
self.ticker = 0;
|
||||
pub fn tick(&mut self, dt: f32) {
|
||||
self.time_bank += dt;
|
||||
if self.time_bank >= self.blink_duration {
|
||||
self.time_bank -= self.blink_duration;
|
||||
self.is_on = !self.is_on;
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the blinking texture.
|
||||
pub fn render(&mut self, canvas: &mut WindowCanvas, dest: sdl2::rect::Rect) -> Result<()> {
|
||||
if self.visible {
|
||||
self.animation.render(canvas, dest)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
pub fn is_on(&self) -> bool {
|
||||
self.is_on
|
||||
}
|
||||
|
||||
pub fn tile(&self) -> &AtlasTile {
|
||||
&self.tile
|
||||
}
|
||||
|
||||
// Helper methods for testing
|
||||
pub fn time_bank(&self) -> f32 {
|
||||
self.time_bank
|
||||
}
|
||||
|
||||
pub fn blink_duration(&self) -> f32 {
|
||||
self.blink_duration
|
||||
}
|
||||
}
|
||||
|
||||
#[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,65 +1,183 @@
|
||||
//! A texture that changes based on the direction of an entity.
|
||||
use crate::entity::direction::Direction;
|
||||
use crate::texture::sprite::AtlasTile;
|
||||
use anyhow::Result;
|
||||
use sdl2::render::WindowCanvas;
|
||||
use sdl2::rect::Rect;
|
||||
use sdl2::render::{Canvas, RenderTarget};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::entity::direction::Direction;
|
||||
use crate::texture::animated::AnimatedTexture;
|
||||
use crate::texture::sprite::SpriteAtlas;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DirectionalAnimatedTexture {
|
||||
pub up: Vec<AtlasTile>,
|
||||
pub down: Vec<AtlasTile>,
|
||||
pub left: Vec<AtlasTile>,
|
||||
pub right: Vec<AtlasTile>,
|
||||
pub ticker: u32,
|
||||
pub ticks_per_frame: u32,
|
||||
textures: HashMap<Direction, AnimatedTexture>,
|
||||
stopped_textures: HashMap<Direction, AnimatedTexture>,
|
||||
}
|
||||
|
||||
impl DirectionalAnimatedTexture {
|
||||
pub fn new(
|
||||
up: Vec<AtlasTile>,
|
||||
down: Vec<AtlasTile>,
|
||||
left: Vec<AtlasTile>,
|
||||
right: Vec<AtlasTile>,
|
||||
ticks_per_frame: u32,
|
||||
) -> Self {
|
||||
pub fn new(textures: HashMap<Direction, AnimatedTexture>, stopped_textures: HashMap<Direction, AnimatedTexture>) -> Self {
|
||||
Self {
|
||||
up,
|
||||
down,
|
||||
left,
|
||||
right,
|
||||
ticker: 0,
|
||||
ticks_per_frame,
|
||||
textures,
|
||||
stopped_textures,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
self.ticker += 1;
|
||||
pub fn tick(&mut self, dt: f32) {
|
||||
for texture in self.textures.values_mut() {
|
||||
texture.tick(dt);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(&mut self, canvas: &mut WindowCanvas, dest: sdl2::rect::Rect, direction: Direction) -> Result<()> {
|
||||
let frames = match direction {
|
||||
Direction::Up => &mut self.up,
|
||||
Direction::Down => &mut self.down,
|
||||
Direction::Left => &mut self.left,
|
||||
Direction::Right => &mut self.right,
|
||||
};
|
||||
|
||||
let frame_index = (self.ticker / self.ticks_per_frame) as usize % frames.len();
|
||||
let tile = &mut frames[frame_index];
|
||||
|
||||
tile.render(canvas, dest)
|
||||
pub fn render<T: RenderTarget>(
|
||||
&self,
|
||||
canvas: &mut Canvas<T>,
|
||||
atlas: &mut SpriteAtlas,
|
||||
dest: Rect,
|
||||
direction: Direction,
|
||||
) -> Result<()> {
|
||||
if let Some(texture) = self.textures.get(&direction) {
|
||||
texture.render(canvas, atlas, dest)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_stopped(&mut self, canvas: &mut WindowCanvas, dest: sdl2::rect::Rect, direction: Direction) -> Result<()> {
|
||||
let frames = match direction {
|
||||
Direction::Up => &mut self.up,
|
||||
Direction::Down => &mut self.down,
|
||||
Direction::Left => &mut self.left,
|
||||
Direction::Right => &mut self.right,
|
||||
};
|
||||
pub fn render_stopped<T: RenderTarget>(
|
||||
&self,
|
||||
canvas: &mut Canvas<T>,
|
||||
atlas: &mut SpriteAtlas,
|
||||
dest: Rect,
|
||||
direction: Direction,
|
||||
) -> Result<()> {
|
||||
if let Some(texture) = self.stopped_textures.get(&direction) {
|
||||
texture.render(canvas, atlas, dest)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Show the last frame (full sprite) when stopped
|
||||
let tile = &mut frames[1];
|
||||
// Helper methods for testing
|
||||
pub fn has_direction(&self, direction: Direction) -> bool {
|
||||
self.textures.contains_key(&direction)
|
||||
}
|
||||
|
||||
tile.render(canvas, dest)
|
||||
pub fn has_stopped_direction(&self, direction: Direction) -> bool {
|
||||
self.stopped_textures.contains_key(&direction)
|
||||
}
|
||||
|
||||
pub fn texture_count(&self) -> usize {
|
||||
self.textures.len()
|
||||
}
|
||||
|
||||
pub fn stopped_texture_count(&self) -> usize {
|
||||
self.stopped_textures.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
|
||||
|
||||
pub mod animated;
|
||||
pub mod blinking;
|
||||
pub mod directional;
|
||||
pub mod sprite;
|
||||
pub mod text;
|
||||
|
||||
pub fn get_atlas_tile(atlas: &Rc<RefCell<SpriteAtlas>>, name: &str) -> AtlasTile {
|
||||
SpriteAtlas::get_tile(atlas, name).unwrap_or_else(|| panic!("Could not find tile {}", name))
|
||||
}
|
||||
|
||||
@@ -4,9 +4,7 @@ use sdl2::pixels::Color;
|
||||
use sdl2::rect::Rect;
|
||||
use sdl2::render::{Canvas, RenderTarget, Texture};
|
||||
use serde::Deserialize;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct AtlasMapper {
|
||||
@@ -21,26 +19,28 @@ pub struct MapperFrame {
|
||||
pub height: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct AtlasTile {
|
||||
pub atlas: Rc<RefCell<SpriteAtlas>>,
|
||||
pub pos: U16Vec2,
|
||||
pub size: U16Vec2,
|
||||
pub color: Option<Color>,
|
||||
}
|
||||
|
||||
impl AtlasTile {
|
||||
pub fn render<C: RenderTarget>(&mut self, canvas: &mut Canvas<C>, dest: Rect) -> Result<()> {
|
||||
let color = self
|
||||
.color
|
||||
.unwrap_or(self.atlas.borrow().default_color.unwrap_or(Color::WHITE));
|
||||
self.render_with_color(canvas, dest, color)
|
||||
pub fn render<C: RenderTarget>(&mut self, canvas: &mut Canvas<C>, atlas: &mut SpriteAtlas, dest: Rect) -> Result<()> {
|
||||
let color = self.color.unwrap_or(atlas.default_color.unwrap_or(Color::WHITE));
|
||||
self.render_with_color(canvas, atlas, dest, color)
|
||||
}
|
||||
|
||||
pub fn render_with_color<C: RenderTarget>(&mut self, canvas: &mut Canvas<C>, dest: Rect, color: Color) -> Result<()> {
|
||||
pub fn render_with_color<C: RenderTarget>(
|
||||
&mut self,
|
||||
canvas: &mut Canvas<C>,
|
||||
atlas: &mut SpriteAtlas,
|
||||
dest: Rect,
|
||||
color: Color,
|
||||
) -> Result<()> {
|
||||
let src = Rect::new(self.pos.x as i32, self.pos.y as i32, self.size.x as u32, self.size.y as u32);
|
||||
|
||||
let mut atlas = self.atlas.borrow_mut();
|
||||
if atlas.last_modulation != Some(color) {
|
||||
atlas.texture.set_color_mod(color.r, color.g, color.b);
|
||||
atlas.last_modulation = Some(color);
|
||||
@@ -49,6 +49,16 @@ impl AtlasTile {
|
||||
canvas.copy(&atlas.texture, src, dest).map_err(anyhow::Error::msg)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Helper methods for testing
|
||||
pub fn new(pos: U16Vec2, size: U16Vec2, color: Option<Color>) -> Self {
|
||||
Self { pos, size, color }
|
||||
}
|
||||
|
||||
pub fn with_color(mut self, color: Color) -> Self {
|
||||
self.color = Some(color);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SpriteAtlas {
|
||||
@@ -68,25 +78,284 @@ impl SpriteAtlas {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_tile(atlas: &Rc<RefCell<SpriteAtlas>>, name: &str) -> Option<AtlasTile> {
|
||||
let atlas_ref = atlas.borrow();
|
||||
atlas_ref.tiles.get(name).map(|frame| AtlasTile {
|
||||
atlas: Rc::clone(atlas),
|
||||
pub fn get_tile(&self, name: &str) -> Option<AtlasTile> {
|
||||
self.tiles.get(name).map(|frame| AtlasTile {
|
||||
pos: U16Vec2::new(frame.x, frame.y),
|
||||
size: U16Vec2::new(frame.width, frame.height),
|
||||
color: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn set_color(&mut self, color: Color) {
|
||||
self.default_color = Some(color);
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn texture(&self) -> &Texture<'static> {
|
||||
&self.texture
|
||||
}
|
||||
|
||||
// Helper methods for testing
|
||||
pub fn tiles_count(&self) -> usize {
|
||||
self.tiles.len()
|
||||
}
|
||||
|
||||
pub fn has_tile(&self, name: &str) -> bool {
|
||||
self.tiles.contains_key(name)
|
||||
}
|
||||
|
||||
pub fn default_color(&self) -> Option<Color> {
|
||||
self.default_color
|
||||
}
|
||||
}
|
||||
|
||||
pub unsafe fn texture_to_static<'a>(texture: Texture<'a>) -> Texture<'static> {
|
||||
/// Converts a `Texture` to a `Texture<'static>` using transmute.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// This function is unsafe because it uses `std::mem::transmute` to change the lifetime
|
||||
/// of the texture from the original lifetime to `'static`. The caller must ensure that:
|
||||
///
|
||||
/// - The original `Texture` will live for the entire duration of the program
|
||||
/// - No references to the original texture exist that could become invalid
|
||||
/// - The texture is not dropped while still being used as a `'static` reference
|
||||
///
|
||||
/// This is typically used when you have a texture that you know will live for the entire
|
||||
/// program duration and need to store it in a structure that requires a `'static` lifetime.
|
||||
pub unsafe fn texture_to_static(texture: Texture) -> Texture<'static> {
|
||||
std::mem::transmute(texture)
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! This module provides text rendering using the texture atlas.
|
||||
//!
|
||||
//! The TextTexture system renders text from the atlas using character mapping.
|
||||
@@ -7,18 +9,13 @@
|
||||
//! # Example Usage
|
||||
//!
|
||||
//! ```rust
|
||||
//! use crate::texture::text::TextTexture;
|
||||
//! use std::rc::Rc;
|
||||
//! use pacman::texture::text::TextTexture;
|
||||
//!
|
||||
//! // Create a text texture with 1.0 scale (8x8 pixels per character)
|
||||
//! let mut text_renderer = TextTexture::new(atlas.clone(), 1.0);
|
||||
//! let mut text_renderer = TextTexture::new(1.0);
|
||||
//!
|
||||
//! // Render text at position (100, 50)
|
||||
//! text_renderer.render(canvas, "PAC-MAN", glam::UVec2::new(100, 50))?;
|
||||
//!
|
||||
//! // Change scale for larger text
|
||||
//! // Set scale for larger text
|
||||
//! text_renderer.set_scale(2.0);
|
||||
//! text_renderer.render(canvas, "SCORE: 1000", glam::UVec2::new(50, 100))?;
|
||||
//!
|
||||
//! // Calculate text width for positioning
|
||||
//! let width = text_renderer.text_width("GAME OVER");
|
||||
@@ -48,41 +45,36 @@
|
||||
|
||||
use anyhow::Result;
|
||||
use glam::UVec2;
|
||||
use sdl2::pixels::Color;
|
||||
use sdl2::rect::Rect;
|
||||
|
||||
use sdl2::render::{Canvas, RenderTarget};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
|
||||
|
||||
/// A text texture that renders characters from the atlas.
|
||||
pub struct TextTexture {
|
||||
atlas: Rc<RefCell<SpriteAtlas>>,
|
||||
char_map: HashMap<char, AtlasTile>,
|
||||
scale: f32,
|
||||
}
|
||||
|
||||
impl TextTexture {
|
||||
/// Creates a new text texture with the given atlas and scale.
|
||||
pub fn new(atlas: Rc<RefCell<SpriteAtlas>>, scale: f32) -> Self {
|
||||
pub fn new(scale: f32) -> Self {
|
||||
Self {
|
||||
atlas,
|
||||
char_map: HashMap::new(),
|
||||
scale,
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a character to its atlas tile, handling special characters.
|
||||
fn get_char_tile(&mut self, c: char) -> Option<AtlasTile> {
|
||||
fn get_char_tile(&mut self, atlas: &SpriteAtlas, c: char) -> Option<AtlasTile> {
|
||||
if let Some(tile) = self.char_map.get(&c) {
|
||||
return Some(tile.clone());
|
||||
return Some(*tile);
|
||||
}
|
||||
|
||||
let tile_name = self.char_to_tile_name(c)?;
|
||||
let tile = SpriteAtlas::get_tile(&self.atlas, &tile_name)?;
|
||||
self.char_map.insert(c, tile.clone());
|
||||
let tile = atlas.get_tile(&tile_name)?;
|
||||
self.char_map.insert(c, tile);
|
||||
Some(tile)
|
||||
}
|
||||
|
||||
@@ -90,9 +82,7 @@ impl TextTexture {
|
||||
fn char_to_tile_name(&self, c: char) -> Option<String> {
|
||||
let name = match c {
|
||||
// Letters A-Z
|
||||
'A'..='Z' => format!("text/{}.png", c),
|
||||
// Numbers 0-9
|
||||
'0'..='9' => format!("text/{}.png", c),
|
||||
'A'..='Z' | '0'..='9' => format!("text/{c}.png"),
|
||||
// Special characters
|
||||
'!' => "text/!.png".to_string(),
|
||||
'-' => "text/-.png".to_string(),
|
||||
@@ -109,15 +99,21 @@ impl TextTexture {
|
||||
}
|
||||
|
||||
/// Renders a string of text at the given position.
|
||||
pub fn render<C: RenderTarget>(&mut self, canvas: &mut Canvas<C>, text: &str, position: UVec2, color: Color) -> Result<()> {
|
||||
pub fn render<C: RenderTarget>(
|
||||
&mut self,
|
||||
canvas: &mut Canvas<C>,
|
||||
atlas: &mut SpriteAtlas,
|
||||
text: &str,
|
||||
position: UVec2,
|
||||
) -> Result<()> {
|
||||
let mut x_offset = 0;
|
||||
let char_width = (8.0 * self.scale) as u32;
|
||||
let char_height = (8.0 * self.scale) as u32;
|
||||
|
||||
for c in text.chars() {
|
||||
if let Some(mut tile) = self.get_char_tile(c) {
|
||||
if let Some(mut tile) = self.get_char_tile(atlas, c) {
|
||||
let dest = sdl2::rect::Rect::new((position.x + x_offset) as i32, position.y as i32, char_width, char_height);
|
||||
tile.render(canvas, dest)?;
|
||||
tile.render(canvas, atlas, dest)?;
|
||||
}
|
||||
// Always advance x_offset for all characters (including spaces)
|
||||
x_offset += char_width;
|
||||
@@ -137,7 +133,7 @@ impl TextTexture {
|
||||
}
|
||||
|
||||
/// Calculates the width of a string in pixels at the current scale.
|
||||
pub fn text_width(&mut self, text: &str) -> u32 {
|
||||
pub fn text_width(&self, text: &str) -> u32 {
|
||||
let char_width = (8.0 * self.scale) as u32;
|
||||
let mut width = 0;
|
||||
|
||||
@@ -155,3 +151,228 @@ impl TextTexture {
|
||||
(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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user