Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4e3f383ec | |||
| 532abd1e45 | |||
| 70528b0dcc | |||
| c5ca7302c2 | |||
| a27f85279e | |||
| bea915b5c7 | |||
| d743aee393 | |||
| 59aba9f691 | |||
| 199b4dc939 | |||
| 2edd23cfbb | |||
| 464d6f9ca6 | |||
| 413f9f156f | |||
| 4f87a116d5 | |||
| 86ffc931e8 | |||
| d72f47d66c | |||
| 7a6182cb85 | |||
| a1d37a1a0b | |||
| 9066b2cdbc | |||
| 238b5aac6a | |||
| 8e5ec9fef0 |
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.86.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
|
||||
2
.github/workflows/build.yaml
vendored
@@ -1,6 +1,6 @@
|
||||
name: Build
|
||||
|
||||
on: [push]
|
||||
on: ["push", "pull_request"]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
53
.github/workflows/coverage.yaml
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
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 }}
|
||||
|
||||
- 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
|
||||
|
||||
- name: Install cargo-tarpaulin
|
||||
run: cargo install cargo-tarpaulin
|
||||
|
||||
- name: Generate coverage report
|
||||
run: cargo tarpaulin --out Html --output-dir coverage
|
||||
|
||||
- name: Upload coverage to Coveralls
|
||||
uses: coverallsapp/github-action@v2
|
||||
with:
|
||||
files: ./coverage/tarpaulin-report.html
|
||||
allow-empty: false
|
||||
51
.github/workflows/test.yaml
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
name: Test
|
||||
|
||||
on: ["push", "pull_request"]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_TOOLCHAIN: 1.86.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 }}
|
||||
|
||||
- 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
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --workspace --verbose
|
||||
|
||||
- name: Run clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt -- --check
|
||||
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
@@ -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/
|
||||
|
||||
1121
assets/game/atlas.json
Normal file
BIN
assets/game/atlas.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.8 KiB |
BIN
assets/unpacked/text/!.png
Normal file
|
After Width: | Height: | Size: 100 B |
BIN
assets/unpacked/text/-.png
Normal file
|
After Width: | Height: | Size: 86 B |
BIN
assets/unpacked/text/0.png
Normal file
|
After Width: | Height: | Size: 119 B |
BIN
assets/unpacked/text/1.png
Normal file
|
After Width: | Height: | Size: 103 B |
BIN
assets/unpacked/text/2.png
Normal file
|
After Width: | Height: | Size: 112 B |
BIN
assets/unpacked/text/3.png
Normal file
|
After Width: | Height: | Size: 109 B |
BIN
assets/unpacked/text/4.png
Normal file
|
After Width: | Height: | Size: 115 B |
BIN
assets/unpacked/text/5.png
Normal file
|
After Width: | Height: | Size: 114 B |
BIN
assets/unpacked/text/6.png
Normal file
|
After Width: | Height: | Size: 109 B |
BIN
assets/unpacked/text/7.png
Normal file
|
After Width: | Height: | Size: 105 B |
BIN
assets/unpacked/text/8.png
Normal file
|
After Width: | Height: | Size: 116 B |
BIN
assets/unpacked/text/9.png
Normal file
|
After Width: | Height: | Size: 111 B |
BIN
assets/unpacked/text/A.png
Normal file
|
After Width: | Height: | Size: 109 B |
BIN
assets/unpacked/text/B.png
Normal file
|
After Width: | Height: | Size: 103 B |
BIN
assets/unpacked/text/C.png
Normal file
|
After Width: | Height: | Size: 113 B |
BIN
assets/unpacked/text/D.png
Normal file
|
After Width: | Height: | Size: 119 B |
BIN
assets/unpacked/text/E.png
Normal file
|
After Width: | Height: | Size: 102 B |
BIN
assets/unpacked/text/F.png
Normal file
|
After Width: | Height: | Size: 105 B |
BIN
assets/unpacked/text/G.png
Normal file
|
After Width: | Height: | Size: 110 B |
BIN
assets/unpacked/text/H.png
Normal file
|
After Width: | Height: | Size: 105 B |
BIN
assets/unpacked/text/I.png
Normal file
|
After Width: | Height: | Size: 95 B |
BIN
assets/unpacked/text/J.png
Normal file
|
After Width: | Height: | Size: 105 B |
BIN
assets/unpacked/text/K.png
Normal file
|
After Width: | Height: | Size: 111 B |
BIN
assets/unpacked/text/L.png
Normal file
|
After Width: | Height: | Size: 93 B |
BIN
assets/unpacked/text/M.png
Normal file
|
After Width: | Height: | Size: 117 B |
BIN
assets/unpacked/text/N.png
Normal file
|
After Width: | Height: | Size: 118 B |
BIN
assets/unpacked/text/O.png
Normal file
|
After Width: | Height: | Size: 103 B |
BIN
assets/unpacked/text/P.png
Normal file
|
After Width: | Height: | Size: 109 B |
BIN
assets/unpacked/text/Q.png
Normal file
|
After Width: | Height: | Size: 118 B |
BIN
assets/unpacked/text/R.png
Normal file
|
After Width: | Height: | Size: 113 B |
BIN
assets/unpacked/text/S.png
Normal file
|
After Width: | Height: | Size: 114 B |
BIN
assets/unpacked/text/T.png
Normal file
|
After Width: | Height: | Size: 90 B |
BIN
assets/unpacked/text/U.png
Normal file
|
After Width: | Height: | Size: 101 B |
BIN
assets/unpacked/text/V.png
Normal file
|
After Width: | Height: | Size: 119 B |
BIN
assets/unpacked/text/W.png
Normal file
|
After Width: | Height: | Size: 114 B |
BIN
assets/unpacked/text/X.png
Normal file
|
After Width: | Height: | Size: 126 B |
BIN
assets/unpacked/text/Y.png
Normal file
|
After Width: | Height: | Size: 107 B |
BIN
assets/unpacked/text/Z.png
Normal file
|
After Width: | Height: | Size: 107 B |
BIN
assets/unpacked/text/_copyright.png
Normal file
|
After Width: | Height: | Size: 119 B |
BIN
assets/unpacked/text/_double_quote.png
Normal file
|
After Width: | Height: | Size: 91 B |
BIN
assets/unpacked/text/_forward_slash.png
Normal file
|
After Width: | Height: | Size: 77 B |
BIN
assets/unpacked/text/blank.png
Normal file
|
After Width: | Height: | Size: 72 B |
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use std::borrow::Cow;
|
||||
use std::io;
|
||||
use thiserror::Error;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AssetError {
|
||||
#[error("IO error: {0}")]
|
||||
@@ -21,13 +22,13 @@ pub enum Asset {
|
||||
Wav2,
|
||||
Wav3,
|
||||
Wav4,
|
||||
FontKonami,
|
||||
Atlas,
|
||||
AtlasJson,
|
||||
// Add more as needed
|
||||
}
|
||||
|
||||
impl Asset {
|
||||
#[allow(dead_code)]
|
||||
pub fn path(&self) -> &str {
|
||||
use Asset::*;
|
||||
match self {
|
||||
@@ -35,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",
|
||||
}
|
||||
@@ -52,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")),
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ impl Audio {
|
||||
self.muted = mute;
|
||||
}
|
||||
|
||||
/// Returns `true` if the audio is muted.
|
||||
pub fn is_muted(&self) -> bool {
|
||||
self.muted
|
||||
}
|
||||
|
||||
101
src/constants.rs
@@ -1,23 +1,30 @@
|
||||
//! This module contains all the constants used in the game.
|
||||
|
||||
/// The width of the game board, in cells.
|
||||
pub const BOARD_WIDTH: u32 = 28;
|
||||
/// The height of the game board, in cells.
|
||||
pub const BOARD_HEIGHT: u32 = 31;
|
||||
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 = 24;
|
||||
pub const CELL_SIZE: u32 = 8;
|
||||
/// The size of the game board, in cells.
|
||||
pub const BOARD_CELL_SIZE: UVec2 = UVec2::new(28, 31);
|
||||
|
||||
/// The offset of the game board from the top-left corner of the window, in
|
||||
/// cells.
|
||||
pub const BOARD_OFFSET: (u32, u32) = (0, 3);
|
||||
/// The scale factor for the window (integer zoom)
|
||||
pub const SCALE: f32 = 2.6;
|
||||
|
||||
/// The width of the window, in pixels.
|
||||
pub const WINDOW_WIDTH: u32 = CELL_SIZE * BOARD_WIDTH;
|
||||
/// The height of the window, in pixels.
|
||||
///
|
||||
/// The map texture is 6 cells taller than the grid (3 above, 3 below), so we
|
||||
/// add 6 to the board height to get the window height.
|
||||
pub const WINDOW_HEIGHT: u32 = CELL_SIZE * (BOARD_HEIGHT + 6);
|
||||
/// The offset of the game board from the top-left corner of the window, in cells.
|
||||
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)]
|
||||
@@ -36,60 +43,8 @@ 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_HEIGHT as usize] = [
|
||||
pub const RAW_BOARD: [&str; BOARD_CELL_SIZE.y as usize] = [
|
||||
"############################",
|
||||
"#............##............#",
|
||||
"#.####.#####.##.#####.####.#",
|
||||
@@ -101,11 +56,11 @@ pub const RAW_BOARD: [&str; BOARD_HEIGHT as usize] = [
|
||||
"#......##....##....##......#",
|
||||
"######.##### ## #####.######",
|
||||
" #.##### ## #####.# ",
|
||||
" #.## 1 ##.# ",
|
||||
" #.## ###==### ##.# ",
|
||||
"######.## # # ##.######",
|
||||
"T . #2 3 4 # . T",
|
||||
"######.## # # ##.######",
|
||||
" #.## == ##.# ",
|
||||
" #.## ######## ##.# ",
|
||||
"######.## ######## ##.######",
|
||||
"T . ######## . T",
|
||||
"######.## ######## ##.######",
|
||||
" #.## ######## ##.# ",
|
||||
" #.## ##.# ",
|
||||
" #.## ######## ##.# ",
|
||||
|
||||
73
src/debug.rs
@@ -1,73 +0,0 @@
|
||||
//! Debug rendering utilities for Pac-Man.
|
||||
use crate::{
|
||||
constants::{MapTile, BOARD_HEIGHT, BOARD_WIDTH},
|
||||
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, 24, 24))
|
||||
.expect("Could not draw rectangle");
|
||||
}
|
||||
|
||||
pub fn draw_debug_grid(canvas: &mut Canvas<Window>, map: &Map, pacman_cell: UVec2) {
|
||||
for x in 0..BOARD_WIDTH {
|
||||
for y in 0..BOARD_HEIGHT {
|
||||
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,96 +0,0 @@
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use sdl2::render::{Canvas, Texture};
|
||||
use sdl2::video::Window;
|
||||
|
||||
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 glam::{IVec2, UVec2};
|
||||
|
||||
pub struct Blinky {
|
||||
ghost: Ghost,
|
||||
}
|
||||
|
||||
impl Blinky {
|
||||
pub fn new(
|
||||
starting_position: UVec2,
|
||||
body_texture: Texture<'_>,
|
||||
eyes_texture: Texture<'_>,
|
||||
map: Rc<RefCell<Map>>,
|
||||
pacman: Rc<RefCell<Pacman>>,
|
||||
) -> Blinky {
|
||||
Blinky {
|
||||
ghost: Ghost::new(GhostType::Blinky, starting_position, body_texture, eyes_texture, 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(&self, canvas: &mut Canvas<Window>) {
|
||||
self.ghost.render(canvas);
|
||||
}
|
||||
}
|
||||
|
||||
impl Moving for Blinky {
|
||||
fn move_forward(&mut self) {
|
||||
self.ghost.move_forward();
|
||||
}
|
||||
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,29 @@ pub enum Direction {
|
||||
}
|
||||
|
||||
impl Direction {
|
||||
/// Returns the angle of the direction in degrees.
|
||||
pub fn angle(&self) -> f64 {
|
||||
match self {
|
||||
Direction::Right => 0f64,
|
||||
Direction::Down => 90f64,
|
||||
Direction::Left => 180f64,
|
||||
Direction::Up => 270f64,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the offset of the direction as a tuple of (x, y).
|
||||
pub fn offset(&self) -> IVec2 {
|
||||
match self {
|
||||
Direction::Right => IVec2::new(1, 0),
|
||||
Direction::Down => IVec2::new(0, 1),
|
||||
Direction::Left => IVec2::new(-1, 0),
|
||||
Direction::Up => IVec2::new(0, -1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the opposite direction.
|
||||
pub fn opposite(&self) -> Direction {
|
||||
match self {
|
||||
Direction::Right => Direction::Left,
|
||||
Direction::Up => Direction::Down,
|
||||
Direction::Down => Direction::Up,
|
||||
Direction::Left => Direction::Right,
|
||||
Direction::Up => Direction::Down,
|
||||
Direction::Right => Direction::Left,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a `Direction` from a `Keycode`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `keycode` - The keycode to convert.
|
||||
pub fn from_keycode(keycode: Keycode) -> Option<Direction> {
|
||||
match keycode {
|
||||
Keycode::D | Keycode::Right => Some(Direction::Right),
|
||||
Keycode::A | Keycode::Left => Some(Direction::Left),
|
||||
Keycode::W | Keycode::Up => Some(Direction::Up),
|
||||
Keycode::S | Keycode::Down => Some(Direction::Down),
|
||||
_ => None,
|
||||
pub fn to_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];
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
//! Edible entity for Pac-Man: pellets, power pellets, and fruits.
|
||||
use crate::constants::{FruitType, MapTile, BOARD_HEIGHT, BOARD_WIDTH};
|
||||
use crate::entity::direction::Direction;
|
||||
use crate::entity::{Entity, Renderable, StaticEntity};
|
||||
use crate::map::Map;
|
||||
use crate::texture::atlas::AtlasTexture;
|
||||
use crate::texture::blinking::BlinkingTexture;
|
||||
use crate::texture::FrameDrawn;
|
||||
use glam::{IVec2, UVec2};
|
||||
use sdl2::{render::Canvas, video::Window};
|
||||
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(Rc<Box<dyn FrameDrawn>>),
|
||||
PowerPellet(Rc<RefCell<BlinkingTexture>>),
|
||||
}
|
||||
|
||||
pub struct Edible {
|
||||
pub base: StaticEntity,
|
||||
pub kind: EdibleKind,
|
||||
pub sprite: EdibleSprite,
|
||||
}
|
||||
|
||||
impl Edible {
|
||||
pub fn new_pellet(cell_position: UVec2, sprite: Rc<Box<dyn FrameDrawn>>) -> 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: Rc<RefCell<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.is_colliding(pacman)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for Edible {
|
||||
fn base(&self) -> &StaticEntity {
|
||||
&self.base
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for Edible {
|
||||
fn render(&self, canvas: &mut Canvas<Window>) {
|
||||
let pos = self.base.pixel_position;
|
||||
match &self.sprite {
|
||||
EdibleSprite::Pellet(sprite) => sprite.render(canvas, pos, Direction::Right, Some(0)),
|
||||
EdibleSprite::PowerPellet(sprite) => sprite.borrow().render(canvas, pos, Direction::Right, Some(0)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstruct all edibles from the original map layout
|
||||
pub fn reconstruct_edibles(
|
||||
map: Rc<RefCell<Map>>,
|
||||
pellet_sprite: Rc<Box<dyn FrameDrawn>>,
|
||||
power_pellet_sprite: Rc<RefCell<BlinkingTexture>>,
|
||||
_fruit_sprite: Rc<Box<dyn FrameDrawn>>,
|
||||
) -> Vec<Edible> {
|
||||
let mut edibles = Vec::new();
|
||||
for x in 0..BOARD_WIDTH {
|
||||
for y in 0..BOARD_HEIGHT {
|
||||
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), Rc::clone(&pellet_sprite)));
|
||||
}
|
||||
Some(MapTile::PowerPellet) => {
|
||||
edibles.push(Edible::new_power_pellet(UVec2::new(x, y), Rc::clone(&power_pellet_sprite)));
|
||||
}
|
||||
// Fruits can be added here if you have fruit positions
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
edibles
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
use rand::rngs::SmallRng;
|
||||
use rand::Rng;
|
||||
use rand::SeedableRng;
|
||||
|
||||
use crate::constants::{MapTile, BOARD_WIDTH};
|
||||
use crate::entity::direction::Direction;
|
||||
use crate::entity::pacman::Pacman;
|
||||
use crate::entity::{Entity, MovableEntity, Moving, Renderable};
|
||||
use crate::map::Map;
|
||||
use crate::modulation::{SimpleTickModulator, TickModulator};
|
||||
use crate::texture::animated::AnimatedAtlasTexture;
|
||||
use crate::texture::atlas::{texture_to_static, AtlasTexture};
|
||||
use crate::texture::FrameDrawn;
|
||||
use glam::{IVec2, UVec2};
|
||||
use sdl2::pixels::Color;
|
||||
use sdl2::render::Texture;
|
||||
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 body_sprite: AnimatedAtlasTexture,
|
||||
pub eyes_sprite: AnimatedAtlasTexture,
|
||||
}
|
||||
|
||||
impl Ghost {
|
||||
/// Creates a new ghost instance
|
||||
pub fn new(
|
||||
ghost_type: GhostType,
|
||||
starting_position: UVec2,
|
||||
body_texture: Texture<'_>,
|
||||
eyes_texture: Texture<'_>,
|
||||
map: Rc<RefCell<Map>>,
|
||||
pacman: Rc<RefCell<Pacman>>,
|
||||
) -> Ghost {
|
||||
let color = ghost_type.color();
|
||||
let mut body_sprite = AnimatedAtlasTexture::new(
|
||||
unsafe { texture_to_static(body_texture) },
|
||||
8,
|
||||
2,
|
||||
32,
|
||||
32,
|
||||
Some(IVec2::new(-4, -4)),
|
||||
);
|
||||
body_sprite.set_color_modulation(color.r, color.g, color.b);
|
||||
let pixel_position = Map::cell_to_pixel(starting_position);
|
||||
Ghost {
|
||||
base: MovableEntity::new(
|
||||
pixel_position,
|
||||
starting_position,
|
||||
Direction::Left,
|
||||
3,
|
||||
SimpleTickModulator::new(1.0),
|
||||
map,
|
||||
),
|
||||
mode: GhostMode::Chase,
|
||||
ghost_type,
|
||||
pacman,
|
||||
body_sprite,
|
||||
eyes_sprite: AnimatedAtlasTexture::new(
|
||||
unsafe { texture_to_static(eyes_texture) },
|
||||
1,
|
||||
4,
|
||||
32,
|
||||
32,
|
||||
Some((-4, -4).into()),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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_WIDTH - 2, p.y), 1));
|
||||
} else if p.x == BOARD_WIDTH - 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 = match new_mode {
|
||||
GhostMode::Chase => 3,
|
||||
GhostMode::Scatter => 2,
|
||||
GhostMode::Frightened => 2,
|
||||
GhostMode::Eyes => 7,
|
||||
GhostMode::House => 0,
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't move if the next tile is a wall
|
||||
if self.base.is_wall_ahead(None) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if self.base.modulation.next() {
|
||||
self.base.move_forward();
|
||||
|
||||
if self.base.is_grid_aligned() {
|
||||
self.base.update_cell_position();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Moving for Ghost {
|
||||
fn move_forward(&mut self) {
|
||||
self.base.move_forward();
|
||||
}
|
||||
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(&self, canvas: &mut sdl2::render::Canvas<sdl2::video::Window>) {
|
||||
let pos = self.base.base.pixel_position;
|
||||
self.body_sprite.render(canvas, pos, Direction::Right, None);
|
||||
// Inline the eye_frame logic here
|
||||
let eye_frame = if self.mode == GhostMode::Frightened {
|
||||
4 // Frightened frame
|
||||
} else {
|
||||
match self.base.direction {
|
||||
Direction::Right => 0,
|
||||
Direction::Up => 1,
|
||||
Direction::Left => 2,
|
||||
Direction::Down => 3,
|
||||
}
|
||||
};
|
||||
self.eyes_sprite.render(canvas, pos, Direction::Right, Some(eye_frame));
|
||||
}
|
||||
}
|
||||
422
src/entity/graph.rs
Normal file
@@ -0,0 +1,422 @@
|
||||
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)]
|
||||
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 Default for Intersection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
up: None,
|
||||
down: None,
|
||||
left: None,
|
||||
right: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
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.
|
||||
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.
|
||||
pub fn to_node_id(&self) -> Option<NodeId> {
|
||||
match self {
|
||||
Position::AtNode(_) => None,
|
||||
Position::BetweenNodes { to, .. } => Some(*to),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the traverser is stopped at a node.
|
||||
pub fn is_stopped(&self) -> bool {
|
||||
matches!(self, Position::AtNode(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages an entity's movement through the graph.
|
||||
///
|
||||
/// A `Traverser` encapsulates the state of an entity's position and direction,
|
||||
/// providing a way to advance along the graph's paths based on a given distance.
|
||||
/// It also handles direction changes, buffering the next intended direction.
|
||||
pub struct Traverser {
|
||||
/// The current position of the traverser in the graph.
|
||||
pub position: Position,
|
||||
/// The current direction of movement.
|
||||
pub direction: Direction,
|
||||
/// Buffered direction change with remaining frame count for timing.
|
||||
///
|
||||
/// The `u8` value represents the number of frames remaining before
|
||||
/// the buffered direction expires. This allows for responsive controls
|
||||
/// by storing direction changes for a limited time.
|
||||
pub next_direction: Option<(Direction, u8)>,
|
||||
}
|
||||
|
||||
impl Traverser {
|
||||
/// Creates a new traverser starting at the given node ID.
|
||||
///
|
||||
/// The traverser will immediately attempt to start moving in the initial direction.
|
||||
pub fn new(graph: &Graph, start_node: NodeId, initial_direction: Direction) -> Self {
|
||||
let mut traverser = Traverser {
|
||||
position: Position::AtNode(start_node),
|
||||
direction: initial_direction,
|
||||
next_direction: Some((initial_direction, 1)),
|
||||
};
|
||||
|
||||
// This will kickstart the traverser into motion
|
||||
traverser.advance(graph, 0.0);
|
||||
|
||||
traverser
|
||||
}
|
||||
|
||||
/// Sets the next direction for the traverser to take.
|
||||
///
|
||||
/// The direction is buffered and will be applied at the next opportunity,
|
||||
/// typically when the traverser reaches a new node. This allows for responsive
|
||||
/// controls, as the new direction is stored for a limited time.
|
||||
pub fn set_next_direction(&mut self, new_direction: Direction) {
|
||||
if self.direction != new_direction {
|
||||
self.next_direction = Some((new_direction, 30));
|
||||
}
|
||||
}
|
||||
|
||||
/// Advances the traverser along the graph by a specified distance.
|
||||
///
|
||||
/// This method updates the traverser's position based on its current state
|
||||
/// and the distance to travel.
|
||||
///
|
||||
/// - If at a node, it checks for a buffered direction to start moving.
|
||||
/// - If between nodes, it moves along the current edge.
|
||||
/// - If it reaches a node, it attempts to transition to a new edge based on
|
||||
/// the buffered direction or by continuing straight.
|
||||
/// - If no valid move is possible, it stops at the node.
|
||||
pub fn advance(&mut self, graph: &Graph, distance: f32) {
|
||||
// Decrement the remaining frames for the next direction
|
||||
if let Some((direction, remaining)) = self.next_direction {
|
||||
if remaining > 0 {
|
||||
self.next_direction = Some((direction, remaining - 1));
|
||||
} else {
|
||||
self.next_direction = None;
|
||||
}
|
||||
}
|
||||
|
||||
match self.position {
|
||||
Position::AtNode(node_id) => {
|
||||
// We're not moving, but a buffered direction is available.
|
||||
if let Some((next_direction, _)) = self.next_direction {
|
||||
if let Some(edge) = graph.find_edge_in_direction(node_id, next_direction) {
|
||||
// Start moving in that direction
|
||||
self.position = Position::BetweenNodes {
|
||||
from: node_id,
|
||||
to: edge.target,
|
||||
traversed: distance.max(0.0),
|
||||
};
|
||||
self.direction = next_direction;
|
||||
}
|
||||
|
||||
self.next_direction = None; // Consume the buffered direction regardless of whether we started moving with it
|
||||
}
|
||||
}
|
||||
Position::BetweenNodes { from, to, traversed } => {
|
||||
// There is no point in any of the next logic if we don't travel at all
|
||||
if distance <= 0.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let edge = graph
|
||||
.find_edge(from, to)
|
||||
.expect("Inconsistent state: Traverser is on a non-existent edge.");
|
||||
|
||||
let new_traversed = traversed + distance;
|
||||
|
||||
if new_traversed < edge.distance {
|
||||
// Still on the same edge, just update the distance.
|
||||
self.position = Position::BetweenNodes {
|
||||
from,
|
||||
to,
|
||||
traversed: new_traversed,
|
||||
};
|
||||
} else {
|
||||
let overflow = new_traversed - edge.distance;
|
||||
let mut moved = false;
|
||||
|
||||
// If we buffered a direction, try to find an edge in that direction
|
||||
if let Some((next_dir, _)) = self.next_direction {
|
||||
if let Some(edge) = graph.find_edge_in_direction(to, next_dir) {
|
||||
self.position = Position::BetweenNodes {
|
||||
from: to,
|
||||
to: edge.target,
|
||||
traversed: overflow,
|
||||
};
|
||||
|
||||
self.direction = next_dir; // Remember our new direction
|
||||
self.next_direction = None; // Consume the buffered direction
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If we didn't move, try to continue in the current direction
|
||||
if !moved {
|
||||
if let Some(edge) = graph.find_edge_in_direction(to, self.direction) {
|
||||
self.position = Position::BetweenNodes {
|
||||
from: to,
|
||||
to: edge.target,
|
||||
traversed: overflow,
|
||||
};
|
||||
} else {
|
||||
self.position = Position::AtNode(to);
|
||||
self.next_direction = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,176 +1,3 @@
|
||||
pub mod blinky;
|
||||
pub mod direction;
|
||||
pub mod edible;
|
||||
pub mod ghost;
|
||||
pub mod graph;
|
||||
pub mod pacman;
|
||||
|
||||
use crate::{
|
||||
constants::{MapTile, BOARD_OFFSET, BOARD_WIDTH, CELL_SIZE},
|
||||
entity::direction::Direction,
|
||||
map::Map,
|
||||
modulation::SimpleTickModulator,
|
||||
};
|
||||
use glam::{IVec2, UVec2};
|
||||
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 move_forward(&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;
|
||||
}
|
||||
|
||||
/// 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: u32,
|
||||
pub modulation: SimpleTickModulator,
|
||||
pub in_tunnel: bool,
|
||||
pub map: Rc<RefCell<Map>>,
|
||||
}
|
||||
|
||||
impl MovableEntity {
|
||||
pub fn new(
|
||||
pixel_position: IVec2,
|
||||
cell_position: UVec2,
|
||||
direction: Direction,
|
||||
speed: u32,
|
||||
modulation: SimpleTickModulator,
|
||||
map: Rc<RefCell<Map>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
base: StaticEntity::new(pixel_position, cell_position),
|
||||
direction,
|
||||
speed,
|
||||
modulation,
|
||||
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 move_forward(&mut self) {
|
||||
let speed = self.speed as i32;
|
||||
match self.direction {
|
||||
Direction::Right => self.base.pixel_position.x += speed,
|
||||
Direction::Left => self.base.pixel_position.x -= speed,
|
||||
Direction::Up => self.base.pixel_position.y -= speed,
|
||||
Direction::Down => self.base.pixel_position.y += speed,
|
||||
}
|
||||
}
|
||||
fn update_cell_position(&mut self) {
|
||||
self.base.cell_position = UVec2::new(
|
||||
(self.base.pixel_position.x as u32 / CELL_SIZE) - BOARD_OFFSET.0,
|
||||
(self.base.pixel_position.y as u32 / CELL_SIZE) - BOARD_OFFSET.1,
|
||||
);
|
||||
}
|
||||
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 {
|
||||
if !self.in_tunnel {
|
||||
let current_tile = self
|
||||
.map
|
||||
.borrow()
|
||||
.get_tile(IVec2::new(self.base.cell_position.x as i32, self.base.cell_position.y as i32));
|
||||
if matches!(current_tile, Some(MapTile::Tunnel)) {
|
||||
self.in_tunnel = true;
|
||||
}
|
||||
}
|
||||
if self.in_tunnel {
|
||||
if self.base.cell_position.x == 0 {
|
||||
self.base.cell_position.x = BOARD_WIDTH - 2;
|
||||
self.base.pixel_position = Map::cell_to_pixel(self.base.cell_position);
|
||||
self.in_tunnel = false;
|
||||
true
|
||||
} else if self.base.cell_position.x == BOARD_WIDTH - 1 {
|
||||
self.base.cell_position.x = 1;
|
||||
self.base.pixel_position = Map::cell_to_pixel(self.base.cell_position);
|
||||
self.in_tunnel = false;
|
||||
true
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
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(&self, canvas: &mut sdl2::render::Canvas<sdl2::video::Window>);
|
||||
}
|
||||
|
||||
@@ -1,139 +1,93 @@
|
||||
//! This module defines the Pac-Man entity, including its behavior and rendering.
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use glam::Vec2;
|
||||
|
||||
use sdl2::{
|
||||
render::{Canvas, Texture},
|
||||
video::Window,
|
||||
};
|
||||
use crate::constants::BOARD_PIXEL_OFFSET;
|
||||
use crate::entity::direction::Direction;
|
||||
use crate::entity::graph::{Graph, NodeId, Position, Traverser};
|
||||
use crate::texture::animated::AnimatedTexture;
|
||||
use crate::texture::directional::DirectionalAnimatedTexture;
|
||||
use crate::texture::sprite::SpriteAtlas;
|
||||
use sdl2::keyboard::Keycode;
|
||||
use sdl2::rect::Rect;
|
||||
use sdl2::render::{Canvas, RenderTarget};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{
|
||||
entity::{direction::Direction, Entity, MovableEntity, Moving, Renderable, StaticEntity},
|
||||
map::Map,
|
||||
modulation::{SimpleTickModulator, TickModulator},
|
||||
texture::animated::AnimatedAtlasTexture,
|
||||
texture::FrameDrawn,
|
||||
};
|
||||
|
||||
use glam::{IVec2, UVec2};
|
||||
|
||||
/// 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 sprite: AnimatedAtlasTexture,
|
||||
}
|
||||
|
||||
impl Entity for Pacman {
|
||||
fn base(&self) -> &StaticEntity {
|
||||
&self.base.base
|
||||
}
|
||||
}
|
||||
|
||||
impl Moving for Pacman {
|
||||
fn move_forward(&mut self) {
|
||||
self.base.move_forward();
|
||||
}
|
||||
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)
|
||||
}
|
||||
pub traverser: Traverser,
|
||||
texture: DirectionalAnimatedTexture,
|
||||
}
|
||||
|
||||
impl Pacman {
|
||||
/// Creates a new `Pacman` instance.
|
||||
pub fn new(starting_position: UVec2, atlas: Texture<'_>, map: Rc<RefCell<Map>>) -> Pacman {
|
||||
let pixel_position = Map::cell_to_pixel(starting_position);
|
||||
Pacman {
|
||||
base: MovableEntity::new(
|
||||
pixel_position,
|
||||
starting_position,
|
||||
Direction::Right,
|
||||
3,
|
||||
SimpleTickModulator::new(1.0),
|
||||
map,
|
||||
),
|
||||
next_direction: None,
|
||||
stopped: false,
|
||||
sprite: AnimatedAtlasTexture::new(
|
||||
unsafe { crate::texture::atlas::texture_to_static(atlas) },
|
||||
2,
|
||||
3,
|
||||
32,
|
||||
32,
|
||||
Some(IVec2::new(-4, -4)),
|
||||
),
|
||||
pub fn new(graph: &Graph, start_node: NodeId, atlas: &SpriteAtlas) -> Self {
|
||||
let mut textures = HashMap::new();
|
||||
let mut stopped_textures = HashMap::new();
|
||||
|
||||
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!("{}_a.png", moving_prefix)).unwrap(),
|
||||
SpriteAtlas::get_tile(&atlas, &format!("{}_b.png", moving_prefix)).unwrap(),
|
||||
SpriteAtlas::get_tile(&atlas, "pacman/full.png").unwrap(),
|
||||
];
|
||||
|
||||
let stopped_tiles = vec![SpriteAtlas::get_tile(&atlas, &format!("{}_b.png", moving_prefix)).unwrap()];
|
||||
|
||||
textures.insert(direction, AnimatedTexture::new(moving_tiles, 0.08));
|
||||
stopped_textures.insert(direction, AnimatedTexture::new(stopped_tiles, 0.1));
|
||||
}
|
||||
|
||||
Self {
|
||||
traverser: Traverser::new(graph, start_node, Direction::Left),
|
||||
texture: DirectionalAnimatedTexture::new(textures, stopped_textures),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a requested direction change.
|
||||
fn handle_direction_change(&mut self) -> bool {
|
||||
match self.next_direction {
|
||||
None => return false,
|
||||
Some(next_direction) => {
|
||||
if <Pacman as Moving>::set_direction_if_valid(self, next_direction) {
|
||||
self.next_direction = None;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
pub fn tick(&mut self, dt: f32, graph: &Graph) {
|
||||
self.traverser.advance(graph, dt * 60.0 * 1.125);
|
||||
self.texture.tick(dt);
|
||||
}
|
||||
|
||||
/// 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 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);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
let can_change = self.internal_position_even() == UVec2::ZERO;
|
||||
if can_change {
|
||||
<Pacman as Moving>::update_cell_position(self);
|
||||
if !<Pacman as Moving>::handle_tunnel(self) {
|
||||
self.handle_direction_change();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !self.stopped && self.base.modulation.next() {
|
||||
<Pacman as Moving>::move_forward(self);
|
||||
if self.internal_position_even() == UVec2::ZERO {
|
||||
<Pacman as Moving>::update_cell_position(self);
|
||||
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;
|
||||
let weight = from_pos.distance(to_pos);
|
||||
from_pos.lerp(to_pos, traversed / weight)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for Pacman {
|
||||
fn render(&self, canvas: &mut Canvas<Window>) {
|
||||
let pos = self.base.base.pixel_position;
|
||||
let dir = self.base.direction;
|
||||
if self.stopped {
|
||||
self.sprite.render(canvas, pos, dir, Some(2));
|
||||
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 = Rect::new(pixel_pos.x - 8, pixel_pos.y - 8, 16, 16);
|
||||
let is_stopped = self.traverser.position.is_stopped();
|
||||
|
||||
if is_stopped {
|
||||
self.texture
|
||||
.render_stopped(canvas, atlas, dest, self.traverser.direction)
|
||||
.unwrap();
|
||||
} else {
|
||||
self.sprite.render(canvas, pos, dir, None);
|
||||
self.texture.render(canvas, atlas, dest, self.traverser.direction).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
433
src/game.rs
@@ -1,383 +1,158 @@
|
||||
//! This module contains the main game logic and state.
|
||||
use std::cell::RefCell;
|
||||
use std::ops::Not;
|
||||
use std::rc::Rc;
|
||||
|
||||
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::rwops::RWops;
|
||||
use sdl2::ttf::Font;
|
||||
use sdl2::video::WindowContext;
|
||||
use sdl2::{pixels::Color, render::Canvas, video::Window};
|
||||
use anyhow::Result;
|
||||
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::atlas::{texture_to_static, AtlasTexture};
|
||||
use crate::texture::blinking::BlinkingTexture;
|
||||
use crate::texture::FrameDrawn;
|
||||
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.
|
||||
///
|
||||
/// This struct contains all the information necessary to run the game, including
|
||||
/// the canvas, textures, fonts, game objects, and the current score.
|
||||
/// Contains all the information necessary to run the game, including
|
||||
/// the game state, rendering resources, and audio.
|
||||
pub struct Game {
|
||||
canvas: &'static mut Canvas<Window>,
|
||||
map_texture: Texture<'static>,
|
||||
pellet_texture: Rc<Box<dyn FrameDrawn>>,
|
||||
power_pellet_texture: Rc<RefCell<BlinkingTexture>>,
|
||||
font: Font<'static, 'static>,
|
||||
pacman: Rc<RefCell<Pacman>>,
|
||||
map: Rc<RefCell<Map>>,
|
||||
debug_mode: DebugMode,
|
||||
score: u32,
|
||||
pub score: u32,
|
||||
pub map: Map,
|
||||
pub pacman: Pacman,
|
||||
pub debug_mode: bool,
|
||||
|
||||
// Rendering resources
|
||||
atlas: SpriteAtlas,
|
||||
map_texture: AtlasTile,
|
||||
text_texture: TextTexture,
|
||||
debug_text_texture: TextTexture,
|
||||
|
||||
// Audio
|
||||
pub audio: Audio,
|
||||
blinky: Blinky,
|
||||
edibles: Vec<Edible>,
|
||||
}
|
||||
|
||||
impl Game {
|
||||
/// Creates a new `Game` instance.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `canvas` - The SDL canvas to render to.
|
||||
/// * `texture_creator` - The SDL texture creator.
|
||||
/// * `ttf_context` - The SDL TTF context.
|
||||
/// * `_audio_subsystem` - The SDL audio subsystem (currently unused).
|
||||
pub fn new(
|
||||
canvas: &'static mut Canvas<Window>,
|
||||
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);
|
||||
|
||||
// Load Pacman texture from asset API
|
||||
let pacman_bytes = get_asset_bytes(Asset::Pacman).expect("Failed to load asset");
|
||||
let pacman_atlas = texture_creator
|
||||
.load_texture_bytes(&pacman_bytes)
|
||||
.expect("Could not load pacman texture from asset API");
|
||||
let pacman = Rc::new(RefCell::new(Pacman::new(UVec2::new(1, 1), pacman_atlas, Rc::clone(&map))));
|
||||
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");
|
||||
|
||||
// Load ghost textures
|
||||
let ghost_body_bytes = get_asset_bytes(Asset::GhostBody).expect("Failed to load asset");
|
||||
let ghost_body = texture_creator
|
||||
.load_texture_bytes(&ghost_body_bytes)
|
||||
.expect("Could not load ghost body texture from asset API");
|
||||
let ghost_eyes_bytes = get_asset_bytes(Asset::GhostEyes).expect("Failed to load asset");
|
||||
let ghost_eyes = texture_creator
|
||||
.load_texture_bytes(&ghost_eyes_bytes)
|
||||
.expect("Could not load ghost eyes texture from asset API");
|
||||
|
||||
// Create Blinky
|
||||
let blinky = Blinky::new(
|
||||
UVec2::new(13, 11), // Starting position just above ghost house
|
||||
ghost_body,
|
||||
ghost_eyes,
|
||||
Rc::clone(&map),
|
||||
Rc::clone(&pacman),
|
||||
);
|
||||
|
||||
// Load pellet texture from asset API
|
||||
let pellet_bytes = get_asset_bytes(Asset::Pellet).expect("Failed to load asset");
|
||||
let power_pellet_bytes = get_asset_bytes(Asset::Energizer).expect("Failed to load asset");
|
||||
let pellet_texture: Rc<Box<dyn FrameDrawn>> = Rc::new(Box::new(AtlasTexture::new(
|
||||
unsafe {
|
||||
texture_to_static(
|
||||
texture_creator
|
||||
.load_texture_bytes(&pellet_bytes)
|
||||
.expect("Could not load pellet texture from asset API"),
|
||||
)
|
||||
},
|
||||
1,
|
||||
24,
|
||||
24,
|
||||
None,
|
||||
)));
|
||||
let power_pellet_texture = Rc::new(RefCell::new(BlinkingTexture::new(
|
||||
texture_creator
|
||||
.load_texture_bytes(&power_pellet_bytes)
|
||||
.expect("Could not load power pellet texture from asset API"),
|
||||
1,
|
||||
24,
|
||||
24,
|
||||
None,
|
||||
30, // on_ticks
|
||||
9, // off_ticks
|
||||
)));
|
||||
|
||||
// Load map texture from asset API
|
||||
let map_bytes = get_asset_bytes(Asset::Map).expect("Failed to load asset");
|
||||
let mut map_texture = texture_creator
|
||||
.load_texture_bytes(&map_bytes)
|
||||
.expect("Could not load map texture from asset API");
|
||||
map_texture.set_color_mod(0, 0, 255);
|
||||
let map_texture = unsafe { texture_to_static(map_texture) };
|
||||
|
||||
let edibles = reconstruct_edibles(
|
||||
Rc::clone(&map),
|
||||
Rc::clone(&pellet_texture),
|
||||
Rc::clone(&power_pellet_texture),
|
||||
Rc::clone(&pellet_texture), // placeholder for fruit sprite
|
||||
);
|
||||
|
||||
// Load font from asset API
|
||||
let font = {
|
||||
let font_bytes = get_asset_bytes(Asset::FontKonami).expect("Failed to load asset").into_owned();
|
||||
let font_bytes_static: &'static [u8] = Box::leak(font_bytes.into_boxed_slice());
|
||||
let font_rwops = RWops::from_bytes(font_bytes_static).expect("Failed to create RWops for font");
|
||||
// Leak the ttf_context to get a 'static lifetime
|
||||
let ttf_context_static: &'static sdl2::ttf::Sdl2TtfContext = unsafe { std::mem::transmute(ttf_context) };
|
||||
ttf_context_static
|
||||
.load_font_from_rwops(font_rwops, 24)
|
||||
.expect("Could not load font from asset API")
|
||||
let atlas_bytes = get_asset_bytes(Asset::Atlas).expect("Failed to load asset");
|
||||
let atlas_texture = unsafe {
|
||||
let texture = texture_creator
|
||||
.load_texture_bytes(&atlas_bytes)
|
||||
.expect("Could not load atlas texture from asset API");
|
||||
sprite::texture_to_static(texture)
|
||||
};
|
||||
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 = 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 debug_text_texture = TextTexture::new(0.5);
|
||||
let audio = Audio::new();
|
||||
|
||||
let pacman = Pacman::new(&map.graph, pacman_start_node, &atlas);
|
||||
Game {
|
||||
canvas,
|
||||
pacman,
|
||||
debug_mode: DebugMode::None,
|
||||
map,
|
||||
map_texture,
|
||||
pellet_texture,
|
||||
power_pellet_texture,
|
||||
font,
|
||||
score: 0,
|
||||
map,
|
||||
pacman,
|
||||
debug_mode: false,
|
||||
map_texture,
|
||||
text_texture,
|
||||
debug_text_texture,
|
||||
audio,
|
||||
blinky,
|
||||
edibles,
|
||||
atlas,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a keyboard event.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `keycode` - The keycode of the key that was pressed.
|
||||
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());
|
||||
self.audio.set_mute(!self.audio.is_muted());
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset game
|
||||
if keycode == Keycode::R {
|
||||
self.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
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);
|
||||
})?;
|
||||
|
||||
// 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),
|
||||
Rc::clone(&self.pellet_texture),
|
||||
Rc::clone(&self.power_pellet_texture),
|
||||
Rc::clone(&self.pellet_texture), // placeholder for fruit sprite
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Advances the game by one tick.
|
||||
pub fn tick(&mut self) {
|
||||
// Advance animation frames for Pacman and Blinky
|
||||
self.pacman.borrow_mut().sprite.tick();
|
||||
self.blinky.body_sprite.tick();
|
||||
self.blinky.eyes_sprite.tick();
|
||||
|
||||
// Advance blinking for power pellets
|
||||
self.power_pellet_texture.borrow_mut().tick();
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
drop(pacman); // Release immutable borrow before mutably borrowing self
|
||||
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);
|
||||
}
|
||||
self.pacman.borrow_mut().tick();
|
||||
self.blinky.tick();
|
||||
self.draw_hud(canvas)?;
|
||||
canvas.present();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Draws the entire game to the canvas.
|
||||
pub fn draw(&mut self) {
|
||||
// Clear the screen (black)
|
||||
self.canvas.set_draw_color(Color::RGB(0, 0, 0));
|
||||
self.canvas.clear();
|
||||
|
||||
// Render the map
|
||||
self.canvas
|
||||
.copy(&self.map_texture, None, None)
|
||||
.expect("Could not render texture on canvas");
|
||||
|
||||
// Render all edibles
|
||||
for edible in &self.edibles {
|
||||
edible.render(self.canvas);
|
||||
}
|
||||
|
||||
// Render Pac-Man
|
||||
self.pacman.borrow().render(self.canvas);
|
||||
|
||||
// Render ghost
|
||||
self.blinky.render(self.canvas);
|
||||
|
||||
// Render score
|
||||
self.render_ui();
|
||||
|
||||
// Draw the debug grid
|
||||
match self.debug_mode {
|
||||
DebugMode::Grid => {
|
||||
DebugRenderer::draw_debug_grid(self.canvas, &self.map.borrow(), self.pacman.borrow().base.base.cell_position);
|
||||
let next_cell = <Pacman as crate::entity::Moving>::next_cell(&*self.pacman.borrow(), None);
|
||||
DebugRenderer::draw_next_cell(self.canvas, &self.map.borrow(), next_cell.as_uvec2());
|
||||
}
|
||||
DebugMode::ValidPositions => {
|
||||
DebugRenderer::draw_valid_positions(self.canvas, &mut self.map.borrow_mut());
|
||||
}
|
||||
DebugMode::Pathfinding => {
|
||||
DebugRenderer::draw_pathfinding(self.canvas, &self.blinky, &self.map.borrow());
|
||||
}
|
||||
DebugMode::None => {}
|
||||
}
|
||||
|
||||
// Present the canvas
|
||||
self.canvas.present();
|
||||
}
|
||||
|
||||
/// Renders the user interface, including the score and lives.
|
||||
fn render_ui(&mut self) {
|
||||
fn draw_hud<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>) -> Result<()> {
|
||||
let score_text = self.score.to_string();
|
||||
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;
|
||||
|
||||
// Render the score and high score
|
||||
self.render_text(
|
||||
self.text_texture.set_scale(1.0);
|
||||
let _ = self.text_texture.render(
|
||||
canvas,
|
||||
&mut self.atlas,
|
||||
&format!("{lives}UP HIGH SCORE "),
|
||||
IVec2::new(24 * lives_offset + x_offset, y_offset),
|
||||
Color::WHITE,
|
||||
UVec2::new(8 * lives_offset as u32 + x_offset, y_offset),
|
||||
);
|
||||
self.render_text(
|
||||
let _ = self.text_texture.render(
|
||||
canvas,
|
||||
&mut self.atlas,
|
||||
&score_text,
|
||||
IVec2::new(24 * score_offset + x_offset, 24 + y_offset + gap_offset),
|
||||
Color::WHITE,
|
||||
UVec2::new(8 * score_offset as u32 + x_offset, 8 + y_offset),
|
||||
);
|
||||
}
|
||||
|
||||
/// Renders text to the screen at the given position.
|
||||
fn render_text(&mut self, text: &str, position: IVec2, color: Color) {
|
||||
let surface = self.font.render(text).blended(color).expect("Could not render text surface");
|
||||
// Display FPS information in top-left corner
|
||||
// let fps_text = format!("FPS: {:.1} (1s) / {:.1} (10s)", self.fps_1s, self.fps_10s);
|
||||
// self.render_text_on(
|
||||
// canvas,
|
||||
// &*texture_creator,
|
||||
// &fps_text,
|
||||
// IVec2::new(10, 10),
|
||||
// Color::RGB(255, 255, 0), // Yellow color for FPS display
|
||||
// );
|
||||
|
||||
let texture_creator = self.canvas.texture_creator();
|
||||
let texture = texture_creator
|
||||
.create_texture_from_surface(&surface)
|
||||
.expect("Could not create texture from surface");
|
||||
let query = texture.query();
|
||||
|
||||
let dst_rect = sdl2::rect::Rect::new(position.x, position.y, query.width, query.height);
|
||||
|
||||
self.canvas
|
||||
.copy(&texture, None, Some(dst_rect))
|
||||
.expect("Could not render text texture");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
107
src/helper.rs
@@ -1,107 +0,0 @@
|
||||
//! This module contains helper functions that are used throughout the game.
|
||||
|
||||
use glam::UVec2;
|
||||
|
||||
/// Checks if two grid positions are adjacent to each other
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `a` - First position as (x, y) coordinates
|
||||
/// * `b` - Second position as (x, y) coordinates
|
||||
/// * `diagonal` - Whether to consider diagonal adjacency (true) or only orthogonal (false)
|
||||
///
|
||||
/// # Returns
|
||||
/// * `true` if positions are adjacent according to the diagonal parameter
|
||||
/// * `false` otherwise
|
||||
pub fn is_adjacent(a: UVec2, b: UVec2, diagonal: bool) -> bool {
|
||||
let dx = a.x.abs_diff(b.x);
|
||||
let dy = a.y.abs_diff(b.y);
|
||||
if diagonal {
|
||||
dx <= 1 && dy <= 1 && (dx != 0 || dy != 0)
|
||||
} else {
|
||||
(dx == 1 && dy == 0) || (dx == 0 && dy == 1)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_orthogonal_adjacency() {
|
||||
// Test orthogonal adjacency (diagonal = false)
|
||||
|
||||
// Same position should not be adjacent
|
||||
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(0, 0), false));
|
||||
|
||||
// Adjacent positions should be true
|
||||
assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(1, 0), false)); // Right
|
||||
assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(0, 1), false)); // Down
|
||||
assert!(is_adjacent(UVec2::new(1, 1), UVec2::new(0, 1), false)); // Left
|
||||
assert!(is_adjacent(UVec2::new(1, 1), UVec2::new(1, 0), false)); // Up
|
||||
|
||||
// Diagonal positions should be false
|
||||
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(1, 1), false));
|
||||
assert!(!is_adjacent(UVec2::new(0, 1), UVec2::new(1, 0), false));
|
||||
|
||||
// Positions more than 1 step away should be false
|
||||
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(2, 0), false));
|
||||
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(0, 2), false));
|
||||
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(2, 2), false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diagonal_adjacency() {
|
||||
// Test diagonal adjacency (diagonal = true)
|
||||
|
||||
// Same position should not be adjacent
|
||||
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(0, 0), true));
|
||||
|
||||
// Orthogonal adjacent positions should be true
|
||||
assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(1, 0), true)); // Right
|
||||
assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(0, 1), true)); // Down
|
||||
assert!(is_adjacent(UVec2::new(1, 1), UVec2::new(0, 1), true)); // Left
|
||||
assert!(is_adjacent(UVec2::new(1, 1), UVec2::new(1, 0), true)); // Up
|
||||
|
||||
// Diagonal adjacent positions should be true
|
||||
assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(1, 1), true)); // Down-right
|
||||
assert!(is_adjacent(UVec2::new(1, 0), UVec2::new(0, 1), true)); // Down-left
|
||||
assert!(is_adjacent(UVec2::new(0, 1), UVec2::new(1, 0), true)); // Up-right
|
||||
assert!(is_adjacent(UVec2::new(1, 1), UVec2::new(0, 0), true)); // Up-left
|
||||
|
||||
// Positions more than 1 step away should be false
|
||||
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(2, 0), true));
|
||||
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(0, 2), true));
|
||||
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(2, 2), true));
|
||||
assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(1, 2), true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edge_cases() {
|
||||
// Test with larger coordinates
|
||||
assert!(is_adjacent(UVec2::new(100, 100), UVec2::new(101, 100), false));
|
||||
assert!(is_adjacent(UVec2::new(100, 100), UVec2::new(100, 101), false));
|
||||
assert!(!is_adjacent(UVec2::new(100, 100), UVec2::new(102, 100), false));
|
||||
|
||||
assert!(is_adjacent(UVec2::new(100, 100), UVec2::new(101, 101), true));
|
||||
assert!(!is_adjacent(UVec2::new(100, 100), UVec2::new(102, 102), true));
|
||||
|
||||
// Test with zero coordinates
|
||||
assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(1, 0), false));
|
||||
assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(0, 1), false));
|
||||
assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(1, 1), true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commutative_property() {
|
||||
// The function should work the same regardless of parameter order
|
||||
assert_eq!(
|
||||
is_adjacent(UVec2::new(1, 2), UVec2::new(2, 2), false),
|
||||
is_adjacent(UVec2::new(2, 2), UVec2::new(1, 2), false)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
is_adjacent(UVec2::new(1, 2), UVec2::new(2, 3), true),
|
||||
is_adjacent(UVec2::new(2, 3), UVec2::new(1, 2), true)
|
||||
);
|
||||
}
|
||||
}
|
||||
11
src/lib.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
//! 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 map;
|
||||
pub mod texture;
|
||||
139
src/main.rs
@@ -1,11 +1,7 @@
|
||||
#![windows_subsystem = "windows"]
|
||||
|
||||
use crate::constants::{WINDOW_HEIGHT, WINDOW_WIDTH};
|
||||
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,39 +48,17 @@ 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 map;
|
||||
mod modulation;
|
||||
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);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "emscripten")]
|
||||
fn now() -> std::time::Instant {
|
||||
std::time::Instant::now() + std::time::Duration::from_millis(emscripten::emscripten::now() as u64)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "emscripten"))]
|
||||
fn now() -> std::time::Instant {
|
||||
std::time::Instant::now()
|
||||
}
|
||||
|
||||
/// The main entry point of the application.
|
||||
///
|
||||
/// This function initializes SDL, the window, the game state, and then enters
|
||||
@@ -96,11 +70,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();
|
||||
|
||||
// Setup tracing
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_ansi(cfg!(not(target_os = "emscripten")))
|
||||
@@ -110,108 +79,12 @@ pub fn main() {
|
||||
|
||||
tracing::subscriber::set_global_default(subscriber).expect("Could not set global default");
|
||||
|
||||
let window = video_subsystem
|
||||
.window("Pac-Man", WINDOW_WIDTH, WINDOW_HEIGHT)
|
||||
.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(WINDOW_WIDTH, WINDOW_HEIGHT)
|
||||
.expect("Could not set logical size");
|
||||
|
||||
let texture_creator = canvas.texture_creator();
|
||||
let canvas_static: &'static mut sdl2::render::Canvas<sdl2::video::Window> = Box::leak(Box::new(canvas));
|
||||
let mut game = Game::new(canvas_static, &texture_creator, &ttf_context, &audio_subsystem);
|
||||
game.audio.set_mute(cfg!(debug_assertions));
|
||||
|
||||
let mut event_pump = sdl_context.event_pump().expect("Could not get SDL EventPump");
|
||||
|
||||
// Initial draw and tick
|
||||
game.draw();
|
||||
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;
|
||||
|
||||
event!(
|
||||
tracing::Level::INFO,
|
||||
"Starting game loop ({:.3}ms)",
|
||||
loop_time.as_secs_f32() * 1000.0
|
||||
);
|
||||
let mut main_loop = || {
|
||||
let start = Instant::now();
|
||||
|
||||
// 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();
|
||||
game.draw();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
159
src/map.rs
@@ -1,159 +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_OFFSET, CELL_SIZE};
|
||||
use crate::constants::{BOARD_HEIGHT, BOARD_WIDTH};
|
||||
use glam::{IVec2, UVec2};
|
||||
use once_cell::sync::OnceCell;
|
||||
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_HEIGHT as usize]; BOARD_WIDTH as usize],
|
||||
/// The default state of the map.
|
||||
default: [[MapTile; BOARD_HEIGHT as usize]; BOARD_WIDTH 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_HEIGHT as usize]) -> Map {
|
||||
let mut map = [[MapTile::Empty; BOARD_HEIGHT as usize]; BOARD_WIDTH as usize];
|
||||
|
||||
for (y, line) in raw_board.iter().enumerate().take(BOARD_HEIGHT as usize) {
|
||||
for (x, character) in line.chars().enumerate().take(BOARD_WIDTH 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_WIDTH as usize) {
|
||||
for (y, cell) in col.iter_mut().enumerate().take(BOARD_HEIGHT 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_WIDTH as usize || y >= BOARD_HEIGHT 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_WIDTH as usize || y >= BOARD_HEIGHT 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.1) * 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_WIDTH as usize) {
|
||||
for (y, &cell) in col.iter().enumerate().take(BOARD_HEIGHT 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_WIDTH && neighbor.y < BOARD_HEIGHT {
|
||||
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)
|
||||
}
|
||||
}
|
||||
339
src/map/builder.rs
Normal file
@@ -0,0 +1,339 @@
|
||||
//! 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;
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
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.to_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)
|
||||
.expect(&format!("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.to_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
|
||||
Self::build_house(&mut graph, &grid_to_node, &house_door);
|
||||
|
||||
// Build tunnel connections
|
||||
Self::build_tunnels(&mut graph, &grid_to_node, &tunnel_ends);
|
||||
|
||||
Map {
|
||||
current: map,
|
||||
grid_to_node,
|
||||
graph,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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]) {
|
||||
// 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.to_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.to_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.to_ivec2() * (CELL_SIZE as i32 / 2)).as_vec2(),
|
||||
});
|
||||
let bottom_node_id = graph.add_node(Node {
|
||||
position: center_pos + (Direction::Down.to_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.to_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.to_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.to_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}");
|
||||
}
|
||||
|
||||
/// 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.to_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.to_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
|
||||
},
|
||||
)
|
||||
.expect("Failed to connect right tunnel entrance to right tunnel hidden node")
|
||||
};
|
||||
|
||||
// Connect the left tunnel hidden node to the right tunnel hidden node
|
||||
graph
|
||||
.connect(
|
||||
left_tunnel_hidden_node_id,
|
||||
right_tunnel_hidden_node_id,
|
||||
false,
|
||||
Some(0.0),
|
||||
Direction::Left,
|
||||
)
|
||||
.expect("Failed to connect left tunnel hidden node to right tunnel hidden node");
|
||||
}
|
||||
}
|
||||
8
src/map/mod.rs
Normal file
@@ -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
@@ -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')));
|
||||
}
|
||||
}
|
||||
68
src/map/render.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
//! Map rendering functionality.
|
||||
|
||||
use crate::constants::{BOARD_PIXEL_OFFSET, BOARD_PIXEL_SIZE};
|
||||
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(
|
||||
BOARD_PIXEL_OFFSET.x as i32,
|
||||
BOARD_PIXEL_OFFSET.y as i32,
|
||||
BOARD_PIXEL_SIZE.x,
|
||||
BOARD_PIXEL_SIZE.y,
|
||||
);
|
||||
let _ = map_texture.render(canvas, 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 + 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 + 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +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;
|
||||
}
|
||||
|
||||
/// A simple tick modulator that skips every Nth tick.
|
||||
pub struct SimpleTickModulator {
|
||||
tick_count: u32,
|
||||
ticks_left: u32,
|
||||
}
|
||||
|
||||
// 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 TickModulator for SimpleTickModulator {
|
||||
fn new(percent: f32) -> Self {
|
||||
let ticks_required: u32 = (1f32 / (1f32 - percent)).round() as u32;
|
||||
|
||||
SimpleTickModulator {
|
||||
tick_count: ticks_required,
|
||||
ticks_left: ticks_required,
|
||||
}
|
||||
}
|
||||
|
||||
fn next(&mut self) -> bool {
|
||||
if self.ticks_left == 0 {
|
||||
self.ticks_left = self.tick_count;
|
||||
return false;
|
||||
}
|
||||
|
||||
self.ticks_left -= 1;
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,41 @@
|
||||
//! This module provides a simple animation and atlas system for textures.
|
||||
use anyhow::Result;
|
||||
use glam::IVec2;
|
||||
use sdl2::render::WindowCanvas;
|
||||
use sdl2::rect::Rect;
|
||||
use sdl2::render::{Canvas, RenderTarget};
|
||||
|
||||
use crate::texture::sprite::AtlasTile;
|
||||
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
|
||||
|
||||
/// An animated texture using a texture atlas.
|
||||
#[derive(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) -> Self {
|
||||
Self {
|
||||
tiles,
|
||||
frame_duration,
|
||||
current_frame: 0,
|
||||
time_bank: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Advances the animation by one tick, unless paused.
|
||||
pub fn tick(&mut self) {
|
||||
if self.paused || self.ticks_per_frame == 0 {
|
||||
return;
|
||||
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();
|
||||
}
|
||||
|
||||
self.ticker += 1;
|
||||
}
|
||||
|
||||
pub fn current_tile(&self) -> &AtlasTile {
|
||||
if self.ticks_per_frame == 0 {
|
||||
return &self.frames[0];
|
||||
}
|
||||
let frame_index = (self.ticker / self.ticks_per_frame) as usize % self.frames.len();
|
||||
&self.frames[frame_index]
|
||||
&self.tiles[self.current_frame]
|
||||
}
|
||||
|
||||
pub fn render(&self, canvas: &mut WindowCanvas, dest: sdl2::rect::Rect) -> Result<()> {
|
||||
let tile = self.current_tile();
|
||||
tile.render(canvas, dest)
|
||||
pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, dest: Rect) -> Result<()> {
|
||||
let mut tile = self.current_tile().clone();
|
||||
tile.render(canvas, atlas, dest)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +1,36 @@
|
||||
//! A texture that blinks on/off for a specified number of ticks.
|
||||
use anyhow::Result;
|
||||
use glam::IVec2;
|
||||
use sdl2::render::WindowCanvas;
|
||||
|
||||
use crate::texture::animated::AnimatedTexture;
|
||||
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(&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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +1,57 @@
|
||||
//! 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 glam::IVec2;
|
||||
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 => &self.up,
|
||||
Direction::Down => &self.down,
|
||||
Direction::Left => &self.left,
|
||||
Direction::Right => &self.right,
|
||||
};
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
let frame_index = (self.ticker / self.ticks_per_frame) as usize % frames.len();
|
||||
let tile = &frames[frame_index];
|
||||
|
||||
tile.render(canvas, dest)
|
||||
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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
use glam::IVec2;
|
||||
use sdl2::{render::Canvas, video::Window};
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::entity::direction::Direction;
|
||||
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
|
||||
|
||||
pub mod animated;
|
||||
pub mod blinking;
|
||||
pub mod directional;
|
||||
pub mod sprite;
|
||||
|
||||
pub fn get_atlas_tile(atlas: &Rc<SpriteAtlas>, name: &str) -> AtlasTile {
|
||||
SpriteAtlas::get_tile(atlas, name).unwrap_or_else(|| panic!("Could not find tile {}", name))
|
||||
}
|
||||
pub mod text;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use anyhow::Result;
|
||||
use glam::U16Vec2;
|
||||
use sdl2::pixels::Color;
|
||||
use sdl2::rect::Rect;
|
||||
use sdl2::render::{Texture, WindowCanvas};
|
||||
use sdl2::render::{Canvas, RenderTarget, Texture};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct AtlasMapper {
|
||||
@@ -19,17 +19,34 @@ pub struct MapperFrame {
|
||||
pub height: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct AtlasTile {
|
||||
pub atlas: Rc<SpriteAtlas>,
|
||||
pub pos: U16Vec2,
|
||||
pub size: U16Vec2,
|
||||
pub color: Option<Color>,
|
||||
}
|
||||
|
||||
impl AtlasTile {
|
||||
pub fn render(&self, canvas: &mut WindowCanvas, dest: Rect) -> Result<()> {
|
||||
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>,
|
||||
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);
|
||||
canvas.copy(&self.atlas.texture, src, dest).map_err(anyhow::Error::msg)?;
|
||||
|
||||
if atlas.last_modulation != Some(color) {
|
||||
atlas.texture.set_color_mod(color.r, color.g, color.b);
|
||||
atlas.last_modulation = Some(color);
|
||||
}
|
||||
|
||||
canvas.copy(&atlas.texture, src, dest).map_err(anyhow::Error::msg)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -37,6 +54,8 @@ impl AtlasTile {
|
||||
pub struct SpriteAtlas {
|
||||
texture: Texture<'static>,
|
||||
tiles: HashMap<String, MapperFrame>,
|
||||
default_color: Option<Color>,
|
||||
last_modulation: Option<Color>,
|
||||
}
|
||||
|
||||
impl SpriteAtlas {
|
||||
@@ -44,18 +63,28 @@ impl SpriteAtlas {
|
||||
Self {
|
||||
texture,
|
||||
tiles: mapper.frames,
|
||||
default_color: None,
|
||||
last_modulation: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_tile(atlas: &Rc<SpriteAtlas>, name: &str) -> Option<AtlasTile> {
|
||||
atlas.tiles.get(name).map(|frame| AtlasTile {
|
||||
atlas: atlas.clone(),
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_color(&mut self, color: Color) {
|
||||
self.default_color = Some(color);
|
||||
}
|
||||
|
||||
pub fn texture(&self) -> &Texture<'static> {
|
||||
&self.texture
|
||||
}
|
||||
}
|
||||
|
||||
pub unsafe fn texture_to_static<'a>(texture: Texture<'a>) -> Texture<'static> {
|
||||
pub unsafe fn texture_to_static(texture: Texture) -> Texture<'static> {
|
||||
std::mem::transmute(texture)
|
||||
}
|
||||
|
||||
151
src/texture/text.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
//! This module provides text rendering using the texture atlas.
|
||||
//!
|
||||
//! The TextTexture system renders text from the atlas using character mapping.
|
||||
//! It supports a subset of characters with special handling for characters that
|
||||
//! can't be used in filenames.
|
||||
//!
|
||||
//! # Example Usage
|
||||
//!
|
||||
//! ```rust
|
||||
//! use pacman::texture::text::TextTexture;
|
||||
//!
|
||||
//! // Create a text texture with 1.0 scale (8x8 pixels per character)
|
||||
//! let mut text_renderer = TextTexture::new(1.0);
|
||||
//!
|
||||
//! // Set scale for larger text
|
||||
//! text_renderer.set_scale(2.0);
|
||||
//!
|
||||
//! // Calculate text width for positioning
|
||||
//! let width = text_renderer.text_width("GAME OVER");
|
||||
//! let height = text_renderer.text_height();
|
||||
//! ```
|
||||
//!
|
||||
//! # Supported Characters
|
||||
//!
|
||||
//! - Letters: A-Z, a-z
|
||||
//! - Numbers: 0-9
|
||||
//! - Common symbols: ! ? . , : ; - _ ( ) [ ] { } < > = + * / \ | & @ # $ % ^ ~ ` ' "
|
||||
//! - Space character
|
||||
//!
|
||||
//! # Character Mapping
|
||||
//!
|
||||
//! Most characters use their literal name (e.g., "A.png", "1.png").
|
||||
//! Special characters use alternative names:
|
||||
//! - `"` → "text/_double_quote.png"
|
||||
//! - `'` → "text/_single_quote.png"
|
||||
//! - `\` → "text/\\backslash.png"
|
||||
//! - ` ` (space) → "text/space.png"
|
||||
//!
|
||||
//! # Memory Optimization
|
||||
//!
|
||||
//! The system caches character tiles in a HashMap to avoid repeated
|
||||
//! atlas lookups. Only tiles for used characters are stored in memory.
|
||||
|
||||
use anyhow::Result;
|
||||
use glam::UVec2;
|
||||
|
||||
use sdl2::render::{Canvas, RenderTarget};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
|
||||
|
||||
/// A text texture that renders characters from the atlas.
|
||||
pub struct TextTexture {
|
||||
char_map: HashMap<char, AtlasTile>,
|
||||
scale: f32,
|
||||
}
|
||||
|
||||
impl TextTexture {
|
||||
/// Creates a new text texture with the given atlas and scale.
|
||||
pub fn new(scale: f32) -> Self {
|
||||
Self {
|
||||
char_map: HashMap::new(),
|
||||
scale,
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a character to its atlas tile, handling special characters.
|
||||
fn get_char_tile(&mut self, atlas: &SpriteAtlas, c: char) -> Option<AtlasTile> {
|
||||
if let Some(tile) = self.char_map.get(&c) {
|
||||
return Some(*tile);
|
||||
}
|
||||
|
||||
let tile_name = self.char_to_tile_name(c)?;
|
||||
let tile = atlas.get_tile(&tile_name)?;
|
||||
self.char_map.insert(c, tile);
|
||||
Some(tile)
|
||||
}
|
||||
|
||||
/// Converts a character to its tile name in the atlas.
|
||||
fn char_to_tile_name(&self, c: char) -> Option<String> {
|
||||
let name = match c {
|
||||
// Letters A-Z
|
||||
'A'..='Z' | '0'..='9' => format!("text/{c}.png"),
|
||||
// Special characters
|
||||
'!' => "text/!.png".to_string(),
|
||||
'-' => "text/-.png".to_string(),
|
||||
'"' => "text/_double_quote.png".to_string(),
|
||||
'/' => "text/_forward_slash.png".to_string(),
|
||||
// Skip spaces for now - they don't have a tile
|
||||
' ' => return None,
|
||||
|
||||
// Unsupported character
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(name)
|
||||
}
|
||||
|
||||
/// Renders a string of text at the given position.
|
||||
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(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, atlas, dest)?;
|
||||
}
|
||||
// Always advance x_offset for all characters (including spaces)
|
||||
x_offset += char_width;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets the scale for text rendering.
|
||||
pub fn set_scale(&mut self, scale: f32) {
|
||||
self.scale = scale;
|
||||
}
|
||||
|
||||
/// Gets the current scale.
|
||||
pub fn scale(&self) -> f32 {
|
||||
self.scale
|
||||
}
|
||||
|
||||
/// Calculates the width of a string in pixels at the current scale.
|
||||
pub fn text_width(&self, text: &str) -> u32 {
|
||||
let char_width = (8.0 * self.scale) as u32;
|
||||
let mut width = 0;
|
||||
|
||||
for c in text.chars() {
|
||||
if self.char_to_tile_name(c).is_some() {
|
||||
width += char_width;
|
||||
}
|
||||
}
|
||||
|
||||
width
|
||||
}
|
||||
|
||||
/// Calculates the height of text in pixels at the current scale.
|
||||
pub fn text_height(&self) -> u32 {
|
||||
(8.0 * self.scale) as u32
|
||||
}
|
||||
}
|
||||