Compare commits

...

14 Commits

31 changed files with 1221 additions and 1013 deletions

View File

@@ -11,7 +11,8 @@ pub enum Asset {
Wav2, Wav2,
Wav3, Wav3,
Wav4, Wav4,
Atlas, AtlasImage,
Font,
} }
impl Asset { impl Asset {
@@ -23,7 +24,8 @@ impl Asset {
Wav2 => "sound/waka/2.ogg", Wav2 => "sound/waka/2.ogg",
Wav3 => "sound/waka/3.ogg", Wav3 => "sound/waka/3.ogg",
Wav4 => "sound/waka/4.ogg", Wav4 => "sound/waka/4.ogg",
Atlas => "atlas.png", AtlasImage => "atlas.png",
Font => "TerminalVector.ttf",
} }
} }
} }

View File

@@ -218,11 +218,6 @@ impl Graph {
self.nodes.get(id) self.nodes.get(id)
} }
/// Returns the total number of nodes in the graph.
pub fn node_count(&self) -> usize {
self.nodes.len()
}
/// Returns an iterator over all nodes in the graph. /// Returns an iterator over all nodes in the graph.
pub fn nodes(&self) -> impl Iterator<Item = &Node> { pub fn nodes(&self) -> impl Iterator<Item = &Node> {
self.nodes.iter() self.nodes.iter()

View File

@@ -8,33 +8,34 @@ use crate::error::{GameError, GameResult, TextureError};
use crate::events::GameEvent; use crate::events::GameEvent;
use crate::map::builder::Map; use crate::map::builder::Map;
use crate::systems::blinking::Blinking; use crate::systems::blinking::Blinking;
use crate::systems::movement::{Movable, MovementState, Position}; use crate::systems::movement::{BufferedDirection, Position, Velocity};
use crate::systems::player::player_movement_system;
use crate::systems::profiling::SystemId;
use crate::systems::{ use crate::systems::{
audio::{audio_system, AudioEvent, AudioResource},
blinking::blinking_system, blinking::blinking_system,
collision::collision_system, collision::collision_system,
components::{ components::{
Collider, CollisionLayer, DeltaTime, DirectionalAnimated, EntityType, GlobalState, ItemBundle, ItemCollider, AudioState, Collider, DeltaTime, DirectionalAnimated, EntityType, Ghost, GhostBundle, GhostCollider, GlobalState,
PacmanCollider, PlayerBundle, PlayerControlled, RenderDirty, Renderable, Score, ScoreResource, ItemBundle, ItemCollider, PacmanCollider, PlayerBundle, PlayerControlled, RenderDirty, Renderable, ScoreResource,
}, },
control::player_system, debug::{debug_render_system, DebugFontResource, DebugState, DebugTextureResource},
debug::{debug_render_system, DebugState, DebugTextureResource}, ghost::ghost_movement_system,
input::input_system, input::input_system,
movement::movement_system, item::item_system,
player::player_control_system,
profiling::{profile, SystemTimings}, profiling::{profile, SystemTimings},
render::{directional_render_system, dirty_render_system, render_system, BackbufferResource, MapTextureResource}, render::{directional_render_system, dirty_render_system, render_system, BackbufferResource, MapTextureResource},
}; };
use crate::texture::animated::AnimatedTexture; use crate::texture::animated::AnimatedTexture;
use bevy_ecs::schedule::IntoScheduleConfigs; use bevy_ecs::event::EventRegistry;
use bevy_ecs::system::NonSendMut; use bevy_ecs::observer::Trigger;
use bevy_ecs::{ use bevy_ecs::schedule::Schedule;
event::EventRegistry, use bevy_ecs::system::{NonSendMut, Res, ResMut};
observer::Trigger, use bevy_ecs::world::World;
schedule::Schedule,
system::{Res, ResMut},
world::World,
};
use sdl2::image::LoadTexture; use sdl2::image::LoadTexture;
use sdl2::render::{Canvas, ScaleMode, TextureCreator}; use sdl2::render::{Canvas, ScaleMode, TextureCreator};
use sdl2::rwops::RWops;
use sdl2::video::{Window, WindowContext}; use sdl2::video::{Window, WindowContext};
use sdl2::EventPump; use sdl2::EventPump;
@@ -43,7 +44,7 @@ use crate::{
constants, constants,
events::GameCommand, events::GameCommand,
map::render::MapRenderer, map::render::MapRenderer,
systems::input::Bindings, systems::input::{Bindings, CursorPosition},
texture::sprite::{AtlasMapper, SpriteAtlas}, texture::sprite::{AtlasMapper, SpriteAtlas},
}; };
@@ -66,9 +67,11 @@ impl Game {
) -> GameResult<Game> { ) -> GameResult<Game> {
let mut world = World::default(); let mut world = World::default();
let mut schedule = Schedule::default(); let mut schedule = Schedule::default();
let ttf_context = Box::leak(Box::new(sdl2::ttf::init().map_err(|e| GameError::Sdl(e.to_string()))?));
EventRegistry::register_event::<GameError>(&mut world); EventRegistry::register_event::<GameError>(&mut world);
EventRegistry::register_event::<GameEvent>(&mut world); EventRegistry::register_event::<GameEvent>(&mut world);
EventRegistry::register_event::<AudioEvent>(&mut world);
let mut backbuffer = texture_creator let mut backbuffer = texture_creator
.create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y) .create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y)
@@ -87,8 +90,18 @@ impl Game {
.map_err(|e| GameError::Sdl(e.to_string()))?; .map_err(|e| GameError::Sdl(e.to_string()))?;
debug_texture.set_scale_mode(ScaleMode::Nearest); debug_texture.set_scale_mode(ScaleMode::Nearest);
let font_data = get_asset_bytes(Asset::Font)?;
let static_font_data: &'static [u8] = Box::leak(font_data.to_vec().into_boxed_slice());
let font_asset = RWops::from_bytes(static_font_data).map_err(|_| GameError::Sdl("Failed to load font".to_string()))?;
let debug_font = ttf_context
.load_font_from_rwops(font_asset, 12)
.map_err(|e| GameError::Sdl(e.to_string()))?;
// Initialize audio system
let audio = crate::audio::Audio::new();
// Load atlas and create map texture // Load atlas and create map texture
let atlas_bytes = get_asset_bytes(Asset::Atlas)?; let atlas_bytes = get_asset_bytes(Asset::AtlasImage)?;
let atlas_texture = texture_creator.load_texture_bytes(&atlas_bytes).map_err(|e| { let atlas_texture = texture_creator.load_texture_bytes(&atlas_bytes).map_err(|e| {
if e.to_string().contains("format") || e.to_string().contains("unsupported") { if e.to_string().contains("format") || e.to_string().contains("unsupported") {
GameError::Texture(crate::error::TextureError::InvalidFormat(format!( GameError::Texture(crate::error::TextureError::InvalidFormat(format!(
@@ -150,16 +163,12 @@ impl Game {
let player = PlayerBundle { let player = PlayerBundle {
player: PlayerControlled, player: PlayerControlled,
position: Position { position: Position::Stopped { node: pacman_start_node },
node: pacman_start_node, velocity: Velocity {
edge_progress: None,
},
movement_state: MovementState::Stopped,
movable: Movable {
speed: 1.15, speed: 1.15,
current_direction: Direction::Left, direction: Direction::Left,
requested_direction: Some(Direction::Left), // Start moving left immediately
}, },
buffered_direction: BufferedDirection::None,
sprite: Renderable { sprite: Renderable {
sprite: SpriteAtlas::get_tile(&atlas, "pacman/full.png") sprite: SpriteAtlas::get_tile(&atlas, "pacman/full.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?, .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?,
@@ -173,7 +182,6 @@ impl Game {
entity_type: EntityType::Player, entity_type: EntityType::Player,
collider: Collider { collider: Collider {
size: constants::CELL_SIZE as f32 * 1.375, size: constants::CELL_SIZE as f32 * 1.375,
layer: CollisionLayer::PACMAN,
}, },
pacman_collider: PacmanCollider, pacman_collider: PacmanCollider,
}; };
@@ -184,6 +192,8 @@ impl Game {
world.insert_non_send_resource(BackbufferResource(backbuffer)); world.insert_non_send_resource(BackbufferResource(backbuffer));
world.insert_non_send_resource(MapTextureResource(map_texture)); world.insert_non_send_resource(MapTextureResource(map_texture));
world.insert_non_send_resource(DebugTextureResource(debug_texture)); world.insert_non_send_resource(DebugTextureResource(debug_texture));
world.insert_non_send_resource(DebugFontResource(debug_font));
world.insert_non_send_resource(AudioResource(audio));
world.insert_resource(map); world.insert_resource(map);
world.insert_resource(GlobalState { exit: false }); world.insert_resource(GlobalState { exit: false });
@@ -193,52 +203,54 @@ impl Game {
world.insert_resource(DeltaTime(0f32)); world.insert_resource(DeltaTime(0f32));
world.insert_resource(RenderDirty::default()); world.insert_resource(RenderDirty::default());
world.insert_resource(DebugState::default()); world.insert_resource(DebugState::default());
world.insert_resource(AudioState::default());
world.insert_resource(CursorPosition::default());
world.add_observer( world.add_observer(
|event: Trigger<GameEvent>, mut state: ResMut<GlobalState>, _score: ResMut<ScoreResource>| match *event { |event: Trigger<GameEvent>, mut state: ResMut<GlobalState>, _score: ResMut<ScoreResource>| {
GameEvent::Command(command) => match command { if matches!(*event, GameEvent::Command(GameCommand::Exit)) {
GameCommand::Exit => { state.exit = true;
state.exit = true; }
}
_ => {}
},
GameEvent::Collision(_a, _b) => {}
}, },
); );
schedule.add_systems( schedule.add_systems((
( profile(SystemId::Input, input_system),
profile("input", input_system), profile(SystemId::PlayerControls, player_control_system),
profile("player", player_system), profile(SystemId::PlayerMovement, player_movement_system),
profile("movement", movement_system), profile(SystemId::Ghost, ghost_movement_system),
profile("collision", collision_system), profile(SystemId::Collision, collision_system),
profile("blinking", blinking_system), profile(SystemId::Item, item_system),
profile("directional_render", directional_render_system), profile(SystemId::Audio, audio_system),
profile("dirty_render", dirty_render_system), profile(SystemId::Blinking, blinking_system),
profile("render", render_system), profile(SystemId::DirectionalRender, directional_render_system),
profile("debug_render", debug_render_system), profile(SystemId::DirtyRender, dirty_render_system),
profile( profile(SystemId::Render, render_system),
"present", profile(SystemId::DebugRender, debug_render_system),
|mut canvas: NonSendMut<&mut Canvas<Window>>, profile(
backbuffer: NonSendMut<BackbufferResource>, SystemId::Present,
debug_state: Res<DebugState>, |mut canvas: NonSendMut<&mut Canvas<Window>>,
mut dirty: ResMut<RenderDirty>| { backbuffer: NonSendMut<BackbufferResource>,
if dirty.0 || *debug_state != DebugState::Off { debug_state: Res<DebugState>,
// Only copy backbuffer to main canvas if debug rendering is off mut dirty: ResMut<RenderDirty>| {
// (debug rendering draws directly to main canvas) if dirty.0 || *debug_state != DebugState::Off {
if *debug_state == DebugState::Off { // Only copy backbuffer to main canvas if debug rendering is off
canvas.copy(&backbuffer.0, None, None).unwrap(); // (debug rendering draws directly to main canvas)
} if *debug_state == DebugState::Off {
dirty.0 = false; canvas.copy(&backbuffer.0, None, None).unwrap();
canvas.present();
} }
}, dirty.0 = false;
), canvas.present();
) }
.chain(), },
); ),
));
// Spawn player // Spawn player
world.spawn(player); world.spawn(player);
// Spawn ghosts
Self::spawn_ghosts(&mut world)?;
// Spawn items // Spawn items
let pellet_sprite = SpriteAtlas::get_tile(world.non_send_resource::<SpriteAtlas>(), "maze/pellet.png") let pellet_sprite = SpriteAtlas::get_tile(world.non_send_resource::<SpriteAtlas>(), "maze/pellet.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("maze/pellet.png".to_string())))?; .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("maze/pellet.png".to_string())))?;
@@ -248,33 +260,23 @@ impl Game {
let nodes: Vec<_> = world.resource::<Map>().iter_nodes().map(|(id, tile)| (*id, *tile)).collect(); let nodes: Vec<_> = world.resource::<Map>().iter_nodes().map(|(id, tile)| (*id, *tile)).collect();
for (node_id, tile) in nodes { for (node_id, tile) in nodes {
let (item_type, score, sprite, size) = match tile { let (item_type, sprite, size) = match tile {
crate::constants::MapTile::Pellet => (EntityType::Pellet, 10, pellet_sprite, constants::CELL_SIZE as f32 * 0.4), crate::constants::MapTile::Pellet => (EntityType::Pellet, pellet_sprite, constants::CELL_SIZE as f32 * 0.4),
crate::constants::MapTile::PowerPellet => ( crate::constants::MapTile::PowerPellet => {
EntityType::PowerPellet, (EntityType::PowerPellet, energizer_sprite, constants::CELL_SIZE as f32 * 0.95)
50, }
energizer_sprite,
constants::CELL_SIZE as f32 * 0.95,
),
_ => continue, _ => continue,
}; };
let mut item = world.spawn(ItemBundle { let mut item = world.spawn(ItemBundle {
position: Position { position: Position::Stopped { node: node_id },
node: node_id,
edge_progress: None,
},
sprite: Renderable { sprite: Renderable {
sprite, sprite,
layer: 1, layer: 1,
visible: true, visible: true,
}, },
entity_type: item_type, entity_type: item_type,
score: Score(score), collider: Collider { size },
collider: Collider {
size,
layer: CollisionLayer::ITEM,
},
item_collider: ItemCollider, item_collider: ItemCollider,
}); });
@@ -289,77 +291,110 @@ impl Game {
Ok(Game { world, schedule }) Ok(Game { world, schedule })
} }
// fn handle_command(&mut self, command: crate::input::commands::GameCommand) { /// Spowns all four ghosts at their starting positions with appropriate textures.
// use crate::input::commands::GameCommand; fn spawn_ghosts(world: &mut World) -> GameResult<()> {
// match command { // Extract the data we need first to avoid borrow conflicts
// GameCommand::MovePlayer(direction) => { let ghost_start_positions = {
// self.state.pacman.set_next_direction(direction); let map = world.resource::<Map>();
// } [
// GameCommand::ToggleDebug => { (Ghost::Blinky, map.start_positions.blinky),
// self.toggle_debug_mode(); (Ghost::Pinky, map.start_positions.pinky),
// } (Ghost::Inky, map.start_positions.inky),
// GameCommand::MuteAudio => { (Ghost::Clyde, map.start_positions.clyde),
// let is_muted = self.state.audio.is_muted(); ]
// self.state.audio.set_mute(!is_muted); };
// }
// GameCommand::ResetLevel => {
// if let Err(e) = self.reset_game_state() {
// tracing::error!("Failed to reset game state: {}", e);
// }
// }
// GameCommand::TogglePause => {
// self.state.paused = !self.state.paused;
// }
// GameCommand::Exit => {}
// }
// }
// fn process_events(&mut self) { for (ghost_type, start_node) in ghost_start_positions {
// while let Some(event) = self.state.event_queue.pop_front() { // Create the ghost bundle in a separate scope to manage borrows
// match event { let ghost = {
// GameEvent::Command(command) => self.handle_command(command), let atlas = world.non_send_resource::<SpriteAtlas>();
// }
// }
// /// Resets the game state, randomizing ghost positions and resetting Pac-Man // Create directional animated textures for the ghost
// fn reset_game_state(&mut self) -> GameResult<()> { let mut textures = [None, None, None, None];
// let pacman_start_node = self.state.map.start_positions.pacman; let mut stopped_textures = [None, None, None, None];
// self.state.pacman = Pacman::new(&self.state.map.graph, pacman_start_node, &self.state.atlas)?;
// // Reset items for direction in Direction::DIRECTIONS {
// self.state.items = self.state.map.generate_items(&self.state.atlas)?; let moving_prefix = match direction {
Direction::Up => "up",
Direction::Down => "down",
Direction::Left => "left",
Direction::Right => "right",
};
// // Randomize ghost positions let moving_tiles = vec![
// let ghost_types = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde]; SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a"))
// let mut rng = SmallRng::from_os_rng(); .ok_or_else(|| {
GameError::Texture(TextureError::AtlasTileNotFound(format!(
"ghost/{}/{}_{}.png",
ghost_type.as_str(),
moving_prefix,
"a"
)))
})?,
SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "b"))
.ok_or_else(|| {
GameError::Texture(TextureError::AtlasTileNotFound(format!(
"ghost/{}/{}_{}.png",
ghost_type.as_str(),
moving_prefix,
"b"
)))
})?,
];
// for (i, ghost) in self.state.ghosts.iter_mut().enumerate() { let stopped_tiles = vec![SpriteAtlas::get_tile(
// let random_node = rng.random_range(0..self.state.map.graph.node_count()); atlas,
// *ghost = Ghost::new(&self.state.map.graph, random_node, ghost_types[i], &self.state.atlas)?; &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a"),
// } )
.ok_or_else(|| {
GameError::Texture(TextureError::AtlasTileNotFound(format!(
"ghost/{}/{}_{}.png",
ghost_type.as_str(),
moving_prefix,
"a"
)))
})?];
// // Reset collision system textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.2)?);
// self.state.collision_system = CollisionSystem::default(); stopped_textures[direction.as_usize()] = Some(AnimatedTexture::new(stopped_tiles, 0.1)?);
}
// // Re-register Pac-Man GhostBundle {
// self.state.pacman_id = self.state.collision_system.register_entity(self.state.pacman.position()); ghost: ghost_type,
position: Position::Stopped { node: start_node },
velocity: Velocity {
speed: ghost_type.base_speed(),
direction: Direction::Left,
},
sprite: Renderable {
sprite: SpriteAtlas::get_tile(atlas, &format!("ghost/{}/left_a.png", ghost_type.as_str())).ok_or_else(
|| {
GameError::Texture(TextureError::AtlasTileNotFound(format!(
"ghost/{}/left_a.png",
ghost_type.as_str()
)))
},
)?,
layer: 0,
visible: true,
},
directional_animated: DirectionalAnimated {
textures,
stopped_textures,
},
entity_type: EntityType::Ghost,
collider: Collider {
size: crate::constants::CELL_SIZE as f32 * 1.375,
},
ghost_collider: GhostCollider,
}
};
// // Re-register items world.spawn(ghost);
// self.state.item_ids.clear(); }
// for item in &self.state.items {
// let item_id = self.state.collision_system.register_entity(item.position());
// self.state.item_ids.push(item_id);
// }
// // Re-register ghosts Ok(())
// self.state.ghost_ids.clear(); }
// for ghost in &self.state.ghosts {
// let ghost_id = self.state.collision_system.register_entity(ghost.position());
// self.state.ghost_ids.push(ghost_id);
// }
// Ok(())
// }
/// Ticks the game state. /// Ticks the game state.
/// ///
@@ -375,52 +410,9 @@ impl Game {
.get_resource::<GlobalState>() .get_resource::<GlobalState>()
.expect("GlobalState could not be acquired"); .expect("GlobalState could not be acquired");
return state.exit; state.exit
// // Process any events that have been posted (such as unpausing)
// self.process_events();
// // If the game is paused, we don't need to do anything beyond returning
// if self.state.paused {
// return false;
// }
// self.schedule.run(&mut self.world);
// self.state.pacman.tick(dt, &self.state.map.graph);
// // Update all ghosts
// for ghost in &mut self.state.ghosts {
// ghost.tick(dt, &self.state.map.graph);
// }
// // Update collision system positions
// self.update_collision_positions();
// // Check for collisions
// self.check_collisions();
} }
// /// Toggles the debug mode on and off.
// ///
// /// When debug mode is enabled, the game will render additional information
// /// that is useful for debugging, such as the collision grid and entity paths.
// pub fn toggle_debug_mode(&mut self) {
// self.state.debug_mode = !self.state.debug_mode;
// }
// fn update_collision_positions(&mut self) {
// // Update Pac-Man's position
// self.state
// .collision_system
// .update_position(self.state.pacman_id, self.state.pacman.position());
// // Update ghost positions
// for (ghost, &ghost_id) in self.state.ghosts.iter().zip(&self.state.ghost_ids) {
// self.state.collision_system.update_position(ghost_id, ghost.position());
// }
// }
// fn check_collisions(&mut self) { // fn check_collisions(&mut self) {
// // Check Pac-Man vs Items // // Check Pac-Man vs Items
// let potential_collisions = self // let potential_collisions = self

View File

@@ -3,12 +3,9 @@ use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE};
use crate::entity::direction::Direction; use crate::entity::direction::Direction;
use crate::entity::graph::{Graph, Node, TraversalFlags}; use crate::entity::graph::{Graph, Node, TraversalFlags};
use crate::map::parser::MapTileParser; use crate::map::parser::MapTileParser;
use crate::map::render::MapRenderer;
use crate::systems::movement::NodeId; use crate::systems::movement::NodeId;
use crate::texture::sprite::SpriteAtlas;
use bevy_ecs::resource::Resource; use bevy_ecs::resource::Resource;
use glam::{IVec2, Vec2}; use glam::{IVec2, Vec2};
use sdl2::render::{Canvas, RenderTarget};
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, VecDeque};
use tracing::debug; use tracing::debug;
@@ -165,20 +162,6 @@ impl Map {
}) })
} }
/// Renders a debug visualization with cursor-based highlighting.
///
/// 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,
) -> GameResult<()> {
MapRenderer::debug_render_with_cursor(&self.graph, canvas, text_renderer, atlas, cursor_pos)
}
/// Builds the house structure in the graph. /// Builds the house structure in the graph.
fn build_house( fn build_house(
graph: &mut Graph, graph: &mut Graph,

View File

@@ -3,14 +3,10 @@
use crate::constants::{BOARD_CELL_OFFSET, CELL_SIZE}; use crate::constants::{BOARD_CELL_OFFSET, CELL_SIZE};
use crate::map::layout::TILE_MAP; use crate::map::layout::TILE_MAP;
use crate::texture::sprite::{AtlasTile, SpriteAtlas}; use crate::texture::sprite::{AtlasTile, SpriteAtlas};
use crate::texture::text::TextTexture;
use glam::Vec2;
use sdl2::pixels::Color; use sdl2::pixels::Color;
use sdl2::rect::{Point, Rect}; use sdl2::rect::Rect;
use sdl2::render::{Canvas, RenderTarget}; use sdl2::render::{Canvas, RenderTarget};
use crate::error::{EntityError, GameError, GameResult};
/// Handles rendering operations for the map. /// Handles rendering operations for the map.
pub struct MapRenderer; pub struct MapRenderer;
@@ -37,111 +33,4 @@ impl MapRenderer {
} }
} }
} }
/// Renders a debug visualization with cursor-based highlighting.
///
/// 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,
) -> GameResult<()> {
// 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).ok_or(GameError::Entity(EntityError::NodeNotFound(i)))?;
let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
for edge in graph.adjacency_list[i].edges() {
let end_pos = graph
.get_node(edge.target)
.ok_or(GameError::Entity(EntityError::NodeNotFound(edge.target)))?
.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))
.map_err(|e| GameError::Sdl(e.to_string()))?;
}
}
// 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).ok_or(GameError::Entity(EntityError::NodeNotFound(i)))?;
let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
canvas
.fill_rect(Rect::new(0, 0, 3, 3).centered_on(Point::new(pos.x as i32, pos.y as i32)))
.map_err(|e| GameError::Sdl(e.to_string()))?;
}
// Highlight connections from the nearest node in bright blue
if let Some(nearest_id) = nearest_node {
let nearest_pos = graph
.get_node(nearest_id)
.ok_or(GameError::Entity(EntityError::NodeNotFound(nearest_id)))?
.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)
.ok_or(GameError::Entity(EntityError::NodeNotFound(edge.target)))?
.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),
)
.map_err(|e| GameError::Sdl(e.to_string()))?;
}
// 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)))
.map_err(|e| GameError::Sdl(e.to_string()))?;
// 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
);
if let Err(e) = text_renderer.render(canvas, atlas, &id_text, text_pos) {
tracing::error!("Failed to render node ID text: {}", e);
}
}
Ok(())
}
/// 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() {
if let Some(node) = graph.get_node(i) {
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
}
} }

