Compare commits

...

12 Commits

Author SHA1 Message Date
Ryan Walters
f14b3d38a4 feat: create hud rendering system 2025-08-27 22:55:26 -05:00
Ryan Walters
bf65c34b28 chore: remove unused code 2025-08-27 22:43:21 -05:00
Ryan Walters
89b0790f19 chore: fix clippy lints 2025-08-27 22:28:14 -05:00
Ryan Walters
9624bcf359 feat: collision helper, ghost/pacman collision events, collision tests
minor format updates from copilot's commit
2025-08-27 22:26:49 -05:00
Copilot
67a5c4a1ed Remove 9 redundant and non-valuable tests to improve test suite quality (#4)
* Initial plan

* Remove 9 redundant and non-valuable tests across events, formatting, and item modules

Co-authored-by: Xevion <44609630+Xevion@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Xevion <44609630+Xevion@users.noreply.github.com>
2025-08-19 13:07:14 -05:00
Ryan Walters
8b5e66f514 refactor: update debug state management and rendering systems 2025-08-19 11:31:31 -05:00
Ryan
5109457fcd test: add input tests 2025-08-19 09:40:59 -05:00
Ryan
5497e4b0b9 feat: improve input system to handle multiple keypress & release states 2025-08-19 09:35:55 -05:00
d72b6eec06 test: add item testing 2025-08-18 09:32:35 -05:00
ae42f6ead0 chore: solve clippy warnings 2025-08-18 00:06:47 -05:00
471b118efd test: add tests for item systems & movement types 2025-08-18 00:04:07 -05:00
13a9c165f7 test: add player control & movement system testing 2025-08-18 00:03:29 -05:00
19 changed files with 1426 additions and 417 deletions

View File

@@ -1,6 +1,5 @@
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use glam::Vec2;
use sdl2::render::TextureCreator; use sdl2::render::TextureCreator;
use sdl2::ttf::Sdl2TtfContext; use sdl2::ttf::Sdl2TtfContext;
use sdl2::video::WindowContext; use sdl2::video::WindowContext;
@@ -21,7 +20,6 @@ pub struct App {
pub game: Game, pub game: Game,
last_tick: Instant, last_tick: Instant,
focused: bool, focused: bool,
_cursor_pos: Vec2,
} }
impl App { impl App {
@@ -81,7 +79,6 @@ impl App {
game, game,
focused: true, focused: true,
last_tick: Instant::now(), last_tick: Instant::now(),
_cursor_pos: Vec2::ZERO,
}) })
} }

View File

@@ -25,7 +25,9 @@ use crate::systems::{
item::item_system, item::item_system,
player::player_control_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, hud_render_system, render_system, BackbufferResource, MapTextureResource,
},
}; };
use crate::texture::animated::AnimatedTexture; use crate::texture::animated::AnimatedTexture;
use bevy_ecs::event::EventRegistry; use bevy_ecs::event::EventRegistry;
@@ -243,22 +245,19 @@ impl Game {
profile(SystemId::Blinking, blinking_system), profile(SystemId::Blinking, blinking_system),
profile(SystemId::DirectionalRender, directional_render_system), profile(SystemId::DirectionalRender, directional_render_system),
profile(SystemId::DirtyRender, dirty_render_system), profile(SystemId::DirtyRender, dirty_render_system),
profile(SystemId::HudRender, hud_render_system),
profile(SystemId::Render, render_system), profile(SystemId::Render, render_system),
profile(SystemId::DebugRender, debug_render_system), profile(SystemId::DebugRender, debug_render_system),
profile( profile(
SystemId::Present, SystemId::Present,
|mut canvas: NonSendMut<&mut Canvas<Window>>, |mut canvas: NonSendMut<&mut Canvas<Window>>, debug_state: Res<DebugState>, mut dirty: ResMut<RenderDirty>| {
backbuffer: NonSendMut<BackbufferResource>, if dirty.0 || debug_state.enabled {
debug_state: Res<DebugState>,
mut dirty: ResMut<RenderDirty>| {
if dirty.0 || *debug_state != DebugState::Off {
// Only copy backbuffer to main canvas if debug rendering is off // Only copy backbuffer to main canvas if debug rendering is off
// (debug rendering draws directly to main canvas) // (debug rendering draws directly to main canvas)
if *debug_state == DebugState::Off { if !debug_state.enabled {
canvas.copy(&backbuffer.0, None, None).unwrap(); canvas.present();
} }
dirty.0 = false; dirty.0 = false;
canvas.present();
} }
}, },
), ),
@@ -449,112 +448,6 @@ impl Game {
state.exit state.exit
} }
// fn check_collisions(&mut self) {
// // Check Pac-Man vs Items
// let potential_collisions = self
// .state
// .collision_system
// .potential_collisions(&self.state.pacman.position());
// for entity_id in potential_collisions {
// if entity_id != self.state.pacman_id {
// // Check if this is an item collision
// if let Some(item_index) = self.find_item_by_id(entity_id) {
// let item = &mut self.state.items[item_index];
// if !item.is_collected() {
// item.collect();
// self.state.score += item.get_score();
// self.state.audio.eat();
// // Handle energizer effects
// if matches!(item.item_type, crate::entity::item::ItemType::Energizer) {
// // TODO: Make ghosts frightened
// tracing::info!("Energizer collected! Ghosts should become frightened.");
// }
// }
// }
// // Check if this is a ghost collision
// if let Some(_ghost_index) = self.find_ghost_by_id(entity_id) {
// // TODO: Handle Pac-Man being eaten by ghost
// tracing::info!("Pac-Man collided with ghost!");
// }
// }
// }
// }
// fn find_item_by_id(&self, entity_id: EntityId) -> Option<usize> {
// self.state.item_ids.iter().position(|&id| id == entity_id)
// }
// fn find_ghost_by_id(&self, entity_id: EntityId) -> Option<usize> {
// self.state.ghost_ids.iter().position(|&id| id == entity_id)
// }
// pub fn draw<T: sdl2::render::RenderTarget>(&mut self, canvas: &mut Canvas<T>, backbuffer: &mut Texture) -> GameResult<()> {
// // Only render the map texture once and cache it
// if !self.state.map_rendered {
// let mut map_texture = self
// .state
// .texture_creator
// .create_texture_target(None, constants::CANVAS_SIZE.x, constants::CANVAS_SIZE.y)
// .map_err(|e| crate::error::GameError::Sdl(e.to_string()))?;
// canvas
// .with_texture_canvas(&mut map_texture, |map_canvas| {
// let mut map_tiles = Vec::with_capacity(35);
// for i in 0..35 {
// let tile_name = format!("maze/tiles/{}.png", i);
// let tile = SpriteAtlas::get_tile(&self.state.atlas, &tile_name).unwrap();
// map_tiles.push(tile);
// }
// MapRenderer::render_map(map_canvas, &mut self.state.atlas, &mut map_tiles);
// })
// .map_err(|e| crate::error::GameError::Sdl(e.to_string()))?;
// self.state.map_texture = Some(map_texture);
// self.state.map_rendered = true;
// }
// canvas.set_draw_color(Color::BLACK);
// canvas.clear();
// if let Some(ref map_texture) = self.state.map_texture {
// canvas.copy(map_texture, None, None).unwrap();
// }
// // Render all items
// for item in &self.state.items {
// if let Err(e) = item.render(canvas, &mut self.state.atlas, &self.state.map.graph) {
// tracing::error!("Failed to render item: {}", e);
// }
// }
// // Render all ghosts
// for ghost in &self.state.ghosts {
// if let Err(e) = ghost.render(canvas, &mut self.state.atlas, &self.state.map.graph) {
// tracing::error!("Failed to render ghost: {}", e);
// }
// }
// if let Err(e) = self.state.pacman.render(canvas, &mut self.state.atlas, &self.state.map.graph) {
// tracing::error!("Failed to render pacman: {}", e);
// }
// if self.state.debug_mode {
// if let Err(e) =
// self.state
// .map
// .debug_render_with_cursor(canvas, &mut self.state.text_texture, &mut self.state.atlas, cursor_pos)
// {
// tracing::error!("Failed to render debug cursor: {}", e);
// }
// self.render_pathfinding_debug(canvas)?;
// }
// self.draw_hud(canvas)?;
// canvas.present();
// Ok(())
// }
// /// Renders pathfinding debug lines from each ghost to Pac-Man. // /// Renders pathfinding debug lines from each ghost to Pac-Man.
// /// // ///
// /// Each ghost's path is drawn in its respective color with a small offset // /// Each ghost's path is drawn in its respective color with a small offset
@@ -618,42 +511,4 @@ impl Game {
// Ok(()) // Ok(())
// } // }
// fn draw_hud<T: sdl2::render::RenderTarget>(&mut self, canvas: &mut Canvas<T>) -> GameResult<()> {
// let lives = 3;
// let score_text = format!("{:02}", self.state.score);
// let x_offset = 4;
// let y_offset = 2;
// let lives_offset = 3;
// let score_offset = 7 - (score_text.len() as i32);
// self.state.text_texture.set_scale(1.0);
// if let Err(e) = self.state.text_texture.render(
// canvas,
// &mut self.state.atlas,
// &format!("{lives}UP HIGH SCORE "),
// glam::UVec2::new(8 * lives_offset as u32 + x_offset, y_offset),
// ) {
// tracing::error!("Failed to render HUD text: {}", e);
// }
// if let Err(e) = self.state.text_texture.render(
// canvas,
// &mut self.state.atlas,
// &score_text,
// glam::UVec2::new(8 * score_offset as u32 + x_offset, 8 + y_offset),
// ) {
// tracing::error!("Failed to render score text: {}", e);
// }
// // Display FPS information in top-left corner
// // let fps_text = format!("FPS: {:.1} (1s) / {:.1} (10s)", self.fps_1s, self.fps_10s);
// // self.render_text_on(
// // canvas,
// // &*texture_creator,
// // &fps_text,
// // IVec2::new(10, 10),
// // Color::RGB(255, 255, 0), // Yellow color for FPS display
// // );
// Ok(())
// }
} }

