From d84f0c831e72668d90d531ef13543afb0ff6c91b Mon Sep 17 00:00:00 2001 From: Ryan Walters Date: Wed, 10 Sep 2025 21:36:51 -0500 Subject: [PATCH] feat: proper scheduling via SystemSet, non-conditional game systems, better collision handling --- src/constants.rs | 8 +- src/events.rs | 2 +- src/game.rs | 90 +++++++++----- src/systems/animation/directional.rs | 6 +- src/systems/collision.rs | 76 ++++++----- src/systems/common/components.rs | 2 +- src/systems/ghost.rs | 4 +- src/systems/player.rs | 44 +++---- src/systems/state.rs | 180 ++++++++++++++++++--------- tests/item.rs | 49 ++------ 10 files changed, 270 insertions(+), 191 deletions(-) diff --git a/src/constants.rs b/src/constants.rs index f328d18..c5c232d 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -52,10 +52,12 @@ pub mod animation { pub const GHOST_EATEN_SPEED: u16 = 6; /// Frightened ghost animation speed (ticks per frame at 60 ticks/sec) pub const GHOST_FRIGHTENED_SPEED: u16 = 12; - - /// Time in ticks when frightened ghosts start flashing (2 seconds at 60 FPS) - pub const FRIGHTENED_FLASH_START_TICKS: u32 = 120; + /// Time in ticks for frightened ghosts to return to normal + pub const GHOST_FRIGHTENED_TICKS: u32 = 300; + /// Time in ticks when frightened ghosts start flashing + pub const GHOST_FRIGHTENED_FLASH_START_TICKS: u32 = GHOST_FRIGHTENED_TICKS - 120; } + /// The size of the canvas, in pixels. pub const CANVAS_SIZE: UVec2 = UVec2::new( (BOARD_CELL_SIZE.x + BOARD_CELL_OFFSET.x + BOARD_BOTTOM_CELL_OFFSET.x) * CELL_SIZE, diff --git a/src/events.rs b/src/events.rs index b75efca..57f7cac 100644 --- a/src/events.rs +++ b/src/events.rs @@ -54,5 +54,5 @@ pub enum CollisionTrigger { ghost_type: Ghost, }, /// Pac-Man collided with an item - ItemCollision { pacman: Entity, item: Entity }, + ItemCollision { item: Entity }, } diff --git a/src/game.rs b/src/game.rs index d9902a9..2cdd50b 100644 --- a/src/game.rs +++ b/src/game.rs @@ -46,10 +46,23 @@ use crate::{ texture::sprite::{AtlasMapper, SpriteAtlas}, }; +/// System set for all gameplay systems to ensure they run after input processing +#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)] +enum GameplaySet { + /// Gameplay systems that process inputs + Input, + /// Gameplay systems that update the game state + Update, + /// Gameplay systems that respond to events + Respond, +} + /// System set for all rendering systems to ensure they run after gameplay logic #[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)] enum RenderSet { Animation, + Draw, + Present, } /// Core game state manager built on the Bevy ECS architecture. @@ -459,13 +472,6 @@ impl Game { let eaten_ghost_system = profile(SystemId::EatenGhost, eaten_ghost_system); let time_to_live_system = profile(SystemId::TimeToLive, time_to_live_system); - let forced_dirty_system = |mut dirty: ResMut| { - dirty.0 = true; - }; - - schedule.add_systems((forced_dirty_system - .run_if(|score: Res, stage: Res| score.is_changed() || stage.is_changed()),)); - // Input system should always run to prevent SDL event pump from blocking let input_systems = ( input_system.run_if(|mut local: Local| { @@ -477,34 +483,50 @@ impl Game { ) .chain(); - let gameplay_systems = ( - (player_movement_system, player_tunnel_slowdown_system, ghost_movement_system).chain(), - eaten_ghost_system, - (collision_system).chain(), - unified_ghost_state_system, - ) - .chain() - .run_if(|game_state: Res| matches!(*game_state, GameStage::Playing)); + // .run_if(|game_state: Res| matches!(*game_state, GameStage::Playing)); - schedule.add_systems((blinking_system, directional_render_system, linear_render_system).in_set(RenderSet::Animation)); - - schedule.add_systems(( - time_to_live_system, - stage_system, - input_systems, - gameplay_systems, - ( - dirty_render_system, - combined_render_system, - hud_render_system, - player_life_sprite_system, - touch_ui_render_system, - present_system, - ) - .chain() - .after(RenderSet::Animation), - audio_system, - )); + schedule + .add_systems(( + input_systems.in_set(GameplaySet::Input), + time_to_live_system.before(GameplaySet::Update), + ( + player_movement_system, + player_tunnel_slowdown_system, + ghost_movement_system, + eaten_ghost_system, + collision_system, + unified_ghost_state_system, + ) + .in_set(GameplaySet::Update), + ( + blinking_system, + directional_render_system, + linear_render_system, + player_life_sprite_system, + ) + .in_set(RenderSet::Animation), + stage_system.in_set(GameplaySet::Respond), + ( + (|mut dirty: ResMut, score: Res, stage: Res| { + dirty.0 = score.is_changed() || stage.is_changed(); + }), + dirty_render_system.run_if(|dirty: Res| dirty.0 == false), + combined_render_system, + hud_render_system, + touch_ui_render_system, + ) + .chain() + .in_set(RenderSet::Draw), + (present_system, audio_system).chain().in_set(RenderSet::Present), + )) + .configure_sets(( + GameplaySet::Input, + GameplaySet::Update, + GameplaySet::Respond, + RenderSet::Animation, + RenderSet::Draw, + RenderSet::Present, + )); } fn spawn_items(world: &mut World) -> GameResult<()> { diff --git a/src/systems/animation/directional.rs b/src/systems/animation/directional.rs index 305ca2b..80f1f28 100644 --- a/src/systems/animation/directional.rs +++ b/src/systems/animation/directional.rs @@ -38,15 +38,15 @@ impl DirectionalAnimation { /// All directions share the same frame timing to ensure perfect synchronization. pub fn directional_render_system( dt: Res, - mut query: Query<(&Position, &Velocity, &mut DirectionalAnimation, &mut Renderable), Without>, + mut query: Query<(&Position, &Velocity, &mut DirectionalAnimation, &mut Renderable, Has)>, ) { let ticks = (dt.seconds * 60.0).round() as u16; // Convert from seconds to ticks at 60 ticks/sec - for (position, velocity, mut anim, mut renderable) in query.iter_mut() { + for (position, velocity, mut anim, mut renderable, frozen) in query.iter_mut() { let stopped = matches!(position, Position::Stopped { .. }); // Only tick animation when moving to preserve stopped frame - if !stopped { + if !stopped && !frozen { // Tick shared animation state anim.time_bank += ticks; while anim.time_bank >= anim.frame_duration { diff --git a/src/systems/collision.rs b/src/systems/collision.rs index 5bf169c..ad00971 100644 --- a/src/systems/collision.rs +++ b/src/systems/collision.rs @@ -8,10 +8,16 @@ use bevy_ecs::{ }; use tracing::{debug, trace, warn}; -use crate::events::{CollisionTrigger, StageTransition}; -use crate::map::builder::Map; -use crate::systems::{movement::Position, AudioEvent, DyingSequence, GameStage, Ghost, ScoreResource}; +use crate::{ + constants, + systems::{movement::Position, AudioEvent, DyingSequence, GameStage, Ghost, ScoreResource, SpawnTrigger}, +}; use crate::{error::GameError, systems::GhostState}; +use crate::{ + events::{CollisionTrigger, StageTransition}, + systems::PelletCount, +}; +use crate::{map::builder::Map, systems::EntityType}; /// A component for defining the collision area of an entity. #[derive(Component)] @@ -71,7 +77,7 @@ pub fn collision_system( map: Res, pacman_query: Query<(Entity, &Position, &Collider), With>, item_query: Query<(Entity, &Position, &Collider), With>, - ghost_query: Query<(Entity, &Position, &Collider, &Ghost), With>, + ghost_query: Query<(Entity, &Position, &Collider, &Ghost, &GhostState), With>, mut commands: Commands, mut errors: EventWriter, ) { @@ -82,10 +88,7 @@ pub fn collision_system( Ok(colliding) => { if colliding { trace!("Item collision detected"); - commands.trigger(CollisionTrigger::ItemCollision { - pacman: pacman_entity, - item: item_entity, - }); + commands.trigger(CollisionTrigger::ItemCollision { item: item_entity }); } } Err(e) => { @@ -98,17 +101,19 @@ pub fn collision_system( } // Check PACMAN × GHOST collisions - for (ghost_entity, ghost_pos, ghost_collider, ghost) in ghost_query.iter() { + for (ghost_entity, ghost_pos, ghost_collider, ghost, ghost_state) in ghost_query.iter() { match check_collision(pacman_pos, pacman_collider, ghost_pos, ghost_collider, &map) { Ok(colliding) => { - if colliding { - trace!(ghost = ?ghost, "Ghost collision detected"); - commands.trigger(CollisionTrigger::GhostCollision { - pacman: pacman_entity, - ghost: ghost_entity, - ghost_type: ghost.clone(), - }); + if !colliding || matches!(*ghost_state, GhostState::Eyes) { + continue; } + + trace!(ghost = ?ghost, "Ghost collision detected"); + commands.trigger(CollisionTrigger::GhostCollision { + pacman: pacman_entity, + ghost: ghost_entity, + ghost_type: *ghost, + }); } Err(e) => { errors.write(GameError::InvalidState(format!( @@ -137,8 +142,13 @@ pub fn ghost_collision_observer( ghost_type, } = *trigger { + // Check if Pac-Man is already dying + if matches!(*game_state, GameStage::PlayerDying(_)) { + return; + } + // Check if the ghost is frightened - if let Ok(ghost_state) = ghost_state_query.get_mut(ghost) { + if let Ok(mut ghost_state) = ghost_state_query.get_mut(ghost) { // Check if ghost is in frightened state if matches!(*ghost_state, GhostState::Frightened { .. }) { // Pac-Man eats the ghost @@ -146,6 +156,8 @@ pub fn ghost_collision_observer( debug!(ghost = ?ghost_type, score_added = 200, new_score = score.0 + 200, "Pacman ate frightened ghost"); score.0 += 200; + *ghost_state = GhostState::Eyes; + // Enter short pause to show bonus points, hide ghost, then set Eyes after pause // Request transition via event so stage_system can process it stage_events.write(StageTransition::GhostEatenPause { @@ -173,12 +185,12 @@ pub fn item_collision_observer( trigger: Trigger, mut commands: Commands, mut score: ResMut, - mut pellet_count: ResMut, - item_query: Query<(Entity, &crate::systems::EntityType, &Position), With>, + mut pellet_count: ResMut, + item_query: Query<(Entity, &EntityType, &Position), With>, mut ghost_query: Query<&mut GhostState, With>, mut events: EventWriter, ) { - if let CollisionTrigger::ItemCollision { pacman: _pacman, item } = *trigger { + if let CollisionTrigger::ItemCollision { item } = *trigger { // Get the item type and update score if let Ok((item_ent, entity_type, position)) = item_query.get(item) { if let Some(score_value) = entity_type.score_value() { @@ -189,20 +201,20 @@ pub fn item_collision_observer( commands.entity(item_ent).despawn(); // Track pellet consumption for fruit spawning - if *entity_type == crate::systems::EntityType::Pellet { + if *entity_type == EntityType::Pellet { pellet_count.0 += 1; trace!(pellet_count = pellet_count.0, "Pellet consumed"); // Check if we should spawn a fruit if pellet_count.0 == 5 || pellet_count.0 == 170 { debug!(pellet_count = pellet_count.0, "Fruit spawn milestone reached"); - commands.trigger(crate::systems::SpawnTrigger::Fruit); + commands.trigger(SpawnTrigger::Fruit); } } // Trigger bonus points effect if a fruit is collected - if matches!(*entity_type, crate::systems::EntityType::Fruit(_)) { - commands.trigger(crate::systems::SpawnTrigger::Bonus { + if matches!(*entity_type, EntityType::Fruit(_)) { + commands.trigger(SpawnTrigger::Bonus { position: *position, value: entity_type.score_value().unwrap(), ttl: 60 * 2, @@ -214,11 +226,19 @@ pub fn item_collision_observer( events.write(AudioEvent::PlayEat); } - // Make ghosts frightened when power pellet is collected - if matches!(*entity_type, crate::systems::EntityType::PowerPellet) { - debug!(duration_ticks = 300, "Power pellet collected, frightening ghosts"); + // Make non-eaten ghosts frightened when power pellet is collected + if matches!(*entity_type, EntityType::PowerPellet) { + debug!( + duration_ticks = constants::animation::GHOST_FRIGHTENED_TICKS, + "Power pellet collected, frightening ghosts" + ); for mut ghost_state in ghost_query.iter_mut() { - *ghost_state = GhostState::new_frightened(300, 60); + if matches!(*ghost_state, GhostState::Normal) { + *ghost_state = GhostState::new_frightened( + constants::animation::GHOST_FRIGHTENED_TICKS, + constants::animation::GHOST_FRIGHTENED_FLASH_START_TICKS, + ); + } } debug!( frightened_count = ghost_query.iter().count(), diff --git a/src/systems/common/components.rs b/src/systems/common/components.rs index 16fc1bf..c35b766 100644 --- a/src/systems/common/components.rs +++ b/src/systems/common/components.rs @@ -94,7 +94,7 @@ impl Default for MovementModifiers { } /// Tag component for entities that should be frozen during startup -#[derive(Component, Debug, Clone, Copy)] +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)] pub struct Frozen; /// Component for HUD life sprite entities. diff --git a/src/systems/ghost.rs b/src/systems/ghost.rs index 8f56239..9556bfd 100644 --- a/src/systems/ghost.rs +++ b/src/systems/ghost.rs @@ -57,7 +57,7 @@ impl Ghost { } } -#[derive(Component, Debug, Clone, Copy)] +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)] pub enum GhostState { /// Normal ghost behavior - chasing Pac-Man Normal, @@ -254,7 +254,7 @@ pub fn ghost_movement_system( pub fn eaten_ghost_system( map: Res, delta_time: Res, - mut eaten_ghosts: Query<(&Ghost, &mut Position, &mut Velocity, &mut GhostState)>, + mut eaten_ghosts: Query<(&Ghost, &mut Position, &mut Velocity, &mut GhostState), Without>, ) { for (ghost_type, mut position, mut velocity, mut ghost_state) in eaten_ghosts.iter_mut() { // Only process ghosts that are in Eyes state diff --git a/src/systems/player.rs b/src/systems/player.rs index 8d6fd43..0d2d3a8 100644 --- a/src/systems/player.rs +++ b/src/systems/player.rs @@ -42,30 +42,30 @@ pub fn player_control_system( ) { // Handle events for event in events.read() { - if let GameEvent::Command(command) = event { - match command { - GameCommand::MovePlayer(direction) => { - // Only handle movement if there's an unfrozen player - if let Some(player_single) = player.as_mut() { - trace!(direction = ?*direction, "Player direction buffered for movement"); - ***player_single = BufferedDirection::Some { - direction: *direction, - remaining_time: 0.25, - }; - } + let GameEvent::Command(command) = event; + + match command { + GameCommand::MovePlayer(direction) => { + // Only handle movement if there's an unfrozen player + if let Some(player_single) = player.as_mut() { + trace!(direction = ?*direction, "Player direction buffered for movement"); + ***player_single = BufferedDirection::Some { + direction: *direction, + remaining_time: 0.25, + }; } - GameCommand::Exit => { - state.exit = true; - } - GameCommand::ToggleDebug => { - debug_state.enabled = !debug_state.enabled; - } - GameCommand::MuteAudio => { - audio_state.muted = !audio_state.muted; - tracing::info!("Audio {}", if audio_state.muted { "muted" } else { "unmuted" }); - } - _ => {} } + GameCommand::Exit => { + state.exit = true; + } + GameCommand::ToggleDebug => { + debug_state.enabled = !debug_state.enabled; + } + GameCommand::MuteAudio => { + audio_state.muted = !audio_state.muted; + tracing::info!("Audio {}", if audio_state.muted { "muted" } else { "unmuted" }); + } + _ => {} } } } diff --git a/src/systems/state.rs b/src/systems/state.rs index f2d38a5..314e75c 100644 --- a/src/systems/state.rs +++ b/src/systems/state.rs @@ -1,8 +1,10 @@ use std::mem::discriminant; use tracing::{debug, info, warn}; +use crate::constants; use crate::events::StageTransition; -use crate::systems::SpawnTrigger; +use crate::map::direction::Direction; +use crate::systems::{EntityType, ItemCollider, SpawnTrigger, Velocity}; use crate::{ map::builder::Map, systems::{ @@ -37,14 +39,51 @@ pub enum GameStage { ghost_type: Ghost, node: NodeId, }, - /// The player has died and the death sequence is in progress. + /// The player has died and the death sequence is in progress. At the end, the player will return to the startup sequence or game over. PlayerDying(DyingSequence), - /// The level is restarting after a death. - LevelRestarting, /// The game has ended. GameOver, } +pub trait TooSimilar { + fn too_similar(&self, other: &Self) -> bool; +} + +impl TooSimilar for GameStage { + fn too_similar(&self, other: &Self) -> bool { + discriminant(self) == discriminant(other) && { + // These states are very simple, so they're 'too similar' automatically + if matches!(self, GameStage::Playing | GameStage::GameOver) { + return true; + } + + // Since the discriminant is the same but the values are different, it's the interior value that is somehow different + match (self, other) { + // These states are similar if their interior values are similar as well + (GameStage::Starting(startup), GameStage::Starting(other)) => startup.too_similar(other), + (GameStage::PlayerDying(dying), GameStage::PlayerDying(other)) => dying.too_similar(other), + ( + GameStage::GhostEatenPause { + ghost_entity, + ghost_type, + node, + .. + }, + GameStage::GhostEatenPause { + ghost_entity: other_ghost_entity, + ghost_type: other_ghost_type, + node: other_node, + .. + }, + ) => ghost_entity == other_ghost_entity && ghost_type == other_ghost_type && node == other_node, + // Already handled, but kept to properly exhaust the match + (GameStage::Playing, _) | (GameStage::GameOver, _) => unreachable!(), + _ => unreachable!(), + } + } + } +} + /// A resource that manages the multi-stage startup sequence of the game. #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum StartupSequence { @@ -71,6 +110,12 @@ impl Default for GameStage { } } +impl TooSimilar for StartupSequence { + fn too_similar(&self, other: &Self) -> bool { + discriminant(self) == discriminant(other) + } +} + /// The state machine for the multi-stage death sequence. #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum DyingSequence { @@ -82,6 +127,12 @@ pub enum DyingSequence { Hidden { remaining_ticks: u32 }, } +impl TooSimilar for DyingSequence { + fn too_similar(&self, other: &Self) -> bool { + discriminant(self) == discriminant(other) + } +} + /// A resource to store the number of player lives. #[derive(Resource, Debug)] pub struct PlayerLives(pub u8); @@ -106,7 +157,8 @@ pub fn stage_system( mut stage_event_reader: EventReader, mut blinking_query: Query>, player: Single<(Entity, &mut Position), With>, - mut ghost_query: Query<(Entity, &Ghost, &mut Position), (With, Without)>, + mut item_query: Query<(Entity, &EntityType), With>, + mut ghost_query: Query<(Entity, &Ghost, &mut Position, &mut GhostState), (With, Without)>, ) { let old_state = *game_state; let mut new_state: Option = None; @@ -119,7 +171,7 @@ pub fn stage_system( } = *event; let pac_node = player.1.current_node(); - debug!(ghost_entity = ?ghost_entity, node = pac_node, "Ghost eaten, entering pause state"); + debug!(ghost = ?ghost_type, node = pac_node, "Ghost eaten, entering pause state"); new_state = Some(GameStage::GhostEatenPause { remaining_ticks: 30, ghost_entity, @@ -200,8 +252,8 @@ pub fn stage_system( player_lives.0 = player_lives.0.saturating_sub(1); if player_lives.0 > 0 { - info!(remaining_lives = player_lives.0, "Player died, restarting level"); - GameStage::LevelRestarting + info!(remaining_lives = player_lives.0, "Player died, returning to startup sequence"); + GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 }) } else { warn!("All lives lost, game over"); GameStage::GameOver @@ -209,10 +261,6 @@ pub fn stage_system( } } }, - GameStage::LevelRestarting => { - debug!("Level restart complete, returning to startup sequence"); - GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 }) - } GameStage::GameOver => GameStage::GameOver, }; @@ -220,12 +268,21 @@ pub fn stage_system( return; } + if !old_state.too_similar(&new_state) { + debug!(old_state = ?old_state, new_state = ?new_state, "Game stage transition"); + } + match (old_state, new_state) { (GameStage::Playing, GameStage::GhostEatenPause { ghost_entity, node, .. }) => { - // Freeze the player & ghosts + // Freeze the player & non-eaten ghosts commands.entity(player.0).insert(Frozen); - for (entity, _, _) in ghost_query.iter_mut() { - commands.entity(entity).insert(Frozen); + commands.entity(ghost_entity).insert(Frozen); + for (entity, _, _, state) in ghost_query.iter_mut() { + // Only freeze ghosts that are not currently eaten + if *state != GhostState::Eyes { + debug!(ghost = ?entity, "Freezing ghost"); + commands.entity(entity).insert(Frozen); + } } // Hide the player & eaten ghost @@ -243,101 +300,110 @@ pub fn stage_system( (GameStage::GhostEatenPause { ghost_entity, .. }, GameStage::Playing) => { // Unfreeze and reveal the player & all ghosts commands.entity(player.0).remove::().insert(Visibility::visible()); - for (entity, _, _) in ghost_query.iter_mut() { + for (entity, _, _, _) in ghost_query.iter_mut() { commands.entity(entity).remove::().insert(Visibility::visible()); } // Reveal the eaten ghost and switch it to Eyes state commands.entity(ghost_entity).insert(GhostState::Eyes); } - (GameStage::Playing, GameStage::PlayerDying(DyingSequence::Frozen { .. })) => { + (_, GameStage::PlayerDying(DyingSequence::Frozen { .. })) => { // Freeze the player & ghosts commands.entity(player.0).insert(Frozen); - for (entity, _, _) in ghost_query.iter_mut() { + for (entity, _, _, _) in ghost_query.iter_mut() { commands.entity(entity).insert(Frozen); } } (GameStage::PlayerDying(DyingSequence::Frozen { .. }), GameStage::PlayerDying(DyingSequence::Animating { .. })) => { // Hide the ghosts - for (entity, _, _) in ghost_query.iter_mut() { + for (entity, _, _, _) in ghost_query.iter_mut() { commands.entity(entity).insert(Visibility::hidden()); } // Start Pac-Man's death animation - commands.entity(player.0).insert((Dying, player_death_animation.0.clone())); + commands + .entity(player.0) + .remove::() + .insert((Dying, player_death_animation.0.clone())); // Play the death sound audio_events.write(AudioEvent::PlayDeath); } - (GameStage::PlayerDying(DyingSequence::Animating { .. }), GameStage::PlayerDying(DyingSequence::Hidden { .. })) => { - // Hide the player - commands.entity(player.0).insert(Visibility::hidden()); - } - (_, GameStage::LevelRestarting) => { - let (player_entity, mut pos) = player.into_inner(); - *pos = Position::Stopped { - node: map.start_positions.pacman, - }; + (_, GameStage::PlayerDying(DyingSequence::Hidden { .. })) => { + // Pac-Man's death animation is complete, so he should be hidden just like the ghosts. + // Then, we reset them all back to their original positions and states. - // Freeze the blinking, force them to be visible (if they were hidden by blinking) + // Freeze the blinking power pellets, force them to be visible (if they were hidden by blinking) for entity in blinking_query.iter_mut() { commands.entity(entity).insert(Frozen).insert(Visibility::visible()); } + // Delete any fruit entities + for (entity, _) in item_query + .iter_mut() + .filter(|(_, entity_type)| matches!(entity_type, EntityType::Fruit(_))) + { + commands.entity(entity).despawn(); + } + // Reset the player animation commands - .entity(player_entity) - .remove::<(Frozen, Dying, LinearAnimation, Looping)>() - .insert(player_animation.0.clone()); + .entity(player.0) + .remove::<(Dying, LinearAnimation, Looping)>() + .insert(( + Velocity { + speed: constants::mechanics::PLAYER_SPEED, + direction: Direction::Left, + }, + Position::Stopped { + node: map.start_positions.pacman, + }, + player_animation.0.clone(), + Visibility::hidden(), + Frozen, + )); // Reset ghost positions and state - for (ghost_entity, ghost, mut ghost_pos) in ghost_query.iter_mut() { - *ghost_pos = Position::Stopped { - node: match ghost { - Ghost::Blinky => map.start_positions.blinky, - Ghost::Pinky => map.start_positions.pinky, - Ghost::Inky => map.start_positions.inky, - Ghost::Clyde => map.start_positions.clyde, + for (ghost_entity, ghost, _, _) in ghost_query.iter_mut() { + commands.entity(ghost_entity).insert(( + GhostState::Normal, + Position::Stopped { + node: match ghost { + Ghost::Blinky => map.start_positions.blinky, + Ghost::Pinky => map.start_positions.pinky, + Ghost::Inky => map.start_positions.inky, + Ghost::Clyde => map.start_positions.clyde, + }, }, - }; - commands - .entity(ghost_entity) - .remove::() - .insert((Visibility::visible(), GhostState::Normal)); + Frozen, + Visibility::hidden(), + )); } } (_, GameStage::Starting(StartupSequence::CharactersVisible { .. })) => { // Unhide the player & ghosts commands.entity(player.0).insert(Visibility::visible()); - for (entity, _, _) in ghost_query.iter_mut() { + for (entity, _, _, _) in ghost_query.iter_mut() { commands.entity(entity).insert(Visibility::visible()); } } (GameStage::Starting(StartupSequence::CharactersVisible { .. }), GameStage::Playing) => { // Unfreeze the player & ghosts & blinking commands.entity(player.0).remove::(); - for (entity, _, _) in ghost_query.iter_mut() { + for (entity, _, _, _) in ghost_query.iter_mut() { commands.entity(entity).remove::(); } for entity in blinking_query.iter_mut() { commands.entity(entity).remove::(); } } - (GameStage::PlayerDying(..), GameStage::GameOver) => { + (_, GameStage::GameOver) => { // Freeze blinking for entity in blinking_query.iter_mut() { commands.entity(entity).insert(Frozen); } } - _ => { - let different = discriminant(&old_state) != discriminant(&new_state); - if different { - tracing::warn!( - new_state = ?new_state, - old_state = ?old_state, - "Unhandled game stage transition"); - } - } + _ => {} } *game_state = new_state; diff --git a/tests/item.rs b/tests/item.rs index f10058d..405d85f 100644 --- a/tests/item.rs +++ b/tests/item.rs @@ -30,11 +30,10 @@ fn test_is_collectible_item() { #[test] fn test_item_system_pellet_collection() { let (mut world, mut schedule) = common::create_test_world(); - let pacman = common::spawn_test_pacman(&mut world, 0); let pellet = common::spawn_test_item(&mut world, 1, EntityType::Pellet); // Send collision event - common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { pacman, item: pellet }); + common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: pellet }); schedule.run(&mut world); @@ -54,16 +53,9 @@ fn test_item_system_pellet_collection() { #[test] fn test_item_system_power_pellet_collection() { let (mut world, mut schedule) = common::create_test_world(); - let pacman = common::spawn_test_pacman(&mut world, 0); let power_pellet = common::spawn_test_item(&mut world, 1, EntityType::PowerPellet); - common::trigger_collision( - &mut world, - CollisionTrigger::ItemCollision { - pacman, - item: power_pellet, - }, - ); + common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: power_pellet }); schedule.run(&mut world); @@ -83,21 +75,14 @@ fn test_item_system_power_pellet_collection() { #[test] fn test_item_system_multiple_collections() { let (mut world, mut schedule) = common::create_test_world(); - let pacman = common::spawn_test_pacman(&mut world, 0); let pellet1 = common::spawn_test_item(&mut world, 1, EntityType::Pellet); let pellet2 = common::spawn_test_item(&mut world, 2, EntityType::Pellet); let power_pellet = common::spawn_test_item(&mut world, 3, EntityType::PowerPellet); // Send multiple collision events - common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { pacman, item: pellet1 }); - common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { pacman, item: pellet2 }); - common::trigger_collision( - &mut world, - CollisionTrigger::ItemCollision { - pacman, - item: power_pellet, - }, - ); + common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: pellet1 }); + common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: pellet2 }); + common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: power_pellet }); schedule.run(&mut world); @@ -125,7 +110,6 @@ fn test_item_system_multiple_collections() { #[test] fn test_item_system_ignores_non_item_collisions() { let (mut world, mut schedule) = common::create_test_world(); - let pacman = common::spawn_test_pacman(&mut world, 0); // Create a ghost entity (not an item) let ghost = world.spawn((Position::Stopped { node: 2 }, EntityType::Ghost)).id(); @@ -134,7 +118,7 @@ fn test_item_system_ignores_non_item_collisions() { let initial_score = world.resource::().0; // Send collision event between pacman and ghost - common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { pacman, item: ghost }); + common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: ghost }); schedule.run(&mut world); @@ -176,18 +160,11 @@ fn test_item_system_no_collision_events() { #[test] fn test_item_system_collision_with_missing_entity() { let (mut world, mut schedule) = common::create_test_world(); - let pacman = common::spawn_test_pacman(&mut world, 0); // Create a fake entity ID that doesn't exist let fake_entity = Entity::from_raw(999); - common::trigger_collision( - &mut world, - CollisionTrigger::ItemCollision { - pacman, - item: fake_entity, - }, - ); + common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: fake_entity }); // System should handle gracefully and not crash schedule.run(&mut world); @@ -203,10 +180,9 @@ fn test_item_system_preserves_existing_score() { // Set initial score world.insert_resource(ScoreResource(100)); - let pacman = common::spawn_test_pacman(&mut world, 0); let pellet = common::spawn_test_item(&mut world, 1, EntityType::Pellet); - common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { pacman, item: pellet }); + common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: pellet }); schedule.run(&mut world); @@ -218,7 +194,6 @@ fn test_item_system_preserves_existing_score() { #[test] fn test_power_pellet_does_not_affect_ghosts_in_eyes_state() { let (mut world, mut schedule) = common::create_test_world(); - let pacman = common::spawn_test_pacman(&mut world, 0); let power_pellet = common::spawn_test_item(&mut world, 1, EntityType::PowerPellet); // Spawn a ghost in Eyes state (returning to ghost house) @@ -227,13 +202,7 @@ fn test_power_pellet_does_not_affect_ghosts_in_eyes_state() { // Spawn a ghost in Normal state let normal_ghost = common::spawn_test_ghost(&mut world, 3, GhostState::Normal); - common::trigger_collision( - &mut world, - CollisionTrigger::ItemCollision { - pacman, - item: power_pellet, - }, - ); + common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: power_pellet }); schedule.run(&mut world);