mirror of
https://github.com/Xevion/Pac-Man.git
synced 2025-12-06 05:15:49 -06:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f14b3d38a4 | ||
|
|
bf65c34b28 | ||
|
|
89b0790f19 | ||
|
|
9624bcf359 | ||
|
|
67a5c4a1ed | ||
|
|
8b5e66f514 | ||
|
|
5109457fcd | ||
|
|
5497e4b0b9 | ||
| d72b6eec06 | |||
| ae42f6ead0 | |||
| 471b118efd | |||
| 13a9c165f7 |
@@ -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,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
161
src/game.rs
161
src/game.rs
@@ -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(())
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,42 +35,49 @@ 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) => {
|
errors.write(GameError::InvalidState(format!(
|
||||||
for result in [result_a, result_b] {
|
"Collision system failed to check collision between entities {:?} and {:?}: {}",
|
||||||
if let Err(e) = result {
|
pacman_entity, item_entity, e
|
||||||
errors.write(GameError::InvalidState(format!(
|
)));
|
||||||
"Collision system failed to get pixel positions for entities {:?} and {:?}: {}",
|
}
|
||||||
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
|
||||||
|
)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,79 +131,86 @@ 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 {
|
// Find the closest node to the cursor
|
||||||
DebugState::Graph => {
|
|
||||||
// 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 {
|
||||||
map.graph
|
map.graph
|
||||||
.nodes()
|
.nodes()
|
||||||
.map(|node| node.position.distance(cursor_world_pos))
|
.map(|node| node.position.distance(cursor_world_pos))
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(Ordering::Less))
|
.min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(Ordering::Less))
|
||||||
.map(|(id, _)| id)
|
.map(|(id, _)| id)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
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() {
|
||||||
|
let start_node_model = map.graph.get_node(start_node).unwrap();
|
||||||
|
let end_node = map.graph.get_node(end_node.target).unwrap().position;
|
||||||
|
|
||||||
|
// Transform positions using common method
|
||||||
|
let start = transform_position_with_offset(start_node_model.position, scale);
|
||||||
|
let end = transform_position_with_offset(end_node, scale);
|
||||||
|
|
||||||
|
debug_canvas
|
||||||
|
.draw_line(Point::from((start.x, start.y)), Point::from((end.x, end.y)))
|
||||||
|
.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(Color {
|
||||||
|
a: f32_to_u8(if Some(id) == closest_node { 0.75 } else { 0.6 }),
|
||||||
|
..(if Some(id) == closest_node {
|
||||||
|
Color::YELLOW
|
||||||
} else {
|
} else {
|
||||||
None
|
Color::BLUE
|
||||||
};
|
})
|
||||||
|
});
|
||||||
|
|
||||||
debug_canvas.set_draw_color(Color::RED);
|
// Transform position using common method
|
||||||
for (start_node, end_node) in map.graph.edges() {
|
let pos = transform_position_with_offset(pos, scale);
|
||||||
let start_node_model = map.graph.get_node(start_node).unwrap();
|
let size = (2.0 * scale) as u32;
|
||||||
let end_node = map.graph.get_node(end_node.target).unwrap().position;
|
|
||||||
|
|
||||||
// Transform positions using common method
|
debug_canvas
|
||||||
let start = transform_position_with_offset(start_node_model.position, scale);
|
.fill_rect(Rect::new(pos.x - (size as i32 / 2), pos.y - (size as i32 / 2), size, size))
|
||||||
let end = transform_position_with_offset(end_node, scale);
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
debug_canvas
|
// Render node ID if a node is highlighted
|
||||||
.draw_line(Point::from((start.x, start.y)), Point::from((end.x, end.y)))
|
if let Some(closest_node_id) = closest_node {
|
||||||
.unwrap();
|
let node = map.graph.get_node(closest_node_id).unwrap();
|
||||||
}
|
let pos = transform_position_with_offset(node.position, scale);
|
||||||
|
|
||||||
for (id, node) in map.graph.nodes().enumerate() {
|
let surface = font
|
||||||
let pos = node.position;
|
.render(&closest_node_id.to_string())
|
||||||
|
.blended(Color {
|
||||||
// Set color based on whether the node is the closest to the cursor
|
a: f32_to_u8(0.4),
|
||||||
debug_canvas.set_draw_color(if Some(id) == closest_node {
|
..Color::WHITE
|
||||||
Color::YELLOW
|
})
|
||||||
} else {
|
.unwrap();
|
||||||
Color::BLUE
|
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();
|
||||||
// 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 => {
|
|
||||||
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
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
Event::KeyUp {
|
||||||
}
|
keycode: Some(key),
|
||||||
|
repeat: false,
|
||||||
if bindings.movement_keys.contains(&key) {
|
..
|
||||||
movement_key_pressed = true;
|
} => {
|
||||||
bindings.last_movement_key = Some(key);
|
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) {
|
||||||
|
|||||||
@@ -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,20 +40,17 @@ 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;
|
|
||||||
|
// Remove the collected item
|
||||||
|
commands.entity(item_ent).despawn();
|
||||||
|
|
||||||
|
// Trigger audio if appropriate
|
||||||
|
if entity_type.is_collectible() {
|
||||||
|
events.write(AudioEvent::PlayEat);
|
||||||
}
|
}
|
||||||
EntityType::PowerPellet => {
|
|
||||||
score.0 += 50;
|
|
||||||
}
|
|
||||||
_ => continue,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the collected item
|
|
||||||
commands.entity(item_ent).despawn();
|
|
||||||
|
|
||||||
events.write(AudioEvent::PlayEat);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ pub enum SystemId {
|
|||||||
Blinking,
|
Blinking,
|
||||||
DirectionalRender,
|
DirectionalRender,
|
||||||
DirtyRender,
|
DirtyRender,
|
||||||
|
HudRender,
|
||||||
Render,
|
Render,
|
||||||
DebugRender,
|
DebugRender,
|
||||||
Present,
|
Present,
|
||||||
|
|||||||
@@ -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
150
tests/collision.rs
Normal 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");
|
||||||
|
}
|
||||||
@@ -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"));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
26
tests/hud.rs
Normal 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
38
tests/input.rs
Normal 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
255
tests/item.rs
Normal 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
195
tests/movement.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
484
tests/player.rs
484
tests/player.rs
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user