View File

@@ -28,7 +28,6 @@ pub trait CommonPlatform {
fn get_canvas_size(&self) -> Option<(u32, u32)>; fn get_canvas_size(&self) -> Option<(u32, u32)>;
/// Loads raw asset data using the appropriate platform-specific method. /// Loads raw asset data using the appropriate platform-specific method.
fn get_asset_bytes(&self, asset: Asset) -> Result<Cow<'static, [u8]>, AssetError>; fn get_asset_bytes(&self, asset: Asset) -> Result<Cow<'static, [u8]>, AssetError>;
} }

View File

@@ -6,9 +6,28 @@ use bevy_ecs::system::{Query, Res};
use crate::error::GameError; use crate::error::GameError;
use crate::events::GameEvent; use crate::events::GameEvent;
use crate::map::builder::Map; use crate::map::builder::Map;
use crate::systems::components::{Collider, ItemCollider, PacmanCollider}; use crate::systems::components::{Collider, GhostCollider, ItemCollider, PacmanCollider};
use crate::systems::movement::Position; use crate::systems::movement::Position;
/// Helper function to check collision between two entities with colliders.
pub fn check_collision(
pos1: &Position,
collider1: &Collider,
pos2: &Position,
collider2: &Collider,
map: &Map,
) -> Result<bool, GameError> {
let pixel1 = pos1
.get_pixel_position(&map.graph)
.map_err(|e| GameError::InvalidState(format!("Failed to get pixel position for entity 1: {}", e)))?;
let pixel2 = pos2
.get_pixel_position(&map.graph)
.map_err(|e| GameError::InvalidState(format!("Failed to get pixel position for entity 2: {}", e)))?;
let distance = pixel1.distance(pixel2);
Ok(collider1.collides_with(collider2.size, distance))
}
/// Detects overlapping entities and generates collision events for gameplay systems. /// Detects overlapping entities and generates collision events for gameplay systems.
/// ///
/// Performs distance-based collision detection between Pac-Man and collectible items /// Performs distance-based collision detection between Pac-Man and collectible items
@@ -16,43 +35,50 @@ use crate::systems::movement::Position;
/// a `GameEvent::Collision` for the item system to handle scoring and removal. /// a `GameEvent::Collision` for the item system to handle scoring and removal.
/// Collision detection accounts for both entities being in motion and supports /// Collision detection accounts for both entities being in motion and supports
/// circular collision boundaries for accurate gameplay feel. /// circular collision boundaries for accurate gameplay feel.
///
/// Also detects collisions between Pac-Man and ghosts for gameplay mechanics like
/// power pellet effects, ghost eating, and player death.
pub fn collision_system( pub fn collision_system(
map: Res<Map>, map: Res<Map>,
pacman_query: Query<(Entity, &Position, &Collider), With<PacmanCollider>>, pacman_query: Query<(Entity, &Position, &Collider), With<PacmanCollider>>,
item_query: Query<(Entity, &Position, &Collider), With<ItemCollider>>, item_query: Query<(Entity, &Position, &Collider), With<ItemCollider>>,
ghost_query: Query<(Entity, &Position, &Collider), With<GhostCollider>>,
mut events: EventWriter<GameEvent>, mut events: EventWriter<GameEvent>,
mut errors: EventWriter<GameError>, mut errors: EventWriter<GameError>,
) { ) {
// 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 ( match check_collision(pacman_pos, pacman_collider, item_pos, item_collider, &map) {
pacman_pos.get_pixel_position(&map.graph), Ok(colliding) => {
item_pos.get_pixel_position(&map.graph), if colliding {
) {
(Ok(pacman_pixel), Ok(item_pixel)) => {
// Calculate the distance between the two entities's precise pixel positions
let distance = pacman_pixel.distance(item_pixel);
// Calculate the distance at which the two entities will collide
let collision_distance = (pacman_collider.size + item_collider.size) / 2.0;
// If the distance between the two entities is less than the collision distance, then the two entities are colliding
if distance < collision_distance {
events.write(GameEvent::Collision(pacman_entity, item_entity)); events.write(GameEvent::Collision(pacman_entity, item_entity));
} }
} }
// Either or both of the pixel positions failed to get, so we need to report the error Err(e) => {
(result_a, result_b) => {
for result in [result_a, result_b] {
if let Err(e) = result {
errors.write(GameError::InvalidState(format!( errors.write(GameError::InvalidState(format!(
"Collision system failed to get pixel positions for entities {:?} and {:?}: {}", "Collision system failed to check collision between entities {:?} and {:?}: {}",
pacman_entity, item_entity, e pacman_entity, item_entity, e
))); )));
} }
} }
} }
// Check PACMAN × GHOST collisions
for (ghost_entity, ghost_pos, ghost_collider) in ghost_query.iter() {
match check_collision(pacman_pos, pacman_collider, ghost_pos, ghost_collider, &map) {
Ok(colliding) => {
if colliding {
events.write(GameEvent::Collision(pacman_entity, ghost_entity));
}
}
Err(e) => {
errors.write(GameError::InvalidState(format!(
"Collision system failed to check collision between entities {:?} and {:?}: {}",
pacman_entity, ghost_entity, e
)));
}
}
}
} }
} }
}
}

View File

@@ -70,6 +70,17 @@ impl EntityType {
_ => TraversalFlags::empty(), // Static entities don't traverse _ => TraversalFlags::empty(), // Static entities don't traverse
} }
} }
pub fn score_value(&self) -> Option<u32> {
match self {
EntityType::Pellet => Some(10),
EntityType::PowerPellet => Some(50),
_ => None,
}
}
pub fn is_collectible(&self) -> bool {
matches!(self, EntityType::Pellet | EntityType::PowerPellet)
}
} }
/// A component for entities that have a sprite, with a layer for ordering. /// A component for entities that have a sprite, with a layer for ordering.
@@ -103,6 +114,14 @@ pub struct Collider {
pub size: f32, pub size: f32,
} }
impl Collider {
/// Checks if this collider collides with another collider at the given distance.
pub fn collides_with(&self, other_size: f32, distance: f32) -> bool {
let collision_distance = (self.size + other_size) / 2.0;
distance < collision_distance
}
}
/// Marker components for collision filtering optimization /// Marker components for collision filtering optimization
#[derive(Component)] #[derive(Component)]
pub struct PacmanCollider; pub struct PacmanCollider;

View File

