diff --git a/src/events.rs b/src/events.rs index 2f2e181..b75efca 100644 --- a/src/events.rs +++ b/src/events.rs @@ -1,6 +1,6 @@ use bevy_ecs::{entity::Entity, event::Event}; -use crate::map::direction::Direction; +use crate::{map::direction::Direction, systems::Ghost}; /// Player input commands that trigger specific game actions. /// @@ -24,15 +24,12 @@ pub enum GameCommand { /// Global events that flow through the ECS event system to coordinate game behavior. /// -/// Events enable loose coupling between systems - input generates commands, collision -/// detection reports overlaps, and various systems respond appropriately without -/// direct dependencies. +/// Events enable loose coupling between systems - input generates commands and +/// various systems respond appropriately without direct dependencies. #[derive(Event, Clone, Copy, Debug, PartialEq, Eq)] pub enum GameEvent { /// Player input command to be processed by relevant game systems Command(GameCommand), - /// Physical overlap detected between two entities requiring gameplay response - Collision(Entity, Entity), } impl From for GameEvent { @@ -44,5 +41,18 @@ impl From for GameEvent { /// Data for requesting stage transitions; processed centrally in stage_system #[derive(Event, Clone, Copy, Debug, PartialEq, Eq)] pub enum StageTransition { - GhostEatenPause { ghost_entity: Entity }, + GhostEatenPause { ghost_entity: Entity, ghost_type: Ghost }, +} + +/// Collision triggers for immediate collision handling via observers +#[derive(Event, Clone, Copy, Debug, PartialEq, Eq)] +pub enum CollisionTrigger { + /// Pac-Man collided with a ghost + GhostCollision { + pacman: Entity, + ghost: Entity, + ghost_type: Ghost, + }, + /// Pac-Man collided with an item + ItemCollision { pacman: Entity, item: Entity }, } diff --git a/src/game.rs b/src/game.rs index c5b482a..d9902a9 100644 --- a/src/game.rs +++ b/src/game.rs @@ -7,13 +7,13 @@ use tracing::{debug, info, trace, warn}; use crate::constants::{self, animation, MapTile, CANVAS_SIZE}; use crate::error::{GameError, GameResult}; -use crate::events::{GameEvent, StageTransition}; +use crate::events::{CollisionTrigger, GameEvent, StageTransition}; use crate::map::builder::Map; use crate::map::direction::Direction; use crate::systems::{ self, audio_system, blinking_system, collision_system, combined_render_system, directional_render_system, - dirty_render_system, eaten_ghost_system, ghost_collision_system, ghost_movement_system, ghost_state_system, - hud_render_system, item_system, linear_render_system, player_life_sprite_system, present_system, profile, + dirty_render_system, eaten_ghost_system, ghost_collision_observer, ghost_movement_system, ghost_state_system, + hud_render_system, item_collision_observer, linear_render_system, player_life_sprite_system, present_system, profile, time_to_live_system, touch_ui_render_system, AudioEvent, AudioResource, AudioState, BackbufferResource, Blinking, BufferedDirection, Collider, DebugState, DebugTextureResource, DeltaTime, DirectionalAnimation, EntityType, Frozen, GameStage, Ghost, GhostAnimation, GhostAnimations, GhostBundle, GhostCollider, GhostState, GlobalState, ItemBundle, @@ -376,6 +376,7 @@ impl Game { EventRegistry::register_event::(world); EventRegistry::register_event::(world); EventRegistry::register_event::(world); + EventRegistry::register_event::(world); world.add_observer( |event: Trigger, mut state: ResMut, _score: ResMut| { @@ -384,6 +385,9 @@ impl Game { } }, ); + + world.add_observer(ghost_collision_observer); + world.add_observer(item_collision_observer); } #[allow(clippy::too_many_arguments)] @@ -443,8 +447,6 @@ impl Game { 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 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); @@ -478,7 +480,7 @@ impl Game { let gameplay_systems = ( (player_movement_system, player_tunnel_slowdown_system, ghost_movement_system).chain(), eaten_ghost_system, - (collision_system, ghost_collision_system, item_system).chain(), + (collision_system).chain(), unified_ghost_state_system, ) .chain() diff --git a/src/systems/collision.rs b/src/systems/collision.rs index 63412de..5bf169c 100644 --- a/src/systems/collision.rs +++ b/src/systems/collision.rs @@ -1,15 +1,16 @@ use bevy_ecs::{ component::Component, entity::Entity, - event::{EventReader, EventWriter}, + event::EventWriter, + observer::Trigger, query::With, - system::{Commands, Query, Res, ResMut, Single}, + system::{Commands, Query, Res, ResMut}, }; use tracing::{debug, trace, warn}; -use crate::events::{GameEvent, StageTransition}; +use crate::events::{CollisionTrigger, StageTransition}; use crate::map::builder::Map; -use crate::systems::{movement::Position, AudioEvent, DyingSequence, Frozen, GameStage, Ghost, PlayerControlled, ScoreResource}; +use crate::systems::{movement::Position, AudioEvent, DyingSequence, GameStage, Ghost, ScoreResource}; use crate::{error::GameError, systems::GhostState}; /// A component for defining the collision area of an entity. @@ -55,11 +56,11 @@ pub fn check_collision( Ok(collider1.collides_with(collider2.size, distance)) } -/// Detects overlapping entities and generates collision events for gameplay systems. +/// Detects overlapping entities and triggers collision observers immediately. /// /// Performs distance-based collision detection between Pac-Man and collectible items -/// using each entity's position and collision radius. When entities overlap, emits -/// a `GameEvent::Collision` for the item system to handle scoring and removal. +/// using each entity's position and collision radius. When entities overlap, triggers +/// collision observers for immediate handling without race conditions. /// Collision detection accounts for both entities being in motion and supports /// circular collision boundaries for accurate gameplay feel. /// @@ -70,8 +71,8 @@ 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), With>, - mut events: EventWriter, + ghost_query: Query<(Entity, &Position, &Collider, &Ghost), With>, + mut commands: Commands, mut errors: EventWriter, ) { // Check PACMAN × ITEM collisions @@ -80,8 +81,11 @@ pub fn collision_system( match check_collision(pacman_pos, pacman_collider, item_pos, item_collider, &map) { Ok(colliding) => { if colliding { - trace!(pacman_entity = ?pacman_entity, item_entity = ?item_entity, "Item collision detected"); - events.write(GameEvent::Collision(pacman_entity, item_entity)); + trace!("Item collision detected"); + commands.trigger(CollisionTrigger::ItemCollision { + pacman: pacman_entity, + item: item_entity, + }); } } Err(e) => { @@ -94,12 +98,16 @@ pub fn collision_system( } // Check PACMAN × GHOST collisions - for (ghost_entity, ghost_pos, ghost_collider) in ghost_query.iter() { + for (ghost_entity, ghost_pos, ghost_collider, ghost) in ghost_query.iter() { match check_collision(pacman_pos, pacman_collider, ghost_pos, ghost_collider, &map) { Ok(colliding) => { if colliding { - trace!(pacman_entity = ?pacman_entity, ghost_entity = ?ghost_entity, "Ghost collision detected"); - events.write(GameEvent::Collision(pacman_entity, ghost_entity)); + trace!(ghost = ?ghost, "Ghost collision detected"); + commands.trigger(CollisionTrigger::GhostCollision { + pacman: pacman_entity, + ghost: ghost_entity, + ghost_type: ghost.clone(), + }); } } Err(e) => { @@ -113,56 +121,110 @@ pub fn collision_system( } } +/// Observer for handling ghost collisions immediately when they occur #[allow(clippy::too_many_arguments)] -pub fn ghost_collision_system( - mut commands: Commands, - mut collision_events: EventReader, +pub fn ghost_collision_observer( + trigger: Trigger, mut stage_events: EventWriter, mut score: ResMut, mut game_state: ResMut, - player: Single>, - ghost_query: Query<(Entity, &Ghost), With>, mut ghost_state_query: Query<&mut GhostState>, mut events: EventWriter, ) { - 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 *entity1 == *player && ghost_query.get(*entity2).is_ok() { - (*entity1, *entity2) - } else if *entity2 == *player && ghost_query.get(*entity1).is_ok() { - (*entity2, *entity1) + if let CollisionTrigger::GhostCollision { + pacman: _pacman, + ghost, + ghost_type, + } = *trigger + { + // Check if the ghost is frightened + if let Ok(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 + // Add score (200 points per ghost eaten) + debug!(ghost = ?ghost_type, score_added = 200, new_score = score.0 + 200, "Pacman ate frightened ghost"); + score.0 += 200; + + // 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 { + ghost_entity: ghost, + ghost_type: ghost_type, + }); + + // Play eat sound + events.write(AudioEvent::PlayEat); + } else if matches!(*ghost_state, GhostState::Normal) { + // Pac-Man dies + warn!(ghost = ?ghost_type, "Pacman hit by normal ghost, player dies"); + *game_state = GameStage::PlayerDying(DyingSequence::Frozen { remaining_ticks: 60 }); + events.write(AudioEvent::StopAll); } else { - continue; - }; + trace!(ghost_state = ?*ghost_state, "Ghost collision ignored due to state"); + } + } + } +} - // Check if the ghost is frightened - if let Ok((ghost_ent, _ghost_type)) = ghost_query.get(ghost_entity) { - if let Ok(ghost_state) = ghost_state_query.get_mut(ghost_ent) { - // Check if ghost is in frightened state - if matches!(*ghost_state, GhostState::Frightened { .. }) { - // Pac-Man eats the ghost - // Add score (200 points per ghost eaten) - debug!(ghost_entity = ?ghost_ent, score_added = 200, new_score = score.0 + 200, "Pacman ate frightened ghost"); - score.0 += 200; +/// Observer for handling item collisions immediately when they occur +#[allow(clippy::too_many_arguments)] +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 ghost_query: Query<&mut GhostState, With>, + mut events: EventWriter, +) { + if let CollisionTrigger::ItemCollision { pacman: _pacman, 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() { + trace!(item_entity = ?item_ent, item_type = ?entity_type, score_value, new_score = score.0 + score_value, "Item collected by player"); + score.0 += score_value; - // 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 { ghost_entity: ghost_ent }); + // Remove the collected item + commands.entity(item_ent).despawn(); - // Play eat sound - events.write(AudioEvent::PlayEat); - } else if matches!(*ghost_state, GhostState::Normal) { - // Pac-Man dies - warn!(ghost_entity = ?ghost_ent, "Pacman hit by normal ghost, player dies"); - *game_state = GameStage::PlayerDying(DyingSequence::Frozen { remaining_ticks: 60 }); - commands.entity(pacman_entity).insert(Frozen); - commands.entity(ghost_entity).insert(Frozen); - events.write(AudioEvent::StopAll); - } else { - trace!(ghost_state = ?*ghost_state, "Ghost collision ignored due to state"); + // Track pellet consumption for fruit spawning + if *entity_type == crate::systems::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); } } + + // Trigger bonus points effect if a fruit is collected + if matches!(*entity_type, crate::systems::EntityType::Fruit(_)) { + commands.trigger(crate::systems::SpawnTrigger::Bonus { + position: *position, + value: entity_type.score_value().unwrap(), + ttl: 60 * 2, + }); + } + + // Trigger audio if appropriate + if entity_type.is_collectible() { + 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"); + for mut ghost_state in ghost_query.iter_mut() { + *ghost_state = GhostState::new_frightened(300, 60); + } + debug!( + frightened_count = ghost_query.iter().count(), + "Ghosts set to frightened state" + ); + } } } } diff --git a/src/systems/item.rs b/src/systems/item.rs index d8bc9d0..f707a59 100644 --- a/src/systems/item.rs +++ b/src/systems/item.rs @@ -1,12 +1,10 @@ use bevy_ecs::{ - entity::Entity, - event::{Event, EventReader, EventWriter}, + event::Event, observer::Trigger, - query::With, - system::{Commands, NonSendMut, Query, Res, ResMut, Single}, + system::{Commands, NonSendMut, Res}, }; use strum_macros::IntoStaticStr; -use tracing::{debug, trace}; +use tracing::debug; use crate::{ constants, @@ -18,12 +16,7 @@ use crate::{ }, }; -use crate::{ - constants::animation::FRIGHTENED_FLASH_START_TICKS, - events::GameEvent, - systems::common::components::EntityType, - systems::{AudioEvent, GhostCollider, GhostState, ItemCollider, PacmanCollider, ScoreResource}, -}; +use crate::{systems::common::components::EntityType, systems::ItemCollider}; /// Tracks the number of pellets consumed by the player for fruit spawning mechanics. #[derive(bevy_ecs::resource::Resource, Debug, Default)] @@ -73,85 +66,6 @@ impl FruitType { } } -#[allow(clippy::too_many_arguments)] -pub fn item_system( - mut commands: Commands, - mut collision_events: EventReader, - mut score: ResMut, - mut pellet_count: ResMut, - pacman: Single>, - item_query: Query<(Entity, &EntityType, &Position), With>, - mut ghost_query: Query<&mut GhostState, With>, - mut events: EventWriter, -) { - for event in collision_events.read() { - if let GameEvent::Collision(entity1, entity2) = event { - // Check if one is Pacman and the other is an item - let (_, item_entity) = if *pacman == *entity1 && item_query.get(*entity2).is_ok() { - (*pacman, *entity2) - } else if *pacman == *entity2 && item_query.get(*entity1).is_ok() { - (*pacman, *entity1) - } else { - continue; - }; - - // Get the item type and update score - if let Ok((item_ent, entity_type, position)) = item_query.get(item_entity) { - if let Some(score_value) = entity_type.score_value() { - trace!(item_entity = ?item_ent, item_type = ?entity_type, score_value, new_score = score.0 + score_value, "Item collected by player"); - score.0 += score_value; - - // Remove the collected item - commands.entity(item_ent).despawn(); - - // Track pellet consumption for fruit spawning - 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(SpawnTrigger::Fruit); - } - } - - // Trigger bonus points effect if a fruit is collected - if matches!(*entity_type, EntityType::Fruit(_)) { - commands.trigger(SpawnTrigger::Bonus { - position: *position, - value: entity_type.score_value().unwrap(), - ttl: 60 * 2, - }); - } - - // Trigger audio if appropriate - if entity_type.is_collectible() { - events.write(AudioEvent::PlayEat); - } - - // Make ghosts frightened when power pellet is collected - if matches!(*entity_type, EntityType::PowerPellet) { - // Convert seconds to frames (assumes 60 FPS) - let total_ticks = 60 * 5; // 5 seconds total - debug!(duration_ticks = total_ticks, "Power pellet collected, frightening ghosts"); - - // Set all ghosts to frightened state, except those in Eyes state - let mut frightened_count = 0; - for mut ghost_state in ghost_query.iter_mut() { - if !matches!(*ghost_state, GhostState::Eyes) { - *ghost_state = GhostState::new_frightened(total_ticks, FRIGHTENED_FLASH_START_TICKS); - frightened_count += 1; - } - } - debug!(frightened_count, "Ghosts set to frightened state"); - } - } - } - } - } -} - /// Trigger to spawn a fruit #[derive(Event, Clone, Copy, Debug)] pub enum SpawnTrigger { diff --git a/src/systems/state.rs b/src/systems/state.rs index 5707729..f2d38a5 100644 --- a/src/systems/state.rs +++ b/src/systems/state.rs @@ -34,6 +34,7 @@ pub enum GameStage { GhostEatenPause { remaining_ticks: u32, ghost_entity: Entity, + ghost_type: Ghost, node: NodeId, }, /// The player has died and the death sequence is in progress. @@ -112,13 +113,17 @@ pub fn stage_system( // Handle stage transition requests before normal ticking for event in stage_event_reader.read() { - let StageTransition::GhostEatenPause { ghost_entity } = *event; + let StageTransition::GhostEatenPause { + ghost_entity, + ghost_type, + } = *event; let pac_node = player.1.current_node(); debug!(ghost_entity = ?ghost_entity, node = pac_node, "Ghost eaten, entering pause state"); new_state = Some(GameStage::GhostEatenPause { remaining_ticks: 30, ghost_entity, + ghost_type, node: pac_node, }); } @@ -131,7 +136,6 @@ pub fn stage_system( remaining_ticks: remaining_ticks - 1, }) } else { - debug!("Transitioning from text-only to characters visible startup stage"); GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 }) } } @@ -150,12 +154,14 @@ pub fn stage_system( GameStage::GhostEatenPause { remaining_ticks, ghost_entity, + ghost_type, node, } => { if remaining_ticks > 0 { GameStage::GhostEatenPause { remaining_ticks: remaining_ticks.saturating_sub(1), ghost_entity, + ghost_type, node, } } else { diff --git a/tests/collision.rs b/tests/collision.rs index 77360e2..37b4a13 100644 --- a/tests/collision.rs +++ b/tests/collision.rs @@ -36,19 +36,17 @@ fn test_check_collision_helper() { #[test] fn test_collision_system_pacman_item() { - let mut world = common::create_test_world(); + let (mut world, mut schedule) = common::create_test_world(); let _pacman = common::spawn_test_pacman(&mut world, 0); let _item = common::spawn_test_item(&mut world, 0, EntityType::Pellet); // Run collision system - should not panic - world - .run_system_once(collision_system) - .expect("System should run successfully"); + schedule.run(&mut world); } #[test] fn test_collision_system_pacman_ghost() { - let mut world = common::create_test_world(); + let (mut world, _) = common::create_test_world(); let _pacman = common::spawn_test_pacman(&mut world, 0); let _ghost = common::spawn_test_ghost(&mut world, 0, GhostState::Normal); @@ -60,19 +58,17 @@ fn test_collision_system_pacman_ghost() { #[test] fn test_collision_system_no_collision() { - let mut world = common::create_test_world(); + let (mut world, mut schedule) = common::create_test_world(); let _pacman = common::spawn_test_pacman(&mut world, 0); let _ghost = common::spawn_test_ghost(&mut world, 1, GhostState::Normal); // Different node // Run collision system - should not panic - world - .run_system_once(collision_system) - .expect("System should run successfully"); + schedule.run(&mut world); } #[test] fn test_collision_system_multiple_entities() { - let mut world = common::create_test_world(); + let (mut world, _) = common::create_test_world(); let _pacman = common::spawn_test_pacman(&mut world, 0); let _item = common::spawn_test_item(&mut world, 0, EntityType::Pellet); let _ghost = common::spawn_test_ghost(&mut world, 0, GhostState::Normal); diff --git a/tests/common.rs b/tests/common.rs index 5458dd2..ab6e39e 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -1,11 +1,11 @@ #![allow(dead_code)] -use bevy_ecs::{entity::Entity, event::Events, world::World}; +use bevy_ecs::{entity::Entity, event::Events, schedule::Schedule, world::World}; use glam::{U16Vec2, Vec2}; use pacman::{ asset::{get_asset_bytes, Asset}, constants::RAW_BOARD, - events::GameEvent, + events::{CollisionTrigger, GameEvent}, game::ATLAS_FRAMES, map::{ builder::Map, @@ -13,9 +13,9 @@ use pacman::{ graph::{Graph, Node}, }, systems::{ - AudioEvent, AudioState, BufferedDirection, Collider, DebugState, DeltaTime, EntityType, Ghost, GhostCollider, GhostState, - GlobalState, ItemCollider, MovementModifiers, PacmanCollider, PelletCount, PlayerControlled, Position, ScoreResource, - Velocity, + item_collision_observer, AudioEvent, AudioState, BufferedDirection, Collider, DebugState, DeltaTime, EntityType, Ghost, + GhostCollider, GhostState, GlobalState, ItemCollider, MovementModifiers, PacmanCollider, PelletCount, PlayerControlled, + Position, ScoreResource, Velocity, }, texture::sprite::{AtlasMapper, AtlasTile, SpriteAtlas}, }; @@ -75,7 +75,7 @@ pub fn create_test_graph() -> Graph { } /// Creates a basic test world with required resources for ECS systems -pub fn create_test_world() -> World { +pub fn create_test_world() -> (World, Schedule) { let mut world = World::new(); // Add required resources @@ -93,7 +93,11 @@ pub fn create_test_world() -> World { }); // 60 FPS world.insert_resource(create_test_map()); - world + let schedule = Schedule::default(); + + world.add_observer(item_collision_observer); + + (world, schedule) } /// Creates a test map using the default RAW_BOARD @@ -163,9 +167,8 @@ pub fn send_game_event(world: &mut World, event: GameEvent) { } /// Sends a collision event between two entities -pub fn send_collision_event(world: &mut World, entity1: Entity, entity2: Entity) { - let mut events = world.resource_mut::>(); - events.send(GameEvent::Collision(entity1, entity2)); +pub fn trigger_collision(world: &mut World, event: CollisionTrigger) { + world.trigger(event); } /// Creates a mock atlas tile for testing diff --git a/tests/item.rs b/tests/item.rs index cc69af5..f10058d 100644 --- a/tests/item.rs +++ b/tests/item.rs @@ -1,5 +1,8 @@ -use bevy_ecs::{entity::Entity, system::RunSystemOnce}; -use pacman::systems::{item_system, EntityType, GhostState, Position, ScoreResource}; +use bevy_ecs::entity::Entity; +use pacman::{ + events::CollisionTrigger, + systems::{EntityType, GhostState, Position, ScoreResource}, +}; use speculoos::prelude::*; mod common; @@ -26,18 +29,17 @@ fn test_is_collectible_item() { #[test] fn test_item_system_pellet_collection() { - let mut world = common::create_test_world(); + 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::send_collision_event(&mut world, pacman, pellet); + common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { pacman, item: pellet }); - // Run the item system - world.run_system_once(item_system).expect("System should run successfully"); + schedule.run(&mut world); // Check that score was updated - let score = world.resource::(); + let score = world.resource_mut::(); assert_that(&score.0).is_equal_to(10); // Check that the pellet was despawned (query should return empty) @@ -51,13 +53,19 @@ fn test_item_system_pellet_collection() { #[test] fn test_item_system_power_pellet_collection() { - let mut world = common::create_test_world(); + 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::send_collision_event(&mut world, pacman, power_pellet); + common::trigger_collision( + &mut world, + CollisionTrigger::ItemCollision { + pacman, + item: power_pellet, + }, + ); - world.run_system_once(item_system).expect("System should run successfully"); + schedule.run(&mut world); // Check that score was updated with power pellet value let score = world.resource::(); @@ -74,23 +82,31 @@ fn test_item_system_power_pellet_collection() { #[test] fn test_item_system_multiple_collections() { - let mut world = common::create_test_world(); + 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::send_collision_event(&mut world, pacman, pellet1); - common::send_collision_event(&mut world, pacman, pellet2); - common::send_collision_event(&mut world, pacman, power_pellet); + 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, + }, + ); - world.run_system_once(item_system).expect("System should run successfully"); + schedule.run(&mut world); // Check final score: 2 pellets (20) + 1 power pellet (50) = 70 let score = world.resource::(); assert_that(&score.0).is_equal_to(70); + schedule.run(&mut world); + // Check that all items were despawned let pellet_count = world .query::<&EntityType>() @@ -108,7 +124,7 @@ fn test_item_system_multiple_collections() { #[test] fn test_item_system_ignores_non_item_collisions() { - let mut world = common::create_test_world(); + 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) @@ -118,9 +134,9 @@ fn test_item_system_ignores_non_item_collisions() { let initial_score = world.resource::().0; // Send collision event between pacman and ghost - common::send_collision_event(&mut world, pacman, ghost); + common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { pacman, item: ghost }); - world.run_system_once(item_system).expect("System should run successfully"); + schedule.run(&mut world); // Score should remain unchanged let score = world.resource::(); @@ -137,14 +153,14 @@ fn test_item_system_ignores_non_item_collisions() { #[test] fn test_item_system_no_collision_events() { - let mut world = common::create_test_world(); + 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); let initial_score = world.resource::().0; // Run system without any collision events - world.run_system_once(item_system).expect("System should run successfully"); + schedule.run(&mut world); // Nothing should change let score = world.resource::(); @@ -159,19 +175,22 @@ fn test_item_system_no_collision_events() { #[test] fn test_item_system_collision_with_missing_entity() { - let mut world = common::create_test_world(); + 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::send_collision_event(&mut world, pacman, fake_entity); + common::trigger_collision( + &mut world, + CollisionTrigger::ItemCollision { + pacman, + item: fake_entity, + }, + ); // System should handle gracefully and not crash - world - .run_system_once(item_system) - .expect("System should handle missing entities gracefully"); - + schedule.run(&mut world); // Score should remain unchanged let score = world.resource::(); assert_that(&score.0).is_equal_to(0); @@ -179,7 +198,7 @@ fn test_item_system_collision_with_missing_entity() { #[test] fn test_item_system_preserves_existing_score() { - let mut world = common::create_test_world(); + let (mut world, mut schedule) = common::create_test_world(); // Set initial score world.insert_resource(ScoreResource(100)); @@ -187,9 +206,9 @@ fn test_item_system_preserves_existing_score() { let pacman = common::spawn_test_pacman(&mut world, 0); let pellet = common::spawn_test_item(&mut world, 1, EntityType::Pellet); - common::send_collision_event(&mut world, pacman, pellet); + common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { pacman, item: pellet }); - world.run_system_once(item_system).expect("System should run successfully"); + schedule.run(&mut world); // Score should be initial + pellet value let score = world.resource::(); @@ -198,7 +217,7 @@ fn test_item_system_preserves_existing_score() { #[test] fn test_power_pellet_does_not_affect_ghosts_in_eyes_state() { - let mut world = common::create_test_world(); + 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); @@ -208,9 +227,15 @@ 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::send_collision_event(&mut world, pacman, power_pellet); + common::trigger_collision( + &mut world, + CollisionTrigger::ItemCollision { + pacman, + item: power_pellet, + }, + ); - world.run_system_once(item_system).expect("System should run successfully"); + schedule.run(&mut world); // Check that the power pellet was collected and score updated let score = world.resource::(); diff --git a/tests/player.rs b/tests/player.rs index 0a5272f..1f5cb9f 100644 --- a/tests/player.rs +++ b/tests/player.rs @@ -112,7 +112,7 @@ fn test_entity_type_traversal_flags() { #[test] fn test_player_control_system_move_command() { - let mut world = common::create_test_world(); + let (mut world, _) = common::create_test_world(); let _player = common::spawn_test_player(&mut world, 0); // Send move command @@ -141,7 +141,7 @@ fn test_player_control_system_move_command() { #[test] fn test_player_control_system_exit_command() { - let mut world = common::create_test_world(); + let (mut world, _) = common::create_test_world(); let _player = common::spawn_test_player(&mut world, 0); // Send exit command @@ -159,7 +159,7 @@ fn test_player_control_system_exit_command() { #[test] fn test_player_control_system_toggle_debug() { - let mut world = common::create_test_world(); + let (mut world, _) = common::create_test_world(); let _player = common::spawn_test_player(&mut world, 0); // Send toggle debug command @@ -177,7 +177,7 @@ fn test_player_control_system_toggle_debug() { #[test] fn test_player_control_system_mute_audio() { - let mut world = common::create_test_world(); + let (mut world, _) = common::create_test_world(); let _player = common::spawn_test_player(&mut world, 0); // Send mute audio command @@ -206,7 +206,7 @@ fn test_player_control_system_mute_audio() { #[test] fn test_player_control_system_no_player_entity() { - let mut world = common::create_test_world(); + let (mut world, _) = common::create_test_world(); // Don't spawn a player entity common::send_game_event(&mut world, GameEvent::Command(GameCommand::MovePlayer(Direction::Up))); @@ -221,7 +221,7 @@ fn test_player_control_system_no_player_entity() { #[test] fn test_player_movement_system_buffered_direction_expires() { - let mut world = common::create_test_world(); + let (mut world, _) = common::create_test_world(); let player = common::spawn_test_player(&mut world, 0); // Set a buffered direction with short time @@ -251,7 +251,7 @@ fn test_player_movement_system_buffered_direction_expires() { #[test] fn test_player_movement_system_start_moving_from_stopped() { - let mut world = common::create_test_world(); + let (mut world, _) = common::create_test_world(); let _player = common::spawn_test_player(&mut world, 0); // Player starts at node 0, facing right (towards node 1) @@ -276,7 +276,7 @@ fn test_player_movement_system_start_moving_from_stopped() { #[test] fn test_player_movement_system_buffered_direction_change() { - let mut world = common::create_test_world(); + let (mut world, _) = common::create_test_world(); let player = common::spawn_test_player(&mut world, 0); // Set a buffered direction to go down (towards node 2) @@ -307,7 +307,7 @@ fn test_player_movement_system_buffered_direction_change() { #[test] fn test_player_movement_system_no_valid_edge() { - let mut world = common::create_test_world(); + let (mut world, _) = common::create_test_world(); let player = common::spawn_test_player(&mut world, 0); // Set velocity to direction with no edge @@ -332,7 +332,7 @@ fn test_player_movement_system_no_valid_edge() { #[test] fn test_player_movement_system_continue_moving() { - let mut world = common::create_test_world(); + let (mut world, _) = common::create_test_world(); let player = common::spawn_test_player(&mut world, 0); // Set player to already be moving @@ -362,7 +362,7 @@ fn test_player_movement_system_continue_moving() { #[test] fn test_full_player_input_to_movement_flow() { - let mut world = common::create_test_world(); + let (mut world, _) = common::create_test_world(); let _player = common::spawn_test_player(&mut world, 0); // Send move command @@ -396,7 +396,7 @@ fn test_full_player_input_to_movement_flow() { #[test] fn test_buffered_direction_timing() { - let mut world = common::create_test_world(); + let (mut world, _) = common::create_test_world(); let _player = common::spawn_test_player(&mut world, 0); // Send move command @@ -435,7 +435,7 @@ fn test_buffered_direction_timing() { #[test] fn test_multiple_rapid_direction_changes() { - let mut world = common::create_test_world(); + let (mut world, _) = common::create_test_world(); let _player = common::spawn_test_player(&mut world, 0); // Send multiple rapid direction changes @@ -468,7 +468,7 @@ fn test_multiple_rapid_direction_changes() { #[test] fn test_player_state_persistence_across_systems() { - let mut world = common::create_test_world(); + let (mut world, _) = common::create_test_world(); let _player = common::spawn_test_player(&mut world, 0); // Test that multiple commands can be processed - but need to handle events properly