mirror of
https://github.com/Xevion/Pac-Man.git
synced 2025-12-06 09:15:46 -06:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 023697dcd7 | |||
| 87ee12543e | |||
| b308bc0ef7 | |||
| 9d5ca54234 | |||
| 2ae73c3c58 | |||
| adfa2cc737 | |||
| 7c937df002 | |||
| 9fb9c959a3 | |||
| 61ebc8f317 | |||
| b7f668c58a | |||
| b1021c28b5 | |||
| 7d6f92283a | |||
| 2a295b1daf | |||
| 4398ec2936 | |||
| 324c358672 | |||
| cda8c40195 | |||
| 89b4ba125f | |||
| fcdbe62f99 | |||
| 57c7afcdb4 | |||
| 2e16c2d170 | |||
| f86c106593 | |||
| 04cf8f217f | |||
| 7e0ca4ff3d | |||
| fcc36c8a46 | |||
| 41affcd7ad | |||
| 4ecfded4ac | |||
| 25d5121a28 | |||
| 91095ed2cc | |||
| cbf52bb994 | |||
| d763b9646f |
@@ -5,6 +5,7 @@ rustflags = [
|
||||
"-C", "link-args=-sUSE_SDL=2 -sUSE_SDL_IMAGE=2 -sUSE_SDL_MIXER=2 -sUSE_OGG=1 -sUSE_SDL_GFX=2 -sUSE_SDL_TTF=2 -sSDL2_IMAGE_FORMATS=['png']",
|
||||
"-C", "link-args=--preload-file assets/game/",
|
||||
]
|
||||
runner = "node"
|
||||
|
||||
[target.'cfg(target_os = "linux")']
|
||||
rustflags = [
|
||||
|
||||
2
.config/nextest.toml
Normal file
2
.config/nextest.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[profile.default]
|
||||
fail-fast = false
|
||||
58
.github/workflows/build.yaml
vendored
58
.github/workflows/build.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Build
|
||||
name: Builds
|
||||
|
||||
on: ["push", "pull_request"]
|
||||
|
||||
@@ -11,20 +11,23 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
toolchain: [1.88.0]
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
artifact_name: pacman
|
||||
toolchain: 1.88.0
|
||||
- os: macos-13
|
||||
target: x86_64-apple-darwin
|
||||
artifact_name: pacman
|
||||
toolchain: 1.88.0
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
artifact_name: pacman
|
||||
toolchain: 1.88.0
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-gnu
|
||||
artifact_name: pacman.exe
|
||||
toolchain: 1.88.0
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -90,7 +93,7 @@ jobs:
|
||||
uses: pyodide/setup-emsdk@v15
|
||||
with:
|
||||
version: 3.1.43
|
||||
actions-cache-folder: "emsdk-cache"
|
||||
actions-cache-folder: "emsdk-cache-b"
|
||||
|
||||
- name: Setup Rust (WASM32 Emscripten)
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
@@ -101,27 +104,48 @@ jobs:
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
version: 8
|
||||
run_install: true
|
||||
bun-version: latest
|
||||
|
||||
- name: Build with Emscripten
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --target=wasm32-unknown-emscripten --release
|
||||
# Retry mechanism for Emscripten build - only retry on specific hash errors
|
||||
MAX_RETRIES=3
|
||||
RETRY_DELAY=30
|
||||
|
||||
- name: Assemble
|
||||
run: |
|
||||
echo "Generating CSS"
|
||||
pnpx postcss-cli ./assets/site/styles.scss -o ./assets/site/build.css
|
||||
for attempt in $(seq 1 $MAX_RETRIES); do
|
||||
echo "Build attempt $attempt of $MAX_RETRIES"
|
||||
|
||||
echo "Copying WASM files"
|
||||
# Capture output and check for specific error while preserving real-time output
|
||||
if bun run web.build.ts 2>&1 | tee /tmp/build_output.log; then
|
||||
echo "Build successful on attempt $attempt"
|
||||
break
|
||||
else
|
||||
echo "Build failed on attempt $attempt"
|
||||
|
||||
mkdir -p dist
|
||||
cp assets/site/{build.css,favicon.ico,index.html} dist
|
||||
output_folder="target/wasm32-unknown-emscripten/release"
|
||||
cp $output_folder/pacman.{wasm,js} $output_folder/deps/pacman.data dist
|
||||
# Check if the failure was due to the specific hash error
|
||||
if grep -q "emcc: error: Unexpected hash:" /tmp/build_output.log; then
|
||||
echo "::warning::Detected 'emcc: error: Unexpected hash:' error - will retry (attempt $attempt of $MAX_RETRIES)"
|
||||
|
||||
if [ $attempt -eq $MAX_RETRIES ]; then
|
||||
echo "::error::All retry attempts failed. Exiting with error."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Waiting $RETRY_DELAY seconds before retry..."
|
||||
sleep $RETRY_DELAY
|
||||
|
||||
# Exponential backoff: double the delay for next attempt
|
||||
RETRY_DELAY=$((RETRY_DELAY * 2))
|
||||
else
|
||||
echo "Build failed but not due to hash error - not retrying"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
|
||||
13
.github/workflows/coverage.yaml
vendored
13
.github/workflows/coverage.yaml
vendored
@@ -18,6 +18,7 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||
components: llvm-tools-preview
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
@@ -40,19 +41,17 @@ jobs:
|
||||
cargo install cargo-vcpkg
|
||||
cargo vcpkg -v build
|
||||
|
||||
- name: Install cargo-tarpaulin
|
||||
run: cargo install cargo-tarpaulin
|
||||
- uses: taiki-e/install-action@cargo-llvm-cov
|
||||
- uses: taiki-e/install-action@nextest
|
||||
|
||||
# Note: We manually link zlib. This should be synchronized with the flags set for Linux in .cargo/config.toml.
|
||||
- name: Generate coverage report
|
||||
run: |
|
||||
cargo tarpaulin \
|
||||
--out Html \
|
||||
--output-dir coverage \
|
||||
--rustflags="-C link-arg=-lz"
|
||||
cargo llvm-cov --no-fail-fast --lcov --output-path lcov.info nextest
|
||||
|
||||
- name: Upload coverage to Coveralls
|
||||
uses: coverallsapp/github-action@v2
|
||||
with:
|
||||
files: ./coverage/tarpaulin-report.html
|
||||
files: ./lcov.info
|
||||
format: lcov
|
||||
allow-empty: false
|
||||
|
||||
10
.github/workflows/test.yaml
vendored
10
.github/workflows/test.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Test
|
||||
name: Tests
|
||||
|
||||
on: ["push", "pull_request"]
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||
components: clippy
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
@@ -42,8 +42,10 @@ jobs:
|
||||
cargo install cargo-vcpkg
|
||||
cargo vcpkg -v build
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --workspace --verbose
|
||||
- uses: taiki-e/install-action@nextest
|
||||
|
||||
- name: Run nextest
|
||||
run: cargo nextest run --workspace
|
||||
|
||||
- name: Run clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,7 +1,6 @@
|
||||
/target
|
||||
/dist
|
||||
target/
|
||||
dist/
|
||||
emsdk/
|
||||
.idea
|
||||
*.dll
|
||||
rust-sdl2-emscripten/
|
||||
assets/site/build.css
|
||||
emsdk/
|
||||
|
||||
89
README.md
89
README.md
@@ -1,9 +1,33 @@
|
||||
# Pac-Man
|
||||
|
||||
If the title doesn't clue you in, I'm remaking Pac-Man with SDL and Rust.
|
||||
[![Tests Status][badge-test]][test] [![Build Status][badge-build]][build] [![Code Coverage][badge-coverage]][coverage] [![Online Demo][badge-online-demo]][demo] [![Last Commit][badge-last-commit]][commits]
|
||||
|
||||
The project is _extremely_ early in development, but check back in a week, and maybe I'll have something cool to look
|
||||
at.
|
||||
[badge-test]: https://github.com/Xevion/Pac-Man/actions/workflows/test.yaml/badge.svg
|
||||
[badge-build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml/badge.svg
|
||||
[badge-coverage]: https://coveralls.io/repos/github/Xevion/Pac-Man/badge.svg?branch=master
|
||||
[badge-demo]: https://img.shields.io/github/deployments/Xevion/Pac-Man/github-pages?label=GitHub%20Pages
|
||||
[badge-online-demo]: https://img.shields.io/badge/GitHub%20Pages-Demo-brightgreen
|
||||
[badge-last-commit]: https://img.shields.io/github/last-commit/Xevion/Pac-Man
|
||||
[build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml
|
||||
[test]: https://github.com/Xevion/Pac-Man/actions/workflows/test.yaml
|
||||
[coverage]: https://coveralls.io/github/Xevion/Pac-Man?branch=master
|
||||
[demo]: https://xevion.github.io/Pac-Man/
|
||||
[commits]: https://github.com/Xevion/Pac-Man/commits/master
|
||||
|
||||
## Description
|
||||
|
||||
A faithful recreation of the classic Pac-Man arcade game written in Rust. This project aims to replicate the original game's mechanics, graphics, sound, and behavior as accurately as possible while providing modern development features like cross-platform compatibility and WebAssembly support.
|
||||
|
||||
The game includes all the original features you'd expect from Pac-Man:
|
||||
|
||||
- [x] Classic maze navigation and dot collection
|
||||
- [ ] Four ghosts with their unique AI behaviors (Blinky, Pinky, Inky, and Clyde)
|
||||
- [ ] Power pellets that allow Pac-Man to eat ghosts
|
||||
- [ ] Fruit bonuses that appear periodically
|
||||
- [ ] Progressive difficulty with faster ghosts and shorter power pellet duration
|
||||
- [x] Authentic sound effects and sprites
|
||||
|
||||
Built with SDL2 for cross-platform graphics and audio, this implementation can run on Windows, Linux, macOS, and in web browsers via WebAssembly.
|
||||
|
||||
## Feature Targets
|
||||
|
||||
@@ -27,53 +51,14 @@ at.
|
||||
- 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
|
||||
## Build Notes
|
||||
|
||||
Besides SDL2, the following extensions are required: Image, Mixer, and TTF.
|
||||
|
||||
### Ubuntu
|
||||
|
||||
On Ubuntu, you can install the required packages with the following command:
|
||||
|
||||
```
|
||||
sudo apt install libsdl2-dev libsdl2-image-dev libsdl2-mixer-dev libsdl2-ttf-dev
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
On Windows, installation requires either building from source (not covered), or downloading the pre-built binaries.
|
||||
|
||||
The latest releases can be found here:
|
||||
|
||||
- [SDL2](https://github.com/libsdl-org/SDL/releases/latest/)
|
||||
- [SDL2_image](https://github.com/libsdl-org/SDL_image/releases/latest/)
|
||||
- [SDL2_mixer](https://github.com/libsdl-org/SDL_mixer/releases/latest/)
|
||||
- [SDL2_ttf](https://github.com/libsdl-org/SDL_ttf/releases/latest/)
|
||||
|
||||
Download each for your architecture, and locate the appropriately named DLL within. Move said DLL to root of this project.
|
||||
|
||||
In total, you should have the following DLLs in the root of the project:
|
||||
|
||||
- SDL2.dll
|
||||
- SDL2_mixer.dll
|
||||
- SDL2_ttf.dll
|
||||
- SDL2_image.dll
|
||||
- libpngX-X.dll
|
||||
- Not sure on what specific version is to be used, or if naming matters. `libpng16-16.dll` is what I had used.
|
||||
- zlib1.dll
|
||||
|
||||
## Building
|
||||
|
||||
To build the project, run the following command:
|
||||
|
||||
```
|
||||
cargo build
|
||||
```
|
||||
|
||||
During development, you can easily run the project with:
|
||||
|
||||
```
|
||||
cargo run
|
||||
cargo run -q # Quiet mode, no logging
|
||||
cargo run --release # Release mode, optimized
|
||||
```
|
||||
- Install `cargo-vcpkg` with `cargo install cargo-vcpkg`, then run `cargo vcpkg build` to build the requisite dependencies via vcpkg.
|
||||
- For the WASM build, you need to have the Emscripten SDK cloned; you can do so with `git clone https://github.com/emscripten-core/emsdk.git`
|
||||
- The first time you clone, you'll need to install the appropriate SDK version with `./emsdk install 3.1.43` and then activate it with `./emsdk activate 3.1.43`. On Windows, use `./emsdk/emsdk.ps1` instead.
|
||||
- You can then activate the Emscripten SDK with `source ./emsdk/emsdk_env.sh` or `./emsdk/emsdk_env.ps1` or `./emsdk/emsdk_env.bat` depending on your OS/terminal.
|
||||
- While using the `web.build.ts` is not technically required, it simplifies the build process and is very helpful.
|
||||
- It is intended to be run with `bun`, which you can acquire at [bun.sh](https://bun.sh/)
|
||||
- Tip: You can launch a fileserver with `python` or `caddy` to serve the files in the `dist` folder.
|
||||
- `python3 -m http.server 8080 -d dist`
|
||||
- `caddy file-server --root dist` (install with `[sudo apt|brew|choco] install caddy` or [a dozen other ways](https://caddyserver.com/docs/install))
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 90 B |
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB |
@@ -1,23 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@font-face {
|
||||
font-family: "Liberation Mono";
|
||||
src:
|
||||
url("LiberationMono.woff2") format("woff2"),
|
||||
url("LiberationMono.woff") format("woff");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
canvas {
|
||||
@apply w-full h-[65vh] min-h-[200px] block mx-auto bg-black;
|
||||
}
|
||||
|
||||
.code {
|
||||
@apply px-1 rounded font-mono bg-zinc-900 border border-zinc-700 lowercase;
|
||||
}
|
||||
|
||||
/*# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbInN0eWxlcy5zY3NzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBIiwiZmlsZSI6ImJ1aWxkLmNzcyIsInNvdXJjZXNDb250ZW50IjpbIkB0YWlsd2luZCBiYXNlO1xuQHRhaWx3aW5kIGNvbXBvbmVudHM7XG5AdGFpbHdpbmQgdXRpbGl0aWVzO1xuXG5AZm9udC1mYWNlIHtcbiAgICBmb250LWZhbWlseTogXCJMaWJlcmF0aW9uIE1vbm9cIjtcbiAgICBzcmM6XG4gICAgICAgIHVybChcIkxpYmVyYXRpb25Nb25vLndvZmYyXCIpIGZvcm1hdChcIndvZmYyXCIpLFxuICAgICAgICB1cmwoXCJMaWJlcmF0aW9uTW9uby53b2ZmXCIpIGZvcm1hdChcIndvZmZcIik7XG4gICAgZm9udC13ZWlnaHQ6IG5vcm1hbDtcbiAgICBmb250LXN0eWxlOiBub3JtYWw7XG4gICAgZm9udC1kaXNwbGF5OiBzd2FwO1xufVxuXG5jYW52YXMge1xuICAgIEBhcHBseSB3LWZ1bGwgaC1bNjV2aF0gbWluLWgtWzIwMHB4XSBibG9jayBteC1hdXRvIGJnLWJsYWNrO1xufVxuXG4uY29kZSB7XG4gICAgQGFwcGx5IHB4LTEgcm91bmRlZCBmb250LW1vbm8gYmctemluYy05MDAgYm9yZGVyIGJvcmRlci16aW5jLTcwMCBsb3dlcmNhc2U7XG59XG4iXX0= */
|
||||
@@ -2,12 +2,25 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Pac-Man Arcade</title>
|
||||
<title>Pac-Man in Rust</title>
|
||||
<link rel="stylesheet" href="build.css" />
|
||||
<style>
|
||||
/* Minimal fallback to prevent white flash and canvas pop-in before CSS loads */
|
||||
html,
|
||||
body {
|
||||
background: #000;
|
||||
color: #facc15;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
#canvas {
|
||||
display: block;
|
||||
margin: 1.5rem auto;
|
||||
background: #000;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-black text-yellow-400 text-center">
|
||||
<body class="bg-black text-yellow-400 text-center min-h-screen">
|
||||
<a
|
||||
href="https://github.com/Xevion/Pac-Man"
|
||||
class="absolute top-0 right-0"
|
||||
@@ -17,7 +30,7 @@
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 250 250"
|
||||
class="fill-yellow-400 text-black"
|
||||
class="fill-yellow-400 text-white"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path>
|
||||
@@ -31,33 +44,54 @@
|
||||
></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>
|
||||
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<header class="pt-10">
|
||||
<h1 class="text-4xl arcade-title scaled-text">Pac-Man in Rust</h1>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 flex items-center justify-center px-4">
|
||||
<div class="w-full max-w-5xl">
|
||||
<canvas
|
||||
id="canvas"
|
||||
class="block mx-auto mt-5"
|
||||
width="800"
|
||||
height="600"
|
||||
class="block mx-auto bg-black w-full max-w-[90vw] h-auto mt-5 rounded-xl shadow-[inset_0_0_0_2px_rgba(255,255,255,0.12),0_10px_30px_rgba(0,0,0,0.8)]"
|
||||
></canvas>
|
||||
<div class="mt-10">
|
||||
<span
|
||||
class="inline-block mx-2 px-4 py-2 bg-yellow-400 text-black rounded scaled-text"
|
||||
>← ↑ → ↓ Move</span
|
||||
>
|
||||
<span
|
||||
class="inline-block mx-2 px-4 py-2 bg-yellow-400 text-black rounded scaled-text"
|
||||
>Space Change Sprite</span
|
||||
>
|
||||
<span
|
||||
class="inline-block mx-2 px-4 py-2 bg-yellow-400 text-black rounded scaled-text"
|
||||
>Shift + ↑↓ Change Volume</span
|
||||
|
||||
<div
|
||||
class="mt-8 flex flex-wrap gap-3 justify-center items-center text-sm"
|
||||
>
|
||||
<span class="code">← ↑ → ↓</span>
|
||||
<span class="opacity-70">Move</span>
|
||||
|
||||
<span class="mx-2 opacity-30">|</span>
|
||||
|
||||
<span class="code">Space</span>
|
||||
<span class="opacity-70">Toggle Debug</span>
|
||||
|
||||
<span class="mx-2 opacity-30">|</span>
|
||||
|
||||
<span class="code">P</span>
|
||||
<span class="opacity-70">Pause / Unpause</span>
|
||||
|
||||
<span class="mx-2 opacity-30">|</span>
|
||||
|
||||
<span class="code">M</span>
|
||||
<span class="opacity-70">Mute / Unmute</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
const canvas = document.getElementById("canvas");
|
||||
var Module = {
|
||||
canvas: document.getElementById("canvas"),
|
||||
canvas: canvas,
|
||||
preRun: [
|
||||
() => {
|
||||
[...canvas.classList]
|
||||
.filter((className) => className.includes("shadow-"))
|
||||
.forEach((className) => canvas.classList.remove(className));
|
||||
},
|
||||
],
|
||||
};
|
||||
</script>
|
||||
<script type="text/javascript" src="pacman.js"></script>
|
||||
|
||||
28
assets/site/styles.css
Normal file
28
assets/site/styles.css
Normal file
@@ -0,0 +1,28 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@font-face {
|
||||
font-family: "TerminalVector";
|
||||
src: url("TerminalVector.ttf");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Key badge styling */
|
||||
.code {
|
||||
@apply px-3 py-1 rounded-md font-mono text-[0.9em] lowercase inline-block align-middle;
|
||||
background: rgba(250, 204, 21, 0.08); /* yellow-400 at low opacity */
|
||||
border: 1px solid rgba(250, 204, 21, 0.25);
|
||||
color: #fde68a; /* lighter yellow for readability */
|
||||
font-family: "TerminalVector", ui-monospace, Consolas, "Courier New",
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* Title styling */
|
||||
.arcade-title {
|
||||
font-family: "TerminalVector", ui-monospace, Consolas, "Courier New",
|
||||
monospace;
|
||||
letter-spacing: 0.08em;
|
||||
text-shadow: 0 0 18px rgba(250, 204, 21, 0.15),
|
||||
0 0 2px rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@font-face {
|
||||
font-family: "Liberation Mono";
|
||||
src:
|
||||
url("LiberationMono.woff2") format("woff2"),
|
||||
url("LiberationMono.woff") format("woff");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
canvas {
|
||||
@apply w-full h-[65vh] min-h-[200px] block mx-auto bg-black;
|
||||
}
|
||||
|
||||
.code {
|
||||
@apply px-1 rounded font-mono bg-zinc-900 border border-zinc-700 lowercase;
|
||||
}
|
||||
74
build.sh
74
build.sh
@@ -1,74 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -eu
|
||||
|
||||
release='false'
|
||||
serve='false'
|
||||
skip_emsdk='false'
|
||||
clean='false'
|
||||
|
||||
print_usage() {
|
||||
printf "Usage: -erdsc\n"
|
||||
printf " -e: Skip EMSDK setup (GitHub workflow only)\n"
|
||||
printf " -r: Build in release mode\n"
|
||||
printf " -d: Build in debug mode\n"
|
||||
printf " -s: Serve the WASM files once built\n"
|
||||
printf " -c: Clean the target/dist directory\n"
|
||||
}
|
||||
|
||||
while getopts 'erdsc' flag; do
|
||||
case "${flag}" in
|
||||
e) skip_emsdk='true' ;;
|
||||
r) release='true' ;;
|
||||
d) release='false' ;; # doesn't actually do anything, but last flag wins
|
||||
s) serve='true' ;;
|
||||
c) clean='true' ;;
|
||||
*)
|
||||
print_usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$clean" = 'true' ]; then
|
||||
echo "Cleaning target directory"
|
||||
cargo clean
|
||||
rm -rf ./dist/
|
||||
fi
|
||||
|
||||
if [ "$skip_emsdk" = 'false' ]; then
|
||||
echo "Activating Emscripten"
|
||||
# SDL2-TTF requires 3.1.43, fails to build on latest
|
||||
../emsdk/emsdk activate 3.1.43
|
||||
source ../emsdk/emsdk_env.sh
|
||||
fi
|
||||
|
||||
echo "Building WASM with Emscripten"
|
||||
build_type='debug'
|
||||
if [ "$release" = 'true' ]; then
|
||||
cargo build --target=wasm32-unknown-emscripten --release
|
||||
build_type='release'
|
||||
else
|
||||
cargo build --target=wasm32-unknown-emscripten
|
||||
fi
|
||||
|
||||
echo "Generating CSS"
|
||||
pnpx postcss-cli ./assets/site/styles.scss -o ./assets/site/build.css
|
||||
|
||||
echo "Copying WASM files"
|
||||
mkdir -p dist
|
||||
output_folder="target/wasm32-unknown-emscripten/$build_type"
|
||||
|
||||
cp assets/site/{build.css,favicon.ico,index.html} dist
|
||||
cp $output_folder/pacman.{wasm,js} dist
|
||||
if [ -f $output_folder/deps/pacman.data ]; then
|
||||
cp $output_folder/deps/pacman.data dist
|
||||
fi
|
||||
|
||||
if [ -f $output_folder/pacman.wasm.map ]; then
|
||||
cp $output_folder/pacman.wasm.map dist
|
||||
fi
|
||||
|
||||
if [ "$serve" = 'true' ]; then
|
||||
echo "Serving WASM with Emscripten"
|
||||
python3 -m http.server -d ./dist/ 8080
|
||||
fi
|
||||
201
build.ts
201
build.ts
@@ -1,201 +0,0 @@
|
||||
import { $ } from "bun";
|
||||
|
||||
// This is a bun script, run with `bun run build.ts`
|
||||
|
||||
import * as path from "path";
|
||||
import * as fs from "fs/promises";
|
||||
|
||||
async function clean() {
|
||||
console.log("Cleaning...");
|
||||
await $`cargo clean`;
|
||||
await $`rm -rf ./dist/`;
|
||||
console.log("Cleaned...");
|
||||
}
|
||||
|
||||
async function setupEmscripten() {
|
||||
const emsdkDir = "./emsdk";
|
||||
const emsdkExists = await fs
|
||||
.access(emsdkDir)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (!emsdkExists) {
|
||||
console.log("Cloning Emscripten SDK...");
|
||||
await $`git clone https://github.com/emscripten-core/emsdk.git`;
|
||||
} else {
|
||||
console.log("Emscripten SDK already exists, skipping clone.");
|
||||
}
|
||||
|
||||
const emscriptenToolchainPath = path.join(emsdkDir, "upstream", "emscripten");
|
||||
const toolchainInstalled = await fs
|
||||
.access(emscriptenToolchainPath)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (!toolchainInstalled) {
|
||||
console.log("Installing Emscripten toolchain...");
|
||||
await $`./emsdk/emsdk install 3.1.43`;
|
||||
} else {
|
||||
console.log(
|
||||
"Emscripten toolchain 3.1.43 already installed, skipping install."
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Activating Emscripten...");
|
||||
await $`./emsdk/emsdk activate 3.1.43`;
|
||||
console.log("Emscripten activated.");
|
||||
|
||||
// Set EMSDK environment variable for subsequent commands
|
||||
process.env.EMSDK = path.resolve(emsdkDir);
|
||||
|
||||
const emsdkPython = path.join(path.resolve(emsdkDir), "python");
|
||||
const emsdkNode = path.join(path.resolve(emsdkDir), "node", "16.20.0_64bit"); // Adjust node version if needed
|
||||
const emsdkBin = path.join(path.resolve(emsdkDir), "upstream", "emscripten");
|
||||
process.env.PATH = `${emsdkPython}:${emsdkNode}:${emsdkBin}:${process.env.PATH}`;
|
||||
}
|
||||
|
||||
async function buildWeb(release: boolean) {
|
||||
console.log("Building WASM with Emscripten...");
|
||||
const rustcFlags = [
|
||||
"-C",
|
||||
"link-arg=--preload-file",
|
||||
"-C",
|
||||
"link-arg=assets",
|
||||
].join(" ");
|
||||
|
||||
if (release) {
|
||||
await $`env RUSTFLAGS=${rustcFlags} cargo build --target=wasm32-unknown-emscripten --release`;
|
||||
} else {
|
||||
await $`env RUSTFLAGS=${rustcFlags} cargo build --target=wasm32-unknown-emscripten`;
|
||||
}
|
||||
|
||||
console.log("Generating CSS...");
|
||||
await $`pnpx postcss-cli ./assets/site/styles.scss -o ./assets/site/build.css`;
|
||||
|
||||
console.log("Copying WASM files...");
|
||||
const buildType = release ? "release" : "debug";
|
||||
const outputFolder = `target/wasm32-unknown-emscripten/${buildType}`;
|
||||
await $`mkdir -p dist`;
|
||||
await $`cp assets/site/index.html dist`;
|
||||
await $`cp assets/site/*.woff* dist`;
|
||||
await $`cp assets/site/build.css dist`;
|
||||
await $`cp assets/site/favicon.ico dist`;
|
||||
await $`cp ${outputFolder}/pacman.wasm dist`;
|
||||
await $`cp ${outputFolder}/pacman.js dist`;
|
||||
|
||||
// Check if .data file exists before copying
|
||||
try {
|
||||
await fs.access(`${outputFolder}/pacman.data`);
|
||||
await $`cp ${outputFolder}/pacman.data dist`;
|
||||
} catch (e) {
|
||||
console.log("No pacman.data file found, skipping copy.");
|
||||
}
|
||||
|
||||
// Check if .map file exists before copying
|
||||
try {
|
||||
await fs.access(`${outputFolder}/pacman.wasm.map`);
|
||||
await $`cp ${outputFolder}/pacman.wasm.map dist`;
|
||||
} catch (e) {
|
||||
console.log("No pacman.wasm.map file found, skipping copy.");
|
||||
}
|
||||
|
||||
console.log("WASM files copied.");
|
||||
}
|
||||
|
||||
async function serve() {
|
||||
console.log("Serving WASM with Emscripten...");
|
||||
await $`python3 -m http.server -d ./dist/ 8080`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
let release = false;
|
||||
let serveFiles = false;
|
||||
let skipEmscriptenSetup = false;
|
||||
let cleanProject = false;
|
||||
let target = "web"; // Default target
|
||||
|
||||
for (const arg of args) {
|
||||
switch (arg) {
|
||||
case "-r":
|
||||
release = true;
|
||||
break;
|
||||
case "-s":
|
||||
serveFiles = true;
|
||||
break;
|
||||
case "-e":
|
||||
skipEmscriptenSetup = true;
|
||||
break;
|
||||
case "-c":
|
||||
cleanProject = true;
|
||||
break;
|
||||
case "--target=linux":
|
||||
target = "linux";
|
||||
break;
|
||||
case "--target=windows":
|
||||
target = "windows";
|
||||
break;
|
||||
case "--target=web":
|
||||
target = "web";
|
||||
break;
|
||||
case "-h":
|
||||
case "--help":
|
||||
console.log(`
|
||||
Usage: ts-node build.ts [options]
|
||||
|
||||
Options:
|
||||
-r Build in release mode
|
||||
-s Serve the WASM files once built (for web target)
|
||||
-e Skip EMSDK setup (GitHub workflow only)
|
||||
-c Clean the target/dist directory
|
||||
--target=[web|linux|windows] Specify target platform (default: web)
|
||||
-h, --help Show this help message
|
||||
`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanProject) {
|
||||
await clean();
|
||||
}
|
||||
|
||||
if (!skipEmscriptenSetup && target === "web") {
|
||||
await setupEmscripten();
|
||||
}
|
||||
|
||||
switch (target) {
|
||||
case "web":
|
||||
await buildWeb(release);
|
||||
if (serveFiles) {
|
||||
await serve();
|
||||
}
|
||||
break;
|
||||
case "linux":
|
||||
console.log("Building for Linux...");
|
||||
if (release) {
|
||||
await $`cargo build --release`;
|
||||
} else {
|
||||
await $`cargo build`;
|
||||
}
|
||||
console.log("Linux build complete.");
|
||||
break;
|
||||
case "windows":
|
||||
console.log("Building for Windows...");
|
||||
if (release) {
|
||||
await $`cargo build --release --target=x86_64-pc-windows-msvc`; // Assuming MSVC toolchain
|
||||
} else {
|
||||
await $`cargo build --target=x86_64-pc-windows-msvc`;
|
||||
}
|
||||
console.log("Windows build complete.");
|
||||
break;
|
||||
default:
|
||||
console.error("Invalid target specified.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -33,7 +33,7 @@ pub struct App<'a> {
|
||||
last_tick: Instant,
|
||||
}
|
||||
|
||||
impl<'a> App<'a> {
|
||||
impl App<'_> {
|
||||
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))?;
|
||||
@@ -92,6 +92,8 @@ impl<'a> App<'a> {
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
// It doesn't really make sense to have this available in the browser
|
||||
#[cfg(not(target_os = "emscripten"))]
|
||||
Event::Quit { .. }
|
||||
| Event::KeyDown {
|
||||
keycode: Some(Keycode::Escape) | Some(Keycode::Q),
|
||||
|
||||
100
src/audio.rs
100
src/audio.rs
@@ -10,13 +10,15 @@ const SOUND_ASSETS: [Asset; 4] = [Asset::Wav1, Asset::Wav2, Asset::Wav3, Asset::
|
||||
/// The audio system for the game.
|
||||
///
|
||||
/// This struct is responsible for initializing the audio device, loading sounds,
|
||||
/// and playing them.
|
||||
/// and playing them. If audio fails to initialize, it will be disabled and all
|
||||
/// functions will silently do nothing.
|
||||
#[allow(dead_code)]
|
||||
pub struct Audio {
|
||||
_mixer_context: mixer::Sdl2MixerContext,
|
||||
_mixer_context: Option<mixer::Sdl2MixerContext>,
|
||||
sounds: Vec<Chunk>,
|
||||
next_sound_index: usize,
|
||||
muted: bool,
|
||||
disabled: bool,
|
||||
}
|
||||
|
||||
impl Default for Audio {
|
||||
@@ -27,13 +29,27 @@ impl Default for Audio {
|
||||
|
||||
impl Audio {
|
||||
/// Creates a new `Audio` instance.
|
||||
///
|
||||
/// If audio fails to initialize, the audio system will be disabled and
|
||||
/// all functions will silently do nothing.
|
||||
pub fn new() -> Self {
|
||||
let frequency = 44100;
|
||||
let format = DEFAULT_FORMAT;
|
||||
let channels = 4;
|
||||
let chunk_size = 256; // 256 is minimum for emscripten
|
||||
|
||||
mixer::open_audio(frequency, format, 1, chunk_size).expect("Failed to open audio");
|
||||
// Try to open audio, but don't panic if it fails
|
||||
if let Err(e) = mixer::open_audio(frequency, format, 1, chunk_size) {
|
||||
tracing::warn!("Failed to open audio: {}. Audio will be disabled.", e);
|
||||
return Self {
|
||||
_mixer_context: None,
|
||||
sounds: Vec::new(),
|
||||
next_sound_index: 0,
|
||||
muted: false,
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
mixer::allocate_channels(channels);
|
||||
|
||||
// set channel volume
|
||||
@@ -41,31 +57,72 @@ impl Audio {
|
||||
mixer::Channel(i).set_volume(32);
|
||||
}
|
||||
|
||||
let mixer_context = mixer::init(InitFlag::OGG).expect("Failed to initialize SDL2_mixer");
|
||||
// Try to initialize mixer, but don't panic if it fails
|
||||
let mixer_context = match mixer::init(InitFlag::OGG) {
|
||||
Ok(ctx) => ctx,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to initialize SDL2_mixer: {}. Audio will be disabled.", e);
|
||||
return Self {
|
||||
_mixer_context: None,
|
||||
sounds: Vec::new(),
|
||||
next_sound_index: 0,
|
||||
muted: false,
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let sounds: Vec<Chunk> = SOUND_ASSETS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, asset)| {
|
||||
let data = get_asset_bytes(*asset).expect("Failed to load sound asset");
|
||||
let rwops = RWops::from_bytes(&data).unwrap_or_else(|_| panic!("Failed to create RWops for sound {}", i + 1));
|
||||
rwops
|
||||
.load_wav()
|
||||
.unwrap_or_else(|_| panic!("Failed to load sound {} from asset API", i + 1))
|
||||
})
|
||||
.collect();
|
||||
// Try to load sounds, but don't panic if any fail
|
||||
let mut sounds = Vec::new();
|
||||
for (i, asset) in SOUND_ASSETS.iter().enumerate() {
|
||||
match get_asset_bytes(*asset) {
|
||||
Ok(data) => match RWops::from_bytes(&data) {
|
||||
Ok(rwops) => match rwops.load_wav() {
|
||||
Ok(chunk) => sounds.push(chunk),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to load sound {} from asset API: {}", i + 1, e);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to create RWops for sound {}: {}", i + 1, e);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to load sound asset {}: {}", i + 1, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no sounds loaded successfully, disable audio
|
||||
if sounds.is_empty() {
|
||||
tracing::warn!("No sounds loaded successfully. Audio will be disabled.");
|
||||
return Self {
|
||||
_mixer_context: Some(mixer_context),
|
||||
sounds: Vec::new(),
|
||||
next_sound_index: 0,
|
||||
muted: false,
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
Audio {
|
||||
_mixer_context: mixer_context,
|
||||
_mixer_context: Some(mixer_context),
|
||||
sounds,
|
||||
next_sound_index: 0,
|
||||
muted: false,
|
||||
disabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays the "eat" sound effect.
|
||||
///
|
||||
/// If audio is disabled or muted, this function does nothing.
|
||||
#[allow(dead_code)]
|
||||
pub fn eat(&mut self) {
|
||||
if self.disabled || self.muted || self.sounds.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(chunk) = self.sounds.get(self.next_sound_index) {
|
||||
match mixer::Channel(0).play(chunk, 0) {
|
||||
Ok(channel) => {
|
||||
@@ -80,12 +137,17 @@ impl Audio {
|
||||
}
|
||||
|
||||
/// Instantly mute or unmute all channels.
|
||||
///
|
||||
/// If audio is disabled, this function does nothing.
|
||||
pub fn set_mute(&mut self, mute: bool) {
|
||||
if !self.disabled {
|
||||
let channels = 4;
|
||||
let volume = if mute { 0 } else { 32 };
|
||||
for i in 0..channels {
|
||||
mixer::Channel(i).set_volume(volume);
|
||||
}
|
||||
}
|
||||
|
||||
self.muted = mute;
|
||||
}
|
||||
|
||||
@@ -93,4 +155,10 @@ impl Audio {
|
||||
pub fn is_muted(&self) -> bool {
|
||||
self.muted
|
||||
}
|
||||
|
||||
/// Returns `true` if the audio system is disabled.
|
||||
#[allow(dead_code)]
|
||||
pub fn is_disabled(&self) -> bool {
|
||||
self.disabled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,8 +37,6 @@ pub enum MapTile {
|
||||
Pellet,
|
||||
/// A power pellet.
|
||||
PowerPellet,
|
||||
/// A starting position for an entity.
|
||||
StartingPosition(u8),
|
||||
/// A tunnel tile.
|
||||
Tunnel,
|
||||
}
|
||||
@@ -68,7 +66,7 @@ pub const RAW_BOARD: [&str; BOARD_CELL_SIZE.y as usize] = [
|
||||
"#............##............#",
|
||||
"#.####.#####.##.#####.####.#",
|
||||
"#.####.#####.##.#####.####.#",
|
||||
"#o..##.......0 .......##..o#",
|
||||
"#o..##.......X .......##..o#",
|
||||
"###.##.##.########.##.##.###",
|
||||
"###.##.##.########.##.##.###",
|
||||
"#......##....##....##......#",
|
||||
|
||||
@@ -5,6 +5,16 @@ use super::direction::Direction;
|
||||
/// A unique identifier for a node, represented by its index in the graph's storage.
|
||||
pub type NodeId = usize;
|
||||
|
||||
/// Defines who can traverse a given edge.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum EdgePermissions {
|
||||
/// Anyone can use this edge.
|
||||
#[default]
|
||||
All,
|
||||
/// Only ghosts can use this edge.
|
||||
GhostsOnly,
|
||||
}
|
||||
|
||||
/// Represents a directed edge from one node to another with a given weight (e.g., distance).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Edge {
|
||||
@@ -14,6 +24,8 @@ pub struct Edge {
|
||||
pub distance: f32,
|
||||
/// The cardinal direction of this edge.
|
||||
pub direction: Direction,
|
||||
/// Defines who is allowed to traverse this edge.
|
||||
pub permissions: EdgePermissions,
|
||||
}
|
||||
|
||||
/// Represents a node in the graph, defined by its position.
|
||||
@@ -121,8 +133,8 @@ impl Graph {
|
||||
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());
|
||||
let edge_a = self.add_edge(from, to, replace, distance, direction, EdgePermissions::default());
|
||||
let edge_b = self.add_edge(to, from, replace, distance, direction.opposite(), EdgePermissions::default());
|
||||
|
||||
if edge_a.is_err() && edge_b.is_err() {
|
||||
return Err("Failed to connect nodes in both directions.");
|
||||
@@ -150,6 +162,7 @@ impl Graph {
|
||||
replace: bool,
|
||||
distance: Option<f32>,
|
||||
direction: Direction,
|
||||
permissions: EdgePermissions,
|
||||
) -> Result<(), &'static str> {
|
||||
let edge = Edge {
|
||||
target: to,
|
||||
@@ -168,6 +181,7 @@ impl Graph {
|
||||
}
|
||||
},
|
||||
direction,
|
||||
permissions,
|
||||
};
|
||||
|
||||
if from >= self.adjacency_list.len() {
|
||||
@@ -295,7 +309,10 @@ 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 {
|
||||
pub fn new<F>(graph: &Graph, start_node: NodeId, initial_direction: Direction, can_traverse: &F) -> Self
|
||||
where
|
||||
F: Fn(Edge) -> bool,
|
||||
{
|
||||
let mut traverser = Traverser {
|
||||
position: Position::AtNode(start_node),
|
||||
direction: initial_direction,
|
||||
@@ -303,7 +320,7 @@ impl Traverser {
|
||||
};
|
||||
|
||||
// This will kickstart the traverser into motion
|
||||
traverser.advance(graph, 0.0);
|
||||
traverser.advance(graph, 0.0, can_traverse);
|
||||
|
||||
traverser
|
||||
}
|
||||
@@ -329,7 +346,10 @@ impl Traverser {
|
||||
/// - 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) {
|
||||
pub fn advance<F>(&mut self, graph: &Graph, distance: f32, can_traverse: &F)
|
||||
where
|
||||
F: Fn(Edge) -> bool,
|
||||
{
|
||||
// Decrement the remaining frames for the next direction
|
||||
if let Some((direction, remaining)) = self.next_direction {
|
||||
if remaining > 0 {
|
||||
@@ -344,6 +364,7 @@ impl Traverser {
|
||||
// We're not moving, but a buffered direction is available.
|
||||
if let Some((next_direction, _)) = self.next_direction {
|
||||
if let Some(edge) = graph.find_edge_in_direction(node_id, next_direction) {
|
||||
if can_traverse(edge) {
|
||||
// Start moving in that direction
|
||||
self.position = Position::BetweenNodes {
|
||||
from: node_id,
|
||||
@@ -352,6 +373,7 @@ impl Traverser {
|
||||
};
|
||||
self.direction = next_direction;
|
||||
}
|
||||
}
|
||||
|
||||
self.next_direction = None; // Consume the buffered direction regardless of whether we started moving with it
|
||||
}
|
||||
@@ -382,6 +404,7 @@ impl Traverser {
|
||||
// If we buffered a direction, try to find an edge in that direction
|
||||
if let Some((next_dir, _)) = self.next_direction {
|
||||
if let Some(edge) = graph.find_edge_in_direction(to, next_dir) {
|
||||
if can_traverse(edge) {
|
||||
self.position = Position::BetweenNodes {
|
||||
from: to,
|
||||
to: edge.target,
|
||||
@@ -393,10 +416,12 @@ impl Traverser {
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we didn't move, try to continue in the current direction
|
||||
if !moved {
|
||||
if let Some(edge) = graph.find_edge_in_direction(to, self.direction) {
|
||||
if can_traverse(edge) {
|
||||
self.position = Position::BetweenNodes {
|
||||
from: to,
|
||||
to: edge.target,
|
||||
@@ -406,6 +431,10 @@ impl Traverser {
|
||||
self.position = Position::AtNode(to);
|
||||
self.next_direction = None;
|
||||
}
|
||||
} else {
|
||||
self.position = Position::AtNode(to);
|
||||
self.next_direction = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
use glam::Vec2;
|
||||
use glam::{UVec2, Vec2};
|
||||
|
||||
use crate::constants::BOARD_PIXEL_OFFSET;
|
||||
use crate::entity::direction::Direction;
|
||||
use crate::entity::graph::{Graph, NodeId, Position, Traverser};
|
||||
use crate::entity::graph::{Edge, EdgePermissions, Graph, NodeId, Position, Traverser};
|
||||
use crate::helpers::centered_with_size;
|
||||
use crate::texture::animated::AnimatedTexture;
|
||||
use crate::texture::directional::DirectionalAnimatedTexture;
|
||||
use crate::texture::sprite::SpriteAtlas;
|
||||
use sdl2::keyboard::Keycode;
|
||||
use sdl2::rect::Rect;
|
||||
use sdl2::render::{Canvas, RenderTarget};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn can_pacman_traverse(edge: Edge) -> bool {
|
||||
matches!(edge.permissions, EdgePermissions::All)
|
||||
}
|
||||
|
||||
pub struct Pacman {
|
||||
pub traverser: Traverser,
|
||||
texture: DirectionalAnimatedTexture,
|
||||
@@ -36,18 +40,24 @@ impl Pacman {
|
||||
|
||||
let stopped_tiles = vec![SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png")).unwrap()];
|
||||
|
||||
textures.insert(direction, AnimatedTexture::new(moving_tiles, 0.08));
|
||||
stopped_textures.insert(direction, AnimatedTexture::new(stopped_tiles, 0.1));
|
||||
textures.insert(
|
||||
direction,
|
||||
AnimatedTexture::new(moving_tiles, 0.08).expect("Invalid frame duration"),
|
||||
);
|
||||
stopped_textures.insert(
|
||||
direction,
|
||||
AnimatedTexture::new(stopped_tiles, 0.1).expect("Invalid frame duration"),
|
||||
);
|
||||
}
|
||||
|
||||
Self {
|
||||
traverser: Traverser::new(graph, start_node, Direction::Left),
|
||||
traverser: Traverser::new(graph, start_node, Direction::Left, &can_pacman_traverse),
|
||||
texture: DirectionalAnimatedTexture::new(textures, stopped_textures),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, dt: f32, graph: &Graph) {
|
||||
self.traverser.advance(graph, dt * 60.0 * 1.125);
|
||||
self.traverser.advance(graph, dt * 60.0 * 1.125, &can_pacman_traverse);
|
||||
self.texture.tick(dt);
|
||||
}
|
||||
|
||||
@@ -71,15 +81,14 @@ impl Pacman {
|
||||
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)
|
||||
from_pos.lerp(to_pos, traversed / from_pos.distance(to_pos))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, graph: &Graph) {
|
||||
let pixel_pos = self.get_pixel_pos(graph).round().as_ivec2() + BOARD_PIXEL_OFFSET.as_ivec2();
|
||||
let dest = Rect::new(pixel_pos.x - 8, pixel_pos.y - 8, 16, 16);
|
||||
let dest = centered_with_size(pixel_pos, UVec2::new(16, 16));
|
||||
let is_stopped = self.traverser.position.is_stopped();
|
||||
|
||||
if is_stopped {
|
||||
|
||||
11
src/helpers.rs
Normal file
11
src/helpers.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
use glam::{IVec2, UVec2};
|
||||
use sdl2::rect::Rect;
|
||||
|
||||
pub fn centered_with_size(pixel_pos: IVec2, size: UVec2) -> Rect {
|
||||
Rect::new(
|
||||
pixel_pos.x - size.x as i32 / 2,
|
||||
pixel_pos.y - size.y as i32 / 2,
|
||||
size.x,
|
||||
size.y,
|
||||
)
|
||||
}
|
||||
@@ -7,5 +7,6 @@ pub mod constants;
|
||||
pub mod emscripten;
|
||||
pub mod entity;
|
||||
pub mod game;
|
||||
pub mod helpers;
|
||||
pub mod map;
|
||||
pub mod texture;
|
||||
|
||||
@@ -56,6 +56,7 @@ mod constants;
|
||||
mod emscripten;
|
||||
mod entity;
|
||||
mod game;
|
||||
mod helpers;
|
||||
mod map;
|
||||
mod texture;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE};
|
||||
use crate::entity::direction::{Direction, DIRECTIONS};
|
||||
use crate::entity::graph::{Graph, Node, NodeId};
|
||||
use crate::entity::graph::{EdgePermissions, Graph, Node, NodeId};
|
||||
use crate::map::parser::MapTileParser;
|
||||
use crate::map::render::MapRenderer;
|
||||
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
|
||||
@@ -11,18 +11,30 @@ 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.
|
||||
/// The starting positions of the entities in the game.
|
||||
#[allow(dead_code)]
|
||||
pub struct NodePositions {
|
||||
pub pacman: NodeId,
|
||||
pub blinky: NodeId,
|
||||
pub pinky: NodeId,
|
||||
pub inky: NodeId,
|
||||
pub clyde: NodeId,
|
||||
}
|
||||
|
||||
/// The main map structure containing the game board and navigation graph.
|
||||
pub struct Map {
|
||||
/// The current state of the map.
|
||||
#[allow(dead_code)]
|
||||
current: [[MapTile; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize],
|
||||
/// The node map for entity movement.
|
||||
pub graph: Graph,
|
||||
/// A mapping from grid positions to node IDs.
|
||||
pub grid_to_node: HashMap<IVec2, NodeId>,
|
||||
/// A mapping of the starting positions of the entities.
|
||||
#[allow(dead_code)]
|
||||
pub start_positions: NodePositions,
|
||||
/// Pac-Man's starting position.
|
||||
pacman_start: Option<IVec2>,
|
||||
}
|
||||
|
||||
impl Map {
|
||||
@@ -41,6 +53,7 @@ impl Map {
|
||||
let map = parsed_map.tiles;
|
||||
let house_door = parsed_map.house_door;
|
||||
let tunnel_ends = parsed_map.tunnel_ends;
|
||||
let pacman_start = parsed_map.pacman_start;
|
||||
|
||||
let mut graph = Graph::new();
|
||||
let mut grid_to_node = HashMap::new();
|
||||
@@ -48,25 +61,7 @@ impl Map {
|
||||
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")
|
||||
});
|
||||
let start_pos = pacman_start.expect("Pac-Man's starting position not found");
|
||||
|
||||
// Add the starting position to the graph/queue
|
||||
let mut queue = VecDeque::new();
|
||||
@@ -100,7 +95,7 @@ impl Map {
|
||||
// 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(_)
|
||||
MapTile::Pellet | MapTile::PowerPellet | MapTile::Empty | MapTile::Tunnel
|
||||
) {
|
||||
// Add the new position to the graph/queue
|
||||
let pos = Vec2::new(
|
||||
@@ -141,15 +136,26 @@ impl Map {
|
||||
}
|
||||
|
||||
// Build house structure
|
||||
let (house_entrance_node_id, left_center_node_id, center_center_node_id, right_center_node_id) =
|
||||
Self::build_house(&mut graph, &grid_to_node, &house_door);
|
||||
|
||||
let start_positions = NodePositions {
|
||||
pacman: grid_to_node[&start_pos],
|
||||
blinky: house_entrance_node_id,
|
||||
pinky: left_center_node_id,
|
||||
inky: right_center_node_id,
|
||||
clyde: center_center_node_id,
|
||||
};
|
||||
|
||||
// Build tunnel connections
|
||||
Self::build_tunnels(&mut graph, &grid_to_node, &tunnel_ends);
|
||||
|
||||
Map {
|
||||
current: map,
|
||||
grid_to_node,
|
||||
graph,
|
||||
grid_to_node,
|
||||
start_positions,
|
||||
pacman_start,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,14 +169,9 @@ impl Map {
|
||||
///
|
||||
/// The starting position as a grid coordinate (`UVec2`), or `None` if not found.
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
// For now, only Pac-Man (entity_id 0) is supported
|
||||
if entity_id == 0 {
|
||||
return self.pacman_start.map(|pos| UVec2::new(pos.x as u32, pos.y as u32));
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -193,7 +194,11 @@ impl Map {
|
||||
}
|
||||
|
||||
/// Builds the house structure in the graph.
|
||||
fn build_house(graph: &mut Graph, grid_to_node: &HashMap<IVec2, NodeId>, house_door: &[Option<IVec2>; 2]) {
|
||||
fn build_house(
|
||||
graph: &mut Graph,
|
||||
grid_to_node: &HashMap<IVec2, NodeId>,
|
||||
house_door: &[Option<IVec2>; 2],
|
||||
) -> (usize, usize, usize, usize) {
|
||||
// Calculate the position of the house entrance node
|
||||
let (house_entrance_node_id, house_entrance_node_position) = {
|
||||
// Translate the grid positions to the actual node ids
|
||||
@@ -254,10 +259,29 @@ impl Map {
|
||||
// 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
|
||||
// Create a ghost-only, two-way connection for the house door.
|
||||
// This prevents Pac-Man from entering or exiting through the door.
|
||||
graph
|
||||
.connect(house_entrance_node_id, center_top_node_id, false, None, Direction::Down)
|
||||
.expect("Failed to connect house entrance to top line");
|
||||
.add_edge(
|
||||
house_entrance_node_id,
|
||||
center_top_node_id,
|
||||
false,
|
||||
None,
|
||||
Direction::Down,
|
||||
EdgePermissions::GhostsOnly,
|
||||
)
|
||||
.expect("Failed to create ghost-only entrance to house");
|
||||
|
||||
graph
|
||||
.add_edge(
|
||||
center_top_node_id,
|
||||
house_entrance_node_id,
|
||||
false,
|
||||
None,
|
||||
Direction::Up,
|
||||
EdgePermissions::GhostsOnly,
|
||||
)
|
||||
.expect("Failed to create ghost-only exit from house");
|
||||
|
||||
// Create the left line
|
||||
let (left_center_node_id, _) = create_house_line(
|
||||
@@ -283,6 +307,13 @@ impl Map {
|
||||
.expect("Failed to connect house entrance to right top line");
|
||||
|
||||
debug!("House entrance node id: {house_entrance_node_id}");
|
||||
|
||||
(
|
||||
house_entrance_node_id,
|
||||
left_center_node_id,
|
||||
center_center_node_id,
|
||||
right_center_node_id,
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds the tunnel connections in the graph.
|
||||
|
||||
@@ -22,6 +22,8 @@ pub struct ParsedMap {
|
||||
pub house_door: [Option<IVec2>; 2],
|
||||
/// The positions of the tunnel end tiles.
|
||||
pub tunnel_ends: [Option<IVec2>; 2],
|
||||
/// Pac-Man's starting position.
|
||||
pub pacman_start: Option<IVec2>,
|
||||
}
|
||||
|
||||
/// Parser for converting raw board layouts into structured map data.
|
||||
@@ -44,7 +46,7 @@ impl MapTileParser {
|
||||
'o' => Ok(MapTile::PowerPellet),
|
||||
' ' => Ok(MapTile::Empty),
|
||||
'T' => Ok(MapTile::Tunnel),
|
||||
c @ '0'..='4' => Ok(MapTile::StartingPosition(c.to_digit(10).unwrap() as u8)),
|
||||
'X' => Ok(MapTile::Empty), // Pac-Man's starting position, treated as empty
|
||||
'=' => Ok(MapTile::Wall), // House door is represented as a wall tile
|
||||
_ => Err(ParseError::UnknownCharacter(c)),
|
||||
}
|
||||
@@ -68,6 +70,7 @@ impl MapTileParser {
|
||||
let mut tiles = [[MapTile::Empty; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize];
|
||||
let mut house_door = [None; 2];
|
||||
let mut tunnel_ends = [None; 2];
|
||||
let mut pacman_start: Option<IVec2> = None;
|
||||
|
||||
for (y, line) in raw_board.iter().enumerate().take(BOARD_CELL_SIZE.y as usize) {
|
||||
for (x, character) in line.chars().enumerate().take(BOARD_CELL_SIZE.x as usize) {
|
||||
@@ -92,6 +95,11 @@ impl MapTileParser {
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Track Pac-Man's starting position
|
||||
if character == 'X' {
|
||||
pacman_start = Some(IVec2::new(x as i32, y as i32));
|
||||
}
|
||||
|
||||
tiles[x][y] = tile;
|
||||
}
|
||||
}
|
||||
@@ -106,63 +114,7 @@ impl MapTileParser {
|
||||
tiles,
|
||||
house_door,
|
||||
tunnel_ends,
|
||||
pacman_start,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::constants::RAW_BOARD;
|
||||
|
||||
#[test]
|
||||
fn test_parse_character() {
|
||||
assert!(matches!(MapTileParser::parse_character('#').unwrap(), MapTile::Wall));
|
||||
assert!(matches!(MapTileParser::parse_character('.').unwrap(), MapTile::Pellet));
|
||||
assert!(matches!(MapTileParser::parse_character('o').unwrap(), MapTile::PowerPellet));
|
||||
assert!(matches!(MapTileParser::parse_character(' ').unwrap(), MapTile::Empty));
|
||||
assert!(matches!(MapTileParser::parse_character('T').unwrap(), MapTile::Tunnel));
|
||||
assert!(matches!(
|
||||
MapTileParser::parse_character('0').unwrap(),
|
||||
MapTile::StartingPosition(0)
|
||||
));
|
||||
assert!(matches!(
|
||||
MapTileParser::parse_character('4').unwrap(),
|
||||
MapTile::StartingPosition(4)
|
||||
));
|
||||
assert!(matches!(MapTileParser::parse_character('=').unwrap(), MapTile::Wall));
|
||||
|
||||
// Test invalid character
|
||||
assert!(MapTileParser::parse_character('X').is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_board() {
|
||||
let result = MapTileParser::parse_board(RAW_BOARD);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let parsed = result.unwrap();
|
||||
|
||||
// Verify we have tiles
|
||||
assert_eq!(parsed.tiles.len(), BOARD_CELL_SIZE.x as usize);
|
||||
assert_eq!(parsed.tiles[0].len(), BOARD_CELL_SIZE.y as usize);
|
||||
|
||||
// Verify we found house door positions
|
||||
assert!(parsed.house_door[0].is_some());
|
||||
assert!(parsed.house_door[1].is_some());
|
||||
|
||||
// Verify we found tunnel ends
|
||||
assert!(parsed.tunnel_ends[0].is_some());
|
||||
assert!(parsed.tunnel_ends[1].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_board_invalid_character() {
|
||||
let mut invalid_board = RAW_BOARD.clone();
|
||||
invalid_board[0] = "###########################X";
|
||||
|
||||
let result = MapTileParser::parse_board(invalid_board);
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), ParseError::UnknownCharacter('X')));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! 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};
|
||||
@@ -16,10 +15,10 @@ impl MapRenderer {
|
||||
/// 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,
|
||||
crate::constants::BOARD_PIXEL_OFFSET.x as i32,
|
||||
crate::constants::BOARD_PIXEL_OFFSET.y as i32,
|
||||
crate::constants::BOARD_PIXEL_SIZE.x,
|
||||
crate::constants::BOARD_PIXEL_SIZE.y,
|
||||
);
|
||||
let _ = map_texture.render(canvas, atlas, dest);
|
||||
}
|
||||
@@ -32,13 +31,13 @@ impl MapRenderer {
|
||||
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();
|
||||
let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
|
||||
|
||||
// Draw connections
|
||||
canvas.set_draw_color(Color::BLUE);
|
||||
|
||||
for edge in graph.adjacency_list[i].edges() {
|
||||
let end_pos = graph.get_node(edge.target).unwrap().position + BOARD_PIXEL_OFFSET.as_vec2();
|
||||
let end_pos = graph.get_node(edge.target).unwrap().position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
|
||||
canvas
|
||||
.draw_line((pos.x as i32, pos.y as i32), (end_pos.x as i32, end_pos.y as i32))
|
||||
.unwrap();
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
use anyhow::Result;
|
||||
use sdl2::rect::Rect;
|
||||
use sdl2::render::{Canvas, RenderTarget};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AnimatedTextureError {
|
||||
#[error("Frame duration must be positive, got {0}")]
|
||||
InvalidFrameDuration(f32),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AnimatedTexture {
|
||||
tiles: Vec<AtlasTile>,
|
||||
frame_duration: f32,
|
||||
@@ -13,13 +20,17 @@ pub struct AnimatedTexture {
|
||||
}
|
||||
|
||||
impl AnimatedTexture {
|
||||
pub fn new(tiles: Vec<AtlasTile>, frame_duration: f32) -> Self {
|
||||
Self {
|
||||
pub fn new(tiles: Vec<AtlasTile>, frame_duration: f32) -> Result<Self, AnimatedTextureError> {
|
||||
if frame_duration <= 0.0 {
|
||||
return Err(AnimatedTextureError::InvalidFrameDuration(frame_duration));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
tiles,
|
||||
frame_duration,
|
||||
current_frame: 0,
|
||||
time_bank: 0.0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, dt: f32) {
|
||||
@@ -38,4 +49,28 @@ impl AnimatedTexture {
|
||||
let mut tile = *self.current_tile();
|
||||
tile.render(canvas, atlas, dest)
|
||||
}
|
||||
|
||||
/// Returns the current frame index.
|
||||
#[allow(dead_code)]
|
||||
pub fn current_frame(&self) -> usize {
|
||||
self.current_frame
|
||||
}
|
||||
|
||||
/// Returns the time bank.
|
||||
#[allow(dead_code)]
|
||||
pub fn time_bank(&self) -> f32 {
|
||||
self.time_bank
|
||||
}
|
||||
|
||||
/// Returns the frame duration.
|
||||
#[allow(dead_code)]
|
||||
pub fn frame_duration(&self) -> f32 {
|
||||
self.frame_duration
|
||||
}
|
||||
|
||||
/// Returns the number of tiles in the animation.
|
||||
#[allow(dead_code)]
|
||||
pub fn tiles_len(&self) -> usize {
|
||||
self.tiles.len()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,4 +34,13 @@ impl BlinkingTexture {
|
||||
pub fn tile(&self) -> &AtlasTile {
|
||||
&self.tile
|
||||
}
|
||||
|
||||
// Helper methods for testing
|
||||
pub fn time_bank(&self) -> f32 {
|
||||
self.time_bank
|
||||
}
|
||||
|
||||
pub fn blink_duration(&self) -> f32 {
|
||||
self.blink_duration
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,4 +54,28 @@ impl DirectionalAnimatedTexture {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the texture has a direction.
|
||||
#[allow(dead_code)]
|
||||
pub fn has_direction(&self, direction: Direction) -> bool {
|
||||
self.textures.contains_key(&direction)
|
||||
}
|
||||
|
||||
/// Returns true if the texture has a stopped direction.
|
||||
#[allow(dead_code)]
|
||||
pub fn has_stopped_direction(&self, direction: Direction) -> bool {
|
||||
self.stopped_textures.contains_key(&direction)
|
||||
}
|
||||
|
||||
/// Returns the number of textures.
|
||||
#[allow(dead_code)]
|
||||
pub fn texture_count(&self) -> usize {
|
||||
self.textures.len()
|
||||
}
|
||||
|
||||
/// Returns the number of stopped textures.
|
||||
#[allow(dead_code)]
|
||||
pub fn stopped_texture_count(&self) -> usize {
|
||||
self.stopped_textures.len()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,19 @@ impl AtlasTile {
|
||||
canvas.copy(&atlas.texture, src, dest).map_err(anyhow::Error::msg)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Creates a new atlas tile.
|
||||
#[allow(dead_code)]
|
||||
pub fn new(pos: U16Vec2, size: U16Vec2, color: Option<Color>) -> Self {
|
||||
Self { pos, size, color }
|
||||
}
|
||||
|
||||
/// Sets the color of the tile.
|
||||
#[allow(dead_code)]
|
||||
pub fn with_color(mut self, color: Color) -> Self {
|
||||
self.color = Some(color);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SpriteAtlas {
|
||||
@@ -85,6 +98,24 @@ impl SpriteAtlas {
|
||||
pub fn texture(&self) -> &Texture<'static> {
|
||||
&self.texture
|
||||
}
|
||||
|
||||
/// Returns the number of tiles in the atlas.
|
||||
#[allow(dead_code)]
|
||||
pub fn tiles_count(&self) -> usize {
|
||||
self.tiles.len()
|
||||
}
|
||||
|
||||
/// Returns true if the atlas has a tile with the given name.
|
||||
#[allow(dead_code)]
|
||||
pub fn has_tile(&self, name: &str) -> bool {
|
||||
self.tiles.contains_key(name)
|
||||
}
|
||||
|
||||
/// Returns the default color of the atlas.
|
||||
#[allow(dead_code)]
|
||||
pub fn default_color(&self) -> Option<Color> {
|
||||
self.default_color
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a `Texture` to a `Texture<'static>` using transmute.
|
||||
|
||||
61
tests/animated.rs
Normal file
61
tests/animated.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use glam::U16Vec2;
|
||||
use pacman::texture::animated::{AnimatedTexture, AnimatedTextureError};
|
||||
use pacman::texture::sprite::AtlasTile;
|
||||
use sdl2::pixels::Color;
|
||||
|
||||
fn mock_atlas_tile(id: u32) -> AtlasTile {
|
||||
AtlasTile {
|
||||
pos: U16Vec2::new(0, 0),
|
||||
size: U16Vec2::new(16, 16),
|
||||
color: Some(Color::RGB(id as u8, 0, 0)),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_animated_texture_creation_errors() {
|
||||
let tiles = vec![mock_atlas_tile(1), mock_atlas_tile(2)];
|
||||
|
||||
assert!(matches!(
|
||||
AnimatedTexture::new(tiles.clone(), 0.0).unwrap_err(),
|
||||
AnimatedTextureError::InvalidFrameDuration(0.0)
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
AnimatedTexture::new(tiles, -0.1).unwrap_err(),
|
||||
AnimatedTextureError::InvalidFrameDuration(-0.1)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_animated_texture_advancement() {
|
||||
let tiles = vec![mock_atlas_tile(1), mock_atlas_tile(2), mock_atlas_tile(3)];
|
||||
let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap();
|
||||
|
||||
assert_eq!(texture.current_frame(), 0);
|
||||
|
||||
texture.tick(0.25);
|
||||
assert_eq!(texture.current_frame(), 2);
|
||||
assert!((texture.time_bank() - 0.05).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_animated_texture_wrap_around() {
|
||||
let tiles = vec![mock_atlas_tile(1), mock_atlas_tile(2)];
|
||||
let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap();
|
||||
|
||||
texture.tick(0.1);
|
||||
assert_eq!(texture.current_frame(), 1);
|
||||
|
||||
texture.tick(0.1);
|
||||
assert_eq!(texture.current_frame(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_animated_texture_single_frame() {
|
||||
let tiles = vec![mock_atlas_tile(1)];
|
||||
let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap();
|
||||
|
||||
texture.tick(0.1);
|
||||
assert_eq!(texture.current_frame(), 0);
|
||||
assert_eq!(texture.current_tile().color.unwrap().r, 1);
|
||||
}
|
||||
49
tests/blinking.rs
Normal file
49
tests/blinking.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use glam::U16Vec2;
|
||||
use pacman::texture::blinking::BlinkingTexture;
|
||||
use pacman::texture::sprite::AtlasTile;
|
||||
use sdl2::pixels::Color;
|
||||
|
||||
fn mock_atlas_tile(id: u32) -> AtlasTile {
|
||||
AtlasTile {
|
||||
pos: U16Vec2::new(0, 0),
|
||||
size: U16Vec2::new(16, 16),
|
||||
color: Some(Color::RGB(id as u8, 0, 0)),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blinking_texture() {
|
||||
let tile = mock_atlas_tile(1);
|
||||
let mut texture = BlinkingTexture::new(tile, 0.5);
|
||||
|
||||
assert_eq!(texture.is_on(), true);
|
||||
|
||||
texture.tick(0.5);
|
||||
assert_eq!(texture.is_on(), false);
|
||||
|
||||
texture.tick(0.5);
|
||||
assert_eq!(texture.is_on(), true);
|
||||
|
||||
texture.tick(0.5);
|
||||
assert_eq!(texture.is_on(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blinking_texture_partial_duration() {
|
||||
let tile = mock_atlas_tile(1);
|
||||
let mut texture = BlinkingTexture::new(tile, 0.5);
|
||||
|
||||
texture.tick(0.625);
|
||||
assert_eq!(texture.is_on(), false);
|
||||
assert_eq!(texture.time_bank(), 0.125);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blinking_texture_negative_time() {
|
||||
let tile = mock_atlas_tile(1);
|
||||
let mut texture = BlinkingTexture::new(tile, 0.5);
|
||||
|
||||
texture.tick(-0.1);
|
||||
assert_eq!(texture.is_on(), true);
|
||||
assert_eq!(texture.time_bank(), -0.1);
|
||||
}
|
||||
28
tests/constants.rs
Normal file
28
tests/constants.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use pacman::constants::*;
|
||||
|
||||
#[test]
|
||||
fn test_raw_board_structure() {
|
||||
assert_eq!(RAW_BOARD.len(), BOARD_CELL_SIZE.y as usize);
|
||||
|
||||
for row in RAW_BOARD.iter() {
|
||||
assert_eq!(row.len(), BOARD_CELL_SIZE.x as usize);
|
||||
}
|
||||
|
||||
// Test boundaries
|
||||
assert!(RAW_BOARD[0].chars().all(|c| c == '#'));
|
||||
assert!(RAW_BOARD[RAW_BOARD.len() - 1].chars().all(|c| c == '#'));
|
||||
|
||||
// Test tunnel row
|
||||
let tunnel_row = RAW_BOARD[14];
|
||||
assert_eq!(tunnel_row.chars().next().unwrap(), 'T');
|
||||
assert_eq!(tunnel_row.chars().last().unwrap(), 'T');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_raw_board_content() {
|
||||
let power_pellet_count = RAW_BOARD.iter().flat_map(|row| row.chars()).filter(|&c| c == 'o').count();
|
||||
assert_eq!(power_pellet_count, 4);
|
||||
|
||||
assert!(RAW_BOARD.iter().any(|row| row.contains('X')));
|
||||
assert!(RAW_BOARD.iter().any(|row| row.contains("==")));
|
||||
}
|
||||
31
tests/direction.rs
Normal file
31
tests/direction.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use glam::IVec2;
|
||||
use pacman::entity::direction::*;
|
||||
|
||||
#[test]
|
||||
fn test_direction_opposite() {
|
||||
let test_cases = [
|
||||
(Direction::Up, Direction::Down),
|
||||
(Direction::Down, Direction::Up),
|
||||
(Direction::Left, Direction::Right),
|
||||
(Direction::Right, Direction::Left),
|
||||
];
|
||||
|
||||
for (dir, expected) in test_cases {
|
||||
assert_eq!(dir.opposite(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_direction_as_ivec2() {
|
||||
let test_cases = [
|
||||
(Direction::Up, -IVec2::Y),
|
||||
(Direction::Down, IVec2::Y),
|
||||
(Direction::Left, -IVec2::X),
|
||||
(Direction::Right, IVec2::X),
|
||||
];
|
||||
|
||||
for (dir, expected) in test_cases {
|
||||
assert_eq!(dir.as_ivec2(), expected);
|
||||
assert_eq!(IVec2::from(dir), expected);
|
||||
}
|
||||
}
|
||||
55
tests/directional.rs
Normal file
55
tests/directional.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use glam::U16Vec2;
|
||||
use pacman::entity::direction::Direction;
|
||||
use pacman::texture::animated::AnimatedTexture;
|
||||
use pacman::texture::directional::DirectionalAnimatedTexture;
|
||||
use pacman::texture::sprite::AtlasTile;
|
||||
use sdl2::pixels::Color;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn mock_atlas_tile(id: u32) -> AtlasTile {
|
||||
AtlasTile {
|
||||
pos: U16Vec2::new(0, 0),
|
||||
size: U16Vec2::new(16, 16),
|
||||
color: Some(Color::RGB(id as u8, 0, 0)),
|
||||
}
|
||||
}
|
||||
|
||||
fn mock_animated_texture(id: u32) -> AnimatedTexture {
|
||||
AnimatedTexture::new(vec![mock_atlas_tile(id)], 0.1).expect("Invalid frame duration")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_directional_texture_partial_directions() {
|
||||
let mut textures = HashMap::new();
|
||||
textures.insert(Direction::Up, mock_animated_texture(1));
|
||||
|
||||
let texture = DirectionalAnimatedTexture::new(textures, HashMap::new());
|
||||
|
||||
assert_eq!(texture.texture_count(), 1);
|
||||
assert!(texture.has_direction(Direction::Up));
|
||||
assert!(!texture.has_direction(Direction::Down));
|
||||
assert!(!texture.has_direction(Direction::Left));
|
||||
assert!(!texture.has_direction(Direction::Right));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_directional_texture_all_directions() {
|
||||
let mut textures = HashMap::new();
|
||||
let directions = [
|
||||
(Direction::Up, 1),
|
||||
(Direction::Down, 2),
|
||||
(Direction::Left, 3),
|
||||
(Direction::Right, 4),
|
||||
];
|
||||
|
||||
for (direction, id) in directions {
|
||||
textures.insert(direction, mock_animated_texture(id));
|
||||
}
|
||||
|
||||
let texture = DirectionalAnimatedTexture::new(textures, HashMap::new());
|
||||
|
||||
assert_eq!(texture.texture_count(), 4);
|
||||
for direction in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] {
|
||||
assert!(texture.has_direction(*direction));
|
||||
}
|
||||
}
|
||||
21
tests/game.rs
Normal file
21
tests/game.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use pacman::constants::RAW_BOARD;
|
||||
use pacman::map::Map;
|
||||
|
||||
#[test]
|
||||
fn test_game_map_creation() {
|
||||
let map = Map::new(RAW_BOARD);
|
||||
|
||||
assert!(map.graph.node_count() > 0);
|
||||
assert!(!map.grid_to_node.is_empty());
|
||||
|
||||
// Should find Pac-Man's starting position
|
||||
let pacman_pos = map.find_starting_position(0);
|
||||
assert!(pacman_pos.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_game_score_initialization() {
|
||||
// This would require creating a full Game instance, but we can test the concept
|
||||
let map = Map::new(RAW_BOARD);
|
||||
assert!(map.find_starting_position(0).is_some());
|
||||
}
|
||||
149
tests/graph.rs
Normal file
149
tests/graph.rs
Normal file
@@ -0,0 +1,149 @@
|
||||
use pacman::entity::direction::Direction;
|
||||
use pacman::entity::graph::{EdgePermissions, Graph, Node, Position, Traverser};
|
||||
|
||||
fn create_test_graph() -> Graph {
|
||||
let mut graph = Graph::new();
|
||||
let node1 = graph.add_node(Node {
|
||||
position: glam::Vec2::new(0.0, 0.0),
|
||||
});
|
||||
let node2 = graph.add_node(Node {
|
||||
position: glam::Vec2::new(16.0, 0.0),
|
||||
});
|
||||
let node3 = graph.add_node(Node {
|
||||
position: glam::Vec2::new(0.0, 16.0),
|
||||
});
|
||||
|
||||
graph.connect(node1, node2, false, None, Direction::Right).unwrap();
|
||||
graph.connect(node1, node3, false, None, Direction::Down).unwrap();
|
||||
|
||||
graph
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_graph_basic_operations() {
|
||||
let mut graph = Graph::new();
|
||||
let node1 = graph.add_node(Node {
|
||||
position: glam::Vec2::new(0.0, 0.0),
|
||||
});
|
||||
let node2 = graph.add_node(Node {
|
||||
position: glam::Vec2::new(16.0, 0.0),
|
||||
});
|
||||
|
||||
assert_eq!(graph.node_count(), 2);
|
||||
assert!(graph.get_node(node1).is_some());
|
||||
assert!(graph.get_node(node2).is_some());
|
||||
assert!(graph.get_node(999).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_graph_connect() {
|
||||
let mut graph = Graph::new();
|
||||
let node1 = graph.add_node(Node {
|
||||
position: glam::Vec2::new(0.0, 0.0),
|
||||
});
|
||||
let node2 = graph.add_node(Node {
|
||||
position: glam::Vec2::new(16.0, 0.0),
|
||||
});
|
||||
|
||||
assert!(graph.connect(node1, node2, false, None, Direction::Right).is_ok());
|
||||
|
||||
let edge1 = graph.find_edge_in_direction(node1, Direction::Right);
|
||||
let edge2 = graph.find_edge_in_direction(node2, Direction::Left);
|
||||
|
||||
assert!(edge1.is_some());
|
||||
assert!(edge2.is_some());
|
||||
assert_eq!(edge1.unwrap().target, node2);
|
||||
assert_eq!(edge2.unwrap().target, node1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_graph_connect_errors() {
|
||||
let mut graph = Graph::new();
|
||||
let node1 = graph.add_node(Node {
|
||||
position: glam::Vec2::new(0.0, 0.0),
|
||||
});
|
||||
|
||||
assert!(graph.connect(node1, 999, false, None, Direction::Right).is_err());
|
||||
assert!(graph.connect(999, node1, false, None, Direction::Right).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_graph_edge_permissions() {
|
||||
let mut graph = Graph::new();
|
||||
let node1 = graph.add_node(Node {
|
||||
position: glam::Vec2::new(0.0, 0.0),
|
||||
});
|
||||
let node2 = graph.add_node(Node {
|
||||
position: glam::Vec2::new(16.0, 0.0),
|
||||
});
|
||||
|
||||
graph
|
||||
.add_edge(node1, node2, false, None, Direction::Right, EdgePermissions::GhostsOnly)
|
||||
.unwrap();
|
||||
|
||||
let edge = graph.find_edge_in_direction(node1, Direction::Right).unwrap();
|
||||
assert_eq!(edge.permissions, EdgePermissions::GhostsOnly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_traverser_basic() {
|
||||
let graph = create_test_graph();
|
||||
let mut traverser = Traverser::new(&graph, 0, Direction::Left, &|_| true);
|
||||
|
||||
traverser.set_next_direction(Direction::Up);
|
||||
assert!(traverser.next_direction.is_some());
|
||||
assert_eq!(traverser.next_direction.unwrap().0, Direction::Up);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_traverser_advance() {
|
||||
let graph = create_test_graph();
|
||||
let mut traverser = Traverser::new(&graph, 0, Direction::Right, &|_| true);
|
||||
|
||||
traverser.advance(&graph, 5.0, &|_| true);
|
||||
|
||||
match traverser.position {
|
||||
Position::BetweenNodes { from, to, traversed } => {
|
||||
assert_eq!(from, 0);
|
||||
assert_eq!(to, 1);
|
||||
assert_eq!(traversed, 5.0);
|
||||
}
|
||||
_ => panic!("Expected to be between nodes"),
|
||||
}
|
||||
|
||||
traverser.advance(&graph, 3.0, &|_| true);
|
||||
|
||||
match traverser.position {
|
||||
Position::BetweenNodes { from, to, traversed } => {
|
||||
assert_eq!(from, 0);
|
||||
assert_eq!(to, 1);
|
||||
assert_eq!(traversed, 8.0);
|
||||
}
|
||||
_ => panic!("Expected to be between nodes"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_traverser_with_permissions() {
|
||||
let mut graph = Graph::new();
|
||||
let node1 = graph.add_node(Node {
|
||||
position: glam::Vec2::new(0.0, 0.0),
|
||||
});
|
||||
let node2 = graph.add_node(Node {
|
||||
position: glam::Vec2::new(16.0, 0.0),
|
||||
});
|
||||
|
||||
graph
|
||||
.add_edge(node1, node2, false, None, Direction::Right, EdgePermissions::GhostsOnly)
|
||||
.unwrap();
|
||||
|
||||
// Pacman can't traverse ghost-only edges
|
||||
let mut traverser = Traverser::new(&graph, node1, Direction::Right, &|edge| {
|
||||
matches!(edge.permissions, EdgePermissions::All)
|
||||
});
|
||||
|
||||
traverser.advance(&graph, 5.0, &|edge| matches!(edge.permissions, EdgePermissions::All));
|
||||
|
||||
// Should still be at the node since it can't traverse
|
||||
assert!(traverser.position.is_at_node());
|
||||
}
|
||||
19
tests/helpers.rs
Normal file
19
tests/helpers.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use glam::{IVec2, UVec2};
|
||||
use pacman::helpers::centered_with_size;
|
||||
|
||||
#[test]
|
||||
fn test_centered_with_size() {
|
||||
let test_cases = [
|
||||
((100, 100), (50, 30), (75, 85)),
|
||||
((50, 50), (51, 31), (25, 35)),
|
||||
((0, 0), (100, 100), (-50, -50)),
|
||||
((-100, -50), (80, 40), (-140, -70)),
|
||||
((1000, 1000), (1000, 1000), (500, 500)),
|
||||
];
|
||||
|
||||
for ((pos_x, pos_y), (size_x, size_y), (expected_x, expected_y)) in test_cases {
|
||||
let rect = centered_with_size(IVec2::new(pos_x, pos_y), UVec2::new(size_x, size_y));
|
||||
assert_eq!(rect.origin(), (expected_x, expected_y));
|
||||
assert_eq!(rect.size(), (size_x, size_y));
|
||||
}
|
||||
}
|
||||
86
tests/map_builder.rs
Normal file
86
tests/map_builder.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use glam::Vec2;
|
||||
use pacman::constants::{BOARD_CELL_SIZE, CELL_SIZE};
|
||||
use pacman::map::Map;
|
||||
|
||||
fn create_minimal_test_board() -> [&'static str; BOARD_CELL_SIZE.y as usize] {
|
||||
let mut board = [""; BOARD_CELL_SIZE.y as usize];
|
||||
board[0] = "############################";
|
||||
board[1] = "#............##............#";
|
||||
board[2] = "#.####.#####.##.#####.####.#";
|
||||
board[3] = "#o####.#####.##.#####.####o#";
|
||||
board[4] = "#.####.#####.##.#####.####.#";
|
||||
board[5] = "#..........................#";
|
||||
board[6] = "#.####.##.########.##.####.#";
|
||||
board[7] = "#.####.##.########.##.####.#";
|
||||
board[8] = "#......##....##....##......#";
|
||||
board[9] = "######.##### ## #####.######";
|
||||
board[10] = " #.##### ## #####.# ";
|
||||
board[11] = " #.## == ##.# ";
|
||||
board[12] = " #.## ######## ##.# ";
|
||||
board[13] = "######.## ######## ##.######";
|
||||
board[14] = "T . ######## . T";
|
||||
board[15] = "######.## ######## ##.######";
|
||||
board[16] = " #.## ######## ##.# ";
|
||||
board[17] = " #.## ##.# ";
|
||||
board[18] = " #.## ######## ##.# ";
|
||||
board[19] = "######.## ######## ##.######";
|
||||
board[20] = "#............##............#";
|
||||
board[21] = "#.####.#####.##.#####.####.#";
|
||||
board[22] = "#.####.#####.##.#####.####.#";
|
||||
board[23] = "#o..##.......X .......##..o#";
|
||||
board[24] = "###.##.##.########.##.##.###";
|
||||
board[25] = "###.##.##.########.##.##.###";
|
||||
board[26] = "#......##....##....##......#";
|
||||
board[27] = "#.##########.##.##########.#";
|
||||
board[28] = "#.##########.##.##########.#";
|
||||
board[29] = "#..........................#";
|
||||
board[30] = "############################";
|
||||
board
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_creation() {
|
||||
let board = create_minimal_test_board();
|
||||
let map = Map::new(board);
|
||||
|
||||
assert!(map.graph.node_count() > 0);
|
||||
assert!(!map.grid_to_node.is_empty());
|
||||
|
||||
// Check that some connections were made
|
||||
let mut has_connections = false;
|
||||
for intersection in &map.graph.adjacency_list {
|
||||
if intersection.edges().next().is_some() {
|
||||
has_connections = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(has_connections);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_starting_positions() {
|
||||
let board = create_minimal_test_board();
|
||||
let map = Map::new(board);
|
||||
|
||||
let pacman_pos = map.find_starting_position(0);
|
||||
assert!(pacman_pos.is_some());
|
||||
assert!(pacman_pos.unwrap().x < BOARD_CELL_SIZE.x);
|
||||
assert!(pacman_pos.unwrap().y < BOARD_CELL_SIZE.y);
|
||||
|
||||
let nonexistent_pos = map.find_starting_position(99);
|
||||
assert_eq!(nonexistent_pos, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_node_positions() {
|
||||
let board = create_minimal_test_board();
|
||||
let map = Map::new(board);
|
||||
|
||||
for (grid_pos, &node_id) in &map.grid_to_node {
|
||||
let node = map.graph.get_node(node_id).unwrap();
|
||||
let expected_pos = Vec2::new((grid_pos.x * CELL_SIZE as i32) as f32, (grid_pos.y * CELL_SIZE as i32) as f32)
|
||||
+ Vec2::splat(CELL_SIZE as f32 / 2.0);
|
||||
|
||||
assert_eq!(node.position, expected_pos);
|
||||
}
|
||||
}
|
||||
107
tests/pacman.rs
Normal file
107
tests/pacman.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use pacman::entity::direction::Direction;
|
||||
use pacman::entity::graph::{Graph, Node};
|
||||
use pacman::entity::pacman::Pacman;
|
||||
use pacman::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas};
|
||||
use sdl2::keyboard::Keycode;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn create_test_graph() -> Graph {
|
||||
let mut graph = Graph::new();
|
||||
let node1 = graph.add_node(Node {
|
||||
position: glam::Vec2::new(0.0, 0.0),
|
||||
});
|
||||
let node2 = graph.add_node(Node {
|
||||
position: glam::Vec2::new(16.0, 0.0),
|
||||
});
|
||||
let node3 = graph.add_node(Node {
|
||||
position: glam::Vec2::new(0.0, 16.0),
|
||||
});
|
||||
|
||||
graph.connect(node1, node2, false, None, Direction::Right).unwrap();
|
||||
graph.connect(node1, node3, false, None, Direction::Down).unwrap();
|
||||
|
||||
graph
|
||||
}
|
||||
|
||||
fn create_test_atlas() -> SpriteAtlas {
|
||||
let mut frames = HashMap::new();
|
||||
let directions = ["up", "down", "left", "right"];
|
||||
|
||||
for (i, dir) in directions.iter().enumerate() {
|
||||
frames.insert(
|
||||
format!("pacman/{dir}_a.png"),
|
||||
MapperFrame {
|
||||
x: i as u16 * 16,
|
||||
y: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
frames.insert(
|
||||
format!("pacman/{dir}_b.png"),
|
||||
MapperFrame {
|
||||
x: i as u16 * 16,
|
||||
y: 16,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
frames.insert(
|
||||
"pacman/full.png".to_string(),
|
||||
MapperFrame {
|
||||
x: 64,
|
||||
y: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
|
||||
let mapper = AtlasMapper { frames };
|
||||
let dummy_texture = unsafe { std::mem::zeroed() };
|
||||
SpriteAtlas::new(dummy_texture, mapper)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pacman_creation() {
|
||||
let graph = create_test_graph();
|
||||
let atlas = create_test_atlas();
|
||||
let pacman = Pacman::new(&graph, 0, &atlas);
|
||||
|
||||
assert!(pacman.traverser.position.is_at_node());
|
||||
assert_eq!(pacman.traverser.direction, Direction::Left);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pacman_key_handling() {
|
||||
let graph = create_test_graph();
|
||||
let atlas = create_test_atlas();
|
||||
let mut pacman = Pacman::new(&graph, 0, &atlas);
|
||||
|
||||
let test_cases = [
|
||||
(Keycode::Up, Direction::Up),
|
||||
(Keycode::Down, Direction::Down),
|
||||
(Keycode::Left, Direction::Left),
|
||||
(Keycode::Right, Direction::Right),
|
||||
];
|
||||
|
||||
for (key, expected_direction) in test_cases {
|
||||
pacman.handle_key(key);
|
||||
assert!(pacman.traverser.next_direction.is_some() || pacman.traverser.direction == expected_direction);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pacman_invalid_key() {
|
||||
let graph = create_test_graph();
|
||||
let atlas = create_test_atlas();
|
||||
let mut pacman = Pacman::new(&graph, 0, &atlas);
|
||||
|
||||
let original_direction = pacman.traverser.direction;
|
||||
let original_next_direction = pacman.traverser.next_direction;
|
||||
|
||||
pacman.handle_key(Keycode::Space);
|
||||
assert_eq!(pacman.traverser.direction, original_direction);
|
||||
assert_eq!(pacman.traverser.next_direction, original_next_direction);
|
||||
}
|
||||
46
tests/parser.rs
Normal file
46
tests/parser.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use pacman::constants::{BOARD_CELL_SIZE, RAW_BOARD};
|
||||
use pacman::map::parser::{MapTileParser, ParseError};
|
||||
|
||||
#[test]
|
||||
fn test_parse_character() {
|
||||
let test_cases = [
|
||||
('#', pacman::constants::MapTile::Wall),
|
||||
('.', pacman::constants::MapTile::Pellet),
|
||||
('o', pacman::constants::MapTile::PowerPellet),
|
||||
(' ', pacman::constants::MapTile::Empty),
|
||||
('T', pacman::constants::MapTile::Tunnel),
|
||||
('X', pacman::constants::MapTile::Empty),
|
||||
('=', pacman::constants::MapTile::Wall),
|
||||
];
|
||||
|
||||
for (char, _expected) in test_cases {
|
||||
assert!(matches!(MapTileParser::parse_character(char).unwrap(), _expected));
|
||||
}
|
||||
|
||||
assert!(MapTileParser::parse_character('Z').is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_board() {
|
||||
let result = MapTileParser::parse_board(RAW_BOARD);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let parsed = result.unwrap();
|
||||
assert_eq!(parsed.tiles.len(), BOARD_CELL_SIZE.x as usize);
|
||||
assert_eq!(parsed.tiles[0].len(), BOARD_CELL_SIZE.y as usize);
|
||||
assert!(parsed.house_door[0].is_some());
|
||||
assert!(parsed.house_door[1].is_some());
|
||||
assert!(parsed.tunnel_ends[0].is_some());
|
||||
assert!(parsed.tunnel_ends[1].is_some());
|
||||
assert!(parsed.pacman_start.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_board_invalid_character() {
|
||||
let mut invalid_board = RAW_BOARD.clone();
|
||||
invalid_board[0] = "###########################Z";
|
||||
|
||||
let result = MapTileParser::parse_board(invalid_board);
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), ParseError::UnknownCharacter('Z')));
|
||||
}
|
||||
78
tests/sprite.rs
Normal file
78
tests/sprite.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
use pacman::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas};
|
||||
use sdl2::pixels::Color;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn mock_texture() -> sdl2::render::Texture<'static> {
|
||||
unsafe { std::mem::transmute(0usize) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sprite_atlas_basic() {
|
||||
let mut frames = HashMap::new();
|
||||
frames.insert(
|
||||
"test".to_string(),
|
||||
MapperFrame {
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 32,
|
||||
height: 64,
|
||||
},
|
||||
);
|
||||
|
||||
let mapper = AtlasMapper { frames };
|
||||
let texture = mock_texture();
|
||||
let atlas = SpriteAtlas::new(texture, mapper);
|
||||
|
||||
let tile = atlas.get_tile("test");
|
||||
assert!(tile.is_some());
|
||||
let tile = tile.unwrap();
|
||||
assert_eq!(tile.pos, glam::U16Vec2::new(10, 20));
|
||||
assert_eq!(tile.size, glam::U16Vec2::new(32, 64));
|
||||
assert_eq!(tile.color, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sprite_atlas_multiple_tiles() {
|
||||
let mut frames = HashMap::new();
|
||||
frames.insert(
|
||||
"tile1".to_string(),
|
||||
MapperFrame {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 32,
|
||||
height: 32,
|
||||
},
|
||||
);
|
||||
frames.insert(
|
||||
"tile2".to_string(),
|
||||
MapperFrame {
|
||||
x: 32,
|
||||
y: 0,
|
||||
width: 64,
|
||||
height: 64,
|
||||
},
|
||||
);
|
||||
|
||||
let mapper = AtlasMapper { frames };
|
||||
let texture = mock_texture();
|
||||
let atlas = SpriteAtlas::new(texture, mapper);
|
||||
|
||||
assert_eq!(atlas.tiles_count(), 2);
|
||||
assert!(atlas.has_tile("tile1"));
|
||||
assert!(atlas.has_tile("tile2"));
|
||||
assert!(!atlas.has_tile("tile3"));
|
||||
assert!(atlas.get_tile("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sprite_atlas_color() {
|
||||
let mapper = AtlasMapper { frames: HashMap::new() };
|
||||
let texture = mock_texture();
|
||||
let mut atlas = SpriteAtlas::new(texture, mapper);
|
||||
|
||||
assert_eq!(atlas.default_color(), None);
|
||||
|
||||
let color = Color::RGB(255, 0, 0);
|
||||
atlas.set_color(color);
|
||||
assert_eq!(atlas.default_color(), Some(color));
|
||||
}
|
||||
262
web.build.ts
Normal file
262
web.build.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { $ } from "bun";
|
||||
import { existsSync, promises as fs } from "fs";
|
||||
import { platform } from "os";
|
||||
import { dirname, join, relative, resolve } from "path";
|
||||
import { match, P } from "ts-pattern";
|
||||
|
||||
type Os =
|
||||
| { type: "linux"; wsl: boolean }
|
||||
| { type: "windows" }
|
||||
| { type: "macos" };
|
||||
|
||||
const os: Os = match(platform())
|
||||
.with("win32", () => ({ type: "windows" as const }))
|
||||
.with("linux", () => ({
|
||||
type: "linux" as const,
|
||||
// We detect WSL by checking for the presence of the WSLInterop file.
|
||||
// This is a semi-standard method of detecting WSL, which is more than workable for this already hacky script.
|
||||
wsl: existsSync("/proc/sys/fs/binfmt_misc/WSLInterop"),
|
||||
}))
|
||||
.with("darwin", () => ({ type: "macos" as const }))
|
||||
.otherwise(() => {
|
||||
throw new Error(`Unsupported platform: ${platform()}`);
|
||||
});
|
||||
|
||||
function log(msg: string) {
|
||||
console.log(`[web.build] ${msg}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the application with Emscripten, generate the CSS, and copy the files into 'dist'.
|
||||
*
|
||||
* @param release - Whether to build in release mode.
|
||||
* @param env - The environment variables to inject into build commands.
|
||||
*/
|
||||
async function build(release: boolean, env: Record<string, string>) {
|
||||
log(
|
||||
`Building for 'wasm32-unknown-emscripten' for ${
|
||||
release ? "release" : "debug"
|
||||
}`
|
||||
);
|
||||
await $`cargo build --target=wasm32-unknown-emscripten ${
|
||||
release ? "--release" : ""
|
||||
}`.env(env);
|
||||
|
||||
log("Invoking @tailwindcss/cli");
|
||||
// unfortunately, bunx doesn't seem to work with @tailwindcss/cli, so we have to use npx directly
|
||||
await $`npx --yes @tailwindcss/cli --minify --input styles.css --output build.css --cwd assets/site`;
|
||||
|
||||
const buildType = release ? "release" : "debug";
|
||||
const siteFolder = resolve("assets/site");
|
||||
const outputFolder = resolve(`target/wasm32-unknown-emscripten/${buildType}`);
|
||||
const dist = resolve("dist");
|
||||
|
||||
// The files to copy into 'dist'
|
||||
const files = [
|
||||
...["index.html", "favicon.ico", "build.css", "TerminalVector.ttf"].map(
|
||||
(file) => ({
|
||||
src: join(siteFolder, file),
|
||||
dest: join(dist, file),
|
||||
optional: false,
|
||||
})
|
||||
),
|
||||
...["pacman.wasm", "pacman.js", "deps/pacman.data"].map((file) => ({
|
||||
src: join(outputFolder, file),
|
||||
dest: join(dist, file.split("/").pop() || file),
|
||||
optional: false,
|
||||
})),
|
||||
{
|
||||
src: join(outputFolder, "pacman.wasm.map"),
|
||||
dest: join(dist, "pacman.wasm.map"),
|
||||
optional: true,
|
||||
},
|
||||
];
|
||||
|
||||
// Create required destination folders
|
||||
await Promise.all(
|
||||
// Get the dirname of files, remove duplicates
|
||||
[...new Set(files.map(({ dest }) => dirname(dest)))]
|
||||
// Create the folders
|
||||
.map(async (dir) => {
|
||||
// If the folder doesn't exist, create it
|
||||
if (!(await fs.exists(dir))) {
|
||||
log(`Creating folder ${dir}`);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Copy the files to the dist folder
|
||||
log("Copying files into dist");
|
||||
await Promise.all(
|
||||
files.map(async ({ optional, src, dest }) => {
|
||||
match({ optional, exists: await fs.exists(src) })
|
||||
// If optional and doesn't exist, skip
|
||||
.with({ optional: true, exists: false }, () => {
|
||||
log(
|
||||
`Optional file ${os.type === "windows" ? "\\" : "/"}${relative(
|
||||
process.cwd(),
|
||||
src
|
||||
)} does not exist, skipping...`
|
||||
);
|
||||
})
|
||||
// If not optional and doesn't exist, throw an error
|
||||
.with({ optional: false, exists: false }, () => {
|
||||
throw new Error(`Required file ${src} does not exist`);
|
||||
})
|
||||
// Otherwise, copy the file
|
||||
.otherwise(async () => await fs.copyFile(src, dest));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks to see if the Emscripten SDK is activated for a Windows or *nix machine by looking for a .exe file and the equivalent file on Linux/macOS. Returns both results for handling.
|
||||
* @param emsdkDir - The directory containing the Emscripten SDK.
|
||||
* @returns A record of environment variables.
|
||||
*/
|
||||
async function checkEmsdkType(
|
||||
emsdkDir: string
|
||||
): Promise<{ windows: boolean; nix: boolean }> {
|
||||
const binary = resolve(join(emsdkDir, "upstream", "bin", "clang"));
|
||||
|
||||
return {
|
||||
windows: await fs.exists(binary + ".exe"),
|
||||
nix: await fs.exists(binary),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate the Emscripten SDK environment variables.
|
||||
* Technically, this doesn't actaully activate the environment variables for the current shell,
|
||||
* it just runs the environment sourcing script and returns the environment variables for future command invocations.
|
||||
* @param emsdkDir - The directory containing the Emscripten SDK.
|
||||
* @returns A record of environment variables.
|
||||
*/
|
||||
async function activateEmsdk(
|
||||
emsdkDir: string
|
||||
): Promise<{ vars: Record<string, string> } | { err: string }> {
|
||||
// Determine the environment script to use based on the OS
|
||||
const envScript = match(os)
|
||||
.with({ type: "windows" }, () => join(emsdkDir, "emsdk_env.bat"))
|
||||
.with({ type: P.union("linux", "macos") }, () =>
|
||||
join(emsdkDir, "emsdk_env.sh")
|
||||
)
|
||||
.exhaustive();
|
||||
|
||||
// Run the environment script and capture the output
|
||||
const { stdout, stderr, exitCode } = await match(os)
|
||||
.with({ type: "windows" }, () =>
|
||||
// run the script, ignore it's output ('>nul'), then print the environment variables ('set')
|
||||
$`cmd /c "${envScript} >nul && set"`.quiet()
|
||||
)
|
||||
.with({ type: P.union("linux", "macos") }, () =>
|
||||
// run the script with bash, ignore it's output ('> /dev/null'), then print the environment variables ('env')
|
||||
$`bash -c "source '${envScript}' && env"`.quiet()
|
||||
)
|
||||
.exhaustive();
|
||||
|
||||
if (exitCode !== 0) {
|
||||
return { err: stderr.toString() };
|
||||
}
|
||||
|
||||
// Parse the output into a record of environment variables
|
||||
const vars = Object.fromEntries(
|
||||
stdout
|
||||
.toString()
|
||||
.split(os.type === "windows" ? /\r?\n/ : "\n") // Split output into lines, handling Windows CRLF vs *nix LF
|
||||
.map((line) => line.split("=", 2)) // Parse each line as KEY=VALUE (limit to 2 parts)
|
||||
.filter(([k, v]) => k && v) // Keep only valid key-value pairs (both parts exist)
|
||||
);
|
||||
|
||||
return { vars };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Print the OS detected
|
||||
log(
|
||||
"OS Detected: " +
|
||||
match(os)
|
||||
.with({ type: "windows" }, () => "Windows")
|
||||
.with({ type: "linux" }, ({ wsl: isWsl }) =>
|
||||
isWsl ? "Linux (via WSL)" : "Linux"
|
||||
)
|
||||
.with({ type: "macos" }, () => "macOS")
|
||||
.exhaustive()
|
||||
);
|
||||
|
||||
const release = process.env.RELEASE !== "0";
|
||||
const emsdkDir = resolve("./emsdk");
|
||||
// Ensure the emsdk directory exists before attempting to activate or use it
|
||||
if (!(await fs.exists(emsdkDir))) {
|
||||
log(
|
||||
`Emscripten SDK directory not found at ${emsdkDir}. Please install or clone 'emsdk' and try again.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const vars = match(await activateEmsdk(emsdkDir)) // result handling
|
||||
.with({ vars: P.select() }, (vars) => vars)
|
||||
.with({ err: P.any }, ({ err }) => {
|
||||
log("Error activating Emscripten SDK: " + err);
|
||||
process.exit(1);
|
||||
})
|
||||
.exhaustive();
|
||||
|
||||
// Check if the Emscripten SDK is activated/installed properly for the current OS
|
||||
match({
|
||||
os: os,
|
||||
...(await checkEmsdkType(emsdkDir)),
|
||||
})
|
||||
// If the Emscripten SDK is not activated/installed properly, exit with an error
|
||||
.with(
|
||||
{
|
||||
nix: false,
|
||||
windows: false,
|
||||
},
|
||||
() => {
|
||||
log(
|
||||
"Emscripten SDK does not appear to be activated/installed properly."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
)
|
||||
// If the Emscripten SDK is activated for Windows, but is currently running on a *nix OS, exit with an error
|
||||
.with(
|
||||
{
|
||||
nix: false,
|
||||
windows: true,
|
||||
os: { type: P.not("windows") },
|
||||
},
|
||||
() => {
|
||||
log(
|
||||
"Emscripten SDK appears to be activated for Windows, but is currently running on a *nix OS."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
)
|
||||
// If the Emscripten SDK is activated for *nix, but is currently running on a Windows OS, exit with an error
|
||||
.with(
|
||||
{
|
||||
nix: true,
|
||||
windows: false,
|
||||
os: { type: "windows" },
|
||||
},
|
||||
() => {
|
||||
log(
|
||||
"Emscripten SDK appears to be activated for *nix, but is currently running on a Windows OS."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
);
|
||||
|
||||
// Build the application
|
||||
await build(release, vars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point.
|
||||
*/
|
||||
main().catch((err) => {
|
||||
console.error("[web.build] Error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user