@@ -16,22 +16,13 @@ use sdl2::render::{Canvas, Texture, TextureCreator};
use sdl2::ttf::Font; 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)]
pub enum DebugState { pub struct DebugState {
#[default] pub enabled: bool,
Off,
Graph,
Collision,
} }
impl DebugState { fn f32_to_u8(value: f32) -> u8 {
pub fn next(&self) -> Self { (value * 255.0) as u8
match self {
DebugState::Off => DebugState::Graph,
DebugState::Graph => DebugState::Collision,
DebugState::Collision => DebugState::Off,
}
}
} }
/// Resource to hold the debug texture for persistent rendering /// Resource to hold the debug texture for persistent rendering
@@ -110,7 +101,7 @@ pub fn debug_render_system(
colliders: Query<(&Collider, &Position)>, colliders: Query<(&Collider, &Position)>,
cursor: Res<CursorPosition>, cursor: Res<CursorPosition>,
) { ) {
if *debug_state == DebugState::Off { if !debug_state.enabled {
return; return;
} }
let scale = let scale =
@@ -140,8 +131,6 @@ pub fn debug_render_system(
// 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 {
DebugState::Graph => {
// Find the closest node to the cursor // Find the closest node to the cursor
let closest_node = if let Some(cursor_world_pos) = cursor_world_pos { let closest_node = if let Some(cursor_world_pos) = cursor_world_pos {
@@ -155,7 +144,23 @@ pub fn debug_render_system(
None None
}; };
debug_canvas.set_draw_color(Color::RED); debug_canvas.set_draw_color(Color::GREEN);
for (collider, position) in colliders.iter() {
let pos = position.get_pixel_position(&map.graph).unwrap();
// Transform position and size using common methods
let pos = (pos * scale).as_ivec2();
let size = (collider.size * scale) as u32;
let rect = Rect::from_center(Point::from((pos.x, pos.y)), size, size);
debug_canvas.draw_rect(rect).unwrap();
}
debug_canvas.set_draw_color(Color {
a: f32_to_u8(0.4),
..Color::RED
});
debug_canvas.set_blend_mode(sdl2::render::BlendMode::Blend);
for (start_node, end_node) in map.graph.edges() { for (start_node, end_node) in map.graph.edges() {
let start_node_model = map.graph.get_node(start_node).unwrap(); 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;
@@ -173,15 +178,18 @@ pub fn debug_render_system(
let pos = node.position; let pos = node.position;
// Set color based on whether the node is the closest to the cursor // Set color based on whether the node is the closest to the cursor
debug_canvas.set_draw_color(if Some(id) == closest_node { debug_canvas.set_draw_color(Color {
a: f32_to_u8(if Some(id) == closest_node { 0.75 } else { 0.6 }),
..(if Some(id) == closest_node {
Color::YELLOW Color::YELLOW
} else { } else {
Color::BLUE Color::BLUE
})
}); });
// Transform position using common method // Transform position using common method
let pos = transform_position_with_offset(pos, scale); let pos = transform_position_with_offset(pos, scale);
let size = (3.0 * scale) as u32; let size = (2.0 * scale) as u32;
debug_canvas debug_canvas
.fill_rect(Rect::new(pos.x - (size as i32 / 2), pos.y - (size as i32 / 2), size, size)) .fill_rect(Rect::new(pos.x - (size as i32 / 2), pos.y - (size as i32 / 2), size, size))
@@ -193,27 +201,17 @@ pub fn debug_render_system(
let node = map.graph.get_node(closest_node_id).unwrap(); let node = map.graph.get_node(closest_node_id).unwrap();
let pos = transform_position_with_offset(node.position, scale); let pos = transform_position_with_offset(node.position, scale);
let surface = font.render(&closest_node_id.to_string()).blended(Color::WHITE).unwrap(); let surface = font
.render(&closest_node_id.to_string())
.blended(Color {
a: f32_to_u8(0.4),
..Color::WHITE
})
.unwrap();
let texture = texture_creator.create_texture_from_surface(&surface).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); let dest = Rect::new(pos.x + 10, pos.y - 5, texture.query().width, texture.query().height);
debug_canvas.copy(&texture, None, dest).unwrap(); debug_canvas.copy(&texture, None, dest).unwrap();
} }
}
DebugState::Collision => {
debug_canvas.set_draw_color(Color::GREEN);
for (collider, position) in colliders.iter() {
let pos = position.get_pixel_position(&map.graph).unwrap();
// Transform position and size using common methods
let pos = (pos * scale).as_ivec2();
let size = (collider.size * scale) as u32;
let rect = Rect::from_center(Point::from((pos.x, pos.y)), size, size);
debug_canvas.draw_rect(rect).unwrap();
}
}
_ => {}
}
// 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, font); render_timing_display(debug_canvas, &mut texture_creator, &timings, font);
@@ -222,4 +220,5 @@ pub fn debug_render_system(
// Draw the debug texture directly onto the main canvas at full resolution // Draw the debug texture directly onto the main canvas at full resolution
canvas.copy(&debug_texture.0, None, None).unwrap(); canvas.copy(&debug_texture.0, None, None).unwrap();
canvas.present();
} }

View File

@@ -28,7 +28,7 @@ pub enum CursorPosition {
pub struct Bindings { pub struct Bindings {
key_bindings: HashMap<Keycode, GameCommand>, key_bindings: HashMap<Keycode, GameCommand>,
movement_keys: HashSet<Keycode>, movement_keys: HashSet<Keycode>,
last_movement_key: Option<Keycode>, pressed_movement_keys: Vec<Keycode>,
} }
impl Default for Bindings { impl Default for Bindings {
@@ -67,11 +67,63 @@ impl Default for Bindings {
Self { Self {
key_bindings, key_bindings,
movement_keys, movement_keys,
last_movement_key: None, pressed_movement_keys: Vec::new(),
} }
} }
} }
/// A simplified input event used for deterministic testing and logic reuse
/// without depending on SDL's event pump.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SimpleKeyEvent {
KeyDown(Keycode),
KeyUp(Keycode),
}
/// Processes a frame's worth of simplified key events and returns the resulting
/// `GameEvent`s that would be emitted by the input system for that frame.
///
/// This mirrors the behavior of `input_system` for keyboard-related logic:
/// - KeyDown emits the bound command immediately (movement or otherwise)
/// - Tracks pressed movement keys in order to continue movement on subsequent frames
/// - KeyUp removes movement keys; if another movement key remains, it resumes
pub fn process_simple_key_events(bindings: &mut Bindings, frame_events: &[SimpleKeyEvent]) -> Vec<GameEvent> {
let mut emitted_events = Vec::new();
let mut movement_key_pressed = false;
for event in frame_events {
match *event {
SimpleKeyEvent::KeyDown(key) => {
if let Some(command) = bindings.key_bindings.get(&key).copied() {
emitted_events.push(GameEvent::Command(command));
}
if bindings.movement_keys.contains(&key) {
movement_key_pressed = true;
if !bindings.pressed_movement_keys.contains(&key) {
bindings.pressed_movement_keys.push(key);
}
}
}
SimpleKeyEvent::KeyUp(key) => {
if bindings.movement_keys.contains(&key) {
bindings.pressed_movement_keys.retain(|&k| k != key);
}
}
}
}
if !movement_key_pressed {
if let Some(&last_movement_key) = bindings.pressed_movement_keys.last() {
if let Some(command) = bindings.key_bindings.get(&last_movement_key).copied() {
emitted_events.push(GameEvent::Command(command));
}
}
}
emitted_events
}
pub fn input_system( pub fn input_system(
delta_time: Res<DeltaTime>, delta_time: Res<DeltaTime>,
mut bindings: ResMut<Bindings>, mut bindings: ResMut<Bindings>,
@@ -79,11 +131,14 @@ pub fn input_system(
mut pump: NonSendMut<&'static mut EventPump>, mut pump: NonSendMut<&'static mut EventPump>,
mut cursor: ResMut<CursorPosition>, mut cursor: ResMut<CursorPosition>,
) { ) {
let mut movement_key_pressed = false;
let mut cursor_seen = false; let mut cursor_seen = false;
// Collect all events for this frame.
let frame_events: Vec<Event> = pump.poll_iter().collect();
for event in pump.poll_iter() { // Handle non-keyboard events inline and build a simplified keyboard event stream.
match event { let mut simple_key_events = Vec::new();
for event in &frame_events {
match *event {
Event::Quit { .. } => { Event::Quit { .. } => {
writer.write(GameEvent::Command(GameCommand::Exit)); writer.write(GameEvent::Command(GameCommand::Exit));
} }
@@ -94,44 +149,28 @@ pub fn input_system(
}; };
cursor_seen = true; 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 { Event::KeyDown {
keycode: Some(key), keycode: Some(key),
repeat: false, repeat: false,
.. ..
} => { } => {
let command = bindings.key_bindings.get(&key).copied(); simple_key_events.push(SimpleKeyEvent::KeyDown(key));
if let Some(command) = command {
writer.write(GameEvent::Command(command));
}
if bindings.movement_keys.contains(&key) {
movement_key_pressed = true;
bindings.last_movement_key = Some(key);
} }
Event::KeyUp {
keycode: Some(key),
repeat: false,
..
} => {
simple_key_events.push(SimpleKeyEvent::KeyUp(key));
} }
_ => {} _ => {}
} }
} }
if let Some(last_movement_key) = bindings.last_movement_key { // Delegate keyboard handling to shared logic used by tests and production.
if !movement_key_pressed { let emitted = process_simple_key_events(&mut bindings, &simple_key_events);
let command = bindings.key_bindings.get(&last_movement_key).copied(); for event in emitted {
if let Some(command) = command { writer.write(event);
writer.write(GameEvent::Command(command));
}
}
} }
if let (false, CursorPosition::Some { remaining_time, .. }) = (cursor_seen, &mut *cursor) { if let (false, CursorPosition::Some { remaining_time, .. }) = (cursor_seen, &mut *cursor) {

View File

@@ -8,6 +8,17 @@ use crate::{
}, },
}; };
/// Determines if a collision between two entity types should be handled by the item system.
///
/// Returns `true` if one entity is a player and the other is a collectible item.
#[allow(dead_code)]
pub fn is_valid_item_collision(entity1: EntityType, entity2: EntityType) -> bool {
match (entity1, entity2) {
(EntityType::Player, entity) | (entity, EntityType::Player) => entity.is_collectible(),
_ => false,
}
}
pub fn item_system( pub fn item_system(
mut commands: Commands, mut commands: Commands,
mut collision_events: EventReader<GameEvent>, mut collision_events: EventReader<GameEvent>,
@@ -29,21 +40,18 @@ pub fn item_system(
// Get the item type and update score // Get the item type and update score
if let Ok((item_ent, entity_type)) = item_query.get(item_entity) { if let Ok((item_ent, entity_type)) = item_query.get(item_entity) {
match entity_type { if let Some(score_value) = entity_type.score_value() {
EntityType::Pellet => { score.0 += score_value;
score.0 += 10;
}
EntityType::PowerPellet => {
score.0 += 50;
}
_ => continue,
}
// Remove the collected item // Remove the collected item
commands.entity(item_ent).despawn(); commands.entity(item_ent).despawn();
// Trigger audio if appropriate
if entity_type.is_collectible() {
events.write(AudioEvent::PlayEat); events.write(AudioEvent::PlayEat);
} }
} }
} }
} }
}
}

