Compare commits

...

8 Commits

15 changed files with 464 additions and 390 deletions

7
.gitignore vendored
View File

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

View File

@@ -50,3 +50,15 @@ Built with SDL2 for cross-platform graphics and audio, this implementation can r
- 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.
## Build Notes
- 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);
});