mirror of
https://github.com/Xevion/Pac-Man.git
synced 2025-12-06 07:15:41 -06:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d275b8e85 | |||
| bd61db9aae | |||
| ed8bd07518 | |||
| 27705f1ba2 | |||
| e964adc818 | |||
| c5213320ac | |||
| e0f8443e75 | |||
| 6702b3723a | |||
| f6e7228f75 | |||
| 14cebe4462 | |||
| c39fcaa7d7 | |||
| 1d9499c4f8 | |||
| 61050a5585 |
27
.github/workflows/audit.yaml
vendored
27
.github/workflows/audit.yaml
vendored
@@ -1,27 +0,0 @@
|
||||
name: Audit
|
||||
|
||||
on: ["push", "pull_request"]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_TOOLCHAIN: 1.88.0
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
name: Audit
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||
|
||||
- name: Install cargo-audit
|
||||
run: cargo install cargo-audit
|
||||
|
||||
- name: Run security audit
|
||||
run: cargo audit
|
||||
3
.github/workflows/build.yaml
vendored
3
.github/workflows/build.yaml
vendored
@@ -1,5 +1,4 @@
|
||||
name: Builds
|
||||
|
||||
on: ["push", "pull_request"]
|
||||
|
||||
permissions:
|
||||
@@ -120,7 +119,7 @@ jobs:
|
||||
echo "Build attempt $attempt of $MAX_RETRIES"
|
||||
|
||||
# Capture output and check for specific error while preserving real-time output
|
||||
if bun run web.build.ts 2>&1 | tee /tmp/build_output.log; then
|
||||
if bun run -i web.build.ts 2>&1 | tee /tmp/build_output.log; then
|
||||
echo "Build successful on attempt $attempt"
|
||||
break
|
||||
else
|
||||
|
||||
3
.github/workflows/coverage.yaml
vendored
3
.github/workflows/coverage.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Coverage
|
||||
name: Code Coverage
|
||||
|
||||
on: ["push", "pull_request"]
|
||||
|
||||
@@ -8,7 +8,6 @@ env:
|
||||
|
||||
jobs:
|
||||
coverage:
|
||||
name: Code Coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Tests
|
||||
name: Tests & Checks
|
||||
|
||||
on: ["push", "pull_request"]
|
||||
|
||||
@@ -8,7 +8,6 @@ env:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -52,3 +51,8 @@ jobs:
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt -- --check
|
||||
|
||||
- uses: taiki-e/install-action@cargo-audit
|
||||
|
||||
- name: Run security audit
|
||||
run: cargo audit
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -192,6 +192,7 @@ dependencies = [
|
||||
"sdl2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"smallvec",
|
||||
"spin_sleep",
|
||||
"thiserror 1.0.69",
|
||||
"tracing",
|
||||
|
||||
@@ -20,6 +20,7 @@ anyhow = "1.0"
|
||||
glam = { version = "0.30.4", features = [] }
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = "1.0.141"
|
||||
smallvec = "1.15.1"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
|
||||
51
README.md
51
README.md
@@ -2,20 +2,18 @@
|
||||
|
||||
[![Tests Status][badge-test]][test] [![Build Status][badge-build]][build] [![Code Coverage][badge-coverage]][coverage] [![Online Demo][badge-online-demo]][demo] [![Last Commit][badge-last-commit]][commits]
|
||||
|
||||
[badge-test]: https://github.com/Xevion/Pac-Man/actions/workflows/test.yaml/badge.svg
|
||||
[badge-test]: https://github.com/Xevion/Pac-Man/actions/workflows/tests.yaml/badge.svg
|
||||
[badge-build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml/badge.svg
|
||||
[badge-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
|
||||
[test]: https://github.com/Xevion/Pac-Man/actions/workflows/tests.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:
|
||||
@@ -27,38 +25,63 @@ The game includes all the original features you'd expect from Pac-Man:
|
||||
- [ ] Progressive difficulty with faster ghosts and shorter power pellet duration
|
||||
- [x] Authentic sound effects and sprites
|
||||
|
||||
Built with SDL2 for cross-platform graphics and audio, this implementation can run on Windows, Linux, macOS, and in web browsers via WebAssembly.
|
||||
This cross-platform implementation is built with SDL2 for graphics, audio, and input handling. It can run on Windows, Linux, macOS, and in web browsers via WebAssembly.
|
||||
|
||||
## Feature Targets
|
||||
## Why?
|
||||
|
||||
- Near-perfect replication of logic, scoring, graphics, sound, and behaviors.
|
||||
- Written in Rust, buildable on Windows, Linux, Mac and WebAssembly.
|
||||
Just because. And because I wanted to learn more about Rust, inter-operability with C, and compiling to WebAssembly.
|
||||
|
||||
I was inspired by a certain code review video on YouTube; [SOME UNIQUE C++ CODE // Pacman Clone Code Review](https://www.youtube.com/watch?v=OKs_JewEeOo) by The Cherno.
|
||||
|
||||
For some reason, I was inspired to try and replicate it in Rust, and it was uniquely challenging.
|
||||
|
||||
I wanted to hit a log of goals and features, making it a 'perfect' project that I could be proud of.
|
||||
|
||||
- Near-perfect replication of logic, scoring, graphics, sound, and behaviors. No hacks, workarounds, or poor designs.
|
||||
- Written in Rust, buildable on Windows, Linux, Mac and WebAssembly. Statically linked, no runtime dependencies.
|
||||
- Performant, low memory, CPU and GPU usage.
|
||||
- Online demo, playable in a browser.
|
||||
- Automatic build system, with releases for Windows, Linux, and Mac & Web-Assembly.
|
||||
- Completely automatic build system with releases for all platforms.
|
||||
- Well documented, well-tested, and maintainable.
|
||||
|
||||
## Experimental Ideas
|
||||
|
||||
- Debug tooling
|
||||
- Game state visualization
|
||||
- Game speed controls + pausing
|
||||
- Log tracing
|
||||
- Performance details
|
||||
|
||||
## Experimental Ideas
|
||||
|
||||
- Customized Themes & Colors
|
||||
- Color-blind friendly
|
||||
- Perfected Ghost Algorithms
|
||||
- More than 4 ghosts
|
||||
- Custom Level Generation
|
||||
- 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.
|
||||
- An online axum server with a simple database and OAuth2 authentication.
|
||||
- Integrates with GitHub, Discord, and Google OAuth2 to acquire an email identifier & avatar.
|
||||
- Avatars are optional for score submission and can be disabled, instead using a blank avatar.
|
||||
- Avatars are downscaled to a low resolution pixellated image to maintain the 8-bit aesthetic.
|
||||
- A custom name is used for the score submission, which is checked for potential abusive language.
|
||||
- A max length of 14 characters, and a min length of 3 characters.
|
||||
- Names are checked for potential abusive language via an external API.
|
||||
- The client implementation should require zero configuration, environment variables, or special secrets.
|
||||
- It simply defaults to the pacman server API, or can be overriden manually.
|
||||
|
||||
## Build Notes
|
||||
|
||||
Since this project is still in progress, I'm only going to cover non-obvious build details. By reading the code, build scripts, and copying the online build workflows, you should be able to replicate the build process.
|
||||
|
||||
- Install `cargo-vcpkg` with `cargo install cargo-vcpkg`, then run `cargo vcpkg build` to build the requisite dependencies via vcpkg.
|
||||
- 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.
|
||||
- I'm still not sure _why_ 3.1.43 is required, but it is. Perhaps in the future I will attempt to use a more modern version.
|
||||
- Occasionally, the build will fail due to dependencies failing to download. I even have a retry mechanism in the build workflow due to this.
|
||||
- You can then activate the Emscripten SDK with `source ./emsdk/emsdk_env.sh` or `./emsdk/emsdk_env.ps1` or `./emsdk/emsdk_env.bat` depending on your OS/terminal.
|
||||
- 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))
|
||||
- `web.build.ts` auto installs dependencies, but you may need to pass `-i` or `--install=fallback|force` to install missing packages. My guess is that if you have some packages installed, it won't install any missing ones. If you have no packages installed, it will install all of them.
|
||||
- If you want to have TypeScript resolution for development, you can manually install the dependencies with `bun install` in the `assets/site` folder.
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 250 250"
|
||||
class="fill-yellow-400 text-white"
|
||||
class="fill-yellow-400 [&>.octo-arm,.octo-body]:fill-black"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path>
|
||||
@@ -46,16 +46,12 @@
|
||||
</a>
|
||||
|
||||
<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"
|
||||
oncontextmenu="event.preventDefault()"
|
||||
class="block bg-black w-full max-w-[90vw] h-auto rounded-xl shadow-[inset_0_0_0_2px_rgba(255,255,255,0.12),0_10px_30px_rgba(0,0,0,0.8)]"
|
||||
class="block w-full h-full max-h-[90vh] aspect-square"
|
||||
></canvas>
|
||||
|
||||
<div
|
||||
|
||||
33
src/app.rs
33
src/app.rs
@@ -1,6 +1,7 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use glam::Vec2;
|
||||
use sdl2::event::{Event, WindowEvent};
|
||||
use sdl2::keyboard::Keycode;
|
||||
use sdl2::render::{Canvas, ScaleMode, Texture, TextureCreator};
|
||||
@@ -10,19 +11,7 @@ use tracing::{error, event};
|
||||
|
||||
use crate::constants::{CANVAS_SIZE, LOOP_TIME, SCALE};
|
||||
use crate::game::Game;
|
||||
|
||||
#[cfg(target_os = "emscripten")]
|
||||
use crate::emscripten;
|
||||
|
||||
#[cfg(not(target_os = "emscripten"))]
|
||||
fn sleep(value: Duration) {
|
||||
spin_sleep::sleep(value);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "emscripten")]
|
||||
fn sleep(value: Duration) {
|
||||
emscripten::emscripten::sleep(value.as_millis() as u32);
|
||||
}
|
||||
use crate::platform::get_platform;
|
||||
|
||||
pub struct App<'a> {
|
||||
game: Game,
|
||||
@@ -31,10 +20,14 @@ pub struct App<'a> {
|
||||
backbuffer: Texture<'a>,
|
||||
paused: bool,
|
||||
last_tick: Instant,
|
||||
cursor_pos: Vec2,
|
||||
}
|
||||
|
||||
impl App<'_> {
|
||||
pub fn new() -> Result<Self> {
|
||||
// Initialize platform-specific console
|
||||
get_platform().init_console().map_err(|e| anyhow!(e))?;
|
||||
|
||||
let sdl_context = sdl2::init().map_err(|e| anyhow!(e))?;
|
||||
let video_subsystem = sdl_context.video().map_err(|e| anyhow!(e))?;
|
||||
let audio_subsystem = sdl_context.audio().map_err(|e| anyhow!(e))?;
|
||||
@@ -65,7 +58,7 @@ impl App<'_> {
|
||||
|
||||
// Initial draw
|
||||
game.draw(&mut canvas, &mut backbuffer)?;
|
||||
game.present_backbuffer(&mut canvas, &backbuffer)?;
|
||||
game.present_backbuffer(&mut canvas, &backbuffer, glam::Vec2::ZERO)?;
|
||||
|
||||
Ok(Self {
|
||||
game,
|
||||
@@ -74,6 +67,7 @@ impl App<'_> {
|
||||
backbuffer,
|
||||
paused: false,
|
||||
last_tick: Instant::now(),
|
||||
cursor_pos: Vec2::ZERO,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -118,6 +112,10 @@ impl App<'_> {
|
||||
Event::KeyDown { keycode, .. } => {
|
||||
self.game.keyboard_event(keycode.unwrap());
|
||||
}
|
||||
Event::MouseMotion { x, y, .. } => {
|
||||
// Convert window coordinates to logical coordinates
|
||||
self.cursor_pos = Vec2::new(x as f32, y as f32);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -130,7 +128,10 @@ impl App<'_> {
|
||||
if let Err(e) = self.game.draw(&mut self.canvas, &mut self.backbuffer) {
|
||||
error!("Failed to draw game: {e}");
|
||||
}
|
||||
if let Err(e) = self.game.present_backbuffer(&mut self.canvas, &self.backbuffer) {
|
||||
if let Err(e) = self
|
||||
.game
|
||||
.present_backbuffer(&mut self.canvas, &self.backbuffer, self.cursor_pos)
|
||||
{
|
||||
error!("Failed to present backbuffer: {e}");
|
||||
}
|
||||
}
|
||||
@@ -138,7 +139,7 @@ impl App<'_> {
|
||||
if start.elapsed() < LOOP_TIME {
|
||||
let time = LOOP_TIME.saturating_sub(start.elapsed());
|
||||
if time != Duration::ZERO {
|
||||
sleep(time);
|
||||
get_platform().sleep(time);
|
||||
}
|
||||
} else {
|
||||
event!(
|
||||
|
||||
32
src/asset.rs
32
src/asset.rs
@@ -42,40 +42,12 @@ impl Asset {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "emscripten"))]
|
||||
mod imp {
|
||||
use super::*;
|
||||
macro_rules! asset_bytes_enum {
|
||||
( $asset:expr ) => {
|
||||
match $asset {
|
||||
Asset::Wav1 => Cow::Borrowed(include_bytes!("../assets/game/sound/waka/1.ogg")),
|
||||
Asset::Wav2 => Cow::Borrowed(include_bytes!("../assets/game/sound/waka/2.ogg")),
|
||||
Asset::Wav3 => Cow::Borrowed(include_bytes!("../assets/game/sound/waka/3.ogg")),
|
||||
Asset::Wav4 => Cow::Borrowed(include_bytes!("../assets/game/sound/waka/4.ogg")),
|
||||
Asset::Atlas => Cow::Borrowed(include_bytes!("../assets/game/atlas.png")),
|
||||
Asset::AtlasJson => Cow::Borrowed(include_bytes!("../assets/game/atlas.json")),
|
||||
}
|
||||
};
|
||||
}
|
||||
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
|
||||
Ok(asset_bytes_enum!(asset))
|
||||
}
|
||||
}
|
||||
use crate::platform::get_platform;
|
||||
|
||||
#[cfg(target_os = "emscripten")]
|
||||
mod imp {
|
||||
use super::*;
|
||||
use sdl2::rwops::RWops;
|
||||
use std::io::Read;
|
||||
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
|
||||
let path = format!("assets/game/{}", asset.path());
|
||||
let mut rwops = RWops::from_file(&path, "rb").map_err(|_| AssetError::NotFound(asset.path().to_string()))?;
|
||||
let len = rwops.len().ok_or_else(|| AssetError::NotFound(asset.path().to_string()))?;
|
||||
let mut buf = vec![0u8; len];
|
||||
rwops
|
||||
.read_exact(&mut buf)
|
||||
.map_err(|e| AssetError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
|
||||
Ok(Cow::Owned(buf))
|
||||
get_platform().get_asset_bytes(asset)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
#[allow(dead_code)]
|
||||
#[cfg(target_os = "emscripten")]
|
||||
pub mod emscripten {
|
||||
use std::os::raw::c_uint;
|
||||
|
||||
extern "C" {
|
||||
pub fn emscripten_get_now() -> f64;
|
||||
pub fn emscripten_sleep(ms: c_uint);
|
||||
pub fn emscripten_get_element_css_size(target: *const u8, width: *mut f64, height: *mut f64) -> i32;
|
||||
}
|
||||
|
||||
// milliseconds since start of program
|
||||
pub fn now() -> f64 {
|
||||
unsafe { emscripten_get_now() }
|
||||
}
|
||||
|
||||
pub fn sleep(ms: u32) {
|
||||
unsafe {
|
||||
emscripten_sleep(ms);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_canvas_size() -> (u32, u32) {
|
||||
let mut width = 0.0;
|
||||
let mut height = 0.0;
|
||||
unsafe {
|
||||
emscripten_get_element_css_size("canvas\0".as_ptr(), &mut width, &mut height);
|
||||
}
|
||||
(width as u32, height as u32)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use glam::IVec2;
|
||||
|
||||
/// The four cardinal directions.
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum Direction {
|
||||
Up,
|
||||
@@ -9,7 +10,12 @@ pub enum Direction {
|
||||
}
|
||||
|
||||
impl Direction {
|
||||
pub fn opposite(&self) -> Direction {
|
||||
/// The four cardinal directions.
|
||||
/// This is just a convenience constant for iterating over the directions.
|
||||
pub const DIRECTIONS: [Direction; 4] = [Direction::Up, Direction::Down, Direction::Left, Direction::Right];
|
||||
|
||||
/// Returns the opposite direction. Constant time.
|
||||
pub const fn opposite(self) -> Direction {
|
||||
match self {
|
||||
Direction::Up => Direction::Down,
|
||||
Direction::Down => Direction::Up,
|
||||
@@ -18,8 +24,20 @@ impl Direction {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_ivec2(&self) -> IVec2 {
|
||||
(*self).into()
|
||||
/// Returns the direction as an IVec2.
|
||||
pub fn as_ivec2(self) -> IVec2 {
|
||||
self.into()
|
||||
}
|
||||
|
||||
/// Returns the direction as a usize (0-3). Constant time.
|
||||
/// This is useful for indexing into arrays.
|
||||
pub const fn as_usize(self) -> usize {
|
||||
match self {
|
||||
Direction::Up => 0,
|
||||
Direction::Down => 1,
|
||||
Direction::Left => 2,
|
||||
Direction::Right => 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,5 +51,3 @@ impl From<Direction> for IVec2 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const DIRECTIONS: [Direction; 4] = [Direction::Up, Direction::Down, Direction::Left, Direction::Right];
|
||||
|
||||
216
src/entity/ghost.rs
Normal file
216
src/entity/ghost.rs
Normal file
@@ -0,0 +1,216 @@
|
||||
//! Ghost entity implementation.
|
||||
//!
|
||||
//! This module contains the ghost character logic, including movement,
|
||||
//! animation, and rendering. Ghosts move through the game graph using
|
||||
//! a traverser and display directional animated textures.
|
||||
|
||||
use glam::Vec2;
|
||||
use rand::prelude::*;
|
||||
use smallvec::SmallVec;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::constants::BOARD_PIXEL_OFFSET;
|
||||
use crate::entity::direction::Direction;
|
||||
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;
|
||||
use crate::texture::sprite::SpriteAtlas;
|
||||
use sdl2::render::{Canvas, RenderTarget};
|
||||
|
||||
/// Determines if a ghost can traverse a given edge.
|
||||
///
|
||||
/// Ghosts can move through edges that allow all entities or ghost-only edges.
|
||||
fn can_ghost_traverse(edge: Edge) -> bool {
|
||||
matches!(edge.permissions, EdgePermissions::All | EdgePermissions::GhostsOnly)
|
||||
}
|
||||
|
||||
/// The four classic ghost types.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GhostType {
|
||||
Blinky,
|
||||
Pinky,
|
||||
Inky,
|
||||
Clyde,
|
||||
}
|
||||
|
||||
impl GhostType {
|
||||
/// Returns the ghost type name for atlas lookups.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
GhostType::Blinky => "blinky",
|
||||
GhostType::Pinky => "pinky",
|
||||
GhostType::Inky => "inky",
|
||||
GhostType::Clyde => "clyde",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the base movement speed for this ghost type.
|
||||
pub fn base_speed(self) -> f32 {
|
||||
match self {
|
||||
GhostType::Blinky => 1.0,
|
||||
GhostType::Pinky => 0.95,
|
||||
GhostType::Inky => 0.9,
|
||||
GhostType::Clyde => 0.85,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A ghost entity that roams the game world.
|
||||
///
|
||||
/// Ghosts move through the game world using a graph-based navigation system
|
||||
/// and display directional animated sprites. They randomly choose directions
|
||||
/// at each intersection.
|
||||
pub struct Ghost {
|
||||
/// Handles movement through the game graph
|
||||
pub traverser: Traverser,
|
||||
/// The type of ghost (affects appearance and speed)
|
||||
pub ghost_type: GhostType,
|
||||
/// Manages directional animated textures for different movement states
|
||||
texture: DirectionalAnimatedTexture,
|
||||
/// Current movement speed
|
||||
speed: f32,
|
||||
}
|
||||
|
||||
impl Ghost {
|
||||
/// Creates a new ghost instance at the specified starting node.
|
||||
///
|
||||
/// Sets up animated textures for all four directions with moving and stopped states.
|
||||
/// The moving animation cycles through two sprite variants.
|
||||
pub fn new(graph: &Graph, start_node: NodeId, ghost_type: GhostType, atlas: &SpriteAtlas) -> Self {
|
||||
let mut textures = [None, None, None, None];
|
||||
let mut stopped_textures = [None, None, None, None];
|
||||
|
||||
for direction in Direction::DIRECTIONS {
|
||||
let moving_prefix = match direction {
|
||||
Direction::Up => "up",
|
||||
Direction::Down => "down",
|
||||
Direction::Left => "left",
|
||||
Direction::Right => "right",
|
||||
};
|
||||
let moving_tiles = vec![
|
||||
SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a")).unwrap(),
|
||||
SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "b")).unwrap(),
|
||||
];
|
||||
|
||||
let stopped_tiles =
|
||||
vec![
|
||||
SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a"))
|
||||
.unwrap(),
|
||||
];
|
||||
|
||||
textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.2).expect("Invalid frame duration"));
|
||||
stopped_textures[direction.as_usize()] =
|
||||
Some(AnimatedTexture::new(stopped_tiles, 0.1).expect("Invalid frame duration"));
|
||||
}
|
||||
|
||||
Self {
|
||||
traverser: Traverser::new(graph, start_node, Direction::Left, &can_ghost_traverse),
|
||||
ghost_type,
|
||||
texture: DirectionalAnimatedTexture::new(textures, stopped_textures),
|
||||
speed: ghost_type.base_speed(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the ghost's position and animation state.
|
||||
///
|
||||
/// Advances movement through the graph, updates texture animation,
|
||||
/// and chooses random directions at intersections.
|
||||
pub fn tick(&mut self, dt: f32, graph: &Graph) {
|
||||
// Choose random direction when at a node
|
||||
if self.traverser.position.is_at_node() {
|
||||
self.choose_random_direction(graph);
|
||||
}
|
||||
|
||||
self.traverser.advance(graph, dt * 60.0 * self.speed, &can_ghost_traverse);
|
||||
self.texture.tick(dt);
|
||||
}
|
||||
|
||||
/// Chooses a random available direction at the current intersection.
|
||||
fn choose_random_direction(&mut self, graph: &Graph) {
|
||||
let current_node = self.traverser.position.from_node_id();
|
||||
let intersection = &graph.adjacency_list[current_node];
|
||||
|
||||
// Collect all available directions
|
||||
let mut available_directions = SmallVec::<[_; 4]>::new();
|
||||
for direction in Direction::DIRECTIONS {
|
||||
if let Some(edge) = intersection.get(direction) {
|
||||
if can_ghost_traverse(edge) {
|
||||
available_directions.push(direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Ghost {} at node {}: available directions: {:?}, current direction: {:?}",
|
||||
self.ghost_type.as_str(),
|
||||
current_node,
|
||||
available_directions,
|
||||
self.traverser.direction
|
||||
);
|
||||
|
||||
// Choose a random direction (avoid reversing unless necessary)
|
||||
if !available_directions.is_empty() {
|
||||
let mut rng = SmallRng::from_os_rng();
|
||||
|
||||
// Filter out the opposite direction if possible, but allow it if we have limited options
|
||||
let opposite = self.traverser.direction.opposite();
|
||||
let filtered_directions: Vec<_> = available_directions
|
||||
.iter()
|
||||
.filter(|&&dir| dir != opposite || available_directions.len() <= 2)
|
||||
.collect();
|
||||
|
||||
debug!(
|
||||
"Ghost {}: filtered directions: {:?}, opposite: {:?}",
|
||||
self.ghost_type.as_str(),
|
||||
filtered_directions,
|
||||
opposite
|
||||
);
|
||||
|
||||
if let Some(&random_direction) = filtered_directions.choose(&mut rng) {
|
||||
self.traverser.set_next_direction(*random_direction);
|
||||
debug!("Ghost {} chose direction: {:?}", self.ghost_type.as_str(), random_direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculates the current pixel position in the game world.
|
||||
///
|
||||
/// Converts the graph position to screen coordinates, accounting for
|
||||
/// the board offset and centering the sprite.
|
||||
fn get_pixel_pos(&self, graph: &Graph) -> Vec2 {
|
||||
let pos = match self.traverser.position {
|
||||
Position::AtNode(node_id) => graph.get_node(node_id).unwrap().position,
|
||||
Position::BetweenNodes { from, to, traversed } => {
|
||||
let from_pos = graph.get_node(from).unwrap().position;
|
||||
let to_pos = graph.get_node(to).unwrap().position;
|
||||
let edge = graph.find_edge(from, to).unwrap();
|
||||
from_pos + (to_pos - from_pos) * (traversed / edge.distance)
|
||||
}
|
||||
};
|
||||
|
||||
Vec2::new(pos.x + BOARD_PIXEL_OFFSET.x as f32, pos.y + BOARD_PIXEL_OFFSET.y as f32)
|
||||
}
|
||||
|
||||
/// Renders the ghost at its current position.
|
||||
///
|
||||
/// Draws the appropriate directional sprite based on the ghost's
|
||||
/// current movement state and direction.
|
||||
pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, graph: &Graph) {
|
||||
let pixel_pos = self.get_pixel_pos(graph);
|
||||
let dest = centered_with_size(
|
||||
glam::IVec2::new(pixel_pos.x as i32, pixel_pos.y as i32),
|
||||
glam::UVec2::new(16, 16),
|
||||
);
|
||||
|
||||
if self.traverser.position.is_stopped() {
|
||||
self.texture
|
||||
.render_stopped(canvas, atlas, dest, self.traverser.direction)
|
||||
.expect("Failed to render ghost");
|
||||
} else {
|
||||
self.texture
|
||||
.render(canvas, atlas, dest, self.traverser.direction)
|
||||
.expect("Failed to render ghost");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod direction;
|
||||
pub mod ghost;
|
||||
pub mod graph;
|
||||
pub mod pacman;
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
//! Pac-Man entity implementation.
|
||||
//!
|
||||
//! This module contains the main player character logic, including movement,
|
||||
//! animation, and rendering. Pac-Man moves through the game graph using
|
||||
//! a traverser and displays directional animated textures.
|
||||
|
||||
use glam::{UVec2, Vec2};
|
||||
|
||||
use crate::constants::BOARD_PIXEL_OFFSET;
|
||||
@@ -9,23 +15,35 @@ use crate::texture::directional::DirectionalAnimatedTexture;
|
||||
use crate::texture::sprite::SpriteAtlas;
|
||||
use sdl2::keyboard::Keycode;
|
||||
use sdl2::render::{Canvas, RenderTarget};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Determines if Pac-Man can traverse a given edge.
|
||||
///
|
||||
/// Pac-Man can only move through edges that allow all entities.
|
||||
fn can_pacman_traverse(edge: Edge) -> bool {
|
||||
matches!(edge.permissions, EdgePermissions::All)
|
||||
}
|
||||
|
||||
/// The main player character entity.
|
||||
///
|
||||
/// Pac-Man moves through the game world using a graph-based navigation system
|
||||
/// and displays directional animated sprites based on movement state.
|
||||
pub struct Pacman {
|
||||
/// Handles movement through the game graph
|
||||
pub traverser: Traverser,
|
||||
/// Manages directional animated textures for different movement states
|
||||
texture: DirectionalAnimatedTexture,
|
||||
}
|
||||
|
||||
impl Pacman {
|
||||
/// Creates a new Pac-Man instance at the specified starting node.
|
||||
///
|
||||
/// Sets up animated textures for all four directions with moving and stopped states.
|
||||
/// The moving animation cycles through open mouth, closed mouth, and full sprites.
|
||||
pub fn new(graph: &Graph, start_node: NodeId, atlas: &SpriteAtlas) -> Self {
|
||||
let mut textures = HashMap::new();
|
||||
let mut stopped_textures = HashMap::new();
|
||||
let mut textures = [None, None, None, None];
|
||||
let mut stopped_textures = [None, None, None, None];
|
||||
|
||||
for &direction in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] {
|
||||
for direction in Direction::DIRECTIONS {
|
||||
let moving_prefix = match direction {
|
||||
Direction::Up => "pacman/up",
|
||||
Direction::Down => "pacman/down",
|
||||
@@ -40,14 +58,9 @@ impl Pacman {
|
||||
|
||||
let stopped_tiles = vec![SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png")).unwrap()];
|
||||
|
||||
textures.insert(
|
||||
direction,
|
||||
AnimatedTexture::new(moving_tiles, 0.08).expect("Invalid frame duration"),
|
||||
);
|
||||
stopped_textures.insert(
|
||||
direction,
|
||||
AnimatedTexture::new(stopped_tiles, 0.1).expect("Invalid frame duration"),
|
||||
);
|
||||
textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.08).expect("Invalid frame duration"));
|
||||
stopped_textures[direction.as_usize()] =
|
||||
Some(AnimatedTexture::new(stopped_tiles, 0.1).expect("Invalid frame duration"));
|
||||
}
|
||||
|
||||
Self {
|
||||
@@ -56,11 +69,19 @@ impl Pacman {
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates Pac-Man's position and animation state.
|
||||
///
|
||||
/// Advances movement through the graph and updates texture animation.
|
||||
/// Movement speed is scaled by 60 FPS and a 1.125 multiplier.
|
||||
pub fn tick(&mut self, dt: f32, graph: &Graph) {
|
||||
self.traverser.advance(graph, dt * 60.0 * 1.125, &can_pacman_traverse);
|
||||
self.texture.tick(dt);
|
||||
}
|
||||
|
||||
/// Handles keyboard input to change Pac-Man's direction.
|
||||
///
|
||||
/// Maps arrow keys to directions and queues the direction change
|
||||
/// for the next valid intersection.
|
||||
pub fn handle_key(&mut self, keycode: Keycode) {
|
||||
let direction = match keycode {
|
||||
Keycode::Up => Some(Direction::Up),
|
||||
@@ -75,6 +96,9 @@ impl Pacman {
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculates the current pixel position in the game world.
|
||||
///
|
||||
/// Interpolates between nodes when moving between them.
|
||||
fn get_pixel_pos(&self, graph: &Graph) -> Vec2 {
|
||||
match self.traverser.position {
|
||||
Position::AtNode(node_id) => graph.get_node(node_id).unwrap().position,
|
||||
@@ -86,6 +110,10 @@ impl Pacman {
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders Pac-Man to the canvas.
|
||||
///
|
||||
/// Calculates screen position, determines if Pac-Man is stopped,
|
||||
/// and renders the appropriate directional texture.
|
||||
pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, graph: &Graph) {
|
||||
let pixel_pos = self.get_pixel_pos(graph).round().as_ivec2() + BOARD_PIXEL_OFFSET.as_ivec2();
|
||||
let dest = centered_with_size(pixel_pos, UVec2::new(16, 16));
|
||||
|
||||
67
src/game.rs
67
src/game.rs
@@ -2,6 +2,7 @@
|
||||
|
||||
use anyhow::Result;
|
||||
use glam::UVec2;
|
||||
use rand::{rngs::SmallRng, Rng, SeedableRng};
|
||||
use sdl2::{
|
||||
image::LoadTexture,
|
||||
keyboard::Keycode,
|
||||
@@ -14,7 +15,10 @@ use crate::{
|
||||
asset::{get_asset_bytes, Asset},
|
||||
audio::Audio,
|
||||
constants::RAW_BOARD,
|
||||
entity::pacman::Pacman,
|
||||
entity::{
|
||||
ghost::{Ghost, GhostType},
|
||||
pacman::Pacman,
|
||||
},
|
||||
map::Map,
|
||||
texture::{
|
||||
sprite::{self, AtlasMapper, AtlasTile, SpriteAtlas},
|
||||
@@ -30,6 +34,7 @@ pub struct Game {
|
||||
pub score: u32,
|
||||
pub map: Map,
|
||||
pub pacman: Pacman,
|
||||
pub ghosts: Vec<Ghost>,
|
||||
pub debug_mode: bool,
|
||||
|
||||
// Rendering resources
|
||||
@@ -73,10 +78,23 @@ impl Game {
|
||||
let audio = Audio::new();
|
||||
let pacman = Pacman::new(&map.graph, pacman_start_node, &atlas);
|
||||
|
||||
// Create ghosts at random positions
|
||||
let mut ghosts = Vec::new();
|
||||
let ghost_types = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde];
|
||||
let mut rng = SmallRng::from_os_rng();
|
||||
|
||||
for &ghost_type in &ghost_types {
|
||||
// Find a random node for the ghost to start at
|
||||
let random_node = rng.random_range(0..map.graph.node_count());
|
||||
let ghost = Ghost::new(&map.graph, random_node, ghost_type, &atlas);
|
||||
ghosts.push(ghost);
|
||||
}
|
||||
|
||||
Game {
|
||||
score: 0,
|
||||
map,
|
||||
pacman,
|
||||
ghosts,
|
||||
debug_mode: false,
|
||||
map_texture,
|
||||
text_texture,
|
||||
@@ -91,10 +109,41 @@ impl Game {
|
||||
if keycode == Keycode::M {
|
||||
self.audio.set_mute(!self.audio.is_muted());
|
||||
}
|
||||
|
||||
if keycode == Keycode::R {
|
||||
self.reset_game_state();
|
||||
}
|
||||
}
|
||||
|
||||
/// Resets the game state, randomizing ghost positions and resetting Pac-Man
|
||||
fn reset_game_state(&mut self) {
|
||||
// Reset Pac-Man to starting position
|
||||
let pacman_start_pos = self.map.find_starting_position(0).unwrap();
|
||||
let pacman_start_node = *self
|
||||
.map
|
||||
.grid_to_node
|
||||
.get(&glam::IVec2::new(pacman_start_pos.x as i32, pacman_start_pos.y as i32))
|
||||
.expect("Pac-Man starting position not found in graph");
|
||||
|
||||
self.pacman = Pacman::new(&self.map.graph, pacman_start_node, &self.atlas);
|
||||
|
||||
// Randomize ghost positions
|
||||
let ghost_types = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde];
|
||||
let mut rng = SmallRng::from_os_rng();
|
||||
|
||||
for (i, ghost) in self.ghosts.iter_mut().enumerate() {
|
||||
let random_node = rng.random_range(0..self.map.graph.node_count());
|
||||
*ghost = Ghost::new(&self.map.graph, random_node, ghost_types[i], &self.atlas);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, dt: f32) {
|
||||
self.pacman.tick(dt, &self.map.graph);
|
||||
|
||||
// Update all ghosts
|
||||
for ghost in &mut self.ghosts {
|
||||
ghost.tick(dt, &self.map.graph);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>, backbuffer: &mut Texture) -> Result<()> {
|
||||
@@ -102,16 +151,28 @@ impl Game {
|
||||
canvas.set_draw_color(Color::BLACK);
|
||||
canvas.clear();
|
||||
self.map.render(canvas, &mut self.atlas, &mut self.map_texture);
|
||||
|
||||
// Render all ghosts
|
||||
for ghost in &self.ghosts {
|
||||
ghost.render(canvas, &mut self.atlas, &self.map.graph);
|
||||
}
|
||||
|
||||
self.pacman.render(canvas, &mut self.atlas, &self.map.graph);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn present_backbuffer<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>, backbuffer: &Texture) -> Result<()> {
|
||||
pub fn present_backbuffer<T: RenderTarget>(
|
||||
&mut self,
|
||||
canvas: &mut Canvas<T>,
|
||||
backbuffer: &Texture,
|
||||
cursor_pos: glam::Vec2,
|
||||
) -> Result<()> {
|
||||
canvas.copy(backbuffer, None, None).map_err(anyhow::Error::msg)?;
|
||||
if self.debug_mode {
|
||||
self.map.debug_render_nodes(canvas);
|
||||
self.map
|
||||
.debug_render_with_cursor(canvas, &mut self.text_texture, &mut self.atlas, cursor_pos);
|
||||
}
|
||||
self.draw_hud(canvas)?;
|
||||
canvas.present();
|
||||
|
||||
@@ -4,9 +4,9 @@ pub mod app;
|
||||
pub mod asset;
|
||||
pub mod audio;
|
||||
pub mod constants;
|
||||
pub mod emscripten;
|
||||
pub mod entity;
|
||||
pub mod game;
|
||||
pub mod helpers;
|
||||
pub mod map;
|
||||
pub mod platform;
|
||||
pub mod texture;
|
||||
|
||||
53
src/main.rs
53
src/main.rs
@@ -5,59 +5,16 @@ use tracing::info;
|
||||
use tracing_error::ErrorLayer;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
|
||||
#[cfg(windows)]
|
||||
use winapi::{
|
||||
shared::ntdef::NULL,
|
||||
um::{
|
||||
fileapi::{CreateFileA, OPEN_EXISTING},
|
||||
handleapi::INVALID_HANDLE_VALUE,
|
||||
processenv::SetStdHandle,
|
||||
winbase::{STD_ERROR_HANDLE, STD_OUTPUT_HANDLE},
|
||||
wincon::{AttachConsole, GetConsoleWindow},
|
||||
winnt::{FILE_SHARE_READ, FILE_SHARE_WRITE, GENERIC_READ, GENERIC_WRITE},
|
||||
},
|
||||
};
|
||||
|
||||
/// Attaches the process to the parent console on Windows.
|
||||
///
|
||||
/// This allows the application to print to the console when run from a terminal,
|
||||
/// which is useful for debugging purposes. If the application is not run from a
|
||||
/// terminal, this function does nothing.
|
||||
#[cfg(windows)]
|
||||
unsafe fn attach_console() {
|
||||
if !std::ptr::eq(GetConsoleWindow(), std::ptr::null_mut()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if AttachConsole(winapi::um::wincon::ATTACH_PARENT_PROCESS) != 0 {
|
||||
let handle = CreateFileA(
|
||||
c"CONOUT$".as_ptr(),
|
||||
GENERIC_READ | GENERIC_WRITE,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
std::ptr::null_mut(),
|
||||
OPEN_EXISTING,
|
||||
0,
|
||||
NULL,
|
||||
);
|
||||
|
||||
if handle != INVALID_HANDLE_VALUE {
|
||||
SetStdHandle(STD_OUTPUT_HANDLE, handle);
|
||||
SetStdHandle(STD_ERROR_HANDLE, handle);
|
||||
}
|
||||
}
|
||||
// Do NOT call AllocConsole here - we don't want a console when launched from Explorer
|
||||
}
|
||||
|
||||
mod app;
|
||||
mod asset;
|
||||
mod audio;
|
||||
mod constants;
|
||||
#[cfg(target_os = "emscripten")]
|
||||
mod emscripten;
|
||||
|
||||
mod entity;
|
||||
mod game;
|
||||
mod helpers;
|
||||
mod map;
|
||||
mod platform;
|
||||
mod texture;
|
||||
|
||||
/// The main entry point of the application.
|
||||
@@ -65,12 +22,6 @@ mod texture;
|
||||
/// This function initializes SDL, the window, the game state, and then enters
|
||||
/// the main game loop.
|
||||
pub fn main() {
|
||||
// Attaches the console on Windows for debugging purposes.
|
||||
#[cfg(windows)]
|
||||
unsafe {
|
||||
attach_console();
|
||||
}
|
||||
|
||||
// Setup tracing
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_ansi(cfg!(not(target_os = "emscripten")))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Map construction and building functionality.
|
||||
|
||||
use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE};
|
||||
use crate::entity::direction::{Direction, DIRECTIONS};
|
||||
use crate::entity::direction::Direction;
|
||||
use crate::entity::graph::{EdgePermissions, Graph, Node, NodeId};
|
||||
use crate::map::parser::MapTileParser;
|
||||
use crate::map::render::MapRenderer;
|
||||
@@ -75,7 +75,7 @@ impl Map {
|
||||
|
||||
// Iterate over the queue, adding nodes to the graph and connecting them to their neighbors
|
||||
while let Some(source_position) = queue.pop_front() {
|
||||
for &dir in DIRECTIONS.iter() {
|
||||
for dir in Direction::DIRECTIONS {
|
||||
let new_position = source_position + dir.as_ivec2();
|
||||
|
||||
// Skip if the new position is out of bounds
|
||||
@@ -121,7 +121,7 @@ impl Map {
|
||||
|
||||
// While most nodes are already connected to their neighbors, some may not be, so we need to connect them
|
||||
for (grid_pos, &node_id) in &grid_to_node {
|
||||
for dir in DIRECTIONS {
|
||||
for dir in Direction::DIRECTIONS {
|
||||
// If the node doesn't have an edge in this direction, look for a neighbor in that direction
|
||||
if graph.adjacency_list[node_id].get(dir).is_none() {
|
||||
let neighbor = grid_pos + dir.as_ivec2();
|
||||
@@ -184,13 +184,18 @@ impl Map {
|
||||
MapRenderer::render_map(canvas, atlas, map_texture);
|
||||
}
|
||||
|
||||
/// Renders a debug visualization of the navigation graph.
|
||||
/// Renders a debug visualization with cursor-based highlighting.
|
||||
///
|
||||
/// This function is intended for development and debugging purposes. It draws the
|
||||
/// nodes and edges of the graph on top of the map, allowing for visual
|
||||
/// inspection of the navigation paths.
|
||||
pub fn debug_render_nodes<T: RenderTarget>(&self, canvas: &mut Canvas<T>) {
|
||||
MapRenderer::debug_render_nodes(&self.graph, canvas);
|
||||
/// This function provides interactive debugging by highlighting the nearest node
|
||||
/// to the cursor, showing its ID, and highlighting its connections.
|
||||
pub fn debug_render_with_cursor<T: RenderTarget>(
|
||||
&self,
|
||||
canvas: &mut Canvas<T>,
|
||||
text_renderer: &mut crate::texture::text::TextTexture,
|
||||
atlas: &mut SpriteAtlas,
|
||||
cursor_pos: glam::Vec2,
|
||||
) {
|
||||
MapRenderer::debug_render_with_cursor(&self.graph, canvas, text_renderer, atlas, cursor_pos);
|
||||
}
|
||||
|
||||
/// Builds the house structure in the graph.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Map rendering functionality.
|
||||
|
||||
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
|
||||
use crate::texture::text::TextTexture;
|
||||
use glam::Vec2;
|
||||
use sdl2::pixels::Color;
|
||||
use sdl2::rect::{Point, Rect};
|
||||
use sdl2::render::{Canvas, RenderTarget};
|
||||
@@ -23,45 +25,93 @@ impl MapRenderer {
|
||||
let _ = map_texture.render(canvas, atlas, dest);
|
||||
}
|
||||
|
||||
/// Renders a debug visualization of the navigation graph.
|
||||
/// Renders a debug visualization with cursor-based highlighting.
|
||||
///
|
||||
/// This function is intended for development and debugging purposes. It draws the
|
||||
/// nodes and edges of the graph on top of the map, allowing for visual
|
||||
/// inspection of the navigation paths.
|
||||
pub fn debug_render_nodes<T: RenderTarget>(graph: &crate::entity::graph::Graph, canvas: &mut Canvas<T>) {
|
||||
/// This function provides interactive debugging by highlighting the nearest node
|
||||
/// to the cursor, showing its ID, and highlighting its connections.
|
||||
pub fn debug_render_with_cursor<T: RenderTarget>(
|
||||
graph: &crate::entity::graph::Graph,
|
||||
canvas: &mut Canvas<T>,
|
||||
text_renderer: &mut TextTexture,
|
||||
atlas: &mut SpriteAtlas,
|
||||
cursor_pos: Vec2,
|
||||
) {
|
||||
// Find the nearest node to the cursor
|
||||
let nearest_node = Self::find_nearest_node(graph, cursor_pos);
|
||||
|
||||
// Draw all connections in blue
|
||||
canvas.set_draw_color(Color::RGB(0, 0, 128)); // Dark blue for regular connections
|
||||
for i in 0..graph.node_count() {
|
||||
let node = graph.get_node(i).unwrap();
|
||||
let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
|
||||
|
||||
// Draw connections
|
||||
canvas.set_draw_color(Color::BLUE);
|
||||
|
||||
for edge in graph.adjacency_list[i].edges() {
|
||||
let end_pos = graph.get_node(edge.target).unwrap().position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
|
||||
canvas
|
||||
.draw_line((pos.x as i32, pos.y as i32), (end_pos.x as i32, end_pos.y as i32))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// Draw all nodes in green
|
||||
canvas.set_draw_color(Color::RGB(0, 128, 0)); // Dark green for regular nodes
|
||||
for i in 0..graph.node_count() {
|
||||
let node = graph.get_node(i).unwrap();
|
||||
let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
|
||||
|
||||
// Draw node
|
||||
// let color = if pacman.position.from_node_idx() == i.into() {
|
||||
// Color::GREEN
|
||||
// } else if let Some(to_idx) = pacman.position.to_node_idx() {
|
||||
// if to_idx == i.into() {
|
||||
// Color::CYAN
|
||||
// } else {
|
||||
// Color::RED
|
||||
// }
|
||||
// } else {
|
||||
// Color::RED
|
||||
// };
|
||||
canvas.set_draw_color(Color::GREEN);
|
||||
canvas
|
||||
.fill_rect(Rect::new(0, 0, 3, 3).centered_on(Point::new(pos.x as i32, pos.y as i32)))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Draw node index
|
||||
// text.render(canvas, atlas, &i.to_string(), pos.as_uvec2()).unwrap();
|
||||
// Highlight connections from the nearest node in bright blue
|
||||
if let Some(nearest_id) = nearest_node {
|
||||
let nearest_pos = graph.get_node(nearest_id).unwrap().position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
|
||||
|
||||
canvas.set_draw_color(Color::RGB(0, 255, 255)); // Bright cyan for highlighted connections
|
||||
for edge in graph.adjacency_list[nearest_id].edges() {
|
||||
let end_pos = graph.get_node(edge.target).unwrap().position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
|
||||
canvas
|
||||
.draw_line(
|
||||
(nearest_pos.x as i32, nearest_pos.y as i32),
|
||||
(end_pos.x as i32, end_pos.y as i32),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Highlight the nearest node in bright green
|
||||
canvas.set_draw_color(Color::RGB(0, 255, 0)); // Bright green for highlighted node
|
||||
canvas
|
||||
.fill_rect(Rect::new(0, 0, 5, 5).centered_on(Point::new(nearest_pos.x as i32, nearest_pos.y as i32)))
|
||||
.unwrap();
|
||||
|
||||
// Draw node ID text (small, offset to top right)
|
||||
text_renderer.set_scale(0.5); // Small text
|
||||
let id_text = format!("#{nearest_id}");
|
||||
let text_pos = glam::UVec2::new(
|
||||
(nearest_pos.x + 4.0) as u32, // Offset to the right
|
||||
(nearest_pos.y - 6.0) as u32, // Offset to the top
|
||||
);
|
||||
let _ = text_renderer.render(canvas, atlas, &id_text, text_pos);
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds the nearest node to the given cursor position.
|
||||
pub fn find_nearest_node(graph: &crate::entity::graph::Graph, cursor_pos: Vec2) -> Option<usize> {
|
||||
let mut nearest_id = None;
|
||||
let mut nearest_distance = f32::INFINITY;
|
||||
|
||||
for i in 0..graph.node_count() {
|
||||
let node = graph.get_node(i).unwrap();
|
||||
let node_pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
|
||||
let distance = cursor_pos.distance(node_pos);
|
||||
|
||||
if distance < nearest_distance {
|
||||
nearest_distance = distance;
|
||||
nearest_id = Some(i);
|
||||
}
|
||||
}
|
||||
|
||||
nearest_id
|
||||
}
|
||||
}
|
||||
|
||||
77
src/platform/desktop.rs
Normal file
77
src/platform/desktop.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
//! Desktop platform implementation.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::asset::{Asset, AssetError};
|
||||
use crate::platform::{Platform, PlatformError};
|
||||
|
||||
/// Desktop platform implementation.
|
||||
pub struct DesktopPlatform;
|
||||
|
||||
impl Platform for DesktopPlatform {
|
||||
fn sleep(&self, duration: Duration) {
|
||||
spin_sleep::sleep(duration);
|
||||
}
|
||||
|
||||
fn get_time(&self) -> f64 {
|
||||
std::time::Instant::now().elapsed().as_secs_f64()
|
||||
}
|
||||
|
||||
fn init_console(&self) -> Result<(), PlatformError> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
unsafe {
|
||||
use winapi::{
|
||||
shared::ntdef::NULL,
|
||||
um::{
|
||||
fileapi::{CreateFileA, OPEN_EXISTING},
|
||||
handleapi::INVALID_HANDLE_VALUE,
|
||||
processenv::SetStdHandle,
|
||||
winbase::{STD_ERROR_HANDLE, STD_OUTPUT_HANDLE},
|
||||
wincon::{AttachConsole, GetConsoleWindow},
|
||||
winnt::{FILE_SHARE_READ, FILE_SHARE_WRITE, GENERIC_READ, GENERIC_WRITE},
|
||||
},
|
||||
};
|
||||
|
||||
if !std::ptr::eq(GetConsoleWindow(), std::ptr::null_mut()) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if AttachConsole(winapi::um::wincon::ATTACH_PARENT_PROCESS) != 0 {
|
||||
let handle = CreateFileA(
|
||||
c"CONOUT$".as_ptr(),
|
||||
GENERIC_READ | GENERIC_WRITE,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
std::ptr::null_mut(),
|
||||
OPEN_EXISTING,
|
||||
0,
|
||||
NULL,
|
||||
);
|
||||
|
||||
if handle != INVALID_HANDLE_VALUE {
|
||||
SetStdHandle(STD_OUTPUT_HANDLE, handle);
|
||||
SetStdHandle(STD_ERROR_HANDLE, handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_canvas_size(&self) -> Option<(u32, u32)> {
|
||||
None // Desktop doesn't need this
|
||||
}
|
||||
|
||||
fn get_asset_bytes(&self, asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
|
||||
match asset {
|
||||
Asset::Wav1 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/1.ogg"))),
|
||||
Asset::Wav2 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/2.ogg"))),
|
||||
Asset::Wav3 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/3.ogg"))),
|
||||
Asset::Wav4 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/4.ogg"))),
|
||||
Asset::Atlas => Ok(Cow::Borrowed(include_bytes!("../../assets/game/atlas.png"))),
|
||||
Asset::AtlasJson => Ok(Cow::Borrowed(include_bytes!("../../assets/game/atlas.json"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
61
src/platform/emscripten.rs
Normal file
61
src/platform/emscripten.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
//! Emscripten platform implementation.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::asset::{Asset, AssetError};
|
||||
use crate::platform::{Platform, PlatformError};
|
||||
|
||||
/// Emscripten platform implementation.
|
||||
pub struct EmscriptenPlatform;
|
||||
|
||||
impl Platform for EmscriptenPlatform {
|
||||
fn sleep(&self, duration: Duration) {
|
||||
unsafe {
|
||||
emscripten_sleep(duration.as_millis() as u32);
|
||||
}
|
||||
}
|
||||
|
||||
fn get_time(&self) -> f64 {
|
||||
unsafe { emscripten_get_now() }
|
||||
}
|
||||
|
||||
fn init_console(&self) -> Result<(), PlatformError> {
|
||||
Ok(()) // No-op for Emscripten
|
||||
}
|
||||
|
||||
fn get_canvas_size(&self) -> Option<(u32, u32)> {
|
||||
Some(unsafe { get_canvas_size() })
|
||||
}
|
||||
|
||||
fn get_asset_bytes(&self, asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
|
||||
use sdl2::rwops::RWops;
|
||||
use std::io::Read;
|
||||
|
||||
let path = format!("assets/game/{}", asset.path());
|
||||
let mut rwops = RWops::from_file(&path, "rb").map_err(|_| AssetError::NotFound(asset.path().to_string()))?;
|
||||
|
||||
let len = rwops.len().ok_or_else(|| AssetError::NotFound(asset.path().to_string()))?;
|
||||
|
||||
let mut buf = vec![0u8; len];
|
||||
rwops
|
||||
.read_exact(&mut buf)
|
||||
.map_err(|e| AssetError::Io(std::io::Error::other(e)))?;
|
||||
|
||||
Ok(Cow::Owned(buf))
|
||||
}
|
||||
}
|
||||
|
||||
// Emscripten FFI functions
|
||||
extern "C" {
|
||||
fn emscripten_get_now() -> f64;
|
||||
fn emscripten_sleep(ms: u32);
|
||||
fn emscripten_get_element_css_size(target: *const u8, width: *mut f64, height: *mut f64) -> i32;
|
||||
}
|
||||
|
||||
unsafe fn get_canvas_size() -> (u32, u32) {
|
||||
let mut width = 0.0;
|
||||
let mut height = 0.0;
|
||||
emscripten_get_element_css_size(c"canvas".as_ptr().cast(), &mut width, &mut height);
|
||||
(width as u32, height as u32)
|
||||
}
|
||||
58
src/platform/mod.rs
Normal file
58
src/platform/mod.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
//! Platform abstraction layer for cross-platform functionality.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::asset::{Asset, AssetError};
|
||||
|
||||
pub mod desktop;
|
||||
pub mod emscripten;
|
||||
|
||||
/// Platform abstraction trait that defines cross-platform functionality.
|
||||
pub trait Platform {
|
||||
/// Sleep for the specified duration using platform-appropriate method.
|
||||
fn sleep(&self, duration: Duration);
|
||||
|
||||
/// Get the current time in seconds since some reference point.
|
||||
/// This is available for future use in timing and performance monitoring.
|
||||
#[allow(dead_code)]
|
||||
fn get_time(&self) -> f64;
|
||||
|
||||
/// Initialize platform-specific console functionality.
|
||||
fn init_console(&self) -> Result<(), PlatformError>;
|
||||
|
||||
/// Get canvas size for platforms that need it (e.g., Emscripten).
|
||||
/// This is available for future use in responsive design.
|
||||
#[allow(dead_code)]
|
||||
fn get_canvas_size(&self) -> Option<(u32, u32)>;
|
||||
|
||||
/// Load asset bytes using platform-appropriate method.
|
||||
fn get_asset_bytes(&self, asset: Asset) -> Result<Cow<'static, [u8]>, AssetError>;
|
||||
}
|
||||
|
||||
/// Platform-specific errors.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[allow(dead_code)]
|
||||
pub enum PlatformError {
|
||||
#[error("Console initialization failed: {0}")]
|
||||
ConsoleInit(String),
|
||||
#[error("Platform-specific error: {0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// Get the current platform implementation.
|
||||
#[allow(dead_code)]
|
||||
pub fn get_platform() -> &'static dyn Platform {
|
||||
static DESKTOP: desktop::DesktopPlatform = desktop::DesktopPlatform;
|
||||
static EMSCRIPTEN: emscripten::EmscriptenPlatform = emscripten::EmscriptenPlatform;
|
||||
|
||||
#[cfg(not(target_os = "emscripten"))]
|
||||
{
|
||||
&DESKTOP
|
||||
}
|
||||
|
||||
#[cfg(target_os = "emscripten")]
|
||||
{
|
||||
&EMSCRIPTEN
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
use anyhow::Result;
|
||||
use sdl2::rect::Rect;
|
||||
use sdl2::render::{Canvas, RenderTarget};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::entity::direction::Direction;
|
||||
use crate::texture::animated::AnimatedTexture;
|
||||
@@ -9,12 +8,12 @@ use crate::texture::sprite::SpriteAtlas;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DirectionalAnimatedTexture {
|
||||
textures: HashMap<Direction, AnimatedTexture>,
|
||||
stopped_textures: HashMap<Direction, AnimatedTexture>,
|
||||
textures: [Option<AnimatedTexture>; 4],
|
||||
stopped_textures: [Option<AnimatedTexture>; 4],
|
||||
}
|
||||
|
||||
impl DirectionalAnimatedTexture {
|
||||
pub fn new(textures: HashMap<Direction, AnimatedTexture>, stopped_textures: HashMap<Direction, AnimatedTexture>) -> Self {
|
||||
pub fn new(textures: [Option<AnimatedTexture>; 4], stopped_textures: [Option<AnimatedTexture>; 4]) -> Self {
|
||||
Self {
|
||||
textures,
|
||||
stopped_textures,
|
||||
@@ -22,7 +21,7 @@ impl DirectionalAnimatedTexture {
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, dt: f32) {
|
||||
for texture in self.textures.values_mut() {
|
||||
for texture in self.textures.iter_mut().flatten() {
|
||||
texture.tick(dt);
|
||||
}
|
||||
}
|
||||
@@ -34,7 +33,7 @@ impl DirectionalAnimatedTexture {
|
||||
dest: Rect,
|
||||
direction: Direction,
|
||||
) -> Result<()> {
|
||||
if let Some(texture) = self.textures.get(&direction) {
|
||||
if let Some(texture) = &self.textures[direction.as_usize()] {
|
||||
texture.render(canvas, atlas, dest)
|
||||
} else {
|
||||
Ok(())
|
||||
@@ -48,7 +47,7 @@ impl DirectionalAnimatedTexture {
|
||||
dest: Rect,
|
||||
direction: Direction,
|
||||
) -> Result<()> {
|
||||
if let Some(texture) = self.stopped_textures.get(&direction) {
|
||||
if let Some(texture) = &self.stopped_textures[direction.as_usize()] {
|
||||
texture.render(canvas, atlas, dest)
|
||||
} else {
|
||||
Ok(())
|
||||
@@ -58,24 +57,24 @@ impl DirectionalAnimatedTexture {
|
||||
/// Returns true if the texture has a direction.
|
||||
#[allow(dead_code)]
|
||||
pub fn has_direction(&self, direction: Direction) -> bool {
|
||||
self.textures.contains_key(&direction)
|
||||
self.textures[direction.as_usize()].is_some()
|
||||
}
|
||||
|
||||
/// Returns true if the texture has a stopped direction.
|
||||
#[allow(dead_code)]
|
||||
pub fn has_stopped_direction(&self, direction: Direction) -> bool {
|
||||
self.stopped_textures.contains_key(&direction)
|
||||
self.stopped_textures[direction.as_usize()].is_some()
|
||||
}
|
||||
|
||||
/// Returns the number of textures.
|
||||
#[allow(dead_code)]
|
||||
pub fn texture_count(&self) -> usize {
|
||||
self.textures.len()
|
||||
self.textures.iter().filter(|t| t.is_some()).count()
|
||||
}
|
||||
|
||||
/// Returns the number of stopped textures.
|
||||
#[allow(dead_code)]
|
||||
pub fn stopped_texture_count(&self) -> usize {
|
||||
self.stopped_textures.len()
|
||||
self.stopped_textures.iter().filter(|t| t.is_some()).count()
|
||||
}
|
||||
}
|
||||
|
||||
34
tests/debug_rendering.rs
Normal file
34
tests/debug_rendering.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use glam::Vec2;
|
||||
use pacman::entity::graph::{Graph, Node};
|
||||
use pacman::map::render::MapRenderer;
|
||||
|
||||
#[test]
|
||||
fn test_find_nearest_node() {
|
||||
let mut graph = Graph::new();
|
||||
|
||||
// Add some test nodes
|
||||
let node1 = graph.add_node(Node {
|
||||
position: Vec2::new(10.0, 10.0),
|
||||
});
|
||||
let node2 = graph.add_node(Node {
|
||||
position: Vec2::new(50.0, 50.0),
|
||||
});
|
||||
let node3 = graph.add_node(Node {
|
||||
position: Vec2::new(100.0, 100.0),
|
||||
});
|
||||
|
||||
// Test cursor near node1
|
||||
let cursor_pos = Vec2::new(12.0, 8.0);
|
||||
let nearest = MapRenderer::find_nearest_node(&graph, cursor_pos);
|
||||
assert_eq!(nearest, Some(node1));
|
||||
|
||||
// Test cursor near node2
|
||||
let cursor_pos = Vec2::new(45.0, 55.0);
|
||||
let nearest = MapRenderer::find_nearest_node(&graph, cursor_pos);
|
||||
assert_eq!(nearest, Some(node2));
|
||||
|
||||
// Test cursor near node3
|
||||
let cursor_pos = Vec2::new(98.0, 102.0);
|
||||
let nearest = MapRenderer::find_nearest_node(&graph, cursor_pos);
|
||||
assert_eq!(nearest, Some(node3));
|
||||
}
|
||||
@@ -4,7 +4,6 @@ use pacman::texture::animated::AnimatedTexture;
|
||||
use pacman::texture::directional::DirectionalAnimatedTexture;
|
||||
use pacman::texture::sprite::AtlasTile;
|
||||
use sdl2::pixels::Color;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn mock_atlas_tile(id: u32) -> AtlasTile {
|
||||
AtlasTile {
|
||||
@@ -20,10 +19,10 @@ fn mock_animated_texture(id: u32) -> AnimatedTexture {
|
||||
|
||||
#[test]
|
||||
fn test_directional_texture_partial_directions() {
|
||||
let mut textures = HashMap::new();
|
||||
textures.insert(Direction::Up, mock_animated_texture(1));
|
||||
let mut textures = [None, None, None, None];
|
||||
textures[Direction::Up.as_usize()] = Some(mock_animated_texture(1));
|
||||
|
||||
let texture = DirectionalAnimatedTexture::new(textures, HashMap::new());
|
||||
let texture = DirectionalAnimatedTexture::new(textures, [None, None, None, None]);
|
||||
|
||||
assert_eq!(texture.texture_count(), 1);
|
||||
assert!(texture.has_direction(Direction::Up));
|
||||
@@ -34,7 +33,7 @@ fn test_directional_texture_partial_directions() {
|
||||
|
||||
#[test]
|
||||
fn test_directional_texture_all_directions() {
|
||||
let mut textures = HashMap::new();
|
||||
let mut textures = [None, None, None, None];
|
||||
let directions = [
|
||||
(Direction::Up, 1),
|
||||
(Direction::Down, 2),
|
||||
@@ -43,10 +42,10 @@ fn test_directional_texture_all_directions() {
|
||||
];
|
||||
|
||||
for (direction, id) in directions {
|
||||
textures.insert(direction, mock_animated_texture(id));
|
||||
textures[direction.as_usize()] = Some(mock_animated_texture(id));
|
||||
}
|
||||
|
||||
let texture = DirectionalAnimatedTexture::new(textures, HashMap::new());
|
||||
let texture = DirectionalAnimatedTexture::new(textures, [None, None, None, None]);
|
||||
|
||||
assert_eq!(texture.texture_count(), 4);
|
||||
for direction in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] {
|
||||
|
||||
48
tests/ghost.rs
Normal file
48
tests/ghost.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use pacman::entity::ghost::{Ghost, GhostType};
|
||||
use pacman::entity::graph::Graph;
|
||||
use pacman::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn create_test_atlas() -> SpriteAtlas {
|
||||
let mut frames = HashMap::new();
|
||||
let directions = ["up", "down", "left", "right"];
|
||||
let ghost_types = ["blinky", "pinky", "inky", "clyde"];
|
||||
|
||||
for ghost_type in &ghost_types {
|
||||
for (i, dir) in directions.iter().enumerate() {
|
||||
frames.insert(
|
||||
format!("ghost/{}/{}_{}.png", ghost_type, dir, "a"),
|
||||
MapperFrame {
|
||||
x: i as u16 * 16,
|
||||
y: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
frames.insert(
|
||||
format!("ghost/{}/{}_{}.png", ghost_type, dir, "b"),
|
||||
MapperFrame {
|
||||
x: i as u16 * 16,
|
||||
y: 16,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mapper = AtlasMapper { frames };
|
||||
let dummy_texture = unsafe { std::mem::zeroed() };
|
||||
SpriteAtlas::new(dummy_texture, mapper)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ghost_creation() {
|
||||
let graph = Graph::new();
|
||||
let atlas = create_test_atlas();
|
||||
|
||||
let ghost = Ghost::new(&graph, 0, GhostType::Blinky, &atlas);
|
||||
|
||||
assert_eq!(ghost.ghost_type, GhostType::Blinky);
|
||||
assert_eq!(ghost.traverser.position.from_node_id(), 0);
|
||||
}
|
||||
205
web.build.ts
205
web.build.ts
@@ -3,6 +3,24 @@ import { existsSync, promises as fs } from "fs";
|
||||
import { platform } from "os";
|
||||
import { dirname, join, relative, resolve } from "path";
|
||||
import { match, P } from "ts-pattern";
|
||||
import { configure, getConsoleSink, getLogger } from "@logtape/logtape";
|
||||
|
||||
// Constants
|
||||
const TAILWIND_UPDATE_WINDOW_DAYS = 60; // 2 months
|
||||
|
||||
await configure({
|
||||
sinks: { console: getConsoleSink() },
|
||||
loggers: [
|
||||
{ category: "web", lowestLevel: "debug", sinks: ["console"] },
|
||||
{
|
||||
category: ["logtape", "meta"],
|
||||
lowestLevel: "warning",
|
||||
sinks: ["console"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const logger = getLogger("web");
|
||||
|
||||
type Os =
|
||||
| { type: "linux"; wsl: boolean }
|
||||
@@ -22,10 +40,6 @@ const os: Os = match(platform())
|
||||
throw new Error(`Unsupported platform: ${platform()}`);
|
||||
});
|
||||
|
||||
function log(msg: string) {
|
||||
console.log(`[web.build] ${msg}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the application with Emscripten, generate the CSS, and copy the files into 'dist'.
|
||||
*
|
||||
@@ -33,7 +47,7 @@ function log(msg: string) {
|
||||
* @param env - The environment variables to inject into build commands.
|
||||
*/
|
||||
async function build(release: boolean, env: Record<string, string> | null) {
|
||||
log(
|
||||
logger.info(
|
||||
`Building for 'wasm32-unknown-emscripten' for ${
|
||||
release ? "release" : "debug"
|
||||
}`
|
||||
@@ -46,7 +60,7 @@ async function build(release: boolean, env: Record<string, string> | null) {
|
||||
const tailwindExecutable = match(
|
||||
await downloadTailwind(process.cwd(), {
|
||||
version: "latest",
|
||||
force: true,
|
||||
force: false,
|
||||
})
|
||||
)
|
||||
.with({ path: P.select() }, (path) => path)
|
||||
@@ -55,7 +69,7 @@ async function build(release: boolean, env: Record<string, string> | null) {
|
||||
})
|
||||
.exhaustive();
|
||||
|
||||
log(`Invoking ${tailwindExecutable}...`);
|
||||
logger.debug(`Invoking ${tailwindExecutable}...`);
|
||||
await $`${tailwindExecutable} --minify --input styles.css --output build.css --cwd assets/site`;
|
||||
|
||||
const buildType = release ? "release" : "debug";
|
||||
@@ -92,20 +106,20 @@ async function build(release: boolean, env: Record<string, string> | null) {
|
||||
.map(async (dir) => {
|
||||
// If the folder doesn't exist, create it
|
||||
if (!(await fs.exists(dir))) {
|
||||
log(`Creating folder ${dir}`);
|
||||
logger.debug(`Creating folder ${dir}`);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Copy the files to the dist folder
|
||||
log("Copying files into dist");
|
||||
logger.debug("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(
|
||||
logger.debug(
|
||||
`Optional file ${os.type === "windows" ? "\\" : "/"}${relative(
|
||||
process.cwd(),
|
||||
src
|
||||
@@ -148,32 +162,130 @@ async function downloadTailwind(
|
||||
? `https://github.com/tailwindlabs/tailwindcss/releases/latest/download/${asset}`
|
||||
: `https://github.com/tailwindlabs/tailwindcss/releases/download/${version}/${asset}`;
|
||||
|
||||
// If the GITHUB_TOKEN environment variable is set, use it for Bearer authentication
|
||||
const headers: Record<string, string> = {};
|
||||
if (process.env.GITHUB_TOKEN) {
|
||||
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
||||
}
|
||||
|
||||
// Check if the file already exists
|
||||
const path = join(dir, asset);
|
||||
if (await fs.exists(path)) {
|
||||
const exists = await fs.exists(path);
|
||||
|
||||
// Check if we should download based on timestamps
|
||||
let shouldDownload = force || !exists;
|
||||
|
||||
if (exists && !force) {
|
||||
try {
|
||||
const fileStats = await fs.stat(path);
|
||||
const fileModifiedTime = fileStats.mtime;
|
||||
const now = new Date();
|
||||
|
||||
// Check if file is older than the update window
|
||||
const updateWindowAgo = new Date(
|
||||
now.getTime() - TAILWIND_UPDATE_WINDOW_DAYS * 24 * 60 * 60 * 1000
|
||||
);
|
||||
|
||||
if (fileModifiedTime < updateWindowAgo) {
|
||||
logger.debug(
|
||||
`File is older than ${TAILWIND_UPDATE_WINDOW_DAYS} days, checking for updates...`
|
||||
);
|
||||
shouldDownload = true;
|
||||
} else {
|
||||
logger.debug(
|
||||
`File is recent (${fileModifiedTime.toISOString()}), checking if newer version available...`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`Error checking file timestamp: ${error}, will download anyway`
|
||||
);
|
||||
shouldDownload = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If we need to download, check the server's last-modified header
|
||||
if (shouldDownload) {
|
||||
const response = await fetch(url, {
|
||||
headers,
|
||||
method: "HEAD",
|
||||
redirect: "follow",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const lastModified = response.headers.get("last-modified");
|
||||
if (lastModified) {
|
||||
const serverTime = new Date(lastModified);
|
||||
const now = new Date();
|
||||
|
||||
// If server timestamp is in the future, something is wrong - download anyway
|
||||
if (serverTime > now) {
|
||||
logger.debug(
|
||||
`Server timestamp is in the future (${serverTime.toISOString()}), downloading anyway`
|
||||
);
|
||||
shouldDownload = true;
|
||||
} else if (exists) {
|
||||
// Compare with local file timestamp (both in UTC)
|
||||
const fileStats = await fs.stat(path);
|
||||
const fileModifiedTime = new Date(fileStats.mtime.getTime());
|
||||
|
||||
if (serverTime > fileModifiedTime) {
|
||||
logger.debug(
|
||||
`Server has newer version (${serverTime.toISOString()} vs local ${fileModifiedTime.toISOString()})`
|
||||
);
|
||||
shouldDownload = true;
|
||||
} else {
|
||||
logger.debug(
|
||||
`Local file is up to date (${fileModifiedTime.toISOString()})`
|
||||
);
|
||||
shouldDownload = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.debug(
|
||||
`No last-modified header available, downloading to be safe`
|
||||
);
|
||||
shouldDownload = true;
|
||||
}
|
||||
} else {
|
||||
logger.debug(
|
||||
`Failed to check server headers: ${response.status} ${response.statusText}`
|
||||
);
|
||||
shouldDownload = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (exists && !shouldDownload) {
|
||||
const displayPath = match(relative(process.cwd(), path))
|
||||
// If the path is not a subpath of cwd, display the absolute path
|
||||
.with(P.string.startsWith(".."), (_relative) => path)
|
||||
// Otherwise, display the relative path
|
||||
.otherwise((relative) => relative);
|
||||
|
||||
if (!force) {
|
||||
log(`Tailwind CSS CLI already exists at ${displayPath}`);
|
||||
return { path };
|
||||
logger.debug(
|
||||
`Tailwind CSS CLI already exists and is up to date at ${displayPath}`
|
||||
);
|
||||
return { path };
|
||||
}
|
||||
|
||||
if (exists) {
|
||||
const displayPath = match(relative(process.cwd(), path))
|
||||
// If the path is not a subpath of cwd, display the absolute path
|
||||
.with(P.string.startsWith(".."), (_relative) => path)
|
||||
// Otherwise, display the relative path
|
||||
.otherwise((relative) => relative);
|
||||
|
||||
if (force) {
|
||||
logger.debug(`Overwriting Tailwind CSS CLI at ${displayPath}`);
|
||||
} else {
|
||||
log(`Overwriting Tailwind CSS CLI at ${displayPath}`);
|
||||
logger.debug(`Downloading updated Tailwind CSS CLI to ${displayPath}`);
|
||||
}
|
||||
} else {
|
||||
log(`Downloading Tailwind CSS CLI to ${path}`);
|
||||
logger.debug(`Downloading Tailwind CSS CLI to ${path}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// If the GITHUB_TOKEN environment variable is set, use it for Bearer authentication
|
||||
const headers: Record<string, string> = {};
|
||||
if (process.env.GITHUB_TOKEN) {
|
||||
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
||||
}
|
||||
|
||||
log(`Fetching ${url}...`);
|
||||
logger.debug(`Fetching ${url}...`);
|
||||
const response = await fetch(url, { headers });
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -184,25 +296,60 @@ async function downloadTailwind(
|
||||
return { err: `No response body received for '${url}'` };
|
||||
}
|
||||
|
||||
log(`Writing to ${path}...`);
|
||||
// Validate Content-Length if available
|
||||
const contentLength = response.headers.get("content-length");
|
||||
if (contentLength) {
|
||||
const expectedSize = parseInt(contentLength, 10);
|
||||
if (isNaN(expectedSize)) {
|
||||
return { err: `Invalid Content-Length header: ${contentLength}` };
|
||||
}
|
||||
logger.debug(`Expected file size: ${expectedSize} bytes`);
|
||||
}
|
||||
|
||||
logger.debug(`Writing to ${path}...`);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
const file = Bun.file(path);
|
||||
const writer = file.writer();
|
||||
|
||||
const reader = response.body.getReader();
|
||||
let downloadedBytes = 0;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
writer.write(value);
|
||||
downloadedBytes += value.length;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
await writer.end();
|
||||
}
|
||||
|
||||
// Validate downloaded file size
|
||||
if (contentLength) {
|
||||
const expectedSize = parseInt(contentLength, 10);
|
||||
const actualSize = downloadedBytes;
|
||||
|
||||
if (actualSize !== expectedSize) {
|
||||
// Clean up the corrupted file
|
||||
try {
|
||||
await fs.unlink(path);
|
||||
} catch (unlinkError) {
|
||||
logger.debug(
|
||||
`Warning: Failed to clean up corrupted file: ${unlinkError}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
err: `File size mismatch: expected ${expectedSize} bytes, got ${actualSize} bytes. File may be corrupted.`,
|
||||
};
|
||||
}
|
||||
|
||||
logger.debug(`File size validation passed: ${actualSize} bytes`);
|
||||
}
|
||||
|
||||
// Make the file executable on Unix-like systems
|
||||
if (os.type !== "windows") {
|
||||
await $`chmod +x ${path}`;
|
||||
@@ -215,7 +362,7 @@ async function downloadTailwind(
|
||||
if ((await fs.stat(path)).size > 0) break;
|
||||
} catch {
|
||||
// File might not be ready yet
|
||||
log(`File ${path} is not ready yet, waiting...`);
|
||||
logger.debug(`File ${path} is not ready yet, waiting...`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
} while (Date.now() < timeout);
|
||||
@@ -259,7 +406,7 @@ async function activateEmsdk(
|
||||
): Promise<{ vars: Record<string, string> | null } | { err: string }> {
|
||||
// If the EMSDK environment variable is set already & the path specified exists, return nothing
|
||||
if (process.env.EMSDK && (await fs.exists(resolve(process.env.EMSDK)))) {
|
||||
log(
|
||||
logger.debug(
|
||||
"Emscripten SDK already activated in environment, using existing configuration"
|
||||
);
|
||||
return { vars: null };
|
||||
@@ -354,7 +501,7 @@ async function activateEmsdk(
|
||||
|
||||
async function main() {
|
||||
// Print the OS detected
|
||||
log(
|
||||
logger.debug(
|
||||
"OS Detected: " +
|
||||
match(os)
|
||||
.with({ type: "windows" }, () => "Windows")
|
||||
@@ -372,7 +519,7 @@ async function main() {
|
||||
const vars = match(await activateEmsdk(emsdkDir))
|
||||
.with({ vars: P.select() }, (vars) => vars)
|
||||
.with({ err: P.any }, ({ err }) => {
|
||||
log("Error activating Emscripten SDK: " + err);
|
||||
logger.debug("Error activating Emscripten SDK: " + err);
|
||||
process.exit(1);
|
||||
})
|
||||
.exhaustive();
|
||||
@@ -385,6 +532,6 @@ async function main() {
|
||||
* Main entry point.
|
||||
*/
|
||||
main().catch((err) => {
|
||||
console.error("[web.build] Error:", err);
|
||||
console.error({ msg: "fatal error", error: err });
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user