View File

@@ -57,7 +57,7 @@ pub fn player_control_system(
state.exit = true; state.exit = true;
} }
GameCommand::ToggleDebug => { GameCommand::ToggleDebug => {
*debug_state = debug_state.next(); debug_state.enabled = !debug_state.enabled;
} }
GameCommand::MuteAudio => { GameCommand::MuteAudio => {
audio_state.muted = !audio_state.muted; audio_state.muted = !audio_state.muted;

View File

@@ -27,6 +27,7 @@ pub enum SystemId {
Blinking, Blinking,
DirectionalRender, DirectionalRender,
DirtyRender, DirtyRender,
HudRender,
Render, Render,
DebugRender, DebugRender,
Present, Present,

View File

@@ -1,8 +1,9 @@
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, ScoreResource};
use crate::systems::movement::{Position, Velocity}; use crate::systems::movement::{Position, Velocity};
use crate::texture::sprite::SpriteAtlas; use crate::texture::sprite::SpriteAtlas;
use crate::texture::text::TextTexture;
use bevy_ecs::entity::Entity; use bevy_ecs::entity::Entity;
use bevy_ecs::event::EventWriter; use bevy_ecs::event::EventWriter;
use bevy_ecs::prelude::{Changed, Or, RemovedComponents}; use bevy_ecs::prelude::{Changed, Or, RemovedComponents};
@@ -61,6 +62,34 @@ 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>);
/// Renders the HUD (score, lives, etc.) on top of the game.
pub fn hud_render_system(
mut canvas: NonSendMut<&mut Canvas<Window>>,
mut atlas: NonSendMut<SpriteAtlas>,
score: Res<ScoreResource>,
mut errors: EventWriter<GameError>,
) {
let mut text_renderer = TextTexture::new(1.0);
// Render lives and high score text
let lives = 3; // TODO: Get from actual lives resource
let lives_text = format!("{lives}UP HIGH SCORE ");
let lives_position = glam::UVec2::new(4 + 8 * 3, 2); // x_offset + lives_offset * 8, y_offset
if let Err(e) = text_renderer.render(&mut canvas, &mut atlas, &lives_text, lives_position) {
errors.write(TextureError::RenderFailed(format!("Failed to render lives text: {}", e)).into());
}
// Render score text
let score_text = format!("{:02}", score.0);
let score_offset = 7 - (score_text.len() as i32);
let score_position = glam::UVec2::new(4 + 8 * score_offset as u32, 10); // x_offset + score_offset * 8, 8 + y_offset
if let Err(e) = text_renderer.render(&mut canvas, &mut atlas, &score_text, score_position) {
errors.write(TextureError::RenderFailed(format!("Failed to render score text: {}", e)).into());
}
}
#[allow(clippy::too_many_arguments)] #[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>>,
@@ -120,4 +149,6 @@ pub fn render_system(
}) })
.err() .err()
.map(|e| errors.write(TextureError::RenderFailed(e.to_string()).into())); .map(|e| errors.write(TextureError::RenderFailed(e.to_string()).into()));
canvas.copy(&backbuffer.0, None, None).unwrap();
} }

150
tests/collision.rs Normal file
View File

@@ -0,0 +1,150 @@
use bevy_ecs::{event::Events, prelude::*, system::RunSystemOnce, world::World};
use pacman::{
error::GameError,
events::GameEvent,
map::builder::Map,
systems::{
collision::{check_collision, collision_system},
components::{Collider, EntityType, Ghost, GhostCollider, ItemCollider, PacmanCollider},
movement::Position,
},
};
fn create_test_world() -> World {
let mut world = World::new();
// Add required resources
world.insert_resource(Events::<GameEvent>::default());
world.insert_resource(Events::<GameError>::default());
// Add a minimal test map
world.insert_resource(create_test_map());
world
}
fn create_test_map() -> Map {
use pacman::constants::RAW_BOARD;
Map::new(RAW_BOARD).expect("Failed to create test map")
}
fn spawn_test_pacman(world: &mut World) -> Entity {
world
.spawn((Position::Stopped { node: 0 }, Collider { size: 10.0 }, PacmanCollider))
.id()
}
fn spawn_test_item(world: &mut World) -> Entity {
world
.spawn((
Position::Stopped { node: 0 },
Collider { size: 8.0 },
ItemCollider,
EntityType::Pellet,
))
.id()
}
fn spawn_test_ghost(world: &mut World) -> Entity {
world
.spawn((
Position::Stopped { node: 0 },
Collider { size: 12.0 },
GhostCollider,
Ghost::Blinky,
EntityType::Ghost,
))
.id()
}
fn spawn_test_ghost_at_node(world: &mut World, node: usize) -> Entity {
world
.spawn((
Position::Stopped { node },
Collider { size: 12.0 },
GhostCollider,
Ghost::Blinky,
EntityType::Ghost,
))
.id()
}
#[test]
fn test_collider_collision_detection() {
let collider1 = Collider { size: 10.0 };
let collider2 = Collider { size: 8.0 };
// Test collision detection
assert!(collider1.collides_with(collider2.size, 5.0)); // Should collide (distance < 9.0)
assert!(!collider1.collides_with(collider2.size, 15.0)); // Should not collide (distance > 9.0)
}
#[test]
fn test_check_collision_helper() {
let map = create_test_map();
let pos1 = Position::Stopped { node: 0 };
let pos2 = Position::Stopped { node: 0 }; // Same position
let collider1 = Collider { size: 10.0 };
let collider2 = Collider { size: 8.0 };
// Test collision at same position
let result = check_collision(&pos1, &collider1, &pos2, &collider2, &map);
assert!(result.is_ok());
assert!(result.unwrap()); // Should collide at same position
// Test collision at different positions
let pos3 = Position::Stopped { node: 1 }; // Different position
let result = check_collision(&pos1, &collider1, &pos3, &collider2, &map);
assert!(result.is_ok());
// May or may not collide depending on actual node positions
}
#[test]
fn test_collision_system_pacman_item() {
let mut world = create_test_world();
let _pacman = spawn_test_pacman(&mut world);
let _item = spawn_test_item(&mut world);
// Run collision system - should not panic
world
.run_system_once(collision_system)
.expect("System should run successfully");
}
#[test]
fn test_collision_system_pacman_ghost() {
let mut world = create_test_world();
let _pacman = spawn_test_pacman(&mut world);
let _ghost = spawn_test_ghost(&mut world);
// Run collision system - should not panic
world
.run_system_once(collision_system)
.expect("System should run successfully");
}
#[test]
fn test_collision_system_no_collision() {
let mut world = create_test_world();
let _pacman = spawn_test_pacman(&mut world);
let _ghost = spawn_test_ghost_at_node(&mut world, 1); // Different node
// Run collision system - should not panic
world
.run_system_once(collision_system)
.expect("System should run successfully");
}
#[test]
fn test_collision_system_multiple_entities() {
let mut world = create_test_world();
let _pacman = spawn_test_pacman(&mut world);
let _item = spawn_test_item(&mut world);
let _ghost = spawn_test_ghost(&mut world);
// Run collision system - should not panic
world
.run_system_once(collision_system)
.expect("System should run successfully");
}

