mirror of
https://github.com/Xevion/Pac-Man.git
synced 2025-12-10 06:07:55 -06:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5213320ac | |||
| e0f8443e75 | |||
| 6702b3723a | |||
| f6e7228f75 | |||
| 14cebe4462 |
19
src/app.rs
19
src/app.rs
@@ -10,19 +10,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,
|
||||
@@ -35,6 +23,9 @@ pub struct App<'a> {
|
||||
|
||||
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))?;
|
||||
@@ -138,7 +129,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];
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] {
|
||||
|
||||
74
web.build.ts
74
web.build.ts
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user