Compare commits

..

6 Commits

17 changed files with 356 additions and 440 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"]

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,17 @@ 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.
## Installation
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
```
- WebAssembly build contains a special API key for communicating with server.
- To prevent abuse, the server will only accept scores from the WebAssembly build.

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

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

@@ -33,7 +33,7 @@ pub struct App<'a> {
last_tick: Instant,
}
impl<'a> App<'a> {
impl App<'_> {
pub fn new() -> Result<Self> {
let sdl_context = sdl2::init().map_err(|e| anyhow!(e))?;
let video_subsystem = sdl_context.video().map_err(|e| anyhow!(e))?;

View File

@@ -157,6 +157,7 @@ impl Audio {
}
/// Returns `true` if the audio system is disabled.
#[allow(dead_code)]
pub fn is_disabled(&self) -> bool {
self.disabled
}

View File

@@ -37,8 +37,6 @@ pub enum MapTile {
Pellet,
/// A power pellet.
PowerPellet,
/// A starting position for an entity.
StartingPosition(u8),
/// A tunnel tile.
Tunnel,
}
@@ -68,7 +66,7 @@ pub const RAW_BOARD: [&str; BOARD_CELL_SIZE.y as usize] = [
"#............##............#",
"#.####.#####.##.#####.####.#",
"#.####.#####.##.#####.####.#",
"#o..##.......0 .......##..o#",
"#o..##.......X .......##..o#",
"###.##.##.########.##.##.###",
"###.##.##.########.##.##.###",
"#......##....##....##......#",
@@ -139,30 +137,12 @@ mod tests {
fn test_map_tile_variants() {
assert_ne!(MapTile::Empty, MapTile::Wall);
assert_ne!(MapTile::Pellet, MapTile::PowerPellet);
assert_ne!(MapTile::StartingPosition(0), MapTile::StartingPosition(1));
assert_ne!(MapTile::Tunnel, MapTile::Empty);
}
#[test]
fn test_map_tile_starting_position() {
let pos0 = MapTile::StartingPosition(0);
let pos1 = MapTile::StartingPosition(1);
let pos0_clone = MapTile::StartingPosition(0);
assert_eq!(pos0, pos0_clone);
assert_ne!(pos0, pos1);
}
#[test]
fn test_map_tile_debug() {
let tile = MapTile::Wall;
let debug_str = format!("{:?}", tile);
assert!(!debug_str.is_empty());
}
#[test]
fn test_map_tile_clone() {
let original = MapTile::StartingPosition(5);
let original = MapTile::Wall;
let cloned = original;
assert_eq!(original, cloned);
}
@@ -217,10 +197,10 @@ mod tests {
#[test]
fn test_raw_board_starting_position() {
// Should have a starting position '0' for Pac-Man
// Should have a starting position 'X' for Pac-Man
let mut found_starting_position = false;
for row in RAW_BOARD.iter() {
if row.contains('0') {
if row.contains('X') {
found_starting_position = true;
break;
}

View File

@@ -11,6 +11,8 @@ use sdl2::render::{Canvas, RenderTarget};
use std::collections::{HashMap, VecDeque};
use tracing::debug;
/// The starting positions of the entities in the game.
#[allow(dead_code)]
pub struct NodePositions {
pub pacman: NodeId,
pub blinky: NodeId,
@@ -19,20 +21,20 @@ pub struct NodePositions {
pub clyde: NodeId,
}
/// The game map, responsible for holding the tile-based layout and the navigation graph.
///
/// The map is represented as a 2D array of `MapTile`s. It also stores a navigation
/// `Graph` that entities like Pac-Man and ghosts use for movement. The graph is
/// generated from the walkable tiles of the map.
/// The main map structure containing the game board and navigation graph.
pub struct Map {
/// The current state of the map.
#[allow(dead_code)]
current: [[MapTile; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize],
/// The node map for entity movement.
pub graph: Graph,
/// A mapping from grid positions to node IDs.
pub grid_to_node: HashMap<IVec2, NodeId>,
/// A mapping of the starting positions of the entities.
#[allow(dead_code)]
pub start_positions: NodePositions,
/// Pac-Man's starting position.
pacman_start: Option<IVec2>,
}
impl Map {
@@ -51,6 +53,7 @@ impl Map {
let map = parsed_map.tiles;
let house_door = parsed_map.house_door;
let tunnel_ends = parsed_map.tunnel_ends;
let pacman_start = parsed_map.pacman_start;
let mut graph = Graph::new();
let mut grid_to_node = HashMap::new();
@@ -58,25 +61,7 @@ impl Map {
let cell_offset = Vec2::splat(CELL_SIZE as f32 / 2.0);
// Find a starting point for the graph generation, preferably Pac-Man's position.
let start_pos = (0..BOARD_CELL_SIZE.y)
.flat_map(|y| (0..BOARD_CELL_SIZE.x).map(move |x| IVec2::new(x as i32, y as i32)))
.find(|&p| matches!(map[p.x as usize][p.y as usize], MapTile::StartingPosition(0)))
.unwrap_or_else(|| {
// Fallback to any valid walkable tile if Pac-Man's start is not found
(0..BOARD_CELL_SIZE.y)
.flat_map(|y| (0..BOARD_CELL_SIZE.x).map(move |x| IVec2::new(x as i32, y as i32)))
.find(|&p| {
matches!(
map[p.x as usize][p.y as usize],
MapTile::Pellet
| MapTile::PowerPellet
| MapTile::Empty
| MapTile::Tunnel
| MapTile::StartingPosition(_)
)
})
.expect("No valid starting position found on map for graph generation")
});
let start_pos = pacman_start.expect("Pac-Man's starting position not found");
// Add the starting position to the graph/queue
let mut queue = VecDeque::new();
@@ -110,7 +95,7 @@ impl Map {
// Skip if the new position is not a walkable tile
if matches!(
map[new_position.x as usize][new_position.y as usize],
MapTile::Pellet | MapTile::PowerPellet | MapTile::Empty | MapTile::Tunnel | MapTile::StartingPosition(_)
MapTile::Pellet | MapTile::PowerPellet | MapTile::Empty | MapTile::Tunnel
) {
// Add the new position to the graph/queue
let pos = Vec2::new(
@@ -167,9 +152,10 @@ impl Map {
Map {
current: map,
grid_to_node,
graph,
grid_to_node,
start_positions,
pacman_start,
}
}
@@ -183,14 +169,9 @@ impl Map {
///
/// The starting position as a grid coordinate (`UVec2`), or `None` if not found.
pub fn find_starting_position(&self, entity_id: u8) -> Option<UVec2> {
for (x, col) in self.current.iter().enumerate().take(BOARD_CELL_SIZE.x as usize) {
for (y, &cell) in col.iter().enumerate().take(BOARD_CELL_SIZE.y as usize) {
if let MapTile::StartingPosition(id) = cell {
if id == entity_id {
return Some(UVec2::new(x as u32, y as u32));
}
}
}
// For now, only Pac-Man (entity_id 0) is supported
if entity_id == 0 {
return self.pacman_start.map(|pos| UVec2::new(pos.x as u32, pos.y as u32));
}
None
}
@@ -373,7 +354,7 @@ impl Map {
mod tests {
use super::*;
use crate::constants::{BOARD_CELL_SIZE, CELL_SIZE};
use glam::{IVec2, UVec2, Vec2};
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];
@@ -401,7 +382,7 @@ mod tests {
board[20] = "#............##............#";
board[21] = "#.####.#####.##.#####.####.#";
board[22] = "#.####.#####.##.#####.####.#";
board[23] = "#o..##.......0 .......##..o#";
board[23] = "#o..##.......X .......##..o#";
board[24] = "###.##.##.########.##.##.###";
board[25] = "###.##.##.########.##.##.###";
board[26] = "#......##....##....##......#";
@@ -517,7 +498,7 @@ mod tests {
// Check that adjacent walkable tiles are connected
// Find any node that has connections
let mut found_connected_node = false;
for (grid_pos, &node_id) in &map.grid_to_node {
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;

View File

@@ -22,6 +22,8 @@ pub struct ParsedMap {
pub house_door: [Option<IVec2>; 2],
/// The positions of the tunnel end tiles.
pub tunnel_ends: [Option<IVec2>; 2],
/// Pac-Man's starting position.
pub pacman_start: Option<IVec2>,
}
/// Parser for converting raw board layouts into structured map data.
@@ -44,8 +46,8 @@ impl MapTileParser {
'o' => Ok(MapTile::PowerPellet),
' ' => Ok(MapTile::Empty),
'T' => Ok(MapTile::Tunnel),
c @ '0'..='4' => Ok(MapTile::StartingPosition(c.to_digit(10).unwrap() as u8)),
'=' => Ok(MapTile::Wall), // House door is represented as a wall tile
'X' => Ok(MapTile::Empty), // Pac-Man's starting position, treated as empty
'=' => Ok(MapTile::Wall), // House door is represented as a wall tile
_ => Err(ParseError::UnknownCharacter(c)),
}
}
@@ -68,6 +70,7 @@ impl MapTileParser {
let mut tiles = [[MapTile::Empty; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize];
let mut house_door = [None; 2];
let mut tunnel_ends = [None; 2];
let mut pacman_start: Option<IVec2> = None;
for (y, line) in raw_board.iter().enumerate().take(BOARD_CELL_SIZE.y as usize) {
for (x, character) in line.chars().enumerate().take(BOARD_CELL_SIZE.x as usize) {
@@ -92,6 +95,11 @@ impl MapTileParser {
_ => {}
}
// Track Pac-Man's starting position
if character == 'X' {
pacman_start = Some(IVec2::new(x as i32, y as i32));
}
tiles[x][y] = tile;
}
}
@@ -106,6 +114,7 @@ impl MapTileParser {
tiles,
house_door,
tunnel_ends,
pacman_start,
})
}
}
@@ -122,18 +131,11 @@ mod tests {
assert!(matches!(MapTileParser::parse_character('o').unwrap(), MapTile::PowerPellet));
assert!(matches!(MapTileParser::parse_character(' ').unwrap(), MapTile::Empty));
assert!(matches!(MapTileParser::parse_character('T').unwrap(), MapTile::Tunnel));
assert!(matches!(
MapTileParser::parse_character('0').unwrap(),
MapTile::StartingPosition(0)
));
assert!(matches!(
MapTileParser::parse_character('4').unwrap(),
MapTile::StartingPosition(4)
));
assert!(matches!(MapTileParser::parse_character('X').unwrap(), MapTile::Empty));
assert!(matches!(MapTileParser::parse_character('=').unwrap(), MapTile::Wall));
// Test invalid character
assert!(MapTileParser::parse_character('X').is_err());
assert!(MapTileParser::parse_character('Z').is_err());
}
#[test]
@@ -154,15 +156,18 @@ mod tests {
// 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] = "###########################X";
invalid_board[0] = "###########################Z";
let result = MapTileParser::parse_board(invalid_board);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ParseError::UnknownCharacter('X')));
assert!(matches!(result.unwrap_err(), ParseError::UnknownCharacter('Z')));
}
}

View File

@@ -50,19 +50,26 @@ impl AnimatedTexture {
tile.render(canvas, atlas, dest)
}
// Helper methods for testing
/// Returns the current frame index.
#[allow(dead_code)]
pub fn current_frame(&self) -> usize {
self.current_frame
}
/// Returns the time bank.
#[allow(dead_code)]
pub fn time_bank(&self) -> f32 {
self.time_bank
}
/// Returns the frame duration.
#[allow(dead_code)]
pub fn frame_duration(&self) -> f32 {
self.frame_duration
}
/// Returns the number of tiles in the animation.
#[allow(dead_code)]
pub fn tiles_len(&self) -> usize {
self.tiles.len()
}

View File

@@ -55,19 +55,26 @@ impl DirectionalAnimatedTexture {
}
}
// Helper methods for testing
/// Returns true if the texture has a direction.
#[allow(dead_code)]
pub fn has_direction(&self, direction: Direction) -> bool {
self.textures.contains_key(&direction)
}
/// Returns true if the texture has a stopped direction.
#[allow(dead_code)]
pub fn has_stopped_direction(&self, direction: Direction) -> bool {
self.stopped_textures.contains_key(&direction)
}
/// Returns the number of textures.
#[allow(dead_code)]
pub fn texture_count(&self) -> usize {
self.textures.len()
}
/// Returns the number of stopped textures.
#[allow(dead_code)]
pub fn stopped_texture_count(&self) -> usize {
self.stopped_textures.len()
}

View File

@@ -50,11 +50,14 @@ impl AtlasTile {
Ok(())
}
// Helper methods for testing
/// Creates a new atlas tile.
#[allow(dead_code)]
pub fn new(pos: U16Vec2, size: U16Vec2, color: Option<Color>) -> Self {
Self { pos, size, color }
}
/// Sets the color of the tile.
#[allow(dead_code)]
pub fn with_color(mut self, color: Color) -> Self {
self.color = Some(color);
self
@@ -96,15 +99,20 @@ impl SpriteAtlas {
&self.texture
}
// Helper methods for testing
/// Returns the number of tiles in the atlas.
#[allow(dead_code)]
pub fn tiles_count(&self) -> usize {
self.tiles.len()
}
/// Returns true if the atlas has a tile with the given name.
#[allow(dead_code)]
pub fn has_tile(&self, name: &str) -> bool {
self.tiles.contains_key(name)
}
/// Returns the default color of the atlas.
#[allow(dead_code)]
pub fn default_color(&self) -> Option<Color> {
self.default_color
}

252
web.build.ts Normal file
View File

@@ -0,0 +1,252 @@
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 $`pnpx postcss-cli ./assets/site/styles.scss -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"].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");
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);
});