View File

@@ -1,72 +1,6 @@
use pacman::events::{GameCommand, GameEvent}; use pacman::events::{GameCommand, GameEvent};
use pacman::map::direction::Direction; use pacman::map::direction::Direction;
#[test]
fn test_game_command_variants() {
// Test that all GameCommand variants can be created
let commands = vec![
GameCommand::Exit,
GameCommand::MovePlayer(Direction::Up),
GameCommand::MovePlayer(Direction::Down),
GameCommand::MovePlayer(Direction::Left),
GameCommand::MovePlayer(Direction::Right),
GameCommand::ToggleDebug,
GameCommand::MuteAudio,
GameCommand::ResetLevel,
GameCommand::TogglePause,
];
// Just verify they can be created and compared
assert_eq!(commands.len(), 9);
assert_eq!(commands[0], GameCommand::Exit);
assert_eq!(commands[1], GameCommand::MovePlayer(Direction::Up));
}
#[test]
fn test_game_command_equality() {
assert_eq!(GameCommand::Exit, GameCommand::Exit);
assert_eq!(GameCommand::ToggleDebug, GameCommand::ToggleDebug);
assert_eq!(
GameCommand::MovePlayer(Direction::Left),
GameCommand::MovePlayer(Direction::Left)
);
assert_ne!(GameCommand::Exit, GameCommand::ToggleDebug);
assert_ne!(
GameCommand::MovePlayer(Direction::Left),
GameCommand::MovePlayer(Direction::Right)
);
}
#[test]
fn test_game_command_copy_clone() {
let original = GameCommand::MovePlayer(Direction::Up);
let copied = original;
let cloned = original.clone();
assert_eq!(original, copied);
assert_eq!(original, cloned);
assert_eq!(copied, cloned);
}
#[test]
fn test_game_event_variants() {
let command_event = GameEvent::Command(GameCommand::Exit);
let collision_event = GameEvent::Collision(bevy_ecs::entity::Entity::from_raw(1), bevy_ecs::entity::Entity::from_raw(2));
// Test that events can be created and compared
assert_eq!(command_event, GameEvent::Command(GameCommand::Exit));
assert_ne!(command_event, collision_event);
}
#[test]
fn test_game_command_to_game_event_conversion() {
let command = GameCommand::ToggleDebug;
let event: GameEvent = command.into();
assert_eq!(event, GameEvent::Command(GameCommand::ToggleDebug));
}
#[test] #[test]
fn test_game_command_to_game_event_conversion_all_variants() { fn test_game_command_to_game_event_conversion_all_variants() {
let commands = vec![ let commands = vec![
@@ -83,35 +17,3 @@ fn test_game_command_to_game_event_conversion_all_variants() {
assert_eq!(event, GameEvent::Command(command)); assert_eq!(event, GameEvent::Command(command));
} }
} }
#[test]
fn test_move_player_all_directions() {
let directions = [Direction::Up, Direction::Down, Direction::Left, Direction::Right];
for direction in directions {
let command = GameCommand::MovePlayer(direction);
let event: GameEvent = command.into();
if let GameEvent::Command(GameCommand::MovePlayer(dir)) = event {
assert_eq!(dir, direction);
} else {
panic!("Expected MovePlayer command with direction {:?}", direction);
}
}
}
#[test]
fn test_game_event_debug_format() {
let event = GameEvent::Command(GameCommand::Exit);
let debug_str = format!("{:?}", event);
assert!(debug_str.contains("Command"));
assert!(debug_str.contains("Exit"));
}
#[test]
fn test_game_command_debug_format() {
let command = GameCommand::MovePlayer(Direction::Left);
let debug_str = format!("{:?}", command);
assert!(debug_str.contains("MovePlayer"));
assert!(debug_str.contains("Left"));
}

View File

@@ -119,13 +119,6 @@ fn test_format_timing_display_basic() {
} }
} }
#[test]
fn test_format_timing_display_empty() {
let timing_data = vec![];
let formatted = format_timing_display(timing_data);
assert!(formatted.is_empty());
}
#[test] #[test]
fn test_format_timing_display_units() { fn test_format_timing_display_units() {
let timing_data = vec![ let timing_data = vec![

26
tests/hud.rs Normal file
View File

@@ -0,0 +1,26 @@
use bevy_ecs::{event::Events, world::World};
use pacman::{error::GameError, systems::components::ScoreResource};
fn create_test_world() -> World {
let mut world = World::new();
// Add required resources
world.insert_resource(Events::<GameError>::default());
world.insert_resource(ScoreResource(1230)); // Test score
world
}
#[test]
fn test_hud_render_system_runs_without_error() {
let world = create_test_world();
// The HUD render system requires SDL2 resources that aren't available in tests,
// but we can at least verify it doesn't panic when called
// In a real test environment, we'd need to mock the SDL2 canvas and atlas
// For now, just verify the score resource is accessible
let score = world.resource::<ScoreResource>();
assert_eq!(score.0, 1230);
}

38
tests/input.rs Normal file
View File

@@ -0,0 +1,38 @@
use pacman::events::{GameCommand, GameEvent};
use pacman::map::direction::Direction;
use pacman::systems::input::{process_simple_key_events, Bindings, SimpleKeyEvent};
use sdl2::keyboard::Keycode;
#[test]
fn resumes_previous_direction_when_secondary_key_released() {
let mut bindings = Bindings::default();
// Frame 1: Press W (Up) => emits Move Up
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::W)]);
assert!(events.contains(&GameEvent::Command(GameCommand::MovePlayer(Direction::Up))));
// Frame 2: Press D (Right) => emits Move Right
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::D)]);
assert!(events.contains(&GameEvent::Command(GameCommand::MovePlayer(Direction::Right))));
// Frame 3: Release D, no new key this frame => should continue previous key W (Up)
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyUp(Keycode::D)]);
assert!(events.contains(&GameEvent::Command(GameCommand::MovePlayer(Direction::Up))));
}
#[test]
fn holds_last_pressed_key_across_frames_when_no_new_input() {
let mut bindings = Bindings::default();
// Frame 1: Press Left
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyDown(Keycode::Left)]);
assert!(events.contains(&GameEvent::Command(GameCommand::MovePlayer(Direction::Left))));
// Frame 2: No input => continues Left
let events = process_simple_key_events(&mut bindings, &[]);
assert!(events.contains(&GameEvent::Command(GameCommand::MovePlayer(Direction::Left))));
// Frame 3: Release Left, no input remains => nothing emitted
let events = process_simple_key_events(&mut bindings, &[SimpleKeyEvent::KeyUp(Keycode::Left)]);
assert!(events.is_empty());
}

255
tests/item.rs Normal file
View File

