Compare commits

..

32 Commits

Author SHA1 Message Date
1f8e7c6d71 fix: resolve clippy warnings, inline format vars, use tracing to log warnings 2025-08-11 22:09:08 -05:00
27079e127d feat!: implement proper error handling, drop most expect() & unwrap() usages 2025-08-11 20:23:39 -05:00
5e9bb3535e ci: add dependabot config 2025-08-11 19:24:52 -05:00
250cf2fc89 fix: avoid rendering path lines between far apart cells 2025-08-11 18:39:01 -05:00
57975495a9 fix: calculate more static, stable offsets for path debug rendering 2025-08-11 16:00:23 -05:00
f3e7a780e2 fix: drop problematic ctrl-c keybind for bacon, reconfigure binds 2025-08-11 15:46:26 -05:00
ee6cb0a670 refactor: implement entity trait, common abstraction for movement & rendering 2025-08-11 15:46:04 -05:00
b3df34b405 fix: crash when entering right tunnel due to overflowing pixel position calculation 2025-08-11 15:44:04 -05:00
dbafa17670 chore: add bacon.toml config file 2025-08-11 15:25:53 -05:00
d9c8f97903 feat: pathfinding for ghosts, add debug rendering of paths 2025-08-11 15:25:39 -05:00
ad2ec35bfb chore: remove unused tracing debug invocations 2025-08-11 15:23:23 -05:00
6331ba0b2f refactor: move graph traversal code into traversal.rs 2025-08-11 14:05:28 -05:00
3d275b8e85 fix: clippy inline format args 2025-08-11 14:05:28 -05:00
bd61db9aae chore: remove unnecessary names, merge audit.yaml with tests.yaml, plural tests.yaml 2025-08-11 14:05:28 -05:00
ed8bd07518 fix: site rendering, fix SVG colors, remove header, viewport scaling, simplify 2025-08-11 12:20:52 -05:00
27705f1ba2 feat: implement ghost entities, movement & rendering 2025-08-11 11:54:05 -05:00
e964adc818 feat: enhance debug visuals with cursor-based effect 2025-08-11 11:54:05 -05:00
c5213320ac fix(emscripten): string pointer casting, fixup AssetError handling 2025-08-11 11:25:52 -05:00
e0f8443e75 refactor: replace HashMap with fixed-size arrays for textures in DirectionalAnimatedTexture 2025-08-11 11:13:46 -05:00
6702b3723a refactor: move DIRECTIONS constant into direction, add as_u8() const fn for array indexing 2025-08-11 11:03:46 -05:00
f6e7228f75 refactor: platform trait, platform-specific code handling into platform module 2025-08-11 10:49:58 -05:00
14cebe4462 chore: use logtape logger properly 2025-08-11 10:34:26 -05:00
c39fcaa7d7 feat: add timestamp-based tailwind cli downloading, add logtape logging, validate content-length 2025-08-11 10:31:54 -05:00
1d9499c4f8 docs: improve story & readme, simplify top header, expand experiment ideas, more build notes 2025-08-08 13:42:26 -05:00
61050a5585 chore: use fallback installer for bun run in build workflow 2025-08-08 13:34:54 -05:00
85420711df fix: preventDefault() context menu on canvas 2025-08-08 13:34:36 -05:00
2efa7a4df5 feat: manually download tailwindcss cli, only check for emsdkDir if not activated 2025-08-08 11:04:31 -05:00
1d018db5e9 ci: handle pre-activated emsdk 2025-08-08 10:15:43 -05:00
023697dcd7 fix: use bun and web.build.ts in build workflow, use minify & cwd args for tailwindcss cli 2025-08-08 10:10:57 -05:00
87ee12543e tests: revamp tests, remove more useless tests 2025-08-08 09:07:10 -05:00
b308bc0ef7 refactor: move all tests out of src/ into tests/, remove unnecessary tests 2025-08-08 08:50:52 -05:00
9d5ca54234 fix: improved frontend web interface, use tailwind cli 2025-08-08 00:20:38 -05:00
57 changed files with 3033 additions and 3089 deletions

20
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,20 @@
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "monthly"
groups:
dependencies:
patterns:
- "*"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
groups:
dependencies:
patterns:
- "*"

View File

@@ -1,27 +0,0 @@
name: Audit
on: ["push", "pull_request"]
env:
CARGO_TERM_COLOR: always
RUST_TOOLCHAIN: 1.88.0
jobs:
audit:
name: Audit
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
- name: Install cargo-audit
run: cargo install cargo-audit
- name: Run security audit
run: cargo audit

View File

@@ -1,5 +1,4 @@
name: Builds name: Builds
on: ["push", "pull_request"] on: ["push", "pull_request"]
permissions: permissions:
@@ -104,11 +103,10 @@ jobs:
- name: Rust Cache - name: Rust Cache
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
- name: Install pnpm - name: Setup Bun
uses: pnpm/action-setup@v3 uses: oven-sh/setup-bun@v2
with: with:
version: 8 bun-version: latest
run_install: true
- name: Build with Emscripten - name: Build with Emscripten
shell: bash shell: bash
@@ -121,7 +119,7 @@ jobs:
echo "Build attempt $attempt of $MAX_RETRIES" echo "Build attempt $attempt of $MAX_RETRIES"
# Capture output and check for specific error while preserving real-time output # Capture output and check for specific error while preserving real-time output
if cargo build --target=wasm32-unknown-emscripten --release 2>&1 | tee /tmp/build_output.log; then if bun run -i web.build.ts 2>&1 | tee /tmp/build_output.log; then
echo "Build successful on attempt $attempt" echo "Build successful on attempt $attempt"
break break
else else
@@ -148,18 +146,6 @@ jobs:
fi fi
done done
- name: Assemble
run: |
echo "Generating CSS"
pnpx postcss-cli ./assets/site/styles.scss -o ./assets/site/build.css
echo "Copying WASM files"
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
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-pages-artifact@v3 uses: actions/upload-pages-artifact@v3
if: github.ref == 'refs/heads/master' && github.event_name == 'push' if: github.ref == 'refs/heads/master' && github.event_name == 'push'

View File

@@ -1,4 +1,4 @@
name: Coverage name: Code Coverage
on: ["push", "pull_request"] on: ["push", "pull_request"]
@@ -8,7 +8,6 @@ env:
jobs: jobs:
coverage: coverage:
name: Code Coverage
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code

View File

@@ -1,4 +1,4 @@
name: Tests name: Tests & Checks
on: ["push", "pull_request"] on: ["push", "pull_request"]
@@ -8,7 +8,6 @@ env:
jobs: jobs:
test: test:
name: Test
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -52,3 +51,8 @@ jobs:
- name: Check formatting - name: Check formatting
run: cargo fmt -- --check run: cargo fmt -- --check
- uses: taiki-e/install-action@cargo-audit
- name: Run security audit
run: cargo audit

2
.gitignore vendored
View File

@@ -3,3 +3,5 @@ dist/
emsdk/ emsdk/
.idea .idea
rust-sdl2-emscripten/ rust-sdl2-emscripten/
assets/site/build.css
tailwindcss-*

1
Cargo.lock generated
View File

