Compare commits

...

9 Commits

17 changed files with 498 additions and 451 deletions

View File

@@ -1,4 +1,4 @@
name: Build
name: Builds
on: ["push", "pull_request"]

View File

@@ -1,4 +1,4 @@
name: Test
name: Tests
on: ["push", "pull_request"]

7
.gitignore vendored
View File

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

103
README.md
View File

@@ -1,9 +1,33 @@
# Pac-Man
If the title doesn't clue you in, I'm remaking Pac-Man with SDL and Rust.
[![Tests Status][badge-test]][test] [![Build Status][badge-build]][build] [![Code Coverage][badge-coverage]][coverage] [![Online Demo][badge-online-demo]][demo] [![Last Commit][badge-last-commit]][commits]
The project is _extremely_ early in development, but check back in a week, and maybe I'll have something cool to look
at.
[badge-test]: https://github.com/Xevion/Pac-Man/actions/workflows/test.yaml/badge.svg
[badge-build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml/badge.svg
[badge-coverage]: https://coveralls.io/repos/github/Xevion/Pac-Man/badge.svg?branch=master
[badge-demo]: https://img.shields.io/github/deployments/Xevion/Pac-Man/github-pages?label=GitHub%20Pages
[badge-online-demo]: https://img.shields.io/badge/GitHub%20Pages-Demo-brightgreen
[badge-last-commit]: https://img.shields.io/github/last-commit/Xevion/Pac-Man
[build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml
[test]: https://github.com/Xevion/Pac-Man/actions/workflows/test.yaml
[coverage]: https://coveralls.io/github/Xevion/Pac-Man?branch=master
[demo]: https://xevion.github.io/Pac-Man/
[commits]: https://github.com/Xevion/Pac-Man/commits/master
## Description
A faithful recreation of the classic Pac-Man arcade game written in Rust. This project aims to replicate the original game's mechanics, graphics, sound, and behavior as accurately as possible while providing modern development features like cross-platform compatibility and WebAssembly support.
The game includes all the original features you'd expect from Pac-Man:
- [x] Classic maze navigation and dot collection
- [ ] Four ghosts with their unique AI behaviors (Blinky, Pinky, Inky, and Clyde)
- [ ] Power pellets that allow Pac-Man to eat ghosts
- [ ] Fruit bonuses that appear periodically
- [ ] Progressive difficulty with faster ghosts and shorter power pellet duration
- [x] Authentic sound effects and sprites
Built with SDL2 for cross-platform graphics and audio, this implementation can run on Windows, Linux, macOS, and in web browsers via WebAssembly.
## Feature Targets
@@ -12,68 +36,29 @@ at.
- Online demo, playable in a browser.
- Automatic build system, with releases for Windows, Linux, and Mac & Web-Assembly.
- Debug tooling
- Game state visualization
- Game speed controls + pausing
- Log tracing
- Performance details
- Game state visualization
- Game speed controls + pausing
- Log tracing
- Performance details
## Experimental Ideas
- Perfected Ghost Algorithms
- More than 4 ghosts
- Custom Level Generation
- Multi-map tunnelling
- Multi-map tunnelling
- Online Scoreboard
- WebAssembly build contains a special API key for communicating with server.
- To prevent abuse, the server will only accept scores from the WebAssembly build.
- WebAssembly build contains a special API key for communicating with server.
- To prevent abuse, the server will only accept scores from the WebAssembly build.
## Installation
## Build Notes
Besides SDL2, the following extensions are required: Image, Mixer, and TTF.
### Ubuntu
On Ubuntu, you can install the required packages with the following command:
```
sudo apt install libsdl2-dev libsdl2-image-dev libsdl2-mixer-dev libsdl2-ttf-dev
```
### Windows
On Windows, installation requires either building from source (not covered), or downloading the pre-built binaries.
The latest releases can be found here:
- [SDL2](https://github.com/libsdl-org/SDL/releases/latest/)
- [SDL2_image](https://github.com/libsdl-org/SDL_image/releases/latest/)
- [SDL2_mixer](https://github.com/libsdl-org/SDL_mixer/releases/latest/)
- [SDL2_ttf](https://github.com/libsdl-org/SDL_ttf/releases/latest/)
Download each for your architecture, and locate the appropriately named DLL within. Move said DLL to root of this project.
In total, you should have the following DLLs in the root of the project:
- SDL2.dll
- SDL2_mixer.dll
- SDL2_ttf.dll
- SDL2_image.dll
- libpngX-X.dll
- Not sure on what specific version is to be used, or if naming matters. `libpng16-16.dll` is what I had used.
- zlib1.dll
## Building
To build the project, run the following command:
```
cargo build
```
During development, you can easily run the project with:
```
cargo run
cargo run -q # Quiet mode, no logging
cargo run --release # Release mode, optimized
```
- Install `cargo-vcpkg` with `cargo install cargo-vcpkg`, then run `cargo vcpkg build` to build the requisite dependencies via vcpkg.
- For the WASM build, you need to have the Emscripten SDK cloned; you can do so with `git clone https://github.com/emscripten-core/emsdk.git`
- The first time you clone, you'll need to install the appropriate SDK version with `./emsdk install 3.1.43` and then activate it with `./emsdk activate 3.1.43`. On Windows, use `./emsdk/emsdk.ps1` instead.
- You can then activate the Emscripten SDK with `source ./emsdk/emsdk_env.sh` or `./emsdk/emsdk_env.ps1` or `./emsdk/emsdk_env.bat` depending on your OS/terminal.
- While using the `web.build.ts` is not technically required, it simplifies the build process and is very helpful.
- It is intended to be run with `bun`, which you can acquire at [bun.sh](https://bun.sh/)
- Tip: You can launch a fileserver with `python` or `caddy` to serve the files in the `dist` folder.
- `python3 -m http.server 8080 -d dist`
- `caddy file-server --root dist` (install with `[sudo apt|brew|choco] install caddy` or [a dozen other ways](https://caddyserver.com/docs/install))

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 B

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

View File

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

View File

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

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

View File

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

201
build.ts
View File

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

View File

@@ -92,6 +92,8 @@ impl App<'_> {
}
_ => {}
},
// It doesn't really make sense to have this available in the browser
#[cfg(not(target_os = "emscripten"))]
Event::Quit { .. }
| Event::KeyDown {
keycode: Some(Keycode::Escape) | Some(Keycode::Q),

View File

@@ -5,6 +5,16 @@ use super::direction::Direction;
/// A unique identifier for a node, represented by its index in the graph's storage.
pub type NodeId = usize;
/// Defines who can traverse a given edge.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EdgePermissions {
/// Anyone can use this edge.
#[default]
All,
/// Only ghosts can use this edge.
GhostsOnly,
}
/// Represents a directed edge from one node to another with a given weight (e.g., distance).
#[derive(Debug, Clone, Copy)]
pub struct Edge {
@@ -14,6 +24,8 @@ pub struct Edge {
pub distance: f32,
/// The cardinal direction of this edge.
pub direction: Direction,
/// Defines who is allowed to traverse this edge.
pub permissions: EdgePermissions,
}
/// Represents a node in the graph, defined by its position.
@@ -121,8 +133,8 @@ impl Graph {
return Err("To node does not exist.");
}
let edge_a = self.add_edge(from, to, replace, distance, direction);
let edge_b = self.add_edge(to, from, replace, distance, direction.opposite());
let edge_a = self.add_edge(from, to, replace, distance, direction, EdgePermissions::default());
let edge_b = self.add_edge(to, from, replace, distance, direction.opposite(), EdgePermissions::default());
if edge_a.is_err() && edge_b.is_err() {
return Err("Failed to connect nodes in both directions.");
@@ -150,6 +162,7 @@ impl Graph {
replace: bool,
distance: Option<f32>,
direction: Direction,
permissions: EdgePermissions,
) -> Result<(), &'static str> {
let edge = Edge {
target: to,
@@ -168,6 +181,7 @@ impl Graph {
}
},
direction,
permissions,
};
if from >= self.adjacency_list.len() {
@@ -295,7 +309,10 @@ impl Traverser {
/// Creates a new traverser starting at the given node ID.
///
/// The traverser will immediately attempt to start moving in the initial direction.
pub fn new(graph: &Graph, start_node: NodeId, initial_direction: Direction) -> Self {
pub fn new<F>(graph: &Graph, start_node: NodeId, initial_direction: Direction, can_traverse: &F) -> Self
where
F: Fn(Edge) -> bool,
{
let mut traverser = Traverser {
position: Position::AtNode(start_node),
direction: initial_direction,
@@ -303,7 +320,7 @@ impl Traverser {
};
// This will kickstart the traverser into motion
traverser.advance(graph, 0.0);
traverser.advance(graph, 0.0, can_traverse);
traverser
}
@@ -329,7 +346,10 @@ impl Traverser {
/// - If it reaches a node, it attempts to transition to a new edge based on
/// the buffered direction or by continuing straight.
/// - If no valid move is possible, it stops at the node.
pub fn advance(&mut self, graph: &Graph, distance: f32) {
pub fn advance<F>(&mut self, graph: &Graph, distance: f32, can_traverse: &F)
where
F: Fn(Edge) -> bool,
{
// Decrement the remaining frames for the next direction
if let Some((direction, remaining)) = self.next_direction {
if remaining > 0 {
@@ -344,13 +364,15 @@ impl Traverser {
// We're not moving, but a buffered direction is available.
if let Some((next_direction, _)) = self.next_direction {
if let Some(edge) = graph.find_edge_in_direction(node_id, next_direction) {
// Start moving in that direction
self.position = Position::BetweenNodes {
from: node_id,
to: edge.target,
traversed: distance.max(0.0),
};
self.direction = 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
@@ -382,26 +404,33 @@ impl Traverser {
// If we buffered a direction, try to find an edge in that direction
if let Some((next_dir, _)) = self.next_direction {
if let Some(edge) = graph.find_edge_in_direction(to, next_dir) {
self.position = Position::BetweenNodes {
from: to,
to: edge.target,
traversed: overflow,
};
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;
self.direction = next_dir; // Remember our new direction
self.next_direction = None; // Consume the buffered direction
moved = true;
}
}
}
// If we didn't move, try to continue in the current direction
if !moved {
if let Some(edge) = graph.find_edge_in_direction(to, self.direction) {
self.position = Position::BetweenNodes {
from: to,
to: edge.target,
traversed: overflow,
};
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;
@@ -577,6 +606,7 @@ mod tests {
target: 1,
distance: 10.0,
direction: Direction::Up,
permissions: EdgePermissions::All,
},
);
intersection.set(
@@ -585,6 +615,7 @@ mod tests {
target: 2,
distance: 15.0,
direction: Direction::Right,
permissions: EdgePermissions::All,
},
);
@@ -607,6 +638,7 @@ mod tests {
target: 1,
distance: 10.0,
direction: Direction::Up,
permissions: EdgePermissions::All,
};
intersection.set(Direction::Up, edge);
@@ -624,6 +656,7 @@ mod tests {
target: 1,
distance: 10.0,
direction: Direction::Left,
permissions: EdgePermissions::All,
};
intersection.set(Direction::Left, edge);
@@ -687,7 +720,7 @@ mod tests {
#[test]
fn test_traverser_new() {
let graph = create_test_graph();
let traverser = Traverser::new(&graph, 0, Direction::Left);
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
@@ -698,7 +731,7 @@ mod tests {
#[test]
fn test_traverser_set_next_direction() {
let graph = create_test_graph();
let mut traverser = Traverser::new(&graph, 0, Direction::Left);
let mut traverser = Traverser::new(&graph, 0, Direction::Left, &|_| true);
traverser.set_next_direction(Direction::Up);
assert!(traverser.next_direction.is_some());
@@ -712,10 +745,10 @@ mod tests {
#[test]
fn test_traverser_advance_at_node() {
let graph = create_test_graph();
let mut traverser = Traverser::new(&graph, 0, Direction::Right);
let mut traverser = Traverser::new(&graph, 0, Direction::Right, &|_| true);
// Should start moving in the initial direction
traverser.advance(&graph, 5.0);
traverser.advance(&graph, 5.0, &|_| true);
match traverser.position {
Position::BetweenNodes { from, to, traversed } => {
@@ -730,13 +763,13 @@ mod tests {
#[test]
fn test_traverser_advance_between_nodes() {
let graph = create_test_graph();
let mut traverser = Traverser::new(&graph, 0, Direction::Right);
let mut traverser = Traverser::new(&graph, 0, Direction::Right, &|_| true);
// Move to between nodes
traverser.advance(&graph, 5.0);
traverser.advance(&graph, 5.0, &|_| true);
// Advance further
traverser.advance(&graph, 3.0);
traverser.advance(&graph, 3.0, &|_| true);
match traverser.position {
Position::BetweenNodes { from, to, traversed } => {
@@ -754,6 +787,7 @@ mod tests {
target: 5,
distance: 10.5,
direction: Direction::Up,
permissions: EdgePermissions::All,
};
assert_eq!(edge.target, 5);

View File

@@ -2,7 +2,7 @@ use glam::{UVec2, Vec2};
use crate::constants::BOARD_PIXEL_OFFSET;
use crate::entity::direction::Direction;
use crate::entity::graph::{Graph, NodeId, Position, Traverser};
use crate::entity::graph::{Edge, EdgePermissions, Graph, NodeId, Position, Traverser};
use crate::helpers::centered_with_size;
use crate::texture::animated::AnimatedTexture;
use crate::texture::directional::DirectionalAnimatedTexture;
@@ -11,6 +11,10 @@ use sdl2::keyboard::Keycode;
use sdl2::render::{Canvas, RenderTarget};
use std::collections::HashMap;
fn can_pacman_traverse(edge: Edge) -> bool {
matches!(edge.permissions, EdgePermissions::All)
}
pub struct Pacman {
pub traverser: Traverser,
texture: DirectionalAnimatedTexture,
@@ -47,13 +51,13 @@ impl Pacman {
}
Self {
traverser: Traverser::new(graph, start_node, Direction::Left),
traverser: Traverser::new(graph, start_node, Direction::Left, &can_pacman_traverse),
texture: DirectionalAnimatedTexture::new(textures, stopped_textures),
}
}
pub fn tick(&mut self, dt: f32, graph: &Graph) {
self.traverser.advance(graph, dt * 60.0 * 1.125);
self.traverser.advance(graph, dt * 60.0 * 1.125, &can_pacman_traverse);
self.texture.tick(dt);
}
@@ -281,7 +285,7 @@ mod tests {
let mut pacman = Pacman::new(&graph, 0, &atlas);
// Move pacman between nodes - need to advance with a larger distance to ensure movement
pacman.traverser.advance(&graph, 5.0); // Larger advance to ensure movement
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

View File

@@ -2,7 +2,7 @@
use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE};
use crate::entity::direction::{Direction, DIRECTIONS};
use crate::entity::graph::{Graph, Node, NodeId};
use crate::entity::graph::{EdgePermissions, Graph, Node, NodeId};
use crate::map::parser::MapTileParser;
use crate::map::render::MapRenderer;
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
@@ -259,10 +259,29 @@ impl Map {
// Create the center line
let (center_center_node_id, center_top_node_id) = create_house_line(graph, center_line_center_position);
// Connect the house entrance to the top line
// Create a ghost-only, two-way connection for the house door.
// This prevents Pac-Man from entering or exiting through the door.
graph
.connect(house_entrance_node_id, center_top_node_id, false, None, Direction::Down)
.expect("Failed to connect house entrance to top line");
.add_edge(
house_entrance_node_id,
center_top_node_id,
false,
None,
Direction::Down,
EdgePermissions::GhostsOnly,
)
.expect("Failed to create ghost-only entrance to house");
graph
.add_edge(
center_top_node_id,
house_entrance_node_id,
false,
None,
Direction::Up,
EdgePermissions::GhostsOnly,
)
.expect("Failed to create ghost-only exit from house");
// Create the left line
let (left_center_node_id, _) = create_house_line(

261
web.build.ts Normal file
View File

@@ -0,0 +1,261 @@
import { $ } from "bun";
import { existsSync, promises as fs } from "fs";
import { platform } from "os";
import { dirname, join, relative, resolve } from "path";
import { match, P } from "ts-pattern";
type Os =
| { type: "linux"; wsl: boolean }
| { type: "windows" }
| { type: "macos" };
const os: Os = match(platform())
.with("win32", () => ({ type: "windows" as const }))
.with("linux", () => ({
type: "linux" as const,
// We detect WSL by checking for the presence of the WSLInterop file.
// This is a semi-standard method of detecting WSL, which is more than workable for this already hacky script.
wsl: existsSync("/proc/sys/fs/binfmt_misc/WSLInterop"),
}))
.with("darwin", () => ({ type: "macos" as const }))
.otherwise(() => {
throw new Error(`Unsupported platform: ${platform()}`);
});
function log(msg: string) {
console.log(`[web.build] ${msg}`);
}
/**
* Build the application with Emscripten, generate the CSS, and copy the files into 'dist'.
*
* @param release - Whether to build in release mode.
* @param env - The environment variables to inject into build commands.
*/
async function build(release: boolean, env: Record<string, string>) {
log(
`Building for 'wasm32-unknown-emscripten' for ${
release ? "release" : "debug"
}`
);
await $`cargo build --target=wasm32-unknown-emscripten ${
release ? "--release" : ""
}`.env(env);
log("Generating CSS");
await $`npx @tailwindcss/cli -i ./assets/site/styles.css -o ./assets/site/build.css`;
const buildType = release ? "release" : "debug";
const siteFolder = resolve("assets/site");
const outputFolder = resolve(`target/wasm32-unknown-emscripten/${buildType}`);
const dist = resolve("dist");
// The files to copy into 'dist'
const files = [
...["index.html", "favicon.ico", "build.css", "TerminalVector.ttf"].map(
(file) => ({
src: join(siteFolder, file),
dest: join(dist, file),
optional: false,
})
),
...["pacman.wasm", "pacman.js", "deps/pacman.data"].map((file) => ({
src: join(outputFolder, file),
dest: join(dist, file.split("/").pop() || file),
optional: false,
})),
{
src: join(outputFolder, "pacman.wasm.map"),
dest: join(dist, "pacman.wasm.map"),
optional: true,
},
];
// Create required destination folders
await Promise.all(
// Get the dirname of files, remove duplicates
[...new Set(files.map(({ dest }) => dirname(dest)))]
// Create the folders
.map(async (dir) => {
// If the folder doesn't exist, create it
if (!(await fs.exists(dir))) {
log(`Creating folder ${dir}`);
await fs.mkdir(dir, { recursive: true });
}
})
);
// Copy the files to the dist folder
log("Copying files into dist");
await Promise.all(
files.map(async ({ optional, src, dest }) => {
match({ optional, exists: await fs.exists(src) })
// If optional and doesn't exist, skip
.with({ optional: true, exists: false }, () => {
log(
`Optional file ${os.type === "windows" ? "\\" : "/"}${relative(
process.cwd(),
src
)} does not exist, skipping...`
);
})
// If not optional and doesn't exist, throw an error
.with({ optional: false, exists: false }, () => {
throw new Error(`Required file ${src} does not exist`);
})
// Otherwise, copy the file
.otherwise(async () => await fs.copyFile(src, dest));
})
);
}
/**
* Checks to see if the Emscripten SDK is activated for a Windows or *nix machine by looking for a .exe file and the equivalent file on Linux/macOS. Returns both results for handling.
* @param emsdkDir - The directory containing the Emscripten SDK.
* @returns A record of environment variables.
*/
async function checkEmsdkType(
emsdkDir: string
): Promise<{ windows: boolean; nix: boolean }> {
const binary = resolve(join(emsdkDir, "upstream", "bin", "clang"));
return {
windows: await fs.exists(binary + ".exe"),
nix: await fs.exists(binary),
};
}
/**
* Activate the Emscripten SDK environment variables.
* Technically, this doesn't actaully activate the environment variables for the current shell,
* it just runs the environment sourcing script and returns the environment variables for future command invocations.
* @param emsdkDir - The directory containing the Emscripten SDK.
* @returns A record of environment variables.
*/
async function activateEmsdk(
emsdkDir: string
): Promise<{ vars: Record<string, string> } | { err: string }> {
// Determine the environment script to use based on the OS
const envScript = match(os)
.with({ type: "windows" }, () => join(emsdkDir, "emsdk_env.bat"))
.with({ type: P.union("linux", "macos") }, () =>
join(emsdkDir, "emsdk_env.sh")
)
.exhaustive();
// Run the environment script and capture the output
const { stdout, stderr, exitCode } = await match(os)
.with({ type: "windows" }, () =>
// run the script, ignore it's output ('>nul'), then print the environment variables ('set')
$`cmd /c "${envScript} >nul && set"`.quiet()
)
.with({ type: P.union("linux", "macos") }, () =>
// run the script with bash, ignore it's output ('> /dev/null'), then print the environment variables ('env')
$`bash -c "source '${envScript}' && env"`.quiet()
)
.exhaustive();
if (exitCode !== 0) {
return { err: stderr.toString() };
}
// Parse the output into a record of environment variables
const vars = Object.fromEntries(
stdout
.toString()
.split(os.type === "windows" ? /\r?\n/ : "\n") // Split output into lines, handling Windows CRLF vs *nix LF
.map((line) => line.split("=", 2)) // Parse each line as KEY=VALUE (limit to 2 parts)
.filter(([k, v]) => k && v) // Keep only valid key-value pairs (both parts exist)
);
return { vars };
}
async function main() {
// Print the OS detected
log(
"OS Detected: " +
match(os)
.with({ type: "windows" }, () => "Windows")
.with({ type: "linux" }, ({ wsl: isWsl }) =>
isWsl ? "Linux (via WSL)" : "Linux"
)
.with({ type: "macos" }, () => "macOS")
.exhaustive()
);
const release = process.env.RELEASE !== "0";
const emsdkDir = resolve("./emsdk");
// Ensure the emsdk directory exists before attempting to activate or use it
if (!(await fs.exists(emsdkDir))) {
log(
`Emscripten SDK directory not found at ${emsdkDir}. Please install or clone 'emsdk' and try again.`
);
process.exit(1);
}
const vars = match(await activateEmsdk(emsdkDir)) // result handling
.with({ vars: P.select() }, (vars) => vars)
.with({ err: P.any }, ({ err }) => {
log("Error activating Emscripten SDK: " + err);
process.exit(1);
})
.exhaustive();
// Check if the Emscripten SDK is activated/installed properly for the current OS
match({
os: os,
...(await checkEmsdkType(emsdkDir)),
})
// If the Emscripten SDK is not activated/installed properly, exit with an error
.with(
{
nix: false,
windows: false,
},
() => {
log(
"Emscripten SDK does not appear to be activated/installed properly."
);
process.exit(1);
}
)
// If the Emscripten SDK is activated for Windows, but is currently running on a *nix OS, exit with an error
.with(
{
nix: false,
windows: true,
os: { type: P.not("windows") },
},
() => {
log(
"Emscripten SDK appears to be activated for Windows, but is currently running on a *nix OS."
);
process.exit(1);
}
)
// If the Emscripten SDK is activated for *nix, but is currently running on a Windows OS, exit with an error
.with(
{
nix: true,
windows: false,
os: { type: "windows" },
},
() => {
log(
"Emscripten SDK appears to be activated for *nix, but is currently running on a Windows OS."
);
process.exit(1);
}
);
// Build the application
await build(release, vars);
}
/**
* Main entry point.
*/
main().catch((err) => {
console.error("[web.build] Error:", err);
process.exit(1);
});