@@ -0,0 +1,255 @@
use bevy_ecs::{event::Events, prelude::*, system::RunSystemOnce, world::World};
use pacman::{
events::GameEvent,
map::builder::Map,
systems::{
audio::AudioEvent,
components::{AudioState, EntityType, ItemCollider, PacmanCollider, ScoreResource},
item::{is_valid_item_collision, item_system},
movement::Position,
},
};
#[test]
fn test_calculate_score_for_item() {
assert!(EntityType::Pellet.score_value() < EntityType::PowerPellet.score_value());
assert!(EntityType::Pellet.score_value().is_some());
assert!(EntityType::PowerPellet.score_value().is_some());
assert!(EntityType::Player.score_value().is_none());
assert!(EntityType::Ghost.score_value().is_none());
}
#[test]
fn test_is_collectible_item() {
// Collectible
assert!(EntityType::Pellet.is_collectible());
assert!(EntityType::PowerPellet.is_collectible());
// Non-collectible
assert!(!EntityType::Player.is_collectible());
assert!(!EntityType::Ghost.is_collectible());
}
#[test]
fn test_is_valid_item_collision() {
// Player-item collisions should be valid
assert!(is_valid_item_collision(EntityType::Player, EntityType::Pellet));
assert!(is_valid_item_collision(EntityType::Player, EntityType::PowerPellet));
assert!(is_valid_item_collision(EntityType::Pellet, EntityType::Player));
assert!(is_valid_item_collision(EntityType::PowerPellet, EntityType::Player));
// Non-player-item collisions should be invalid
assert!(!is_valid_item_collision(EntityType::Player, EntityType::Ghost));
assert!(!is_valid_item_collision(EntityType::Ghost, EntityType::Pellet));
assert!(!is_valid_item_collision(EntityType::Pellet, EntityType::PowerPellet));
assert!(!is_valid_item_collision(EntityType::Player, EntityType::Player));
}
fn create_test_world() -> World {
let mut world = World::new();
// Add required resources
world.insert_resource(ScoreResource(0));
world.insert_resource(AudioState::default());
world.insert_resource(Events::<GameEvent>::default());
world.insert_resource(Events::<AudioEvent>::default());
world.insert_resource(Events::<pacman::error::GameError>::default());
// Add a minimal test map
world.insert_resource(create_test_map());
world
}
fn create_test_map() -> Map {
use pacman::constants::RAW_BOARD;
Map::new(RAW_BOARD).expect("Failed to create test map")
}
fn spawn_test_pacman(world: &mut World) -> Entity {
world
.spawn((Position::Stopped { node: 0 }, EntityType::Player, PacmanCollider))
.id()
}
fn spawn_test_item(world: &mut World, item_type: EntityType) -> Entity {
world.spawn((Position::Stopped { node: 1 }, item_type, ItemCollider)).id()
}
fn send_collision_event(world: &mut World, entity1: Entity, entity2: Entity) {
let mut events = world.resource_mut::<Events<GameEvent>>();
events.send(GameEvent::Collision(entity1, entity2));
}
#[test]
fn test_item_system_pellet_collection() {
let mut world = create_test_world();
let pacman = spawn_test_pacman(&mut world);
let pellet = spawn_test_item(&mut world, EntityType::Pellet);
// Send collision event
send_collision_event(&mut world, pacman, pellet);
// Run the item system
world.run_system_once(item_system).expect("System should run successfully");
// Check that score was updated
let score = world.resource::<ScoreResource>();
assert_eq!(score.0, 10);
// Check that the pellet was despawned (query should return empty)
let item_count = world
.query::<&EntityType>()
.iter(&world)
.filter(|&entity_type| matches!(entity_type, EntityType::Pellet))
.count();
assert_eq!(item_count, 0);
}
#[test]
fn test_item_system_power_pellet_collection() {
let mut world = create_test_world();
let pacman = spawn_test_pacman(&mut world);
let power_pellet = spawn_test_item(&mut world, EntityType::PowerPellet);
send_collision_event(&mut world, pacman, power_pellet);
world.run_system_once(item_system).expect("System should run successfully");
// Check that score was updated with power pellet value
let score = world.resource::<ScoreResource>();
assert_eq!(score.0, 50);
// Check that the power pellet was despawned (query should return empty)
let item_count = world
.query::<&EntityType>()
.iter(&world)
.filter(|&entity_type| matches!(entity_type, EntityType::PowerPellet))
.count();
assert_eq!(item_count, 0);
}
#[test]
fn test_item_system_multiple_collections() {
let mut world = create_test_world();
let pacman = spawn_test_pacman(&mut world);
let pellet1 = spawn_test_item(&mut world, EntityType::Pellet);
let pellet2 = spawn_test_item(&mut world, EntityType::Pellet);
let power_pellet = spawn_test_item(&mut world, EntityType::PowerPellet);
// Send multiple collision events
send_collision_event(&mut world, pacman, pellet1);
send_collision_event(&mut world, pacman, pellet2);
send_collision_event(&mut world, pacman, power_pellet);
world.run_system_once(item_system).expect("System should run successfully");
// Check final score: 2 pellets (20) + 1 power pellet (50) = 70
let score = world.resource::<ScoreResource>();
assert_eq!(score.0, 70);
// Check that all items were despawned
let pellet_count = world
.query::<&EntityType>()
.iter(&world)
.filter(|&entity_type| matches!(entity_type, EntityType::Pellet))
.count();
let power_pellet_count = world
.query::<&EntityType>()
.iter(&world)
.filter(|&entity_type| matches!(entity_type, EntityType::PowerPellet))
.count();
assert_eq!(pellet_count, 0);
assert_eq!(power_pellet_count, 0);
}
#[test]
fn test_item_system_ignores_non_item_collisions() {
let mut world = create_test_world();
let pacman = spawn_test_pacman(&mut world);
// Create a ghost entity (not an item)
let ghost = world.spawn((Position::Stopped { node: 2 }, EntityType::Ghost)).id();
// Initial score
let initial_score = world.resource::<ScoreResource>().0;
// Send collision event between pacman and ghost
send_collision_event(&mut world, pacman, ghost);
world.run_system_once(item_system).expect("System should run successfully");
// Score should remain unchanged
let score = world.resource::<ScoreResource>();
assert_eq!(score.0, initial_score);
// Ghost should still exist (not despawned)
let ghost_count = world
.query::<&EntityType>()
.iter(&world)
.filter(|&entity_type| matches!(entity_type, EntityType::Ghost))
.count();
assert_eq!(ghost_count, 1);
}
#[test]
fn test_item_system_no_collision_events() {
let mut world = create_test_world();
let _pacman = spawn_test_pacman(&mut world);
let _pellet = spawn_test_item(&mut world, EntityType::Pellet);
let initial_score = world.resource::<ScoreResource>().0;
// Run system without any collision events
world.run_system_once(item_system).expect("System should run successfully");
// Nothing should change
let score = world.resource::<ScoreResource>();
assert_eq!(score.0, initial_score);
let pellet_count = world
.query::<&EntityType>()
.iter(&world)
.filter(|&entity_type| matches!(entity_type, EntityType::Pellet))
.count();
assert_eq!(pellet_count, 1);
}
#[test]
fn test_item_system_collision_with_missing_entity() {
let mut world = create_test_world();
let pacman = spawn_test_pacman(&mut world);
// Create a fake entity ID that doesn't exist
let fake_entity = Entity::from_raw(999);
send_collision_event(&mut world, pacman, fake_entity);
// System should handle gracefully and not crash
world
.run_system_once(item_system)
.expect("System should handle missing entities gracefully");
// Score should remain unchanged
let score = world.resource::<ScoreResource>();
assert_eq!(score.0, 0);
}
#[test]
fn test_item_system_preserves_existing_score() {
let mut world = create_test_world();
// Set initial score
world.insert_resource(ScoreResource(100));
let pacman = spawn_test_pacman(&mut world);
let pellet = spawn_test_item(&mut world, EntityType::Pellet);
send_collision_event(&mut world, pacman, pellet);
world.run_system_once(item_system).expect("System should run successfully");
// Score should be initial + pellet value
let score = world.resource::<ScoreResource>();
assert_eq!(score.0, 110);
}

195
tests/movement.rs Normal file
View File