@@ -192,6 +192,7 @@ dependencies = [
"sdl2", "sdl2",
"serde", "serde",
"serde_json", "serde_json",
"smallvec",
"spin_sleep", "spin_sleep",
"thiserror 1.0.69", "thiserror 1.0.69",
"tracing", "tracing",

View File

@@ -20,6 +20,7 @@ anyhow = "1.0"
glam = { version = "0.30.4", features = [] } glam = { version = "0.30.4", features = [] }
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.141" serde_json = "1.0.141"
smallvec = "1.15.1"
[profile.release] [profile.release]
lto = true lto = true

View File

@@ -2,20 +2,18 @@
[![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] [![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]
[badge-test]: https://github.com/Xevion/Pac-Man/actions/workflows/test.yaml/badge.svg [badge-test]: https://github.com/Xevion/Pac-Man/actions/workflows/tests.yaml/badge.svg
[badge-build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.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-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-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-online-demo]: https://img.shields.io/badge/GitHub%20Pages-Demo-brightgreen
[badge-last-commit]: https://img.shields.io/github/last-commit/Xevion/Pac-Man [badge-last-commit]: https://img.shields.io/github/last-commit/Xevion/Pac-Man
[build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml [build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml
[test]: https://github.com/Xevion/Pac-Man/actions/workflows/test.yaml [test]: https://github.com/Xevion/Pac-Man/actions/workflows/tests.yaml
[coverage]: https://coveralls.io/github/Xevion/Pac-Man?branch=master [coverage]: https://coveralls.io/github/Xevion/Pac-Man?branch=master
[demo]: https://xevion.github.io/Pac-Man/ [demo]: https://xevion.github.io/Pac-Man/
[commits]: https://github.com/Xevion/Pac-Man/commits/master [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. 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: The game includes all the original features you'd expect from Pac-Man:
@@ -27,38 +25,63 @@ The game includes all the original features you'd expect from Pac-Man:
- [ ] Progressive difficulty with faster ghosts and shorter power pellet duration - [ ] Progressive difficulty with faster ghosts and shorter power pellet duration
- [x] Authentic sound effects and sprites - [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. This cross-platform implementation is built with SDL2 for graphics, audio, and input handling. It can run on Windows, Linux, macOS, and in web browsers via WebAssembly.
## Feature Targets ## Why?
- Near-perfect replication of logic, scoring, graphics, sound, and behaviors. Just because. And because I wanted to learn more about Rust, inter-operability with C, and compiling to WebAssembly.
- Written in Rust, buildable on Windows, Linux, Mac and WebAssembly.
I was inspired by a certain code review video on YouTube; [SOME UNIQUE C++ CODE // Pacman Clone Code Review](https://www.youtube.com/watch?v=OKs_JewEeOo) by The Cherno.
For some reason, I was inspired to try and replicate it in Rust, and it was uniquely challenging.
I wanted to hit a log of goals and features, making it a 'perfect' project that I could be proud of.
- Near-perfect replication of logic, scoring, graphics, sound, and behaviors. No hacks, workarounds, or poor designs.
- Written in Rust, buildable on Windows, Linux, Mac and WebAssembly. Statically linked, no runtime dependencies.
- Performant, low memory, CPU and GPU usage.
- Online demo, playable in a browser. - Online demo, playable in a browser.
- Automatic build system, with releases for Windows, Linux, and Mac & Web-Assembly. - Completely automatic build system with releases for all platforms.
- Well documented, well-tested, and maintainable.
## Experimental Ideas
- Debug tooling - Debug tooling
- Game state visualization - Game state visualization
- Game speed controls + pausing - Game speed controls + pausing
- Log tracing - Log tracing
- Performance details - Performance details
- Customized Themes & Colors
## Experimental Ideas - Color-blind friendly
- Perfected Ghost Algorithms - Perfected Ghost Algorithms
- More than 4 ghosts - More than 4 ghosts
- Custom Level Generation - Custom Level Generation
- Multi-map tunnelling - Multi-map tunnelling
- Online Scoreboard - Online Scoreboard
- WebAssembly build contains a special API key for communicating with server. - An online axum server with a simple database and OAuth2 authentication.
- To prevent abuse, the server will only accept scores from the WebAssembly build. - Integrates with GitHub, Discord, and Google OAuth2 to acquire an email identifier & avatar.
- Avatars are optional for score submission and can be disabled, instead using a blank avatar.
- Avatars are downscaled to a low resolution pixellated image to maintain the 8-bit aesthetic.
- A custom name is used for the score submission, which is checked for potential abusive language.
- A max length of 14 characters, and a min length of 3 characters.
- Names are checked for potential abusive language via an external API.
- The client implementation should require zero configuration, environment variables, or special secrets.
- It simply defaults to the pacman server API, or can be overriden manually.
## Build Notes ## Build Notes
Since this project is still in progress, I'm only going to cover non-obvious build details. By reading the code, build scripts, and copying the online build workflows, you should be able to replicate the build process.
- Install `cargo-vcpkg` with `cargo install cargo-vcpkg`, then run `cargo vcpkg build` to build the requisite dependencies via vcpkg. - 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` - 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. - The first time you clone, you'll need to install the appropriate SDK version with `./emsdk install 3.1.43` and then activate it with `./emsdk activate 3.1.43`. On Windows, use `./emsdk/emsdk.ps1` instead.
- I'm still not sure _why_ 3.1.43 is required, but it is. Perhaps in the future I will attempt to use a more modern version.
- Occasionally, the build will fail due to dependencies failing to download. I even have a retry mechanism in the build workflow due to this.
- 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. - 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. - 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/) - 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. - 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` - `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)) - `caddy file-server --root dist` (install with `[sudo apt|brew|choco] install caddy` or [a dozen other ways](https://caddyserver.com/docs/install))
- `web.build.ts` auto installs dependencies, but you may need to pass `-i` or `--install=fallback|force` to install missing packages. My guess is that if you have some packages installed, it won't install any missing ones. If you have no packages installed, it will install all of them.
- If you want to have TypeScript resolution for development, you can manually install the dependencies with `bun install` in the `assets/site` folder.

View File

@@ -2,12 +2,25 @@
<html> <html>
<head> <head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pac-Man Arcade</title> <title>Pac-Man in Rust</title>
<link rel="stylesheet" href="build.css" /> <link rel="stylesheet" href="build.css" />
<style> <style>
/* Minimal fallback to prevent white flash and canvas pop-in before CSS loads */
html,
body {
background: #000;
color: #facc15;
margin: 0;
text-align: center;
}
#canvas {
display: block;
margin: 1.5rem auto;
background: #000;
}
</style> </style>
</head> </head>
<body class="bg-black text-yellow-400 text-center"> <body class="bg-black text-yellow-400 text-center min-h-screen">
<a <a
href="https://github.com/Xevion/Pac-Man" href="https://github.com/Xevion/Pac-Man"
class="absolute top-0 right-0" class="absolute top-0 right-0"
@@ -17,7 +30,7 @@
width="80" width="80"
height="80" height="80"
viewBox="0 0 250 250" viewBox="0 0 250 250"
class="fill-yellow-400 text-black" class="fill-yellow-400 [&>.octo-arm,.octo-body]:fill-black"
aria-hidden="true" aria-hidden="true"
> >
<path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path> <path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path>
@@ -31,33 +44,51 @@
></path> ></path>
</svg> </svg>
</a> </a>
<h1 class="text-4xl mt-10 scaled-text">Pac-Man Arcade</h1>
<p class="text-lg mt-5 scaled-text"> <div class="min-h-screen flex flex-col">
Welcome to the Pac-Man Arcade! Use the controls below to play. <main class="flex-1 flex items-center justify-center px-4">
</p> <div class="w-full max-w-5xl">
<canvas <canvas
id="canvas" id="canvas"
class="block mx-auto mt-5" oncontextmenu="event.preventDefault()"
width="800" class="block w-full h-full max-h-[90vh] aspect-square"
height="600" ></canvas>
></canvas>
<div class="mt-10"> <div
<span class="mt-8 flex flex-wrap gap-3 justify-center items-center text-sm"
class="inline-block mx-2 px-4 py-2 bg-yellow-400 text-black rounded scaled-text" >
>&larr; &uarr; &rarr; &darr; Move</span <span class="code">&larr; &uarr; &rarr; &darr;</span>
> <span class="opacity-70">Move</span>
<span
class="inline-block mx-2 px-4 py-2 bg-yellow-400 text-black rounded scaled-text" <span class="mx-2 opacity-30">|</span>
>Space Change Sprite</span
> <span class="code">Space</span>
<span <span class="opacity-70">Toggle Debug</span>
class="inline-block mx-2 px-4 py-2 bg-yellow-400 text-black rounded scaled-text"
>Shift + &uarr;&darr; Change Volume</span <span class="mx-2 opacity-30">|</span>
>
<span class="code">P</span>
<span class="opacity-70">Pause / Unpause</span>
<span class="mx-2 opacity-30">|</span>
<span class="code">M</span>
<span class="opacity-70">Mute / Unmute</span>
</div>
</div>
</main>
</div> </div>
<script type="text/javascript"> <script type="text/javascript">
const canvas = document.getElementById("canvas");
var Module = { var Module = {
canvas: document.getElementById("canvas"), canvas: canvas,
preRun: [
() => {
[...canvas.classList]
.filter((className) => className.includes("shadow-"))
.forEach((className) => canvas.classList.remove(className));
},
],
}; };
</script> </script>
<script type="text/javascript" src="pacman.js"></script> <script type="text/javascript" src="pacman.js"></script>

28
assets/site/styles.css Normal file
View 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);
}

View File

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

61
bacon.toml Normal file
View File

@@ -0,0 +1,61 @@
# This is a configuration file for the bacon tool
#
# Complete help on configuration: https://dystroy.org/bacon/config/
#
# You may check the current default at
# https://github.com/Canop/bacon/blob/main/defaults/default-bacon.toml
default_job = "check"
env.CARGO_TERM_COLOR = "always"
[jobs.check]
command = ["cargo", "check"]
need_stdout = false
[jobs.check-all]
command = ["cargo", "check", "--all-targets"]
need_stdout = false
# Run clippy on the default target
[jobs.clippy]
command = ["cargo", "clippy"]
need_stdout = false
# Run clippy on all targets
[jobs.clippy-all]
command = ["cargo", "clippy", "--all-targets"]
need_stdout = false
[jobs.test]
command = [
"cargo", "nextest", "run",
"--hide-progress-bar", "--failure-output", "final"
]
need_stdout = true
analyzer = "nextest"
[jobs.doc]
command = ["cargo", "doc", "--no-deps"]
need_stdout = false
# If the doc compiles, then it opens in your browser and bacon switches to the previous job
[jobs.doc-open]
command = ["cargo", "doc", "--no-deps", "--open"]
need_stdout = false
on_success = "back" # so that we don't open the browser at each change
[jobs.run]
command = [
"cargo", "run",
]
need_stdout = true
allow_warnings = true
background = false
on_change_strategy = "kill_then_restart"
# kill = ["pkill", "-TERM", "-P"]'
[keybindings]
c = "job:clippy"
alt-c = "job:check"
ctrl-alt-c = "job:check-all"
shift-c = "job:clippy-all"

View File

@@ -1,6 +1,6 @@
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use anyhow::{anyhow, Result}; use glam::Vec2;
use sdl2::event::{Event, WindowEvent}; use sdl2::event::{Event, WindowEvent};
use sdl2::keyboard::Keycode; use sdl2::keyboard::Keycode;
use sdl2::render::{Canvas, ScaleMode, Texture, TextureCreator}; use sdl2::render::{Canvas, ScaleMode, Texture, TextureCreator};
@@ -8,21 +8,11 @@ use sdl2::video::{Window, WindowContext};
use sdl2::EventPump; use sdl2::EventPump;
use tracing::{error, event}; use tracing::{error, event};
use crate::error::{GameError, GameResult};
use crate::constants::{CANVAS_SIZE, LOOP_TIME, SCALE}; use crate::constants::{CANVAS_SIZE, LOOP_TIME, SCALE};
use crate::game::Game; use crate::game::Game;
use crate::platform::get_platform;
#[cfg(target_os = "emscripten")]
use crate::emscripten;
#[cfg(not(target_os = "emscripten"))]
fn sleep(value: Duration) {
spin_sleep::sleep(value);
}
#[cfg(target_os = "emscripten")]
fn sleep(value: Duration) {
emscripten::emscripten::sleep(value.as_millis() as u32);
}
pub struct App<'a> { pub struct App<'a> {
game: Game, game: Game,
@@ -31,14 +21,18 @@ pub struct App<'a> {
backbuffer: Texture<'a>, backbuffer: Texture<'a>,
paused: bool, paused: bool,
last_tick: Instant, last_tick: Instant,
cursor_pos: Vec2,
} }
impl App<'_> { impl App<'_> {
pub fn new() -> Result<Self> { pub fn new() -> GameResult<Self> {
let sdl_context = sdl2::init().map_err(|e| anyhow!(e))?; // Initialize platform-specific console
let video_subsystem = sdl_context.video().map_err(|e| anyhow!(e))?; get_platform().init_console()?;
let audio_subsystem = sdl_context.audio().map_err(|e| anyhow!(e))?;
let ttf_context = sdl2::ttf::init().map_err(|e| anyhow!(e.to_string()))?; let sdl_context = sdl2::init().map_err(|e| GameError::Sdl(e.to_string()))?;
let video_subsystem = sdl_context.video().map_err(|e| GameError::Sdl(e.to_string()))?;
let audio_subsystem = sdl_context.audio().map_err(|e| GameError::Sdl(e.to_string()))?;
let ttf_context = sdl2::ttf::init().map_err(|e| GameError::Sdl(e.to_string()))?;
let window = video_subsystem let window = video_subsystem
.window( .window(
@@ -48,24 +42,31 @@ impl App<'_> {
) )
.resizable() .resizable()
.position_centered() .position_centered()
.build()?; .build()
.map_err(|e| GameError::Sdl(e.to_string()))?;
let mut canvas = window.into_canvas().build()?; let mut canvas = window.into_canvas().build().map_err(|e| GameError::Sdl(e.to_string()))?;
canvas.set_logical_size(CANVAS_SIZE.x, CANVAS_SIZE.y)?; canvas
.set_logical_size(CANVAS_SIZE.x, CANVAS_SIZE.y)
.map_err(|e| GameError::Sdl(e.to_string()))?;
let texture_creator_static: &'static TextureCreator<WindowContext> = Box::leak(Box::new(canvas.texture_creator())); let texture_creator_static: &'static TextureCreator<WindowContext> = Box::leak(Box::new(canvas.texture_creator()));
let mut game = Game::new(texture_creator_static, &ttf_context, &audio_subsystem); let mut game = Game::new(texture_creator_static, &ttf_context, &audio_subsystem)?;
game.audio.set_mute(cfg!(debug_assertions)); game.audio.set_mute(cfg!(debug_assertions));
let mut backbuffer = texture_creator_static.create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y)?; let mut backbuffer = texture_creator_static
.create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y)
.map_err(|e| GameError::Sdl(e.to_string()))?;
backbuffer.set_scale_mode(ScaleMode::Nearest); backbuffer.set_scale_mode(ScaleMode::Nearest);
let event_pump = sdl_context.event_pump().map_err(|e| anyhow!(e))?; let event_pump = sdl_context.event_pump().map_err(|e| GameError::Sdl(e.to_string()))?;
// Initial draw // Initial draw
game.draw(&mut canvas, &mut backbuffer)?; game.draw(&mut canvas, &mut backbuffer)
game.present_backbuffer(&mut canvas, &backbuffer)?; .map_err(|e| GameError::Sdl(e.to_string()))?;
game.present_backbuffer(&mut canvas, &backbuffer, glam::Vec2::ZERO)
.map_err(|e| GameError::Sdl(e.to_string()))?;
Ok(Self { Ok(Self {
game, game,
@@ -74,6 +75,7 @@ impl App<'_> {
backbuffer, backbuffer,
paused: false, paused: false,
last_tick: Instant::now(), last_tick: Instant::now(),
cursor_pos: Vec2::ZERO,
}) })
} }
@@ -115,8 +117,12 @@ impl App<'_> {
} => { } => {
self.game.debug_mode = !self.game.debug_mode; self.game.debug_mode = !self.game.debug_mode;
} }
Event::KeyDown { keycode, .. } => { Event::KeyDown { keycode: Some(key), .. } => {
self.game.keyboard_event(keycode.unwrap()); self.game.keyboard_event(key);
}
Event::MouseMotion { x, y, .. } => {
// Convert window coordinates to logical coordinates
self.cursor_pos = Vec2::new(x as f32, y as f32);
} }
_ => {} _ => {}
} }
@@ -128,17 +134,20 @@ impl App<'_> {
if !self.paused { if !self.paused {
self.game.tick(dt); self.game.tick(dt);
if let Err(e) = self.game.draw(&mut self.canvas, &mut self.backbuffer) { if let Err(e) = self.game.draw(&mut self.canvas, &mut self.backbuffer) {
error!("Failed to draw game: {e}"); error!("Failed to draw game: {}", e);
} }
if let Err(e) = self.game.present_backbuffer(&mut self.canvas, &self.backbuffer) { if let Err(e) = self
error!("Failed to present backbuffer: {e}"); .game
.present_backbuffer(&mut self.canvas, &self.backbuffer, self.cursor_pos)
{
error!("Failed to present backbuffer: {}", e);
} }
} }
if start.elapsed() < LOOP_TIME { if start.elapsed() < LOOP_TIME {
let time = LOOP_TIME.saturating_sub(start.elapsed()); let time = LOOP_TIME.saturating_sub(start.elapsed());
if time != Duration::ZERO { if time != Duration::ZERO {
sleep(time); get_platform().sleep(time);
} }
} else { } else {
event!( event!(

View File

@@ -42,40 +42,12 @@ impl Asset {
} }
} }
#[cfg(not(target_os = "emscripten"))]
mod imp { mod imp {
use super::*; use super::*;
macro_rules! asset_bytes_enum { use crate::platform::get_platform;
( $asset:expr ) => {
match $asset {
Asset::Wav1 => Cow::Borrowed(include_bytes!("../assets/game/sound/waka/1.ogg")),
Asset::Wav2 => Cow::Borrowed(include_bytes!("../assets/game/sound/waka/2.ogg")),
Asset::Wav3 => Cow::Borrowed(include_bytes!("../assets/game/sound/waka/3.ogg")),
Asset::Wav4 => Cow::Borrowed(include_bytes!("../assets/game/sound/waka/4.ogg")),
Asset::Atlas => Cow::Borrowed(include_bytes!("../assets/game/atlas.png")),
Asset::AtlasJson => Cow::Borrowed(include_bytes!("../assets/game/atlas.json")),
}
};
}
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
Ok(asset_bytes_enum!(asset))
}
}
#[cfg(target_os = "emscripten")]
mod imp {
use super::*;
use sdl2::rwops::RWops;
use std::io::Read;
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> { pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
let path = format!("assets/game/{}", asset.path()); get_platform().get_asset_bytes(asset)
let mut rwops = RWops::from_file(&path, "rb").map_err(|_| AssetError::NotFound(asset.path().to_string()))?;
let len = rwops.len().ok_or_else(|| AssetError::NotFound(asset.path().to_string()))?;
let mut buf = vec![0u8; len];
rwops
.read_exact(&mut buf)
.map_err(|e| AssetError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
Ok(Cow::Owned(buf))
} }
} }

View File

@@ -162,156 +162,3 @@ impl Audio {
self.disabled self.disabled
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Once;
static INIT: Once = Once::new();
fn init_sdl() -> Result<(), String> {
INIT.call_once(|| {
if let Err(e) = sdl2::init() {
eprintln!("Failed to initialize SDL2: {}", e);
}
});
Ok(())
}
#[test]
fn test_sound_assets_array() {
assert_eq!(SOUND_ASSETS.len(), 4);
assert_eq!(SOUND_ASSETS[0], Asset::Wav1);
assert_eq!(SOUND_ASSETS[1], Asset::Wav2);
assert_eq!(SOUND_ASSETS[2], Asset::Wav3);
assert_eq!(SOUND_ASSETS[3], Asset::Wav4);
}
#[test]
fn test_audio_asset_paths() {
// Test that all sound assets have valid paths
for asset in SOUND_ASSETS.iter() {
let path = asset.path();
assert!(!path.is_empty());
assert!(path.contains("sound/waka/"));
assert!(path.ends_with(".ogg"));
}
}
// Only run SDL2-dependent tests if SDL2 initialization succeeds
#[test]
fn test_audio_basic_functionality() {
if let Err(_) = init_sdl() {
eprintln!("Skipping SDL2-dependent tests due to initialization failure");
return;
}
// Test basic audio creation
let audio = Audio::new();
assert_eq!(audio.is_muted(), false);
assert_eq!(audio.next_sound_index, 0);
// Audio might be disabled if initialization failed
if !audio.is_disabled() {
assert_eq!(audio.sounds.len(), 4);
}
}
#[test]
fn test_audio_mute_functionality() {
if let Err(_) = init_sdl() {
eprintln!("Skipping SDL2-dependent tests due to initialization failure");
return;
}
let mut audio = Audio::new();
// Test mute/unmute
assert_eq!(audio.is_muted(), false);
audio.set_mute(true);
assert_eq!(audio.is_muted(), true);
audio.set_mute(false);
assert_eq!(audio.is_muted(), false);
}
#[test]
fn test_audio_sound_rotation() {
if let Err(_) = init_sdl() {
eprintln!("Skipping SDL2-dependent tests due to initialization failure");
return;
}
let mut audio = Audio::new();
// Skip test if audio is disabled
if audio.is_disabled() {
eprintln!("Skipping sound rotation test due to disabled audio");
return;
}
let initial_index = audio.next_sound_index;
// Test sound rotation
for i in 0..4 {
audio.eat();
assert_eq!(audio.next_sound_index, (initial_index + i + 1) % 4);
}
assert_eq!(audio.next_sound_index, initial_index);
}
#[test]
fn test_audio_sound_index_bounds() {
if let Err(_) = init_sdl() {
eprintln!("Skipping SDL2-dependent tests due to initialization failure");
return;
}
let audio = Audio::new();
// Skip test if audio is disabled
if audio.is_disabled() {
eprintln!("Skipping sound index bounds test due to disabled audio");
return;
}
assert!(audio.next_sound_index < audio.sounds.len());
}
#[test]
fn test_audio_default_impl() {
if let Err(_) = init_sdl() {
eprintln!("Skipping SDL2-dependent tests due to initialization failure");
return;
}
let audio = Audio::default();
assert_eq!(audio.is_muted(), false);
assert_eq!(audio.next_sound_index, 0);
// Audio might be disabled if initialization failed
if !audio.is_disabled() {
assert_eq!(audio.sounds.len(), 4);
}
}
#[test]
fn test_audio_disabled_state() {
if let Err(_) = init_sdl() {
eprintln!("Skipping SDL2-dependent tests due to initialization failure");
return;
}
// Test that disabled audio doesn't crash when calling functions
let mut audio = Audio::new();
// These should not panic even if audio is disabled
audio.eat();
audio.set_mute(true);
audio.set_mute(false);
// Test that we can check the disabled state
let _is_disabled = audio.is_disabled();
}
}

View File

@@ -75,179 +75,3 @@ pub const RAW_BOARD: [&str; BOARD_CELL_SIZE.y as usize] = [
"#..........................#", "#..........................#",
"############################", "############################",
]; ];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_loop_time() {
// 60 FPS = 16.67ms per frame
let expected_nanos = (1_000_000_000.0 / 60.0) as u64;
assert_eq!(LOOP_TIME.as_nanos() as u64, expected_nanos);
}
#[test]
fn test_cell_size() {
assert_eq!(CELL_SIZE, 8);
}
#[test]
fn test_board_cell_size() {
assert_eq!(BOARD_CELL_SIZE.x, 28);
assert_eq!(BOARD_CELL_SIZE.y, 31);
}
#[test]
fn test_scale() {
assert_eq!(SCALE, 2.6);
}
#[test]
fn test_board_cell_offset() {
assert_eq!(BOARD_CELL_OFFSET.x, 0);
assert_eq!(BOARD_CELL_OFFSET.y, 3);
}
#[test]
fn test_board_pixel_offset() {
let expected = UVec2::new(0 * CELL_SIZE, 3 * CELL_SIZE);
assert_eq!(BOARD_PIXEL_OFFSET, expected);
assert_eq!(BOARD_PIXEL_OFFSET.x, 0);
assert_eq!(BOARD_PIXEL_OFFSET.y, 24); // 3 * 8
}
#[test]
fn test_board_pixel_size() {
let expected = UVec2::new(28 * CELL_SIZE, 31 * CELL_SIZE);
assert_eq!(BOARD_PIXEL_SIZE, expected);
assert_eq!(BOARD_PIXEL_SIZE.x, 224); // 28 * 8
assert_eq!(BOARD_PIXEL_SIZE.y, 248); // 31 * 8
}
#[test]
fn test_canvas_size() {
let expected = UVec2::new((28 + 0) * CELL_SIZE, (31 + 3) * CELL_SIZE);
assert_eq!(CANVAS_SIZE, expected);
assert_eq!(CANVAS_SIZE.x, 224); // (28 + 0) * 8
assert_eq!(CANVAS_SIZE.y, 272); // (31 + 3) * 8
}
#[test]
fn test_map_tile_variants() {
assert_ne!(MapTile::Empty, MapTile::Wall);
assert_ne!(MapTile::Pellet, MapTile::PowerPellet);
assert_ne!(MapTile::Tunnel, MapTile::Empty);
}
#[test]
fn test_map_tile_clone() {
let original = MapTile::Wall;
let cloned = original;
assert_eq!(original, cloned);
}
#[test]
fn test_raw_board_dimensions() {
assert_eq!(RAW_BOARD.len(), BOARD_CELL_SIZE.y as usize);
assert_eq!(RAW_BOARD.len(), 31);
for row in RAW_BOARD.iter() {
assert_eq!(row.len(), BOARD_CELL_SIZE.x as usize);
assert_eq!(row.len(), 28);
}
}
#[test]
fn test_raw_board_boundaries() {
// First row should be all walls
assert!(RAW_BOARD[0].chars().all(|c| c == '#'));
// Last row should be all walls
let last_row = RAW_BOARD[RAW_BOARD.len() - 1];
assert!(last_row.chars().all(|c| c == '#'));
// First and last character of each row should be walls (except tunnel rows and rows with spaces)
for (i, row) in RAW_BOARD.iter().enumerate() {
if i != 14 && !row.starts_with(' ') {
// Skip tunnel row and rows that start with spaces
assert_eq!(row.chars().next().unwrap(), '#');
assert_eq!(row.chars().last().unwrap(), '#');
}
}
}
#[test]
fn test_raw_board_tunnel_row() {
// Row 14 should have tunnel characters 'T' at the edges
let tunnel_row = RAW_BOARD[14];
assert_eq!(tunnel_row.chars().next().unwrap(), 'T');
assert_eq!(tunnel_row.chars().last().unwrap(), 'T');
}
#[test]
fn test_raw_board_power_pellets() {
// Power pellets are represented by 'o'
let mut power_pellet_count = 0;
for row in RAW_BOARD.iter() {
power_pellet_count += row.chars().filter(|&c| c == 'o').count();
}
assert_eq!(power_pellet_count, 4); // Should have exactly 4 power pellets
}
#[test]
fn test_raw_board_starting_position() {
// Should have a starting position 'X' for Pac-Man
let mut found_starting_position = false;
for row in RAW_BOARD.iter() {
if row.contains('X') {
found_starting_position = true;
break;
}
}
assert!(found_starting_position);
}
#[test]
fn test_raw_board_ghost_house() {
// The ghost house area should be present (the == characters)
let mut found_ghost_house = false;
for row in RAW_BOARD.iter() {
if row.contains("==") {
found_ghost_house = true;
break;
}
}
assert!(found_ghost_house);
}
#[test]
fn test_raw_board_symmetry() {
// The board should be roughly symmetrical
let mid_point = RAW_BOARD[0].len() / 2;
for row in RAW_BOARD.iter() {
let left_half = &row[..mid_point];
let right_half = &row[mid_point..];
// Check that the halves are symmetrical (accounting for the center column)
assert_eq!(left_half.len(), right_half.len());
}
}
#[test]
fn test_constants_consistency() {
// Verify that derived constants are calculated correctly
let calculated_pixel_offset = UVec2::new(BOARD_CELL_OFFSET.x * CELL_SIZE, BOARD_CELL_OFFSET.y * CELL_SIZE);
assert_eq!(BOARD_PIXEL_OFFSET, calculated_pixel_offset);
let calculated_pixel_size = UVec2::new(BOARD_CELL_SIZE.x * CELL_SIZE, BOARD_CELL_SIZE.y * CELL_SIZE);
assert_eq!(BOARD_PIXEL_SIZE, calculated_pixel_size);
let calculated_canvas_size = UVec2::new(
(BOARD_CELL_SIZE.x + BOARD_CELL_OFFSET.x) * CELL_SIZE,
(BOARD_CELL_SIZE.y + BOARD_CELL_OFFSET.y) * CELL_SIZE,
);
assert_eq!(CANVAS_SIZE, calculated_canvas_size);
}
}

View File

@@ -1,31 +0,0 @@
#[allow(dead_code)]
#[cfg(target_os = "emscripten")]
pub mod emscripten {
use std::os::raw::c_uint;
extern "C" {
pub fn emscripten_get_now() -> f64;
pub fn emscripten_sleep(ms: c_uint);
pub fn emscripten_get_element_css_size(target: *const u8, width: *mut f64, height: *mut f64) -> i32;
}
// milliseconds since start of program
pub fn now() -> f64 {
unsafe { emscripten_get_now() }
}
pub fn sleep(ms: u32) {
unsafe {
emscripten_sleep(ms);
}
}
pub fn get_canvas_size() -> (u32, u32) {
let mut width = 0.0;
let mut height = 0.0;
unsafe {
emscripten_get_element_css_size("canvas\0".as_ptr(), &mut width, &mut height);
}
(width as u32, height as u32)
}
}

View File

@@ -1,5 +1,6 @@
use glam::IVec2; use glam::IVec2;
/// The four cardinal directions.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
pub enum Direction { pub enum Direction {
Up, Up,
@@ -9,7 +10,12 @@ pub enum Direction {
} }
impl Direction { impl Direction {
pub fn opposite(&self) -> Direction { /// The four cardinal directions.
/// This is just a convenience constant for iterating over the directions.
pub const DIRECTIONS: [Direction; 4] = [Direction::Up, Direction::Down, Direction::Left, Direction::Right];
/// Returns the opposite direction. Constant time.
pub const fn opposite(self) -> Direction {
match self { match self {
Direction::Up => Direction::Down, Direction::Up => Direction::Down,
Direction::Down => Direction::Up, Direction::Down => Direction::Up,
@@ -18,8 +24,20 @@ impl Direction {
} }
} }
pub fn as_ivec2(&self) -> IVec2 { /// Returns the direction as an IVec2.
(*self).into() pub fn as_ivec2(self) -> IVec2 {
self.into()
}
/// Returns the direction as a usize (0-3). Constant time.
/// This is useful for indexing into arrays.
pub const fn as_usize(self) -> usize {
match self {
Direction::Up => 0,
Direction::Down => 1,
Direction::Left => 2,
Direction::Right => 3,
}
} }
} }
@@ -33,69 +51,3 @@ impl From<Direction> for IVec2 {
} }
} }
} }
pub const DIRECTIONS: [Direction; 4] = [Direction::Up, Direction::Down, Direction::Left, Direction::Right];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_direction_opposite() {
assert_eq!(Direction::Up.opposite(), Direction::Down);
assert_eq!(Direction::Down.opposite(), Direction::Up);
assert_eq!(Direction::Left.opposite(), Direction::Right);
assert_eq!(Direction::Right.opposite(), Direction::Left);
}
#[test]
fn test_direction_as_ivec2() {
assert_eq!(Direction::Up.as_ivec2(), -IVec2::Y);
assert_eq!(Direction::Down.as_ivec2(), IVec2::Y);
assert_eq!(Direction::Left.as_ivec2(), -IVec2::X);
assert_eq!(Direction::Right.as_ivec2(), IVec2::X);
}
#[test]
fn test_direction_from_ivec2() {
assert_eq!(IVec2::from(Direction::Up), -IVec2::Y);
assert_eq!(IVec2::from(Direction::Down), IVec2::Y);
assert_eq!(IVec2::from(Direction::Left), -IVec2::X);
assert_eq!(IVec2::from(Direction::Right), IVec2::X);
}
#[test]
fn test_directions_constant() {
assert_eq!(DIRECTIONS.len(), 4);
assert!(DIRECTIONS.contains(&Direction::Up));
assert!(DIRECTIONS.contains(&Direction::Down));
assert!(DIRECTIONS.contains(&Direction::Left));
assert!(DIRECTIONS.contains(&Direction::Right));
}
#[test]
fn test_direction_equality() {
assert_eq!(Direction::Up, Direction::Up);
assert_ne!(Direction::Up, Direction::Down);
assert_ne!(Direction::Left, Direction::Right);
}
#[test]
fn test_direction_clone() {
let dir = Direction::Up;
let cloned = dir;
assert_eq!(dir, cloned);
}
#[test]
fn test_direction_hash() {
use std::collections::HashMap;
let mut map = HashMap::new();
map.insert(Direction::Up, "up");
map.insert(Direction::Down, "down");
assert_eq!(map.get(&Direction::Up), Some(&"up"));
assert_eq!(map.get(&Direction::Down), Some(&"down"));
assert_eq!(map.get(&Direction::Left), None);
}
}

247
src/entity/ghost.rs Normal file
View File

@@ -0,0 +1,247 @@
//! Ghost entity implementation.
//!
//! This module contains the ghost character logic, including movement,
//! animation, and rendering. Ghosts move through the game graph using
//! a traverser and display directional animated textures.
use pathfinding::prelude::dijkstra;
use rand::prelude::*;
use smallvec::SmallVec;
use tracing::error;
use crate::entity::direction::Direction;
use crate::entity::graph::{Edge, EdgePermissions, Graph, NodeId};
use crate::entity::r#trait::Entity;
use crate::entity::traversal::Traverser;
use crate::texture::animated::AnimatedTexture;
use crate::texture::directional::DirectionalAnimatedTexture;
use crate::texture::sprite::SpriteAtlas;
use crate::error::{EntityError, GameError, GameResult, TextureError};
/// Determines if a ghost can traverse a given edge.
///
/// Ghosts can move through edges that allow all entities or ghost-only edges.
fn can_ghost_traverse(edge: Edge) -> bool {
matches!(edge.permissions, EdgePermissions::All | EdgePermissions::GhostsOnly)
}
/// The four classic ghost types.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GhostType {
Blinky,
Pinky,
Inky,
Clyde,
}
impl GhostType {
/// Returns the ghost type name for atlas lookups.
pub fn as_str(self) -> &'static str {
match self {
GhostType::Blinky => "blinky",
GhostType::Pinky => "pinky",
GhostType::Inky => "inky",
GhostType::Clyde => "clyde",
}
}
/// Returns the base movement speed for this ghost type.
pub fn base_speed(self) -> f32 {
match self {
GhostType::Blinky => 1.0,
GhostType::Pinky => 0.95,
GhostType::Inky => 0.9,
GhostType::Clyde => 0.85,
}
}
}
/// A ghost entity that roams the game world.
///
/// Ghosts move through the game world using a graph-based navigation system
/// and display directional animated sprites. They randomly choose directions
/// at each intersection.
pub struct Ghost {
/// Handles movement through the game graph
pub traverser: Traverser,
/// The type of ghost (affects appearance and speed)
pub ghost_type: GhostType,
/// Manages directional animated textures for different movement states
texture: DirectionalAnimatedTexture,
/// Current movement speed
speed: f32,
}
impl Entity for Ghost {
fn traverser(&self) -> &Traverser {
&self.traverser
}
fn traverser_mut(&mut self) -> &mut Traverser {
&mut self.traverser
}
fn texture(&self) -> &DirectionalAnimatedTexture {
&self.texture
}
fn texture_mut(&mut self) -> &mut DirectionalAnimatedTexture {
&mut self.texture
}
fn speed(&self) -> f32 {
self.speed
}
fn can_traverse(&self, edge: Edge) -> bool {
can_ghost_traverse(edge)
}
fn tick(&mut self, dt: f32, graph: &Graph) {
// Choose random direction when at a node
if self.traverser.position.is_at_node() {
self.choose_random_direction(graph);
}
if let Err(e) = self.traverser.advance(graph, dt * 60.0 * self.speed, &can_ghost_traverse) {
error!("Ghost movement error: {}", e);
}
self.texture.tick(dt);
}
}
impl Ghost {
/// Creates a new ghost instance at the specified starting node.
///
/// Sets up animated textures for all four directions with moving and stopped states.
/// The moving animation cycles through two sprite variants.
pub fn new(graph: &Graph, start_node: NodeId, ghost_type: GhostType, atlas: &SpriteAtlas) -> GameResult<Self> {
let mut textures = [None, None, None, None];
let mut stopped_textures = [None, None, None, None];
for direction in Direction::DIRECTIONS {
let moving_prefix = match direction {
Direction::Up => "up",
Direction::Down => "down",
Direction::Left => "left",
Direction::Right => "right",
};
let moving_tiles = vec![
SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a"))
.ok_or_else(|| {
GameError::Texture(TextureError::AtlasTileNotFound(format!(
"ghost/{}/{}_{}.png",
ghost_type.as_str(),
moving_prefix,
"a"
)))
})?,
SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "b"))
.ok_or_else(|| {
GameError::Texture(TextureError::AtlasTileNotFound(format!(
"ghost/{}/{}_{}.png",
ghost_type.as_str(),
moving_prefix,
"b"
)))
})?,
];
let stopped_tiles =
vec![
SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a"))
.ok_or_else(|| {
GameError::Texture(TextureError::AtlasTileNotFound(format!(
"ghost/{}/{}_{}.png",
ghost_type.as_str(),
moving_prefix,
"a"
)))
})?,
];
textures[direction.as_usize()] =
Some(AnimatedTexture::new(moving_tiles, 0.2).map_err(|e| GameError::Texture(TextureError::Animated(e)))?);
stopped_textures[direction.as_usize()] =
Some(AnimatedTexture::new(stopped_tiles, 0.1).map_err(|e| GameError::Texture(TextureError::Animated(e)))?);
}
Ok(Self {
traverser: Traverser::new(graph, start_node, Direction::Left, &can_ghost_traverse),
ghost_type,
texture: DirectionalAnimatedTexture::new(textures, stopped_textures),
speed: ghost_type.base_speed(),
})
}
/// Chooses a random available direction at the current intersection.
fn choose_random_direction(&mut self, graph: &Graph) {
let current_node = self.traverser.position.from_node_id();
let intersection = &graph.adjacency_list[current_node];
// Collect all available directions
let mut available_directions = SmallVec::<[_; 4]>::new();
for direction in Direction::DIRECTIONS {
if let Some(edge) = intersection.get(direction) {
if can_ghost_traverse(edge) {
available_directions.push(direction);
}
}
}
// Choose a random direction (avoid reversing unless necessary)
if !available_directions.is_empty() {
let mut rng = SmallRng::from_os_rng();
// Filter out the opposite direction if possible, but allow it if we have limited options
let opposite = self.traverser.direction.opposite();
let filtered_directions: Vec<_> = available_directions
.iter()
.filter(|&&dir| dir != opposite || available_directions.len() <= 2)
.collect();
if let Some(&random_direction) = filtered_directions.choose(&mut rng) {
self.traverser.set_next_direction(*random_direction);
}
}
}
/// Calculates the shortest path from the ghost's current position to a target node using Dijkstra's algorithm.
///
/// Returns a vector of NodeIds representing the path, or an error if pathfinding fails.
/// The path includes the current node and the target node.
pub fn calculate_path_to_target(&self, graph: &Graph, target: NodeId) -> GameResult<Vec<NodeId>> {
let start_node = self.traverser.position.from_node_id();
// Use Dijkstra's algorithm to find the shortest path
let result = dijkstra(
&start_node,
|&node_id| {
// Get all edges from the current node
graph.adjacency_list[node_id]
.edges()
.filter(|edge| can_ghost_traverse(*edge))
.map(|edge| (edge.target, (edge.distance * 100.0) as u32))
.collect::<Vec<_>>()
},
|&node_id| node_id == target,
);
result.map(|(path, _cost)| path).ok_or_else(|| {
GameError::Entity(EntityError::PathfindingFailed(format!(
"No path found from node {} to target {}",
start_node, target
)))
})
}
/// Returns the ghost's color for debug rendering.
pub fn debug_color(&self) -> sdl2::pixels::Color {
match self.ghost_type {
GhostType::Blinky => sdl2::pixels::Color::RGB(255, 0, 0), // Red
GhostType::Pinky => sdl2::pixels::Color::RGB(255, 182, 255), // Pink
GhostType::Inky => sdl2::pixels::Color::RGB(0, 255, 255), // Cyan
GhostType::Clyde => sdl2::pixels::Color::RGB(255, 182, 85), // Orange
}
}
}

View File

@@ -111,7 +111,7 @@ impl Graph {
} }
/// Connects a new node to the graph and adds an edge between the existing node and the new node. /// Connects a new node to the graph and adds an edge between the existing node and the new node.
pub fn connect_node(&mut self, from: NodeId, direction: Direction, new_node: Node) -> Result<NodeId, &'static str> { pub fn add_connected(&mut self, from: NodeId, direction: Direction, new_node: Node) -> Result<NodeId, &'static str> {
let to = self.add_node(new_node); let to = self.add_node(new_node);
self.connect(from, to, false, None, direction)?; self.connect(from, to, false, None, direction)?;
Ok(to) Ok(to)
@@ -236,571 +236,3 @@ impl Default for Graph {
Self::new() Self::new()
} }
} }
// --- Traversal State and Logic ---
/// Represents the current position of an entity traversing the graph.
///
/// This enum allows for precise tracking of whether an entity is exactly at a node
/// or moving along an edge between two nodes.
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum Position {
/// The traverser is located exactly at a node.
AtNode(NodeId),
/// The traverser is on an edge between two nodes.
BetweenNodes {
from: NodeId,
to: NodeId,
/// The floating-point distance traversed along the edge from the `from` node.
traversed: f32,
},
}
#[allow(dead_code)]
impl Position {
/// Returns `true` if the position is exactly at a node.
pub fn is_at_node(&self) -> bool {
matches!(self, Position::AtNode(_))
}
/// Returns the `NodeId` of the current or most recently departed node.
#[allow(clippy::wrong_self_convention)]
pub fn from_node_id(&self) -> NodeId {
match self {
Position::AtNode(id) => *id,
Position::BetweenNodes { from, .. } => *from,
}
}
/// Returns the `NodeId` of the destination node, if currently on an edge.
#[allow(clippy::wrong_self_convention)]
pub fn to_node_id(&self) -> Option<NodeId> {
match self {
Position::AtNode(_) => None,
Position::BetweenNodes { to, .. } => Some(*to),
}
}
/// Returns `true` if the traverser is stopped at a node.
pub fn is_stopped(&self) -> bool {
matches!(self, Position::AtNode(_))
}
}
/// Manages an entity's movement through the graph.
///
/// A `Traverser` encapsulates the state of an entity's position and direction,
/// providing a way to advance along the graph's paths based on a given distance.
/// It also handles direction changes, buffering the next intended direction.
pub struct Traverser {
/// The current position of the traverser in the graph.
pub position: Position,
/// The current direction of movement.
pub direction: Direction,
/// Buffered direction change with remaining frame count for timing.
///
/// The `u8` value represents the number of frames remaining before
/// the buffered direction expires. This allows for responsive controls
/// by storing direction changes for a limited time.
pub next_direction: Option<(Direction, u8)>,
}
impl Traverser {
/// Creates a new traverser starting at the given node ID.
///
/// The traverser will immediately attempt to start moving in the initial direction.
pub fn new<F>(graph: &Graph, start_node: NodeId, initial_direction: Direction, can_traverse: &F) -> Self
where
F: Fn(Edge) -> bool,
{
let mut traverser = Traverser {
position: Position::AtNode(start_node),
direction: initial_direction,
next_direction: Some((initial_direction, 1)),
};
// This will kickstart the traverser into motion
traverser.advance(graph, 0.0, can_traverse);
traverser
}
/// Sets the next direction for the traverser to take.
///
/// The direction is buffered and will be applied at the next opportunity,
/// typically when the traverser reaches a new node. This allows for responsive
/// controls, as the new direction is stored for a limited time.
pub fn set_next_direction(&mut self, new_direction: Direction) {
if self.direction != new_direction {
self.next_direction = Some((new_direction, 30));
}
}
/// Advances the traverser along the graph by a specified distance.
///
/// This method updates the traverser's position based on its current state
/// and the distance to travel.
///
/// - If at a node, it checks for a buffered direction to start moving.
/// - If between nodes, it moves along the current edge.
/// - If it reaches a node, it attempts to transition to a new edge based on
/// the buffered direction or by continuing straight.
/// - If no valid move is possible, it stops at the node.
pub fn advance<F>(&mut self, graph: &Graph, distance: f32, can_traverse: &F)
where
F: Fn(Edge) -> bool,
{
// Decrement the remaining frames for the next direction
if let Some((direction, remaining)) = self.next_direction {
if remaining > 0 {
self.next_direction = Some((direction, remaining - 1));
} else {
self.next_direction = None;
}
}
match self.position {
Position::AtNode(node_id) => {
// We're not moving, but a buffered direction is available.
if let Some((next_direction, _)) = self.next_direction {
if let Some(edge) = graph.find_edge_in_direction(node_id, next_direction) {
if can_traverse(edge) {
// Start moving in that direction
self.position = Position::BetweenNodes {
from: node_id,
to: edge.target,
traversed: distance.max(0.0),
};
self.direction = next_direction;
}
}
self.next_direction = None; // Consume the buffered direction regardless of whether we started moving with it
}
}
Position::BetweenNodes { from, to, traversed } => {
// There is no point in any of the next logic if we don't travel at all
if distance <= 0.0 {
return;
}
let edge = graph
.find_edge(from, to)
.expect("Inconsistent state: Traverser is on a non-existent edge.");
let new_traversed = traversed + distance;
if new_traversed < edge.distance {
// Still on the same edge, just update the distance.
self.position = Position::BetweenNodes {
from,
to,
traversed: new_traversed,
};
} else {
let overflow = new_traversed - edge.distance;
let mut moved = false;
// If we buffered a direction, try to find an edge in that direction
if let Some((next_dir, _)) = self.next_direction {
if let Some(edge) = graph.find_edge_in_direction(to, next_dir) {
if can_traverse(edge) {
self.position = Position::BetweenNodes {
from: to,
to: edge.target,
traversed: overflow,
};
self.direction = next_dir; // Remember our new direction
self.next_direction = None; // Consume the buffered direction
moved = true;
}
}
}
// If we didn't move, try to continue in the current direction
if !moved {
if let Some(edge) = graph.find_edge_in_direction(to, self.direction) {
if can_traverse(edge) {
self.position = Position::BetweenNodes {
from: to,
to: edge.target,
traversed: overflow,
};
} else {
self.position = Position::AtNode(to);
self.next_direction = None;
}
} else {
self.position = Position::AtNode(to);
self.next_direction = None;
}
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entity::direction::Direction;
fn create_test_graph() -> Graph {
let mut graph = Graph::new();
let node1 = graph.add_node(Node {
position: glam::Vec2::new(0.0, 0.0),
});
let node2 = graph.add_node(Node {
position: glam::Vec2::new(16.0, 0.0),
});
let node3 = graph.add_node(Node {
position: glam::Vec2::new(0.0, 16.0),
});
graph.connect(node1, node2, false, None, Direction::Right).unwrap();
graph.connect(node1, node3, false, None, Direction::Down).unwrap();
graph
}
#[test]
fn test_graph_new() {
let graph = Graph::new();
assert_eq!(graph.node_count(), 0);
assert!(graph.adjacency_list.is_empty());
}
#[test]
fn test_graph_add_node() {
let mut graph = Graph::new();
let node = Node {
position: glam::Vec2::new(10.0, 20.0),
};
let id = graph.add_node(node);
assert_eq!(id, 0);
assert_eq!(graph.node_count(), 1);
assert_eq!(graph.adjacency_list.len(), 1);
let retrieved_node = graph.get_node(id).unwrap();
assert_eq!(retrieved_node.position, glam::Vec2::new(10.0, 20.0));
}
#[test]
fn test_graph_node_count() {
let mut graph = Graph::new();
assert_eq!(graph.node_count(), 0);
graph.add_node(Node {
position: glam::Vec2::new(0.0, 0.0),
});
assert_eq!(graph.node_count(), 1);
graph.add_node(Node {
position: glam::Vec2::new(1.0, 1.0),
});
assert_eq!(graph.node_count(), 2);
}
#[test]
fn test_graph_get_node() {
let mut graph = Graph::new();
let node = Node {
position: glam::Vec2::new(5.0, 10.0),
};
let id = graph.add_node(node);
let retrieved = graph.get_node(id).unwrap();
assert_eq!(retrieved.position, glam::Vec2::new(5.0, 10.0));
assert!(graph.get_node(999).is_none());
}
#[test]
fn test_graph_connect() {
let mut graph = Graph::new();
let node1 = graph.add_node(Node {
position: glam::Vec2::new(0.0, 0.0),
});
let node2 = graph.add_node(Node {
position: glam::Vec2::new(16.0, 0.0),
});
let result = graph.connect(node1, node2, false, None, Direction::Right);
assert!(result.is_ok());
// Check that edges were added in both directions
let edge1 = graph.find_edge_in_direction(node1, Direction::Right);
let edge2 = graph.find_edge_in_direction(node2, Direction::Left);
assert!(edge1.is_some());
assert!(edge2.is_some());
assert_eq!(edge1.unwrap().target, node2);
assert_eq!(edge2.unwrap().target, node1);
}
#[test]
fn test_graph_connect_invalid_nodes() {
let mut graph = Graph::new();
let node1 = graph.add_node(Node {
position: glam::Vec2::new(0.0, 0.0),
});
// Try to connect to non-existent node
let result = graph.connect(node1, 999, false, None, Direction::Right);
assert!(result.is_err());
// Try to connect from non-existent node
let result = graph.connect(999, node1, false, None, Direction::Right);
assert!(result.is_err());
}
#[test]
fn test_graph_find_edge() {
let mut graph = Graph::new();
let node1 = graph.add_node(Node {
position: glam::Vec2::new(0.0, 0.0),
});
let node2 = graph.add_node(Node {
position: glam::Vec2::new(16.0, 0.0),
});
graph.connect(node1, node2, false, None, Direction::Right).unwrap();
let edge = graph.find_edge(node1, node2);
assert!(edge.is_some());
assert_eq!(edge.unwrap().target, node2);
// Test non-existent edge
assert!(graph.find_edge(node1, 999).is_none());
}
#[test]
fn test_graph_find_edge_in_direction() {
let mut graph = Graph::new();
let node1 = graph.add_node(Node {
position: glam::Vec2::new(0.0, 0.0),
});
let node2 = graph.add_node(Node {
position: glam::Vec2::new(16.0, 0.0),
});
graph.connect(node1, node2, false, None, Direction::Right).unwrap();
let edge = graph.find_edge_in_direction(node1, Direction::Right);
assert!(edge.is_some());
assert_eq!(edge.unwrap().target, node2);
// Test non-existent direction
assert!(graph.find_edge_in_direction(node1, Direction::Up).is_none());
}
#[test]
fn test_intersection_edges() {
let mut intersection = Intersection::default();
intersection.set(
Direction::Up,
Edge {
target: 1,
distance: 10.0,
direction: Direction::Up,
permissions: EdgePermissions::All,
},
);
intersection.set(
Direction::Right,
Edge {
target: 2,
distance: 15.0,
direction: Direction::Right,
permissions: EdgePermissions::All,
},
);
let edges: Vec<_> = intersection.edges().collect();
assert_eq!(edges.len(), 2);
let up_edge = edges.iter().find(|e| e.direction == Direction::Up).unwrap();
let right_edge = edges.iter().find(|e| e.direction == Direction::Right).unwrap();
assert_eq!(up_edge.target, 1);
assert_eq!(up_edge.distance, 10.0);
assert_eq!(right_edge.target, 2);
assert_eq!(right_edge.distance, 15.0);
}
#[test]
fn test_intersection_get() {
let mut intersection = Intersection::default();
let edge = Edge {
target: 1,
distance: 10.0,
direction: Direction::Up,
permissions: EdgePermissions::All,
};
intersection.set(Direction::Up, edge);
let retrieved = intersection.get(Direction::Up);
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().target, 1);
assert!(intersection.get(Direction::Down).is_none());
}
#[test]
fn test_intersection_set() {
let mut intersection = Intersection::default();
let edge = Edge {
target: 1,
distance: 10.0,
direction: Direction::Left,
permissions: EdgePermissions::All,
};
intersection.set(Direction::Left, edge);
let retrieved = intersection.get(Direction::Left);
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().target, 1);
}
#[test]
fn test_position_is_at_node() {
let pos = Position::AtNode(5);
assert!(pos.is_at_node());
let pos = Position::BetweenNodes {
from: 1,
to: 2,
traversed: 5.0,
};
assert!(!pos.is_at_node());
}
#[test]
fn test_position_from_node_id() {
let pos = Position::AtNode(5);
assert_eq!(pos.from_node_id(), 5);
let pos = Position::BetweenNodes {
from: 1,
to: 2,
traversed: 5.0,
};
assert_eq!(pos.from_node_id(), 1);
}
#[test]
fn test_position_to_node_id() {
let pos = Position::AtNode(5);
assert_eq!(pos.to_node_id(), None);
let pos = Position::BetweenNodes {
from: 1,
to: 2,
traversed: 5.0,
};
assert_eq!(pos.to_node_id(), Some(2));
}
#[test]
fn test_position_is_stopped() {
let pos = Position::AtNode(5);
assert!(pos.is_stopped());
let pos = Position::BetweenNodes {
from: 1,
to: 2,
traversed: 5.0,
};
assert!(!pos.is_stopped());
}
#[test]
fn test_traverser_new() {
let graph = create_test_graph();
let traverser = Traverser::new(&graph, 0, Direction::Left, &|_| true);
assert_eq!(traverser.direction, Direction::Left);
// The next_direction might be consumed immediately when the traverser starts moving
// So we just check that the direction is set correctly
assert_eq!(traverser.direction, Direction::Left);
}
#[test]
fn test_traverser_set_next_direction() {
let graph = create_test_graph();
let mut traverser = Traverser::new(&graph, 0, Direction::Left, &|_| true);
traverser.set_next_direction(Direction::Up);
assert!(traverser.next_direction.is_some());
assert_eq!(traverser.next_direction.unwrap().0, Direction::Up);
// Setting same direction should not change anything
traverser.set_next_direction(Direction::Up);
assert_eq!(traverser.next_direction.unwrap().0, Direction::Up);
}
#[test]
fn test_traverser_advance_at_node() {
let graph = create_test_graph();
let mut traverser = Traverser::new(&graph, 0, Direction::Right, &|_| true);
// Should start moving in the initial direction
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"),
}
}
#[test]
fn test_traverser_advance_between_nodes() {
let graph = create_test_graph();
let mut traverser = Traverser::new(&graph, 0, Direction::Right, &|_| true);
// Move to between nodes
traverser.advance(&graph, 5.0, &|_| true);
// Advance further
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_edge_structure() {
let edge = Edge {
target: 5,
distance: 10.5,
direction: Direction::Up,
permissions: EdgePermissions::All,
};
assert_eq!(edge.target, 5);
assert_eq!(edge.distance, 10.5);
assert_eq!(edge.direction, Direction::Up);
}
#[test]
fn test_node_structure() {
let node = Node {
position: glam::Vec2::new(10.0, 20.0),
};
assert_eq!(node.position, glam::Vec2::new(10.0, 20.0));
}
}

View File

@@ -1,3 +1,6 @@
pub mod direction; pub mod direction;
pub mod ghost;
pub mod graph; pub mod graph;
pub mod pacman; pub mod pacman;
pub mod r#trait;
pub mod traversal;

View File

@@ -1,31 +1,82 @@
use glam::{UVec2, Vec2}; //! Pac-Man entity implementation.
//!
//! This module contains the main player character logic, including movement,
//! animation, and rendering. Pac-Man moves through the game graph using
//! a traverser and displays directional animated textures.
use crate::constants::BOARD_PIXEL_OFFSET;
use crate::entity::direction::Direction; use crate::entity::direction::Direction;
use crate::entity::graph::{Edge, EdgePermissions, Graph, NodeId, Position, Traverser}; use crate::entity::graph::{Edge, EdgePermissions, Graph, NodeId};
use crate::helpers::centered_with_size; use crate::entity::r#trait::Entity;
use crate::entity::traversal::Traverser;
use crate::texture::animated::AnimatedTexture; use crate::texture::animated::AnimatedTexture;
use crate::texture::directional::DirectionalAnimatedTexture; use crate::texture::directional::DirectionalAnimatedTexture;
use crate::texture::sprite::SpriteAtlas; use crate::texture::sprite::SpriteAtlas;
use sdl2::keyboard::Keycode; use sdl2::keyboard::Keycode;
use sdl2::render::{Canvas, RenderTarget}; use tracing::error;
use std::collections::HashMap;
use crate::error::{GameError, GameResult, TextureError};
/// Determines if Pac-Man can traverse a given edge.
///
/// Pac-Man can only move through edges that allow all entities.
fn can_pacman_traverse(edge: Edge) -> bool { fn can_pacman_traverse(edge: Edge) -> bool {
matches!(edge.permissions, EdgePermissions::All) matches!(edge.permissions, EdgePermissions::All)
} }
/// The main player character entity.
///
/// Pac-Man moves through the game world using a graph-based navigation system
/// and displays directional animated sprites based on movement state.
pub struct Pacman { pub struct Pacman {
/// Handles movement through the game graph
pub traverser: Traverser, pub traverser: Traverser,
/// Manages directional animated textures for different movement states
texture: DirectionalAnimatedTexture, texture: DirectionalAnimatedTexture,
} }
impl Pacman { impl Entity for Pacman {
pub fn new(graph: &Graph, start_node: NodeId, atlas: &SpriteAtlas) -> Self { fn traverser(&self) -> &Traverser {
let mut textures = HashMap::new(); &self.traverser
let mut stopped_textures = HashMap::new(); }
for &direction in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] { fn traverser_mut(&mut self) -> &mut Traverser {
&mut self.traverser
}
fn texture(&self) -> &DirectionalAnimatedTexture {
&self.texture
}
fn texture_mut(&mut self) -> &mut DirectionalAnimatedTexture {
&mut self.texture
}
fn speed(&self) -> f32 {
1.125
}
fn can_traverse(&self, edge: Edge) -> bool {
can_pacman_traverse(edge)
}
fn tick(&mut self, dt: f32, graph: &Graph) {
if let Err(e) = self.traverser.advance(graph, dt * 60.0 * 1.125, &can_pacman_traverse) {
error!("Pac-Man movement error: {}", e);
}
self.texture.tick(dt);
}
}
impl Pacman {
/// Creates a new Pac-Man instance at the specified starting node.
///
/// Sets up animated textures for all four directions with moving and stopped states.
/// The moving animation cycles through open mouth, closed mouth, and full sprites.
pub fn new(graph: &Graph, start_node: NodeId, atlas: &SpriteAtlas) -> GameResult<Self> {
let mut textures = [None, None, None, None];
let mut stopped_textures = [None, None, None, None];
for direction in Direction::DIRECTIONS {
let moving_prefix = match direction { let moving_prefix = match direction {
Direction::Up => "pacman/up", Direction::Up => "pacman/up",
Direction::Down => "pacman/down", Direction::Down => "pacman/down",
@@ -33,34 +84,33 @@ impl Pacman {
Direction::Right => "pacman/right", Direction::Right => "pacman/right",
}; };
let moving_tiles = vec![ let moving_tiles = vec![
SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_a.png")).unwrap(), SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_a.png"))
SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png")).unwrap(), .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_a.png"))))?,
SpriteAtlas::get_tile(atlas, "pacman/full.png").unwrap(), SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png"))
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_b.png"))))?,
SpriteAtlas::get_tile(atlas, "pacman/full.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?,
]; ];
let stopped_tiles = vec![SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png")).unwrap()]; let stopped_tiles = vec![SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png"))
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_b.png"))))?];
textures.insert( textures[direction.as_usize()] =
direction, Some(AnimatedTexture::new(moving_tiles, 0.08).map_err(|e| GameError::Texture(TextureError::Animated(e)))?);
AnimatedTexture::new(moving_tiles, 0.08).expect("Invalid frame duration"), stopped_textures[direction.as_usize()] =
); Some(AnimatedTexture::new(stopped_tiles, 0.1).map_err(|e| GameError::Texture(TextureError::Animated(e)))?);
stopped_textures.insert(
direction,
AnimatedTexture::new(stopped_tiles, 0.1).expect("Invalid frame duration"),
);
} }
Self { Ok(Self {
traverser: Traverser::new(graph, start_node, Direction::Left, &can_pacman_traverse), traverser: Traverser::new(graph, start_node, Direction::Left, &can_pacman_traverse),
texture: DirectionalAnimatedTexture::new(textures, stopped_textures), texture: DirectionalAnimatedTexture::new(textures, stopped_textures),
} })
}
pub fn tick(&mut self, dt: f32, graph: &Graph) {
self.traverser.advance(graph, dt * 60.0 * 1.125, &can_pacman_traverse);
self.texture.tick(dt);
} }
/// Handles keyboard input to change Pac-Man's direction.
///
/// Maps arrow keys to directions and queues the direction change
/// for the next valid intersection.
pub fn handle_key(&mut self, keycode: Keycode) { pub fn handle_key(&mut self, keycode: Keycode) {
let direction = match keycode { let direction = match keycode {
Keycode::Up => Some(Direction::Up), Keycode::Up => Some(Direction::Up),
@@ -74,245 +124,4 @@ impl Pacman {
self.traverser.set_next_direction(direction); self.traverser.set_next_direction(direction);
} }
} }
fn get_pixel_pos(&self, graph: &Graph) -> Vec2 {
match self.traverser.position {
Position::AtNode(node_id) => graph.get_node(node_id).unwrap().position,
Position::BetweenNodes { from, to, traversed } => {
let from_pos = graph.get_node(from).unwrap().position;
let to_pos = graph.get_node(to).unwrap().position;
from_pos.lerp(to_pos, traversed / from_pos.distance(to_pos))
}
}
}
pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, graph: &Graph) {
let pixel_pos = self.get_pixel_pos(graph).round().as_ivec2() + BOARD_PIXEL_OFFSET.as_ivec2();
let dest = centered_with_size(pixel_pos, UVec2::new(16, 16));
let is_stopped = self.traverser.position.is_stopped();
if is_stopped {
self.texture
.render_stopped(canvas, atlas, dest, self.traverser.direction)
.unwrap();
} else {
self.texture.render(canvas, atlas, dest, self.traverser.direction).unwrap();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entity::graph::{Graph, Node};
use crate::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas};
use sdl2::keyboard::Keycode;
use std::collections::HashMap;
fn create_test_graph() -> Graph {
let mut graph = Graph::new();
let node1 = graph.add_node(Node {
position: glam::Vec2::new(0.0, 0.0),
});
let node2 = graph.add_node(Node {
position: glam::Vec2::new(16.0, 0.0),
});
let node3 = graph.add_node(Node {
position: glam::Vec2::new(0.0, 16.0),
});
graph.connect(node1, node2, false, None, Direction::Right).unwrap();
graph.connect(node1, node3, false, None, Direction::Down).unwrap();
graph
}
fn create_test_atlas() -> SpriteAtlas {
// Create a minimal test atlas with required tiles
let mut frames = HashMap::new();
frames.insert(
"pacman/up_a.png".to_string(),
MapperFrame {
x: 0,
y: 0,
width: 16,
height: 16,
},
);
frames.insert(
"pacman/up_b.png".to_string(),
MapperFrame {
x: 16,
y: 0,
width: 16,
height: 16,
},
);
frames.insert(
"pacman/down_a.png".to_string(),
MapperFrame {
x: 32,
y: 0,
width: 16,
height: 16,
},
);
frames.insert(
"pacman/down_b.png".to_string(),
MapperFrame {
x: 48,
y: 0,
width: 16,
height: 16,
},
);
frames.insert(
"pacman/left_a.png".to_string(),
MapperFrame {
x: 64,
y: 0,
width: 16,
height: 16,
},
);
frames.insert(
"pacman/left_b.png".to_string(),
MapperFrame {
x: 80,
y: 0,
width: 16,
height: 16,
},
);
frames.insert(
"pacman/right_a.png".to_string(),
MapperFrame {
x: 96,
y: 0,
width: 16,
height: 16,
},
);
frames.insert(
"pacman/right_b.png".to_string(),
MapperFrame {
x: 112,
y: 0,
width: 16,
height: 16,
},
);
frames.insert(
"pacman/full.png".to_string(),
MapperFrame {
x: 128,
y: 0,
width: 16,
height: 16,
},
);
let mapper = AtlasMapper { frames };
// Create a dummy texture (we won't actually render, just test the logic)
let dummy_texture = unsafe { std::mem::zeroed() };
SpriteAtlas::new(dummy_texture, mapper)
}
#[test]
fn test_pacman_new() {
let graph = create_test_graph();
let atlas = create_test_atlas();
let pacman = Pacman::new(&graph, 0, &atlas);
assert_eq!(pacman.traverser.direction, Direction::Left);
assert!(matches!(pacman.traverser.position, crate::entity::graph::Position::AtNode(0)));
}
#[test]
fn test_handle_key_valid_directions() {
let graph = create_test_graph();
let atlas = create_test_atlas();
let mut pacman = Pacman::new(&graph, 0, &atlas);
// Test that direction keys are handled correctly
// The traverser might consume next_direction immediately, so we check the actual direction
pacman.handle_key(Keycode::Up);
// Check that the direction was set (either in next_direction or current direction)
assert!(pacman.traverser.next_direction.is_some() || pacman.traverser.direction == Direction::Up);
pacman.handle_key(Keycode::Down);
assert!(pacman.traverser.next_direction.is_some() || pacman.traverser.direction == Direction::Down);
pacman.handle_key(Keycode::Left);
assert!(pacman.traverser.next_direction.is_some() || pacman.traverser.direction == Direction::Left);
pacman.handle_key(Keycode::Right);
assert!(pacman.traverser.next_direction.is_some() || pacman.traverser.direction == Direction::Right);
}
#[test]
fn test_handle_key_invalid_direction() {
let graph = create_test_graph();
let atlas = create_test_atlas();
let mut pacman = Pacman::new(&graph, 0, &atlas);
let original_direction = pacman.traverser.direction;
let original_next_direction = pacman.traverser.next_direction;
// Test invalid key
pacman.handle_key(Keycode::Space);
// Should not change direction
assert_eq!(pacman.traverser.direction, original_direction);
assert_eq!(pacman.traverser.next_direction, original_next_direction);
}
#[test]
fn test_get_pixel_pos_at_node() {
let graph = create_test_graph();
let atlas = create_test_atlas();
let pacman = Pacman::new(&graph, 0, &atlas);
let pos = pacman.get_pixel_pos(&graph);
assert_eq!(pos, glam::Vec2::new(0.0, 0.0));
}
#[test]
fn test_get_pixel_pos_between_nodes() {
let graph = create_test_graph();
let atlas = create_test_atlas();
let mut pacman = Pacman::new(&graph, 0, &atlas);
// Move pacman between nodes - need to advance with a larger distance to ensure movement
pacman.traverser.advance(&graph, 5.0, &can_pacman_traverse); // Larger advance to ensure movement
let pos = pacman.get_pixel_pos(&graph);
// Should be between (0,0) and (16,0), but not exactly at (8,0) due to advance distance
assert!(pos.x >= 0.0 && pos.x <= 16.0);
assert_eq!(pos.y, 0.0);
}
#[test]
fn test_tick_updates_texture() {
let graph = create_test_graph();
let atlas = create_test_atlas();
let mut pacman = Pacman::new(&graph, 0, &atlas);
// Test that tick doesn't panic
pacman.tick(0.016, &graph); // 60 FPS frame time
}
#[test]
fn test_pacman_initial_direction() {
let graph = create_test_graph();
let atlas = create_test_atlas();
let pacman = Pacman::new(&graph, 0, &atlas);
// Pacman should start with the initial direction (Left)
assert_eq!(pacman.traverser.direction, Direction::Left);
// The next_direction might be consumed immediately when the traverser starts moving
// So we just check that the direction is set correctly
assert_eq!(pacman.traverser.direction, Direction::Left);
}
} }

114
src/entity/trait.rs Normal file
View File

@@ -0,0 +1,114 @@
//! Entity trait for common movement and rendering functionality.
//!
//! This module defines a trait that captures the shared behavior between
//! different game entities like Ghosts and Pac-Man, including movement,
//! rendering, and position calculations.
use glam::Vec2;
use sdl2::render::{Canvas, RenderTarget};
use crate::entity::direction::Direction;
use crate::entity::graph::{Edge, Graph, NodeId};
use crate::entity::traversal::{Position, Traverser};
use crate::error::{EntityError, GameError, GameResult, TextureError};
use crate::texture::directional::DirectionalAnimatedTexture;
use crate::texture::sprite::SpriteAtlas;
/// Trait defining common functionality for game entities that move through the graph.
///
/// This trait provides a unified interface for entities that:
/// - Move through the game graph using a traverser
/// - Render using directional animated textures
/// - Have position calculations and movement speed
#[allow(dead_code)]
pub trait Entity {
/// Returns a reference to the entity's traverser for movement control.
fn traverser(&self) -> &Traverser;
/// Returns a mutable reference to the entity's traverser for movement control.
fn traverser_mut(&mut self) -> &mut Traverser;
/// Returns a reference to the entity's directional animated texture.
fn texture(&self) -> &DirectionalAnimatedTexture;
/// Returns a mutable reference to the entity's directional animated texture.
fn texture_mut(&mut self) -> &mut DirectionalAnimatedTexture;
/// Returns the movement speed multiplier for this entity.
fn speed(&self) -> f32;
/// Determines if this entity can traverse a given edge.
fn can_traverse(&self, edge: Edge) -> bool;
/// Updates the entity's position and animation state.
///
/// This method advances movement through the graph and updates texture animation.
fn tick(&mut self, dt: f32, graph: &Graph);
/// Calculates the current pixel position in the game world.
///
/// Converts the graph position to screen coordinates, accounting for
/// the board offset and centering the sprite.
fn get_pixel_pos(&self, graph: &Graph) -> GameResult<Vec2> {
let pos = match self.traverser().position {
Position::AtNode(node_id) => {
let node = graph.get_node(node_id).ok_or(EntityError::NodeNotFound(node_id))?;
node.position
}
Position::BetweenNodes { from, to, traversed } => {
let from_node = graph.get_node(from).ok_or(EntityError::NodeNotFound(from))?;
let to_node = graph.get_node(to).ok_or(EntityError::NodeNotFound(to))?;
let edge = graph.find_edge(from, to).ok_or(EntityError::EdgeNotFound { from, to })?;
from_node.position + (to_node.position - from_node.position) * (traversed / edge.distance)
}
};
Ok(Vec2::new(
pos.x + crate::constants::BOARD_PIXEL_OFFSET.x as f32,
pos.y + crate::constants::BOARD_PIXEL_OFFSET.y as f32,
))
}
/// Returns the current node ID that the entity is at or moving towards.
///
/// If the entity is at a node, returns that node ID.
/// If the entity is between nodes, returns the node it's moving towards.
fn current_node_id(&self) -> NodeId {
match self.traverser().position {
Position::AtNode(node_id) => node_id,
Position::BetweenNodes { to, .. } => to,
}
}
/// Sets the next direction for the entity to take.
///
/// The direction is buffered and will be applied at the next opportunity,
/// typically when the entity reaches a new node.
fn set_next_direction(&mut self, direction: Direction) {
self.traverser_mut().set_next_direction(direction);
}
/// Renders the entity at its current position.
///
/// Draws the appropriate directional sprite based on the entity's
/// current movement state and direction.
fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, graph: &Graph) -> GameResult<()> {
let pixel_pos = self.get_pixel_pos(graph)?;
let dest = crate::helpers::centered_with_size(
glam::IVec2::new(pixel_pos.x as i32, pixel_pos.y as i32),
glam::UVec2::new(16, 16),
);
if self.traverser().position.is_stopped() {
self.texture()
.render_stopped(canvas, atlas, dest, self.traverser().direction)
.map_err(|e| GameError::Texture(TextureError::RenderFailed(e.to_string())))?;
} else {
self.texture()
.render(canvas, atlas, dest, self.traverser().direction)
.map_err(|e| GameError::Texture(TextureError::RenderFailed(e.to_string())))?;
}
Ok(())
}
}

229
src/entity/traversal.rs Normal file
View File

@@ -0,0 +1,229 @@
use tracing::error;
use crate::error::GameResult;
use super::direction::Direction;
use super::graph::{Edge, Graph, NodeId};
/// Represents the current position of an entity traversing the graph.
///
/// This enum allows for precise tracking of whether an entity is exactly at a node
/// or moving along an edge between two nodes.
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum Position {
/// The traverser is located exactly at a node.
AtNode(NodeId),
/// The traverser is on an edge between two nodes.
BetweenNodes {
from: NodeId,
to: NodeId,
/// The floating-point distance traversed along the edge from the `from` node.
traversed: f32,
},
}
#[allow(dead_code)]
impl Position {
/// Returns `true` if the position is exactly at a node.
pub fn is_at_node(&self) -> bool {
matches!(self, Position::AtNode(_))
}
/// Returns the `NodeId` of the current or most recently departed node.
#[allow(clippy::wrong_self_convention)]
pub fn from_node_id(&self) -> NodeId {
match self {
Position::AtNode(id) => *id,
Position::BetweenNodes { from, .. } => *from,
}
}
/// Returns the `NodeId` of the destination node, if currently on an edge.
#[allow(clippy::wrong_self_convention)]
pub fn to_node_id(&self) -> Option<NodeId> {
match self {
Position::AtNode(_) => None,
Position::BetweenNodes { to, .. } => Some(*to),
}
}
/// Returns `true` if the traverser is stopped at a node.
pub fn is_stopped(&self) -> bool {
matches!(self, Position::AtNode(_))
}
}
/// Manages an entity's movement through the graph.
///
/// A `Traverser` encapsulates the state of an entity's position and direction,
/// providing a way to advance along the graph's paths based on a given distance.
/// It also handles direction changes, buffering the next intended direction.
pub struct Traverser {
/// The current position of the traverser in the graph.
pub position: Position,
/// The current direction of movement.
pub direction: Direction,
/// Buffered direction change with remaining frame count for timing.
///
/// The `u8` value represents the number of frames remaining before
/// the buffered direction expires. This allows for responsive controls
/// by storing direction changes for a limited time.
pub next_direction: Option<(Direction, u8)>,
}
impl Traverser {
/// Creates a new traverser starting at the given node ID.
///
/// The traverser will immediately attempt to start moving in the initial direction.
pub fn new<F>(graph: &Graph, start_node: NodeId, initial_direction: Direction, can_traverse: &F) -> Self
where
F: Fn(Edge) -> bool,
{
let mut traverser = Traverser {
position: Position::AtNode(start_node),
direction: initial_direction,
next_direction: Some((initial_direction, 1)),
};
// This will kickstart the traverser into motion
if let Err(e) = traverser.advance(graph, 0.0, can_traverse) {
error!("Traverser initialization error: {}", e);
}
traverser
}
/// Sets the next direction for the traverser to take.
///
/// The direction is buffered and will be applied at the next opportunity,
/// typically when the traverser reaches a new node. This allows for responsive
/// controls, as the new direction is stored for a limited time.
pub fn set_next_direction(&mut self, new_direction: Direction) {
if self.direction != new_direction {
self.next_direction = Some((new_direction, 30));
}
}
/// Advances the traverser along the graph by a specified distance.
///
/// This method updates the traverser's position based on its current state
/// and the distance to travel.
///
/// - If at a node, it checks for a buffered direction to start moving.
/// - If between nodes, it moves along the current edge.
/// - If it reaches a node, it attempts to transition to a new edge based on
/// the buffered direction or by continuing straight.
/// - If no valid move is possible, it stops at the node.
///
/// Returns an error if the movement is invalid (e.g., trying to move in an impossible direction).
pub fn advance<F>(&mut self, graph: &Graph, distance: f32, can_traverse: &F) -> GameResult<()>
where
F: Fn(Edge) -> bool,
{
// Decrement the remaining frames for the next direction
if let Some((direction, remaining)) = self.next_direction {
if remaining > 0 {
self.next_direction = Some((direction, remaining - 1));
} else {
self.next_direction = None;
}
}
match self.position {
Position::AtNode(node_id) => {
// We're not moving, but a buffered direction is available.
if let Some((next_direction, _)) = self.next_direction {
if let Some(edge) = graph.find_edge_in_direction(node_id, next_direction) {
if can_traverse(edge) {
// Start moving in that direction
self.position = Position::BetweenNodes {
from: node_id,
to: edge.target,
traversed: distance.max(0.0),
};
self.direction = next_direction;
} else {
return Err(crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement(
format!(
"Cannot traverse edge from {} to {} in direction {:?}",
node_id, edge.target, next_direction
),
)));
}
} else {
return Err(crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement(
format!("No edge found in direction {:?} from node {}", next_direction, node_id),
)));
}
self.next_direction = None; // Consume the buffered direction regardless of whether we started moving with it
}
}
Position::BetweenNodes { from, to, traversed } => {
// There is no point in any of the next logic if we don't travel at all
if distance <= 0.0 {
return Ok(());
}
let edge = graph.find_edge(from, to).ok_or_else(|| {
crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement(format!(
"Inconsistent state: Traverser is on a non-existent edge from {} to {}.",
from, to
)))
})?;
let new_traversed = traversed + distance;
if new_traversed < edge.distance {
// Still on the same edge, just update the distance.
self.position = Position::BetweenNodes {
from,
to,
traversed: new_traversed,
};
} else {
let overflow = new_traversed - edge.distance;
let mut moved = false;
// If we buffered a direction, try to find an edge in that direction
if let Some((next_dir, _)) = self.next_direction {
if let Some(edge) = graph.find_edge_in_direction(to, next_dir) {
if can_traverse(edge) {
self.position = Position::BetweenNodes {
from: to,
to: edge.target,
traversed: overflow,
};
self.direction = next_dir; // Remember our new direction
self.next_direction = None; // Consume the buffered direction
moved = true;
}
}
}
// If we didn't move, try to continue in the current direction
if !moved {
if let Some(edge) = graph.find_edge_in_direction(to, self.direction) {
if can_traverse(edge) {
self.position = Position::BetweenNodes {
from: to,
to: edge.target,
traversed: overflow,
};
} else {
self.position = Position::AtNode(to);
self.next_direction = None;
}
} else {
self.position = Position::AtNode(to);
self.next_direction = None;
}
}
}
}
}
Ok(())
}
}

156
src/error.rs Normal file
View File

@@ -0,0 +1,156 @@
//! Centralized error types for the Pac-Man game.
//!
//! This module defines all error types used throughout the application,
//! providing a consistent error handling approach.
use thiserror::Error;
/// Main error type for the Pac-Man game.
///
/// This is the primary error type that should be used in public APIs.
/// It can represent any error that can occur during game operation.
#[derive(Error, Debug)]
pub enum GameError {
#[error("Asset error: {0}")]
Asset(#[from] crate::asset::AssetError),
#[error("Platform error: {0}")]
Platform(#[from] crate::platform::PlatformError),
#[error("Map parsing error: {0}")]
MapParse(#[from] crate::map::parser::ParseError),
#[error("Map error: {0}")]
Map(#[from] MapError),
#[error("Texture error: {0}")]
Texture(#[from] TextureError),
#[error("Entity error: {0}")]
Entity(#[from] EntityError),
#[error("Game state error: {0}")]
GameState(#[from] GameStateError),
#[error("SDL error: {0}")]
Sdl(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("Invalid state: {0}")]
InvalidState(String),
#[error("Resource not found: {0}")]
NotFound(String),
#[error("Configuration error: {0}")]
Config(String),
}
/// Errors related to texture operations.
#[derive(Error, Debug)]
pub enum TextureError {
#[error("Animated texture error: {0}")]
Animated(#[from] crate::texture::animated::AnimatedTextureError),
#[error("Failed to load texture: {0}")]
LoadFailed(String),
#[error("Texture not found in atlas: {0}")]
AtlasTileNotFound(String),
#[error("Invalid texture format: {0}")]
InvalidFormat(String),
#[error("Rendering failed: {0}")]
RenderFailed(String),
}
/// Errors related to entity operations.
#[derive(Error, Debug)]
pub enum EntityError {
#[error("Node not found in graph: {0}")]
NodeNotFound(usize),
#[error("Edge not found: from {from} to {to}")]
EdgeNotFound { from: usize, to: usize },
#[error("Invalid movement: {0}")]
InvalidMovement(String),
#[error("Pathfinding failed: {0}")]
PathfindingFailed(String),
}
/// Errors related to game state operations.
#[derive(Error, Debug)]
pub enum GameStateError {}
/// Errors related to map operations.
#[derive(Error, Debug)]
pub enum MapError {
#[error("Node not found: {0}")]
NodeNotFound(usize),
#[error("Invalid map configuration: {0}")]
InvalidConfig(String),
}
/// Result type for game operations.
pub type GameResult<T> = Result<T, GameError>;
/// Helper trait for converting other error types to GameError.
pub trait IntoGameError<T> {
#[allow(dead_code)]
fn into_game_error(self) -> GameResult<T>;
}
impl<T, E> IntoGameError<T> for Result<T, E>
where
E: std::error::Error + Send + Sync + 'static,
{
fn into_game_error(self) -> GameResult<T> {
self.map_err(|e| GameError::InvalidState(e.to_string()))
}
}
/// Helper trait for converting Option to GameResult with a custom error.
pub trait OptionExt<T> {
#[allow(dead_code)]
fn ok_or_game_error<F>(self, f: F) -> GameResult<T>
where
F: FnOnce() -> GameError;
}
impl<T> OptionExt<T> for Option<T> {
fn ok_or_game_error<F>(self, f: F) -> GameResult<T>
where
F: FnOnce() -> GameError,
{
self.ok_or_else(f)
}
}
/// Helper trait for converting Result to GameResult with context.
pub trait ResultExt<T, E> {
#[allow(dead_code)]
fn with_context<F>(self, f: F) -> GameResult<T>
where
F: FnOnce(&E) -> GameError;
}
impl<T, E> ResultExt<T, E> for Result<T, E>
where
E: std::error::Error + Send + Sync + 'static,
{
fn with_context<F>(self, f: F) -> GameResult<T>
where
F: FnOnce(&E) -> GameError,
{
self.map_err(|e| f(&e))
}
}

View File

@@ -1,7 +1,7 @@
//! This module contains the main game logic and state. //! This module contains the main game logic and state.
use anyhow::Result; use glam::{UVec2, Vec2};
use glam::UVec2; use rand::{rngs::SmallRng, Rng, SeedableRng};
use sdl2::{ use sdl2::{
image::LoadTexture, image::LoadTexture,
keyboard::Keycode, keyboard::Keycode,
@@ -10,11 +10,17 @@ use sdl2::{
video::WindowContext, video::WindowContext,
}; };
use crate::error::{EntityError, GameError, GameResult, TextureError};
use crate::{ use crate::{
asset::{get_asset_bytes, Asset}, asset::{get_asset_bytes, Asset},
audio::Audio, audio::Audio,
constants::RAW_BOARD, constants::{CELL_SIZE, RAW_BOARD},
entity::pacman::Pacman, entity::{
ghost::{Ghost, GhostType},
pacman::Pacman,
r#trait::Entity,
},
map::Map, map::Map,
texture::{ texture::{
sprite::{self, AtlasMapper, AtlasTile, SpriteAtlas}, sprite::{self, AtlasMapper, AtlasTile, SpriteAtlas},
@@ -30,6 +36,7 @@ pub struct Game {
pub score: u32, pub score: u32,
pub map: Map, pub map: Map,
pub pacman: Pacman, pub pacman: Pacman,
pub ghosts: Vec<Ghost>,
pub debug_mode: bool, pub debug_mode: bool,
// Rendering resources // Rendering resources
@@ -46,43 +53,68 @@ impl Game {
texture_creator: &TextureCreator<WindowContext>, texture_creator: &TextureCreator<WindowContext>,
_ttf_context: &sdl2::ttf::Sdl2TtfContext, _ttf_context: &sdl2::ttf::Sdl2TtfContext,
_audio_subsystem: &sdl2::AudioSubsystem, _audio_subsystem: &sdl2::AudioSubsystem,
) -> Game { ) -> GameResult<Game> {
let map = Map::new(RAW_BOARD); let map = Map::new(RAW_BOARD)?;
let pacman_start_pos = map.find_starting_position(0).unwrap(); let pacman_start_pos = map
.find_starting_position(0)
.ok_or_else(|| GameError::NotFound("Pac-Man starting position".to_string()))?;
let pacman_start_node = *map let pacman_start_node = *map
.grid_to_node .grid_to_node
.get(&glam::IVec2::new(pacman_start_pos.x as i32, pacman_start_pos.y as i32)) .get(&glam::IVec2::new(pacman_start_pos.x as i32, pacman_start_pos.y as i32))
.expect("Pac-Man starting position not found in graph"); .ok_or_else(|| GameError::NotFound("Pac-Man starting position not found in graph".to_string()))?;
let atlas_bytes = get_asset_bytes(Asset::Atlas).expect("Failed to load asset"); let atlas_bytes = get_asset_bytes(Asset::Atlas)?;
let atlas_texture = unsafe { let atlas_texture = unsafe {
let texture = texture_creator let texture = texture_creator.load_texture_bytes(&atlas_bytes).map_err(|e| {
.load_texture_bytes(&atlas_bytes) if e.to_string().contains("format") || e.to_string().contains("unsupported") {
.expect("Could not load atlas texture from asset API"); GameError::Texture(TextureError::InvalidFormat(format!("Unsupported texture format: {e}")))
} else {
GameError::Texture(TextureError::LoadFailed(e.to_string()))
}
})?;
sprite::texture_to_static(texture) sprite::texture_to_static(texture)
}; };
let atlas_json = get_asset_bytes(Asset::AtlasJson).expect("Failed to load asset"); let atlas_json = get_asset_bytes(Asset::AtlasJson)?;
let atlas_mapper: AtlasMapper = serde_json::from_slice(&atlas_json).expect("Could not parse atlas JSON"); let atlas_mapper: AtlasMapper = serde_json::from_slice(&atlas_json)?;
let atlas = SpriteAtlas::new(atlas_texture, atlas_mapper); let atlas = SpriteAtlas::new(atlas_texture, atlas_mapper);
let mut map_texture = SpriteAtlas::get_tile(&atlas, "maze/full.png").expect("Failed to load map tile"); let mut map_texture = SpriteAtlas::get_tile(&atlas, "maze/full.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("maze/full.png".to_string())))?;
map_texture.color = Some(Color::RGB(0x20, 0x20, 0xf9)); map_texture.color = Some(Color::RGB(0x20, 0x20, 0xf9));
let text_texture = TextTexture::new(1.0); let text_texture = TextTexture::new(1.0);
let audio = Audio::new(); let audio = Audio::new();
let pacman = Pacman::new(&map.graph, pacman_start_node, &atlas); let pacman = Pacman::new(&map.graph, pacman_start_node, &atlas)?;
Game { // Create ghosts at random positions
let mut ghosts = Vec::new();
let ghost_types = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde];
let mut rng = SmallRng::from_os_rng();
if map.graph.node_count() == 0 {
return Err(GameError::Config("Game map has no nodes - invalid configuration".to_string()));
// TODO: This is a bug, we should handle this better
}
for &ghost_type in &ghost_types {
// Find a random node for the ghost to start at
let random_node = rng.random_range(0..map.graph.node_count());
let ghost = Ghost::new(&map.graph, random_node, ghost_type, &atlas)?;
ghosts.push(ghost);
}
Ok(Game {
score: 0, score: 0,
map, map,
pacman, pacman,
ghosts,
debug_mode: false, debug_mode: false,
map_texture, map_texture,
text_texture, text_texture,
audio, audio,
atlas, atlas,
} })
} }
pub fn keyboard_event(&mut self, keycode: Keycode) { pub fn keyboard_event(&mut self, keycode: Keycode) {
@@ -91,34 +123,160 @@ impl Game {
if keycode == Keycode::M { if keycode == Keycode::M {
self.audio.set_mute(!self.audio.is_muted()); self.audio.set_mute(!self.audio.is_muted());
} }
if keycode == Keycode::R {
if let Err(e) = self.reset_game_state() {
tracing::error!("Failed to reset game state: {}", e);
}
}
} }
pub fn tick(&mut self, dt: f32) { /// Resets the game state, randomizing ghost positions and resetting Pac-Man
self.pacman.tick(dt, &self.map.graph); fn reset_game_state(&mut self) -> GameResult<()> {
} // Reset Pac-Man to starting position
let pacman_start_pos = self
.map
.find_starting_position(0)
.ok_or_else(|| GameError::NotFound("Pac-Man starting position".to_string()))?;
let pacman_start_node = *self
.map
.grid_to_node
.get(&glam::IVec2::new(pacman_start_pos.x as i32, pacman_start_pos.y as i32))
.ok_or_else(|| GameError::NotFound("Pac-Man starting position not found in graph".to_string()))?;
pub fn draw<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>, backbuffer: &mut Texture) -> Result<()> { self.pacman = Pacman::new(&self.map.graph, pacman_start_node, &self.atlas)?;
canvas.with_texture_canvas(backbuffer, |canvas| {
canvas.set_draw_color(Color::BLACK); // Randomize ghost positions
canvas.clear(); let ghost_types = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde];
self.map.render(canvas, &mut self.atlas, &mut self.map_texture); let mut rng = SmallRng::from_os_rng();
self.pacman.render(canvas, &mut self.atlas, &self.map.graph);
})?; for (i, ghost) in self.ghosts.iter_mut().enumerate() {
let random_node = rng.random_range(0..self.map.graph.node_count());
*ghost = Ghost::new(&self.map.graph, random_node, ghost_types[i], &self.atlas)?;
}
Ok(()) Ok(())
} }
pub fn present_backbuffer<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>, backbuffer: &Texture) -> Result<()> { pub fn tick(&mut self, dt: f32) {
canvas.copy(backbuffer, None, None).map_err(anyhow::Error::msg)?; self.pacman.tick(dt, &self.map.graph);
// Update all ghosts
for ghost in &mut self.ghosts {
ghost.tick(dt, &self.map.graph);
}
}
pub fn draw<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>, backbuffer: &mut Texture) -> GameResult<()> {
canvas
.with_texture_canvas(backbuffer, |canvas| {
canvas.set_draw_color(Color::BLACK);
canvas.clear();
self.map.render(canvas, &mut self.atlas, &mut self.map_texture);
// Render all ghosts
for ghost in &self.ghosts {
if let Err(e) = ghost.render(canvas, &mut self.atlas, &self.map.graph) {
tracing::error!("Failed to render ghost: {}", e);
}
}
if let Err(e) = self.pacman.render(canvas, &mut self.atlas, &self.map.graph) {
tracing::error!("Failed to render pacman: {}", e);
}
})
.map_err(|e| GameError::Sdl(e.to_string()))?;
Ok(())
}
pub fn present_backbuffer<T: RenderTarget>(
&mut self,
canvas: &mut Canvas<T>,
backbuffer: &Texture,
cursor_pos: glam::Vec2,
) -> GameResult<()> {
canvas
.copy(backbuffer, None, None)
.map_err(|e| GameError::Sdl(e.to_string()))?;
if self.debug_mode { if self.debug_mode {
self.map.debug_render_nodes(canvas); if let Err(e) = self
.map
.debug_render_with_cursor(canvas, &mut self.text_texture, &mut self.atlas, cursor_pos)
{
tracing::error!("Failed to render debug cursor: {}", e);
}
self.render_pathfinding_debug(canvas)?;
} }
self.draw_hud(canvas)?; self.draw_hud(canvas)?;
canvas.present(); canvas.present();
Ok(()) Ok(())
} }
fn draw_hud<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>) -> Result<()> { /// Renders pathfinding debug lines from each ghost to Pac-Man.
///
/// Each ghost's path is drawn in its respective color with a small offset
/// to prevent overlapping lines.
fn render_pathfinding_debug<T: RenderTarget>(&self, canvas: &mut Canvas<T>) -> GameResult<()> {
let pacman_node = self.pacman.current_node_id();
for ghost in self.ghosts.iter() {
if let Ok(path) = ghost.calculate_path_to_target(&self.map.graph, pacman_node) {
if path.len() < 2 {
continue; // Skip if path is too short
}
// Set the ghost's color
canvas.set_draw_color(ghost.debug_color());
// Calculate offset based on ghost index to prevent overlapping lines
// let offset = (i as f32) * 2.0 - 3.0; // Offset range: -3.0 to 3.0
// Calculate a consistent offset direction for the entire path
// let first_node = self.map.graph.get_node(path[0]).unwrap();
// let last_node = self.map.graph.get_node(path[path.len() - 1]).unwrap();
// Use the overall direction from start to end to determine the perpendicular offset
let offset = match ghost.ghost_type {
GhostType::Blinky => Vec2::new(0.25, 0.5),
GhostType::Pinky => Vec2::new(-0.25, -0.25),
GhostType::Inky => Vec2::new(0.5, -0.5),
GhostType::Clyde => Vec2::new(-0.5, 0.25),
} * 5.0;
// Calculate offset positions for all nodes using the same perpendicular direction
let mut offset_positions = Vec::new();
for &node_id in &path {
let node = self
.map
.graph
.get_node(node_id)
.ok_or(GameError::Entity(EntityError::NodeNotFound(node_id)))?;
let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
offset_positions.push(pos + offset);
}
// Draw lines between the offset positions
for window in offset_positions.windows(2) {
if let (Some(from), Some(to)) = (window.first(), window.get(1)) {
// Skip if the distance is too far (used for preventing lines between tunnel portals)
if from.distance_squared(*to) > (CELL_SIZE * 16).pow(2) as f32 {
continue;
}
// Draw the line
canvas
.draw_line((from.x as i32, from.y as i32), (to.x as i32, to.y as i32))
.map_err(|e| GameError::Sdl(e.to_string()))?;
}
}
}
}
Ok(())
}
fn draw_hud<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>) -> GameResult<()> {
let lives = 3; let lives = 3;
let score_text = format!("{:02}", self.score); let score_text = format!("{:02}", self.score);
let x_offset = 4; let x_offset = 4;
@@ -126,18 +284,22 @@ impl Game {
let lives_offset = 3; let lives_offset = 3;
let score_offset = 7 - (score_text.len() as i32); let score_offset = 7 - (score_text.len() as i32);
self.text_texture.set_scale(1.0); self.text_texture.set_scale(1.0);
let _ = self.text_texture.render( if let Err(e) = self.text_texture.render(
canvas, canvas,
&mut self.atlas, &mut self.atlas,
&format!("{lives}UP HIGH SCORE "), &format!("{lives}UP HIGH SCORE "),
UVec2::new(8 * lives_offset as u32 + x_offset, y_offset), UVec2::new(8 * lives_offset as u32 + x_offset, y_offset),
); ) {
let _ = self.text_texture.render( tracing::error!("Failed to render HUD text: {}", e);
}
if let Err(e) = self.text_texture.render(
canvas, canvas,
&mut self.atlas, &mut self.atlas,
&score_text, &score_text,
UVec2::new(8 * score_offset as u32 + x_offset, 8 + y_offset), UVec2::new(8 * score_offset as u32 + x_offset, 8 + y_offset),
); ) {
tracing::error!("Failed to render score text: {}", e);
}
// Display FPS information in top-left corner // Display FPS information in top-left corner
// let fps_text = format!("FPS: {:.1} (1s) / {:.1} (10s)", self.fps_1s, self.fps_10s); // let fps_text = format!("FPS: {:.1} (1s) / {:.1} (10s)", self.fps_1s, self.fps_10s);
@@ -152,233 +314,3 @@ impl Game {
Ok(()) Ok(())
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use sdl2::keyboard::Keycode;
use sdl2::pixels::Color;
fn create_test_game() -> Game {
// Create a minimal test game without SDL dependencies
// This is a simplified version for testing basic logic
let map = Map::new(RAW_BOARD);
let pacman_start_pos = map.find_starting_position(0).unwrap();
let pacman_start_node = *map
.grid_to_node
.get(&glam::IVec2::new(pacman_start_pos.x as i32, pacman_start_pos.y as i32))
.expect("Pac-Man starting position not found in graph");
// Create a dummy atlas for testing
let mut mapper = std::collections::HashMap::new();
mapper.insert(
"pacman/up_a.png".to_string(),
crate::texture::sprite::MapperFrame {
x: 0,
y: 0,
width: 16,
height: 16,
},
);
mapper.insert(
"pacman/up_b.png".to_string(),
crate::texture::sprite::MapperFrame {
x: 16,
y: 0,
width: 16,
height: 16,
},
);
mapper.insert(
"pacman/down_a.png".to_string(),
crate::texture::sprite::MapperFrame {
x: 32,
y: 0,
width: 16,
height: 16,
},
);
mapper.insert(
"pacman/down_b.png".to_string(),
crate::texture::sprite::MapperFrame {
x: 48,
y: 0,
width: 16,
height: 16,
},
);
mapper.insert(
"pacman/left_a.png".to_string(),
crate::texture::sprite::MapperFrame {
x: 64,
y: 0,
width: 16,
height: 16,
},
);
mapper.insert(
"pacman/left_b.png".to_string(),
crate::texture::sprite::MapperFrame {
x: 80,
y: 0,
width: 16,
height: 16,
},
);
mapper.insert(
"pacman/right_a.png".to_string(),
crate::texture::sprite::MapperFrame {
x: 96,
y: 0,
width: 16,
height: 16,
},
);
mapper.insert(
"pacman/right_b.png".to_string(),
crate::texture::sprite::MapperFrame {
x: 112,
y: 0,
width: 16,
height: 16,
},
);
mapper.insert(
"pacman/full.png".to_string(),
crate::texture::sprite::MapperFrame {
x: 128,
y: 0,
width: 16,
height: 16,
},
);
mapper.insert(
"maze/full.png".to_string(),
crate::texture::sprite::MapperFrame {
x: 0,
y: 0,
width: 224,
height: 248,
},
);
let atlas_mapper = crate::texture::sprite::AtlasMapper { frames: mapper };
let dummy_texture = unsafe { std::mem::zeroed() };
let atlas = crate::texture::sprite::SpriteAtlas::new(dummy_texture, atlas_mapper);
let mut map_texture = crate::texture::sprite::SpriteAtlas::get_tile(&atlas, "maze/full.png").unwrap();
map_texture.color = Some(Color::RGB(0x20, 0x20, 0xf9));
let text_texture = TextTexture::new(1.0);
let audio = Audio::new();
let pacman = Pacman::new(&map.graph, pacman_start_node, &atlas);
Game {
score: 0,
map,
pacman,
debug_mode: false,
map_texture,
text_texture,
audio,
atlas,
}
}
#[test]
fn test_game_keyboard_event_direction_keys() {
let mut game = create_test_game();
// Test that direction keys are handled
game.keyboard_event(Keycode::Up);
game.keyboard_event(Keycode::Down);
game.keyboard_event(Keycode::Left);
game.keyboard_event(Keycode::Right);
// Should not panic
assert!(true);
}
#[test]
fn test_game_keyboard_event_mute_toggle() {
let mut game = create_test_game();
let initial_mute_state = game.audio.is_muted();
// Toggle mute
game.keyboard_event(Keycode::M);
// Mute state should have changed
assert_eq!(game.audio.is_muted(), !initial_mute_state);
// Toggle again
game.keyboard_event(Keycode::M);
// Should be back to original state
assert_eq!(game.audio.is_muted(), initial_mute_state);
}
#[test]
fn test_game_tick() {
let mut game = create_test_game();
// Test that tick doesn't panic
game.tick(0.016); // 60 FPS frame time
assert!(true);
}
#[test]
fn test_game_initial_state() {
let game = create_test_game();
assert_eq!(game.score, 0);
assert!(!game.debug_mode);
assert!(game.map.graph.node_count() > 0);
}
#[test]
fn test_game_debug_mode_toggle() {
let mut game = create_test_game();
assert!(!game.debug_mode);
// Toggle debug mode (this would normally be done via Space key in the app)
game.debug_mode = !game.debug_mode;
assert!(game.debug_mode);
}
#[test]
fn test_game_score_increment() {
let mut game = create_test_game();
let initial_score = game.score;
game.score += 10;
assert_eq!(game.score, initial_score + 10);
}
#[test]
fn test_game_pacman_initialization() {
let game = create_test_game();
// Check that Pac-Man was initialized
assert_eq!(game.pacman.traverser.direction, crate::entity::direction::Direction::Left);
// The traverser might start moving immediately, so we just check the direction
assert_eq!(game.pacman.traverser.direction, crate::entity::direction::Direction::Left);
}
#[test]
fn test_game_map_initialization() {
let game = create_test_game();
// Check that map was initialized
assert!(game.map.graph.node_count() > 0);
assert!(!game.map.grid_to_node.is_empty());
// Check that Pac-Man's starting position exists
let pacman_pos = game.map.find_starting_position(0);
assert!(pacman_pos.is_some());
}
}

View File

@@ -2,50 +2,9 @@ use glam::{IVec2, UVec2};
use sdl2::rect::Rect; use sdl2::rect::Rect;
pub fn centered_with_size(pixel_pos: IVec2, size: UVec2) -> Rect { pub fn centered_with_size(pixel_pos: IVec2, size: UVec2) -> Rect {
Rect::new( // Ensure the position doesn't cause integer overflow when centering
pixel_pos.x - size.x as i32 / 2, let x = pixel_pos.x.saturating_sub(size.x as i32 / 2);
pixel_pos.y - size.y as i32 / 2, let y = pixel_pos.y.saturating_sub(size.y as i32 / 2);
size.x,
size.y, Rect::new(x, y, size.x, size.y)
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_centered_with_size_basic() {
let rect = centered_with_size(IVec2::new(100, 100), UVec2::new(50, 30));
assert_eq!(rect.origin(), (75, 85));
assert_eq!(rect.size(), (50, 30));
}
#[test]
fn test_centered_with_size_odd_dimensions() {
let rect = centered_with_size(IVec2::new(50, 50), UVec2::new(51, 31));
assert_eq!(rect.origin(), (25, 35));
assert_eq!(rect.size(), (51, 31));
}
#[test]
fn test_centered_with_size_zero_position() {
let rect = centered_with_size(IVec2::new(0, 0), UVec2::new(100, 100));
assert_eq!(rect.origin(), (-50, -50));
assert_eq!(rect.size(), (100, 100));
}
#[test]
fn test_centered_with_size_negative_position() {
let rect = centered_with_size(IVec2::new(-100, -50), UVec2::new(80, 40));
assert_eq!(rect.origin(), (-140, -70));
assert_eq!(rect.size(), (80, 40));
}
#[test]
fn test_centered_with_size_large_dimensions() {
let rect = centered_with_size(IVec2::new(1000, 1000), UVec2::new(1000, 1000));
assert_eq!(rect.origin(), (500, 500));
assert_eq!(rect.size(), (1000, 1000));
}
} }

View File

@@ -4,9 +4,10 @@ pub mod app;
pub mod asset; pub mod asset;
pub mod audio; pub mod audio;
pub mod constants; pub mod constants;
pub mod emscripten;
pub mod entity; pub mod entity;
pub mod error;
pub mod game; pub mod game;
pub mod helpers; pub mod helpers;
pub mod map; pub mod map;
pub mod platform;
pub mod texture; pub mod texture;

View File

@@ -5,59 +5,17 @@ use tracing::info;
use tracing_error::ErrorLayer; use tracing_error::ErrorLayer;
use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::layer::SubscriberExt;
#[cfg(windows)]
use winapi::{
shared::ntdef::NULL,
um::{
fileapi::{CreateFileA, OPEN_EXISTING},
handleapi::INVALID_HANDLE_VALUE,
processenv::SetStdHandle,
winbase::{STD_ERROR_HANDLE, STD_OUTPUT_HANDLE},
wincon::{AttachConsole, GetConsoleWindow},
winnt::{FILE_SHARE_READ, FILE_SHARE_WRITE, GENERIC_READ, GENERIC_WRITE},
},
};
/// Attaches the process to the parent console on Windows.
///
/// This allows the application to print to the console when run from a terminal,
/// which is useful for debugging purposes. If the application is not run from a
/// terminal, this function does nothing.
#[cfg(windows)]
unsafe fn attach_console() {
if !std::ptr::eq(GetConsoleWindow(), std::ptr::null_mut()) {
return;
}
if AttachConsole(winapi::um::wincon::ATTACH_PARENT_PROCESS) != 0 {
let handle = CreateFileA(
c"CONOUT$".as_ptr(),
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
std::ptr::null_mut(),
OPEN_EXISTING,
0,
NULL,
);
if handle != INVALID_HANDLE_VALUE {
SetStdHandle(STD_OUTPUT_HANDLE, handle);
SetStdHandle(STD_ERROR_HANDLE, handle);
}
}
// Do NOT call AllocConsole here - we don't want a console when launched from Explorer
}
mod app; mod app;
mod asset; mod asset;
mod audio; mod audio;
mod constants; mod constants;
#[cfg(target_os = "emscripten")]
mod emscripten;
mod entity; mod entity;
mod error;
mod game; mod game;
mod helpers; mod helpers;
mod map; mod map;
mod platform;
mod texture; mod texture;
/// The main entry point of the application. /// The main entry point of the application.
@@ -65,12 +23,6 @@ mod texture;
/// This function initializes SDL, the window, the game state, and then enters /// This function initializes SDL, the window, the game state, and then enters
/// the main game loop. /// the main game loop.
pub fn main() { pub fn main() {
// Attaches the console on Windows for debugging purposes.
#[cfg(windows)]
unsafe {
attach_console();
}
// Setup tracing // Setup tracing
let subscriber = tracing_subscriber::fmt() let subscriber = tracing_subscriber::fmt()
.with_ansi(cfg!(not(target_os = "emscripten"))) .with_ansi(cfg!(not(target_os = "emscripten")))

View File

@@ -1,7 +1,7 @@
//! Map construction and building functionality. //! Map construction and building functionality.
use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE}; use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE};
use crate::entity::direction::{Direction, DIRECTIONS}; use crate::entity::direction::Direction;
use crate::entity::graph::{EdgePermissions, Graph, Node, NodeId}; use crate::entity::graph::{EdgePermissions, Graph, Node, NodeId};
use crate::map::parser::MapTileParser; use crate::map::parser::MapTileParser;
use crate::map::render::MapRenderer; use crate::map::render::MapRenderer;
@@ -11,6 +11,8 @@ use sdl2::render::{Canvas, RenderTarget};
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, VecDeque};
use tracing::debug; use tracing::debug;
use crate::error::{GameResult, MapError};
/// The starting positions of the entities in the game. /// The starting positions of the entities in the game.
#[allow(dead_code)] #[allow(dead_code)]
pub struct NodePositions { pub struct NodePositions {
@@ -47,8 +49,8 @@ impl Map {
/// ///
/// This function will panic if the board layout contains unknown characters or if /// This function will panic if the board layout contains unknown characters or if
/// the house door is not defined by exactly two '=' characters. /// the house door is not defined by exactly two '=' characters.
pub fn new(raw_board: [&str; BOARD_CELL_SIZE.y as usize]) -> Map { pub fn new(raw_board: [&str; BOARD_CELL_SIZE.y as usize]) -> GameResult<Map> {
let parsed_map = MapTileParser::parse_board(raw_board).expect("Failed to parse board layout"); let parsed_map = MapTileParser::parse_board(raw_board)?;
let map = parsed_map.tiles; let map = parsed_map.tiles;
let house_door = parsed_map.house_door; let house_door = parsed_map.house_door;
@@ -61,7 +63,8 @@ impl Map {
let cell_offset = Vec2::splat(CELL_SIZE as f32 / 2.0); let cell_offset = Vec2::splat(CELL_SIZE as f32 / 2.0);
// Find a starting point for the graph generation, preferably Pac-Man's position. // Find a starting point for the graph generation, preferably Pac-Man's position.
let start_pos = pacman_start.expect("Pac-Man's starting position not found"); let start_pos =
pacman_start.ok_or_else(|| MapError::InvalidConfig("Pac-Man's starting position not found".to_string()))?;
// Add the starting position to the graph/queue // Add the starting position to the graph/queue
let mut queue = VecDeque::new(); let mut queue = VecDeque::new();
@@ -75,7 +78,7 @@ impl Map {
// Iterate over the queue, adding nodes to the graph and connecting them to their neighbors // Iterate over the queue, adding nodes to the graph and connecting them to their neighbors
while let Some(source_position) = queue.pop_front() { while let Some(source_position) = queue.pop_front() {
for &dir in DIRECTIONS.iter() { for dir in Direction::DIRECTIONS {
let new_position = source_position + dir.as_ivec2(); let new_position = source_position + dir.as_ivec2();
// Skip if the new position is out of bounds // Skip if the new position is out of bounds
@@ -114,14 +117,14 @@ impl Map {
// Connect the new node to the source node // Connect the new node to the source node
graph graph
.connect(*source_node_id, new_node_id, false, None, dir) .connect(*source_node_id, new_node_id, false, None, dir)
.expect("Failed to add edge"); .map_err(|e| MapError::InvalidConfig(format!("Failed to add edge: {e}")))?;
} }
} }
} }
// While most nodes are already connected to their neighbors, some may not be, so we need to connect them // While most nodes are already connected to their neighbors, some may not be, so we need to connect them
for (grid_pos, &node_id) in &grid_to_node { for (grid_pos, &node_id) in &grid_to_node {
for dir in DIRECTIONS { for dir in Direction::DIRECTIONS {
// If the node doesn't have an edge in this direction, look for a neighbor in that direction // If the node doesn't have an edge in this direction, look for a neighbor in that direction
if graph.adjacency_list[node_id].get(dir).is_none() { if graph.adjacency_list[node_id].get(dir).is_none() {
let neighbor = grid_pos + dir.as_ivec2(); let neighbor = grid_pos + dir.as_ivec2();
@@ -129,7 +132,7 @@ impl Map {
if let Some(&neighbor_id) = grid_to_node.get(&neighbor) { if let Some(&neighbor_id) = grid_to_node.get(&neighbor) {
graph graph
.connect(node_id, neighbor_id, false, None, dir) .connect(node_id, neighbor_id, false, None, dir)
.expect("Failed to add edge"); .map_err(|e| MapError::InvalidConfig(format!("Failed to add edge: {e}")))?;
} }
} }
} }
@@ -137,7 +140,7 @@ impl Map {
// Build house structure // Build house structure
let (house_entrance_node_id, left_center_node_id, center_center_node_id, right_center_node_id) = 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); Self::build_house(&mut graph, &grid_to_node, &house_door)?;
let start_positions = NodePositions { let start_positions = NodePositions {
pacman: grid_to_node[&start_pos], pacman: grid_to_node[&start_pos],
@@ -148,15 +151,15 @@ impl Map {
}; };
// Build tunnel connections // Build tunnel connections
Self::build_tunnels(&mut graph, &grid_to_node, &tunnel_ends); Self::build_tunnels(&mut graph, &grid_to_node, &tunnel_ends)?;
Map { Ok(Map {
current: map, current: map,
graph, graph,
grid_to_node, grid_to_node,
start_positions, start_positions,
pacman_start, pacman_start,
} })
} }
/// Finds the starting position for a given entity ID. /// Finds the starting position for a given entity ID.
@@ -184,13 +187,18 @@ impl Map {
MapRenderer::render_map(canvas, atlas, map_texture); MapRenderer::render_map(canvas, atlas, map_texture);
} }
/// Renders a debug visualization of the navigation graph. /// Renders a debug visualization with cursor-based highlighting.
/// ///
/// This function is intended for development and debugging purposes. It draws the /// This function provides interactive debugging by highlighting the nearest node
/// nodes and edges of the graph on top of the map, allowing for visual /// to the cursor, showing its ID, and highlighting its connections.
/// inspection of the navigation paths. pub fn debug_render_with_cursor<T: RenderTarget>(
pub fn debug_render_nodes<T: RenderTarget>(&self, canvas: &mut Canvas<T>) { &self,
MapRenderer::debug_render_nodes(&self.graph, canvas); canvas: &mut Canvas<T>,
text_renderer: &mut crate::texture::text::TextTexture,
atlas: &mut SpriteAtlas,
cursor_pos: glam::Vec2,
) -> GameResult<()> {
MapRenderer::debug_render_with_cursor(&self.graph, canvas, text_renderer, atlas, cursor_pos)
} }
/// Builds the house structure in the graph. /// Builds the house structure in the graph.
@@ -198,21 +206,32 @@ impl Map {
graph: &mut Graph, graph: &mut Graph,
grid_to_node: &HashMap<IVec2, NodeId>, grid_to_node: &HashMap<IVec2, NodeId>,
house_door: &[Option<IVec2>; 2], house_door: &[Option<IVec2>; 2],
) -> (usize, usize, usize, usize) { ) -> GameResult<(usize, usize, usize, usize)> {
// Calculate the position of the house entrance node // Calculate the position of the house entrance node
let (house_entrance_node_id, house_entrance_node_position) = { let (house_entrance_node_id, house_entrance_node_position) = {
// Translate the grid positions to the actual node ids // Translate the grid positions to the actual node ids
let left_node = grid_to_node let left_node = grid_to_node
.get(&(house_door[0].expect("First house door position not acquired") + Direction::Left.as_ivec2())) .get(
.expect("Left house door node not found"); &(house_door[0]
.ok_or_else(|| MapError::InvalidConfig("First house door position not acquired".to_string()))?
+ Direction::Left.as_ivec2()),
)
.ok_or_else(|| MapError::InvalidConfig("Left house door node not found".to_string()))?;
let right_node = grid_to_node let right_node = grid_to_node
.get(&(house_door[1].expect("Second house door position not acquired") + Direction::Right.as_ivec2())) .get(
.expect("Right house door node not found"); &(house_door[1]
.ok_or_else(|| MapError::InvalidConfig("Second house door position not acquired".to_string()))?
+ Direction::Right.as_ivec2()),
)
.ok_or_else(|| MapError::InvalidConfig("Right house door node not found".to_string()))?;
// Calculate the position of the house node // Calculate the position of the house node
let (node_id, node_position) = { let (node_id, node_position) = {
let left_pos = graph.get_node(*left_node).unwrap().position; let left_pos = graph.get_node(*left_node).ok_or(MapError::NodeNotFound(*left_node))?.position;
let right_pos = graph.get_node(*right_node).unwrap().position; let right_pos = graph
.get_node(*right_node)
.ok_or(MapError::NodeNotFound(*right_node))?
.position;
let house_node = graph.add_node(Node { let house_node = graph.add_node(Node {
position: left_pos.lerp(right_pos, 0.5), position: left_pos.lerp(right_pos, 0.5),
}); });
@@ -222,16 +241,16 @@ impl Map {
// Connect the house door to the left and right nodes // Connect the house door to the left and right nodes
graph graph
.connect(node_id, *left_node, true, None, Direction::Left) .connect(node_id, *left_node, true, None, Direction::Left)
.expect("Failed to connect house door to left node"); .map_err(|e| MapError::InvalidConfig(format!("Failed to connect house door to left node: {e}")))?;
graph graph
.connect(node_id, *right_node, true, None, Direction::Right) .connect(node_id, *right_node, true, None, Direction::Right)
.expect("Failed to connect house door to right node"); .map_err(|e| MapError::InvalidConfig(format!("Failed to connect house door to right node: {e}")))?;
(node_id, node_position) (node_id, node_position)
}; };
// A helper function to help create the various 'lines' of nodes within the house // A helper function to help create the various 'lines' of nodes within the house
let create_house_line = |graph: &mut Graph, center_pos: Vec2| -> (NodeId, NodeId) { let create_house_line = |graph: &mut Graph, center_pos: Vec2| -> GameResult<(NodeId, NodeId)> {
// Place the nodes at, above, and below the center position // Place the nodes at, above, and below the center position
let center_node_id = graph.add_node(Node { position: center_pos }); let center_node_id = graph.add_node(Node { position: center_pos });
let top_node_id = graph.add_node(Node { let top_node_id = graph.add_node(Node {
@@ -244,12 +263,12 @@ impl Map {
// Connect the center node to the top and bottom nodes // Connect the center node to the top and bottom nodes
graph graph
.connect(center_node_id, top_node_id, false, None, Direction::Up) .connect(center_node_id, top_node_id, false, None, Direction::Up)
.expect("Failed to connect house line to left node"); .map_err(|e| MapError::InvalidConfig(format!("Failed to connect house line to top node: {e}")))?;
graph graph
.connect(center_node_id, bottom_node_id, false, None, Direction::Down) .connect(center_node_id, bottom_node_id, false, None, Direction::Down)
.expect("Failed to connect house line to right node"); .map_err(|e| MapError::InvalidConfig(format!("Failed to connect house line to bottom node: {e}")))?;
(center_node_id, top_node_id) Ok((center_node_id, top_node_id))
}; };
// Calculate the position of the center line's center node // Calculate the position of the center line's center node
@@ -257,7 +276,7 @@ impl Map {
house_entrance_node_position + (Direction::Down.as_ivec2() * (3 * CELL_SIZE as i32)).as_vec2(); house_entrance_node_position + (Direction::Down.as_ivec2() * (3 * CELL_SIZE as i32)).as_vec2();
// Create the center line // Create the center line
let (center_center_node_id, center_top_node_id) = create_house_line(graph, center_line_center_position); let (center_center_node_id, center_top_node_id) = create_house_line(graph, center_line_center_position)?;
// Create a ghost-only, two-way connection for the house door. // Create a ghost-only, two-way connection for the house door.
// This prevents Pac-Man from entering or exiting through the door. // This prevents Pac-Man from entering or exiting through the door.
@@ -270,7 +289,7 @@ impl Map {
Direction::Down, Direction::Down,
EdgePermissions::GhostsOnly, EdgePermissions::GhostsOnly,
) )
.expect("Failed to create ghost-only entrance to house"); .map_err(|e| MapError::InvalidConfig(format!("Failed to create ghost-only entrance to house: {e}")))?;
graph graph
.add_edge( .add_edge(
@@ -281,52 +300,57 @@ impl Map {
Direction::Up, Direction::Up,
EdgePermissions::GhostsOnly, EdgePermissions::GhostsOnly,
) )
.expect("Failed to create ghost-only exit from house"); .map_err(|e| MapError::InvalidConfig(format!("Failed to create ghost-only exit from house: {e}")))?;
// Create the left line // Create the left line
let (left_center_node_id, _) = create_house_line( let (left_center_node_id, _) = create_house_line(
graph, graph,
center_line_center_position + (Direction::Left.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(), center_line_center_position + (Direction::Left.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
); )?;
// Create the right line // Create the right line
let (right_center_node_id, _) = create_house_line( let (right_center_node_id, _) = create_house_line(
graph, graph,
center_line_center_position + (Direction::Right.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(), center_line_center_position + (Direction::Right.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
); )?;
debug!("Left center node id: {left_center_node_id}"); debug!("Left center node id: {left_center_node_id}");
// Connect the center line to the left and right lines // Connect the center line to the left and right lines
graph graph
.connect(center_center_node_id, left_center_node_id, false, None, Direction::Left) .connect(center_center_node_id, left_center_node_id, false, None, Direction::Left)
.expect("Failed to connect house entrance to left top line"); .map_err(|e| MapError::InvalidConfig(format!("Failed to connect house entrance to left top line: {e}")))?;
graph graph
.connect(center_center_node_id, right_center_node_id, false, None, Direction::Right) .connect(center_center_node_id, right_center_node_id, false, None, Direction::Right)
.expect("Failed to connect house entrance to right top line"); .map_err(|e| MapError::InvalidConfig(format!("Failed to connect house entrance to right top line: {e}")))?;
debug!("House entrance node id: {house_entrance_node_id}"); debug!("House entrance node id: {house_entrance_node_id}");
( Ok((
house_entrance_node_id, house_entrance_node_id,
left_center_node_id, left_center_node_id,
center_center_node_id, center_center_node_id,
right_center_node_id, right_center_node_id,
) ))
} }
/// Builds the tunnel connections in the graph. /// Builds the tunnel connections in the graph.
fn build_tunnels(graph: &mut Graph, grid_to_node: &HashMap<IVec2, NodeId>, tunnel_ends: &[Option<IVec2>; 2]) { fn build_tunnels(
graph: &mut Graph,
grid_to_node: &HashMap<IVec2, NodeId>,
tunnel_ends: &[Option<IVec2>; 2],
) -> GameResult<()> {
// Create the hidden tunnel nodes // Create the hidden tunnel nodes
let left_tunnel_hidden_node_id = { let left_tunnel_hidden_node_id = {
let left_tunnel_entrance_node_id = grid_to_node[&tunnel_ends[0].expect("Left tunnel end not found")]; let left_tunnel_entrance_node_id =
grid_to_node[&tunnel_ends[0].ok_or_else(|| MapError::InvalidConfig("Left tunnel end not found".to_string()))?];
let left_tunnel_entrance_node = graph let left_tunnel_entrance_node = graph
.get_node(left_tunnel_entrance_node_id) .get_node(left_tunnel_entrance_node_id)
.expect("Left tunnel entrance node not found"); .ok_or_else(|| MapError::InvalidConfig("Left tunnel entrance node not found".to_string()))?;
graph graph
.connect_node( .add_connected(
left_tunnel_entrance_node_id, left_tunnel_entrance_node_id,
Direction::Left, Direction::Left,
Node { Node {
@@ -334,18 +358,24 @@ impl Map {
+ (Direction::Left.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(), + (Direction::Left.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
}, },
) )
.expect("Failed to connect left tunnel entrance to left tunnel hidden node") .map_err(|e| {
MapError::InvalidConfig(format!(
"Failed to connect left tunnel entrance to left tunnel hidden node: {}",
e
))
})?
}; };
// Create the right tunnel nodes // Create the right tunnel nodes
let right_tunnel_hidden_node_id = { let right_tunnel_hidden_node_id = {
let right_tunnel_entrance_node_id = grid_to_node[&tunnel_ends[1].expect("Right tunnel end not found")]; let right_tunnel_entrance_node_id =
grid_to_node[&tunnel_ends[1].ok_or_else(|| MapError::InvalidConfig("Right tunnel end not found".to_string()))?];
let right_tunnel_entrance_node = graph let right_tunnel_entrance_node = graph
.get_node(right_tunnel_entrance_node_id) .get_node(right_tunnel_entrance_node_id)
.expect("Right tunnel entrance node not found"); .ok_or_else(|| MapError::InvalidConfig("Right tunnel entrance node not found".to_string()))?;
graph graph
.connect_node( .add_connected(
right_tunnel_entrance_node_id, right_tunnel_entrance_node_id,
Direction::Right, Direction::Right,
Node { Node {
@@ -353,7 +383,12 @@ impl Map {
+ (Direction::Right.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(), + (Direction::Right.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
}, },
) )
.expect("Failed to connect right tunnel entrance to right tunnel hidden node") .map_err(|e| {
MapError::InvalidConfig(format!(
"Failed to connect right tunnel entrance to right tunnel hidden node: {}",
e
))
})?
}; };
// Connect the left tunnel hidden node to the right tunnel hidden node // Connect the left tunnel hidden node to the right tunnel hidden node
@@ -365,165 +400,13 @@ impl Map {
Some(0.0), Some(0.0),
Direction::Left, Direction::Left,
) )
.expect("Failed to connect left tunnel hidden node to right tunnel hidden node"); .map_err(|e| {
} MapError::InvalidConfig(format!(
} "Failed to connect left tunnel hidden node to right tunnel hidden node: {}",
e
#[cfg(test)] ))
mod tests { })?;
use super::*;
use crate::constants::{BOARD_CELL_SIZE, CELL_SIZE}; Ok(())
use glam::{IVec2, Vec2};
fn create_minimal_test_board() -> [&'static str; BOARD_CELL_SIZE.y as usize] {
let mut board = [""; BOARD_CELL_SIZE.y as usize];
// Create a minimal valid board with house doors
board[0] = "############################";
board[1] = "#............##............#";
board[2] = "#.####.#####.##.#####.####.#";
board[3] = "#o####.#####.##.#####.####o#";
board[4] = "#.####.#####.##.#####.####.#";
board[5] = "#..........................#";
board[6] = "#.####.##.########.##.####.#";
board[7] = "#.####.##.########.##.####.#";
board[8] = "#......##....##....##......#";
board[9] = "######.##### ## #####.######";
board[10] = " #.##### ## #####.# ";
board[11] = " #.## == ##.# ";
board[12] = " #.## ######## ##.# ";
board[13] = "######.## ######## ##.######";
board[14] = "T . ######## . T";
board[15] = "######.## ######## ##.######";
board[16] = " #.## ######## ##.# ";
board[17] = " #.## ##.# ";
board[18] = " #.## ######## ##.# ";
board[19] = "######.## ######## ##.######";
board[20] = "#............##............#";
board[21] = "#.####.#####.##.#####.####.#";
board[22] = "#.####.#####.##.#####.####.#";
board[23] = "#o..##.......X .......##..o#";
board[24] = "###.##.##.########.##.##.###";
board[25] = "###.##.##.########.##.##.###";
board[26] = "#......##....##....##......#";
board[27] = "#.##########.##.##########.#";
board[28] = "#.##########.##.##########.#";
board[29] = "#..........................#";
board[30] = "############################";
board
}
#[test]
fn test_map_new() {
let board = create_minimal_test_board();
let map = Map::new(board);
assert!(map.graph.node_count() > 0);
assert!(!map.grid_to_node.is_empty());
}
#[test]
fn test_find_starting_position_pacman() {
let board = create_minimal_test_board();
let map = Map::new(board);
let pacman_pos = map.find_starting_position(0);
assert!(pacman_pos.is_some());
let pos = pacman_pos.unwrap();
// Pacman should be found somewhere in the board
assert!(pos.x < BOARD_CELL_SIZE.x);
assert!(pos.y < BOARD_CELL_SIZE.y);
}
#[test]
fn test_find_starting_position_ghost() {
let board = create_minimal_test_board();
let map = Map::new(board);
// Test for ghost 1 (might not exist in this board)
let ghost_pos = map.find_starting_position(1);
// Ghost 1 might not exist, so this could be None
if let Some(pos) = ghost_pos {
assert!(pos.x < BOARD_CELL_SIZE.x);
assert!(pos.y < BOARD_CELL_SIZE.y);
}
}
#[test]
fn test_find_starting_position_nonexistent() {
let board = create_minimal_test_board();
let map = Map::new(board);
let pos = map.find_starting_position(99); // Non-existent entity
assert!(pos.is_none());
}
#[test]
fn test_map_graph_construction() {
let board = create_minimal_test_board();
let map = Map::new(board);
// Check that nodes were created
assert!(map.graph.node_count() > 0);
// Check that grid_to_node mapping was created
assert!(!map.grid_to_node.is_empty());
// Check that some connections were made
let mut has_connections = false;
for intersection in &map.graph.adjacency_list {
if intersection.edges().next().is_some() {
has_connections = true;
break;
}
}
assert!(has_connections);
}
#[test]
fn test_map_grid_to_node_mapping() {
let board = create_minimal_test_board();
let map = Map::new(board);
// Check that Pac-Man's position is mapped
let pacman_pos = map.find_starting_position(0).unwrap();
let grid_pos = IVec2::new(pacman_pos.x as i32, pacman_pos.y as i32);
assert!(map.grid_to_node.contains_key(&grid_pos));
let node_id = map.grid_to_node[&grid_pos];
assert!(map.graph.get_node(node_id).is_some());
}
#[test]
fn test_map_node_positions() {
let board = create_minimal_test_board();
let map = Map::new(board);
// Check that node positions are correctly calculated
for (grid_pos, &node_id) in &map.grid_to_node {
let node = map.graph.get_node(node_id).unwrap();
let expected_pos = Vec2::new((grid_pos.x * CELL_SIZE as i32) as f32, (grid_pos.y * CELL_SIZE as i32) as f32)
+ Vec2::splat(CELL_SIZE as f32 / 2.0);
assert_eq!(node.position, expected_pos);
}
}
#[test]
fn test_map_adjacent_connections() {
let board = create_minimal_test_board();
let map = Map::new(board);
// Check that adjacent walkable tiles are connected
// Find any node that has connections
let mut found_connected_node = false;
for &node_id in map.grid_to_node.values() {
let intersection = &map.graph.adjacency_list[node_id];
if intersection.edges().next().is_some() {
found_connected_node = true;
break;
}
}
assert!(found_connected_node);
} }
} }

View File

@@ -11,6 +11,8 @@ pub enum ParseError {
UnknownCharacter(char), UnknownCharacter(char),
#[error("House door must have exactly 2 positions, found {0}")] #[error("House door must have exactly 2 positions, found {0}")]
InvalidHouseDoorCount(usize), InvalidHouseDoorCount(usize),
#[error("Map parsing failed: {0}")]
ParseFailed(String),
} }
/// Represents the parsed data from a raw board layout. /// Represents the parsed data from a raw board layout.
@@ -67,6 +69,25 @@ impl MapTileParser {
/// Returns an error if the board contains unknown characters or if the house door /// Returns an error if the board contains unknown characters or if the house door
/// is not properly defined by exactly two '=' characters. /// is not properly defined by exactly two '=' characters.
pub fn parse_board(raw_board: [&str; BOARD_CELL_SIZE.y as usize]) -> Result<ParsedMap, ParseError> { pub fn parse_board(raw_board: [&str; BOARD_CELL_SIZE.y as usize]) -> Result<ParsedMap, ParseError> {
// Validate board dimensions
if raw_board.len() != BOARD_CELL_SIZE.y as usize {
return Err(ParseError::ParseFailed(format!(
"Invalid board height: expected {}, got {}",
BOARD_CELL_SIZE.y,
raw_board.len()
)));
}
for (i, line) in raw_board.iter().enumerate() {
if line.len() != BOARD_CELL_SIZE.x as usize {
return Err(ParseError::ParseFailed(format!(
"Invalid board width at line {}: expected {}, got {}",
i,
BOARD_CELL_SIZE.x,
line.len()
)));
}
}
let mut tiles = [[MapTile::Empty; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize]; let mut tiles = [[MapTile::Empty; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize];
let mut house_door = [None; 2]; let mut house_door = [None; 2];
let mut tunnel_ends = [None; 2]; let mut tunnel_ends = [None; 2];
@@ -118,56 +139,3 @@ impl MapTileParser {
}) })
} }
} }
#[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('X').unwrap(), MapTile::Empty));
assert!(matches!(MapTileParser::parse_character('=').unwrap(), MapTile::Wall));
// Test invalid character
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();
// 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());
// Verify we found Pac-Man's starting position
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')));
}
}

View File

@@ -1,10 +1,14 @@
//! Map rendering functionality. //! Map rendering functionality.
use crate::texture::sprite::{AtlasTile, SpriteAtlas}; use crate::texture::sprite::{AtlasTile, SpriteAtlas};
use crate::texture::text::TextTexture;
use glam::Vec2;
use sdl2::pixels::Color; use sdl2::pixels::Color;
use sdl2::rect::{Point, Rect}; use sdl2::rect::{Point, Rect};
use sdl2::render::{Canvas, RenderTarget}; use sdl2::render::{Canvas, RenderTarget};
use crate::error::{EntityError, GameError, GameResult};
/// Handles rendering operations for the map. /// Handles rendering operations for the map.
pub struct MapRenderer; pub struct MapRenderer;
@@ -20,125 +24,115 @@ impl MapRenderer {
crate::constants::BOARD_PIXEL_SIZE.x, crate::constants::BOARD_PIXEL_SIZE.x,
crate::constants::BOARD_PIXEL_SIZE.y, crate::constants::BOARD_PIXEL_SIZE.y,
); );
let _ = map_texture.render(canvas, atlas, dest); if let Err(e) = map_texture.render(canvas, atlas, dest) {
} tracing::error!("Failed to render map: {}", e);
/// Renders a debug visualization of the navigation graph.
///
/// This function is intended for development and debugging purposes. It draws the
/// nodes and edges of the graph on top of the map, allowing for visual
/// inspection of the navigation paths.
pub fn debug_render_nodes<T: RenderTarget>(graph: &crate::entity::graph::Graph, canvas: &mut Canvas<T>) {
for i in 0..graph.node_count() {
let node = graph.get_node(i).unwrap();
let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
// Draw connections
canvas.set_draw_color(Color::BLUE);
for edge in graph.adjacency_list[i].edges() {
let end_pos = graph.get_node(edge.target).unwrap().position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
canvas
.draw_line((pos.x as i32, pos.y as i32), (end_pos.x as i32, end_pos.y as i32))
.unwrap();
}
// Draw node
// let color = if pacman.position.from_node_idx() == i.into() {
// Color::GREEN
// } else if let Some(to_idx) = pacman.position.to_node_idx() {
// if to_idx == i.into() {
// Color::CYAN
// } else {
// Color::RED
// }
// } else {
// Color::RED
// };
canvas.set_draw_color(Color::GREEN);
canvas
.fill_rect(Rect::new(0, 0, 3, 3).centered_on(Point::new(pos.x as i32, pos.y as i32)))
.unwrap();
// Draw node index
// text.render(canvas, atlas, &i.to_string(), pos.as_uvec2()).unwrap();
} }
} }
}
#[cfg(test)] /// Renders a debug visualization with cursor-based highlighting.
mod tests { ///
use super::*; /// This function provides interactive debugging by highlighting the nearest node
use crate::entity::graph::{Graph, Node}; /// to the cursor, showing its ID, and highlighting its connections.
use crate::texture::sprite::{AtlasMapper, MapperFrame}; pub fn debug_render_with_cursor<T: RenderTarget>(
use std::collections::HashMap; graph: &crate::entity::graph::Graph,
canvas: &mut Canvas<T>,
text_renderer: &mut TextTexture,
atlas: &mut SpriteAtlas,
cursor_pos: Vec2,
) -> GameResult<()> {
// Find the nearest node to the cursor
let nearest_node = Self::find_nearest_node(graph, cursor_pos);
fn create_test_graph() -> Graph { // Draw all connections in blue
let mut graph = Graph::new(); canvas.set_draw_color(Color::RGB(0, 0, 128)); // Dark blue for regular connections
let node1 = graph.add_node(Node { for i in 0..graph.node_count() {
position: glam::Vec2::new(0.0, 0.0), let node = graph.get_node(i).ok_or(GameError::Entity(EntityError::NodeNotFound(i)))?;
}); let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
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 for edge in graph.adjacency_list[i].edges() {
.connect(node1, node2, false, None, crate::entity::direction::Direction::Right) let end_pos = graph
.unwrap(); .get_node(edge.target)
graph .ok_or(GameError::Entity(EntityError::NodeNotFound(edge.target)))?
.connect(node1, node3, false, None, crate::entity::direction::Direction::Down) .position
.unwrap(); + 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))
.map_err(|e| GameError::Sdl(e.to_string()))?;
}
}
graph // Draw all nodes in green
canvas.set_draw_color(Color::RGB(0, 128, 0)); // Dark green for regular nodes
for i in 0..graph.node_count() {
let node = graph.get_node(i).ok_or(GameError::Entity(EntityError::NodeNotFound(i)))?;
let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
canvas
.fill_rect(Rect::new(0, 0, 3, 3).centered_on(Point::new(pos.x as i32, pos.y as i32)))
.map_err(|e| GameError::Sdl(e.to_string()))?;
}
// Highlight connections from the nearest node in bright blue
if let Some(nearest_id) = nearest_node {
let nearest_pos = graph
.get_node(nearest_id)
.ok_or(GameError::Entity(EntityError::NodeNotFound(nearest_id)))?
.position
+ crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
canvas.set_draw_color(Color::RGB(0, 255, 255)); // Bright cyan for highlighted connections
for edge in graph.adjacency_list[nearest_id].edges() {
let end_pos = graph
.get_node(edge.target)
.ok_or(GameError::Entity(EntityError::NodeNotFound(edge.target)))?
.position
+ crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
canvas
.draw_line(
(nearest_pos.x as i32, nearest_pos.y as i32),
(end_pos.x as i32, end_pos.y as i32),
)
.map_err(|e| GameError::Sdl(e.to_string()))?;
}
// Highlight the nearest node in bright green
canvas.set_draw_color(Color::RGB(0, 255, 0)); // Bright green for highlighted node
canvas
.fill_rect(Rect::new(0, 0, 5, 5).centered_on(Point::new(nearest_pos.x as i32, nearest_pos.y as i32)))
.map_err(|e| GameError::Sdl(e.to_string()))?;
// Draw node ID text (small, offset to top right)
text_renderer.set_scale(0.5); // Small text
let id_text = format!("#{nearest_id}");
let text_pos = glam::UVec2::new(
(nearest_pos.x + 4.0) as u32, // Offset to the right
(nearest_pos.y - 6.0) as u32, // Offset to the top
);
if let Err(e) = text_renderer.render(canvas, atlas, &id_text, text_pos) {
tracing::error!("Failed to render node ID text: {}", e);
}
}
Ok(())
} }
fn create_test_atlas() -> SpriteAtlas { /// Finds the nearest node to the given cursor position.
let mut frames = HashMap::new(); pub fn find_nearest_node(graph: &crate::entity::graph::Graph, cursor_pos: Vec2) -> Option<usize> {
frames.insert( let mut nearest_id = None;
"maze/full.png".to_string(), let mut nearest_distance = f32::INFINITY;
MapperFrame {
x: 0,
y: 0,
width: 224,
height: 248,
},
);
let mapper = AtlasMapper { frames };
let dummy_texture = unsafe { std::mem::zeroed() };
SpriteAtlas::new(dummy_texture, mapper)
}
#[test] for i in 0..graph.node_count() {
fn test_render_map_does_not_panic() { if let Some(node) = graph.get_node(i) {
// This test just ensures the function doesn't panic let node_pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
// We can't easily test the actual rendering without SDL context let distance = cursor_pos.distance(node_pos);
let atlas = create_test_atlas();
let _map_texture = SpriteAtlas::get_tile(&atlas, "maze/full.png").unwrap();
// The function should not panic even with dummy data if distance < nearest_distance {
// Note: We can't actually call render_map without a canvas, but we can test the logic nearest_distance = distance;
assert!(true); // Placeholder test nearest_id = Some(i);
} }
}
}
#[test] nearest_id
fn test_debug_render_nodes_does_not_panic() {
// This test just ensures the function doesn't panic
// We can't easily test the actual rendering without SDL context
let _graph = create_test_graph();
// The function should not panic even with dummy data
// Note: We can't actually call debug_render_nodes without a canvas, but we can test the logic
assert!(true); // Placeholder test
}
#[test]
fn test_map_renderer_structure() {
// Test that MapRenderer is a unit struct
let _renderer = MapRenderer;
// This should compile and not panic
assert!(true);
} }
} }

77
src/platform/desktop.rs Normal file
View File

@@ -0,0 +1,77 @@
//! Desktop platform implementation.
use std::borrow::Cow;
use std::time::Duration;
use crate::asset::{Asset, AssetError};
use crate::platform::{Platform, PlatformError};
/// Desktop platform implementation.
pub struct DesktopPlatform;
impl Platform for DesktopPlatform {
fn sleep(&self, duration: Duration) {
spin_sleep::sleep(duration);
}
fn get_time(&self) -> f64 {
std::time::Instant::now().elapsed().as_secs_f64()
}
fn init_console(&self) -> Result<(), PlatformError> {
#[cfg(windows)]
{
unsafe {
use winapi::{
shared::ntdef::NULL,
um::{
fileapi::{CreateFileA, OPEN_EXISTING},
handleapi::INVALID_HANDLE_VALUE,
processenv::SetStdHandle,
winbase::{STD_ERROR_HANDLE, STD_OUTPUT_HANDLE},
wincon::{AttachConsole, GetConsoleWindow},
winnt::{FILE_SHARE_READ, FILE_SHARE_WRITE, GENERIC_READ, GENERIC_WRITE},
},
};
if !std::ptr::eq(GetConsoleWindow(), std::ptr::null_mut()) {
return Ok(());
}
if AttachConsole(winapi::um::wincon::ATTACH_PARENT_PROCESS) != 0 {
let handle = CreateFileA(
c"CONOUT$".as_ptr(),
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
std::ptr::null_mut(),
OPEN_EXISTING,
0,
NULL,
);
if handle != INVALID_HANDLE_VALUE {
SetStdHandle(STD_OUTPUT_HANDLE, handle);
SetStdHandle(STD_ERROR_HANDLE, handle);
}
}
}
}
Ok(())
}
fn get_canvas_size(&self) -> Option<(u32, u32)> {
None // Desktop doesn't need this
}
fn get_asset_bytes(&self, asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
match asset {
Asset::Wav1 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/1.ogg"))),
Asset::Wav2 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/2.ogg"))),
Asset::Wav3 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/3.ogg"))),
Asset::Wav4 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/4.ogg"))),
Asset::Atlas => Ok(Cow::Borrowed(include_bytes!("../../assets/game/atlas.png"))),
Asset::AtlasJson => Ok(Cow::Borrowed(include_bytes!("../../assets/game/atlas.json"))),
}
}
}

View File

@@ -0,0 +1,61 @@
//! Emscripten platform implementation.
use std::borrow::Cow;
use std::time::Duration;
use crate::asset::{Asset, AssetError};
use crate::platform::{Platform, PlatformError};
/// Emscripten platform implementation.
pub struct EmscriptenPlatform;
impl Platform for EmscriptenPlatform {
fn sleep(&self, duration: Duration) {
unsafe {
emscripten_sleep(duration.as_millis() as u32);
}
}
fn get_time(&self) -> f64 {
unsafe { emscripten_get_now() }
}
fn init_console(&self) -> Result<(), PlatformError> {
Ok(()) // No-op for Emscripten
}
fn get_canvas_size(&self) -> Option<(u32, u32)> {
Some(unsafe { get_canvas_size() })
}
fn get_asset_bytes(&self, asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
use sdl2::rwops::RWops;
use std::io::Read;
let path = format!("assets/game/{}", asset.path());
let mut rwops = RWops::from_file(&path, "rb").map_err(|_| AssetError::NotFound(asset.path().to_string()))?;
let len = rwops.len().ok_or_else(|| AssetError::NotFound(asset.path().to_string()))?;
let mut buf = vec![0u8; len];
rwops
.read_exact(&mut buf)
.map_err(|e| AssetError::Io(std::io::Error::other(e)))?;
Ok(Cow::Owned(buf))
}
}
// Emscripten FFI functions
extern "C" {
fn emscripten_get_now() -> f64;
fn emscripten_sleep(ms: u32);
fn emscripten_get_element_css_size(target: *const u8, width: *mut f64, height: *mut f64) -> i32;
}
unsafe fn get_canvas_size() -> (u32, u32) {
let mut width = 0.0;
let mut height = 0.0;
emscripten_get_element_css_size(c"canvas".as_ptr().cast(), &mut width, &mut height);
(width as u32, height as u32)
}

58
src/platform/mod.rs Normal file
View File

@@ -0,0 +1,58 @@
//! Platform abstraction layer for cross-platform functionality.
use std::borrow::Cow;
use std::time::Duration;
use crate::asset::{Asset, AssetError};
pub mod desktop;
pub mod emscripten;
/// Platform abstraction trait that defines cross-platform functionality.
pub trait Platform {
/// Sleep for the specified duration using platform-appropriate method.
fn sleep(&self, duration: Duration);
/// Get the current time in seconds since some reference point.
/// This is available for future use in timing and performance monitoring.
#[allow(dead_code)]
fn get_time(&self) -> f64;
/// Initialize platform-specific console functionality.
fn init_console(&self) -> Result<(), PlatformError>;
/// Get canvas size for platforms that need it (e.g., Emscripten).
/// This is available for future use in responsive design.
#[allow(dead_code)]
fn get_canvas_size(&self) -> Option<(u32, u32)>;
/// Load asset bytes using platform-appropriate method.
fn get_asset_bytes(&self, asset: Asset) -> Result<Cow<'static, [u8]>, AssetError>;
}
/// Platform-specific errors.
#[derive(Debug, thiserror::Error)]
#[allow(dead_code)]
pub enum PlatformError {
#[error("Console initialization failed: {0}")]
ConsoleInit(String),
#[error("Platform-specific error: {0}")]
Other(String),
}
/// Get the current platform implementation.
#[allow(dead_code)]
pub fn get_platform() -> &'static dyn Platform {
static DESKTOP: desktop::DesktopPlatform = desktop::DesktopPlatform;
static EMSCRIPTEN: emscripten::EmscriptenPlatform = emscripten::EmscriptenPlatform;
#[cfg(not(target_os = "emscripten"))]
{
&DESKTOP
}
#[cfg(target_os = "emscripten")]
{
&EMSCRIPTEN
}
}

View File

@@ -74,127 +74,3 @@ impl AnimatedTexture {
self.tiles.len() self.tiles.len()
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use glam::U16Vec2;
use sdl2::pixels::Color;
impl AtlasTile {
fn mock(id: u32) -> Self {
AtlasTile {
pos: U16Vec2::new(0, 0),
size: U16Vec2::new(16, 16),
color: Some(Color::RGB(id as u8, 0, 0)),
}
}
}
#[test]
fn test_new_animated_texture() {
let tiles = vec![AtlasTile::mock(1), AtlasTile::mock(2), AtlasTile::mock(3)];
let texture = AnimatedTexture::new(tiles.clone(), 0.1).unwrap();
assert_eq!(texture.current_frame(), 0);
assert_eq!(texture.time_bank(), 0.0);
assert_eq!(texture.frame_duration(), 0.1);
assert_eq!(texture.tiles_len(), 3);
}
#[test]
fn test_new_animated_texture_zero_duration() {
let tiles = vec![AtlasTile::mock(1), AtlasTile::mock(2)];
let result = AnimatedTexture::new(tiles, 0.0);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), AnimatedTextureError::InvalidFrameDuration(0.0)));
}
#[test]
fn test_new_animated_texture_negative_duration() {
let tiles = vec![AtlasTile::mock(1), AtlasTile::mock(2)];
let result = AnimatedTexture::new(tiles, -0.1);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
AnimatedTextureError::InvalidFrameDuration(-0.1)
));
}
#[test]
fn test_tick_no_frame_change() {
let tiles = vec![AtlasTile::mock(1), AtlasTile::mock(2)];
let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap();
// Tick with less than frame duration
texture.tick(0.05);
assert_eq!(texture.current_frame(), 0);
assert_eq!(texture.time_bank(), 0.05);
}
#[test]
fn test_tick_single_frame_change() {
let tiles = vec![AtlasTile::mock(1), AtlasTile::mock(2)];
let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap();
// Tick with exactly frame duration
texture.tick(0.1);
assert_eq!(texture.current_frame(), 1);
assert_eq!(texture.time_bank(), 0.0);
}
#[test]
fn test_tick_multiple_frame_changes() {
let tiles = vec![AtlasTile::mock(1), AtlasTile::mock(2), AtlasTile::mock(3)];
let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap();
// Tick with 2.5 frame durations
texture.tick(0.25);
assert_eq!(texture.current_frame(), 2);
assert!((texture.time_bank() - 0.05).abs() < 0.001);
}
#[test]
fn test_tick_wrap_around() {
let tiles = vec![AtlasTile::mock(1), AtlasTile::mock(2)];
let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap();
// Advance to last frame
texture.tick(0.1);
assert_eq!(texture.current_frame(), 1);
// Advance again to wrap around
texture.tick(0.1);
assert_eq!(texture.current_frame(), 0);
}
#[test]
fn test_current_tile() {
let tiles = vec![AtlasTile::mock(1), AtlasTile::mock(2)];
let texture = AnimatedTexture::new(tiles, 0.1).unwrap();
// Should return first tile initially
assert_eq!(texture.current_tile().color.unwrap().r, 1);
}
#[test]
fn test_current_tile_after_frame_change() {
let tiles = vec![AtlasTile::mock(1), AtlasTile::mock(2)];
let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap();
// Advance one frame
texture.tick(0.1);
assert_eq!(texture.current_tile().color.unwrap().r, 2);
}
#[test]
fn test_single_tile_animation() {
let tiles = vec![AtlasTile::mock(1)];
let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap();
// Should stay on same frame
texture.tick(0.1);
assert_eq!(texture.current_frame(), 0);
assert_eq!(texture.current_tile().color.unwrap().r, 1);
}
}

View File

@@ -44,135 +44,3 @@ impl BlinkingTexture {
self.blink_duration self.blink_duration
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use glam::U16Vec2;
use sdl2::pixels::Color;
fn mock_atlas_tile(id: u32) -> AtlasTile {
AtlasTile {
pos: U16Vec2::new(0, 0),
size: U16Vec2::new(16, 16),
color: Some(Color::RGB(id as u8, 0, 0)),
}
}
#[test]
fn test_new_blinking_texture() {
let tile = mock_atlas_tile(1);
let texture = BlinkingTexture::new(tile, 0.5);
assert_eq!(texture.is_on(), true);
assert_eq!(texture.time_bank(), 0.0);
assert_eq!(texture.blink_duration(), 0.5);
assert_eq!(texture.tile().color.unwrap().r, 1);
}
#[test]
fn test_tick_no_blink_change() {
let tile = mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 0.5);
// Tick with less than blink duration
texture.tick(0.25);
assert_eq!(texture.is_on(), true);
assert_eq!(texture.time_bank(), 0.25);
}
#[test]
fn test_tick_single_blink_change() {
let tile = mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 0.5);
// Tick with exactly blink duration
texture.tick(0.5);
assert_eq!(texture.is_on(), false);
assert_eq!(texture.time_bank(), 0.0);
}
#[test]
fn test_tick_multiple_blink_changes() {
let tile = mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 0.5);
// First blink
texture.tick(0.5);
assert_eq!(texture.is_on(), false);
// Second blink (back to on)
texture.tick(0.5);
assert_eq!(texture.is_on(), true);
// Third blink (back to off)
texture.tick(0.5);
assert_eq!(texture.is_on(), false);
}
#[test]
fn test_tick_partial_blink_duration() {
let tile = mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 0.5);
// Tick with 1.25 blink durations
texture.tick(0.625);
assert_eq!(texture.is_on(), false);
assert_eq!(texture.time_bank(), 0.125);
}
#[test]
fn test_tick_with_zero_duration() {
let tile = mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 0.0);
// Should not cause issues - skip the test if blink_duration is 0
if texture.blink_duration() > 0.0 {
texture.tick(0.1);
assert_eq!(texture.is_on(), true);
}
}
#[test]
fn test_tick_with_negative_duration() {
let tile = mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, -0.5);
// Should not cause issues - skip the test if blink_duration is negative
if texture.blink_duration() > 0.0 {
texture.tick(0.1);
assert_eq!(texture.is_on(), true);
}
}
#[test]
fn test_tick_with_negative_delta_time() {
let tile = mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 0.5);
// Should not cause issues
texture.tick(-0.1);
assert_eq!(texture.is_on(), true);
assert_eq!(texture.time_bank(), -0.1);
}
#[test]
fn test_tile_access() {
let tile = mock_atlas_tile(42);
let texture = BlinkingTexture::new(tile, 0.5);
assert_eq!(texture.tile().color.unwrap().r, 42);
}
#[test]
fn test_clone() {
let tile = mock_atlas_tile(1);
let texture = BlinkingTexture::new(tile, 0.5);
let cloned = texture.clone();
assert_eq!(texture.is_on(), cloned.is_on());
assert_eq!(texture.time_bank(), cloned.time_bank());
assert_eq!(texture.blink_duration(), cloned.blink_duration());
assert_eq!(texture.tile().color.unwrap().r, cloned.tile().color.unwrap().r);
}
}

View File

@@ -1,7 +1,6 @@
use anyhow::Result; use anyhow::Result;
use sdl2::rect::Rect; use sdl2::rect::Rect;
use sdl2::render::{Canvas, RenderTarget}; use sdl2::render::{Canvas, RenderTarget};
use std::collections::HashMap;
use crate::entity::direction::Direction; use crate::entity::direction::Direction;
use crate::texture::animated::AnimatedTexture; use crate::texture::animated::AnimatedTexture;
@@ -9,12 +8,12 @@ use crate::texture::sprite::SpriteAtlas;
#[derive(Clone)] #[derive(Clone)]
pub struct DirectionalAnimatedTexture { pub struct DirectionalAnimatedTexture {
textures: HashMap<Direction, AnimatedTexture>, textures: [Option<AnimatedTexture>; 4],
stopped_textures: HashMap<Direction, AnimatedTexture>, stopped_textures: [Option<AnimatedTexture>; 4],
} }
impl DirectionalAnimatedTexture { impl DirectionalAnimatedTexture {
pub fn new(textures: HashMap<Direction, AnimatedTexture>, stopped_textures: HashMap<Direction, AnimatedTexture>) -> Self { pub fn new(textures: [Option<AnimatedTexture>; 4], stopped_textures: [Option<AnimatedTexture>; 4]) -> Self {
Self { Self {
textures, textures,
stopped_textures, stopped_textures,
@@ -22,7 +21,7 @@ impl DirectionalAnimatedTexture {
} }
pub fn tick(&mut self, dt: f32) { pub fn tick(&mut self, dt: f32) {
for texture in self.textures.values_mut() { for texture in self.textures.iter_mut().flatten() {
texture.tick(dt); texture.tick(dt);
} }
} }
@@ -34,7 +33,7 @@ impl DirectionalAnimatedTexture {
dest: Rect, dest: Rect,
direction: Direction, direction: Direction,
) -> Result<()> { ) -> Result<()> {
if let Some(texture) = self.textures.get(&direction) { if let Some(texture) = &self.textures[direction.as_usize()] {
texture.render(canvas, atlas, dest) texture.render(canvas, atlas, dest)
} else { } else {
Ok(()) Ok(())
@@ -48,7 +47,7 @@ impl DirectionalAnimatedTexture {
dest: Rect, dest: Rect,
direction: Direction, direction: Direction,
) -> Result<()> { ) -> Result<()> {
if let Some(texture) = self.stopped_textures.get(&direction) { if let Some(texture) = &self.stopped_textures[direction.as_usize()] {
texture.render(canvas, atlas, dest) texture.render(canvas, atlas, dest)
} else { } else {
Ok(()) Ok(())
@@ -58,133 +57,24 @@ impl DirectionalAnimatedTexture {
/// Returns true if the texture has a direction. /// Returns true if the texture has a direction.
#[allow(dead_code)] #[allow(dead_code)]
pub fn has_direction(&self, direction: Direction) -> bool { pub fn has_direction(&self, direction: Direction) -> bool {
self.textures.contains_key(&direction) self.textures[direction.as_usize()].is_some()
} }
/// Returns true if the texture has a stopped direction. /// Returns true if the texture has a stopped direction.
#[allow(dead_code)] #[allow(dead_code)]
pub fn has_stopped_direction(&self, direction: Direction) -> bool { pub fn has_stopped_direction(&self, direction: Direction) -> bool {
self.stopped_textures.contains_key(&direction) self.stopped_textures[direction.as_usize()].is_some()
} }
/// Returns the number of textures. /// Returns the number of textures.
#[allow(dead_code)] #[allow(dead_code)]
pub fn texture_count(&self) -> usize { pub fn texture_count(&self) -> usize {
self.textures.len() self.textures.iter().filter(|t| t.is_some()).count()
} }
/// Returns the number of stopped textures. /// Returns the number of stopped textures.
#[allow(dead_code)] #[allow(dead_code)]
pub fn stopped_texture_count(&self) -> usize { pub fn stopped_texture_count(&self) -> usize {
self.stopped_textures.len() self.stopped_textures.iter().filter(|t| t.is_some()).count()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::texture::sprite::AtlasTile;
use glam::U16Vec2;
use sdl2::pixels::Color;
fn mock_atlas_tile(id: u32) -> AtlasTile {
AtlasTile {
pos: U16Vec2::new(0, 0),
size: U16Vec2::new(16, 16),
color: Some(Color::RGB(id as u8, 0, 0)),
}
}
fn mock_animated_texture(id: u32) -> AnimatedTexture {
AnimatedTexture::new(vec![mock_atlas_tile(id)], 0.1).expect("Invalid frame duration")
}
#[test]
fn test_new_directional_animated_texture() {
let mut textures = HashMap::new();
let mut stopped_textures = HashMap::new();
textures.insert(Direction::Up, mock_animated_texture(1));
textures.insert(Direction::Down, mock_animated_texture(2));
stopped_textures.insert(Direction::Up, mock_animated_texture(3));
stopped_textures.insert(Direction::Down, mock_animated_texture(4));
let texture = DirectionalAnimatedTexture::new(textures, stopped_textures);
assert_eq!(texture.texture_count(), 2);
assert_eq!(texture.stopped_texture_count(), 2);
assert!(texture.has_direction(Direction::Up));
assert!(texture.has_direction(Direction::Down));
assert!(!texture.has_direction(Direction::Left));
assert!(texture.has_stopped_direction(Direction::Up));
assert!(texture.has_stopped_direction(Direction::Down));
assert!(!texture.has_stopped_direction(Direction::Left));
}
#[test]
fn test_tick() {
let mut textures = HashMap::new();
textures.insert(Direction::Up, mock_animated_texture(1));
textures.insert(Direction::Down, mock_animated_texture(2));
let mut texture = DirectionalAnimatedTexture::new(textures, HashMap::new());
// Should not panic
texture.tick(0.1);
assert_eq!(texture.texture_count(), 2);
}
#[test]
fn test_empty_texture() {
let texture = DirectionalAnimatedTexture::new(HashMap::new(), HashMap::new());
assert_eq!(texture.texture_count(), 0);
assert_eq!(texture.stopped_texture_count(), 0);
assert!(!texture.has_direction(Direction::Up));
assert!(!texture.has_stopped_direction(Direction::Up));
}
#[test]
fn test_partial_directions() {
let mut textures = HashMap::new();
textures.insert(Direction::Up, mock_animated_texture(1));
let texture = DirectionalAnimatedTexture::new(textures, HashMap::new());
assert_eq!(texture.texture_count(), 1);
assert!(texture.has_direction(Direction::Up));
assert!(!texture.has_direction(Direction::Down));
assert!(!texture.has_direction(Direction::Left));
assert!(!texture.has_direction(Direction::Right));
}
#[test]
fn test_clone() {
let mut textures = HashMap::new();
textures.insert(Direction::Up, mock_animated_texture(1));
let texture = DirectionalAnimatedTexture::new(textures, HashMap::new());
let cloned = texture.clone();
assert_eq!(texture.texture_count(), cloned.texture_count());
assert_eq!(texture.stopped_texture_count(), cloned.stopped_texture_count());
assert_eq!(texture.has_direction(Direction::Up), cloned.has_direction(Direction::Up));
}
#[test]
fn test_all_directions() {
let mut textures = HashMap::new();
textures.insert(Direction::Up, mock_animated_texture(1));
textures.insert(Direction::Down, mock_animated_texture(2));
textures.insert(Direction::Left, mock_animated_texture(3));
textures.insert(Direction::Right, mock_animated_texture(4));
let texture = DirectionalAnimatedTexture::new(textures, HashMap::new());
assert_eq!(texture.texture_count(), 4);
assert!(texture.has_direction(Direction::Up));
assert!(texture.has_direction(Direction::Down));
assert!(texture.has_direction(Direction::Left));
assert!(texture.has_direction(Direction::Right));
} }
} }

View File

@@ -134,236 +134,3 @@ impl SpriteAtlas {
pub unsafe fn texture_to_static(texture: Texture) -> Texture<'static> { pub unsafe fn texture_to_static(texture: Texture) -> Texture<'static> {
std::mem::transmute(texture) std::mem::transmute(texture)
} }
#[cfg(test)]
mod tests {
use super::*;
use sdl2::pixels::Color;
// Mock texture for testing - we'll use a dummy approach since we can't create real SDL2 textures
fn mock_texture() -> Texture<'static> {
// This is unsafe and only for testing - in real usage this would be a proper texture
unsafe { std::mem::transmute(0usize) }
}
#[test]
fn test_atlas_tile_new() {
let pos = U16Vec2::new(10, 20);
let size = U16Vec2::new(32, 32);
let tile = AtlasTile::new(pos, size, None);
assert_eq!(tile.pos, pos);
assert_eq!(tile.size, size);
assert_eq!(tile.color, None);
}
#[test]
fn test_atlas_tile_with_color() {
let pos = U16Vec2::new(10, 20);
let size = U16Vec2::new(32, 32);
let color = Color::RGB(255, 0, 0);
let tile = AtlasTile::new(pos, size, None).with_color(color);
assert_eq!(tile.pos, pos);
assert_eq!(tile.size, size);
assert_eq!(tile.color, Some(color));
}
#[test]
fn test_mapper_frame() {
let frame = MapperFrame {
x: 10,
y: 20,
width: 32,
height: 32,
};
assert_eq!(frame.x, 10);
assert_eq!(frame.y, 20);
assert_eq!(frame.width, 32);
assert_eq!(frame.height, 32);
}
#[test]
fn test_atlas_mapper_new() {
let mut frames = HashMap::new();
frames.insert(
"test".to_string(),
MapperFrame {
x: 0,
y: 0,
width: 32,
height: 32,
},
);
let mapper = AtlasMapper { frames };
assert_eq!(mapper.frames.len(), 1);
assert!(mapper.frames.contains_key("test"));
}
#[test]
fn test_sprite_atlas_new() {
let mut frames = HashMap::new();
frames.insert(
"test".to_string(),
MapperFrame {
x: 0,
y: 0,
width: 32,
height: 32,
},
);
let mapper = AtlasMapper { frames };
let texture = mock_texture();
let atlas = SpriteAtlas::new(texture, mapper);
assert_eq!(atlas.tiles_count(), 1);
assert!(atlas.has_tile("test"));
assert_eq!(atlas.default_color(), None);
}
#[test]
fn test_sprite_atlas_get_tile() {
let mut frames = HashMap::new();
frames.insert(
"test".to_string(),
MapperFrame {
x: 10,
y: 20,
width: 32,
height: 64,
},
);
let mapper = AtlasMapper { frames };
let texture = mock_texture();
let atlas = SpriteAtlas::new(texture, mapper);
let tile = atlas.get_tile("test");
assert!(tile.is_some());
let tile = tile.unwrap();
assert_eq!(tile.pos, U16Vec2::new(10, 20));
assert_eq!(tile.size, U16Vec2::new(32, 64));
assert_eq!(tile.color, None);
}
#[test]
fn test_sprite_atlas_get_tile_nonexistent() {
let mapper = AtlasMapper { frames: HashMap::new() };
let texture = mock_texture();
let atlas = SpriteAtlas::new(texture, mapper);
let tile = atlas.get_tile("nonexistent");
assert!(tile.is_none());
}
#[test]
fn test_sprite_atlas_set_color() {
let mapper = AtlasMapper { frames: HashMap::new() };
let texture = mock_texture();
let mut atlas = SpriteAtlas::new(texture, mapper);
assert_eq!(atlas.default_color(), None);
let color = Color::RGB(255, 0, 0);
atlas.set_color(color);
assert_eq!(atlas.default_color(), Some(color));
}
#[test]
fn test_sprite_atlas_empty() {
let mapper = AtlasMapper { frames: HashMap::new() };
let texture = mock_texture();
let atlas = SpriteAtlas::new(texture, mapper);
assert_eq!(atlas.tiles_count(), 0);
assert!(!atlas.has_tile("any"));
}
#[test]
fn test_sprite_atlas_multiple_tiles() {
let mut frames = HashMap::new();
frames.insert(
"tile1".to_string(),
MapperFrame {
x: 0,
y: 0,
width: 32,
height: 32,
},
);
frames.insert(
"tile2".to_string(),
MapperFrame {
x: 32,
y: 0,
width: 64,
height: 64,
},
);
let mapper = AtlasMapper { frames };
let texture = mock_texture();
let atlas = SpriteAtlas::new(texture, mapper);
assert_eq!(atlas.tiles_count(), 2);
assert!(atlas.has_tile("tile1"));
assert!(atlas.has_tile("tile2"));
assert!(!atlas.has_tile("tile3"));
}
#[test]
fn test_atlas_tile_clone() {
let pos = U16Vec2::new(10, 20);
let size = U16Vec2::new(32, 32);
let color = Color::RGB(255, 0, 0);
let tile = AtlasTile::new(pos, size, Some(color));
let cloned = tile;
assert_eq!(tile.pos, cloned.pos);
assert_eq!(tile.size, cloned.size);
assert_eq!(tile.color, cloned.color);
}
#[test]
fn test_mapper_frame_clone() {
let frame = MapperFrame {
x: 10,
y: 20,
width: 32,
height: 64,
};
let cloned = frame;
assert_eq!(frame.x, cloned.x);
assert_eq!(frame.y, cloned.y);
assert_eq!(frame.width, cloned.width);
assert_eq!(frame.height, cloned.height);
}
#[test]
fn test_atlas_mapper_clone() {
let mut frames = HashMap::new();
frames.insert(
"test".to_string(),
MapperFrame {
x: 0,
y: 0,
width: 32,
height: 32,
},
);
let mapper = AtlasMapper { frames };
let cloned = mapper.clone();
assert_eq!(mapper.frames.len(), cloned.frames.len());
assert!(mapper.frames.contains_key("test"));
assert!(cloned.frames.contains_key("test"));
}
}

View File

@@ -151,228 +151,3 @@ impl TextTexture {
(8.0 * self.scale) as u32 (8.0 * self.scale) as u32
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas};
use std::collections::HashMap;
fn create_mock_atlas() -> SpriteAtlas {
let mut frames = HashMap::new();
frames.insert(
"text/A.png".to_string(),
MapperFrame {
x: 0,
y: 0,
width: 8,
height: 8,
},
);
frames.insert(
"text/1.png".to_string(),
MapperFrame {
x: 8,
y: 0,
width: 8,
height: 8,
},
);
frames.insert(
"text/!.png".to_string(),
MapperFrame {
x: 16,
y: 0,
width: 8,
height: 8,
},
);
frames.insert(
"text/-.png".to_string(),
MapperFrame {
x: 24,
y: 0,
width: 8,
height: 8,
},
);
frames.insert(
"text/_double_quote.png".to_string(),
MapperFrame {
x: 32,
y: 0,
width: 8,
height: 8,
},
);
frames.insert(
"text/_forward_slash.png".to_string(),
MapperFrame {
x: 40,
y: 0,
width: 8,
height: 8,
},
);
let mapper = AtlasMapper { frames };
// Note: In real tests, we'd need a proper texture, but for unit tests we can work around this
unsafe { SpriteAtlas::new(std::mem::zeroed(), mapper) }
}
#[test]
fn test_text_texture_new() {
let text_texture = TextTexture::new(1.0);
assert_eq!(text_texture.scale(), 1.0);
assert!(text_texture.char_map.is_empty());
}
#[test]
fn test_text_texture_new_with_scale() {
let text_texture = TextTexture::new(2.5);
assert_eq!(text_texture.scale(), 2.5);
}
#[test]
fn test_char_to_tile_name_letters() {
let text_texture = TextTexture::new(1.0);
assert_eq!(text_texture.char_to_tile_name('A'), Some("text/A.png".to_string()));
assert_eq!(text_texture.char_to_tile_name('Z'), Some("text/Z.png".to_string()));
assert_eq!(text_texture.char_to_tile_name('a'), None); // lowercase not supported
}
#[test]
fn test_char_to_tile_name_numbers() {
let text_texture = TextTexture::new(1.0);
assert_eq!(text_texture.char_to_tile_name('0'), Some("text/0.png".to_string()));
assert_eq!(text_texture.char_to_tile_name('9'), Some("text/9.png".to_string()));
}
#[test]
fn test_char_to_tile_name_special_characters() {
let text_texture = TextTexture::new(1.0);
assert_eq!(text_texture.char_to_tile_name('!'), Some("text/!.png".to_string()));
assert_eq!(text_texture.char_to_tile_name('-'), Some("text/-.png".to_string()));
assert_eq!(
text_texture.char_to_tile_name('"'),
Some("text/_double_quote.png".to_string())
);
assert_eq!(
text_texture.char_to_tile_name('/'),
Some("text/_forward_slash.png".to_string())
);
}
#[test]
fn test_char_to_tile_name_unsupported() {
let text_texture = TextTexture::new(1.0);
assert_eq!(text_texture.char_to_tile_name(' '), None);
assert_eq!(text_texture.char_to_tile_name('@'), None);
assert_eq!(text_texture.char_to_tile_name('a'), None);
assert_eq!(text_texture.char_to_tile_name('z'), None);
}
#[test]
fn test_set_scale() {
let mut text_texture = TextTexture::new(1.0);
assert_eq!(text_texture.scale(), 1.0);
text_texture.set_scale(3.0);
assert_eq!(text_texture.scale(), 3.0);
text_texture.set_scale(0.5);
assert_eq!(text_texture.scale(), 0.5);
}
#[test]
fn test_text_width_empty_string() {
let text_texture = TextTexture::new(1.0);
assert_eq!(text_texture.text_width(""), 0);
}
#[test]
fn test_text_width_single_character() {
let text_texture = TextTexture::new(1.0);
assert_eq!(text_texture.text_width("A"), 8); // 8 pixels per character at scale 1.0
}
#[test]
fn test_text_width_multiple_characters() {
let text_texture = TextTexture::new(1.0);
assert_eq!(text_texture.text_width("ABC"), 24); // 3 * 8 = 24 pixels
}
#[test]
fn test_text_width_with_scale() {
let text_texture = TextTexture::new(2.0);
assert_eq!(text_texture.text_width("A"), 16); // 8 * 2 = 16 pixels
assert_eq!(text_texture.text_width("ABC"), 48); // 3 * 16 = 48 pixels
}
#[test]
fn test_text_width_with_unsupported_characters() {
let text_texture = TextTexture::new(1.0);
// Only supported characters should be counted
assert_eq!(text_texture.text_width("A B"), 16); // A and B only, space ignored
assert_eq!(text_texture.text_width("A@B"), 16); // A and B only, @ ignored
}
#[test]
fn test_text_height() {
let text_texture = TextTexture::new(1.0);
assert_eq!(text_texture.text_height(), 8); // 8 pixels per character at scale 1.0
}
#[test]
fn test_text_height_with_scale() {
let text_texture = TextTexture::new(2.0);
assert_eq!(text_texture.text_height(), 16); // 8 * 2 = 16 pixels
}
#[test]
fn test_text_height_with_fractional_scale() {
let text_texture = TextTexture::new(1.5);
assert_eq!(text_texture.text_height(), 12); // 8 * 1.5 = 12 pixels
}
#[test]
fn test_get_char_tile_caching() {
let mut text_texture = TextTexture::new(1.0);
let atlas = create_mock_atlas();
// First call should cache the tile
let tile1 = text_texture.get_char_tile(&atlas, 'A');
assert!(tile1.is_some());
// Second call should use cached tile
let tile2 = text_texture.get_char_tile(&atlas, 'A');
assert!(tile2.is_some());
// Both should be the same tile
assert_eq!(tile1.unwrap().pos, tile2.unwrap().pos);
assert_eq!(tile1.unwrap().size, tile2.unwrap().size);
}
#[test]
fn test_get_char_tile_unsupported_character() {
let mut text_texture = TextTexture::new(1.0);
let atlas = create_mock_atlas();
let tile = text_texture.get_char_tile(&atlas, ' ');
assert!(tile.is_none());
}
#[test]
fn test_get_char_tile_missing_from_atlas() {
let mut text_texture = TextTexture::new(1.0);
let atlas = create_mock_atlas();
// 'B' is not in our mock atlas
let tile = text_texture.get_char_tile(&atlas, 'B');
assert!(tile.is_none());
}
}

61
tests/animated.rs Normal file
View 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
View 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!(texture.is_on());
texture.tick(0.5);
assert!(!texture.is_on());
texture.tick(0.5);
assert!(texture.is_on());
texture.tick(0.5);
assert!(!texture.is_on());
}
#[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!(!texture.is_on());
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!(texture.is_on());
assert_eq!(texture.time_bank(), -0.1);
}

28
tests/constants.rs Normal file
View File

@@ -0,0 +1,28 @@
use pacman::constants::*;
#[test]
fn test_raw_board_structure() {
assert_eq!(RAW_BOARD.len(), BOARD_CELL_SIZE.y as usize);
for row in RAW_BOARD.iter() {
assert_eq!(row.len(), BOARD_CELL_SIZE.x as usize);
}
// Test boundaries
assert!(RAW_BOARD[0].chars().all(|c| c == '#'));
assert!(RAW_BOARD[RAW_BOARD.len() - 1].chars().all(|c| c == '#'));
// Test tunnel row
let tunnel_row = RAW_BOARD[14];
assert_eq!(tunnel_row.chars().next().unwrap(), 'T');
assert_eq!(tunnel_row.chars().last().unwrap(), 'T');
}
#[test]
fn test_raw_board_content() {
let power_pellet_count = RAW_BOARD.iter().flat_map(|row| row.chars()).filter(|&c| c == 'o').count();
assert_eq!(power_pellet_count, 4);
assert!(RAW_BOARD.iter().any(|row| row.contains('X')));
assert!(RAW_BOARD.iter().any(|row| row.contains("==")));
}

34
tests/debug_rendering.rs Normal file
View File

@@ -0,0 +1,34 @@
use glam::Vec2;
use pacman::entity::graph::{Graph, Node};
use pacman::map::render::MapRenderer;
#[test]
fn test_find_nearest_node() {
let mut graph = Graph::new();
// Add some test nodes
let node1 = graph.add_node(Node {
position: Vec2::new(10.0, 10.0),
});
let node2 = graph.add_node(Node {
position: Vec2::new(50.0, 50.0),
});
let node3 = graph.add_node(Node {
position: Vec2::new(100.0, 100.0),
});
// Test cursor near node1
let cursor_pos = Vec2::new(12.0, 8.0);
let nearest = MapRenderer::find_nearest_node(&graph, cursor_pos);
assert_eq!(nearest, Some(node1));
// Test cursor near node2
let cursor_pos = Vec2::new(45.0, 55.0);
let nearest = MapRenderer::find_nearest_node(&graph, cursor_pos);
assert_eq!(nearest, Some(node2));
// Test cursor near node3
let cursor_pos = Vec2::new(98.0, 102.0);
let nearest = MapRenderer::find_nearest_node(&graph, cursor_pos);
assert_eq!(nearest, Some(node3));
}

31
tests/direction.rs Normal file
View File

@@ -0,0 +1,31 @@
use glam::IVec2;
use pacman::entity::direction::*;
#[test]
fn test_direction_opposite() {
let test_cases = [
(Direction::Up, Direction::Down),
(Direction::Down, Direction::Up),
(Direction::Left, Direction::Right),
(Direction::Right, Direction::Left),
];
for (dir, expected) in test_cases {
assert_eq!(dir.opposite(), expected);
}
}
#[test]
fn test_direction_as_ivec2() {
let test_cases = [
(Direction::Up, -IVec2::Y),
(Direction::Down, IVec2::Y),
(Direction::Left, -IVec2::X),
(Direction::Right, IVec2::X),
];
for (dir, expected) in test_cases {
assert_eq!(dir.as_ivec2(), expected);
assert_eq!(IVec2::from(dir), expected);
}
}

54
tests/directional.rs Normal file
View File

@@ -0,0 +1,54 @@
use glam::U16Vec2;
use pacman::entity::direction::Direction;
use pacman::texture::animated::AnimatedTexture;
use pacman::texture::directional::DirectionalAnimatedTexture;
use pacman::texture::sprite::AtlasTile;
use sdl2::pixels::Color;
fn mock_atlas_tile(id: u32) -> AtlasTile {
AtlasTile {
pos: U16Vec2::new(0, 0),
size: U16Vec2::new(16, 16),
color: Some(Color::RGB(id as u8, 0, 0)),
}
}
fn mock_animated_texture(id: u32) -> AnimatedTexture {
AnimatedTexture::new(vec![mock_atlas_tile(id)], 0.1).expect("Invalid frame duration")
}
#[test]
fn test_directional_texture_partial_directions() {
let mut textures = [None, None, None, None];
textures[Direction::Up.as_usize()] = Some(mock_animated_texture(1));
let texture = DirectionalAnimatedTexture::new(textures, [None, None, None, None]);
assert_eq!(texture.texture_count(), 1);
assert!(texture.has_direction(Direction::Up));
assert!(!texture.has_direction(Direction::Down));
assert!(!texture.has_direction(Direction::Left));
assert!(!texture.has_direction(Direction::Right));
}
#[test]
fn test_directional_texture_all_directions() {
let mut textures = [None, None, None, None];
let directions = [
(Direction::Up, 1),
(Direction::Down, 2),
(Direction::Left, 3),
(Direction::Right, 4),
];
for (direction, id) in directions {
textures[direction.as_usize()] = Some(mock_animated_texture(id));
}
let texture = DirectionalAnimatedTexture::new(textures, [None, None, None, None]);
assert_eq!(texture.texture_count(), 4);
for direction in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] {
assert!(texture.has_direction(*direction));
}
}

21
tests/game.rs Normal file
View 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).unwrap();
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).unwrap();
assert!(map.find_starting_position(0).is_some());
}

48
tests/ghost.rs Normal file
View File

@@ -0,0 +1,48 @@
use pacman::entity::ghost::{Ghost, GhostType};
use pacman::entity::graph::Graph;
use pacman::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas};
use std::collections::HashMap;
fn create_test_atlas() -> SpriteAtlas {
let mut frames = HashMap::new();
let directions = ["up", "down", "left", "right"];
let ghost_types = ["blinky", "pinky", "inky", "clyde"];
for ghost_type in &ghost_types {
for (i, dir) in directions.iter().enumerate() {
frames.insert(
format!("ghost/{}/{}_{}.png", ghost_type, dir, "a"),
MapperFrame {
x: i as u16 * 16,
y: 0,
width: 16,
height: 16,
},
);
frames.insert(
format!("ghost/{}/{}_{}.png", ghost_type, dir, "b"),
MapperFrame {
x: i as u16 * 16,
y: 16,
width: 16,
height: 16,
},
);
}
}
let mapper = AtlasMapper { frames };
let dummy_texture = unsafe { std::mem::zeroed() };
SpriteAtlas::new(dummy_texture, mapper)
}
#[test]
fn test_ghost_creation() {
let graph = Graph::new();
let atlas = create_test_atlas();
let ghost = Ghost::new(&graph, 0, GhostType::Blinky, &atlas).unwrap();
assert_eq!(ghost.ghost_type, GhostType::Blinky);
assert_eq!(ghost.traverser.position.from_node_id(), 0);
}

152
tests/graph.rs Normal file
View File

@@ -0,0 +1,152 @@
use pacman::entity::direction::Direction;
use pacman::entity::graph::{EdgePermissions, Graph, Node};
use pacman::entity::traversal::{Position, Traverser};
fn create_test_graph() -> Graph {
let mut graph = Graph::new();
let node1 = graph.add_node(Node {
position: glam::Vec2::new(0.0, 0.0),
});
let node2 = graph.add_node(Node {
position: glam::Vec2::new(16.0, 0.0),
});
let node3 = graph.add_node(Node {
position: glam::Vec2::new(0.0, 16.0),
});
graph.connect(node1, node2, false, None, Direction::Right).unwrap();
graph.connect(node1, node3, false, None, Direction::Down).unwrap();
graph
}
#[test]
fn test_graph_basic_operations() {
let mut graph = Graph::new();
let node1 = graph.add_node(Node {
position: glam::Vec2::new(0.0, 0.0),
});
let node2 = graph.add_node(Node {
position: glam::Vec2::new(16.0, 0.0),
});
assert_eq!(graph.node_count(), 2);
assert!(graph.get_node(node1).is_some());
assert!(graph.get_node(node2).is_some());
assert!(graph.get_node(999).is_none());
}
#[test]
fn test_graph_connect() {
let mut graph = Graph::new();
let node1 = graph.add_node(Node {
position: glam::Vec2::new(0.0, 0.0),
});
let node2 = graph.add_node(Node {
position: glam::Vec2::new(16.0, 0.0),
});
assert!(graph.connect(node1, node2, false, None, Direction::Right).is_ok());
let edge1 = graph.find_edge_in_direction(node1, Direction::Right);
let edge2 = graph.find_edge_in_direction(node2, Direction::Left);
assert!(edge1.is_some());
assert!(edge2.is_some());
assert_eq!(edge1.unwrap().target, node2);
assert_eq!(edge2.unwrap().target, node1);
}
#[test]
fn test_graph_connect_errors() {
let mut graph = Graph::new();
let node1 = graph.add_node(Node {
position: glam::Vec2::new(0.0, 0.0),
});
assert!(graph.connect(node1, 999, false, None, Direction::Right).is_err());
assert!(graph.connect(999, node1, false, None, Direction::Right).is_err());
}
#[test]
fn test_graph_edge_permissions() {
let mut graph = Graph::new();
let node1 = graph.add_node(Node {
position: glam::Vec2::new(0.0, 0.0),
});
let node2 = graph.add_node(Node {
position: glam::Vec2::new(16.0, 0.0),
});
graph
.add_edge(node1, node2, false, None, Direction::Right, EdgePermissions::GhostsOnly)
.unwrap();
let edge = graph.find_edge_in_direction(node1, Direction::Right).unwrap();
assert_eq!(edge.permissions, EdgePermissions::GhostsOnly);
}
#[test]
fn test_traverser_basic() {
let graph = create_test_graph();
let mut traverser = Traverser::new(&graph, 0, Direction::Left, &|_| true);
traverser.set_next_direction(Direction::Up);
assert!(traverser.next_direction.is_some());
assert_eq!(traverser.next_direction.unwrap().0, Direction::Up);
}
#[test]
fn test_traverser_advance() {
let graph = create_test_graph();
let mut traverser = Traverser::new(&graph, 0, Direction::Right, &|_| true);
traverser.advance(&graph, 5.0, &|_| true).unwrap();
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).unwrap();
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))
.unwrap();
// Should still be at the node since it can't traverse
assert!(traverser.position.is_at_node());
}

19
tests/helpers.rs Normal file
View 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
View 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).unwrap();
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).unwrap();
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).unwrap();
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
View 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).unwrap();
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).unwrap();
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).unwrap();
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
View 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.map(|s| s.to_string());
invalid_board[0] = "###########################Z".to_string();
let result = MapTileParser::parse_board(invalid_board.each_ref().map(|s| s.as_str()));
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ParseError::UnknownCharacter('Z')));
}

120
tests/pathfinding.rs Normal file
View File

@@ -0,0 +1,120 @@
use pacman::entity::direction::Direction;
use pacman::entity::ghost::{Ghost, GhostType};
use pacman::entity::graph::{Graph, Node};
use pacman::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas};
use std::collections::HashMap;
fn create_test_atlas() -> SpriteAtlas {
let mut frames = HashMap::new();
let directions = ["up", "down", "left", "right"];
let ghost_types = ["blinky", "pinky", "inky", "clyde"];
for ghost_type in &ghost_types {
for (i, dir) in directions.iter().enumerate() {
frames.insert(
format!("ghost/{}/{}_{}.png", ghost_type, dir, "a"),
MapperFrame {
x: i as u16 * 16,
y: 0,
width: 16,
height: 16,
},
);
frames.insert(
format!("ghost/{}/{}_{}.png", ghost_type, dir, "b"),
MapperFrame {
x: i as u16 * 16,
y: 16,
width: 16,
height: 16,
},
);
}
}
let mapper = AtlasMapper { frames };
let dummy_texture = unsafe { std::mem::zeroed() };
SpriteAtlas::new(dummy_texture, mapper)
}
#[test]
fn test_ghost_pathfinding() {
// Create a simple test graph
let mut graph = Graph::new();
// Add nodes in a simple line: 0 -> 1 -> 2
let node0 = graph.add_node(Node {
position: glam::Vec2::new(0.0, 0.0),
});
let node1 = graph.add_node(Node {
position: glam::Vec2::new(10.0, 0.0),
});
let node2 = graph.add_node(Node {
position: glam::Vec2::new(20.0, 0.0),
});
// Connect the nodes
graph.connect(node0, node1, false, None, Direction::Right).unwrap();
graph.connect(node1, node2, false, None, Direction::Right).unwrap();
// Create a test atlas for the ghost
let atlas = create_test_atlas();
// Create a ghost at node 0
let ghost = Ghost::new(&graph, node0, GhostType::Blinky, &atlas).unwrap();
// Test pathfinding from node 0 to node 2
let path = ghost.calculate_path_to_target(&graph, node2);
assert!(path.is_ok());
let path = path.unwrap();
assert!(
path == vec![node0, node1, node2] || path == vec![node2, node1, node0],
"Path was not what was expected"
);
}
#[test]
fn test_ghost_pathfinding_no_path() {
// Create a test graph with disconnected components
let mut graph = Graph::new();
let node0 = graph.add_node(Node {
position: glam::Vec2::new(0.0, 0.0),
});
let node1 = graph.add_node(Node {
position: glam::Vec2::new(10.0, 0.0),
});
// Don't connect the nodes
let atlas = create_test_atlas();
let ghost = Ghost::new(&graph, node0, GhostType::Blinky, &atlas).unwrap();
// Test pathfinding when no path exists
let path = ghost.calculate_path_to_target(&graph, node1);
assert!(path.is_err());
}
#[test]
fn test_ghost_debug_colors() {
let atlas = create_test_atlas();
let mut graph = Graph::new();
let node = graph.add_node(Node {
position: glam::Vec2::new(0.0, 0.0),
});
let blinky = Ghost::new(&graph, node, GhostType::Blinky, &atlas).unwrap();
let pinky = Ghost::new(&graph, node, GhostType::Pinky, &atlas).unwrap();
let inky = Ghost::new(&graph, node, GhostType::Inky, &atlas).unwrap();
let clyde = Ghost::new(&graph, node, GhostType::Clyde, &atlas).unwrap();
// Test that each ghost has a different debug color
let colors = std::collections::HashSet::from([
blinky.debug_color(),
pinky.debug_color(),
inky.debug_color(),
clyde.debug_color(),
]);
assert_eq!(colors.len(), 4, "All ghost colors should be unique");
}

78
tests/sprite.rs Normal file
View 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));
}

View File

@@ -3,6 +3,24 @@ import { existsSync, promises as fs } from "fs";
import { platform } from "os"; import { platform } from "os";
import { dirname, join, relative, resolve } from "path"; import { dirname, join, relative, resolve } from "path";
import { match, P } from "ts-pattern"; import { match, P } from "ts-pattern";
import { configure, getConsoleSink, getLogger } from "@logtape/logtape";
// Constants
const TAILWIND_UPDATE_WINDOW_DAYS = 60; // 2 months
await configure({
sinks: { console: getConsoleSink() },
loggers: [
{ category: "web", lowestLevel: "debug", sinks: ["console"] },
{
category: ["logtape", "meta"],
lowestLevel: "warning",
sinks: ["console"],
},
],
});
const logger = getLogger("web");
type Os = type Os =
| { type: "linux"; wsl: boolean } | { type: "linux"; wsl: boolean }
@@ -22,28 +40,37 @@ const os: Os = match(platform())
throw new Error(`Unsupported platform: ${platform()}`); 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'. * Build the application with Emscripten, generate the CSS, and copy the files into 'dist'.
* *
* @param release - Whether to build in release mode. * @param release - Whether to build in release mode.
* @param env - The environment variables to inject into build commands. * @param env - The environment variables to inject into build commands.
*/ */
async function build(release: boolean, env: Record<string, string>) { async function build(release: boolean, env: Record<string, string> | null) {
log( logger.info(
`Building for 'wasm32-unknown-emscripten' for ${ `Building for 'wasm32-unknown-emscripten' for ${
release ? "release" : "debug" release ? "release" : "debug"
}` }`
); );
await $`cargo build --target=wasm32-unknown-emscripten ${ await $`cargo build --target=wasm32-unknown-emscripten ${
release ? "--release" : "" release ? "--release" : ""
}`.env(env); }`.env(env ?? undefined);
log("Generating CSS"); // Download the Tailwind CSS CLI for rendering the CSS
await $`pnpx postcss-cli ./assets/site/styles.scss -o ./assets/site/build.css`; const tailwindExecutable = match(
await downloadTailwind(process.cwd(), {
version: "latest",
force: false,
})
)
.with({ path: P.select() }, (path) => path)
.with({ err: P.select() }, (err) => {
throw new Error(err);
})
.exhaustive();
logger.debug(`Invoking ${tailwindExecutable}...`);
await $`${tailwindExecutable} --minify --input styles.css --output build.css --cwd assets/site`;
const buildType = release ? "release" : "debug"; const buildType = release ? "release" : "debug";
const siteFolder = resolve("assets/site"); const siteFolder = resolve("assets/site");
@@ -52,11 +79,13 @@ async function build(release: boolean, env: Record<string, string>) {
// The files to copy into 'dist' // The files to copy into 'dist'
const files = [ const files = [
...["index.html", "favicon.ico", "build.css"].map((file) => ({ ...["index.html", "favicon.ico", "build.css", "TerminalVector.ttf"].map(
src: join(siteFolder, file), (file) => ({
dest: join(dist, file), src: join(siteFolder, file),
optional: false, dest: join(dist, file),
})), optional: false,
})
),
...["pacman.wasm", "pacman.js", "deps/pacman.data"].map((file) => ({ ...["pacman.wasm", "pacman.js", "deps/pacman.data"].map((file) => ({
src: join(outputFolder, file), src: join(outputFolder, file),
dest: join(dist, file.split("/").pop() || file), dest: join(dist, file.split("/").pop() || file),
@@ -77,20 +106,20 @@ async function build(release: boolean, env: Record<string, string>) {
.map(async (dir) => { .map(async (dir) => {
// If the folder doesn't exist, create it // If the folder doesn't exist, create it
if (!(await fs.exists(dir))) { if (!(await fs.exists(dir))) {
log(`Creating folder ${dir}`); logger.debug(`Creating folder ${dir}`);
await fs.mkdir(dir, { recursive: true }); await fs.mkdir(dir, { recursive: true });
} }
}) })
); );
// Copy the files to the dist folder // Copy the files to the dist folder
log("Copying files into dist"); logger.debug("Copying files into dist");
await Promise.all( await Promise.all(
files.map(async ({ optional, src, dest }) => { files.map(async ({ optional, src, dest }) => {
match({ optional, exists: await fs.exists(src) }) match({ optional, exists: await fs.exists(src) })
// If optional and doesn't exist, skip // If optional and doesn't exist, skip
.with({ optional: true, exists: false }, () => { .with({ optional: true, exists: false }, () => {
log( logger.debug(
`Optional file ${os.type === "windows" ? "\\" : "/"}${relative( `Optional file ${os.type === "windows" ? "\\" : "/"}${relative(
process.cwd(), process.cwd(),
src src
@@ -107,6 +136,248 @@ async function build(release: boolean, env: Record<string, string>) {
); );
} }
/**
* Download the Tailwind CSS CLI to the specified directory.
* @param dir - The directory to download the Tailwind CSS CLI to.
* @returns The path to the downloaded Tailwind CSS CLI, or an error message if the download fails.
*/
async function downloadTailwind(
dir: string,
options?: Partial<{
version: string; // The version of Tailwind CSS to download. If not specified, the latest version will be downloaded.
force: boolean; // Whether to force the download even if the file already exists.
}>
): Promise<{ path: string } | { err: string }> {
const asset = match(os)
.with({ type: "linux" }, () => "tailwindcss-linux-x64")
.with({ type: "macos" }, () => "tailwindcss-macos-arm64")
.with({ type: "windows" }, () => "tailwindcss-windows-x64.exe")
.exhaustive();
const version = options?.version ?? "latest";
const force = options?.force ?? false;
const url =
version === "latest" || version == null
? `https://github.com/tailwindlabs/tailwindcss/releases/latest/download/${asset}`
: `https://github.com/tailwindlabs/tailwindcss/releases/download/${version}/${asset}`;
// If the GITHUB_TOKEN environment variable is set, use it for Bearer authentication
const headers: Record<string, string> = {};
if (process.env.GITHUB_TOKEN) {
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
}
// Check if the file already exists
const path = join(dir, asset);
const exists = await fs.exists(path);
// Check if we should download based on timestamps
let shouldDownload = force || !exists;
if (exists && !force) {
try {
const fileStats = await fs.stat(path);
const fileModifiedTime = fileStats.mtime;
const now = new Date();
// Check if file is older than the update window
const updateWindowAgo = new Date(
now.getTime() - TAILWIND_UPDATE_WINDOW_DAYS * 24 * 60 * 60 * 1000
);
if (fileModifiedTime < updateWindowAgo) {
logger.debug(
`File is older than ${TAILWIND_UPDATE_WINDOW_DAYS} days, checking for updates...`
);
shouldDownload = true;
} else {
logger.debug(
`File is recent (${fileModifiedTime.toISOString()}), checking if newer version available...`
);
}
} catch (error) {
logger.debug(
`Error checking file timestamp: ${error}, will download anyway`
);
shouldDownload = true;
}
}
// If we need to download, check the server's last-modified header
if (shouldDownload) {
const response = await fetch(url, {
headers,
method: "HEAD",
redirect: "follow",
});
if (response.ok) {
const lastModified = response.headers.get("last-modified");
if (lastModified) {
const serverTime = new Date(lastModified);
const now = new Date();
// If server timestamp is in the future, something is wrong - download anyway
if (serverTime > now) {
logger.debug(
`Server timestamp is in the future (${serverTime.toISOString()}), downloading anyway`
);
shouldDownload = true;
} else if (exists) {
// Compare with local file timestamp (both in UTC)
const fileStats = await fs.stat(path);
const fileModifiedTime = new Date(fileStats.mtime.getTime());
if (serverTime > fileModifiedTime) {
logger.debug(
`Server has newer version (${serverTime.toISOString()} vs local ${fileModifiedTime.toISOString()})`
);
shouldDownload = true;
} else {
logger.debug(
`Local file is up to date (${fileModifiedTime.toISOString()})`
);
shouldDownload = false;
}
}
} else {
logger.debug(
`No last-modified header available, downloading to be safe`
);
shouldDownload = true;
}
} else {
logger.debug(
`Failed to check server headers: ${response.status} ${response.statusText}`
);
shouldDownload = true;
}
}
if (exists && !shouldDownload) {
const displayPath = match(relative(process.cwd(), path))
// If the path is not a subpath of cwd, display the absolute path
.with(P.string.startsWith(".."), (_relative) => path)
// Otherwise, display the relative path
.otherwise((relative) => relative);
logger.debug(
`Tailwind CSS CLI already exists and is up to date at ${displayPath}`
);
return { path };
}
if (exists) {
const displayPath = match(relative(process.cwd(), path))
// If the path is not a subpath of cwd, display the absolute path
.with(P.string.startsWith(".."), (_relative) => path)
// Otherwise, display the relative path
.otherwise((relative) => relative);
if (force) {
logger.debug(`Overwriting Tailwind CSS CLI at ${displayPath}`);
} else {
logger.debug(`Downloading updated Tailwind CSS CLI to ${displayPath}`);
}
} else {
logger.debug(`Downloading Tailwind CSS CLI to ${path}`);
}
try {
logger.debug(`Fetching ${url}...`);
const response = await fetch(url, { headers });
if (!response.ok) {
return {
err: `Failed to download Tailwind CSS: ${response.status} ${response.statusText} for '${url}'`,
};
} else if (!response.body) {
return { err: `No response body received for '${url}'` };
}
// Validate Content-Length if available
const contentLength = response.headers.get("content-length");
if (contentLength) {
const expectedSize = parseInt(contentLength, 10);
if (isNaN(expectedSize)) {
return { err: `Invalid Content-Length header: ${contentLength}` };
}
logger.debug(`Expected file size: ${expectedSize} bytes`);
}
logger.debug(`Writing to ${path}...`);
await fs.mkdir(dir, { recursive: true });
const file = Bun.file(path);
const writer = file.writer();
const reader = response.body.getReader();
let downloadedBytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
writer.write(value);
downloadedBytes += value.length;
}
} finally {
reader.releaseLock();
await writer.end();
}
// Validate downloaded file size
if (contentLength) {
const expectedSize = parseInt(contentLength, 10);
const actualSize = downloadedBytes;
if (actualSize !== expectedSize) {
// Clean up the corrupted file
try {
await fs.unlink(path);
} catch (unlinkError) {
logger.debug(
`Warning: Failed to clean up corrupted file: ${unlinkError}`
);
}
return {
err: `File size mismatch: expected ${expectedSize} bytes, got ${actualSize} bytes. File may be corrupted.`,
};
}
logger.debug(`File size validation passed: ${actualSize} bytes`);
}
// Make the file executable on Unix-like systems
if (os.type !== "windows") {
await $`chmod +x ${path}`;
}
// Ensure file is not locked; sometimes the runtime is too fast and the file is executed before the lock is released
const timeout = Date.now() + 2500; // 2.5s timeout
do {
try {
if ((await fs.stat(path)).size > 0) break;
} catch {
// File might not be ready yet
logger.debug(`File ${path} is not ready yet, waiting...`);
}
await new Promise((resolve) => setTimeout(resolve, 10));
} while (Date.now() < timeout);
// All done!
return { path };
} catch (error) {
return {
err: `Download failed: ${
error instanceof Error ? error.message : String(error)
}`,
};
}
}
/** /**
* Checks to see if the Emscripten SDK is activated for a Windows or *nix machine by looking for a .exe file and the equivalent file on Linux/macOS. Returns both results for handling. * 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. * @param emsdkDir - The directory containing the Emscripten SDK.
@@ -125,14 +396,73 @@ async function checkEmsdkType(
/** /**
* Activate the Emscripten SDK environment variables. * Activate the Emscripten SDK environment variables.
* Technically, this doesn't actaully activate the environment variables for the current shell, * Technically, this doesn't actually activate the environment variables for the current shell,
* it just runs the environment sourcing script and returns the environment variables for future command invocations. * it just runs the environment sourcing script and returns the environment variables for future command invocations.
* @param emsdkDir - The directory containing the Emscripten SDK. * @param emsdkDir - The directory containing the Emscripten SDK.
* @returns A record of environment variables. * @returns A record of environment variables.
*/ */
async function activateEmsdk( async function activateEmsdk(
emsdkDir: string emsdkDir: string
): Promise<{ vars: Record<string, string> } | { err: string }> { ): Promise<{ vars: Record<string, string> | null } | { err: string }> {
// If the EMSDK environment variable is set already & the path specified exists, return nothing
if (process.env.EMSDK && (await fs.exists(resolve(process.env.EMSDK)))) {
logger.debug(
"Emscripten SDK already activated in environment, using existing configuration"
);
return { vars: null };
}
// Check if the emsdk directory exists
if (!(await fs.exists(emsdkDir))) {
return {
err: `Emscripten SDK directory not found at ${emsdkDir}. Please install or clone 'emsdk' and try again.`,
};
}
// Check if the emsdk directory is activated/installed properly for the current OS
match({
os: os,
...(await checkEmsdkType(emsdkDir)),
})
// If the Emscripten SDK is not activated/installed properly, exit with an error
.with(
{
nix: false,
windows: false,
},
() => {
return {
err: "Emscripten SDK does not appear to be activated/installed properly.",
};
}
)
// If the Emscripten SDK is activated for Windows, but is currently running on a *nix OS, exit with an error
.with(
{
nix: false,
windows: true,
os: { type: P.not("windows") },
},
() => {
return {
err: "Emscripten SDK appears to be activated for Windows, but is currently running on a *nix OS.",
};
}
)
// If the Emscripten SDK is activated for *nix, but is currently running on a Windows OS, exit with an error
.with(
{
nix: true,
windows: false,
os: { type: "windows" },
},
() => {
return {
err: "Emscripten SDK appears to be activated for *nix, but is currently running on a Windows OS.",
};
}
);
// Determine the environment script to use based on the OS // Determine the environment script to use based on the OS
const envScript = match(os) const envScript = match(os)
.with({ type: "windows" }, () => join(emsdkDir, "emsdk_env.bat")) .with({ type: "windows" }, () => join(emsdkDir, "emsdk_env.bat"))
@@ -171,7 +501,7 @@ async function activateEmsdk(
async function main() { async function main() {
// Print the OS detected // Print the OS detected
log( logger.debug(
"OS Detected: " + "OS Detected: " +
match(os) match(os)
.with({ type: "windows" }, () => "Windows") .with({ type: "windows" }, () => "Windows")
@@ -184,68 +514,16 @@ async function main() {
const release = process.env.RELEASE !== "0"; const release = process.env.RELEASE !== "0";
const emsdkDir = resolve("./emsdk"); const emsdkDir = resolve("./emsdk");
// Ensure the emsdk directory exists before attempting to activate or use it
if (!(await fs.exists(emsdkDir))) { // Activate the Emscripten SDK (returns null if already activated)
log( const vars = match(await activateEmsdk(emsdkDir))
`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({ vars: P.select() }, (vars) => vars)
.with({ err: P.any }, ({ err }) => { .with({ err: P.any }, ({ err }) => {
log("Error activating Emscripten SDK: " + err); logger.debug("Error activating Emscripten SDK: " + err);
process.exit(1); process.exit(1);
}) })
.exhaustive(); .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 // Build the application
await build(release, vars); await build(release, vars);
} }
@@ -254,6 +532,6 @@ async function main() {
* Main entry point. * Main entry point.
*/ */
main().catch((err) => { main().catch((err) => {
console.error("[web.build] Error:", err); console.error({ msg: "fatal error", error: err });
process.exit(1); process.exit(1);
}); });