Compare commits

..

30 Commits

Author SHA1 Message Date
fd7eecf53e feat: allow instant direction reversal, improve cell position state tracking 2025-07-22 12:12:41 -05:00
f540dc5373 docs: minor documentation commentsa cross project 2025-07-22 12:12:41 -05:00
f51a3ddeb0 chore: web frontend files 2025-07-22 10:06:30 -05:00
9f7c460369 chore: ignore ephemeral submodules, build.css 2025-07-22 10:06:13 -05:00
2c0df99dea fix: emscripten libc, dynamic feature targets for emscripten, vcpkg triplets for linux 2025-07-22 10:05:07 -05:00
cf12a04c69 chore: use emscripten linker args direct from demo project 2025-07-22 09:19:46 -05:00
fa7e985c0d feat: embedded data files, audio effects processing 2025-07-21 22:30:40 -05:00
f5ff90cb11 feat: smarter winapi-based console window handling 2025-07-21 21:54:22 -05:00
a0f65b551c feat: board reset, store original map matrix 2025-07-18 20:21:16 -05:00
8808a1aa3b feat: pellet consumption, score & map mutation 2025-07-18 20:15:50 -05:00
62b2c607a9 feat: ttf score rendering, konami font 2025-07-18 20:07:50 -05:00
14b34db6de feat: enable sdl2 gfx feature, update sdl2, use vcpkg for library builds 2025-07-18 19:30:57 -05:00
9238b53c40 refactor: simplify/improve is_adjacent, add tests & docs 2025-07-18 19:28:39 -05:00
3e498ee5c3 feat: pull latest rust-sdl2-emscripten build workflow 2025-06-17 12:00:59 -05:00
715eeb6296 docs(story): sdl2 mixer + gfx extensions, cross platform 2025-06-17 11:54:13 -05:00
682ce059fa docs(story): SDL2-TTF 2025-06-17 11:54:13 -05:00
c8314b23dd docs(story): long break, emscripten callback main loop, asyncify, vsync 2025-06-17 11:54:13 -05:00
40acffafd1 fix: rebuild, try removing zero ms sleeps 2025-06-17 11:54:13 -05:00
2187212b7c chore: increase speed, no modulation, increase animation speed 2025-06-17 11:54:13 -05:00
229d2242ef fix: minor comments, disable accelerated, use std sleep on web builds 2025-06-17 11:54:13 -05:00
00c4c76299 chore: add ogg/vorbis comment for emscripten 2025-06-17 11:54:13 -05:00
8b30a602bf fix: remove idbfs.js inclusion linker arg 2025-06-17 11:54:13 -05:00
83a5ccdb8e chore: delete emscripten.rs 2025-06-17 11:54:13 -05:00
44d8184d8b feat: downloading in Windows build process, cleaning script 2025-06-17 11:54:13 -05:00
0630fc56ec fix: key stealing, disable Emscripten module, disable colored ANSI for emscripten builds 2025-06-17 11:54:13 -05:00
98d8960c57 docs(story): begin documenting project story/history 2025-06-17 11:54:13 -05:00
394344c11f docs: experimental scoreboard concept 2025-06-17 11:54:13 -05:00
93ba470ce9 fix: progress on reproducible Windows builds, disable script tracing 2025-06-17 11:54:13 -05:00
129aed0ffb docs: add more TODOs 2025-06-17 11:54:13 -05:00
e062ada301 chore: remove old windows build/serve scripts 2025-06-17 11:54:13 -05:00
37 changed files with 1781 additions and 234 deletions

View File

@@ -1,8 +1,12 @@
[target.wasm32-unknown-emscripten]
[target.'cfg(target_os = "emscripten")']
# TODO: Document what the fuck this is.
rustflags = [
"-O", "-C", "link-args=-O2 --profiling",
# "-O", "-C", "link-args=-O2 --profiling",
#"-C", "link-args=-O3 --closure 1",
# "-C", "link-args=-g -gsource-map",
"-C", "link-args=-sASYNCIFY -sALLOW_MEMORY_GROWTH=1",
"-C", "link-args=-sUSE_SDL=2 -sUSE_SDL_IMAGE=2 -sSDL2_IMAGE_FORMATS=['png']",
"-C", "link-args=--preload-file assets/ -lidbfs.js",
]
# "-C", "link-args=-sALLOW_MEMORY_GROWTH=1",
"-C", "link-args=-sUSE_SDL=2 -sUSE_SDL_IMAGE=2 -sUSE_SDL_MIXER=2 -sUSE_OGG=1 -sUSE_SDL_GFX=2 -sUSE_SDL_TTF=2 -sSDL2_IMAGE_FORMATS=['png']",
# USE_OGG, USE_VORBIS for OGG/VORBIS usage
"-C", "link-args=--preload-file assets/",
]

View File