@@ -0,0 +1,195 @@
use glam::Vec2;
use pacman::map::direction::Direction;
use pacman::map::graph::{Graph, Node};
use pacman::systems::movement::{BufferedDirection, Position, Velocity};
fn create_test_graph() -> Graph {
let mut graph = Graph::new();
// Add a few test nodes
let node0 = graph.add_node(Node {
position: Vec2::new(0.0, 0.0),
});
let node1 = graph.add_node(Node {
position: Vec2::new(16.0, 0.0),
});
let node2 = graph.add_node(Node {
position: Vec2::new(0.0, 16.0),
});
// Connect them
graph.connect(node0, node1, false, None, Direction::Right).unwrap();
graph.connect(node0, node2, false, None, Direction::Down).unwrap();
graph
}
#[test]
fn test_position_is_at_node() {
let stopped_pos = Position::Stopped { node: 0 };
let moving_pos = Position::Moving {
from: 0,
to: 1,
remaining_distance: 8.0,
};
assert!(stopped_pos.is_at_node());
assert!(!moving_pos.is_at_node());
}
#[test]
fn test_position_current_node() {
let stopped_pos = Position::Stopped { node: 5 };
let moving_pos = Position::Moving {
from: 3,
to: 7,
remaining_distance: 12.0,
};
assert_eq!(stopped_pos.current_node(), 5);
assert_eq!(moving_pos.current_node(), 3);
}
#[test]
fn test_position_tick_no_movement_when_stopped() {
let mut pos = Position::Stopped { node: 0 };
let result = pos.tick(5.0);
assert!(result.is_none());
assert_eq!(pos, Position::Stopped { node: 0 });
}
#[test]
fn test_position_tick_no_movement_when_zero_distance() {
let mut pos = Position::Moving {
from: 0,
to: 1,
remaining_distance: 10.0,
};
let result = pos.tick(0.0);
assert!(result.is_none());
assert_eq!(
pos,
Position::Moving {
from: 0,
to: 1,
remaining_distance: 10.0,
}
);
}
#[test]
fn test_position_tick_partial_movement() {
let mut pos = Position::Moving {
from: 0,
to: 1,
remaining_distance: 10.0,
};
let result = pos.tick(3.0);
assert!(result.is_none());
assert_eq!(
pos,
Position::Moving {
from: 0,
to: 1,
remaining_distance: 7.0,
}
);
}
#[test]
fn test_position_tick_exact_arrival() {
let mut pos = Position::Moving {
from: 0,
to: 1,
remaining_distance: 5.0,
};
let result = pos.tick(5.0);
assert!(result.is_none());
assert_eq!(pos, Position::Stopped { node: 1 });
}
#[test]
fn test_position_tick_overshoot_with_overflow() {
let mut pos = Position::Moving {
from: 0,
to: 1,
remaining_distance: 3.0,
};
let result = pos.tick(8.0);
assert_eq!(result, Some(5.0));
assert_eq!(pos, Position::Stopped { node: 1 });
}
#[test]
fn test_position_get_pixel_position_stopped() {
let graph = create_test_graph();
let pos = Position::Stopped { node: 0 };
let pixel_pos = pos.get_pixel_position(&graph).unwrap();
let expected = Vec2::new(
0.0 + pacman::constants::BOARD_PIXEL_OFFSET.x as f32,
0.0 + pacman::constants::BOARD_PIXEL_OFFSET.y as f32,
);
assert_eq!(pixel_pos, expected);
}
#[test]
fn test_position_get_pixel_position_moving() {
let graph = create_test_graph();
let pos = Position::Moving {
from: 0,
to: 1,
remaining_distance: 8.0, // Halfway through a 16-unit edge
};
let pixel_pos = pos.get_pixel_position(&graph).unwrap();
// Should be halfway between (0,0) and (16,0), so at (8,0) plus offset
let expected = Vec2::new(
8.0 + pacman::constants::BOARD_PIXEL_OFFSET.x as f32,
0.0 + pacman::constants::BOARD_PIXEL_OFFSET.y as f32,
);
assert_eq!(pixel_pos, expected);
}
#[test]
fn test_velocity_basic_properties() {
let velocity = Velocity {
speed: 2.5,
direction: Direction::Up,
};
assert_eq!(velocity.speed, 2.5);
assert_eq!(velocity.direction, Direction::Up);
}
#[test]
fn test_buffered_direction_none() {
let buffered = BufferedDirection::None;
assert_eq!(buffered, BufferedDirection::None);
}
#[test]
fn test_buffered_direction_some() {
let buffered = BufferedDirection::Some {
direction: Direction::Left,
remaining_time: 0.5,
};
if let BufferedDirection::Some {
direction,
remaining_time,
} = buffered
{
assert_eq!(direction, Direction::Left);
assert_eq!(remaining_time, 0.5);
} else {
panic!("Expected BufferedDirection::Some");
}
}

View File

