Compare commits

...

22 Commits

Author SHA1 Message Date
Ryan Walters
66499b6285 fix: remove broken console stream re-attach on Windows 2025-08-29 10:56:26 -05:00
Ryan Walters
a8e62aec56 fix: force dirty render using resource_change conditions, hide ghosts & player on initial spawn 2025-08-28 20:20:38 -05:00
Ryan Walters
cde1ea5394 feat: allow freezing of blinking entities, lightly refactor game.rs structure 2025-08-28 20:02:27 -05:00
Ryan Walters
d0628ef70b feat: use backbuffer fully, proper 'present' system, debug texture draws with transparency 2025-08-28 19:40:31 -05:00
Ryan Walters
9bfe4a9ce7 fix: add expected MovementModifiers to spawn_test_player to fix movement tests 2025-08-28 18:35:47 -05:00
Ryan Walters
2da8a312f3 chore: remove PlayerLifecycle, move MovementModifiers directly into PlayerBundle 2025-08-28 18:32:19 -05:00
Ryan Walters
2bdd4f0d04 feat: re-implement visbility via 'Hidden' tag component, move stage visibility logic into stage system 2025-08-28 18:24:47 -05:00
Ryan Walters
5cc9b1a6ee fix: avoid acquiring filtered player query until movement command received 2025-08-28 14:17:46 -05:00
Ryan Walters
5d4adb7743 refactor: merge 'formatting' submodule into 'profiling' 2025-08-28 14:12:23 -05:00
Ryan Walters
633d467f2c chore: remove LevelTiming resource 2025-08-28 13:21:21 -05:00
Ryan Walters
d3e83262db feat: better 'Vulnerable' tag for ghosts, fix movement issues 2025-08-28 13:18:47 -05:00
Ryan Walters
f31b4952e4 chore: remove wildcard/prelude imports, remove unused functions 2025-08-28 13:14:40 -05:00
Ryan Walters
ad3f896f82 chore: reorganize component definitions into relevant system files 2025-08-28 12:54:52 -05:00
Ryan Walters
80ebf08dd3 feat: stage sequence, ghost collisions & energizer logic, text color method, scheduler ordering 2025-08-28 12:40:02 -05:00
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
28 changed files with 1185 additions and 771 deletions

View File

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

View File

