Compare commits

...

5 Commits

14 changed files with 327 additions and 198 deletions

View File

@@ -10,19 +10,7 @@ use tracing::{error, event};
use crate::constants::{CANVAS_SIZE, LOOP_TIME, SCALE}; use crate::constants::{CANVAS_SIZE, LOOP_TIME, SCALE};
use crate::game::Game; use crate::game::Game;
use crate::platform::get_platform;
#[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);
}
pub struct App<'a> { pub struct App<'a> {
game: Game, game: Game,
@@ -35,6 +23,9 @@ pub struct App<'a> {
impl App<'_> { impl App<'_> {
pub fn new() -> Result<Self> { 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 sdl_context = sdl2::init().map_err(|e| anyhow!(e))?;
let video_subsystem = sdl_context.video().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))?; let audio_subsystem = sdl_context.audio().map_err(|e| anyhow!(e))?;
@@ -138,7 +129,7 @@ impl App<'_> {
if start.elapsed() < LOOP_TIME { if start.elapsed() < LOOP_TIME {
let time = LOOP_TIME.saturating_sub(start.elapsed()); let time = LOOP_TIME.saturating_sub(start.elapsed());
if time != Duration::ZERO { if time != Duration::ZERO {
sleep(time); get_platform().sleep(time);
} }
} else { } else {
event!( event!(

View File

@@ -42,40 +42,12 @@ impl Asset {
} }
} }
#[cfg(not(target_os = "emscripten"))]
mod imp { mod imp {
use super::*; use super::*;
macro_rules! asset_bytes_enum { use crate::platform::get_platform;
( $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))
}
}
#[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> { pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
let path = format!("assets/game/{}", asset.path()); get_platform().get_asset_bytes(asset)
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))
} }
} }

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; use glam::IVec2;
/// The four cardinal directions.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
pub enum Direction { pub enum Direction {
Up, Up,
@@ -9,7 +10,12 @@ pub enum Direction {
} }
impl 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 { match self {
Direction::Up => Direction::Down, Direction::Up => Direction::Down,
Direction::Down => Direction::Up, Direction::Down => Direction::Up,
@@ -18,8 +24,20 @@ impl Direction {
} }
} }
pub fn as_ivec2(&self) -> IVec2 { /// Returns the direction as an IVec2.
(*self).into() 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];

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 glam::{UVec2, Vec2};
use crate::constants::BOARD_PIXEL_OFFSET; use crate::constants::BOARD_PIXEL_OFFSET;
@@ -9,23 +15,35 @@ use crate::texture::directional::DirectionalAnimatedTexture;
use crate::texture::sprite::SpriteAtlas; use crate::texture::sprite::SpriteAtlas;
use sdl2::keyboard::Keycode; use sdl2::keyboard::Keycode;
use sdl2::render::{Canvas, RenderTarget}; 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 { fn can_pacman_traverse(edge: Edge) -> bool {
matches!(edge.permissions, EdgePermissions::All) 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 { pub struct Pacman {
/// Handles movement through the game graph
pub traverser: Traverser, pub traverser: Traverser,
/// Manages directional animated textures for different movement states
texture: DirectionalAnimatedTexture, texture: DirectionalAnimatedTexture,
} }
impl Pacman { 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 { pub fn new(graph: &Graph, start_node: NodeId, atlas: &SpriteAtlas) -> Self {
let mut textures = HashMap::new(); let mut textures = [None, None, None, None];
let mut stopped_textures = HashMap::new(); 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 { let moving_prefix = match direction {
Direction::Up => "pacman/up", Direction::Up => "pacman/up",
Direction::Down => "pacman/down", Direction::Down => "pacman/down",
@@ -40,14 +58,9 @@ impl Pacman {
let stopped_tiles = vec![SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png")).unwrap()]; let stopped_tiles = vec![SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png")).unwrap()];
textures.insert( textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.08).expect("Invalid frame duration"));
direction, stopped_textures[direction.as_usize()] =
AnimatedTexture::new(moving_tiles, 0.08).expect("Invalid frame duration"), Some(AnimatedTexture::new(stopped_tiles, 0.1).expect("Invalid frame duration"));
);
stopped_textures.insert(
direction,
AnimatedTexture::new(stopped_tiles, 0.1).expect("Invalid frame duration"),
);
} }
Self { 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) { pub fn tick(&mut self, dt: f32, graph: &Graph) {
self.traverser.advance(graph, dt * 60.0 * 1.125, &can_pacman_traverse); self.traverser.advance(graph, dt * 60.0 * 1.125, &can_pacman_traverse);
self.texture.tick(dt); 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) { pub fn handle_key(&mut self, keycode: Keycode) {
let direction = match keycode { let direction = match keycode {
Keycode::Up => Some(Direction::Up), 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 { fn get_pixel_pos(&self, graph: &Graph) -> Vec2 {
match self.traverser.position { match self.traverser.position {
Position::AtNode(node_id) => graph.get_node(node_id).unwrap().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) { 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 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)); let dest = centered_with_size(pixel_pos, UVec2::new(16, 16));

View File

@@ -4,9 +4,9 @@ pub mod app;
pub mod asset; pub mod asset;
pub mod audio; pub mod audio;
pub mod constants; pub mod constants;
pub mod emscripten;
pub mod entity; pub mod entity;
pub mod game; pub mod game;
pub mod helpers; pub mod helpers;
pub mod map; pub mod map;
pub mod platform;
pub mod texture; pub mod texture;

View File

@@ -5,59 +5,16 @@ use tracing::info;
use tracing_error::ErrorLayer; use tracing_error::ErrorLayer;
use tracing_subscriber::layer::SubscriberExt; 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 app;
mod asset; mod asset;
mod audio; mod audio;
mod constants; mod constants;
#[cfg(target_os = "emscripten")]
mod emscripten;
mod entity; mod entity;
mod game; mod game;
mod helpers; mod helpers;
mod map; mod map;
mod platform;
mod texture; mod texture;
/// The main entry point of the application. /// 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 /// This function initializes SDL, the window, the game state, and then enters
/// the main game loop. /// the main game loop.
pub fn main() { pub fn main() {
// Attaches the console on Windows for debugging purposes.
#[cfg(windows)]
unsafe {
attach_console();
}
// Setup tracing // Setup tracing
let subscriber = tracing_subscriber::fmt() let subscriber = tracing_subscriber::fmt()
.with_ansi(cfg!(not(target_os = "emscripten"))) .with_ansi(cfg!(not(target_os = "emscripten")))

View File

@@ -1,7 +1,7 @@
//! Map construction and building functionality. //! Map construction and building functionality.
use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE}; 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::entity::graph::{EdgePermissions, Graph, Node, NodeId};
use crate::map::parser::MapTileParser; use crate::map::parser::MapTileParser;
use crate::map::render::MapRenderer; 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 // Iterate over the queue, adding nodes to the graph and connecting them to their neighbors
while let Some(source_position) = queue.pop_front() { 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(); let new_position = source_position + dir.as_ivec2();
// Skip if the new position is out of bounds // 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 // 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 (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 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() { if graph.adjacency_list[node_id].get(dir).is_none() {
let neighbor = grid_pos + dir.as_ivec2(); let neighbor = grid_pos + dir.as_ivec2();

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 anyhow::Result;
use sdl2::rect::Rect; use sdl2::rect::Rect;
use sdl2::render::{Canvas, RenderTarget}; use sdl2::render::{Canvas, RenderTarget};
use std::collections::HashMap;
use crate::entity::direction::Direction; use crate::entity::direction::Direction;
use crate::texture::animated::AnimatedTexture; use crate::texture::animated::AnimatedTexture;
@@ -9,12 +8,12 @@ use crate::texture::sprite::SpriteAtlas;
#[derive(Clone)] #[derive(Clone)]
pub struct DirectionalAnimatedTexture { pub struct DirectionalAnimatedTexture {
textures: HashMap<Direction, AnimatedTexture>, textures: [Option<AnimatedTexture>; 4],
stopped_textures: HashMap<Direction, AnimatedTexture>, stopped_textures: [Option<AnimatedTexture>; 4],
} }
impl DirectionalAnimatedTexture { 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 { Self {
textures, textures,
stopped_textures, stopped_textures,
@@ -22,7 +21,7 @@ impl DirectionalAnimatedTexture {
} }
pub fn tick(&mut self, dt: f32) { 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); texture.tick(dt);
} }
} }
@@ -34,7 +33,7 @@ impl DirectionalAnimatedTexture {
dest: Rect, dest: Rect,
direction: Direction, direction: Direction,
) -> Result<()> { ) -> Result<()> {
if let Some(texture) = self.textures.get(&direction) { if let Some(texture) = &self.textures[direction.as_usize()] {
texture.render(canvas, atlas, dest) texture.render(canvas, atlas, dest)
} else { } else {
Ok(()) Ok(())
@@ -48,7 +47,7 @@ impl DirectionalAnimatedTexture {
dest: Rect, dest: Rect,
direction: Direction, direction: Direction,
) -> Result<()> { ) -> 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) texture.render(canvas, atlas, dest)
} else { } else {
Ok(()) Ok(())
@@ -58,24 +57,24 @@ impl DirectionalAnimatedTexture {
/// Returns true if the texture has a direction. /// Returns true if the texture has a direction.
#[allow(dead_code)] #[allow(dead_code)]
pub fn has_direction(&self, direction: Direction) -> bool { 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. /// Returns true if the texture has a stopped direction.
#[allow(dead_code)] #[allow(dead_code)]
pub fn has_stopped_direction(&self, direction: Direction) -> bool { 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. /// Returns the number of textures.
#[allow(dead_code)] #[allow(dead_code)]
pub fn texture_count(&self) -> usize { pub fn texture_count(&self) -> usize {
self.textures.len() self.textures.iter().filter(|t| t.is_some()).count()
} }
/// Returns the number of stopped textures. /// Returns the number of stopped textures.
#[allow(dead_code)] #[allow(dead_code)]
pub fn stopped_texture_count(&self) -> usize { pub fn stopped_texture_count(&self) -> usize {
self.stopped_textures.len() self.stopped_textures.iter().filter(|t| t.is_some()).count()
} }
} }

View File

@@ -4,7 +4,6 @@ use pacman::texture::animated::AnimatedTexture;
use pacman::texture::directional::DirectionalAnimatedTexture; use pacman::texture::directional::DirectionalAnimatedTexture;
use pacman::texture::sprite::AtlasTile; use pacman::texture::sprite::AtlasTile;
use sdl2::pixels::Color; use sdl2::pixels::Color;
use std::collections::HashMap;
fn mock_atlas_tile(id: u32) -> AtlasTile { fn mock_atlas_tile(id: u32) -> AtlasTile {
AtlasTile { AtlasTile {
@@ -20,10 +19,10 @@ fn mock_animated_texture(id: u32) -> AnimatedTexture {
#[test] #[test]
fn test_directional_texture_partial_directions() { fn test_directional_texture_partial_directions() {
let mut textures = HashMap::new(); let mut textures = [None, None, None, None];
textures.insert(Direction::Up, mock_animated_texture(1)); 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_eq!(texture.texture_count(), 1);
assert!(texture.has_direction(Direction::Up)); assert!(texture.has_direction(Direction::Up));
@@ -34,7 +33,7 @@ fn test_directional_texture_partial_directions() {
#[test] #[test]
fn test_directional_texture_all_directions() { fn test_directional_texture_all_directions() {
let mut textures = HashMap::new(); let mut textures = [None, None, None, None];
let directions = [ let directions = [
(Direction::Up, 1), (Direction::Up, 1),
(Direction::Down, 2), (Direction::Down, 2),
@@ -43,10 +42,10 @@ fn test_directional_texture_all_directions() {
]; ];
for (direction, id) in 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); assert_eq!(texture.texture_count(), 4);
for direction in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] { for direction in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] {

View File

@@ -3,7 +3,7 @@ import { existsSync, promises as fs } from "fs";
import { platform } from "os"; import { platform } from "os";
import { dirname, join, relative, resolve } from "path"; import { dirname, join, relative, resolve } from "path";
import { match, P } from "ts-pattern"; import { match, P } from "ts-pattern";
import { configure, getConsoleSink } from "@logtape/logtape"; import { configure, getConsoleSink, getLogger } from "@logtape/logtape";
// Constants // Constants
const TAILWIND_UPDATE_WINDOW_DAYS = 60; // 2 months const TAILWIND_UPDATE_WINDOW_DAYS = 60; // 2 months
@@ -11,7 +11,7 @@ const TAILWIND_UPDATE_WINDOW_DAYS = 60; // 2 months
await configure({ await configure({
sinks: { console: getConsoleSink() }, sinks: { console: getConsoleSink() },
loggers: [ loggers: [
{ category: "web.build", lowestLevel: "debug", sinks: ["console"] }, { category: "web", lowestLevel: "debug", sinks: ["console"] },
{ {
category: ["logtape", "meta"], category: ["logtape", "meta"],
lowestLevel: "warning", lowestLevel: "warning",
@@ -20,6 +20,8 @@ await configure({
], ],
}); });
const logger = getLogger("web");
type Os = type Os =
| { type: "linux"; wsl: boolean } | { type: "linux"; wsl: boolean }
| { type: "windows" } | { type: "windows" }
@@ -38,10 +40,6 @@ const os: Os = match(platform())
throw new Error(`Unsupported platform: ${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'. * 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. * @param env - The environment variables to inject into build commands.
*/ */
async function build(release: boolean, env: Record<string, string> | null) { async function build(release: boolean, env: Record<string, string> | null) {
log( logger.info(
`Building for 'wasm32-unknown-emscripten' for ${ `Building for 'wasm32-unknown-emscripten' for ${
release ? "release" : "debug" release ? "release" : "debug"
}` }`
@@ -71,7 +69,7 @@ async function build(release: boolean, env: Record<string, string> | null) {
}) })
.exhaustive(); .exhaustive();
log(`Invoking ${tailwindExecutable}...`); logger.debug(`Invoking ${tailwindExecutable}...`);
await $`${tailwindExecutable} --minify --input styles.css --output build.css --cwd assets/site`; await $`${tailwindExecutable} --minify --input styles.css --output build.css --cwd assets/site`;
const buildType = release ? "release" : "debug"; const buildType = release ? "release" : "debug";
@@ -108,20 +106,20 @@ async function build(release: boolean, env: Record<string, string> | null) {
.map(async (dir) => { .map(async (dir) => {
// If the folder doesn't exist, create it // If the folder doesn't exist, create it
if (!(await fs.exists(dir))) { if (!(await fs.exists(dir))) {
log(`Creating folder ${dir}`); logger.debug(`Creating folder ${dir}`);
await fs.mkdir(dir, { recursive: true }); await fs.mkdir(dir, { recursive: true });
} }
}) })
); );
// Copy the files to the dist folder // Copy the files to the dist folder
log("Copying files into dist"); logger.debug("Copying files into dist");
await Promise.all( await Promise.all(
files.map(async ({ optional, src, dest }) => { files.map(async ({ optional, src, dest }) => {
match({ optional, exists: await fs.exists(src) }) match({ optional, exists: await fs.exists(src) })
// If optional and doesn't exist, skip // If optional and doesn't exist, skip
.with({ optional: true, exists: false }, () => { .with({ optional: true, exists: false }, () => {
log( logger.debug(
`Optional file ${os.type === "windows" ? "\\" : "/"}${relative( `Optional file ${os.type === "windows" ? "\\" : "/"}${relative(
process.cwd(), process.cwd(),
src src
@@ -189,17 +187,19 @@ async function downloadTailwind(
); );
if (fileModifiedTime < updateWindowAgo) { if (fileModifiedTime < updateWindowAgo) {
log( logger.debug(
`File is older than ${TAILWIND_UPDATE_WINDOW_DAYS} days, checking for updates...` `File is older than ${TAILWIND_UPDATE_WINDOW_DAYS} days, checking for updates...`
); );
shouldDownload = true; shouldDownload = true;
} else { } else {
log( logger.debug(
`File is recent (${fileModifiedTime.toISOString()}), checking if newer version available...` `File is recent (${fileModifiedTime.toISOString()}), checking if newer version available...`
); );
} }
} catch (error) { } catch (error) {
log(`Error checking file timestamp: ${error}, will download anyway`); logger.debug(
`Error checking file timestamp: ${error}, will download anyway`
);
shouldDownload = true; shouldDownload = true;
} }
} }
@@ -220,7 +220,7 @@ async function downloadTailwind(
// If server timestamp is in the future, something is wrong - download anyway // If server timestamp is in the future, something is wrong - download anyway
if (serverTime > now) { if (serverTime > now) {
log( logger.debug(
`Server timestamp is in the future (${serverTime.toISOString()}), downloading anyway` `Server timestamp is in the future (${serverTime.toISOString()}), downloading anyway`
); );
shouldDownload = true; shouldDownload = true;
@@ -230,21 +230,25 @@ async function downloadTailwind(
const fileModifiedTime = new Date(fileStats.mtime.getTime()); const fileModifiedTime = new Date(fileStats.mtime.getTime());
if (serverTime > fileModifiedTime) { if (serverTime > fileModifiedTime) {
log( logger.debug(
`Server has newer version (${serverTime.toISOString()} vs local ${fileModifiedTime.toISOString()})` `Server has newer version (${serverTime.toISOString()} vs local ${fileModifiedTime.toISOString()})`
); );
shouldDownload = true; shouldDownload = true;
} else { } else {
log(`Local file is up to date (${fileModifiedTime.toISOString()})`); logger.debug(
`Local file is up to date (${fileModifiedTime.toISOString()})`
);
shouldDownload = false; shouldDownload = false;
} }
} }
} else { } else {
log(`No last-modified header available, downloading to be safe`); logger.debug(
`No last-modified header available, downloading to be safe`
);
shouldDownload = true; shouldDownload = true;
} }
} else { } else {
log( logger.debug(
`Failed to check server headers: ${response.status} ${response.statusText}` `Failed to check server headers: ${response.status} ${response.statusText}`
); );
shouldDownload = true; shouldDownload = true;
@@ -258,7 +262,9 @@ async function downloadTailwind(
// Otherwise, display the relative path // Otherwise, display the relative path
.otherwise((relative) => relative); .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 }; return { path };
} }
@@ -270,16 +276,16 @@ async function downloadTailwind(
.otherwise((relative) => relative); .otherwise((relative) => relative);
if (force) { if (force) {
log(`Overwriting Tailwind CSS CLI at ${displayPath}`); logger.debug(`Overwriting Tailwind CSS CLI at ${displayPath}`);
} else { } else {
log(`Downloading updated Tailwind CSS CLI to ${displayPath}`); logger.debug(`Downloading updated Tailwind CSS CLI to ${displayPath}`);
} }
} else { } else {
log(`Downloading Tailwind CSS CLI to ${path}`); logger.debug(`Downloading Tailwind CSS CLI to ${path}`);
} }
try { try {
log(`Fetching ${url}...`); logger.debug(`Fetching ${url}...`);
const response = await fetch(url, { headers }); const response = await fetch(url, { headers });
if (!response.ok) { if (!response.ok) {
@@ -297,10 +303,10 @@ async function downloadTailwind(
if (isNaN(expectedSize)) { if (isNaN(expectedSize)) {
return { err: `Invalid Content-Length header: ${contentLength}` }; 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 }); await fs.mkdir(dir, { recursive: true });
const file = Bun.file(path); const file = Bun.file(path);
@@ -331,7 +337,9 @@ async function downloadTailwind(
try { try {
await fs.unlink(path); await fs.unlink(path);
} catch (unlinkError) { } catch (unlinkError) {
log(`Warning: Failed to clean up corrupted file: ${unlinkError}`); logger.debug(
`Warning: Failed to clean up corrupted file: ${unlinkError}`
);
} }
return { 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 // Make the file executable on Unix-like systems
@@ -354,7 +362,7 @@ async function downloadTailwind(
if ((await fs.stat(path)).size > 0) break; if ((await fs.stat(path)).size > 0) break;
} catch { } catch {
// File might not be ready yet // 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)); await new Promise((resolve) => setTimeout(resolve, 10));
} while (Date.now() < timeout); } while (Date.now() < timeout);
@@ -398,7 +406,7 @@ async function activateEmsdk(
): Promise<{ vars: Record<string, string> | null } | { err: string }> { ): Promise<{ vars: Record<string, string> | null } | { err: string }> {
// If the EMSDK environment variable is set already & the path specified exists, return nothing // 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)))) { if (process.env.EMSDK && (await fs.exists(resolve(process.env.EMSDK)))) {
log( logger.debug(
"Emscripten SDK already activated in environment, using existing configuration" "Emscripten SDK already activated in environment, using existing configuration"
); );
return { vars: null }; return { vars: null };
@@ -493,7 +501,7 @@ async function activateEmsdk(
async function main() { async function main() {
// Print the OS detected // Print the OS detected
log( logger.debug(
"OS Detected: " + "OS Detected: " +
match(os) match(os)
.with({ type: "windows" }, () => "Windows") .with({ type: "windows" }, () => "Windows")
@@ -511,7 +519,7 @@ async function main() {
const vars = match(await activateEmsdk(emsdkDir)) const vars = match(await activateEmsdk(emsdkDir))
.with({ vars: P.select() }, (vars) => vars) .with({ vars: P.select() }, (vars) => vars)
.with({ err: P.any }, ({ err }) => { .with({ err: P.any }, ({ err }) => {
log("Error activating Emscripten SDK: " + err); logger.debug("Error activating Emscripten SDK: " + err);
process.exit(1); process.exit(1);
}) })
.exhaustive(); .exhaustive();
@@ -524,6 +532,6 @@ async function main() {
* Main entry point. * Main entry point.
*/ */
main().catch((err) => { main().catch((err) => {
console.error("[web.build] Error:", err); console.error({ msg: "fatal error", error: err });
process.exit(1); process.exit(1);
}); });