Compare commits

..

6 Commits

10 changed files with 218 additions and 98 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

@@ -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

@@ -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(

View File

@@ -43,7 +43,7 @@ async function build(release: boolean, env: Record<string, string>) {
}`.env(env);
log("Generating CSS");
await $`pnpx postcss-cli ./assets/site/styles.scss -o ./assets/site/build.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");
@@ -52,11 +52,13 @@ async function build(release: boolean, env: Record<string, string>) {
// The files to copy into 'dist'
const files = [
...["index.html", "favicon.ico", "build.css"].map((file) => ({
src: join(siteFolder, file),
dest: join(dist, file),
optional: false,
})),
...["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),
@@ -184,6 +186,13 @@ async function main() {
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 }) => {