@@ -2,39 +2,34 @@
include!(concat!(env!("OUT_DIR"), "/atlas_data.rs"));
use crate::constants::CANVAS_SIZE;
use crate::constants::{MapTile, CANVAS_SIZE};
use crate::error::{GameError, GameResult, TextureError};
use crate::events::GameEvent;
use crate::map::builder::Map;
use crate::map::direction::Direction;
use crate::systems::blinking::Blinking;
use crate::systems::{self, ghost_collision_system, present_system, Hidden, MovementModifiers};
use crate::systems::movement::{BufferedDirection, Position, Velocity};
use crate::systems::player::player_movement_system;
use crate::systems::profiling::SystemId;
use crate::systems::render::RenderDirty;
use crate::systems::{
audio::{audio_system, AudioEvent, AudioResource},
blinking::blinking_system,
collision::collision_system,
components::{
AudioState, Collider, DeltaTime, DirectionalAnimated, EntityType, Ghost, GhostBundle, GhostCollider, GlobalState,
ItemBundle, ItemCollider, PacmanCollider, PlayerBundle, PlayerControlled, RenderDirty, Renderable, ScoreResource,
},
debug::{debug_render_system, DebugFontResource, DebugState, DebugTextureResource},
ghost::ghost_movement_system,
input::input_system,
item::item_system,
player::player_control_system,
profiling::{profile, SystemTimings},
render::{directional_render_system, dirty_render_system, render_system, BackbufferResource, MapTextureResource},
audio_system, blinking_system, collision_system, debug_render_system, directional_render_system, dirty_render_system,
ghost_movement_system, hud_render_system, item_system, profile, render_system, AudioEvent, AudioResource, AudioState,
BackbufferResource, Collider, DebugFontResource, DebugState, DebugTextureResource, DeltaTime, DirectionalAnimated,
EntityType, Frozen, Ghost, GhostBundle, GhostCollider, GlobalState, ItemBundle, ItemCollider, MapTextureResource,
PacmanCollider, PlayerBundle, PlayerControlled, Renderable, ScoreResource, StartupSequence, SystemTimings,
};
use crate::texture::animated::AnimatedTexture;
use crate::texture::sprite::AtlasTile;
use bevy_ecs::event::EventRegistry;
use bevy_ecs::observer::Trigger;
use bevy_ecs::schedule::Schedule;
use bevy_ecs::system::{NonSendMut, Res, ResMut};
use bevy_ecs::schedule::common_conditions::resource_changed;
use bevy_ecs::schedule::{Condition, IntoScheduleConfigs, Schedule, SystemSet};
use bevy_ecs::system::ResMut;
use bevy_ecs::world::World;
use sdl2::image::LoadTexture;
use sdl2::render::{Canvas, ScaleMode, TextureCreator};
use sdl2::render::{BlendMode, Canvas, ScaleMode, TextureCreator};
use sdl2::rwops::RWops;
use sdl2::video::{Window, WindowContext};
use sdl2::EventPump;
@@ -48,6 +43,10 @@ use crate::{
texture::sprite::{AtlasMapper, SpriteAtlas},
};
/// System set for all rendering systems to ensure they run after gameplay logic
#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
pub struct RenderSet;
/// Core game state manager built on the Bevy ECS architecture.
///
/// Orchestrates all game systems through a centralized `World` containing entities,
@@ -84,14 +83,7 @@ impl Game {
texture_creator: &'static mut TextureCreator<WindowContext>,
event_pump: &'static mut EventPump,
) -> GameResult<Game> {
let mut world = World::default();
let mut schedule = Schedule::default();
let ttf_context = Box::leak(Box::new(sdl2::ttf::init().map_err(|e| GameError::Sdl(e.to_string()))?));
EventRegistry::register_event::<GameError>(&mut world);
EventRegistry::register_event::<GameEvent>(&mut world);
EventRegistry::register_event::<AudioEvent>(&mut world);
let mut backbuffer = texture_creator
.create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y)
.map_err(|e| GameError::Sdl(e.to_string()))?;
@@ -107,6 +99,9 @@ impl Game {
let mut debug_texture = texture_creator
.create_texture_target(None, output_size.0, output_size.1)
.map_err(|e| GameError::Sdl(e.to_string()))?;
// Debug texture is copied over the backbuffer, it requires transparency abilities
debug_texture.set_blend_mode(BlendMode::Blend);
debug_texture.set_scale_mode(ScaleMode::Nearest);
let font_data = get_asset_bytes(Asset::Font)?;
@@ -152,11 +147,10 @@ impl Game {
.map_err(|e| GameError::Sdl(e.to_string()))?;
let map = Map::new(constants::RAW_BOARD)?;
let pacman_start_node = map.start_positions.pacman;
// Create directional animated textures for Pac-Man
let mut textures = [None, None, None, None];
let mut stopped_textures = [None, None, None, None];
for direction in Direction::DIRECTIONS {
let moving_prefix = match direction {
Direction::Up => "pacman/up",
@@ -182,17 +176,19 @@ impl Game {
let player = PlayerBundle {
player: PlayerControlled,
position: Position::Stopped { node: pacman_start_node },
position: Position::Stopped {
node: map.start_positions.pacman,
},
velocity: Velocity {
speed: 1.15,
direction: Direction::Left,
},
movement_modifiers: MovementModifiers::default(),
buffered_direction: BufferedDirection::None,
sprite: Renderable {
sprite: SpriteAtlas::get_tile(&atlas, "pacman/full.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?,
layer: 0,
visible: true,
},
directional_animated: DirectionalAnimated {
textures,
@@ -205,6 +201,13 @@ impl Game {
pacman_collider: PacmanCollider,
};
let mut world = World::default();
let mut schedule = Schedule::default();
EventRegistry::register_event::<GameError>(&mut world);
EventRegistry::register_event::<GameEvent>(&mut world);
EventRegistry::register_event::<AudioEvent>(&mut world);
world.insert_non_send_resource(atlas);
world.insert_non_send_resource(event_pump);
world.insert_non_send_resource(canvas);
@@ -224,6 +227,7 @@ impl Game {
world.insert_resource(DebugState::default());
world.insert_resource(AudioState::default());
world.insert_resource(CursorPosition::default());
world.insert_resource(StartupSequence::new(60 * 3, 60));
world.add_observer(
|event: Trigger<GameEvent>, mut state: ResMut<GlobalState>, _score: ResMut<ScoreResource>| {
@@ -232,78 +236,96 @@ impl Game {
}
},
);
let input_system = profile(SystemId::Input, systems::input::input_system);
let player_control_system = profile(SystemId::PlayerControls, systems::player_control_system);
let player_movement_system = profile(SystemId::PlayerMovement, systems::player_movement_system);
let startup_stage_system = profile(SystemId::Stage, systems::startup_stage_system);
let player_tunnel_slowdown_system = profile(SystemId::PlayerMovement, systems::player::player_tunnel_slowdown_system);
let ghost_movement_system = profile(SystemId::Ghost, ghost_movement_system);
let collision_system = profile(SystemId::Collision, collision_system);
let ghost_collision_system = profile(SystemId::GhostCollision, ghost_collision_system);
let vulnerable_tick_system = profile(SystemId::Ghost, systems::vulnerable_tick_system);
let item_system = profile(SystemId::Item, item_system);
let audio_system = profile(SystemId::Audio, audio_system);
let blinking_system = profile(SystemId::Blinking, blinking_system);
let directional_render_system = profile(SystemId::DirectionalRender, directional_render_system);
let dirty_render_system = profile(SystemId::DirtyRender, dirty_render_system);
let render_system = profile(SystemId::Render, render_system);
let hud_render_system = profile(SystemId::HudRender, hud_render_system);
let debug_render_system = profile(SystemId::DebugRender, debug_render_system);
let present_system = profile(SystemId::Present, present_system);
let forced_dirty_system = |mut dirty: ResMut<RenderDirty>| {
dirty.0 = true;
};
schedule.add_systems((
profile(SystemId::Input, input_system),
profile(SystemId::PlayerControls, player_control_system),
profile(SystemId::PlayerMovement, player_movement_system),
profile(SystemId::Ghost, ghost_movement_system),
profile(SystemId::Collision, collision_system),
profile(SystemId::Item, item_system),
profile(SystemId::Audio, audio_system),
profile(SystemId::Blinking, blinking_system),
profile(SystemId::DirectionalRender, directional_render_system),
profile(SystemId::DirtyRender, dirty_render_system),
profile(SystemId::Render, render_system),
profile(SystemId::DebugRender, debug_render_system),
profile(
SystemId::Present,
|mut canvas: NonSendMut<&mut Canvas<Window>>,
backbuffer: NonSendMut<BackbufferResource>,
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
// (debug rendering draws directly to main canvas)
if *debug_state == DebugState::Off {
canvas.copy(&backbuffer.0, None, None).unwrap();
}
dirty.0 = false;
canvas.present();
}
},
),
forced_dirty_system.run_if(resource_changed::<ScoreResource>.or(resource_changed::<StartupSequence>)),
(
input_system,
player_control_system,
player_movement_system,
startup_stage_system,
)
.chain(),
player_tunnel_slowdown_system,
ghost_movement_system,
vulnerable_tick_system,
(collision_system, ghost_collision_system, item_system).chain(),
audio_system,
blinking_system,
(
directional_render_system,
dirty_render_system,
render_system,
hud_render_system,
debug_render_system,
present_system,
)
.chain(),
));
// Spawn player
world.spawn(player);
// Spawn player and attach initial state bundle
world.spawn(player).insert((Frozen, Hidden));
// Spawn ghosts
Self::spawn_ghosts(&mut world)?;
// Spawn items
let pellet_sprite = SpriteAtlas::get_tile(world.non_send_resource::<SpriteAtlas>(), "maze/pellet.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("maze/pellet.png".to_string())))?;
let energizer_sprite = SpriteAtlas::get_tile(world.non_send_resource::<SpriteAtlas>(), "maze/energizer.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("maze/energizer.png".to_string())))?;
let nodes: Vec<_> = world.resource::<Map>().iter_nodes().map(|(id, tile)| (*id, *tile)).collect();
for (node_id, tile) in nodes {
let (item_type, sprite, size) = match tile {
crate::constants::MapTile::Pellet => (EntityType::Pellet, pellet_sprite, constants::CELL_SIZE as f32 * 0.4),
crate::constants::MapTile::PowerPellet => {
(EntityType::PowerPellet, energizer_sprite, constants::CELL_SIZE as f32 * 0.95)
}
_ => continue,
};
// Build a list of item entities to spawn from the map
let nodes: Vec<(usize, EntityType, AtlasTile, f32)> = world
.resource::<Map>()
.iter_nodes()
.filter_map(|(id, tile)| match tile {
MapTile::Pellet => Some((*id, EntityType::Pellet, pellet_sprite, constants::CELL_SIZE as f32 * 0.4)),
MapTile::PowerPellet => Some((
*id,
EntityType::PowerPellet,
energizer_sprite,
constants::CELL_SIZE as f32 * 0.95,
)),
_ => None,
})
.collect();
// Construct and spawn the item entities
for (id, item_type, sprite, size) in nodes {
let mut item = world.spawn(ItemBundle {
position: Position::Stopped { node: node_id },
sprite: Renderable {
sprite,
layer: 1,
visible: true,
},
position: Position::Stopped { node: id },
sprite: Renderable { sprite, layer: 1 },
entity_type: item_type,
collider: Collider { size },
item_collider: ItemCollider,
});
// Make power pellets blink
if item_type == EntityType::PowerPellet {
item.insert(Blinking {
timer: 0.0,
interval: 0.2,
});
item.insert((Frozen, Blinking::new(0.2)));
}
}
@@ -400,7 +422,6 @@ impl Game {
},
)?,
layer: 0,
visible: true,
},
directional_animated: DirectionalAnimated {
textures,
@@ -414,7 +435,7 @@ impl Game {
}
};
world.spawn(ghost);
world.spawn(ghost).insert((Frozen, Hidden));
}
Ok(())
@@ -449,112 +470,6 @@ impl Game {
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.
// ///
// /// Each ghost's path is drawn in its respective color with a small offset
@@ -618,42 +533,4 @@ impl Game {
// 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

@@ -174,6 +174,17 @@ impl Map {
})
}
/// Returns the `MapTile` at a given node id.
pub fn tile_at_node(&self, node_id: NodeId) -> Option<MapTile> {
// reverse lookup: node -> grid
for (grid_pos, id) in &self.grid_to_node {
if *id == node_id {
return Some(self.tiles[grid_pos.x as usize][grid_pos.y as usize]);
}
}
None
}
/// Constructs the ghost house area with restricted access and internal navigation.
///
/// Creates a multi-level ghost house with entrance control, internal movement

View File

@@ -24,44 +24,6 @@ impl CommonPlatform for Platform {
}
fn init_console(&self) -> Result<(), PlatformError> {
#[cfg(windows)]
{
unsafe {
use winapi::{
shared::ntdef::NULL,
um::{
fileapi::{CreateFileA, OPEN_EXISTING},
handleapi::INVALID_HANDLE_VALUE,
processenv::SetStdHandle,
winbase::{STD_ERROR_HANDLE, STD_OUTPUT_HANDLE},
wincon::{AttachConsole, GetConsoleWindow},
winnt::{FILE_SHARE_READ, FILE_SHARE_WRITE, GENERIC_READ, GENERIC_WRITE},
},
};
if !std::ptr::eq(GetConsoleWindow(), std::ptr::null_mut()) {
return Ok(());
}
if AttachConsole(winapi::um::wincon::ATTACH_PARENT_PROCESS) != 0 {
let handle = CreateFileA(
c"CONOUT$".as_ptr(),
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
std::ptr::null_mut(),
OPEN_EXISTING,
0,
NULL,
);
if handle != INVALID_HANDLE_VALUE {
SetStdHandle(STD_OUTPUT_HANDLE, handle);
SetStdHandle(STD_ERROR_HANDLE, handle);
}
}
}
}
Ok(())
}

View File

@@ -6,10 +6,20 @@
use bevy_ecs::{
event::{Event, EventReader, EventWriter},
resource::Resource,
system::{NonSendMut, ResMut},
};
use crate::{audio::Audio, error::GameError, systems::components::AudioState};
use crate::{audio::Audio, error::GameError};
/// Resource for tracking audio state
#[derive(Resource, Debug, Clone, Default)]
pub struct AudioState {
/// Whether audio is currently muted
pub muted: bool,
/// Current sound index for cycling through eat sounds
pub sound_index: usize,
}
/// Events for triggering audio playback
#[derive(Event, Debug, Clone, Copy, PartialEq, Eq)]

View File

@@ -1,27 +1,63 @@
use bevy_ecs::{
component::Component,
system::{Query, Res},
entity::Entity,
query::{Has, With},
system::{Commands, Query, Res},
};
use crate::systems::components::{DeltaTime, Renderable};
use crate::systems::{
components::{DeltaTime, Renderable},
Frozen, Hidden,
};
#[derive(Component)]
#[derive(Component, Debug)]
pub struct Blinking {
pub timer: f32,
pub interval: f32,
}
impl Blinking {
pub fn new(interval: f32) -> Self {
Self { timer: 0.0, interval }
}
}
/// Updates blinking entities by toggling their visibility at regular intervals.
///
/// This system manages entities that have both `Blinking` and `Renderable` components,
/// accumulating time and toggling visibility when the specified interval is reached.
pub fn blinking_system(time: Res<DeltaTime>, mut query: Query<(&mut Blinking, &mut Renderable)>) {
for (mut blinking, mut renderable) in query.iter_mut() {
#[allow(clippy::type_complexity)]
pub fn blinking_system(
mut commands: Commands,
time: Res<DeltaTime>,
mut query: Query<(Entity, &mut Blinking, Has<Hidden>, Has<Frozen>), With<Renderable>>,
) {
for (entity, mut blinking, hidden, frozen) in query.iter_mut() {
// If the entity is frozen, blinking is disabled and the entity is unhidden (if it was hidden)
if frozen {
if hidden {
commands.entity(entity).remove::<Hidden>();
}
continue;
}
// Increase the timer by the delta time
blinking.timer += time.0;
if blinking.timer >= blinking.interval {
blinking.timer = 0.0;
renderable.visible = !renderable.visible;
// If the timer is less than the interval, there's nothing to do yet
if blinking.timer < blinking.interval {
continue;
}
// Subtract the interval (allows for the timer to retain partial interval progress)
blinking.timer -= blinking.interval;
// Toggle the Hidden component
if hidden {
commands.entity(entity).remove::<Hidden>();
} else {
commands.entity(entity).insert(Hidden);
}
}
}

View File

@@ -1,13 +1,56 @@
use bevy_ecs::component::Component;
use bevy_ecs::entity::Entity;
use bevy_ecs::event::EventWriter;
use bevy_ecs::event::{EventReader, EventWriter};
use bevy_ecs::query::With;
use bevy_ecs::system::{Query, Res};
use bevy_ecs::system::{Commands, Query, Res, ResMut};
use crate::error::GameError;
use crate::events::GameEvent;
use crate::map::builder::Map;
use crate::systems::components::{Collider, ItemCollider, PacmanCollider};
use crate::systems::movement::Position;
use crate::systems::{AudioEvent, Ghost, PlayerControlled, ScoreResource, Vulnerable};
#[derive(Component)]
pub struct Collider {
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
#[derive(Component)]
pub struct PacmanCollider;
#[derive(Component)]
pub struct GhostCollider;
#[derive(Component)]
pub struct ItemCollider;
/// 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.
///
@@ -16,42 +59,92 @@ use crate::systems::movement::Position;
/// a `GameEvent::Collision` for the item system to handle scoring and removal.
/// Collision detection accounts for both entities being in motion and supports
/// 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(
map: Res<Map>,
pacman_query: Query<(Entity, &Position, &Collider), With<PacmanCollider>>,
item_query: Query<(Entity, &Position, &Collider), With<ItemCollider>>,
ghost_query: Query<(Entity, &Position, &Collider), With<GhostCollider>>,
mut events: EventWriter<GameEvent>,
mut errors: EventWriter<GameError>,
) {
// Check PACMAN × ITEM collisions
for (pacman_entity, pacman_pos, pacman_collider) in pacman_query.iter() {
for (item_entity, item_pos, item_collider) in item_query.iter() {
match (
pacman_pos.get_pixel_position(&map.graph),
item_pos.get_pixel_position(&map.graph),
) {
(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 {
match check_collision(pacman_pos, pacman_collider, item_pos, item_collider, &map) {
Ok(colliding) => {
if colliding {
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
(result_a, result_b) => {
for result in [result_a, result_b] {
if let Err(e) = result {
errors.write(GameError::InvalidState(format!(
"Collision system failed to get pixel positions for entities {:?} and {:?}: {}",
pacman_entity, item_entity, e
)));
}
Err(e) => {
errors.write(GameError::InvalidState(format!(
"Collision system failed to check collision between 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
)));
}
}
}
}
}
pub fn ghost_collision_system(
mut commands: Commands,
mut collision_events: EventReader<GameEvent>,
mut score: ResMut<ScoreResource>,
pacman_query: Query<(), With<PlayerControlled>>,
ghost_query: Query<(Entity, &Ghost), With<GhostCollider>>,
vulnerable_query: Query<Entity, With<Vulnerable>>,
mut events: EventWriter<AudioEvent>,
) {
for event in collision_events.read() {
if let GameEvent::Collision(entity1, entity2) = event {
// Check if one is Pacman and the other is a ghost
let (_pacman_entity, ghost_entity) = if pacman_query.get(*entity1).is_ok() && ghost_query.get(*entity2).is_ok() {
(*entity1, *entity2)
} else if pacman_query.get(*entity2).is_ok() && ghost_query.get(*entity1).is_ok() {
(*entity2, *entity1)
} else {
continue;
};
// Check if the ghost is vulnerable
if let Ok((ghost_ent, _ghost_type)) = ghost_query.get(ghost_entity) {
// Check if ghost has Vulnerable component
if vulnerable_query.get(ghost_ent).is_ok() {
// Pac-Man eats the ghost
// Add score (200 points per ghost eaten)
score.0 += 200;
// Remove the ghost
commands.entity(ghost_ent).despawn();
// Play eat sound
events.write(AudioEvent::PlayEat);
} else {
// Pac-Man dies (this would need a death system)
// For now, just log it
tracing::warn!("Pac-Man collided with ghost while not vulnerable!");
}
}
}
}

View File

@@ -3,7 +3,10 @@ use bitflags::bitflags;
use crate::{
map::graph::TraversalFlags,
systems::movement::{BufferedDirection, Position, Velocity},
systems::{
movement::{BufferedDirection, Position, Velocity},
Collider, GhostCollider, ItemCollider, PacmanCollider,
},
texture::{animated::AnimatedTexture, sprite::AtlasTile},
};
@@ -90,7 +93,6 @@ impl EntityType {
pub struct Renderable {
pub sprite: AtlasTile,
pub layer: u8,
pub visible: bool,
}
/// A component for entities that have a directional animated texture.
@@ -109,20 +111,44 @@ bitflags! {
}
}
#[derive(Component)]
pub struct Collider {
pub size: f32,
#[derive(Resource)]
pub struct GlobalState {
pub exit: bool,
}
/// Marker components for collision filtering optimization
#[derive(Component)]
pub struct PacmanCollider;
#[derive(Resource)]
pub struct ScoreResource(pub u32);
#[derive(Component)]
pub struct GhostCollider;
#[derive(Resource)]
pub struct DeltaTime(pub f32);
#[derive(Component)]
pub struct ItemCollider;
/// Movement modifiers that can affect Pac-Man's speed or handling.
#[derive(Component, Debug, Clone, Copy)]
pub struct MovementModifiers {
/// Multiplier applied to base speed (e.g., tunnels)
pub speed_multiplier: f32,
/// True when currently in a tunnel slowdown region
pub tunnel_slowdown_active: bool,
}
impl Default for MovementModifiers {
fn default() -> Self {
Self {
speed_multiplier: 1.0,
tunnel_slowdown_active: false,
}
}
}
/// Tag component for entities that should be frozen during startup
#[derive(Component, Debug, Clone, Copy)]
pub struct Frozen;
/// Component for ghosts that are vulnerable to Pac-Man
#[derive(Component, Debug, Clone, Copy)]
pub struct Vulnerable {
pub remaining_ticks: u32,
}
#[derive(Bundle)]
pub struct PlayerBundle {
@@ -134,6 +160,7 @@ pub struct PlayerBundle {
pub directional_animated: DirectionalAnimated,
pub entity_type: EntityType,
pub collider: Collider,
pub movement_modifiers: MovementModifiers,
pub pacman_collider: PacmanCollider,
}
@@ -157,26 +184,3 @@ pub struct GhostBundle {
pub collider: Collider,
pub ghost_collider: GhostCollider,
}
#[derive(Resource)]
pub struct GlobalState {
pub exit: bool,
}
#[derive(Resource)]
pub struct ScoreResource(pub u32);
#[derive(Resource)]
pub struct DeltaTime(pub f32);
#[derive(Resource, Default)]
pub struct RenderDirty(pub bool);
/// Resource for tracking audio state
#[derive(Resource, Debug, Clone, Default)]
pub struct AudioState {
/// Whether audio is currently muted
pub muted: bool,
/// Current sound index for cycling through eat sounds
pub sound_index: usize,
}

View File

@@ -3,12 +3,9 @@ use std::cmp::Ordering;
use crate::constants::BOARD_PIXEL_OFFSET;
use crate::map::builder::Map;
use crate::systems::components::Collider;
use crate::systems::input::CursorPosition;
use crate::systems::movement::Position;
use crate::systems::profiling::SystemTimings;
use crate::systems::render::BackbufferResource;
use bevy_ecs::prelude::*;
use crate::systems::{Collider, CursorPosition, Position, SystemTimings};
use bevy_ecs::resource::Resource;
use bevy_ecs::system::{NonSendMut, Query, Res};
use glam::{IVec2, UVec2, Vec2};
use sdl2::pixels::Color;
use sdl2::rect::{Point, Rect};
@@ -16,22 +13,13 @@ use sdl2::render::{Canvas, Texture, TextureCreator};
use sdl2::ttf::Font;
use sdl2::video::{Window, WindowContext};
#[derive(Resource, Default, Debug, Copy, Clone, PartialEq)]
pub enum DebugState {
#[default]
Off,
Graph,
Collision,
#[derive(Resource, Default, Debug, Copy, Clone)]
pub struct DebugState {
pub enabled: bool,
}
impl DebugState {
pub fn next(&self) -> Self {
match self {
DebugState::Off => DebugState::Graph,
DebugState::Graph => DebugState::Collision,
DebugState::Collision => DebugState::Off,
}
}
fn f32_to_u8(value: f32) -> u8 {
(value * 255.0) as u8
}
/// Resource to hold the debug texture for persistent rendering
@@ -101,7 +89,6 @@ fn render_timing_display(
#[allow(clippy::too_many_arguments)]
pub fn debug_render_system(
mut canvas: NonSendMut<&mut Canvas<Window>>,
backbuffer: NonSendMut<BackbufferResource>,
mut debug_texture: NonSendMut<DebugTextureResource>,
debug_font: NonSendMut<DebugFontResource>,
debug_state: Res<DebugState>,
@@ -110,24 +97,12 @@ pub fn debug_render_system(
colliders: Query<(&Collider, &Position)>,
cursor: Res<CursorPosition>,
) {
if *debug_state == DebugState::Off {
if !debug_state.enabled {
return;
}
let scale =
(UVec2::from(canvas.output_size().unwrap()).as_vec2() / UVec2::from(canvas.logical_size()).as_vec2()).min_element();
// Copy the current backbuffer to the debug texture
canvas
.with_texture_canvas(&mut debug_texture.0, |debug_canvas| {
// Clear the debug canvas
debug_canvas.set_draw_color(Color::BLACK);
debug_canvas.clear();
// Copy the backbuffer to the debug canvas
debug_canvas.copy(&backbuffer.0, None, None).unwrap();
})
.unwrap();
// Get texture creator before entering the closure to avoid borrowing conflicts
let mut texture_creator = canvas.texture_creator();
let font = &debug_font.0;
@@ -140,86 +115,93 @@ pub fn debug_render_system(
// Draw debug info on the high-resolution debug texture
canvas
.with_texture_canvas(&mut debug_texture.0, |debug_canvas| {
match *debug_state {
DebugState::Graph => {
// Find the closest node to the cursor
// Clear the debug canvas
debug_canvas.set_draw_color(Color::RGBA(0, 0, 0, 0));
debug_canvas.clear();
let closest_node = if let Some(cursor_world_pos) = cursor_world_pos {
map.graph
.nodes()
.map(|node| node.position.distance(cursor_world_pos))
.enumerate()
.min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(Ordering::Less))
.map(|(id, _)| id)
// Find the closest node to the cursor
let closest_node = if let Some(cursor_world_pos) = cursor_world_pos {
map.graph
.nodes()
.map(|node| node.position.distance(cursor_world_pos))
.enumerate()
.min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(Ordering::Less))
.map(|(id, _)| id)
} else {
None
};
debug_canvas.set_draw_color(Color::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 {
None
};
Color::BLUE
})
});
debug_canvas.set_draw_color(Color::RED);
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 position using common method
let pos = transform_position_with_offset(pos, scale);
let size = (2.0 * scale) as u32;
// 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
.fill_rect(Rect::new(pos.x - (size as i32 / 2), pos.y - (size as i32 / 2), size, size))
.unwrap();
}
debug_canvas
.draw_line(Point::from((start.x, start.y)), Point::from((end.x, end.y)))
.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);
for (id, node) in map.graph.nodes().enumerate() {
let pos = node.position;
// Set color based on whether the node is the closest to the cursor
debug_canvas.set_draw_color(if Some(id) == closest_node {
Color::YELLOW
} else {
Color::BLUE
});
// Transform position using common method
let pos = transform_position_with_offset(pos, scale);
let size = (3.0 * scale) as u32;
debug_canvas
.fill_rect(Rect::new(pos.x - (size as i32 / 2), pos.y - (size as i32 / 2), size, size))
.unwrap();
}
// Render node ID if a node is highlighted
if let Some(closest_node_id) = closest_node {
let node = map.graph.get_node(closest_node_id).unwrap();
let pos = transform_position_with_offset(node.position, scale);
let surface = font.render(&closest_node_id.to_string()).blended(Color::WHITE).unwrap();
let texture = texture_creator.create_texture_from_surface(&surface).unwrap();
let dest = Rect::new(pos.x + 10, pos.y - 5, texture.query().width, texture.query().height);
debug_canvas.copy(&texture, None, dest).unwrap();
}
}
DebugState::Collision => {
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();
}
}
_ => {}
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 dest = Rect::new(pos.x + 10, pos.y - 5, texture.query().width, texture.query().height);
debug_canvas.copy(&texture, None, dest).unwrap();
}
// Render timing information in the top-left corner
render_timing_display(debug_canvas, &mut texture_creator, &timings, font);
})
.unwrap();
// Draw the debug texture directly onto the main canvas at full resolution
canvas.copy(&debug_texture.0, None, None).unwrap();
}

View File

@@ -1,107 +0,0 @@
use num_width::NumberWidth;
use smallvec::SmallVec;
use std::time::Duration;
use strum::EnumCount;
use crate::systems::profiling::SystemId;
// Helper to split a duration into a integer, decimal, and unit
fn get_value(duration: &Duration) -> (u64, u32, &'static str) {
let (int, decimal, unit) = match duration {
// if greater than 1 second, return as seconds
n if n >= &Duration::from_secs(1) => {
let secs = n.as_secs();
let decimal = n.as_millis() as u64 % 1000;
(secs, decimal as u32, "s")
}
// if greater than 1 millisecond, return as milliseconds
n if n >= &Duration::from_millis(1) => {
let ms = n.as_millis() as u64;
let decimal = n.as_micros() as u64 % 1000;
(ms, decimal as u32, "ms")
}
// if greater than 1 microsecond, return as microseconds
n if n >= &Duration::from_micros(1) => {
let us = n.as_micros() as u64;
let decimal = n.as_nanos() as u64 % 1000;
(us, decimal as u32, "µs")
}
// otherwise, return as nanoseconds
n => {
let ns = n.as_nanos() as u64;
(ns, 0, "ns")
}
};
(int, decimal, unit)
}
/// Formats timing data into a vector of strings with proper alignment
pub fn format_timing_display(
timing_data: impl IntoIterator<Item = (String, Duration, Duration)>,
) -> SmallVec<[String; SystemId::COUNT]> {
let mut iter = timing_data.into_iter().peekable();
if iter.peek().is_none() {
return SmallVec::new();
}
struct Entry {
name: String,
avg_int: u64,
avg_decimal: u32,
avg_unit: &'static str,
std_int: u64,
std_decimal: u32,
std_unit: &'static str,
}
let entries = iter
.map(|(name, avg, std_dev)| {
let (avg_int, avg_decimal, avg_unit) = get_value(&avg);
let (std_int, std_decimal, std_unit) = get_value(&std_dev);
Entry {
name: name.clone(),
avg_int,
avg_decimal,
avg_unit,
std_int,
std_decimal,
std_unit,
}
})
.collect::<SmallVec<[Entry; 12]>>();
let (max_name_width, max_avg_int_width, max_avg_decimal_width, max_std_int_width, max_std_decimal_width) = entries
.iter()
.fold((0, 0, 3, 0, 3), |(name_w, avg_int_w, avg_dec_w, std_int_w, std_dec_w), e| {
(
name_w.max(e.name.len()),
avg_int_w.max(e.avg_int.width() as usize),
avg_dec_w.max(e.avg_decimal.width() as usize),
std_int_w.max(e.std_int.width() as usize),
std_dec_w.max(e.std_decimal.width() as usize),
)
});
entries.iter().map(|e| {
format!(
"{name:max_name_width$} : {avg_int:max_avg_int_width$}.{avg_decimal:<max_avg_decimal_width$}{avg_unit} ± {std_int:max_std_int_width$}.{std_decimal:<max_std_decimal_width$}{std_unit}",
// Content
name = e.name,
avg_int = e.avg_int,
avg_decimal = e.avg_decimal,
std_int = e.std_int,
std_decimal = e.std_decimal,
// Units
avg_unit = e.avg_unit,
std_unit = e.std_unit,
// Padding
max_name_width = max_name_width,
max_avg_int_width = max_avg_int_width,
max_avg_decimal_width = max_avg_decimal_width,
max_std_int_width = max_std_int_width,
max_std_decimal_width = max_std_decimal_width
)
}).collect::<SmallVec<[String; SystemId::COUNT]>>()
}

View File

@@ -1,7 +1,4 @@
use bevy_ecs::system::{Query, Res};
use rand::prelude::*;
use smallvec::SmallVec;
use crate::systems::components::Frozen;
use crate::{
map::{
builder::Map,
@@ -13,12 +10,18 @@ use crate::{
movement::{Position, Velocity},
},
};
use bevy_ecs::query::Without;
use bevy_ecs::system::{Query, Res};
use rand::rngs::SmallRng;
use rand::seq::IndexedRandom;
use rand::SeedableRng;
use smallvec::SmallVec;
/// Autonomous ghost AI system implementing randomized movement with backtracking avoidance.
pub fn ghost_movement_system(
map: Res<Map>,
delta_time: Res<DeltaTime>,
mut ghosts: Query<(&Ghost, &mut Velocity, &mut Position)>,
mut ghosts: Query<(&Ghost, &mut Velocity, &mut Position), Without<Frozen>>,
) {
for (_ghost, mut velocity, mut position) in ghosts.iter_mut() {
let mut distance = velocity.speed * 60.0 * delta_time.0;

View File

@@ -28,7 +28,7 @@ pub enum CursorPosition {
pub struct Bindings {
key_bindings: HashMap<Keycode, GameCommand>,
movement_keys: HashSet<Keycode>,
last_movement_key: Option<Keycode>,
pressed_movement_keys: Vec<Keycode>,
}
impl Default for Bindings {
@@ -67,11 +67,63 @@ impl Default for Bindings {
Self {
key_bindings,
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(
delta_time: Res<DeltaTime>,
mut bindings: ResMut<Bindings>,
@@ -79,11 +131,14 @@ pub fn input_system(
mut pump: NonSendMut<&'static mut EventPump>,
mut cursor: ResMut<CursorPosition>,
) {
let mut movement_key_pressed = 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() {
match event {
// Handle non-keyboard events inline and build a simplified keyboard event stream.
let mut simple_key_events = Vec::new();
for event in &frame_events {
match *event {
Event::Quit { .. } => {
writer.write(GameEvent::Command(GameCommand::Exit));
}
@@ -94,44 +149,28 @@ pub fn input_system(
};
cursor_seen = true;
}
Event::KeyUp {
repeat: false,
keycode: Some(key),
..
} => {
// If the last movement key was released, then forget it.
if let Some(last_movement_key) = bindings.last_movement_key {
if last_movement_key == key {
bindings.last_movement_key = None;
}
}
}
Event::KeyDown {
keycode: Some(key),
repeat: false,
..
} => {
let command = bindings.key_bindings.get(&key).copied();
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);
}
simple_key_events.push(SimpleKeyEvent::KeyDown(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 {
if !movement_key_pressed {
let command = bindings.key_bindings.get(&last_movement_key).copied();
if let Some(command) = command {
writer.write(GameEvent::Command(command));
}
}
// Delegate keyboard handling to shared logic used by tests and production.
let emitted = process_simple_key_events(&mut bindings, &simple_key_events);
for event in emitted {
writer.write(event);
}
if let (false, CursorPosition::Some { remaining_time, .. }) = (cursor_seen, &mut *cursor) {

View File

@@ -1,11 +1,13 @@
use bevy_ecs::{event::EventReader, prelude::*, query::With, system::Query};
use bevy_ecs::{
entity::Entity,
event::{EventReader, EventWriter},
query::With,
system::{Commands, Query, ResMut},
};
use crate::{
events::GameEvent,
systems::{
audio::AudioEvent,
components::{EntityType, ItemCollider, PacmanCollider, ScoreResource},
},
systems::{AudioEvent, EntityType, GhostCollider, ItemCollider, PacmanCollider, ScoreResource, Vulnerable},
};
/// Determines if a collision between two entity types should be handled by the item system.
@@ -25,6 +27,7 @@ pub fn item_system(
mut score: ResMut<ScoreResource>,
pacman_query: Query<Entity, With<PacmanCollider>>,
item_query: Query<(Entity, &EntityType), With<ItemCollider>>,
ghost_query: Query<Entity, With<GhostCollider>>,
mut events: EventWriter<AudioEvent>,
) {
for event in collision_events.read() {
@@ -50,6 +53,19 @@ pub fn item_system(
if entity_type.is_collectible() {
events.write(AudioEvent::PlayEat);
}
// Make ghosts vulnerable when power pellet is collected
if *entity_type == EntityType::PowerPellet {
// Convert seconds to frames (assumes 60 FPS)
let total_ticks = 60 * 5;
// Add Vulnerable component to all ghosts
for ghost_entity in ghost_query.iter() {
commands.entity(ghost_entity).insert(Vulnerable {
remaining_ticks: total_ticks,
});
}
}
}
}
}

View File

@@ -8,7 +8,6 @@ pub mod blinking;
pub mod collision;
pub mod components;
pub mod debug;
pub mod formatting;
pub mod ghost;
pub mod input;
pub mod item;
@@ -16,3 +15,20 @@ pub mod movement;
pub mod player;
pub mod profiling;
pub mod render;
pub mod stage;
pub mod vulnerable;
pub use self::audio::*;
pub use self::blinking::*;
pub use self::collision::*;
pub use self::components::*;
pub use self::debug::*;
pub use self::ghost::*;
pub use self::input::*;
pub use self::item::*;
pub use self::movement::*;
pub use self::player::*;
pub use self::profiling::*;
pub use self::render::*;
pub use self::stage::*;
pub use self::vulnerable::*;

View File

@@ -1,22 +1,26 @@
use bevy_ecs::{
event::{EventReader, EventWriter},
prelude::ResMut,
query::With,
system::{Query, Res},
query::{With, Without},
system::{Query, Res, ResMut},
};
use crate::{
error::GameError,
events::{GameCommand, GameEvent},
map::builder::Map,
map::graph::Edge,
map::{builder::Map, graph::Edge},
systems::{
components::{AudioState, DeltaTime, EntityType, GlobalState, PlayerControlled},
components::{DeltaTime, EntityType, Frozen, GlobalState, MovementModifiers, PlayerControlled},
debug::DebugState,
movement::{BufferedDirection, Position, Velocity},
AudioState,
},
};
pub fn can_traverse(entity_type: EntityType, edge: Edge) -> bool {
let entity_flags = entity_type.traversal_flags();
edge.traversal_flags.contains(entity_flags)
}
/// Processes player input commands and updates game state accordingly.
///
/// Handles keyboard-driven commands like movement direction changes, debug mode
@@ -28,26 +32,26 @@ pub fn player_control_system(
mut state: ResMut<GlobalState>,
mut debug_state: ResMut<DebugState>,
mut audio_state: ResMut<AudioState>,
mut players: Query<&mut BufferedDirection, With<PlayerControlled>>,
mut players: Query<&mut BufferedDirection, (With<PlayerControlled>, Without<Frozen>)>,
mut errors: EventWriter<GameError>,
) {
// Get the player's movable component (ensuring there is only one player)
let mut buffered_direction = match players.single_mut() {
Ok(buffered_direction) => buffered_direction,
Err(e) => {
errors.write(GameError::InvalidState(format!(
"No/multiple entities queried for player system: {}",
e
)));
return;
}
};
// Handle events
for event in events.read() {
if let GameEvent::Command(command) = event {
match command {
GameCommand::MovePlayer(direction) => {
// Get the player's movable component (ensuring there is only one player)
let mut buffered_direction = match players.single_mut() {
Ok(tuple) => tuple,
Err(e) => {
errors.write(GameError::InvalidState(format!(
"No/multiple entities queried for player system: {}",
e
)));
return;
}
};
*buffered_direction = BufferedDirection::Some {
direction: *direction,
remaining_time: 0.25,
@@ -57,7 +61,7 @@ pub fn player_control_system(
state.exit = true;
}
GameCommand::ToggleDebug => {
*debug_state = debug_state.next();
debug_state.enabled = !debug_state.enabled;
}
GameCommand::MuteAudio => {
audio_state.muted = !audio_state.muted;
@@ -69,23 +73,21 @@ pub fn player_control_system(
}
}
pub fn can_traverse(entity_type: EntityType, edge: Edge) -> bool {
let entity_flags = entity_type.traversal_flags();
edge.traversal_flags.contains(entity_flags)
}
/// Executes frame-by-frame movement for Pac-Man.
///
/// Handles movement logic including buffered direction changes, edge traversal validation, and continuous movement between nodes.
/// When stopped, prioritizes buffered directions for responsive controls, falling back to current direction.
/// Supports movement chaining within a single frame when traveling at high speeds.
#[allow(clippy::type_complexity)]
pub fn player_movement_system(
map: Res<Map>,
delta_time: Res<DeltaTime>,
mut entities: Query<(&mut Position, &mut Velocity, &mut BufferedDirection), With<PlayerControlled>>,
// mut errors: EventWriter<GameError>,
mut entities: Query<
(&MovementModifiers, &mut Position, &mut Velocity, &mut BufferedDirection),
(With<PlayerControlled>, Without<Frozen>),
>,
) {
for (mut position, mut velocity, mut buffered_direction) in entities.iter_mut() {
for (modifiers, mut position, mut velocity, mut buffered_direction) in entities.iter_mut() {
// Decrement the buffered direction remaining time
if let BufferedDirection::Some {
direction,
@@ -102,7 +104,7 @@ pub fn player_movement_system(
}
}
let mut distance = velocity.speed * 60.0 * delta_time.0;
let mut distance = velocity.speed * modifiers.speed_multiplier * 60.0 * delta_time.0;
loop {
match *position {
@@ -151,3 +153,16 @@ pub fn player_movement_system(
}
}
}
/// Applies tunnel slowdown based on the current node tile
pub fn player_tunnel_slowdown_system(map: Res<Map>, mut q: Query<(&Position, &mut MovementModifiers), With<PlayerControlled>>) {
if let Ok((position, mut modifiers)) = q.single_mut() {
let node = position.current_node();
let in_tunnel = map
.tile_at_node(node)
.map(|t| t == crate::constants::MapTile::Tunnel)
.unwrap_or(false);
modifiers.tunnel_slowdown_active = in_tunnel;
modifiers.speed_multiplier = if in_tunnel { 0.6 } else { 1.0 };
}
}

View File

@@ -1,7 +1,8 @@
use bevy_ecs::prelude::Resource;
use bevy_ecs::system::{IntoSystem, System};
use bevy_ecs::system::IntoSystem;
use bevy_ecs::{resource::Resource, system::System};
use circular_buffer::CircularBuffer;
use micromap::Map;
use num_width::NumberWidth;
use parking_lot::{Mutex, RwLock};
use smallvec::SmallVec;
use std::fmt::Display;
@@ -10,8 +11,6 @@ use strum::EnumCount;
use strum_macros::{EnumCount, IntoStaticStr};
use thousands::Separable;
use crate::systems::formatting;
/// The maximum number of systems that can be profiled. Must not be exceeded, or it will panic.
const MAX_SYSTEMS: usize = SystemId::COUNT;
/// The number of durations to keep in the circular buffer.
@@ -27,12 +26,15 @@ pub enum SystemId {
Blinking,
DirectionalRender,
DirtyRender,
HudRender,
Render,
DebugRender,
Present,
Collision,
Item,
PlayerMovement,
GhostCollision,
Stage,
}
impl Display for SystemId {
@@ -156,7 +158,7 @@ impl SystemTimings {
}
// Use the formatting module to format the data
formatting::format_timing_display(timing_data)
format_timing_display(timing_data)
}
}
@@ -181,3 +183,104 @@ where
}
}
}
// Helper to split a duration into a integer, decimal, and unit
fn get_value(duration: &Duration) -> (u64, u32, &'static str) {
let (int, decimal, unit) = match duration {
// if greater than 1 second, return as seconds
n if n >= &Duration::from_secs(1) => {
let secs = n.as_secs();
let decimal = n.as_millis() as u64 % 1000;
(secs, decimal as u32, "s")
}
// if greater than 1 millisecond, return as milliseconds
n if n >= &Duration::from_millis(1) => {
let ms = n.as_millis() as u64;
let decimal = n.as_micros() as u64 % 1000;
(ms, decimal as u32, "ms")
}
// if greater than 1 microsecond, return as microseconds
n if n >= &Duration::from_micros(1) => {
let us = n.as_micros() as u64;
let decimal = n.as_nanos() as u64 % 1000;
(us, decimal as u32, "µs")
}
// otherwise, return as nanoseconds
n => {
let ns = n.as_nanos() as u64;
(ns, 0, "ns")
}
};
(int, decimal, unit)
}
/// Formats timing data into a vector of strings with proper alignment
pub fn format_timing_display(
timing_data: impl IntoIterator<Item = (String, Duration, Duration)>,
) -> SmallVec<[String; SystemId::COUNT]> {
let mut iter = timing_data.into_iter().peekable();
if iter.peek().is_none() {
return SmallVec::new();
}
struct Entry {
name: String,
avg_int: u64,
avg_decimal: u32,
avg_unit: &'static str,
std_int: u64,
std_decimal: u32,
std_unit: &'static str,
}
let entries = iter
.map(|(name, avg, std_dev)| {
let (avg_int, avg_decimal, avg_unit) = get_value(&avg);
let (std_int, std_decimal, std_unit) = get_value(&std_dev);
Entry {
name: name.clone(),
avg_int,
avg_decimal,
avg_unit,
std_int,
std_decimal,
std_unit,
}
})
.collect::<SmallVec<[Entry; 12]>>();
let (max_name_width, max_avg_int_width, max_avg_decimal_width, max_std_int_width, max_std_decimal_width) = entries
.iter()
.fold((0, 0, 3, 0, 3), |(name_w, avg_int_w, avg_dec_w, std_int_w, std_dec_w), e| {
(
name_w.max(e.name.len()),
avg_int_w.max(e.avg_int.width() as usize),
avg_dec_w.max(e.avg_decimal.width() as usize),
std_int_w.max(e.std_int.width() as usize),
std_dec_w.max(e.std_decimal.width() as usize),
)
});
entries.iter().map(|e| {
format!(
"{name:max_name_width$} : {avg_int:max_avg_int_width$}.{avg_decimal:<max_avg_decimal_width$}{avg_unit} ± {std_int:max_std_int_width$}.{std_decimal:<max_std_decimal_width$}{std_unit}",
// Content
name = e.name,
avg_int = e.avg_int,
avg_decimal = e.avg_decimal,
std_int = e.std_int,
std_decimal = e.std_decimal,
// Units
avg_unit = e.avg_unit,
std_unit = e.std_unit,
// Padding
max_name_width = max_name_width,
max_avg_int_width = max_avg_int_width,
max_avg_decimal_width = max_avg_decimal_width,
max_std_int_width = max_std_int_width,
max_std_decimal_width = max_std_decimal_width
)
}).collect::<SmallVec<[String; SystemId::COUNT]>>()
}

View File

@@ -1,23 +1,38 @@
use crate::constants::CANVAS_SIZE;
use crate::error::{GameError, TextureError};
use crate::map::builder::Map;
use crate::systems::components::{DeltaTime, DirectionalAnimated, RenderDirty, Renderable};
use crate::systems::movement::{Position, Velocity};
use crate::systems::{
DebugState, DebugTextureResource, DeltaTime, DirectionalAnimated, Position, Renderable, ScoreResource, StartupSequence,
Velocity,
};
use crate::texture::sprite::SpriteAtlas;
use crate::texture::text::TextTexture;
use bevy_ecs::component::Component;
use bevy_ecs::entity::Entity;
use bevy_ecs::event::EventWriter;
use bevy_ecs::prelude::{Changed, Or, RemovedComponents};
use bevy_ecs::query::{Changed, Or, Without};
use bevy_ecs::removal_detection::RemovedComponents;
use bevy_ecs::resource::Resource;
use bevy_ecs::system::{NonSendMut, Query, Res, ResMut};
use sdl2::pixels::Color;
use sdl2::rect::{Point, Rect};
use sdl2::render::{Canvas, Texture};
use sdl2::render::{BlendMode, Canvas, Texture};
use sdl2::video::Window;
#[derive(Resource, Default)]
pub struct RenderDirty(pub bool);
#[derive(Component)]
pub struct Hidden;
#[allow(clippy::type_complexity)]
pub fn dirty_render_system(
mut dirty: ResMut<RenderDirty>,
changed_renderables: Query<(), Or<(Changed<Renderable>, Changed<Position>)>>,
changed: Query<(), Or<(Changed<Renderable>, Changed<Position>)>>,
removed_hidden: RemovedComponents<Hidden>,
removed_renderables: RemovedComponents<Renderable>,
) {
if !changed_renderables.is_empty() || !removed_renderables.is_empty() {
if !changed.is_empty() || !removed_hidden.is_empty() || !removed_renderables.is_empty() {
dirty.0 = true;
}
}
@@ -61,6 +76,63 @@ 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.
pub struct BackbufferResource(pub Texture<'static>);
/// Renders the HUD (score, lives, etc.) on top of the game.
pub fn hud_render_system(
mut backbuffer: NonSendMut<BackbufferResource>,
mut canvas: NonSendMut<&mut Canvas<Window>>,
mut atlas: NonSendMut<SpriteAtlas>,
score: Res<ScoreResource>,
startup: Res<StartupSequence>,
mut errors: EventWriter<GameError>,
) {
let _ = canvas.with_texture_canvas(&mut backbuffer.0, |canvas| {
let mut text_renderer = TextTexture::new(1.0);
// Render lives and high score text in white
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(canvas, &mut atlas, &lives_text, lives_position) {
errors.write(TextureError::RenderFailed(format!("Failed to render lives text: {}", e)).into());
}
// Render score text in yellow (Pac-Man's color)
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(canvas, &mut atlas, &score_text, score_position) {
errors.write(TextureError::RenderFailed(format!("Failed to render score text: {}", e)).into());
}
// Render text based on StartupSequence stage
if matches!(
*startup,
StartupSequence::TextOnly { .. } | StartupSequence::CharactersVisible { .. }
) {
let ready_text = "READY!";
let ready_width = text_renderer.text_width(ready_text);
let ready_position = glam::UVec2::new((CANVAS_SIZE.x - ready_width) / 2, 160);
if let Err(e) = text_renderer.render_with_color(canvas, &mut atlas, ready_text, ready_position, Color::YELLOW) {
errors.write(TextureError::RenderFailed(format!("Failed to render READY text: {}", e)).into());
}
if matches!(*startup, StartupSequence::TextOnly { .. }) {
let player_one_text = "PLAYER ONE";
let player_one_width = text_renderer.text_width(player_one_text);
let player_one_position = glam::UVec2::new((CANVAS_SIZE.x - player_one_width) / 2, 113);
if let Err(e) =
text_renderer.render_with_color(canvas, &mut atlas, player_one_text, player_one_position, Color::CYAN)
{
errors.write(TextureError::RenderFailed(format!("Failed to render PLAYER ONE text: {}", e)).into());
}
}
}
});
}
#[allow(clippy::too_many_arguments)]
pub fn render_system(
mut canvas: NonSendMut<&mut Canvas<Window>>,
@@ -69,7 +141,7 @@ pub fn render_system(
mut atlas: NonSendMut<SpriteAtlas>,
map: Res<Map>,
dirty: Res<RenderDirty>,
renderables: Query<(Entity, &Renderable, &Position)>,
renderables: Query<(Entity, &Renderable, &Position), Without<Hidden>>,
mut errors: EventWriter<GameError>,
) {
if !dirty.0 {
@@ -93,10 +165,6 @@ pub fn render_system(
.sort_by_key::<(Entity, &Renderable, &Position), _>(|(_, renderable, _)| renderable.layer)
.rev()
{
if !renderable.visible {
continue;
}
let pos = position.get_pixel_position(&map.graph);
match pos {
Ok(pos) => {
@@ -120,4 +188,28 @@ pub fn render_system(
})
.err()
.map(|e| errors.write(TextureError::RenderFailed(e.to_string()).into()));
canvas.copy(&backbuffer.0, None, None).unwrap();
}
pub fn present_system(
mut canvas: NonSendMut<&mut Canvas<Window>>,
mut dirty: ResMut<RenderDirty>,
backbuffer: NonSendMut<BackbufferResource>,
debug_texture: NonSendMut<DebugTextureResource>,
debug_state: Res<DebugState>,
) {
if dirty.0 {
// Copy the backbuffer to the main canvas
canvas.copy(&backbuffer.0, None, None).unwrap();
// Copy the debug texture to the canvas
if debug_state.enabled {
canvas.set_blend_mode(BlendMode::Blend);
canvas.copy(&debug_texture.0, None, None).unwrap();
}
canvas.present();
dirty.0 = false;
}
}

97
src/systems/stage.rs Normal file
View File

@@ -0,0 +1,97 @@
use bevy_ecs::{
entity::Entity,
query::With,
resource::Resource,
system::{Commands, Query, ResMut},
};
use tracing::debug;
use crate::systems::{Blinking, Frozen, GhostCollider, Hidden, PlayerControlled};
#[derive(Resource, Debug, Clone, Copy)]
pub enum StartupSequence {
/// Stage 1: Text-only stage
/// - Player & ghosts are hidden
/// - READY! and PLAYER ONE text are shown
/// - Energizers do not blink
TextOnly {
/// Remaining ticks in this stage
remaining_ticks: u32,
},
/// Stage 2: Characters visible stage
/// - PLAYER ONE text is hidden, READY! text remains
/// - Ghosts and Pac-Man are now shown
CharactersVisible {
/// Remaining ticks in this stage
remaining_ticks: u32,
},
/// Stage 3: Game begins
/// - Final state, game is fully active
GameActive,
}
impl StartupSequence {
/// Creates a new StartupSequence with the specified duration in ticks
pub fn new(text_only_ticks: u32, _characters_visible_ticks: u32) -> Self {
Self::TextOnly {
remaining_ticks: text_only_ticks,
}
}
/// Ticks the timer by one frame, returning transition information if state changes
pub fn tick(&mut self) -> Option<(StartupSequence, StartupSequence)> {
match self {
StartupSequence::TextOnly { remaining_ticks } => {
if *remaining_ticks > 0 {
*remaining_ticks -= 1;
None
} else {
let from = *self;
*self = StartupSequence::CharactersVisible {
remaining_ticks: 60, // 1 second at 60 FPS
};
Some((from, *self))
}
}
StartupSequence::CharactersVisible { remaining_ticks } => {
if *remaining_ticks > 0 {
*remaining_ticks -= 1;
None
} else {
let from = *self;
*self = StartupSequence::GameActive;
Some((from, *self))
}
}
StartupSequence::GameActive => None,
}
}
}
/// Handles startup sequence transitions and component management
pub fn startup_stage_system(
mut startup: ResMut<StartupSequence>,
mut commands: Commands,
mut blinking_query: Query<Entity, With<Blinking>>,
mut player_query: Query<Entity, With<PlayerControlled>>,
mut ghost_query: Query<Entity, With<GhostCollider>>,
) {
if let Some((from, to)) = startup.tick() {
debug!("StartupSequence transition from {from:?} to {to:?}");
match (from, to) {
// (StartupSequence::TextOnly { .. }, StartupSequence::CharactersVisible { .. }) => {}
(StartupSequence::CharactersVisible { .. }, StartupSequence::GameActive) => {
// Unfreeze and unhide the player & ghosts
for entity in player_query.iter_mut().chain(ghost_query.iter_mut()) {
commands.entity(entity).remove::<(Frozen, Hidden)>();
}
// Unfreeze pellet blinking
for entity in blinking_query.iter_mut() {
commands.entity(entity).remove::<Frozen>();
}
}
_ => {}
}
}
}

20
src/systems/vulnerable.rs Normal file
View File

@@ -0,0 +1,20 @@
use bevy_ecs::query::With;
use bevy_ecs::system::{Commands, Query};
use crate::systems::{GhostCollider, Vulnerable};
/// System that decrements the remaining_ticks on Vulnerable components and removes them when they reach zero
pub fn vulnerable_tick_system(
mut commands: Commands,
mut vulnerable_query: Query<(bevy_ecs::entity::Entity, &mut Vulnerable), With<GhostCollider>>,
) {
for (entity, mut vulnerable) in vulnerable_query.iter_mut() {
if vulnerable.remaining_ticks > 0 {
vulnerable.remaining_ticks -= 1;
}
if vulnerable.remaining_ticks == 0 {
commands.entity(entity).remove::<Vulnerable>();
}
}
}

View File

@@ -10,10 +10,20 @@
//!
//! ```rust
//! use pacman::texture::text::TextTexture;
//! use sdl2::pixels::Color;
//!
//! // Create a text texture with 1.0 scale (8x8 pixels per character)
//! let mut text_renderer = TextTexture::new(1.0);
//!
//! // Set default color for all text
//! text_renderer.set_color(Color::WHITE);
//!
//! // Render text with default color
//! text_renderer.render(&mut canvas, &mut atlas, "Hello", position)?;
//!
//! // Render text with specific color
//! text_renderer.render_with_color(&mut canvas, &mut atlas, "World", position, Color::YELLOW)?;
//!
//! // Set scale for larger text
//! text_renderer.set_scale(2.0);
//!
@@ -46,6 +56,7 @@
use anyhow::Result;
use glam::UVec2;
use sdl2::pixels::Color;
use sdl2::render::{Canvas, RenderTarget};
use std::collections::HashMap;
@@ -79,6 +90,7 @@ fn char_to_tile_name(c: char) -> Option<String> {
pub struct TextTexture {
char_map: HashMap<char, AtlasTile>,
scale: f32,
default_color: Option<Color>,
}
impl Default for TextTexture {
@@ -86,6 +98,7 @@ impl Default for TextTexture {
Self {
scale: 1.0,
char_map: Default::default(),
default_color: None,
}
}
}
@@ -119,13 +132,26 @@ impl TextTexture {
}
}
/// Renders a string of text at the given position.
/// Renders a string of text at the given position using the default color.
pub fn render<C: RenderTarget>(
&mut self,
canvas: &mut Canvas<C>,
atlas: &mut SpriteAtlas,
text: &str,
position: UVec2,
) -> Result<()> {
let color = self.default_color.unwrap_or(Color::WHITE);
self.render_with_color(canvas, atlas, text, position, color)
}
/// Renders a string of text at the given position with a specific color.
pub fn render_with_color<C: RenderTarget>(
&mut self,
canvas: &mut Canvas<C>,
atlas: &mut SpriteAtlas,
text: &str,
position: UVec2,
color: Color,
) -> Result<()> {
let mut x_offset = 0;
let char_width = (8.0 * self.scale) as u32;
@@ -134,9 +160,9 @@ impl TextTexture {
for c in text.chars() {
// Get the tile from the char_map, or insert it if it doesn't exist
if let Some(tile) = self.get_tile(c, atlas)? {
// Render the tile if it exists
// Render the tile with the specified color
let dest = sdl2::rect::Rect::new((position.x + x_offset) as i32, position.y as i32, char_width, char_height);
tile.render(canvas, atlas, dest)?;
tile.render_with_color(canvas, atlas, dest, color)?;
}
// Always advance x_offset for all characters (including spaces)
@@ -146,6 +172,16 @@ impl TextTexture {
Ok(())
}
/// Sets the default color for text rendering.
pub fn set_color(&mut self, color: Color) {
self.default_color = Some(color);
}
/// Gets the current default color.
pub fn color(&self) -> Option<Color> {
self.default_color
}
/// Sets the scale for text rendering.
pub fn set_scale(&mut self, scale: f32) {
self.scale = scale;

148
tests/collision.rs Normal file
View File

@@ -0,0 +1,148 @@
use bevy_ecs::{entity::Entity, event::Events, system::RunSystemOnce, world::World};
use pacman::{
error::GameError,
events::GameEvent,
map::builder::Map,
systems::{
check_collision, collision_system, Collider, EntityType, Ghost, GhostCollider, ItemCollider, PacmanCollider, 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,61 +1,6 @@
use pacman::events::{GameCommand, GameEvent};
use pacman::map::direction::Direction;
#[test]
fn test_game_command_variants() {
// Test that all GameCommand variants can be created
let commands = [
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_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]
fn test_game_command_to_game_event_conversion_all_variants() {
let commands = vec![
@@ -72,35 +17,3 @@ fn test_game_command_to_game_event_conversion_all_variants() {
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

@@ -1,4 +1,4 @@
use pacman::systems::formatting::format_timing_display;
use pacman::systems::profiling::format_timing_display;
use std::time::Duration;
use pretty_assertions::assert_eq;
@@ -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]
fn test_format_timing_display_units() {
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());
}

View File

@@ -1,13 +1,11 @@
use bevy_ecs::{event::Events, prelude::*, system::RunSystemOnce, world::World};
use bevy_ecs::{entity::Entity, event::Events, 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,
is_valid_item_collision, item_system, AudioEvent, AudioState, EntityType, ItemCollider, PacmanCollider, Position,
ScoreResource,
},
};
@@ -16,7 +14,6 @@ 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::Pellet.score_value().unwrap() < EntityType::PowerPellet.score_value().unwrap());
assert!(EntityType::Player.score_value().is_none());
assert!(EntityType::Ghost.score_value().is_none());
}
@@ -194,28 +191,6 @@ fn test_item_system_ignores_non_item_collisions() {
assert_eq!(ghost_count, 1);
}
#[test]
fn test_item_system_wrong_collision_order() {
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 with entities in reverse order
send_collision_event(&mut world, pellet, pacman);
world.run_system_once(item_system).expect("System should run successfully");
// Should still work correctly
let score = world.resource::<ScoreResource>();
assert_eq!(score.0, 10);
let pellet_count = world
.query::<&EntityType>()
.iter(&world)
.filter(|&entity_type| matches!(entity_type, EntityType::Pellet))
.count();
assert_eq!(pellet_count, 0);
}
#[test]
fn test_item_system_no_collision_events() {
let mut world = create_test_world();

View File

@@ -1,4 +1,4 @@
use bevy_ecs::{event::Events, prelude::*, system::RunSystemOnce, world::World};
use bevy_ecs::{entity::Entity, event::Events, system::RunSystemOnce, world::World};
use pacman::{
events::{GameCommand, GameEvent},
@@ -8,10 +8,8 @@ use pacman::{
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},
can_traverse, player_control_system, player_movement_system, AudioState, BufferedDirection, DebugState, DeltaTime,
EntityType, GlobalState, MovementModifiers, PlayerControlled, Position, Velocity,
},
};
@@ -21,7 +19,7 @@ fn create_test_world() -> World {
// Add resources
world.insert_resource(GlobalState { exit: false });
world.insert_resource(DebugState::Off);
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());
@@ -51,6 +49,7 @@ fn spawn_test_player(world: &mut World) -> Entity {
},
BufferedDirection::None,
EntityType::Player,
MovementModifiers::default(),
))
.id()
}
@@ -222,7 +221,7 @@ fn test_player_control_system_toggle_debug() {
// Check that debug state changed
let debug_state = world.resource::<DebugState>();
assert_eq!(*debug_state, DebugState::Graph);
assert!(debug_state.enabled);
}
#[test]
@@ -565,7 +564,7 @@ fn test_player_state_persistence_across_systems() {
let position = *query.single(&world).expect("Player should exist");
// Check that the state changes persisted individually
assert_eq!(debug_state_after_toggle, DebugState::Graph, "Debug state should have toggled");
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

View File

@@ -107,3 +107,23 @@ fn test_text_scale() -> Result<(), String> {
Ok(())
}
#[test]
fn test_text_color() -> Result<(), String> {
let mut text_texture = TextTexture::new(1.0);
// Test default color (should be None initially)
assert_eq!(text_texture.color(), None);
// Test setting color
let test_color = sdl2::pixels::Color::YELLOW;
text_texture.set_color(test_color);
assert_eq!(text_texture.color(), Some(test_color));
// Test changing color
let new_color = sdl2::pixels::Color::RED;
text_texture.set_color(new_color);
assert_eq!(text_texture.color(), Some(new_color));
Ok(())
}