@@ -1,38 +1,225 @@
name: Github Pages
name: Build
on: [push]
permissions:
contents: write
jobs:
deploy:
wasm:
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
steps:
- uses: actions/checkout@v2 # repo checkout
- name: Checkout
uses: actions/checkout@v4
- uses: mymindstorm/setup-emsdk@v11 # setup emscripten toolchain
- name: Setup Emscripten SDK
uses: mymindstorm/setup-emsdk@v14
with:
version: 1.39.20
version: 3.1.43
- uses: actions-rs/toolchain@v1 # get rust toolchain for wasm
- name: Setup Rust (WASM32 Emscripten)
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
target: wasm32-unknown-emscripten
override: true
# TODO: Update to v2
- name: Rust Cache # cache the rust build artefacts
uses: Swatinem/rust-cache@v1
- name: Rust Cache
uses: Swatinem/rust-cache@v2
- name: Build # build
run: ./scripts/build-wasm.sh
- name: Install pnpm
uses: pnpm/action-setup@v3
with:
version: 8
run_install: true
- name: Build
run: ./scripts/build.sh -er # release mode, skip emsdk
- name: Upload Artifact
uses: actions/upload-pages-artifact@v2
uses: actions/upload-pages-artifact@v3
with:
path: './dist/'
path: "./dist/"
retention-days: 7
- name: Deploy
uses: actions/deploy-pages@v2
uses: actions/deploy-pages@v4
linux:
runs-on: ubuntu-latest
env:
TARGET: x86_64-unknown-linux-gnu
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install SDL2 Packages
run: sudo apt-get install libsdl2-dev libsdl2-image-dev libsdl2-mixer-dev libsdl2-ttf-dev libsdl2-gfx-dev
- name: Setup Rust Toolchain (Linux)
uses: dtolnay/rust-toolchain@stable
with:
target: ${{ env.TARGET }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
- name: Build
run: cargo build --release
- name: Assemble Archive
run: |
mkdir /tmp/example/
cp ./target/release/pacman /tmp/example/
chmod a+x /tmp/example/pacman
mkdir /tmp/example/assets
cp ./assets/TerminalVector.ttf ./assets/fruit.png /tmp/example/assets
- name: Install Cargo Binstall
uses: cargo-bins/cargo-binstall@main
- name: Acquire Package Version
run: |
cargo binstall toml-cli -y
PACKAGE_VERSION=$(toml get ./Cargo.toml package.version --raw)
echo "PACKAGE_VERSION=${PACKAGE_VERSION}" >> $GITHUB_ENV
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: "pacman-${{ env.PACKAGE_VERSION }}-${{ env.TARGET }}"
path: /tmp/example/
retention-days: 7
if-no-files-found: error
macos:
runs-on: macos-13
env:
TARGET: x86_64-apple-darwin
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install SDL2 Packages
run: brew install sdl2 sdl2_image sdl2_mixer sdl2_ttf sdl2_gfx
- name: Setup Rust Toolchain (MacOS)
uses: dtolnay/rust-toolchain@stable
with:
target: ${{ env.TARGET }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
- name: Build
run: cargo build --release
- name: Assemble Archive
run: |
mkdir /tmp/example/
cp ./target/release/pacman /tmp/example/
mkdir /tmp/example/assets
cp ./assets/TerminalVector.ttf ./assets/fruit.png /tmp/example/assets
- name: Install Cargo Binstall
uses: cargo-bins/cargo-binstall@main
- name: Acquire Package Version
run: |
cargo binstall toml-cli -y
PACKAGE_VERSION=$(toml get ./Cargo.toml package.version --raw)
echo "PACKAGE_VERSION=${PACKAGE_VERSION}" >> $GITHUB_ENV
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: "pacman-${{ env.PACKAGE_VERSION }}-${{ env.TARGET }}"
path: /tmp/example/
retention-days: 7
if-no-files-found: error
windows:
env:
TARGET: x86_64-pc-windows-gnu
SDL2: 2.30.2
SDL2_TTF: 2.22.0
SDL2_MIXER: 2.8.0
SDL2_IMAGE: 2.8.2
# SDL2_GFX: 1.0.4
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download SDL2 Libraries
run: |
curl -L "https://github.com/libsdl-org/SDL/releases/download/release-${{ env.SDL2 }}/SDL2-devel-${{ env.SDL2 }}-VC.zip" -o "sdl2_devel.zip"
curl -L "https://github.com/libsdl-org/SDL_mixer/releases/download/release-${{ env.SDL2_MIXER }}/SDL2_mixer-devel-${{ env.SDL2_MIXER }}-VC.zip" -o "sdl2_mixer_devel.zip"
curl -L "https://github.com/libsdl-org/SDL_ttf/releases/download/release-${{ env.SDL2_TTF }}/SDL2_ttf-devel-${{ env.SDL2_TTF }}-VC.zip" -o "sdl2_ttf_devel.zip"
curl -L "https://github.com/libsdl-org/SDL_image/releases/download/release-${{ env.SDL2_IMAGE }}/SDL2_image-devel-${{ env.SDL2_IMAGE }}-VC.zip" -o "sdl2_image_devel.zip"
- name: Extract SDL2 DLLs
run: |
7z x ./sdl2_devel.zip -o"./tmp/"
mv ./tmp/SDL2-${{ env.SDL2 }}/lib/x64/SDL2.dll ./
mv ./tmp/SDL2-${{ env.SDL2 }}/lib/x64/SDL2.lib ./
7z x ./sdl2_mixer_devel.zip -o"./tmp/"
mv ./tmp/SDL2_mixer-${{ env.SDL2_MIXER }}/lib/x64/SDL2_mixer.dll ./
mv ./tmp/SDL2_mixer-${{ env.SDL2_MIXER }}/lib/x64/SDL2_mixer.lib ./
7z x ./sdl2_ttf_devel.zip -o"./tmp/"
mv ./tmp/SDL2_ttf-${{ env.SDL2_TTF }}/lib/x64/SDL2_ttf.dll ./
mv ./tmp/SDL2_ttf-${{ env.SDL2_TTF }}/lib/x64/SDL2_ttf.lib ./
7z x ./sdl2_image_devel.zip -o"./tmp/"
mv ./tmp/SDL2_image-${{ env.SDL2_IMAGE }}/lib/x64/SDL2_image.dll ./
mv ./tmp/SDL2_image-${{ env.SDL2_IMAGE }}/lib/x64/SDL2_image.lib ./
- name: Install SDL2_gfx
run: |
C:\vcpkg\vcpkg.exe install sdl2-gfx:x64-windows-release
cp C:\vcpkg\packages\sdl2-gfx_x64-windows-release\bin\SDL2_gfx.dll ./
cp C:\vcpkg\packages\sdl2-gfx_x64-windows-release\lib\SDL2_gfx.lib ./
- name: Setup Rust (Windows)
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ env.TARGET }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
- name: Build
run: cargo build --release
- name: Prepare Archive
run: |
New-Item -Type Directory ./release/
Move-Item -Path ./target/release/pacman.exe -Destination ./release/
Move-Item -Path ./SDL2.dll, ./SDL2_image.dll, ./SDL2_ttf.dll, ./SDL2_mixer.dll, ./SDL2_gfx.dll -Destination ./release/
New-Item -Type Directory ./release/assets/
Move-Item -Path ./assets/TerminalVector.ttf, ./assets/fruit.png -Destination ./release/assets/
- name: Install Cargo Binstall
uses: cargo-bins/cargo-binstall@main
- name: Acquire Package Version
run: |
cargo binstall toml-cli -y
PACKAGE_VERSION=$(toml get ./Cargo.toml package.version --raw)
echo "PACKAGE_VERSION=${PACKAGE_VERSION}" >> $env:GITHUB_ENV
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: "pacman-${{ env.PACKAGE_VERSION }}-${{ env.TARGET }}"
path: ./release/
retention-days: 7
if-no-files-found: error

5
.gitignore vendored
View File

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

24
Cargo.lock generated
View File

@@ -17,6 +17,12 @@ version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "c_vec"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdd7a427adc0135366d99db65b36dae9237130997e560ed61118041fb72be6e8"
[[package]]
name = "cfg-if"
version = "1.0.0"
@@ -83,11 +89,13 @@ name = "pacman"
version = "0.1.0"
dependencies = [
"lazy_static",
"libc",
"sdl2",
"spin_sleep",
"tracing",
"tracing-error",
"tracing-subscriber",
"winapi",
]
[[package]]
@@ -160,11 +168,12 @@ checksum = "e5ea92a5b6195c6ef2a0295ea818b312502c6fc94dde986c5553242e18fd4ce2"
[[package]]
name = "sdl2"
version = "0.35.2"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7959277b623f1fb9e04aea73686c3ca52f01b2145f8ea16f4ff30d8b7623b1a"
checksum = "2d42407afc6a8ab67e36f92e80b8ba34cbdc55aaeed05249efe9a2e8d0e9feef"
dependencies = [
"bitflags",
"c_vec",
"lazy_static",
"libc",
"sdl2-sys",
@@ -172,12 +181,13 @@ dependencies = [
[[package]]
name = "sdl2-sys"
version = "0.35.2"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3586be2cf6c0a8099a79a12b4084357aa9b3e0b0d7980e3b67aaf7a9d55f9f0"
checksum = "3ff61407fc75d4b0bbc93dc7e4d6c196439965fbef8e4a4f003a36095823eac0"
dependencies = [
"cfg-if",
"libc",
"vcpkg",
"version-compare",
]
@@ -311,6 +321,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.1.1"

View File

@@ -7,8 +7,31 @@ edition = "2021"
[dependencies]
lazy_static = "1.4.0"
sdl2 = { version = "0.35", features = ["image", "ttf", "mixer"] }
spin_sleep = "1.1.1"
tracing = { version = "0.1.37", features = ["max_level_debug", "release_max_level_warn"]}
tracing = { version = "0.1.37", features = ["max_level_debug", "release_max_level_debug"]}
tracing-error = "0.2.0"
tracing-subscriber = {version = "0.3.17", features = ["env-filter"]}
tracing-subscriber = {version = "0.3.17", features = ["env-filter"]}
winapi = { version = "0.3", features = ["consoleapi", "fileapi", "handleapi", "processenv", "winbase", "wincon", "winnt", "winuser", "windef", "minwindef"] }
[target.'cfg(target_os = "emscripten")'.dependencies.sdl2]
version = "0.38"
default-features = false
features = ["ttf","image","gfx","mixer"]
[target.'cfg(not(target_os = "emscripten"))'.dependencies.sdl2]
version = "0.38"
default-features = false
features = ["ttf","image","gfx","mixer","static-link","use-vcpkg"]
[package.metadata.vcpkg]
dependencies = ["sdl2", "sdl2-image[libjpeg-turbo,tiff,libwebp]", "sdl2-ttf", "sdl2-gfx", "sdl2-mixer"]
git = "https://github.com/microsoft/vcpkg"
rev = "2024.05.24" # release 2024.05.24 # to check for a new one, check https://github.com/microsoft/vcpkg/releases
[package.metadata.vcpkg.target]
x86_64-pc-windows-msvc = { triplet = "x64-windows-static-md" }
stable-x86_64-unknown-linux-gnu = { triplet = "x86_64-unknown-linux-gnu" }
[target.'cfg(target_os = "emscripten")'.dependencies]
libc = "0.2.16"

View File

@@ -23,6 +23,9 @@ at.
- More than 4 ghosts
- Custom Level Generation
- Multi-map tunnelling
- Online Scoreboard
- WebAssembly build contains a special API key for communicating with server.
- To prevent abuse, the server will only accept scores from the WebAssembly build.
## Installation

333
STORY.md Normal file
View File

@@ -0,0 +1,333 @@
# Story
This is living document that describes the story of the project, from inspiration to solution.
When a website is available, this document will help curate it's content.
## Inspiration
I initially got the idea for this project after finding a video about another Pac-Man clone on YouTube.
[![Code Review Thumbnail][code-review-thumbnail]][code-review-video]
This implementation was written in C++, used SDL2 for graphics, and was kinda weird - but it worked.
- I think it was weird because the way it linked files together is extremely non-standard.
Essentially, it was a single file that included all the other files. This is not how C++ projects are typically structured.
- This implementation was also extremely dependent on OOP; Rust has no real counterpart for OOP code, so writing my own implementation would be a challenge.
## Lifetimes
Rust's SDL2 implementation is a wrapper around the C library, so it's not as nice as the C++ implementation.
Additionally, lifetimes in this library are a bit weird, making them quite difficult to deal with.
I found a whole blog post complaining about this ([1][fighting-lifetimes-1], [2][fighting-lifetimes-2], [3][fighting-lifetimes-3]), so I'm not alone in this.
## Emscripten & RuggRogue
One of the targets for this project is to build a web-accessible version of the game. If you were watching at all during
the Rust hype, one of it's primary selling points was a growing community of Rust-based web applications, thanks to
WebAssembly.
The problem is that much of this work was done for pure-Rust applications - and SDL is C++.
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
- Also compiles for Windows & Linux
This repository has been massively helpful in getting my WebAssembly builds working.
## Key Capturing Extensions in WASM Build
Some extensions I had installed were capturing keys.
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.
## Revisiting
I decided to revisit the project because I didn't want to see this project die. It's actually a lot of fun, and has a very interesting stack, with a simple premise, and a lot of potential for expansion.
Unfortunately, the issue above still lingered. I did a lot of testing, and concluded that I needed to create a simple example with as much stripped away as possible. All I learned from this was that the freeze occurred the moment that the 'Hidden' event (for the Window) was fired. After that, the rendered would take 0 nanoseconds to render, and some script for Asyncify would keep spinning in the background.
I tried to ask around but didn't get anywhere, but one reply on my post gave me the idea to back away from Emscripten 1.39.20 (several years old at this point).
## Emscripten Callback Main Loop
I looked into as many examples online (not that many), and came across an Emscripten callback loop exposed in C. Some were basic and all over the place, some were advanced, but also imbibed extremely annoying static lifetime requirements.
- I tried my best to satisfy and work with these lifetimes, but it was a nightmare.
- Instead, I tried to simplify and move away from this annoying Emscripten callback loop, but simpler ones had issues, crashing with `invalid renderer` errors.
- This guy named Greg Buchholz apparently was the creator of this special Emscripten bindings with the static lifetimes, and it was done to solve this issue with `invalid renderer`.
- [GitHub](https://github.com/gregbuchholz), [Repository](https://github.com/gregbuchholz/RuSDLem), [StackOverflow](https://stackoverflow.com/questions/69748049/rust-sdl2-emscripten-and-invalid-renderer-panic), [Forum Post](https://users.rust-lang.org/t/sdl2-emscripten-asmjs-and-invalid-renderer-panic/66567/2)
With this in mind, it seemed like I was at a dead end AGAIN; either I had to deal with the static lifetimes (I am not that good at Rust), or I had to deal with Asyncify.
But this did help me narrow my search even more for a good example. I needed to find a repository with Rust, SDL2, Emscripten, and `TextureCreator`.
`TextureCreator` was key, as the static lifetimes issue was most encumbering when dealing with borrows and lifetimes of `TextureCreator` inside the `main` loop closure.
## Return to Asyncify
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.
But, unlike my original project which called `std::thread::sleep` directly, it used bindings into Emscripten's functions like `emscripten_sleep`.
Even better, it had an example of script execution (JavaScript) bindings, which I could use to handle all sorts of things. I tested it out, and it worked.
## Instant::now() 32-bit Byte Cutoff
Unfortunately while trying to get basic FPS timings working, I got divide by zero errors when trying to calculate the time difference between two `Instant` times.
This was weird, and honestly, I'm confused as to why the 2-year old sample code 'worked' at the time, but not now.
After a bit of time, I noted that the `Instant` times were printing with only the whole seconds changing, and the nanoseconds were always 0.
```
Instant { tv_sec: 0, tv_nsec: 0 }
Instant { tv_sec: 1, tv_nsec: 0 }
Instant { tv_sec: 2, tv_nsec: 0 }
Instant { tv_sec: 3, tv_nsec: 0 }
Instant { tv_sec: 4, tv_nsec: 0 }
...
```
This was super weird, but I stumbled upon [an issue on GitHub](https://github.com/rust-lang/rust/issues/113852) that mentioned the exact situation I was in, as well as providing a patch solution (`emscripten_get_now`).
## VSync Gotcha
After getting the timing working, I noticed that the rendering was extremely slow. I was getting 60 FPS, but I wasn't sleeping at all.
Normally when rendering occurs, you want to sleep for the remaining time so that your game calculations can occur at a consistent rate (60 FPS for example).
If your rendering time is less than the sleep time, you can just sleep for the remaining time. But if your rendering time is greater than the sleep time, you encounter lag, the FPS starts to drop.
This was a confusing issue as I knew it couldn't be a coincidence that the rendering time was exactly ~16ms (60 FPS) every time.
After a little bit though, I found the `present_vsync` function in the SDL2 render initialization. This was causing the rendering to try and time the canvas present() to the monitor's refresh rate (60 FPS).
Maybe I could have skipped my custom timing and just used this, but I don't know if it would be platform-independent, what would happen on 120 FPS displays, etc.
## Emscripten v.s. SDL2-TTF
While working on the next extension of SDL2 for my test repository, SDL2-TTF had some pretty annoying issues. It would build fine, but it would raise a runtime error: `indirect call to null`.
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");
#[cfg(not(target_os = "emscripten"))]
fn ttf_context() -> ttf::Sdl2TtfContext {
ttf::init().unwrap()
}
#[cfg(target_os = "emscripten")]
fn ttf_context() -> &'static ttf::Sdl2TtfContext {
// Deliberately leak so we get a static lifetime
Box::leak(Box::new(ttf::init().unwrap()))
}
const FONT_MULTIPLE: u16 = 9;
// Funny division is done here to round to nearest multiple of FONT_MULTIPLE
const FONT_SIZE: u16 = (WINDOW_HEIGHT / 32) as u16 / FONT_MULTIPLE * FONT_MULTIPLE;
fn main() {
...
let font_data = RWops::from_bytes(FONT_DATA).unwrap();
let font_size = max(FONT_SIZE, FONT_MULTIPLE);
let font = ttf_context
.load_font_from_rwops(font_data, font_size)
.unwrap();
}
```
I don't particularly understand why loading from memory is used, but it's a neat trick. I tested normal font loading afterwards, and it seems to be totally fine.
On to the Mixer extension, then.
## Mixer and GFX
Mixer was relatively easy, I don't remember anything special about it.
As it happens, neither was SDL GFX, except for me finding that getting it compiling on Windows would soon be difficult; `SDL2_gfx` is not currently being updated, nor is it managed by the SDL team. This meant that no releases of development libraries including DLLs or LIB files were going to be available.
When I added in GFX, I wanted to add some mouse interaction since that currently wasn't being done anywhere in the demo, but I also wanted the ability for the mouse to be hidden until used.
Detecting whether the mouse was focusing the window or not wasn't super easy, and I'm still not sure that it's working perfectly, but at the very least Emscripten seems to support what I'm trying to do. I should look into asynchronous Javascript callbacks, see what Emscripten supports.
## Styling with PostCSS + Tailwind
I'm big on using Tailwind, and while this project probably could have done without it, I didn't want to forego my favorite tool.
But I also didn't want to include some big framework on this, like Astro, so I looked for the smallest way to include Tailwind.
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.
## Cross-platform Builds
With the next step of the demo project, I needed to get builds for every OS running, that's one down out of the four targets I'm gunning for.
Linux was the easiest, as usual, with `apt` providing access to all the development libraries of SDL & the associated extensions, including `SDL2_gfx`.
There's also no requirement for providing sidecar DLLs like Windows needs, so that worked well.
The hardest part was figuring out the most satisfying way to zip and load all the assets together, but luckily the artifact uploader provides it's own zip implementation; albeit I may need to modify it to add further system hinting (`.tar.gz` for Linux, `.dmg` for MacOS, `.zip` for Windows).
## SDL2 on Windows
SDL2 on Windows has to be one of the least fun development cycles; setting up the environment is pretty painful as there's almost no guides for Rust users to figure out each requirement. You'll learn fast, and this knowledge is hands on experience that will probably be applicable later on in C++ development, but I'm sure a fair number of Rust users like myself have no idea why a DLL or LIB file is necessary at all.
To be honest, I still don't.
Regardless, SDL2 needs a LIB file for compliation to be available in the root directory, and each extension has there own.
Once the EXE is compiled, the working directory needs to contain a DLL file for execution, too. Each extension has it's own as well.
This sounds easy, but acquiring these DLLs and LIB files is not easy. At the very least, the SDL-supported extensions have releases available containing
![SDL2 Mixer GitHub Release Files](https://i.xevion.dev/ShareX/2024/04/firefox_6zAmbsD97n.png)
So I got to creating a build step involving the download of each of these libraries. I'm no expert with `curl`, but I had it figured out eventually.
```yaml
- name: Download SDL2 Libraries
run: |
curl -L "https://github.com/libsdl-org/SDL/releases/download/release-${{ env.SDL2 }}/SDL2-devel-${{ env.SDL2 }}-VC.zip" -o "sdl2_devel.zip"
curl -L "https://github.com/libsdl-org/SDL_mixer/releases/download/release-${{ env.SDL2_MIXER }}/SDL2_mixer-devel-${{ env.SDL2_MIXER }}-VC.zip" -o "sdl2_mixer_devel.zip"
curl -L "https://github.com/libsdl-org/SDL_ttf/releases/download/release-${{ env.SDL2_TTF }}/SDL2_ttf-devel-${{ env.SDL2_TTF }}-VC.zip" -o "sdl2_ttf_devel.zip"
curl -L "https://github.com/libsdl-org/SDL_image/releases/download/release-${{ env.SDL2_IMAGE }}/SDL2_image-devel-${{ env.SDL2_IMAGE }}-VC.zip" -o "sdl2_image_devel.zip"
```
I did take a lot of care in making sure that versions were specified externally in different variables, which took a couple tries while I learned how interpolation works with GitHub Actions.
Additionally, I realized that `LIB` files were required for compliation after this, so I had to painfully fix all the files to use the `-devel-` version. Speifically the one with `-VC` appended.
I still do not know what VC means here. Perhaps it is related to `vcpkg` somehow.
The next step was to extract the files I needed from the `.zip`s, but that proved quite hard. I'm a lover of precision and using tools to the best of my knowledge, so I wanted to finely take just the DLL and ZIP I needed from these archives, and nothing else.
While I was able to get working commands to do this on Linux, finely finding the exact DLL and placing it in `pwd`, I was not able to replicate it on the Windows-imaged GitHub Runner;
When specifying the `-o` flag meaning 'output directory here' like `-o./tmp` (yes, there is no space in between), it would always error with `Too short switch: -o`. I was unable to find meaningful discussions on Google.
My Linux machine did not complain, and I wasn't yet ready to switch OSes for an error like this, so I just extracted everything and then `mv`'d the items into `pwd`.
I knew what lay ahead with `SDL2_gfx`, so I tested whether the compilation error changed, and luckily, it was only erroring on the missing `SDL2_gfx.lib` at this point.
While reading discussions online, I came across [a reddit post](https://www.reddit.com/r/rust_gamedev/comments/am84q9/using_sdl2_gfx_on_windows/efk6uwq/) talking about `vcpkg`. I'd heard of it, but never used the program before. It seemed like it could provide `SDL2_gfx` for me without hassle.
And that was partly true.
The primary 'boon' of `vcpkg` here was that it setup and compiled `SDL2_gfx` without the hassle of messing with the compiler, options, or most importantly: dependencies.
I didn't know it at the time, but `SDL2_gfx` depended on `SDL2` directly, and so I'd have to setup and compile both projects, if I was hoping to do this 'manually'.
## VCPKG for SDL2_GFX
I tried to use the GitHub-provided environment variables relating to VCPKG's installation location, but nothing really worked here. I was on the correcti mage (`windows-latest` for Windows 2022 Enterprise on GitHub's Runner Images), but nothing seemed to work.
[This comment](https://github.com/actions/runner-images/issues/6376#issuecomment-1781269302) seemed to describe the exact same experience I was happening, several months ago.
Alas, I simply tried `C:\vcpkg\` and it worked, providing me the ability to install `SDL2_gfx`.
As it were though, the hard part wasn't going to be compiling, but locating the DLLs and LIB files for movement. No matter where I looked online or in the logs, nothing was obvious about the location of my files.
In retrospect, a recursive `Get-ChildItem` looking for `DLL` or `LIB` files probably would've worked well, but.. yeah...
After a couple attempts with various test commits, I couldn't find it, and just switched to Windows to install and compile it myself, so I could locate the file manually.
> 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.
This brings me to one issue, and one fix; while compiling you're required to specify that the build is for 64 bit systems manually, on each invocation of VCPKG (while in classic mode, which I am).
On top of that, they'll be built in debug mode (with extra symbols and such) by default, which I am not interested in.
To get the x64 Release build of a package, append `:x64-windows-release` to it, as in `sdl2-gfx:x64-windows-release` for `sdl2-gfx`.
After getting this sorted, I struggled a little bit in using the `mv` (`Move-Item`) command in Powershell, as I battled with the comma delimited files when moving multiple files to a given destination. Dumb.
This is also the point at which I renamed the executable from `pacman` to `spiritus` to differentiate the two projects. The name is just my play on the word 'sprite'.
## Console Window Hiding
When launching the demo app, I saw a console window pop up, even though I launched it from the File Explorer; this is not the behavior I was interested in.
I believe that apps launched from File Explorer shouldn't have a console window available unless...
- It's a CLI app by nature, and it uses the Console Window.
- It has a specific debugging flag passed into it, perhaps by a Shortcut file.
- The console window is required for the nature of the app, or it is the preferred method of log inspection.
- It's a debug build.
But if it's launched from the console, then it should either
- Detach and relinquish control of the console back to the user.
- Use the console actively in it's logging.
Most programs I know and use follow this general consensus. Naturally, mine must too.
But, when searching for a solution online, it seemed what I want doesn't really exist; I implemented the closest approximation.
If `stdout` is detected to be a `tty` (an active console), the console window won't be hidden. Otherwise, it will be hidden.
Unfortunately, this results in the millisecond flash of a black console window appearing.
## Updating Deprecated Actions
As it were, most of the actions I were using were deprecated in some way. It didn't feel like I was using super old actions, but I guess I was. Luckily, most of them were simply just updating the version (`2` or `3` to `4` or `5`).
`actions-rs@toolchain` was different though, and was officially deprecated, the GitHub repository archived. Couldn't find a good reason why, but the repository was untouched in 4 years, so maybe that's why...
I found `dtolnay@rust-toolchain` and switched, it more or less was perfect with no differences. I think it's sorta neat that the Rust version is specified using the version of the action. I'd be worried though of a changing feature set across different action versions...
I guess a well designed GitHub Action shouldn't change much, including a Rust toolchain action.
## Artifact Naming
Perhaps it's super unnecessary and won't be appreciated, but I wanted the artifact files produced by my script to have semantic meaning in it's version and target.
For each OS, I extracted the targets (`x86_64-unknown-linux-gnu`, `x86_64-apple-darwin`...) into an environment variable scoped at the job level (love that).
I looked into ways to get the package version, but nothing obvious jumped out at me. I did come across `toml-cli` though, a Rust-based CLI program analogous to `jq` (for JSON).
It worked great, but it was sorta slow; compiling `toml-cli` added an extra 20-60 seconds for each job. I'd heard of a Rust project to speed up builds by providing prebuilt executables though; it's called `binstall`.
Even cooler, it was had an action available to easily add it to my build script, and so I had `toml-cli` installing and available 10x faster!
![GitHub Actions output showing 2 cargo binstall process taking 4 seconds total](https://i.xevion.dev/ShareX/2024/04/firefox_5sOEDM7zkc.png)
Perhaps I could use some special bash commands to acquire the package version, but it'd be a lot of work and maintenance to get it working in both bash and Powershell, maintaining it across four jobs.
This is both cool, fast, and easy!
![GitHub Artifacts with Sizes Listed](https://i.xevion.dev/ShareX/2024/04/firefox_jDI8MDwWRc.png)
I was thinking of a github-pages artifact name that aligns with the others, but I think that'd be stupid AND overkill.
Perhaps at the least I'll look into a 32-bit build for Windows, just for demonstration purposes.
[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/

BIN
assets/TerminalVector.ttf Normal file
View File

Binary file not shown.

BIN
assets/favicon.ico Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
assets/font/konami.ttf Normal file
View File

Binary file not shown.

View File

@@ -1,27 +1,65 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
</head>
<style>
body {
margin: 0;
padding: 0;
background: #000;
}
canvas {
display: block;
margin: 0 auto;
background: #000;
}
</style>
<body>
<canvas id="canvas"></canvas>
<script>
var Module = {
'canvas': document.getElementById('canvas'),
};
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pac-Man Arcade</title>
<link rel="stylesheet" href="build.css" />
<style>
</style>
</head>
<body class="bg-black text-yellow-400 text-center">
<a
href="https://github.com/Xevion/Pac-Man"
class="absolute top-0 right-0"
aria-label="View source on GitHub"
>
<svg
width="80"
height="80"
viewBox="0 0 250 250"
class="fill-yellow-400 text-black"
aria-hidden="true"
>
<path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path>
<path
d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2"
class="octo-arm"
></path>
<path
d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z"
class="octo-body"
></path>
</svg>
</a>
<h1 class="text-4xl mt-10 scaled-text">Pac-Man Arcade</h1>
<p class="text-lg mt-5 scaled-text">
Welcome to the Pac-Man Arcade! Use the controls below to play.
</p>
<canvas
id="canvas"
class="block mx-auto mt-5"
width="800"
height="600"
></canvas>
<div class="mt-10">
<span
class="inline-block mx-2 px-4 py-2 bg-yellow-400 text-black rounded scaled-text"
>&larr; &uarr; &rarr; &darr; Move</span
>
<span
class="inline-block mx-2 px-4 py-2 bg-yellow-400 text-black rounded scaled-text"
>Space Change Sprite</span
>
<span
class="inline-block mx-2 px-4 py-2 bg-yellow-400 text-black rounded scaled-text"
>Shift + &uarr;&darr; Change Volume</span
>
</div>
<script type="text/javascript">
var Module = {
canvas: document.getElementById("canvas"),
};
</script>
<script src="pacman.js"></script>
</body>
</html>
<script type="text/javascript" src="pacman.js"></script>
</body>
</html>

21
assets/styles.scss Normal file
View File

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

BIN
assets/wav/1.ogg Normal file
View File

Binary file not shown.

BIN
assets/wav/2.ogg Normal file
View File

Binary file not shown.

BIN
assets/wav/3.ogg Normal file
View File

Binary file not shown.

BIN
assets/wav/4.ogg Normal file
View File

Binary file not shown.

BIN
assets/wav/eating.wav Normal file
View File

Binary file not shown.

BIN
assets/wav/waka_ka.wav Normal file
View File

Binary file not shown.

BIN
assets/wav/waka_wa.wav Normal file
View File

Binary file not shown.

View File

@@ -1,7 +0,0 @@
& cargo build --target=wasm32-unknown-emscripten --release
mkdir -p dist -Force
cp ./target/wasm32-unknown-emscripten/release/Pac_Man.wasm ./dist
cp ./target/wasm32-unknown-emscripten/release/Pac-Man.js ./dist
cp index.html dist

76
build.sh Normal file
View File

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

194
build.ts Normal file
View File

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

View File

@@ -1,14 +0,0 @@
#!/bin/sh
set -eux
echo "Building WASM with Emscripten"
cargo build --target=wasm32-unknown-emscripten --release
echo "Copying release files to dist/"
mkdir -p dist
output_folder="target/wasm32-unknown-emscripten/release"
cp $output_folder/pacman.wasm dist
cp $output_folder/pacman.js dist
cp $output_folder/deps/pacman.data dist
cp assets/index.html dist

View File

@@ -1,3 +0,0 @@
& ./build.ps1
cd ./dist
python -m http.server

View File

@@ -1,3 +1,4 @@
//! This module provides a simple animation system for textures.
use sdl2::{
rect::Rect,
render::{Canvas, Texture},
@@ -6,18 +7,37 @@ use sdl2::{
use crate::direction::Direction;
/// An animated texture, which is a texture that is rendered as a series of
/// frames.
pub struct AnimatedTexture<'a> {
raw_texture: Texture<'a>,
/// The current tick of the animation.
ticker: u32,
/// Whether the animation is currently playing in reverse.
reversed: bool,
/// The offset of the texture, in pixels.
offset: (i32, i32),
/// The number of ticks per frame.
ticks_per_frame: u32,
/// The number of frames in the animation.
frame_count: u32,
/// The width of each frame, in pixels.
frame_width: u32,
/// The height of each frame, in pixels.
frame_height: u32,
}
impl<'a> AnimatedTexture<'a> {
/// Creates a new `AnimatedTexture`.
///
/// # Arguments
///
/// * `texture` - The texture to animate.
/// * `ticks_per_frame` - The number of ticks to display each frame for.
/// * `frame_count` - The number of frames in the animation.
/// * `frame_width` - The width of each frame.
/// * `frame_height` - The height of each frame.
/// * `offset` - The offset of the texture, in pixels.
pub fn new(
texture: Texture<'a>,
ticks_per_frame: u32,
@@ -38,12 +58,14 @@ impl<'a> AnimatedTexture<'a> {
}
}
// Get the current frame number
/// Returns the current frame number.
fn current_frame(&self) -> u32 {
self.ticker / self.ticks_per_frame
}
// Move to the next frame. If we are at the end of the animation, reverse the direction
/// Advances the animation by one tick.
///
/// The animation will play forwards, then backwards, then forwards, and so on.
pub fn tick(&mut self) {
if self.reversed {
self.ticker -= 1;
@@ -60,7 +82,7 @@ impl<'a> AnimatedTexture<'a> {
}
}
// Calculate the frame rect (portion of the texture to render) for the given frame.
/// Calculates the source rectangle for the given frame.
fn get_frame_rect(&self, frame: u32) -> Rect {
if frame >= self.frame_count {
panic!("Frame {} is out of bounds for this texture", frame);
@@ -74,6 +96,13 @@ impl<'a> AnimatedTexture<'a> {
)
}
/// Renders the animation to the canvas.
///
/// # Arguments
///
/// * `canvas` - The canvas to render to.
/// * `position` - The position to render the animation at.
/// * `direction` - The direction the animation is facing.
pub fn render(
&mut self,
canvas: &mut Canvas<Window>,
@@ -84,7 +113,15 @@ impl<'a> AnimatedTexture<'a> {
self.tick();
}
// Functions like render, but only ticks the animation until the given frame is reached.
/// Renders the animation to the canvas, but only ticks the animation until
/// the given frame is reached.
///
/// # Arguments
///
/// * `canvas` - The canvas to render to.
/// * `position` - The position to render the animation at.
/// * `direction` - The direction the animation is facing.
/// * `frame` - The frame to render until.
pub fn render_until(
&mut self,
canvas: &mut Canvas<Window>,
@@ -92,6 +129,9 @@ impl<'a> AnimatedTexture<'a> {
direction: Direction,
frame: u32,
) {
// TODO: If the frame we're targeting is in the opposite direction (due
// to self.reverse), we should pre-emptively reverse. This would require
// a more complex ticking mechanism.
let current = self.current_frame();
self.render_static(canvas, position, direction, Some(current));
@@ -100,7 +140,14 @@ impl<'a> AnimatedTexture<'a> {
}
}
// Renders a specific frame of the animation. Defaults to the current frame.
/// Renders a specific frame of the animation.
///
/// # Arguments
///
/// * `canvas` - The canvas to render to.
/// * `position` - The position to render the animation at.
/// * `direction` - The direction the animation is facing.
/// * `frame` - The frame to render. If `None`, the current frame is used.
pub fn render_static(
&mut self,
canvas: &mut Canvas<Window>,

82
src/audio.rs Normal file
View File

@@ -0,0 +1,82 @@
//! This module handles the audio playback for the game.
use sdl2::{
mixer::{self, Chunk, InitFlag, LoaderRWops, DEFAULT_FORMAT},
rwops::RWops,
};
const SOUND_1_DATA: &[u8] = include_bytes!("../assets/wav/1.ogg");
const SOUND_2_DATA: &[u8] = include_bytes!("../assets/wav/2.ogg");
const SOUND_3_DATA: &[u8] = include_bytes!("../assets/wav/3.ogg");
const SOUND_4_DATA: &[u8] = include_bytes!("../assets/wav/4.ogg");
/// An array of all the sound effect data.
const SOUND_DATA: [&[u8]; 4] = [SOUND_1_DATA, SOUND_2_DATA, SOUND_3_DATA, SOUND_4_DATA];
/// The audio system for the game.
///
/// This struct is responsible for initializing the audio device, loading sounds,
/// and playing them.
pub struct Audio {
_mixer_context: mixer::Sdl2MixerContext,
sounds: Vec<Chunk>,
next_sound_index: usize,
}
impl Audio {
/// Creates a new `Audio` instance.
pub fn new() -> Self {
let frequency = 44100;
let format = DEFAULT_FORMAT;
let channels = 4;
let chunk_size = 128;
mixer::open_audio(frequency, format, 1, chunk_size).expect("Failed to open audio");
mixer::allocate_channels(channels);
// set channel volume
for i in 0..channels {
mixer::Channel(i as i32).set_volume(32);
}
let mixer_context = mixer::init(InitFlag::OGG).expect("Failed to initialize SDL2_mixer");
let sounds: Vec<Chunk> = SOUND_DATA
.iter()
.enumerate()
.map(|(i, data)| {
let rwops = RWops::from_bytes(data)
.expect(&format!("Failed to create RWops for sound {}", i + 1));
rwops.load_wav().expect(&format!(
"Failed to load sound {} from embedded data",
i + 1
))
})
.collect();
Audio {
_mixer_context: mixer_context,
sounds,
next_sound_index: 0,
}
}
/// Plays the "eat" sound effect.
///
/// This function also logs the time since the last sound effect was played.
pub fn eat(&mut self) {
if let Some(chunk) = self.sounds.get(self.next_sound_index) {
match mixer::Channel(0).play(chunk, 0) {
Ok(channel) => {
tracing::info!(
"Playing sound #{} on channel {:?}",
self.next_sound_index + 1,
channel
);
}
Err(e) => {
tracing::warn!("Could not play sound #{}: {}", self.next_sound_index + 1, e);
}
}
}
self.next_sound_index = (self.next_sound_index + 1) % self.sounds.len();
}
}

View File

View File

@@ -1,21 +1,40 @@
//! This module contains all the constants used in the game.
/// The width of the game board, in cells.
pub const BOARD_WIDTH: u32 = 28;
pub const BOARD_HEIGHT: u32 = 31; // Adjusted to fit map texture?
/// The height of the game board, in cells.
pub const BOARD_HEIGHT: u32 = 31;
/// The size of each cell, in pixels.
pub const CELL_SIZE: u32 = 24;
pub const BOARD_OFFSET: (u32, u32) = (0, 3); // Relative cell offset for where map text / grid starts
/// 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 width of the window, in pixels.
pub const WINDOW_WIDTH: u32 = CELL_SIZE * BOARD_WIDTH;
pub const WINDOW_HEIGHT: u32 = CELL_SIZE * (BOARD_HEIGHT + 6); // Map texture is 6 cells taller (3 above, 3 below) than the grid
/// 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);
/// An enum representing the different types of tiles on the map.
#[derive(Debug, Copy, Clone, PartialEq)]
pub enum MapTile {
/// An empty tile.
Empty,
/// A wall tile.
Wall,
/// A regular pellet.
Pellet,
/// A power pellet.
PowerPellet,
/// A starting position for an entity.
StartingPosition(u8),
}
/// The raw layout of the game board, as a 2D array of characters.
pub const RAW_BOARD: [&str; BOARD_HEIGHT as usize] = [
"############################",
"#............##............#",

View File

@@ -1,5 +1,8 @@
//! This module defines the `Direction` enum, which is used to represent the
//! direction of an entity.
use sdl2::keyboard::Keycode;
/// An enum representing the direction of an entity.
#[derive(Debug, Copy, Clone, PartialEq)]
pub enum Direction {
Up,
@@ -9,6 +12,7 @@ pub enum Direction {
}
impl Direction {
/// Returns the angle of the direction in degrees.
pub fn angle(&self) -> f64 {
match self {
Direction::Right => 0f64,
@@ -18,6 +22,7 @@ impl Direction {
}
}
/// Returns the offset of the direction as a tuple of (x, y).
pub fn offset(&self) -> (i32, i32) {
match self {
Direction::Right => (1, 0),
@@ -27,16 +32,27 @@ impl Direction {
}
}
/// Returns the opposite direction.
pub fn opposite(&self) -> Direction {
match self {
Direction::Right => Direction::Left,
Direction::Down => Direction::Up,
Direction::Left => Direction::Right,
Direction::Up => Direction::Down,
}
}
/// Creates a `Direction` from a `Keycode`.
///
/// # Arguments
///
/// * `keycode` - The keycode to convert.
pub fn from_keycode(keycode: Keycode) -> Option<Direction> {
match keycode {
Keycode::D => Some(Direction::Right),
Keycode::Right => Some(Direction::Right),
Keycode::A => Some(Direction::Left),
Keycode::Left => Some(Direction::Left),
Keycode::W => Some(Direction::Up),
Keycode::Up => Some(Direction::Up),
Keycode::S => Some(Direction::Down),
Keycode::Down => Some(Direction::Down),
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,
}
}

View File

@@ -1,49 +0,0 @@
// taken from https://github.com/Gigoteur/PX8/blob/master/src/px8/emscripten.rs
#[cfg(target_os = "emscripten")]
pub mod emscripten {
use std::cell::RefCell;
use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_float, c_int, c_void};
use std::ptr::null_mut;
#[allow(non_camel_case_types)]
type em_callback_func = unsafe extern "C" fn();
extern "C" {
// void emscripten_set_main_loop(em_callback_func func, int fps, int simulate_infinite_loop)
pub fn emscripten_set_main_loop(
func: em_callback_func,
fps: c_int,
simulate_infinite_loop: c_int,
);
pub fn emscripten_cancel_main_loop();
pub fn emscripten_pause_main_loop();
pub fn emscripten_get_now() -> c_float;
}
thread_local!(static MAIN_LOOP_CALLBACK: RefCell<*mut c_void> = RefCell::new(null_mut()));
pub fn set_main_loop_callback<F>(callback: F)
where
F: FnMut(),
{
MAIN_LOOP_CALLBACK.with(|log| {
*log.borrow_mut() = &callback as *const _ as *mut c_void;
});
unsafe {
emscripten_set_main_loop(wrapper::<F>, -1, 1);
}
unsafe extern "C" fn wrapper<F>()
where
F: FnMut(),
{
MAIN_LOOP_CALLBACK.with(|z| {
let closure = *z.borrow_mut() as *mut F;
(*closure)();
});
}
}
}

View File

@@ -1,11 +1,16 @@
//! This module defines the `Entity` trait, which is implemented by all game
//! objects that can be moved and rendered.
/// A trait for game objects that can be moved and rendered.
pub trait Entity {
// Returns true if the entity is colliding with the other entity
/// Returns true if the entity is colliding with the other entity.
fn is_colliding(&self, other: &dyn Entity) -> bool;
// Returns the absolute position of the entity
/// Returns the absolute position of the entity, in pixels.
fn position(&self) -> (i32, i32);
// Returns the cell position of the entity (XY position within the grid)
/// Returns the cell position of the entity, in grid coordinates.
fn cell_position(&self) -> (u32, u32);
/// Returns the position of the entity within its current cell, in pixels.
fn internal_position(&self) -> (u32, u32);
// Tick the entity (move it, perform collision checks, etc)
/// Ticks the entity, which updates its state and position.
fn tick(&mut self);
}

View File

@@ -1,48 +1,112 @@
//! This module contains the main game logic and state.
use std::rc::Rc;
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 tracing::event;
use crate::audio::Audio;
use crate::constants::{MapTile, BOARD_HEIGHT, BOARD_WIDTH, RAW_BOARD};
use crate::direction::Direction;
use crate::entity::Entity;
use crate::map::Map;
use crate::pacman::Pacman;
// Embed texture data directly into the executable
static PACMAN_TEXTURE_DATA: &[u8] = include_bytes!("../assets/32/pacman.png");
static PELLET_TEXTURE_DATA: &[u8] = include_bytes!("../assets/24/pellet.png");
static POWER_PELLET_TEXTURE_DATA: &[u8] = include_bytes!("../assets/24/energizer.png");
static MAP_TEXTURE_DATA: &[u8] = include_bytes!("../assets/map.png");
static FONT_DATA: &[u8] = include_bytes!("../assets/font/konami.ttf");
/// 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.
pub struct Game<'a> {
canvas: &'a mut Canvas<Window>,
map_texture: Texture<'a>,
pellet_texture: Texture<'a>,
power_pellet_texture: Texture<'a>,
font: Font<'a, 'static>,
pacman: Pacman<'a>,
map: Rc<Map>,
map: Rc<std::cell::RefCell<Map>>,
debug: bool,
score: u32,
audio: Audio,
}
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<'a>(
canvas: &'a mut Canvas<Window>,
texture_creator: &'a TextureCreator<WindowContext>,
ttf_context: &'a sdl2::ttf::Sdl2TtfContext,
_audio_subsystem: &'a sdl2::AudioSubsystem,
) -> Game<'a> {
let map = Rc::new(Map::new(RAW_BOARD));
let map = Rc::new(std::cell::RefCell::new(Map::new(RAW_BOARD)));
// Load Pacman texture from embedded data
let pacman_atlas = texture_creator
.load_texture("assets/32/pacman.png")
.expect("Could not load pacman texture");
.load_texture_bytes(PACMAN_TEXTURE_DATA)
.expect("Could not load pacman texture from embedded data");
let pacman = Pacman::new((1, 1), pacman_atlas, Rc::clone(&map));
// Load pellet texture from embedded data
let pellet_texture = texture_creator
.load_texture_bytes(PELLET_TEXTURE_DATA)
.expect("Could not load pellet texture from embedded data");
// Load power pellet texture from embedded data
let power_pellet_texture = texture_creator
.load_texture_bytes(POWER_PELLET_TEXTURE_DATA)
.expect("Could not load power pellet texture from embedded data");
// Load font from embedded data
let font_rwops = RWops::from_bytes(FONT_DATA).expect("Failed to create RWops for font");
let font = ttf_context
.load_font_from_rwops(font_rwops, 24)
.expect("Could not load font from embedded data");
let audio = Audio::new();
// Load map texture from embedded data
let mut map_texture = texture_creator
.load_texture_bytes(MAP_TEXTURE_DATA)
.expect("Could not load map texture from embedded data");
map_texture.set_color_mod(0, 0, 255);
Game {
canvas,
pacman: pacman,
debug: false,
map: map,
map_texture: texture_creator
.load_texture("assets/map.png")
.expect("Could not load pacman texture"),
map_texture,
pellet_texture,
power_pellet_texture,
font,
score: 0,
audio,
}
}
/// 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);
@@ -52,30 +116,130 @@ impl Game<'_> {
if keycode == Keycode::Space {
self.debug = !self.debug;
}
// 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;
}
/// Resets the game to its initial state.
pub fn reset(&mut self) {
// Reset the map to restore all pellets
{
let mut map = self.map.borrow_mut();
map.reset();
}
// Reset the score
self.score = 0;
// Reset Pacman position (you might want to customize this)
// For now, we'll keep Pacman where he is, but you could add:
// self.pacman.position = Map::cell_to_pixel((1, 1));
event!(tracing::Level::INFO, "Game reset - map and score cleared");
}
/// Advances the game by one tick.
pub fn tick(&mut self) {
self.check_pellet_eating();
self.pacman.tick();
}
/// Checks if Pac-Man is currently eating a pellet and updates the game state
/// accordingly.
fn check_pellet_eating(&mut self) {
let cell_pos = self.pacman.cell_position();
// Check if there's a pellet at the current position
let tile = {
let map = self.map.borrow();
map.get_tile((cell_pos.0 as i32, cell_pos.1 as i32))
};
if let Some(tile) = tile {
let pellet_value = match tile {
MapTile::Pellet => Some(10),
MapTile::PowerPellet => Some(50),
_ => None,
};
if let Some(value) = pellet_value {
{
let mut map = self.map.borrow_mut();
map.set_tile((cell_pos.0 as i32, cell_pos.1 as i32), MapTile::Empty);
}
self.add_score(value);
self.audio.eat();
event!(
tracing::Level::DEBUG,
"Pellet eaten at ({}, {})",
cell_pos.0,
cell_pos.1
);
}
}
}
/// 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 pellets
for x in 0..BOARD_WIDTH {
for y in 0..BOARD_HEIGHT {
let tile = self
.map
.borrow()
.get_tile((x as i32, y as i32))
.unwrap_or(MapTile::Empty);
let texture = match tile {
MapTile::Pellet => Some(&self.pellet_texture),
MapTile::PowerPellet => Some(&self.power_pellet_texture),
_ => None,
};
if let Some(texture) = texture {
let position = Map::cell_to_pixel((x, y));
let dst_rect = sdl2::rect::Rect::new(position.0, position.1, 24, 24);
self.canvas
.copy(texture, None, Some(dst_rect))
.expect("Could not render pellet");
}
}
}
// Render the pacman
self.pacman.render(self.canvas);
// Draw a grid
// Render score
self.render_ui();
// Draw the debug grid
if self.debug {
for x in 0..BOARD_WIDTH {
for y in 0..BOARD_HEIGHT {
let tile = self
.map
.borrow()
.get_tile((x as i32, y as i32))
.unwrap_or(MapTile::Empty);
let mut color = None;
@@ -103,11 +267,19 @@ impl Game<'_> {
self.draw_cell((next_cell.0 as u32, next_cell.1 as u32), Color::YELLOW);
}
// Present the canvas
self.canvas.present();
}
/// Draws a single cell to the canvas with the given color.
///
/// # Arguments
///
/// * `cell` - The cell to draw, in grid coordinates.
/// * `color` - The color to draw the cell with.
fn draw_cell(&mut self, cell: (u32, u32), color: Color) {
let position = Map::cell_to_pixel(cell);
self.canvas.set_draw_color(color);
self.canvas
.draw_rect(sdl2::rect::Rect::new(
@@ -118,4 +290,49 @@ impl Game<'_> {
))
.expect("Could not draw rectangle");
}
/// Renders the user interface, including the score and lives.
fn render_ui(&mut self) {
let lives = 3;
let score_text = format!("{:02}", self.score);
let x_offset = 12;
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(
&format!("{}UP HIGH SCORE ", lives),
(24 * lives_offset + x_offset, y_offset),
Color::WHITE,
);
self.render_text(
&score_text,
(24 * score_offset + x_offset, 24 + y_offset + gap_offset),
Color::WHITE,
);
}
/// Renders text to the screen at the given position.
fn render_text(&mut self, text: &str, position: (i32, i32), color: Color) {
let surface = self
.font
.render(text)
.blended(color)
.expect("Could not render text surface");
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.0, position.1, query.width, query.height);
self.canvas
.copy(&texture, None, Some(dst_rect))
.expect("Could not render text texture");
}
}

View File

@@ -1,15 +1,112 @@
//! This module contains helper functions that are used throughout the game.
/// 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: (u32, u32), b: (u32, u32), diagonal: bool) -> bool {
let (ax, ay) = a;
let (bx, by) = b;
// Calculate absolute differences between coordinates
let dx = if ax > bx { ax - bx } else { bx - ax };
let dy = if ay > by { ay - by } else { by - ay };
if diagonal {
(ax == bx && (ay == by + 1 || ay == by - 1))
|| (ay == by && (ax == bx + 1 || ax == bx - 1))
|| (ax == bx + 1 && ay == by + 1)
|| (ax == bx + 1 && ay == by - 1)
|| (ax == bx - 1 && ay == by + 1)
|| (ax == bx - 1 && ay == by - 1)
// For diagonal adjacency: both differences must be ≤ 1 and at least one > 0
dx <= 1 && dy <= 1 && (dx + dy) > 0
} else {
(ax == bx && (ay == by + 1 || ay == by - 1))
|| (ay == by && (ax == bx + 1 || ax == bx - 1))
// For orthogonal adjacency: exactly one difference must be 1, the other 0
(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((0, 0), (0, 0), false));
// Adjacent positions should be true
assert!(is_adjacent((0, 0), (1, 0), false)); // Right
assert!(is_adjacent((0, 0), (0, 1), false)); // Down
assert!(is_adjacent((1, 1), (0, 1), false)); // Left
assert!(is_adjacent((1, 1), (1, 0), false)); // Up
// Diagonal positions should be false
assert!(!is_adjacent((0, 0), (1, 1), false));
assert!(!is_adjacent((0, 1), (1, 0), false));
// Positions more than 1 step away should be false
assert!(!is_adjacent((0, 0), (2, 0), false));
assert!(!is_adjacent((0, 0), (0, 2), false));
assert!(!is_adjacent((0, 0), (2, 2), false));
}
#[test]
fn test_diagonal_adjacency() {
// Test diagonal adjacency (diagonal = true)
// Same position should not be adjacent
assert!(!is_adjacent((0, 0), (0, 0), true));
// Orthogonal adjacent positions should be true
assert!(is_adjacent((0, 0), (1, 0), true)); // Right
assert!(is_adjacent((0, 0), (0, 1), true)); // Down
assert!(is_adjacent((1, 1), (0, 1), true)); // Left
assert!(is_adjacent((1, 1), (1, 0), true)); // Up
// Diagonal adjacent positions should be true
assert!(is_adjacent((0, 0), (1, 1), true)); // Down-right
assert!(is_adjacent((1, 0), (0, 1), true)); // Down-left
assert!(is_adjacent((0, 1), (1, 0), true)); // Up-right
assert!(is_adjacent((1, 1), (0, 0), true)); // Up-left
// Positions more than 1 step away should be false
assert!(!is_adjacent((0, 0), (2, 0), true));
assert!(!is_adjacent((0, 0), (0, 2), true));
assert!(!is_adjacent((0, 0), (2, 2), true));
assert!(!is_adjacent((0, 0), (1, 2), true));
}
#[test]
fn test_edge_cases() {
// Test with larger coordinates
assert!(is_adjacent((100, 100), (101, 100), false));
assert!(is_adjacent((100, 100), (100, 101), false));
assert!(!is_adjacent((100, 100), (102, 100), false));
assert!(is_adjacent((100, 100), (101, 101), true));
assert!(!is_adjacent((100, 100), (102, 102), true));
// Test with zero coordinates
assert!(is_adjacent((0, 0), (1, 0), false));
assert!(is_adjacent((0, 0), (0, 1), false));
assert!(is_adjacent((0, 0), (1, 1), true));
}
#[test]
fn test_commutative_property() {
// The function should work the same regardless of parameter order
assert_eq!(
is_adjacent((1, 2), (2, 2), false),
is_adjacent((2, 2), (1, 2), false)
);
assert_eq!(
is_adjacent((1, 2), (2, 3), true),
is_adjacent((2, 3), (1, 2), true)
);
}
}

View File

@@ -1,31 +1,87 @@
#![windows_subsystem = "windows"]
use crate::constants::{WINDOW_HEIGHT, WINDOW_WIDTH};
use crate::game::Game;
use sdl2::event::Event;
use sdl2::event::{Event, WindowEvent};
use sdl2::keyboard::Keycode;
use spin_sleep::sleep;
use std::time::{Duration, Instant};
use tracing::event;
use tracing_error::ErrorLayer;
use tracing_subscriber::layer::SubscriberExt;
#[cfg(windows)]
use winapi::{
shared::{ntdef::NULL, windef::HWND},
um::{
fileapi::{CreateFileA, OPEN_EXISTING},
handleapi::INVALID_HANDLE_VALUE,
processenv::SetStdHandle,
winbase::{STD_ERROR_HANDLE, STD_OUTPUT_HANDLE},
wincon::{AttachConsole, GetConsoleWindow},
winnt::{FILE_SHARE_READ, FILE_SHARE_WRITE, GENERIC_READ, GENERIC_WRITE},
},
};
/// Attaches the process to the parent console on Windows.
///
/// This allows the application to print to the console when run from a terminal,
/// which is useful for debugging purposes. If the application is not run from a
/// terminal, this function does nothing.
#[cfg(windows)]
unsafe fn attach_console() {
if GetConsoleWindow() != std::ptr::null_mut() as HWND {
return;
}
if AttachConsole(winapi::um::wincon::ATTACH_PARENT_PROCESS) != 0 {
let handle = CreateFileA(
"CONOUT$\0".as_ptr() as *const i8,
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
std::ptr::null_mut(),
OPEN_EXISTING,
0,
NULL,
);
if handle != INVALID_HANDLE_VALUE {
SetStdHandle(STD_OUTPUT_HANDLE, handle);
SetStdHandle(STD_ERROR_HANDLE, handle);
}
}
// Do NOT call AllocConsole here - we don't want a console when launched from Explorer
}
mod animation;
mod audio;
mod constants;
mod direction;
mod entity;
mod game;
mod helper;
mod map;
mod modulation;
mod pacman;
#[cfg(target_os = "emscripten")]
mod emscripten;
/// The main entry point of the application.
///
/// This function initializes SDL, the window, the game state, and then enters
/// the main game loop.
pub fn main() {
// Attaches the console on Windows for debugging purposes.
#[cfg(windows)]
unsafe {
attach_console();
}
let sdl_context = sdl2::init().unwrap();
let video_subsystem = sdl_context.video().unwrap();
let audio_subsystem = sdl_context.audio().unwrap();
let ttf_context = sdl2::ttf::init().unwrap();
// Setup tracing
let subscriber = tracing_subscriber::fmt()
.with_ansi(cfg!(not(target_os = "emscripten")))
.with_max_level(tracing::Level::DEBUG)
.finish()
.with(ErrorLayer::default());
@@ -40,7 +96,6 @@ pub fn main() {
let mut canvas = window
.into_canvas()
.accelerated()
.build()
.expect("Could not build canvas");
@@ -49,7 +104,12 @@ pub fn main() {
.expect("Could not set logical size");
let texture_creator = canvas.texture_creator();
let mut game = Game::new(&mut canvas, &texture_creator);
let mut game = Game::new(
&mut canvas,
&texture_creator,
&ttf_context,
&audio_subsystem,
);
let mut event_pump = sdl_context
.event_pump()
@@ -59,13 +119,17 @@ pub fn main() {
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 tick_no = 0u32;
// The start of a period of time over which we average the frame time.
let mut last_averaging_time = Instant::now();
// The total time spent sleeping during the current averaging period.
let mut sleep_time = Duration::ZERO;
let mut paused = false;
// Whether the window is currently shown.
let mut shown = false;
event!(
tracing::Level::INFO,
@@ -75,9 +139,22 @@ pub fn main() {
let mut main_loop = || {
let start = Instant::now();
// TODO: Fix key repeat delay issues by using VecDeque for instant key repeat
// 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 {
@@ -105,15 +182,25 @@ pub fn main() {
}
}
// TODO: Proper pausing implementation that does not interfere with statistic gathering
// 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 - start.elapsed();
sleep(time);
let time = loop_time.saturating_sub(start.elapsed());
if time != Duration::ZERO {
#[cfg(not(target_os = "emscripten"))]
{
spin_sleep::sleep(time);
}
#[cfg(target_os = "emscripten")]
{
std::thread::sleep(time);
}
}
sleep_time += time;
} else {
event!(
@@ -125,6 +212,7 @@ pub fn main() {
tick_no += 1;
// Caclulate and display performance statistics every 60 seconds.
const PERIOD: u32 = 60 * 60;
let tick_mod = tick_no % PERIOD;
if tick_mod % PERIOD == 0 {
@@ -132,21 +220,13 @@ pub fn main() {
let average_sleep = sleep_time / PERIOD;
let average_process = loop_time - average_sleep;
event!(
tracing::Level::DEBUG,
"Timing Averages [fps={}] [sleep={:?}] [process={:?}]",
average_fps,
average_sleep,
average_process
);
sleep_time = Duration::ZERO;
last_averaging_time = Instant::now();
}
true
};
loop {
if !main_loop() {
break;

View File

@@ -1,13 +1,26 @@
use crate::constants::MapTile;
//! This module defines the game map and provides functions for interacting with it.
use crate::constants::{MapTile, BOARD_OFFSET, CELL_SIZE};
use crate::constants::{BOARD_HEIGHT, BOARD_WIDTH};
/// 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 {
inner: [[MapTile; BOARD_HEIGHT as usize]; BOARD_WIDTH as usize],
/// 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 inner = [[MapTile::Empty; BOARD_HEIGHT as usize]; BOARD_WIDTH as usize];
let mut map = [[MapTile::Empty; BOARD_HEIGHT as usize]; BOARD_WIDTH as usize];
for y in 0..BOARD_HEIGHT as usize {
let line = raw_board[y];
@@ -35,13 +48,31 @@ impl Map {
_ => panic!("Unknown character in board: {}", character),
};
inner[x as usize][y as usize] = tile;
map[x as usize][y as usize] = tile;
}
}
Map { inner: inner }
Map {
current: map,
default: map.clone(),
}
}
/// Resets the map to its original state.
pub fn reset(&mut self) {
// Restore the map to its original state
for x in 0..BOARD_WIDTH as usize {
for y in 0..BOARD_HEIGHT as usize {
self.current[x][y] = 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: (i32, i32)) -> Option<MapTile> {
let x = cell.0 as usize;
let y = cell.1 as usize;
@@ -50,10 +81,36 @@ impl Map {
return None;
}
Some(self.inner[x][y])
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: (i32, i32), tile: MapTile) -> bool {
let x = cell.0 as usize;
let y = cell.1 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: (u32, u32)) -> (i32, i32) {
((cell.0 as i32) * 24, ((cell.1 + 3) as i32) * 24)
(
(cell.0 * CELL_SIZE) as i32,
((cell.1 + BOARD_OFFSET.1) * CELL_SIZE) as i32,
)
}
}

View File

@@ -1,3 +1,5 @@
//! 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
@@ -12,17 +14,25 @@
///
/// 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
// TODO: Look into average precision, binary code modulation strategy
// 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;
@@ -34,15 +44,12 @@ impl TickModulator for SimpleTickModulator {
}
fn next(&mut self) -> bool {
self.ticks_left -= 1;
// Return whether or not we should skip this tick
if self.ticks_left == 0 {
// We've reached the tick to skip, reset the counter
self.ticks_left = self.tick_count;
false
} else {
true
return false;
}
self.ticks_left -= 1;
true
}
}

View File

@@ -1,3 +1,5 @@
//! This module defines the Pac-Man entity, including its behavior and rendering.
use std::cell::RefCell;
use std::rc::Rc;
use sdl2::{
@@ -16,66 +18,133 @@ use crate::{
modulation::{SimpleTickModulator, TickModulator},
};
/// The Pac-Man entity.
pub struct Pacman<'a> {
// Absolute position on the board (precise)
pub position: (i32, i32),
/// The absolute position of Pac-Man on the board, in pixels.
pub pixel_position: (i32, i32),
/// The position of Pac-Man on the board, in grid coordinates.
/// This is only updated at the moment Pac-Man is aligned with the grid.
pub cell_position: (u32, u32),
/// The current direction of Pac-Man.
pub direction: Direction,
/// 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,
map: Rc<Map>,
map: Rc<RefCell<Map>>,
speed: u32,
modulation: SimpleTickModulator,
sprite: AnimatedTexture<'a>,
}
impl Pacman<'_> {
pub fn new<'a>(starting_position: (u32, u32), atlas: Texture<'a>, map: Rc<Map>) -> Pacman<'a> {
/// Creates a new `Pacman` instance.
///
/// # Arguments
///
/// * `starting_position` - The starting position of Pac-Man, in grid coordinates.
/// * `atlas` - The texture atlas containing the Pac-Man sprites.
/// * `map` - A reference to the game map.
pub fn new<'a>(
starting_position: (u32, u32),
atlas: Texture<'a>,
map: Rc<RefCell<Map>>,
) -> Pacman<'a> {
Pacman {
position: Map::cell_to_pixel(starting_position),
pixel_position: Map::cell_to_pixel(starting_position),
cell_position: starting_position,
direction: Direction::Right,
next_direction: None,
speed: 2,
speed: 3,
map,
stopped: false,
modulation: SimpleTickModulator::new(0.9333),
sprite: AnimatedTexture::new(atlas, 4, 3, 32, 32, Some((-4, -4))),
modulation: SimpleTickModulator::new(1.0),
sprite: AnimatedTexture::new(atlas, 2, 3, 32, 32, Some((-4, -4))),
}
}
/// Renders Pac-Man to the canvas.
///
/// # Arguments
///
/// * `canvas` - The SDL canvas to render to.
pub fn render(&mut self, canvas: &mut Canvas<Window>) {
// When stopped, render the last frame of the animation
if self.stopped {
self.sprite
.render_until(canvas, self.position, self.direction, 2);
.render_until(canvas, self.pixel_position, self.direction, 2);
} else {
self.sprite.render(canvas, self.position, self.direction);
self.sprite
.render(canvas, self.pixel_position, self.direction);
}
}
/// Calculates the next cell in the given direction.
///
/// # Arguments
///
/// * `direction` - The direction to check. If `None`, the current direction is used.
pub fn next_cell(&self, direction: Option<Direction>) -> (i32, i32) {
let (x, y) = direction.unwrap_or(self.direction).offset();
let cell = self.cell_position();
let cell = self.cell_position;
(cell.0 as i32 + x, cell.1 as i32 + y)
}
fn handle_requested_direction(&mut self) {
if self.next_direction.is_none() {
return;
}
if self.next_direction.unwrap() == self.direction {
self.next_direction = None;
return;
/// Handles a requested direction change.
///
/// The direction change is only applied if the next tile in the requested
/// direction is not a wall.
fn handle_direction_change(&mut self) -> bool {
match self.next_direction {
// If there is no next direction, do nothing.
None => return false,
// If the next direction is the same as the current direction, do nothing.
Some(next_direction) => {
if next_direction == self.direction {
self.next_direction = None;
return false;
}
}
}
// Get the next cell in the proposed direction.
let proposed_next_cell = self.next_cell(self.next_direction);
let proposed_next_tile = self
.map
.borrow()
.get_tile(proposed_next_cell)
.unwrap_or(MapTile::Empty);
if proposed_next_tile != MapTile::Wall {
self.direction = self.next_direction.unwrap();
self.next_direction = None;
// If the next tile is a wall, do nothing.
if proposed_next_tile == MapTile::Wall {
return false;
}
// If the next tile is not a wall, change direction.
event!(
tracing::Level::DEBUG,
"Direction change: {:?} -> {:?} at position ({}, {}) internal ({}, {})",
self.direction,
self.next_direction.unwrap(),
self.pixel_position.0,
self.pixel_position.1,
self.internal_position().0,
self.internal_position().1
);
self.direction = self.next_direction.unwrap();
self.next_direction = None;
true
}
/// Returns the internal position of Pac-Man, rounded down to the nearest
/// even number.
///
/// This is used to ensure that Pac-Man is aligned with the grid before
/// changing direction.
fn internal_position_even(&self) -> (u32, u32) {
let (x, y) = self.internal_position();
((x / 2u32) * 2u32, (y / 2u32) * 2u32)
}
}
@@ -87,15 +156,11 @@ impl Entity for Pacman<'_> {
}
fn position(&self) -> (i32, i32) {
self.position
self.pixel_position
}
fn cell_position(&self) -> (u32, u32) {
let (x, y) = self.position;
(
(x as u32 / CELL_SIZE) - BOARD_OFFSET.0,
(y as u32 / CELL_SIZE) - BOARD_OFFSET.1,
)
self.cell_position
}
fn internal_position(&self) -> (u32, u32) {
@@ -104,13 +169,35 @@ impl Entity for Pacman<'_> {
}
fn tick(&mut self) {
let can_change = self.internal_position() == (0, 0);
// Pac-Man can only change direction when he is perfectly aligned with the grid.
let can_change = self.internal_position_even() == (0, 0);
if let Some(next_direction) = self.next_direction {
if next_direction == self.direction.opposite() {
let next_tile_position = self.next_cell(Some(next_direction));
let next_tile = self
.map
.borrow()
.get_tile(next_tile_position)
.unwrap_or(MapTile::Empty);
if next_tile != MapTile::Wall {
self.direction = next_direction;
self.next_direction = None;
}
}
}
if can_change {
self.handle_requested_direction();
self.handle_direction_change();
let next = self.next_cell(None);
let next_tile = self.map.get_tile(next).unwrap_or(MapTile::Empty);
// Check if the next tile in the current direction is a wall.
let next_tile_position = self.next_cell(None);
let next_tile = self
.map
.borrow()
.get_tile(next_tile_position)
.unwrap_or(MapTile::Empty);
if !self.stopped && next_tile == MapTile::Wall {
event!(tracing::Level::DEBUG, "Wall collision. Stopping.");
@@ -125,18 +212,26 @@ impl Entity for Pacman<'_> {
let speed = self.speed as i32;
match self.direction {
Direction::Right => {
self.position.0 += speed;
self.pixel_position.0 += speed;
}
Direction::Left => {
self.position.0 -= speed;
self.pixel_position.0 -= speed;
}
Direction::Up => {
self.position.1 -= speed;
self.pixel_position.1 -= speed;
}
Direction::Down => {
self.position.1 += speed;
self.pixel_position.1 += speed;
}
}
// Update the cell position if Pac-Man is aligned with the grid.
if self.internal_position_even() == (0, 0) {
self.cell_position = (
(self.pixel_position.0 as u32 / CELL_SIZE) - BOARD_OFFSET.0,
(self.pixel_position.1 as u32 / CELL_SIZE) - BOARD_OFFSET.1,
);
}
}
}
}