Compare commits

...

7 Commits

22 changed files with 788 additions and 232 deletions

1
Cargo.lock generated
View File

@@ -192,6 +192,7 @@ dependencies = [
"sdl2",
"serde",
"serde_json",
"smallvec",
"spin_sleep",
"thiserror 1.0.69",
"tracing",

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View 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");
}
}
}

View File

@@ -1,3 +1,4 @@
pub mod direction;
pub mod ghost;
pub mod graph;
pub mod pacman;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View 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"))),
}
}
}

View 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
View 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
}
}

View File

@@ -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
View 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));
}

View File

@@ -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
View 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);
}

View File

@@ -3,7 +3,7 @@ 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 } from "@logtape/logtape";
import { configure, getConsoleSink, getLogger } from "@logtape/logtape";
// Constants
const TAILWIND_UPDATE_WINDOW_DAYS = 60; // 2 months
@@ -11,7 +11,7 @@ const TAILWIND_UPDATE_WINDOW_DAYS = 60; // 2 months
await configure({
sinks: { console: getConsoleSink() },
loggers: [
{ category: "web.build", lowestLevel: "debug", sinks: ["console"] },
{ category: "web", lowestLevel: "debug", sinks: ["console"] },
{
category: ["logtape", "meta"],
lowestLevel: "warning",
@@ -20,6 +20,8 @@ await configure({
],
});
const logger = getLogger("web");
type Os =
| { type: "linux"; wsl: boolean }
| { type: "windows" }
@@ -38,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'.
*
@@ -49,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"
}`
@@ -71,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";
@@ -108,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
@@ -189,17 +187,19 @@ async function downloadTailwind(
);
if (fileModifiedTime < updateWindowAgo) {
log(
logger.debug(
`File is older than ${TAILWIND_UPDATE_WINDOW_DAYS} days, checking for updates...`
);
shouldDownload = true;
} else {
log(
logger.debug(
`File is recent (${fileModifiedTime.toISOString()}), checking if newer version available...`
);
}
} catch (error) {
log(`Error checking file timestamp: ${error}, will download anyway`);
logger.debug(
`Error checking file timestamp: ${error}, will download anyway`
);
shouldDownload = true;
}
}
@@ -220,7 +220,7 @@ async function downloadTailwind(
// If server timestamp is in the future, something is wrong - download anyway
if (serverTime > now) {
log(
logger.debug(
`Server timestamp is in the future (${serverTime.toISOString()}), downloading anyway`
);
shouldDownload = true;
@@ -230,21 +230,25 @@ async function downloadTailwind(
const fileModifiedTime = new Date(fileStats.mtime.getTime());
if (serverTime > fileModifiedTime) {
log(
logger.debug(
`Server has newer version (${serverTime.toISOString()} vs local ${fileModifiedTime.toISOString()})`
);
shouldDownload = true;
} else {
log(`Local file is up to date (${fileModifiedTime.toISOString()})`);
logger.debug(
`Local file is up to date (${fileModifiedTime.toISOString()})`
);
shouldDownload = false;
}
}
} else {
log(`No last-modified header available, downloading to be safe`);
logger.debug(
`No last-modified header available, downloading to be safe`
);
shouldDownload = true;
}
} else {
log(
logger.debug(
`Failed to check server headers: ${response.status} ${response.statusText}`
);
shouldDownload = true;
@@ -258,7 +262,9 @@ async function downloadTailwind(
// Otherwise, display the relative path
.otherwise((relative) => relative);
log(`Tailwind CSS CLI already exists and is up to date at ${displayPath}`);
logger.debug(
`Tailwind CSS CLI already exists and is up to date at ${displayPath}`
);
return { path };
}
@@ -270,16 +276,16 @@ async function downloadTailwind(
.otherwise((relative) => relative);
if (force) {
log(`Overwriting Tailwind CSS CLI at ${displayPath}`);
logger.debug(`Overwriting Tailwind CSS CLI at ${displayPath}`);
} else {
log(`Downloading updated Tailwind CSS CLI to ${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 {
log(`Fetching ${url}...`);
logger.debug(`Fetching ${url}...`);
const response = await fetch(url, { headers });
if (!response.ok) {
@@ -297,10 +303,10 @@ async function downloadTailwind(
if (isNaN(expectedSize)) {
return { err: `Invalid Content-Length header: ${contentLength}` };
}
log(`Expected file size: ${expectedSize} bytes`);
logger.debug(`Expected file size: ${expectedSize} bytes`);
}
log(`Writing to ${path}...`);
logger.debug(`Writing to ${path}...`);
await fs.mkdir(dir, { recursive: true });
const file = Bun.file(path);
@@ -331,7 +337,9 @@ async function downloadTailwind(
try {
await fs.unlink(path);
} catch (unlinkError) {
log(`Warning: Failed to clean up corrupted file: ${unlinkError}`);
logger.debug(
`Warning: Failed to clean up corrupted file: ${unlinkError}`
);
}
return {
@@ -339,7 +347,7 @@ async function downloadTailwind(
};
}
log(`File size validation passed: ${actualSize} bytes`);
logger.debug(`File size validation passed: ${actualSize} bytes`);
}
// Make the file executable on Unix-like systems
@@ -354,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);
@@ -398,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 };
@@ -493,7 +501,7 @@ async function activateEmsdk(
async function main() {
// Print the OS detected
log(
logger.debug(
"OS Detected: " +
match(os)
.with({ type: "windows" }, () => "Windows")
@@ -511,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();
@@ -524,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);
});