View File

@@ -5,12 +5,12 @@ use std::time::Duration;
use crate::asset::Asset; use crate::asset::Asset;
use crate::error::{AssetError, PlatformError}; use crate::error::{AssetError, PlatformError};
use crate::platform::Platform; use crate::platform::CommonPlatform;
/// Desktop platform implementation. /// Desktop platform implementation.
pub struct DesktopPlatform; pub struct Platform;
impl Platform for DesktopPlatform { impl CommonPlatform for Platform {
fn sleep(&self, duration: Duration, focused: bool) { fn sleep(&self, duration: Duration, focused: bool) {
if focused { if focused {
spin_sleep::sleep(duration); spin_sleep::sleep(duration);
@@ -75,7 +75,8 @@ impl Platform for DesktopPlatform {
Asset::Wav2 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/2.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::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::Wav4 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/4.ogg"))),
Asset::Atlas => Ok(Cow::Borrowed(include_bytes!("../../assets/game/atlas.png"))), Asset::AtlasImage => Ok(Cow::Borrowed(include_bytes!("../../assets/game/atlas.png"))),
Asset::Font => Ok(Cow::Borrowed(include_bytes!("../../assets/game/TerminalVector.ttf"))),
} }
} }
} }

View File

@@ -5,12 +5,12 @@ use std::time::Duration;
use crate::asset::Asset; use crate::asset::Asset;
use crate::error::{AssetError, PlatformError}; use crate::error::{AssetError, PlatformError};
use crate::platform::Platform; use crate::platform::CommonPlatform;
/// Emscripten platform implementation. /// Emscripten platform implementation.
pub struct EmscriptenPlatform; pub struct Platform;
impl Platform for EmscriptenPlatform { impl CommonPlatform for Platform {
fn sleep(&self, duration: Duration, _focused: bool) { fn sleep(&self, duration: Duration, _focused: bool) {
unsafe { unsafe {
emscripten_sleep(duration.as_millis() as u32); emscripten_sleep(duration.as_millis() as u32);

View File

@@ -5,11 +5,13 @@ use crate::error::{AssetError, PlatformError};
use std::borrow::Cow; use std::borrow::Cow;
use std::time::Duration; use std::time::Duration;
pub mod desktop; #[cfg(not(target_os = "emscripten"))]
pub mod emscripten; mod desktop;
#[cfg(target_os = "emscripten")]
mod emscripten;
/// Platform abstraction trait that defines cross-platform functionality. /// Platform abstraction trait that defines cross-platform functionality.
pub trait Platform { pub trait CommonPlatform {
/// Sleep for the specified duration using platform-appropriate method. /// Sleep for the specified duration using platform-appropriate method.
fn sleep(&self, duration: Duration, focused: bool); fn sleep(&self, duration: Duration, focused: bool);
@@ -32,17 +34,14 @@ pub trait Platform {
/// Get the current platform implementation. /// Get the current platform implementation.
#[allow(dead_code)] #[allow(dead_code)]
pub fn get_platform() -> &'static dyn Platform { pub fn get_platform() -> &'static dyn CommonPlatform {
static DESKTOP: desktop::DesktopPlatform = desktop::DesktopPlatform;
static EMSCRIPTEN: emscripten::EmscriptenPlatform = emscripten::EmscriptenPlatform;
#[cfg(not(target_os = "emscripten"))] #[cfg(not(target_os = "emscripten"))]
{ {
&DESKTOP &desktop::Platform
} }
#[cfg(target_os = "emscripten")] #[cfg(target_os = "emscripten")]
{ {
&EMSCRIPTEN &emscripten::Platform
} }
} }

54
src/systems/audio.rs Normal file
View File

@@ -0,0 +1,54 @@
//! Audio system for handling sound playback in the Pac-Man game.
//!
//! This module provides an ECS-based audio system that integrates with SDL2_mixer
//! for playing sound effects. The system uses NonSendMut resources to handle SDL2's
//! main-thread requirements while maintaining Bevy ECS compatibility.
use bevy_ecs::{
event::{Event, EventReader, EventWriter},
system::{NonSendMut, ResMut},
};
use crate::{audio::Audio, error::GameError, systems::components::AudioState};
/// Events for triggering audio playback
#[derive(Event, Debug, Clone, Copy, PartialEq, Eq)]
pub enum AudioEvent {
/// Play the "eat" sound when Pac-Man consumes a pellet
PlayEat,
}
/// Non-send resource wrapper for SDL2 audio system
///
/// This wrapper is needed because SDL2 audio components are not Send,
/// but Bevy ECS requires Send for regular resources. Using NonSendMut
/// allows us to use SDL2 audio on the main thread while integrating
/// with the ECS system.
pub struct AudioResource(pub Audio);
/// System that processes audio events and plays sounds
pub fn audio_system(
mut audio: NonSendMut<AudioResource>,
mut audio_state: ResMut<AudioState>,
mut audio_events: EventReader<AudioEvent>,
_errors: EventWriter<GameError>,
) {
// Set mute state if it has changed
if audio.0.is_muted() != audio_state.muted {
audio.0.set_mute(audio_state.muted);
}
// Process audio events
for event in audio_events.read() {
match event {
AudioEvent::PlayEat => {
if !audio.0.is_disabled() && !audio_state.muted {
audio.0.eat();
// Update the sound index for cycling through sounds
audio_state.sound_index = (audio_state.sound_index + 1) % 4;
// 4 eat sounds available
}
}
}
}
}

View File

@@ -19,7 +19,10 @@ pub fn collision_system(
// Check PACMAN × ITEM collisions // Check PACMAN × ITEM collisions
for (pacman_entity, pacman_pos, pacman_collider) in pacman_query.iter() { for (pacman_entity, pacman_pos, pacman_collider) in pacman_query.iter() {
for (item_entity, item_pos, item_collider) in item_query.iter() { for (item_entity, item_pos, item_collider) in item_query.iter() {
match (pacman_pos.get_pixel_pos(&map.graph), item_pos.get_pixel_pos(&map.graph)) { match (
pacman_pos.get_pixel_position(&map.graph),
item_pos.get_pixel_position(&map.graph),
) {
(Ok(pacman_pixel), Ok(item_pixel)) => { (Ok(pacman_pixel), Ok(item_pixel)) => {
// Calculate the distance between the two entities's precise pixel positions // Calculate the distance between the two entities's precise pixel positions
let distance = pacman_pixel.distance(item_pixel); let distance = pacman_pixel.distance(item_pixel);

View File

@@ -3,7 +3,7 @@ use bitflags::bitflags;
use crate::{ use crate::{
entity::graph::TraversalFlags, entity::graph::TraversalFlags,
systems::movement::{Movable, MovementState, Position}, systems::movement::{BufferedDirection, Position, Velocity},
texture::{animated::AnimatedTexture, sprite::AtlasTile}, texture::{animated::AnimatedTexture, sprite::AtlasTile},
}; };
@@ -11,6 +11,46 @@ use crate::{
#[derive(Default, Component)] #[derive(Default, Component)]
pub struct PlayerControlled; pub struct PlayerControlled;
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ghost {
Blinky,
Pinky,
Inky,
Clyde,
}
impl Ghost {
/// Returns the ghost type name for atlas lookups.
pub fn as_str(self) -> &'static str {
match self {
Ghost::Blinky => "blinky",
Ghost::Pinky => "pinky",
Ghost::Inky => "inky",
Ghost::Clyde => "clyde",
}
}
/// Returns the base movement speed for this ghost type.
pub fn base_speed(self) -> f32 {
match self {
Ghost::Blinky => 1.0,
Ghost::Pinky => 0.95,
Ghost::Inky => 0.9,
Ghost::Clyde => 0.85,
}
}
/// Returns the ghost's color for debug rendering.
pub fn debug_color(&self) -> sdl2::pixels::Color {
match self {
Ghost::Blinky => sdl2::pixels::Color::RGB(255, 0, 0), // Red
Ghost::Pinky => sdl2::pixels::Color::RGB(255, 182, 255), // Pink
Ghost::Inky => sdl2::pixels::Color::RGB(0, 255, 255), // Cyan
Ghost::Clyde => sdl2::pixels::Color::RGB(255, 182, 85), // Orange
}
}
}
/// A tag component denoting the type of entity. /// A tag component denoting the type of entity.
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EntityType { pub enum EntityType {
@@ -60,7 +100,6 @@ bitflags! {
#[derive(Component)] #[derive(Component)]
pub struct Collider { pub struct Collider {
pub size: f32, pub size: f32,
pub layer: CollisionLayer,
} }
/// Marker components for collision filtering optimization /// Marker components for collision filtering optimization
@@ -73,15 +112,12 @@ pub struct GhostCollider;
#[derive(Component)] #[derive(Component)]
pub struct ItemCollider; pub struct ItemCollider;
#[derive(Component)]
pub struct Score(pub u32);
#[derive(Bundle)] #[derive(Bundle)]
pub struct PlayerBundle { pub struct PlayerBundle {
pub player: PlayerControlled, pub player: PlayerControlled,
pub position: Position, pub position: Position,
pub movement_state: MovementState, pub velocity: Velocity,
pub movable: Movable, pub buffered_direction: BufferedDirection,
pub sprite: Renderable, pub sprite: Renderable,
pub directional_animated: DirectionalAnimated, pub directional_animated: DirectionalAnimated,
pub entity_type: EntityType, pub entity_type: EntityType,
@@ -94,11 +130,22 @@ pub struct ItemBundle {
pub position: Position, pub position: Position,
pub sprite: Renderable, pub sprite: Renderable,
pub entity_type: EntityType, pub entity_type: EntityType,
pub score: Score,
pub collider: Collider, pub collider: Collider,
pub item_collider: ItemCollider, pub item_collider: ItemCollider,
} }
#[derive(Bundle)]
pub struct GhostBundle {
pub ghost: Ghost,
pub position: Position,
pub velocity: Velocity,
pub sprite: Renderable,
pub directional_animated: DirectionalAnimated,
pub entity_type: EntityType,
pub collider: Collider,
pub ghost_collider: GhostCollider,
}
#[derive(Resource)] #[derive(Resource)]
pub struct GlobalState { pub struct GlobalState {
pub exit: bool, pub exit: bool,
@@ -112,3 +159,12 @@ pub struct DeltaTime(pub f32);
#[derive(Resource, Default)] #[derive(Resource, Default)]
pub struct RenderDirty(pub bool); pub struct RenderDirty(pub bool);
/// Resource for tracking audio state
#[derive(Resource, Debug, Clone, Default)]
pub struct AudioState {
/// Whether audio is currently muted
pub muted: bool,
/// Current sound index for cycling through eat sounds
pub sound_index: usize,
}

View File

@@ -1,50 +0,0 @@
use bevy_ecs::{
event::{EventReader, EventWriter},
prelude::ResMut,
query::With,
system::Query,
};
use crate::{
error::GameError,
events::{GameCommand, GameEvent},
systems::components::{GlobalState, PlayerControlled},
systems::debug::DebugState,
systems::movement::Movable,
};
// Handles player input and control
pub fn player_system(
mut events: EventReader<GameEvent>,
mut state: ResMut<GlobalState>,
mut debug_state: ResMut<DebugState>,
mut players: Query<&mut Movable, With<PlayerControlled>>,
mut errors: EventWriter<GameError>,
) {
// Get the player's movable component (ensuring there is only one player)
let mut movable = match players.single_mut() {
Ok(movable) => movable,
Err(e) => {
errors.write(GameError::InvalidState(format!("No/multiple entities queried for player system: {}", e)).into());
return;
}
};
// Handle events
for event in events.read() {
if let GameEvent::Command(command) = event {
match command {
GameCommand::MovePlayer(direction) => {
movable.requested_direction = Some(*direction);
}
GameCommand::Exit => {
state.exit = true;
}
GameCommand::ToggleDebug => {
*debug_state = debug_state.next();
}
_ => {}
}
}
}
}

View File

@@ -1,14 +1,19 @@
//! Debug rendering system //! Debug rendering system
use std::cmp::Ordering;
use crate::constants::BOARD_PIXEL_OFFSET; use crate::constants::BOARD_PIXEL_OFFSET;
use crate::map::builder::Map; use crate::map::builder::Map;
use crate::systems::components::Collider; use crate::systems::components::Collider;
use crate::systems::input::CursorPosition;
use crate::systems::movement::Position; use crate::systems::movement::Position;
use crate::systems::profiling::SystemTimings; use crate::systems::profiling::SystemTimings;
use crate::systems::render::BackbufferResource; use crate::systems::render::BackbufferResource;
use bevy_ecs::prelude::*; use bevy_ecs::prelude::*;
use glam::{IVec2, UVec2, Vec2};
use sdl2::pixels::Color; use sdl2::pixels::Color;
use sdl2::rect::Rect; use sdl2::rect::{Point, Rect};
use sdl2::render::{Canvas, Texture, TextureCreator}; use sdl2::render::{Canvas, Texture, TextureCreator};
use sdl2::ttf::Font;
use sdl2::video::{Window, WindowContext}; use sdl2::video::{Window, WindowContext};
#[derive(Resource, Default, Debug, Copy, Clone, PartialEq)] #[derive(Resource, Default, Debug, Copy, Clone, PartialEq)]
@@ -32,35 +37,12 @@ impl DebugState {
/// Resource to hold the debug texture for persistent rendering /// Resource to hold the debug texture for persistent rendering
pub struct DebugTextureResource(pub Texture<'static>); pub struct DebugTextureResource(pub Texture<'static>);
/// Transforms a position from logical canvas coordinates to output canvas coordinates /// Resource to hold the debug font
fn transform_position(pos: (f32, f32), output_size: (u32, u32), logical_size: (u32, u32)) -> (i32, i32) { pub struct DebugFontResource(pub Font<'static, 'static>);
let scale_x = output_size.0 as f32 / logical_size.0 as f32;
let scale_y = output_size.1 as f32 / logical_size.1 as f32;
let scale = scale_x.min(scale_y); // Use the smaller scale to maintain aspect ratio
let x = (pos.0 * scale) as i32;
let y = (pos.1 * scale) as i32;
(x, y)
}
/// Transforms a position from logical canvas coordinates to output canvas coordinates (with board offset) /// Transforms a position from logical canvas coordinates to output canvas coordinates (with board offset)
fn transform_position_with_offset(pos: (f32, f32), output_size: (u32, u32), logical_size: (u32, u32)) -> (i32, i32) { fn transform_position_with_offset(pos: Vec2, scale: f32) -> IVec2 {
let scale_x = output_size.0 as f32 / logical_size.0 as f32; ((pos + BOARD_PIXEL_OFFSET.as_vec2()) * scale).as_ivec2()
let scale_y = output_size.1 as f32 / logical_size.1 as f32;
let scale = scale_x.min(scale_y); // Use the smaller scale to maintain aspect ratio
let x = ((pos.0 + BOARD_PIXEL_OFFSET.x as f32) * scale) as i32;
let y = ((pos.1 + BOARD_PIXEL_OFFSET.y as f32) * scale) as i32;
(x, y)
}
/// Transforms a size from logical canvas coordinates to output canvas coordinates
fn transform_size(size: f32, output_size: (u32, u32), logical_size: (u32, u32)) -> u32 {
let scale_x = output_size.0 as f32 / logical_size.0 as f32;
let scale_y = output_size.1 as f32 / logical_size.1 as f32;
let scale = scale_x.min(scale_y); // Use the smaller scale to maintain aspect ratio
(size * scale) as u32
} }
/// Renders timing information in the top-left corner of the screen /// Renders timing information in the top-left corner of the screen
@@ -68,28 +50,17 @@ fn render_timing_display(
canvas: &mut Canvas<Window>, canvas: &mut Canvas<Window>,
texture_creator: &mut TextureCreator<WindowContext>, texture_creator: &mut TextureCreator<WindowContext>,
timings: &SystemTimings, timings: &SystemTimings,
font: &Font,
) { ) {
// Get TTF context
let ttf_context = sdl2::ttf::init().unwrap();
// Load font
let font = ttf_context.load_font("assets/site/TerminalVector.ttf", 12).unwrap();
// Format timing information using the formatting module // Format timing information using the formatting module
let timing_text = timings.format_timing_display(); let lines = timings.format_timing_display();
// Split text by newlines and render each line separately
let lines: Vec<&str> = timing_text.lines().collect();
if lines.is_empty() {
return;
}
let line_height = 14; // Approximate line height for 12pt font let line_height = 14; // Approximate line height for 12pt font
let padding = 10; let padding = 10;
// Calculate background dimensions // Calculate background dimensions
let max_width = lines let max_width = lines
.iter() .iter()
.filter(|&&l| !l.is_empty()) // Don't consider empty lines for width .filter(|l| !l.is_empty()) // Don't consider empty lines for width
.map(|line| font.size_of(line).unwrap().0) .map(|line| font.size_of(line).unwrap().0)
.max() .max()
.unwrap_or(0); .unwrap_or(0);
@@ -127,22 +98,23 @@ fn render_timing_display(
} }
} }
#[allow(clippy::too_many_arguments)]
pub fn debug_render_system( pub fn debug_render_system(
mut canvas: NonSendMut<&mut Canvas<Window>>, mut canvas: NonSendMut<&mut Canvas<Window>>,
backbuffer: NonSendMut<BackbufferResource>, backbuffer: NonSendMut<BackbufferResource>,
mut debug_texture: NonSendMut<DebugTextureResource>, mut debug_texture: NonSendMut<DebugTextureResource>,
debug_font: NonSendMut<DebugFontResource>,
debug_state: Res<DebugState>, debug_state: Res<DebugState>,
timings: Res<SystemTimings>, timings: Res<SystemTimings>,
map: Res<Map>, map: Res<Map>,
colliders: Query<(&Collider, &Position)>, colliders: Query<(&Collider, &Position)>,
cursor: Res<CursorPosition>,
) { ) {
if *debug_state == DebugState::Off { if *debug_state == DebugState::Off {
return; return;
} }
let scale =
// Get canvas sizes for coordinate transformation (UVec2::from(canvas.output_size().unwrap()).as_vec2() / UVec2::from(canvas.logical_size()).as_vec2()).min_element();
let output_size = canvas.output_size().unwrap();
let logical_size = canvas.logical_size();
// Copy the current backbuffer to the debug texture // Copy the current backbuffer to the debug texture
canvas canvas
@@ -158,49 +130,85 @@ pub fn debug_render_system(
// Get texture creator before entering the closure to avoid borrowing conflicts // Get texture creator before entering the closure to avoid borrowing conflicts
let mut texture_creator = canvas.texture_creator(); let mut texture_creator = canvas.texture_creator();
let font = &debug_font.0;
let cursor_world_pos = match *cursor {
CursorPosition::None => None,
CursorPosition::Some { position, .. } => Some(position - BOARD_PIXEL_OFFSET.as_vec2()),
};
// Draw debug info on the high-resolution debug texture // Draw debug info on the high-resolution debug texture
canvas canvas
.with_texture_canvas(&mut debug_texture.0, |debug_canvas| { .with_texture_canvas(&mut debug_texture.0, |debug_canvas| {
match *debug_state { match *debug_state {
DebugState::Graph => { DebugState::Graph => {
// Find the closest node to the cursor
let closest_node = if let Some(cursor_world_pos) = cursor_world_pos {
map.graph
.nodes()
.map(|node| node.position.distance(cursor_world_pos))
.enumerate()
.min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(Ordering::Less))
.map(|(id, _)| id)
} else {
None
};
debug_canvas.set_draw_color(Color::RED); debug_canvas.set_draw_color(Color::RED);
for (start_node, end_node) in map.graph.edges() { for (start_node, end_node) in map.graph.edges() {
let start_node = map.graph.get_node(start_node).unwrap().position; let start_node_model = map.graph.get_node(start_node).unwrap();
let end_node = map.graph.get_node(end_node.target).unwrap().position; let end_node = map.graph.get_node(end_node.target).unwrap().position;
// Transform positions using common method // Transform positions using common method
let (start_x, start_y) = let start = transform_position_with_offset(start_node_model.position, scale);
transform_position_with_offset((start_node.x, start_node.y), output_size, logical_size); let end = transform_position_with_offset(end_node, scale);
let (end_x, end_y) = transform_position_with_offset((end_node.x, end_node.y), output_size, logical_size);
debug_canvas.draw_line((start_x, start_y), (end_x, end_y)).unwrap();
}
debug_canvas.set_draw_color(Color::BLUE);
for node in map.graph.nodes() {
let pos = node.position;
// Transform position using common method
let (x, y) = transform_position_with_offset((pos.x, pos.y), output_size, logical_size);
let size = transform_size(4.0, output_size, logical_size);
debug_canvas debug_canvas
.fill_rect(Rect::new(x - (size as i32 / 2), y - (size as i32 / 2), size, size)) .draw_line(Point::from((start.x, start.y)), Point::from((end.x, end.y)))
.unwrap(); .unwrap();
} }
for (id, node) in map.graph.nodes().enumerate() {
let pos = node.position;
// Set color based on whether the node is the closest to the cursor
debug_canvas.set_draw_color(if Some(id) == closest_node {
Color::YELLOW
} else {
Color::BLUE
});
// Transform position using common method
let pos = transform_position_with_offset(pos, scale);
let size = (3.0 * scale) as u32;
debug_canvas
.fill_rect(Rect::new(pos.x - (size as i32 / 2), pos.y - (size as i32 / 2), size, size))
.unwrap();
}
// Render node ID if a node is highlighted
if let Some(closest_node_id) = closest_node {
let node = map.graph.get_node(closest_node_id).unwrap();
let pos = transform_position_with_offset(node.position, scale);
let surface = font.render(&closest_node_id.to_string()).blended(Color::WHITE).unwrap();
let texture = texture_creator.create_texture_from_surface(&surface).unwrap();
let dest = Rect::new(pos.x + 10, pos.y - 5, texture.query().width, texture.query().height);
debug_canvas.copy(&texture, None, dest).unwrap();
}
} }
DebugState::Collision => { DebugState::Collision => {
debug_canvas.set_draw_color(Color::GREEN); debug_canvas.set_draw_color(Color::GREEN);
for (collider, position) in colliders.iter() { for (collider, position) in colliders.iter() {
let pos = position.get_pixel_pos(&map.graph).unwrap(); let pos = position.get_pixel_position(&map.graph).unwrap();
// Transform position and size using common methods // Transform position and size using common methods
let (x, y) = transform_position((pos.x, pos.y), output_size, logical_size); let pos = (pos * scale).as_ivec2();
let size = transform_size(collider.size, output_size, logical_size); let size = (collider.size * scale) as u32;
// Center the collision box on the entity let rect = Rect::from_center(Point::from((pos.x, pos.y)), size, size);
let rect = Rect::new(x - (size as i32 / 2), y - (size as i32 / 2), size, size);
debug_canvas.draw_rect(rect).unwrap(); debug_canvas.draw_rect(rect).unwrap();
} }
} }
@@ -208,7 +216,7 @@ pub fn debug_render_system(
} }
// Render timing information in the top-left corner // Render timing information in the top-left corner
render_timing_display(debug_canvas, &mut texture_creator, &timings); render_timing_display(debug_canvas, &mut texture_creator, &timings, font);
}) })
.unwrap(); .unwrap();

View File

@@ -1,41 +1,48 @@
use num_width::NumberWidth; use num_width::NumberWidth;
use smallvec::SmallVec;
use std::time::Duration; use std::time::Duration;
use strum::EnumCount;
use crate::systems::profiling::SystemId;
// Helper to split a duration into a integer, decimal, and unit
fn get_value(duration: &Duration) -> (u64, u32, &'static str) {
let (int, decimal, unit) = match duration {
// if greater than 1 second, return as seconds
n if n >= &Duration::from_secs(1) => {
let secs = n.as_secs();
let decimal = n.as_millis() as u64 % 1000;
(secs, decimal as u32, "s")
}
// if greater than 1 millisecond, return as milliseconds
n if n >= &Duration::from_millis(1) => {
let ms = n.as_millis() as u64;
let decimal = n.as_micros() as u64 % 1000;
(ms, decimal as u32, "ms")
}
// if greater than 1 microsecond, return as microseconds
n if n >= &Duration::from_micros(1) => {
let us = n.as_micros() as u64;
let decimal = n.as_nanos() as u64 % 1000;
(us, decimal as u32, "µs")
}
// otherwise, return as nanoseconds
n => {
let ns = n.as_nanos() as u64;
(ns, 0, "ns")
}
};
(int, decimal, unit)
}
/// Formats timing data into a vector of strings with proper alignment /// Formats timing data into a vector of strings with proper alignment
pub fn format_timing_display(timing_data: Vec<(String, Duration, Duration)>) -> String { pub fn format_timing_display(
if timing_data.is_empty() { timing_data: impl IntoIterator<Item = (String, Duration, Duration)>,
return String::new(); ) -> SmallVec<[String; SystemId::COUNT]> {
} let mut iter = timing_data.into_iter().peekable();
if iter.peek().is_none() {
// Helper to split a duration into a integer, decimal, and unit return SmallVec::new();
fn get_value(duration: &Duration) -> (u64, u32, &'static str) {
let (int, decimal, unit) = match duration {
// if greater than 1 second, return as seconds
n if n >= &Duration::from_secs(1) => {
let secs = n.as_secs();
let decimal = n.as_millis() as u64 % 1000;
(secs, decimal as u32, "s")
}
// if greater than 1 millisecond, return as milliseconds
n if n >= &Duration::from_millis(1) => {
let ms = n.as_millis() as u64;
let decimal = n.as_micros() as u64 % 1000;
(ms, decimal as u32, "ms")
}
// if greater than 1 microsecond, return as microseconds
n if n >= &Duration::from_micros(1) => {
let us = n.as_micros() as u64;
let decimal = n.as_nanos() as u64 % 1000;
(us, decimal as u32, "µs")
}
// otherwise, return as nanoseconds
n => {
let ns = n.as_nanos() as u64;
(ns, 0, "ns")
}
};
(int, decimal, unit)
} }
struct Entry { struct Entry {
@@ -48,8 +55,7 @@ pub fn format_timing_display(timing_data: Vec<(String, Duration, Duration)>) ->
std_unit: &'static str, std_unit: &'static str,
} }
let entries = timing_data let entries = iter
.iter()
.map(|(name, avg, std_dev)| { .map(|(name, avg, std_dev)| {
let (avg_int, avg_decimal, avg_unit) = get_value(&avg); let (avg_int, avg_decimal, avg_unit) = get_value(&avg);
let (std_int, std_decimal, std_unit) = get_value(&std_dev); let (std_int, std_decimal, std_unit) = get_value(&std_dev);
@@ -64,84 +70,38 @@ pub fn format_timing_display(timing_data: Vec<(String, Duration, Duration)>) ->
std_unit, std_unit,
} }
}) })
.collect::<Vec<_>>(); .collect::<SmallVec<[Entry; 12]>>();
let max_name_width = entries.iter().map(|e| e.name.len() as usize).max().unwrap_or(0); let (max_name_width, max_avg_int_width, max_avg_decimal_width, max_std_int_width, max_std_decimal_width) = entries
let max_avg_int_width = entries.iter().map(|e| e.avg_int.width() as usize).max().unwrap_or(0);
let max_avg_decimal_width = entries
.iter() .iter()
.map(|e| e.avg_decimal.width() as usize) .fold((0, 0, 3, 0, 3), |(name_w, avg_int_w, avg_dec_w, std_int_w, std_dec_w), e| {
.max() (
.unwrap_or(0) name_w.max(e.name.len()),
.max(3); avg_int_w.max(e.avg_int.width() as usize),
let max_std_int_width = entries.iter().map(|e| e.std_int.width() as usize).max().unwrap_or(0); avg_dec_w.max(e.avg_decimal.width() as usize),
let max_std_decimal_width = entries std_int_w.max(e.std_int.width() as usize),
.iter() std_dec_w.max(e.std_decimal.width() as usize),
.map(|e| e.std_decimal.width() as usize) )
.max() });
.unwrap_or(0)
.max(3);
let mut output_lines = Vec::new(); entries.iter().map(|e| {
format!(
// Format each line using the calculated max widths for alignment "{name:max_name_width$} : {avg_int:max_avg_int_width$}.{avg_decimal:<max_avg_decimal_width$}{avg_unit} ± {std_int:max_std_int_width$}.{std_decimal:<max_std_decimal_width$}{std_unit}",
for Entry { // Content
name, name = e.name,
avg_int, avg_int = e.avg_int,
avg_decimal, avg_decimal = e.avg_decimal,
avg_unit, std_int = e.std_int,
std_int, std_decimal = e.std_decimal,
std_decimal, // Units
std_unit, avg_unit = e.avg_unit,
} in entries.iter() std_unit = e.std_unit,
{ // Padding
output_lines.push(format!( max_name_width = max_name_width,
"{name:max_name_width$} : {avg_int:max_avg_int_width$}.{avg_decimal:<max_avg_decimal_width$}{avg_unit} ± {std_int:max_std_int_width$}.{std_decimal:<max_std_decimal_width$}{std_unit}" max_avg_int_width = max_avg_int_width,
)); max_avg_decimal_width = max_avg_decimal_width,
} max_std_int_width = max_std_int_width,
max_std_decimal_width = max_std_decimal_width
output_lines.join("\n") )
} }).collect::<SmallVec<[String; SystemId::COUNT]>>()
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::time::Duration;
#[test]
fn test_format_timing_display() {
let timing_data = vec![
("total".to_string(), Duration::from_micros(1234), Duration::from_micros(570)),
("input".to_string(), Duration::from_micros(120), Duration::from_micros(45)),
("player".to_string(), Duration::from_micros(456), Duration::from_micros(123)),
("movement".to_string(), Duration::from_micros(789), Duration::from_micros(234)),
("render".to_string(), Duration::from_micros(12), Duration::from_micros(3)),
("debug".to_string(), Duration::from_nanos(460), Duration::from_nanos(557)),
];
let result = format_timing_display(timing_data);
let lines: Vec<&str> = result.lines().collect();
// Verify we have the expected number of lines
assert_eq!(lines.len(), 6);
let expected = r#"
total : 1.234ms ± 570.0 µs
input : 120.0 µs ± 45.0 µs
player : 456.0 µs ± 123.0 µs
movement : 789.0 µs ± 234.0 µs
render : 12.0 µs ± 3.0 µs
debug : 460.0 ns ± 557.0 ns
"#
.trim();
for (line, expected_line) in lines.iter().zip(expected.lines()) {
assert_eq!(*line, expected_line);
}
// Print the result for manual inspection
println!("Formatted output:");
println!("{}", result);
}
} }

69
src/systems/ghost.rs Normal file
View File

@@ -0,0 +1,69 @@
use bevy_ecs::system::{Query, Res};
use rand::prelude::*;
use smallvec::SmallVec;
use crate::{
entity::{direction::Direction, graph::Edge},
map::builder::Map,
systems::{
components::{DeltaTime, Ghost},
movement::{Position, Velocity},
},
};
/// Ghost AI system that handles randomized movement decisions.
///
/// This system runs on all ghosts and makes periodic decisions about
/// which direction to move in when they reach intersections.
pub fn ghost_movement_system(
map: Res<Map>,
delta_time: Res<DeltaTime>,
mut ghosts: Query<(&Ghost, &mut Velocity, &mut Position)>,
) {
for (_ghost, mut velocity, mut position) in ghosts.iter_mut() {
let mut distance = velocity.speed * 60.0 * delta_time.0;
loop {
match *position {
Position::Stopped { node: current_node } => {
let intersection = &map.graph.adjacency_list[current_node];
let opposite = velocity.direction.opposite();
let mut non_opposite_options: SmallVec<[Edge; 3]> = SmallVec::new();
// Collect all available directions that ghosts can traverse
for edge in Direction::DIRECTIONS.iter().flat_map(|d| intersection.get(*d)) {
if edge.traversal_flags.contains(crate::entity::graph::TraversalFlags::GHOST)
&& edge.direction != opposite
{
non_opposite_options.push(edge);
}
}
let new_edge: Edge = if non_opposite_options.is_empty() {
if let Some(edge) = intersection.get(opposite) {
edge
} else {
break;
}
} else {
*non_opposite_options.choose(&mut SmallRng::from_os_rng()).unwrap()
};
velocity.direction = new_edge.direction;
*position = Position::Moving {
from: current_node,
to: new_edge.target,
remaining_distance: new_edge.distance,
};
}
Position::Moving { .. } => {
if let Some(overflow) = position.tick(distance) {
distance = overflow;
} else {
break;
}
}
}
}
}
}

View File

@@ -1,16 +1,34 @@
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use bevy_ecs::{event::EventWriter, prelude::Res, resource::Resource, system::NonSendMut}; use bevy_ecs::{
event::EventWriter,
resource::Resource,
system::{NonSendMut, Res, ResMut},
};
use glam::Vec2;
use sdl2::{event::Event, keyboard::Keycode, EventPump}; use sdl2::{event::Event, keyboard::Keycode, EventPump};
use crate::systems::components::DeltaTime;
use crate::{ use crate::{
entity::direction::Direction, entity::direction::Direction,
events::{GameCommand, GameEvent}, events::{GameCommand, GameEvent},
}; };
#[derive(Debug, Clone, Resource)] #[derive(Resource, Default, Debug, Copy, Clone)]
pub enum CursorPosition {
#[default]
None,
Some {
position: Vec2,
remaining_time: f32,
},
}
#[derive(Resource, Debug, Clone)]
pub struct Bindings { pub struct Bindings {
key_bindings: HashMap<Keycode, GameCommand>, key_bindings: HashMap<Keycode, GameCommand>,
movement_keys: HashSet<Keycode>,
last_movement_key: Option<Keycode>,
} }
impl Default for Bindings { impl Default for Bindings {
@@ -35,23 +53,91 @@ impl Default for Bindings {
key_bindings.insert(Keycode::Escape, GameCommand::Exit); key_bindings.insert(Keycode::Escape, GameCommand::Exit);
key_bindings.insert(Keycode::Q, GameCommand::Exit); key_bindings.insert(Keycode::Q, GameCommand::Exit);
Self { key_bindings } let movement_keys = HashSet::from([
Keycode::W,
Keycode::A,
Keycode::S,
Keycode::D,
Keycode::Up,
Keycode::Down,
Keycode::Left,
Keycode::Right,
]);
Self {
key_bindings,
movement_keys,
last_movement_key: None,
}
} }
} }
pub fn input_system(bindings: Res<Bindings>, mut writer: EventWriter<GameEvent>, mut pump: NonSendMut<&'static mut EventPump>) { pub fn input_system(
delta_time: Res<DeltaTime>,
mut bindings: ResMut<Bindings>,
mut writer: EventWriter<GameEvent>,
mut pump: NonSendMut<&'static mut EventPump>,
mut cursor: ResMut<CursorPosition>,
) {
let mut movement_key_pressed = false;
let mut cursor_seen = false;
for event in pump.poll_iter() { for event in pump.poll_iter() {
match event { match event {
Event::Quit { .. } => { Event::Quit { .. } => {
writer.write(GameEvent::Command(GameCommand::Exit)); writer.write(GameEvent::Command(GameCommand::Exit));
} }
Event::KeyDown { keycode: Some(key), .. } => { Event::MouseMotion { x, y, .. } => {
*cursor = CursorPosition::Some {
position: Vec2::new(x as f32, y as f32),
remaining_time: 0.20,
};
cursor_seen = true;
}
Event::KeyUp {
repeat: false,
keycode: Some(key),
..
} => {
// If the last movement key was released, then forget it.
if let Some(last_movement_key) = bindings.last_movement_key {
if last_movement_key == key {
bindings.last_movement_key = None;
}
}
}
Event::KeyDown {
keycode: Some(key),
repeat: false,
..
} => {
let command = bindings.key_bindings.get(&key).copied(); let command = bindings.key_bindings.get(&key).copied();
if let Some(command) = command { if let Some(command) = command {
writer.write(GameEvent::Command(command)); writer.write(GameEvent::Command(command));
} }
if bindings.movement_keys.contains(&key) {
movement_key_pressed = true;
bindings.last_movement_key = Some(key);
}
} }
_ => {} _ => {}
} }
} }
if let Some(last_movement_key) = bindings.last_movement_key {
if !movement_key_pressed {
let command = bindings.key_bindings.get(&last_movement_key).copied();
if let Some(command) = command {
writer.write(GameEvent::Command(command));
}
}
}
if let (false, CursorPosition::Some { remaining_time, .. }) = (cursor_seen, &mut *cursor) {
*remaining_time -= delta_time.0;
if *remaining_time <= 0.0 {
*cursor = CursorPosition::None;
}
}
} }

49
src/systems/item.rs Normal file
View File

@@ -0,0 +1,49 @@
use bevy_ecs::{event::EventReader, prelude::*, query::With, system::Query};
use crate::{
events::GameEvent,
systems::{
audio::AudioEvent,
components::{EntityType, ItemCollider, PacmanCollider, ScoreResource},
},
};
pub fn item_system(
mut commands: Commands,
mut collision_events: EventReader<GameEvent>,
mut score: ResMut<ScoreResource>,
pacman_query: Query<Entity, With<PacmanCollider>>,
item_query: Query<(Entity, &EntityType), With<ItemCollider>>,
mut events: EventWriter<AudioEvent>,
) {
for event in collision_events.read() {
if let GameEvent::Collision(entity1, entity2) = event {
// Check if one is Pacman and the other is an item
let (_pacman_entity, item_entity) = if pacman_query.get(*entity1).is_ok() && item_query.get(*entity2).is_ok() {
(*entity1, *entity2)
} else if pacman_query.get(*entity2).is_ok() && item_query.get(*entity1).is_ok() {
(*entity2, *entity1)
} else {
continue;
};
// Get the item type and update score
if let Ok((item_ent, entity_type)) = item_query.get(item_entity) {
match entity_type {
EntityType::Pellet => {
score.0 += 10;
}
EntityType::PowerPellet => {
score.0 += 50;
}
_ => continue,
}
// Remove the collected item
commands.entity(item_ent).despawn();
events.write(AudioEvent::PlayEat);
}
}
}
}

View File

@@ -3,13 +3,16 @@
//! This module contains all the ECS-related logic, including components, systems, //! This module contains all the ECS-related logic, including components, systems,
//! and resources. //! and resources.
pub mod audio;
pub mod blinking; pub mod blinking;
pub mod collision; pub mod collision;
pub mod components; pub mod components;
pub mod control;
pub mod debug; pub mod debug;
pub mod formatting; pub mod formatting;
pub mod ghost;
pub mod input; pub mod input;
pub mod item;
pub mod movement; pub mod movement;
pub mod player;
pub mod profiling; pub mod profiling;
pub mod render; pub mod render;

View File

@@ -1,46 +1,40 @@
use crate::entity::direction::Direction;
use crate::entity::graph::Graph; use crate::entity::graph::Graph;
use crate::entity::{direction::Direction, graph::Edge}; use crate::error::{EntityError, GameResult};
use crate::error::{EntityError, GameError, GameResult};
use crate::map::builder::Map;
use crate::systems::components::{DeltaTime, EntityType};
use bevy_ecs::component::Component; use bevy_ecs::component::Component;
use bevy_ecs::event::EventWriter;
use bevy_ecs::system::{Query, Res};
use glam::Vec2; use glam::Vec2;
/// A unique identifier for a node, represented by its index in the graph's storage. /// A unique identifier for a node, represented by its index in the graph's storage.
pub type NodeId = usize; pub type NodeId = usize;
/// Progress along an edge between two nodes. /// A component that represents the speed and cardinal direction of an entity.
#[derive(Debug, Clone, Copy, PartialEq)] /// Speed is static, only applied when the entity has an edge to traverse.
pub struct EdgeProgress { /// Direction is dynamic, but is controlled externally.
pub target_node: NodeId, #[derive(Component, Debug, Copy, Clone, PartialEq)]
/// Progress from 0.0 (at source node) to 1.0 (at target node) pub struct Velocity {
pub progress: f32, pub speed: f32,
pub direction: Direction,
}
/// A component that represents a direction change that is only remembered for a period of time.
/// This is used to allow entities to change direction before they reach their current target node (which consumes their buffered direction).
#[derive(Component, Debug, Copy, Clone, PartialEq)]
pub enum BufferedDirection {
None,
Some { direction: Direction, remaining_time: f32 },
} }
/// Pure spatial position component - works for both static and dynamic entities. /// Pure spatial position component - works for both static and dynamic entities.
#[derive(Component, Debug, Copy, Clone, PartialEq)] #[derive(Component, Debug, Copy, Clone, PartialEq)]
pub struct Position { pub enum Position {
/// The current/primary node this entity is at or traveling from Stopped {
pub node: NodeId, node: NodeId,
/// If Some, entity is traveling between nodes. If None, entity is stationary at node. },
pub edge_progress: Option<EdgeProgress>, Moving {
} from: NodeId,
to: NodeId,
/// Explicit movement state - only for entities that can move. remaining_distance: f32,
#[derive(Component, Debug, Clone, Copy, PartialEq)] },
pub enum MovementState {
Stopped,
Moving { direction: Direction },
}
/// Movement capability and parameters - only for entities that can move.
#[derive(Component, Debug, Clone, Copy)]
pub struct Movable {
pub speed: f32,
pub current_direction: Direction,
pub requested_direction: Option<Direction>,
} }
impl Position { impl Position {
@@ -52,26 +46,32 @@ impl Position {
/// # Errors /// # Errors
/// ///
/// Returns an `EntityError` if the node or edge is not found. /// Returns an `EntityError` if the node or edge is not found.
pub fn get_pixel_pos(&self, graph: &Graph) -> GameResult<Vec2> { pub fn get_pixel_position(&self, graph: &Graph) -> GameResult<Vec2> {
let pos = match &self.edge_progress { let pos = match &self {
None => { Position::Stopped { node } => {
// Entity is stationary at a node // Entity is stationary at a node
let node = graph.get_node(self.node).ok_or(EntityError::NodeNotFound(self.node))?; let node = graph.get_node(*node).ok_or(EntityError::NodeNotFound(*node))?;
node.position node.position
} }
Some(edge_progress) => { Position::Moving {
from,
to,
remaining_distance,
} => {
// Entity is traveling between nodes // Entity is traveling between nodes
let from_node = graph.get_node(self.node).ok_or(EntityError::NodeNotFound(self.node))?; let from_node = graph.get_node(*from).ok_or(EntityError::NodeNotFound(*from))?;
let to_node = graph let to_node = graph.get_node(*to).ok_or(EntityError::NodeNotFound(*to))?;
.get_node(edge_progress.target_node) let edge = graph
.ok_or(EntityError::NodeNotFound(edge_progress.target_node))?; .find_edge(*from, *to)
.ok_or(EntityError::EdgeNotFound { from: *from, to: *to })?;
// For zero-distance edges (tunnels), progress >= 1.0 means we're at the target // For zero-distance edges (tunnels), progress >= 1.0 means we're at the target
if edge_progress.progress >= 1.0 { if edge.distance == 0.0 {
to_node.position to_node.position
} else { } else {
// Interpolate position based on progress // Interpolate position based on progress
from_node.position + (to_node.position - from_node.position) * edge_progress.progress let progress = 1.0 - (*remaining_distance / edge.distance);
from_node.position.lerp(to_node.position, progress)
} }
} }
}; };
@@ -81,192 +81,205 @@ impl Position {
pos.y + crate::constants::BOARD_PIXEL_OFFSET.y as f32, pos.y + crate::constants::BOARD_PIXEL_OFFSET.y as f32,
)) ))
} }
}
impl Default for Position { /// Moves the position by a given distance towards it's current target node.
fn default() -> Self { ///
Position { /// Returns the overflow distance, if any.
node: 0, pub fn tick(&mut self, distance: f32) -> Option<f32> {
edge_progress: None, if distance <= 0.0 || self.is_at_node() {
return None;
}
match self {
Position::Moving {
to, remaining_distance, ..
} => {
// If the remaining distance is less than or equal the distance, we'll reach the target
if *remaining_distance <= distance {
let overflow: Option<f32> = if *remaining_distance != distance {
Some(distance - *remaining_distance)
} else {
None
};
*self = Position::Stopped { node: *to };
return overflow;
}
*remaining_distance -= distance;
None
}
_ => unreachable!(),
} }
} }
}
#[allow(dead_code)]
impl Position {
/// Returns `true` if the position is exactly at a node (not traveling). /// Returns `true` if the position is exactly at a node (not traveling).
pub fn is_at_node(&self) -> bool { pub fn is_at_node(&self) -> bool {
self.edge_progress.is_none() matches!(self, Position::Stopped { .. })
} }
/// Returns the `NodeId` of the current node (source of travel if moving). /// Returns the `NodeId` of the current node (source of travel if moving).
pub fn current_node(&self) -> NodeId { pub fn current_node(&self) -> NodeId {
self.node match self {
} Position::Stopped { node } => *node,
Position::Moving { from, .. } => *from,
/// Returns the `NodeId` of the destination node, if currently traveling.
pub fn target_node(&self) -> Option<NodeId> {
self.edge_progress.as_ref().map(|ep| ep.target_node)
}
/// Returns `true` if the entity is traveling between nodes.
pub fn is_moving(&self) -> bool {
self.edge_progress.is_some()
}
}
fn can_traverse(entity_type: EntityType, edge: Edge) -> bool {
let entity_flags = entity_type.traversal_flags();
edge.traversal_flags.contains(entity_flags)
}
pub fn movement_system(
map: Res<Map>,
delta_time: Res<DeltaTime>,
mut entities: Query<(&mut MovementState, &mut Movable, &mut Position, &EntityType)>,
mut errors: EventWriter<GameError>,
) {
for (mut movement_state, mut movable, mut position, entity_type) in entities.iter_mut() {
let distance = movable.speed * 60.0 * delta_time.0;
match *movement_state {
MovementState::Stopped => {
// Check if we have a requested direction to start moving
if let Some(requested_direction) = movable.requested_direction {
if let Some(edge) = map.graph.find_edge_in_direction(position.node, requested_direction) {
if can_traverse(*entity_type, edge) {
// Start moving in the requested direction
let progress = if edge.distance > 0.0 {
distance / edge.distance
} else {
// Zero-distance edge (tunnels) - immediately teleport
tracing::debug!("Entity entering tunnel from node {} to node {}", position.node, edge.target);
1.0
};
position.edge_progress = Some(EdgeProgress {
target_node: edge.target,
progress,
});
movable.current_direction = requested_direction;
movable.requested_direction = None;
*movement_state = MovementState::Moving {
direction: requested_direction,
};
}
} else {
errors.write(
EntityError::InvalidMovement(format!(
"No edge found in direction {:?} from node {}",
requested_direction, position.node
))
.into(),
);
}
}
}
MovementState::Moving { direction } => {
// Continue moving or handle node transitions
let current_node = position.node;
if let Some(edge_progress) = &mut position.edge_progress {
// Extract target node before mutable operations
let target_node = edge_progress.target_node;
// Get the current edge for distance calculation
let edge = map.graph.find_edge(current_node, target_node);
if let Some(edge) = edge {
// Update progress along the edge
if edge.distance > 0.0 {
edge_progress.progress += distance / edge.distance;
} else {
// Zero-distance edge (tunnels) - immediately complete
edge_progress.progress = 1.0;
}
if edge_progress.progress >= 1.0 {
// Reached the target node
let overflow = if edge.distance > 0.0 {
(edge_progress.progress - 1.0) * edge.distance
} else {
// Zero-distance edge - use remaining distance for overflow
distance
};
position.node = target_node;
position.edge_progress = None;
let mut continued_moving = false;
// Try to use requested direction first
if let Some(requested_direction) = movable.requested_direction {
if let Some(next_edge) = map.graph.find_edge_in_direction(position.node, requested_direction) {
if can_traverse(*entity_type, next_edge) {
let next_progress = if next_edge.distance > 0.0 {
overflow / next_edge.distance
} else {
// Zero-distance edge - immediately complete
1.0
};
position.edge_progress = Some(EdgeProgress {
target_node: next_edge.target,
progress: next_progress,
});
movable.current_direction = requested_direction;
movable.requested_direction = None;
*movement_state = MovementState::Moving {
direction: requested_direction,
};
continued_moving = true;
}
}
}
// If no requested direction or it failed, try to continue in current direction
if !continued_moving {
if let Some(next_edge) = map.graph.find_edge_in_direction(position.node, direction) {
if can_traverse(*entity_type, next_edge) {
let next_progress = if next_edge.distance > 0.0 {
overflow / next_edge.distance
} else {
// Zero-distance edge - immediately complete
1.0
};
position.edge_progress = Some(EdgeProgress {
target_node: next_edge.target,
progress: next_progress,
});
// Keep current direction and movement state
continued_moving = true;
}
}
}
// If we couldn't continue moving, stop
if !continued_moving {
*movement_state = MovementState::Stopped;
movable.requested_direction = None;
}
}
} else {
// Edge not found - this is an inconsistent state
errors.write(
EntityError::InvalidMovement(format!(
"Inconsistent state: Moving on non-existent edge from {} to {}",
current_node, target_node
))
.into(),
);
*movement_state = MovementState::Stopped;
position.edge_progress = None;
}
} else {
// Movement state says moving but no edge progress - this shouldn't happen
errors.write(EntityError::InvalidMovement("Entity in Moving state but no edge progress".to_string()).into());
*movement_state = MovementState::Stopped;
}
}
} }
} }
} }
// pub fn movement_system(
// map: Res<Map>,
// delta_time: Res<DeltaTime>,
// mut entities: Query<(&mut Position, &mut Movable, &EntityType)>,
// mut errors: EventWriter<GameError>,
// ) {
// for (mut position, mut movable, entity_type) in entities.iter_mut() {
// let distance = movable.speed * 60.0 * delta_time.0;
// match *position {
// Position::Stopped { .. } => {
// // Check if we have a requested direction to start moving
// if let Some(requested_direction) = movable.requested_direction {
// if let Some(edge) = map.graph.find_edge_in_direction(position.current_node(), requested_direction) {
// if can_traverse(*entity_type, edge) {
// // Start moving in the requested direction
// let progress = if edge.distance > 0.0 {
// distance / edge.distance
// } else {
// // Zero-distance edge (tunnels) - immediately teleport
// tracing::debug!(
// "Entity entering tunnel from node {} to node {}",
// position.current_node(),
// edge.target
// );
// 1.0
// };
// *position = Position::Moving {
// from: position.current_node(),
// to: edge.target,
// remaining_distance: progress,
// };
// movable.current_direction = requested_direction;
// movable.requested_direction = None;
// }
// } else {
// errors.write(
// EntityError::InvalidMovement(format!(
// "No edge found in direction {:?} from node {}",
// requested_direction,
// position.current_node()
// ))
// .into(),
// );
// }
// }
// }
// Position::Moving {
// from,
// to,
// remaining_distance,
// } => {
// // Continue moving or handle node transitions
// let current_node = *from;
// if let Some(edge) = map.graph.find_edge(current_node, *to) {
// // Extract target node before mutable operations
// let target_node = *to;
// // Get the current edge for distance calculation
// let edge = map.graph.find_edge(current_node, target_node);
// if let Some(edge) = edge {
// // Update progress along the edge
// if edge.distance > 0.0 {
// *remaining_distance += distance / edge.distance;
// } else {
// // Zero-distance edge (tunnels) - immediately complete
// *remaining_distance = 1.0;
// }
// if *remaining_distance >= 1.0 {
// // Reached the target node
// let overflow = if edge.distance > 0.0 {
// (*remaining_distance - 1.0) * edge.distance
// } else {
// // Zero-distance edge - use remaining distance for overflow
// distance
// };
// *position = Position::Stopped { node: target_node };
// let mut continued_moving = false;
// // Try to use requested direction first
// if let Some(requested_direction) = movable.requested_direction {
// if let Some(next_edge) = map.graph.find_edge_in_direction(position.node, requested_direction) {
// if can_traverse(*entity_type, next_edge) {
// let next_progress = if next_edge.distance > 0.0 {
// overflow / next_edge.distance
// } else {
// // Zero-distance edge - immediately complete
// 1.0
// };
// *position = Position::Moving {
// from: position.current_node(),
// to: next_edge.target,
// remaining_distance: next_progress,
// };
// movable.current_direction = requested_direction;
// movable.requested_direction = None;
// continued_moving = true;
// }
// }
// }
// // If no requested direction or it failed, try to continue in current direction
// if !continued_moving {
// if let Some(next_edge) = map.graph.find_edge_in_direction(position.node, direction) {
// if can_traverse(*entity_type, next_edge) {
// let next_progress = if next_edge.distance > 0.0 {
// overflow / next_edge.distance
// } else {
// // Zero-distance edge - immediately complete
// 1.0
// };
// *position = Position::Moving {
// from: position.current_node(),
// to: next_edge.target,
// remaining_distance: next_progress,
// };
// // Keep current direction and movement state
// continued_moving = true;
// }
// }
// }
// // If we couldn't continue moving, stop
// if !continued_moving {
// *movement_state = MovementState::Stopped;
// movable.requested_direction = None;
// }
// }
// } else {
// // Edge not found - this is an inconsistent state
// errors.write(
// EntityError::InvalidMovement(format!(
// "Inconsistent state: Moving on non-existent edge from {} to {}",
// current_node, target_node
// ))
// .into(),
// );
// *movement_state = MovementState::Stopped;
// position.edge_progress = None;
// }
// } else {
// // Movement state says moving but no edge progress - this shouldn't happen
// errors.write(EntityError::InvalidMovement("Entity in Moving state but no edge progress".to_string()).into());
// *movement_state = MovementState::Stopped;
// }
// }
// }
// }
// }

143
src/systems/player.rs Normal file
View File

@@ -0,0 +1,143 @@
use bevy_ecs::{
event::{EventReader, EventWriter},
prelude::ResMut,
query::With,
system::{Query, Res},
};
use crate::{
entity::graph::Edge,
error::GameError,
events::{GameCommand, GameEvent},
map::builder::Map,
systems::{
components::{AudioState, DeltaTime, EntityType, GlobalState, PlayerControlled},
debug::DebugState,
movement::{BufferedDirection, Position, Velocity},
},
};
// Handles player input and control
pub fn player_control_system(
mut events: EventReader<GameEvent>,
mut state: ResMut<GlobalState>,
mut debug_state: ResMut<DebugState>,
mut audio_state: ResMut<AudioState>,
mut players: Query<&mut BufferedDirection, With<PlayerControlled>>,
mut errors: EventWriter<GameError>,
) {
// Get the player's movable component (ensuring there is only one player)
let mut buffered_direction = match players.single_mut() {
Ok(buffered_direction) => buffered_direction,
Err(e) => {
errors.write(GameError::InvalidState(format!(
"No/multiple entities queried for player system: {}",
e
)));
return;
}
};
// Handle events
for event in events.read() {
if let GameEvent::Command(command) = event {
match command {
GameCommand::MovePlayer(direction) => {
*buffered_direction = BufferedDirection::Some {
direction: *direction,
remaining_time: 0.25,
};
}
GameCommand::Exit => {
state.exit = true;
}
GameCommand::ToggleDebug => {
*debug_state = debug_state.next();
}
GameCommand::MuteAudio => {
audio_state.muted = !audio_state.muted;
tracing::info!("Audio {}", if audio_state.muted { "muted" } else { "unmuted" });
}
_ => {}
}
}
}
}
fn can_traverse(entity_type: EntityType, edge: Edge) -> bool {
let entity_flags = entity_type.traversal_flags();
edge.traversal_flags.contains(entity_flags)
}
pub fn player_movement_system(
map: Res<Map>,
delta_time: Res<DeltaTime>,
mut entities: Query<(&mut Position, &mut Velocity, &mut BufferedDirection), With<PlayerControlled>>,
// mut errors: EventWriter<GameError>,
) {
for (mut position, mut velocity, mut buffered_direction) in entities.iter_mut() {
// Decrement the buffered direction remaining time
if let BufferedDirection::Some {
direction,
remaining_time,
} = *buffered_direction
{
if remaining_time <= 0.0 {
*buffered_direction = BufferedDirection::None;
} else {
*buffered_direction = BufferedDirection::Some {
direction,
remaining_time: remaining_time - delta_time.0,
};
}
}
let mut distance = velocity.speed * 60.0 * delta_time.0;
loop {
match *position {
Position::Stopped { .. } => {
// If there is a buffered direction, travel it's edge first if available.
if let BufferedDirection::Some { direction, .. } = *buffered_direction {
// If there's no edge in that direction, ignore the buffered direction.
if let Some(edge) = map.graph.find_edge_in_direction(position.current_node(), direction) {
// If there is an edge in that direction (and it's traversable), start moving towards it and consume the buffered direction.
if can_traverse(EntityType::Player, edge) {
velocity.direction = edge.direction;
*position = Position::Moving {
from: position.current_node(),
to: edge.target,
remaining_distance: edge.distance,
};
*buffered_direction = BufferedDirection::None;
}
}
}
// If there is no buffered direction (or it's not yet valid), continue in the current direction.
if let Some(edge) = map.graph.find_edge_in_direction(position.current_node(), velocity.direction) {
if can_traverse(EntityType::Player, edge) {
velocity.direction = edge.direction;
*position = Position::Moving {
from: position.current_node(),
to: edge.target,
remaining_distance: edge.distance,
};
}
} else {
// No edge in our current direction either, erase the buffered direction and stop.
*buffered_direction = BufferedDirection::None;
break;
}
}
Position::Moving { .. } => {
if let Some(overflow) = position.tick(distance) {
distance = overflow;
} else {
break;
}
}
}
}
}
}

View File

@@ -3,14 +3,44 @@ use bevy_ecs::system::{IntoSystem, System};
use circular_buffer::CircularBuffer; use circular_buffer::CircularBuffer;
use micromap::Map; use micromap::Map;
use parking_lot::{Mutex, RwLock}; use parking_lot::{Mutex, RwLock};
use smallvec::SmallVec;
use std::fmt::Display;
use std::time::Duration; use std::time::Duration;
use strum::EnumCount;
use strum_macros::{EnumCount, IntoStaticStr};
use thousands::Separable; use thousands::Separable;
use crate::systems::formatting;
/// The maximum number of systems that can be profiled. Must not be exceeded, or it will panic. /// The maximum number of systems that can be profiled. Must not be exceeded, or it will panic.
const MAX_SYSTEMS: usize = 11; const MAX_SYSTEMS: usize = SystemId::COUNT;
/// The number of durations to keep in the circular buffer. /// The number of durations to keep in the circular buffer.
const TIMING_WINDOW_SIZE: usize = 30; const TIMING_WINDOW_SIZE: usize = 30;
#[derive(EnumCount, IntoStaticStr, Debug, PartialEq, Eq, Hash, Copy, Clone)]
pub enum SystemId {
Input,
PlayerControls,
Ghost,
Movement,
Audio,
Blinking,
DirectionalRender,
DirtyRender,
Render,
DebugRender,
Present,
Collision,
Item,
PlayerMovement,
}
impl Display for SystemId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", Into::<&'static str>::into(self).to_ascii_lowercase())
}
}
#[derive(Resource, Default, Debug)] #[derive(Resource, Default, Debug)]
pub struct SystemTimings { pub struct SystemTimings {
/// Map of system names to a queue of durations, using a circular buffer. /// Map of system names to a queue of durations, using a circular buffer.
@@ -20,18 +50,18 @@ pub struct SystemTimings {
/// ///
/// Also, we use a micromap::Map as the number of systems is generally quite small. /// Also, we use a micromap::Map as the number of systems is generally quite small.
/// Just make sure to set the capacity appropriately, or it will panic. /// Just make sure to set the capacity appropriately, or it will panic.
pub timings: RwLock<Map<&'static str, Mutex<CircularBuffer<TIMING_WINDOW_SIZE, Duration>>, MAX_SYSTEMS>>, pub timings: RwLock<Map<SystemId, Mutex<CircularBuffer<TIMING_WINDOW_SIZE, Duration>>, MAX_SYSTEMS>>,
} }
impl SystemTimings { impl SystemTimings {
pub fn add_timing(&self, name: &'static str, duration: Duration) { pub fn add_timing(&self, id: SystemId, duration: Duration) {
// acquire a upgradable read lock // acquire a upgradable read lock
let mut timings = self.timings.upgradable_read(); let mut timings = self.timings.upgradable_read();
// happy path, the name is already in the map (no need to mutate the hashmap) // happy path, the name is already in the map (no need to mutate the hashmap)
if timings.contains_key(name) { if timings.contains_key(&id) {
let queue = timings let queue = timings
.get(name) .get(&id)
.expect("System name not found in map after contains_key check"); .expect("System name not found in map after contains_key check");
let mut queue = queue.lock(); let mut queue = queue.lock();
@@ -41,16 +71,16 @@ impl SystemTimings {
// otherwise, acquire a write lock and insert a new queue // otherwise, acquire a write lock and insert a new queue
timings.with_upgraded(|timings| { timings.with_upgraded(|timings| {
let queue = timings.entry(name).or_insert_with(|| Mutex::new(CircularBuffer::new())); let queue = timings.entry(id).or_insert_with(|| Mutex::new(CircularBuffer::new()));
queue.lock().push_back(duration); queue.lock().push_back(duration);
}); });
} }
pub fn get_stats(&self) -> Map<&'static str, (Duration, Duration), MAX_SYSTEMS> { pub fn get_stats(&self) -> Map<SystemId, (Duration, Duration), MAX_SYSTEMS> {
let timings = self.timings.read(); let timings = self.timings.read();
let mut stats = Map::new(); let mut stats = Map::new();
for (name, queue) in timings.iter() { for (id, queue) in timings.iter() {
if queue.lock().is_empty() { if queue.lock().is_empty() {
continue; continue;
} }
@@ -65,7 +95,7 @@ impl SystemTimings {
let std_dev = variance.sqrt(); let std_dev = variance.sqrt();
stats.insert( stats.insert(
*name, *id,
( (
Duration::from_secs_f64(mean / 1000.0), Duration::from_secs_f64(mean / 1000.0),
Duration::from_secs_f64(std_dev / 1000.0), Duration::from_secs_f64(std_dev / 1000.0),
@@ -101,7 +131,7 @@ impl SystemTimings {
) )
} }
pub fn format_timing_display(&self) -> String { pub fn format_timing_display(&self) -> SmallVec<[String; SystemId::COUNT]> {
let stats = self.get_stats(); let stats = self.get_stats();
let (total_avg, total_std) = self.get_total_stats(); let (total_avg, total_std) = self.get_total_stats();
@@ -126,11 +156,11 @@ impl SystemTimings {
} }
// Use the formatting module to format the data // Use the formatting module to format the data
crate::systems::formatting::format_timing_display(timing_data) formatting::format_timing_display(timing_data)
} }
} }
pub fn profile<S, M>(name: &'static str, system: S) -> impl FnMut(&mut bevy_ecs::world::World) pub fn profile<S, M>(id: SystemId, system: S) -> impl FnMut(&mut bevy_ecs::world::World)
where where
S: IntoSystem<(), (), M> + 'static, S: IntoSystem<(), (), M> + 'static,
{ {
@@ -147,7 +177,7 @@ where
let duration = start.elapsed(); let duration = start.elapsed();
if let Some(timings) = world.get_resource::<SystemTimings>() { if let Some(timings) = world.get_resource::<SystemTimings>() {
timings.add_timing(name, duration); timings.add_timing(id, duration);
} }
} }
} }

View File

@@ -1,7 +1,7 @@
use crate::error::{GameError, TextureError}; use crate::error::{GameError, TextureError};
use crate::map::builder::Map; use crate::map::builder::Map;
use crate::systems::components::{DeltaTime, DirectionalAnimated, RenderDirty, Renderable}; use crate::systems::components::{DeltaTime, DirectionalAnimated, RenderDirty, Renderable};
use crate::systems::movement::{Movable, MovementState, Position}; use crate::systems::movement::{Position, Velocity};
use crate::texture::sprite::SpriteAtlas; use crate::texture::sprite::SpriteAtlas;
use bevy_ecs::entity::Entity; use bevy_ecs::entity::Entity;
use bevy_ecs::event::EventWriter; use bevy_ecs::event::EventWriter;
@@ -10,6 +10,7 @@ use bevy_ecs::system::{NonSendMut, Query, Res, ResMut};
use sdl2::render::{Canvas, Texture}; use sdl2::render::{Canvas, Texture};
use sdl2::video::Window; use sdl2::video::Window;
#[allow(clippy::type_complexity)]
pub fn dirty_render_system( pub fn dirty_render_system(
mut dirty: ResMut<RenderDirty>, mut dirty: ResMut<RenderDirty>,
changed_renderables: Query<(), Or<(Changed<Renderable>, Changed<Position>)>>, changed_renderables: Query<(), Or<(Changed<Renderable>, Changed<Position>)>>,
@@ -25,12 +26,12 @@ pub fn dirty_render_system(
/// This runs before the render system so it can update the sprite based on the current direction of travel, as well as whether the entity is moving. /// This runs before the render system so it can update the sprite based on the current direction of travel, as well as whether the entity is moving.
pub fn directional_render_system( pub fn directional_render_system(
dt: Res<DeltaTime>, dt: Res<DeltaTime>,
mut renderables: Query<(&MovementState, &Movable, &mut DirectionalAnimated, &mut Renderable)>, mut renderables: Query<(&Position, &Velocity, &mut DirectionalAnimated, &mut Renderable)>,
mut errors: EventWriter<GameError>, mut errors: EventWriter<GameError>,
) { ) {
for (movement_state, movable, mut texture, mut renderable) in renderables.iter_mut() { for (position, velocity, mut texture, mut renderable) in renderables.iter_mut() {
let stopped = matches!(movement_state, MovementState::Stopped); let stopped = matches!(position, Position::Stopped { .. });
let current_direction = movable.current_direction; let current_direction = velocity.direction;
let texture = if stopped { let texture = if stopped {
texture.stopped_textures[current_direction.as_usize()].as_mut() texture.stopped_textures[current_direction.as_usize()].as_mut()
@@ -47,7 +48,7 @@ pub fn directional_render_system(
renderable.sprite = new_tile; renderable.sprite = new_tile;
} }
} else { } else {
errors.write(TextureError::RenderFailed(format!("Entity has no texture")).into()); errors.write(TextureError::RenderFailed("Entity has no texture".to_string()).into());
continue; continue;
} }
} }
@@ -59,6 +60,7 @@ pub struct MapTextureResource(pub Texture<'static>);
/// A non-send resource for the backbuffer texture. This just wraps the texture with a type so it can be differentiated when exposed as a resource. /// A non-send resource for the backbuffer texture. This just wraps the texture with a type so it can be differentiated when exposed as a resource.
pub struct BackbufferResource(pub Texture<'static>); pub struct BackbufferResource(pub Texture<'static>);
#[allow(clippy::too_many_arguments)]
pub fn render_system( pub fn render_system(
mut canvas: NonSendMut<&mut Canvas<Window>>, mut canvas: NonSendMut<&mut Canvas<Window>>,
map_texture: NonSendMut<MapTextureResource>, map_texture: NonSendMut<MapTextureResource>,
@@ -85,12 +87,16 @@ pub fn render_system(
} }
// Render all entities to the backbuffer // Render all entities to the backbuffer
for (_, renderable, position) in renderables.iter() { for (_, renderable, position) in renderables
.iter()
.sort_by_key::<(Entity, &Renderable, &Position), _>(|(_, renderable, _)| renderable.layer)
.rev()
{
if !renderable.visible { if !renderable.visible {
continue; continue;
} }
let pos = position.get_pixel_pos(&map.graph); let pos = position.get_pixel_position(&map.graph);
match pos { match pos {
Ok(pos) => { Ok(pos) => {
let dest = crate::helpers::centered_with_size( let dest = crate::helpers::centered_with_size(
@@ -105,7 +111,7 @@ pub fn render_system(
.map(|e| errors.write(TextureError::RenderFailed(e.to_string()).into())); .map(|e| errors.write(TextureError::RenderFailed(e.to_string()).into()));
} }
Err(e) => { Err(e) => {
errors.write(e.into()); errors.write(e);
} }
} }
} }

View File

@@ -28,7 +28,7 @@ pub fn setup_sdl() -> Result<(Canvas<Window>, TextureCreator<WindowContext>, Sdl
pub fn create_atlas(canvas: &mut sdl2::render::Canvas<sdl2::video::Window>) -> SpriteAtlas { pub fn create_atlas(canvas: &mut sdl2::render::Canvas<sdl2::video::Window>) -> SpriteAtlas {
let texture_creator = canvas.texture_creator(); let texture_creator = canvas.texture_creator();
let atlas_bytes = get_asset_bytes(Asset::Atlas).unwrap(); let atlas_bytes = get_asset_bytes(Asset::AtlasImage).unwrap();
let texture = texture_creator.load_texture_bytes(&atlas_bytes).unwrap(); let texture = texture_creator.load_texture_bytes(&atlas_bytes).unwrap();
let texture: Texture<'static> = unsafe { std::mem::transmute(texture) }; let texture: Texture<'static> = unsafe { std::mem::transmute(texture) };

View File

@@ -1,34 +0,0 @@
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

@@ -1,135 +1,95 @@
use pacman::systems::formatting::format_timing_display; use pacman::systems::formatting::format_timing_display;
use std::time::Duration; use std::time::Duration;
#[test] use pretty_assertions::assert_eq;
fn test_basic_formatting() {
let timing_data = vec![ fn get_timing_data() -> Vec<(String, Duration, Duration)> {
("60 FPS".to_string(), Duration::from_micros(1234), Duration::from_micros(567)), vec![
("input".to_string(), Duration::from_micros(123), Duration::from_micros(45)), ("total".to_string(), Duration::from_micros(1234), Duration::from_micros(570)),
("input".to_string(), Duration::from_micros(120), Duration::from_micros(45)),
("player".to_string(), Duration::from_micros(456), Duration::from_micros(123)), ("player".to_string(), Duration::from_micros(456), Duration::from_micros(123)),
("movement".to_string(), Duration::from_micros(789), Duration::from_micros(234)), ("movement".to_string(), Duration::from_micros(789), Duration::from_micros(234)),
("render".to_string(), Duration::from_micros(12), Duration::from_micros(3)), ("render".to_string(), Duration::from_micros(12), Duration::from_micros(3)),
("debug".to_string(), Duration::from_nanos(1000000), Duration::from_nanos(1000)), ("debug".to_string(), Duration::from_nanos(460), Duration::from_nanos(557)),
]; ]
}
let result = format_timing_display(timing_data); fn get_formatted_output() -> impl IntoIterator<Item = String> {
println!("Basic formatting test:"); format_timing_display(get_timing_data())
println!("{}", result);
println!();
} }
#[test] #[test]
fn test_desired_format() { fn test_formatting_alignment() {
// This test represents the exact format you want to achieve let mut colon_positions = vec![];
let timing_data = vec![ let mut first_decimal_positions = vec![];
("total".to_string(), Duration::from_micros(1230), Duration::from_micros(570)), let mut second_decimal_positions = vec![];
("input".to_string(), Duration::from_micros(120), Duration::from_micros(50)), let mut first_unit_positions = vec![];
("player".to_string(), Duration::from_micros(460), Duration::from_micros(120)), let mut second_unit_positions = vec![];
("movement".to_string(), Duration::from_micros(790), Duration::from_micros(230)),
("render".to_string(), Duration::from_micros(10), Duration::from_micros(3)),
("debug".to_string(), Duration::from_nanos(1000000), Duration::from_nanos(1000)),
];
let result = format_timing_display(timing_data); get_formatted_output().into_iter().for_each(|line| {
println!("Desired format test:"); let (mut got_decimal, mut got_unit) = (false, false);
println!("{}", result); for (i, char) in line.chars().enumerate() {
println!(); match char {
':' => colon_positions.push(i),
'.' => {
if got_decimal {
second_decimal_positions.push(i);
} else {
first_decimal_positions.push(i);
}
got_decimal = true;
}
's' => {
if got_unit {
first_unit_positions.push(i);
} else {
second_unit_positions.push(i);
got_unit = true;
}
}
_ => {}
}
}
});
// Expected output should look like: // Assert that all positions were found
// total : 1.23 ms ± 0.57 ms assert_eq!(
// input : 0.12 ms ± 0.05 ms [
// player : 0.46 ms ± 0.12 ms &colon_positions,
// movement : 0.79 ms ± 0.23 ms &first_decimal_positions,
// render : 0.01 ms ± 0.003ms &second_decimal_positions,
// debug : 0.001ms ± 0.000ms &first_unit_positions,
} &second_unit_positions
]
#[test] .iter()
fn test_mixed_units() { .all(|p| p.len() == 6),
let timing_data = vec![ true
("60 FPS".to_string(), Duration::from_millis(16), Duration::from_micros(500)), );
(
"fast_system".to_string(), // Assert that all positions are the same
Duration::from_nanos(500000), assert!(
Duration::from_nanos(100000), colon_positions.iter().all(|&p| p == colon_positions[0]),
), "colon positions are not the same {:?}",
( colon_positions
"medium_system".to_string(), );
Duration::from_micros(2500), assert!(
Duration::from_micros(500), first_decimal_positions.iter().all(|&p| p == first_decimal_positions[0]),
), "first decimal positions are not the same {:?}",
("slow_system".to_string(), Duration::from_millis(5), Duration::from_millis(1)), first_decimal_positions
]; );
assert!(
let result = format_timing_display(timing_data); second_decimal_positions.iter().all(|&p| p == second_decimal_positions[0]),
println!("Mixed units test:"); "second decimal positions are not the same {:?}",
println!("{}", result); second_decimal_positions
println!(); );
} assert!(
first_unit_positions.iter().all(|&p| p == first_unit_positions[0]),
#[test] "first unit positions are not the same {:?}",
fn test_trailing_zeros() { first_unit_positions
let timing_data = vec![ );
("60 FPS".to_string(), Duration::from_micros(1000), Duration::from_micros(500)), assert!(
("exact_ms".to_string(), Duration::from_millis(1), Duration::from_micros(100)), second_unit_positions.iter().all(|&p| p == second_unit_positions[0]),
("exact_us".to_string(), Duration::from_micros(1), Duration::from_nanos(100000)), "second unit positions are not the same {:?}",
("exact_ns".to_string(), Duration::from_nanos(1000), Duration::from_nanos(100)), second_unit_positions
]; );
let result = format_timing_display(timing_data);
println!("Trailing zeros test:");
println!("{}", result);
println!();
}
#[test]
fn test_edge_cases() {
let timing_data = vec![
("60 FPS".to_string(), Duration::from_nanos(1), Duration::from_nanos(1)),
("very_small".to_string(), Duration::from_nanos(100), Duration::from_nanos(50)),
("very_large".to_string(), Duration::from_secs(1), Duration::from_millis(100)),
("zero_time".to_string(), Duration::ZERO, Duration::ZERO),
];
let result = format_timing_display(timing_data);
println!("Edge cases test:");
println!("{}", result);
println!();
}
#[test]
fn test_variable_name_lengths() {
let timing_data = vec![
("60 FPS".to_string(), Duration::from_micros(1234), Duration::from_micros(567)),
("a".to_string(), Duration::from_micros(123), Duration::from_micros(45)),
(
"very_long_system_name".to_string(),
Duration::from_micros(456),
Duration::from_micros(123),
),
("medium".to_string(), Duration::from_micros(789), Duration::from_micros(234)),
];
let result = format_timing_display(timing_data);
println!("Variable name lengths test:");
println!("{}", result);
println!();
}
#[test]
fn test_empty_input() {
let timing_data = vec![];
let result = format_timing_display(timing_data);
assert_eq!(result, "");
println!("Empty input test: PASS");
}
#[test]
fn test_single_entry() {
let timing_data = vec![("60 FPS".to_string(), Duration::from_micros(1234), Duration::from_micros(567))];
let result = format_timing_display(timing_data);
println!("Single entry test:");
println!("{}", result);
println!();
} }

View File

@@ -1,12 +0,0 @@
use pacman::constants::RAW_BOARD;
use pacman::map::builder::Map;
mod item;
#[test]
fn test_game_map_creation() {
let map = Map::new(RAW_BOARD).unwrap();
assert!(map.graph.node_count() > 0);
assert!(!map.grid_to_node.is_empty());
}

View File

@@ -29,7 +29,7 @@ fn test_graph_basic_operations() {
position: glam::Vec2::new(16.0, 0.0), position: glam::Vec2::new(16.0, 0.0),
}); });
assert_eq!(graph.node_count(), 2); assert_eq!(graph.nodes().count(), 2);
assert!(graph.get_node(node1).is_some()); assert!(graph.get_node(node1).is_some());
assert!(graph.get_node(node2).is_some()); assert!(graph.get_node(node2).is_some());
assert!(graph.get_node(999).is_none()); assert!(graph.get_node(999).is_none());
@@ -102,7 +102,7 @@ fn should_add_connected_node() {
) )
.unwrap(); .unwrap();
assert_eq!(graph.node_count(), 2); assert_eq!(graph.nodes().count(), 2);
let edge = graph.find_edge(node1, node2); let edge = graph.find_edge(node1, node2);
assert!(edge.is_some()); assert!(edge.is_some());
assert_eq!(edge.unwrap().direction, Direction::Right); assert_eq!(edge.unwrap().direction, Direction::Right);

View File

@@ -6,7 +6,7 @@ use pacman::map::builder::Map;
fn test_map_creation() { fn test_map_creation() {
let map = Map::new(RAW_BOARD).unwrap(); let map = Map::new(RAW_BOARD).unwrap();
assert!(map.graph.node_count() > 0); assert!(map.graph.nodes().count() > 0);
assert!(!map.grid_to_node.is_empty()); assert!(!map.grid_to_node.is_empty());
// Check that some connections were made // Check that some connections were made

View File

@@ -1,4 +1,4 @@
use pacman::systems::profiling::SystemTimings; use pacman::systems::profiling::{SystemId, SystemTimings};
use std::time::Duration; use std::time::Duration;
#[test] #[test]
@@ -6,12 +6,12 @@ fn test_timing_statistics() {
let timings = SystemTimings::default(); let timings = SystemTimings::default();
// Add some test data // Add some test data
timings.add_timing("test_system", Duration::from_millis(10)); timings.add_timing(SystemId::PlayerControls, Duration::from_millis(10));
timings.add_timing("test_system", Duration::from_millis(12)); timings.add_timing(SystemId::PlayerControls, Duration::from_millis(12));
timings.add_timing("test_system", Duration::from_millis(8)); timings.add_timing(SystemId::PlayerControls, Duration::from_millis(8));
let stats = timings.get_stats(); let stats = timings.get_stats();
let (avg, std_dev) = stats.get("test_system").unwrap(); let (avg, std_dev) = stats.get(&SystemId::PlayerControls).unwrap();
// Average should be 10ms, standard deviation should be small // Average should be 10ms, standard deviation should be small
assert!((avg.as_millis() as f64 - 10.0).abs() < 1.0); assert!((avg.as_millis() as f64 - 10.0).abs() < 1.0);

View File

@@ -1,7 +1,7 @@
import { $ } from "bun"; import { $ } from "bun";
import { existsSync, promises as fs } from "fs"; import { existsSync, promises as fs } from "fs";
import { platform } from "os"; import { platform } from "os";
import { dirname, join, relative, resolve } from "path"; import { basename, dirname, join, relative, resolve } from "path";
import { match, P } from "ts-pattern"; import { match, P } from "ts-pattern";
import { configure, getConsoleSink, getLogger } from "@logtape/logtape"; import { configure, getConsoleSink, getLogger } from "@logtape/logtape";
@@ -79,16 +79,19 @@ async function build(release: boolean, env: Record<string, string> | null) {
// The files to copy into 'dist' // The files to copy into 'dist'
const files = [ const files = [
...["index.html", "favicon.ico", "build.css", "TerminalVector.ttf"].map( ...[
(file) => ({ "index.html",
src: join(siteFolder, file), "favicon.ico",
dest: join(dist, file), "build.css",
optional: false, "../game/TerminalVector.ttf",
}) ].map((file) => ({
), src: resolve(join(siteFolder, file)),
dest: join(dist, basename(file)),
optional: false,
})),
...["pacman.wasm", "pacman.js", "deps/pacman.data"].map((file) => ({ ...["pacman.wasm", "pacman.js", "deps/pacman.data"].map((file) => ({
src: join(outputFolder, file), src: join(outputFolder, file),
dest: join(dist, file.split("/").pop() || file), dest: join(dist, basename(file)),
optional: false, optional: false,
})), })),
{ {