@@ -1,7 +1,64 @@
use pacman::map::direction::Direction; use bevy_ecs::{event::Events, prelude::*, system::RunSystemOnce, world::World};
use pacman::map::graph::{Edge, TraversalFlags};
use pacman::systems::components::EntityType; use pacman::{
use pacman::systems::player::can_traverse; events::{GameCommand, GameEvent},
map::{
builder::Map,
direction::Direction,
graph::{Edge, TraversalFlags},
},
systems::{
components::{AudioState, DeltaTime, EntityType, GlobalState, PlayerControlled},
debug::DebugState,
movement::{BufferedDirection, Position, Velocity},
player::{can_traverse, player_control_system, player_movement_system},
},
};
// Test helper functions for ECS setup
fn create_test_world() -> World {
let mut world = World::new();
// Add resources
world.insert_resource(GlobalState { exit: false });
world.insert_resource(DebugState::default());
world.insert_resource(AudioState::default());
world.insert_resource(DeltaTime(1.0 / 60.0)); // 60 FPS
world.insert_resource(Events::<GameEvent>::default());
world.insert_resource(Events::<pacman::error::GameError>::default());
// Create a simple test map with nodes and edges
let test_map = create_test_map();
world.insert_resource(test_map);
world
}
fn create_test_map() -> Map {
// Use the actual RAW_BOARD from constants.rs
use pacman::constants::RAW_BOARD;
Map::new(RAW_BOARD).expect("Failed to create test map")
}
fn spawn_test_player(world: &mut World) -> Entity {
world
.spawn((
PlayerControlled,
Position::Stopped { node: 0 },
Velocity {
speed: 1.0,
direction: Direction::Right,
},
BufferedDirection::None,
EntityType::Player,
))
.id()
}
fn send_game_event(world: &mut World, command: GameCommand) {
let mut events = world.resource_mut::<Events<GameEvent>>();
events.send(GameEvent::Command(command));
}
#[test] #[test]
fn test_can_traverse_player_on_all_edges() { fn test_can_traverse_player_on_all_edges() {
@@ -98,3 +155,422 @@ fn test_entity_type_traversal_flags() {
assert_eq!(EntityType::Pellet.traversal_flags(), TraversalFlags::empty()); assert_eq!(EntityType::Pellet.traversal_flags(), TraversalFlags::empty());
assert_eq!(EntityType::PowerPellet.traversal_flags(), TraversalFlags::empty()); assert_eq!(EntityType::PowerPellet.traversal_flags(), TraversalFlags::empty());
} }
// ============================================================================
// ECS System Tests
// ============================================================================
#[test]
fn test_player_control_system_move_command() {
let mut world = create_test_world();
let _player = spawn_test_player(&mut world);
// Send move command
send_game_event(&mut world, GameCommand::MovePlayer(Direction::Up));
// Run the system
world
.run_system_once(player_control_system)
.expect("System should run successfully");
// Check that buffered direction was updated
let mut query = world.query::<&BufferedDirection>();
let buffered_direction = query.single(&world).expect("Player should exist");
match *buffered_direction {
BufferedDirection::Some {
direction,
remaining_time,
} => {
assert_eq!(direction, Direction::Up);
assert_eq!(remaining_time, 0.25);
}
BufferedDirection::None => panic!("Expected buffered direction to be set"),
}
}
#[test]
fn test_player_control_system_exit_command() {
let mut world = create_test_world();
let _player = spawn_test_player(&mut world);
// Send exit command
send_game_event(&mut world, GameCommand::Exit);
// Run the system
world
.run_system_once(player_control_system)
.expect("System should run successfully");
// Check that exit flag was set
let state = world.resource::<GlobalState>();
assert!(state.exit);
}
#[test]
fn test_player_control_system_toggle_debug() {
let mut world = create_test_world();
let _player = spawn_test_player(&mut world);
// Send toggle debug command
send_game_event(&mut world, GameCommand::ToggleDebug);
// Run the system
world
.run_system_once(player_control_system)
.expect("System should run successfully");
// Check that debug state changed
let debug_state = world.resource::<DebugState>();
assert!(debug_state.enabled);
}
#[test]
fn test_player_control_system_mute_audio() {
let mut world = create_test_world();
let _player = spawn_test_player(&mut world);
// Send mute audio command
send_game_event(&mut world, GameCommand::MuteAudio);
// Run the system
world
.run_system_once(player_control_system)
.expect("System should run successfully");
// Check that audio was muted
let audio_state = world.resource::<AudioState>();
assert!(audio_state.muted);
// Send mute audio command again to unmute - need fresh events
world.resource_mut::<Events<GameEvent>>().clear(); // Clear previous events
send_game_event(&mut world, GameCommand::MuteAudio);
world
.run_system_once(player_control_system)
.expect("System should run successfully");
// Check that audio was unmuted
let audio_state = world.resource::<AudioState>();
assert!(!audio_state.muted, "Audio should be unmuted after second toggle");
}
#[test]
fn test_player_control_system_no_player_entity() {
let mut world = create_test_world();
// Don't spawn a player entity
send_game_event(&mut world, GameCommand::MovePlayer(Direction::Up));
// Run the system - should write an error
world
.run_system_once(player_control_system)
.expect("System should run successfully");
// Check that an error was written (we can't easily check Events without manual management,
// so for this test we just verify the system ran without panicking)
// In a real implementation, you might expose error checking through the ECS world
}
#[test]
fn test_player_movement_system_buffered_direction_expires() {
let mut world = create_test_world();
let player = spawn_test_player(&mut world);
// Set a buffered direction with short time
world.entity_mut(player).insert(BufferedDirection::Some {
direction: Direction::Up,
remaining_time: 0.01, // Very short time
});
// Set delta time to expire the buffered direction
world.insert_resource(DeltaTime(0.02));
// Run the system
world
.run_system_once(player_movement_system)
.expect("System should run successfully");
// Check that buffered direction expired or remaining time decreased significantly
let mut query = world.query::<&BufferedDirection>();
let buffered_direction = query.single(&world).expect("Player should exist");
match *buffered_direction {
BufferedDirection::None => {} // Expected - fully expired
BufferedDirection::Some { remaining_time, .. } => {
assert!(
remaining_time <= 0.0,
"Buffered direction should be expired or have non-positive time"
);
}
}
}
#[test]
fn test_player_movement_system_start_moving_from_stopped() {
let mut world = create_test_world();
let _player = spawn_test_player(&mut world);
// Player starts at node 0, facing right (towards node 1)
// Should start moving when system runs
world
.run_system_once(player_movement_system)
.expect("System should run successfully");
// Check that player started moving
let mut query = world.query::<&Position>();
let position = query.single(&world).expect("Player should exist");
match *position {
Position::Moving { from, .. } => {
assert_eq!(from, 0, "Player should start from node 0");
// Don't assert exact target node since the real map has different connectivity
}
Position::Stopped { .. } => {} // May stay stopped if no valid edge in current direction
}
}
#[test]
fn test_player_movement_system_buffered_direction_change() {
let mut world = create_test_world();
let player = spawn_test_player(&mut world);
// Set a buffered direction to go down (towards node 2)
world.entity_mut(player).insert(BufferedDirection::Some {
direction: Direction::Down,
remaining_time: 1.0,
});
world
.run_system_once(player_movement_system)
.expect("System should run successfully");
// Check that player started moving down instead of right
let mut query = world.query::<(&Position, &Velocity, &BufferedDirection)>();
let (position, _velocity, _buffered_direction) = query.single(&world).expect("Player should exist");
match *position {
Position::Moving { from, to, .. } => {
assert_eq!(from, 0);
assert_eq!(to, 2); // Should be moving to node 2 (down)
}
Position::Stopped { .. } => panic!("Player should have started moving"),
}
// Check if the movement actually happened based on the real map connectivity
// The buffered direction might not be consumed if there's no valid edge in that direction
}
#[test]
fn test_player_movement_system_no_valid_edge() {
let mut world = create_test_world();
let player = spawn_test_player(&mut world);
// Set velocity to direction with no edge
world.entity_mut(player).insert(Velocity {
speed: 1.0,
direction: Direction::Up, // No edge up from node 0
});
world
.run_system_once(player_movement_system)
.expect("System should run successfully");
// Player should remain stopped
let mut query = world.query::<&Position>();
let position = query.single(&world).expect("Player should exist");
match *position {
Position::Stopped { node } => assert_eq!(node, 0),
Position::Moving { .. } => panic!("Player shouldn't be able to move without valid edge"),
}
}
#[test]
fn test_player_movement_system_continue_moving() {
let mut world = create_test_world();
let player = spawn_test_player(&mut world);
// Set player to already be moving
world.entity_mut(player).insert(Position::Moving {
from: 0,
to: 1,
remaining_distance: 50.0,
});
world
.run_system_once(player_movement_system)
.expect("System should run successfully");
// Check that player continued moving and distance decreased
let mut query = world.query::<&Position>();
let position = query.single(&world).expect("Player should exist");
match *position {
Position::Moving { remaining_distance, .. } => {
assert!(remaining_distance < 50.0); // Should have moved
}
Position::Stopped { .. } => {
// If player reached destination, that's also valid
}
}
}
// ============================================================================
// Integration Tests
// ============================================================================
#[test]
fn test_full_player_input_to_movement_flow() {
let mut world = create_test_world();
let _player = spawn_test_player(&mut world);
// Send move command
send_game_event(&mut world, GameCommand::MovePlayer(Direction::Down));
// Run control system to process input
world
.run_system_once(player_control_system)
.expect("System should run successfully");
// Run movement system to execute movement
world
.run_system_once(player_movement_system)
.expect("System should run successfully");
// Check final state - player should be moving down
let mut query = world.query::<(&Position, &Velocity, &BufferedDirection)>();
let (position, _velocity, _buffered_direction) = query.single(&world).expect("Player should exist");
match *position {
Position::Moving { from, to, .. } => {
assert_eq!(from, 0);
assert_eq!(to, 2); // Moving to node 2 (down)
}
Position::Stopped { .. } => panic!("Player should be moving"),
}
// Check that player moved in the buffered direction if possible
// In the real map, the buffered direction may not be consumable if there's no valid edge
}
#[test]
fn test_buffered_direction_timing() {
let mut world = create_test_world();
let _player = spawn_test_player(&mut world);
// Send move command
send_game_event(&mut world, GameCommand::MovePlayer(Direction::Up));
world
.run_system_once(player_control_system)
.expect("System should run successfully");
// Run movement system multiple times with small delta times
world.insert_resource(DeltaTime(0.1)); // 0.1 seconds
// First run - buffered direction should still be active
world
.run_system_once(player_movement_system)
.expect("System should run successfully");
let mut query = world.query::<&BufferedDirection>();
let buffered_direction = query.single(&world).expect("Player should exist");
match *buffered_direction {
BufferedDirection::Some { remaining_time, .. } => {
assert!(remaining_time > 0.0);
assert!(remaining_time < 0.25);
}
BufferedDirection::None => panic!("Buffered direction should still be active"),
}
// Run again to fully expire the buffered direction
world.insert_resource(DeltaTime(0.2)); // Total 0.3 seconds, should expire
world
.run_system_once(player_movement_system)
.expect("System should run successfully");
let buffered_direction = query.single(&world).expect("Player should exist");
assert_eq!(*buffered_direction, BufferedDirection::None);
}
#[test]
fn test_multiple_rapid_direction_changes() {
let mut world = create_test_world();
let _player = spawn_test_player(&mut world);
// Send multiple rapid direction changes
send_game_event(&mut world, GameCommand::MovePlayer(Direction::Up));
world
.run_system_once(player_control_system)
.expect("System should run successfully");
send_game_event(&mut world, GameCommand::MovePlayer(Direction::Down));
world
.run_system_once(player_control_system)
.expect("System should run successfully");
send_game_event(&mut world, GameCommand::MovePlayer(Direction::Left));
world
.run_system_once(player_control_system)
.expect("System should run successfully");
// Only the last direction should be buffered
let mut query = world.query::<&BufferedDirection>();
let buffered_direction = query.single(&world).expect("Player should exist");
match *buffered_direction {
BufferedDirection::Some { direction, .. } => {
assert_eq!(direction, Direction::Left);
}
BufferedDirection::None => panic!("Expected buffered direction"),
}
}
#[test]
fn test_player_state_persistence_across_systems() {
let mut world = create_test_world();
let _player = spawn_test_player(&mut world);
// Test that multiple commands can be processed - but need to handle events properly
// Clear any existing events first
world.resource_mut::<Events<GameEvent>>().clear();
// Toggle debug mode
send_game_event(&mut world, GameCommand::ToggleDebug);
world
.run_system_once(player_control_system)
.expect("System should run successfully");
let debug_state_after_toggle = *world.resource::<DebugState>();
// Clear events and mute audio
world.resource_mut::<Events<GameEvent>>().clear();
send_game_event(&mut world, GameCommand::MuteAudio);
world
.run_system_once(player_control_system)
.expect("System should run successfully");
let audio_muted_after_toggle = world.resource::<AudioState>().muted;
// Clear events and move player
world.resource_mut::<Events<GameEvent>>().clear();
send_game_event(&mut world, GameCommand::MovePlayer(Direction::Down));
world
.run_system_once(player_control_system)
.expect("System should run successfully");
world
.run_system_once(player_movement_system)
.expect("System should run successfully");
// Check that all state changes persisted
// Variables already captured above during individual tests
let mut query = world.query::<&Position>();
let position = *query.single(&world).expect("Player should exist");
// Check that the state changes persisted individually
assert!(debug_state_after_toggle.enabled, "Debug state should have toggled");
assert!(audio_muted_after_toggle, "Audio should be muted");
// Player position depends on actual map connectivity
match position {
Position::Moving { .. } => {} // Good - player is moving
Position::Stopped { .. } => {} // Also ok - might not have valid edge in that direction
}
}