feat: rewrite ghost/item collision eventing into trigger-based observer

This commit is contained in:
Ryan Walters
2025-09-10 17:15:15 -05:00
parent abf341d753
commit ae19ca1795
9 changed files with 240 additions and 222 deletions

View File

@@ -1,6 +1,6 @@
use bevy_ecs::{entity::Entity, event::Event}; 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. /// 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. /// Global events that flow through the ECS event system to coordinate game behavior.
/// ///
/// Events enable loose coupling between systems - input generates commands, collision /// Events enable loose coupling between systems - input generates commands and
/// detection reports overlaps, and various systems respond appropriately without /// various systems respond appropriately without direct dependencies.
/// direct dependencies.
#[derive(Event, Clone, Copy, Debug, PartialEq, Eq)] #[derive(Event, Clone, Copy, Debug, PartialEq, Eq)]
pub enum GameEvent { pub enum GameEvent {
/// Player input command to be processed by relevant game systems /// Player input command to be processed by relevant game systems
Command(GameCommand), Command(GameCommand),
/// Physical overlap detected between two entities requiring gameplay response
Collision(Entity, Entity),
} }
impl From<GameCommand> for GameEvent { impl From<GameCommand> for GameEvent {
@@ -44,5 +41,18 @@ impl From<GameCommand> for GameEvent {
/// Data for requesting stage transitions; processed centrally in stage_system /// Data for requesting stage transitions; processed centrally in stage_system
#[derive(Event, Clone, Copy, Debug, PartialEq, Eq)] #[derive(Event, Clone, Copy, Debug, PartialEq, Eq)]
pub enum StageTransition { 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 },
} }

View File

@@ -7,13 +7,13 @@ use tracing::{debug, info, trace, warn};
use crate::constants::{self, animation, MapTile, CANVAS_SIZE}; use crate::constants::{self, animation, MapTile, CANVAS_SIZE};
use crate::error::{GameError, GameResult}; use crate::error::{GameError, GameResult};
use crate::events::{GameEvent, StageTransition}; use crate::events::{CollisionTrigger, GameEvent, StageTransition};
use crate::map::builder::Map; use crate::map::builder::Map;
use crate::map::direction::Direction; use crate::map::direction::Direction;
use crate::systems::{ use crate::systems::{
self, audio_system, blinking_system, collision_system, combined_render_system, directional_render_system, 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, dirty_render_system, eaten_ghost_system, ghost_collision_observer, ghost_movement_system, ghost_state_system,
hud_render_system, item_system, linear_render_system, player_life_sprite_system, present_system, profile, 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, time_to_live_system, touch_ui_render_system, AudioEvent, AudioResource, AudioState, BackbufferResource, Blinking,
BufferedDirection, Collider, DebugState, DebugTextureResource, DeltaTime, DirectionalAnimation, EntityType, Frozen, BufferedDirection, Collider, DebugState, DebugTextureResource, DeltaTime, DirectionalAnimation, EntityType, Frozen,
GameStage, Ghost, GhostAnimation, GhostAnimations, GhostBundle, GhostCollider, GhostState, GlobalState, ItemBundle, GameStage, Ghost, GhostAnimation, GhostAnimations, GhostBundle, GhostCollider, GhostState, GlobalState, ItemBundle,
@@ -376,6 +376,7 @@ impl Game {
EventRegistry::register_event::<GameEvent>(world); EventRegistry::register_event::<GameEvent>(world);
EventRegistry::register_event::<AudioEvent>(world); EventRegistry::register_event::<AudioEvent>(world);
EventRegistry::register_event::<StageTransition>(world); EventRegistry::register_event::<StageTransition>(world);
EventRegistry::register_event::<CollisionTrigger>(world);
world.add_observer( world.add_observer(
|event: Trigger<GameEvent>, mut state: ResMut<GlobalState>, _score: ResMut<ScoreResource>| { |event: Trigger<GameEvent>, mut state: ResMut<GlobalState>, _score: ResMut<ScoreResource>| {
@@ -384,6 +385,9 @@ impl Game {
} }
}, },
); );
world.add_observer(ghost_collision_observer);
world.add_observer(item_collision_observer);
} }
#[allow(clippy::too_many_arguments)] #[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 player_tunnel_slowdown_system = profile(SystemId::PlayerMovement, systems::player::player_tunnel_slowdown_system);
let ghost_movement_system = profile(SystemId::Ghost, ghost_movement_system); let ghost_movement_system = profile(SystemId::Ghost, ghost_movement_system);
let collision_system = profile(SystemId::Collision, collision_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 audio_system = profile(SystemId::Audio, audio_system);
let blinking_system = profile(SystemId::Blinking, blinking_system); let blinking_system = profile(SystemId::Blinking, blinking_system);
let directional_render_system = profile(SystemId::DirectionalRender, directional_render_system); let directional_render_system = profile(SystemId::DirectionalRender, directional_render_system);
@@ -478,7 +480,7 @@ impl Game {
let gameplay_systems = ( let gameplay_systems = (
(player_movement_system, player_tunnel_slowdown_system, ghost_movement_system).chain(), (player_movement_system, player_tunnel_slowdown_system, ghost_movement_system).chain(),
eaten_ghost_system, eaten_ghost_system,
(collision_system, ghost_collision_system, item_system).chain(), (collision_system).chain(),
unified_ghost_state_system, unified_ghost_state_system,
) )
.chain() .chain()

View File

@@ -1,15 +1,16 @@
use bevy_ecs::{ use bevy_ecs::{
component::Component, component::Component,
entity::Entity, entity::Entity,
event::{EventReader, EventWriter}, event::EventWriter,
observer::Trigger,
query::With, query::With,
system::{Commands, Query, Res, ResMut, Single}, system::{Commands, Query, Res, ResMut},
}; };
use tracing::{debug, trace, warn}; use tracing::{debug, trace, warn};
use crate::events::{GameEvent, StageTransition}; use crate::events::{CollisionTrigger, StageTransition};
use crate::map::builder::Map; 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}; use crate::{error::GameError, systems::GhostState};
/// A component for defining the collision area of an entity. /// 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)) 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 /// Performs distance-based collision detection between Pac-Man and collectible items
/// using each entity's position and collision radius. When entities overlap, emits /// using each entity's position and collision radius. When entities overlap, triggers
/// a `GameEvent::Collision` for the item system to handle scoring and removal. /// collision observers for immediate handling without race conditions.
/// Collision detection accounts for both entities being in motion and supports /// Collision detection accounts for both entities being in motion and supports
/// circular collision boundaries for accurate gameplay feel. /// circular collision boundaries for accurate gameplay feel.
/// ///
@@ -70,8 +71,8 @@ pub fn collision_system(
map: Res<Map>, map: Res<Map>,
pacman_query: Query<(Entity, &Position, &Collider), With<PacmanCollider>>, pacman_query: Query<(Entity, &Position, &Collider), With<PacmanCollider>>,
item_query: Query<(Entity, &Position, &Collider), With<ItemCollider>>, item_query: Query<(Entity, &Position, &Collider), With<ItemCollider>>,
ghost_query: Query<(Entity, &Position, &Collider), With<GhostCollider>>, ghost_query: Query<(Entity, &Position, &Collider, &Ghost), With<GhostCollider>>,
mut events: EventWriter<GameEvent>, mut commands: Commands,
mut errors: EventWriter<GameError>, mut errors: EventWriter<GameError>,
) { ) {
// Check PACMAN × ITEM collisions // Check PACMAN × ITEM collisions
@@ -80,8 +81,11 @@ pub fn collision_system(
match check_collision(pacman_pos, pacman_collider, item_pos, item_collider, &map) { match check_collision(pacman_pos, pacman_collider, item_pos, item_collider, &map) {
Ok(colliding) => { Ok(colliding) => {
if colliding { if colliding {
trace!(pacman_entity = ?pacman_entity, item_entity = ?item_entity, "Item collision detected"); trace!("Item collision detected");
events.write(GameEvent::Collision(pacman_entity, item_entity)); commands.trigger(CollisionTrigger::ItemCollision {
pacman: pacman_entity,
item: item_entity,
});
} }
} }
Err(e) => { Err(e) => {
@@ -94,12 +98,16 @@ pub fn collision_system(
} }
// Check PACMAN × GHOST collisions // 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) { match check_collision(pacman_pos, pacman_collider, ghost_pos, ghost_collider, &map) {
Ok(colliding) => { Ok(colliding) => {
if colliding { if colliding {
trace!(pacman_entity = ?pacman_entity, ghost_entity = ?ghost_entity, "Ghost collision detected"); trace!(ghost = ?ghost, "Ghost collision detected");
events.write(GameEvent::Collision(pacman_entity, ghost_entity)); commands.trigger(CollisionTrigger::GhostCollision {
pacman: pacman_entity,
ghost: ghost_entity,
ghost_type: ghost.clone(),
});
} }
} }
Err(e) => { Err(e) => {
@@ -113,51 +121,44 @@ pub fn collision_system(
} }
} }
/// Observer for handling ghost collisions immediately when they occur
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn ghost_collision_system( pub fn ghost_collision_observer(
mut commands: Commands, trigger: Trigger<CollisionTrigger>,
mut collision_events: EventReader<GameEvent>,
mut stage_events: EventWriter<StageTransition>, mut stage_events: EventWriter<StageTransition>,
mut score: ResMut<ScoreResource>, mut score: ResMut<ScoreResource>,
mut game_state: ResMut<GameStage>, mut game_state: ResMut<GameStage>,
player: Single<Entity, With<PlayerControlled>>,
ghost_query: Query<(Entity, &Ghost), With<GhostCollider>>,
mut ghost_state_query: Query<&mut GhostState>, mut ghost_state_query: Query<&mut GhostState>,
mut events: EventWriter<AudioEvent>, mut events: EventWriter<AudioEvent>,
) { ) {
for event in collision_events.read() { if let CollisionTrigger::GhostCollision {
if let GameEvent::Collision(entity1, entity2) = event { pacman: _pacman,
// Check if one is Pacman and the other is a ghost ghost,
let (pacman_entity, ghost_entity) = if *entity1 == *player && ghost_query.get(*entity2).is_ok() { ghost_type,
(*entity1, *entity2) } = *trigger
} else if *entity2 == *player && ghost_query.get(*entity1).is_ok() { {
(*entity2, *entity1)
} else {
continue;
};
// Check if the ghost is frightened // 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) {
if let Ok(ghost_state) = ghost_state_query.get_mut(ghost_ent) {
// Check if ghost is in frightened state // Check if ghost is in frightened state
if matches!(*ghost_state, GhostState::Frightened { .. }) { if matches!(*ghost_state, GhostState::Frightened { .. }) {
// Pac-Man eats the ghost // Pac-Man eats the ghost
// Add score (200 points per ghost eaten) // Add score (200 points per ghost eaten)
debug!(ghost_entity = ?ghost_ent, score_added = 200, new_score = score.0 + 200, "Pacman ate frightened ghost"); debug!(ghost = ?ghost_type, score_added = 200, new_score = score.0 + 200, "Pacman ate frightened ghost");
score.0 += 200; score.0 += 200;
// Enter short pause to show bonus points, hide ghost, then set Eyes after pause // Enter short pause to show bonus points, hide ghost, then set Eyes after pause
// Request transition via event so stage_system can process it // Request transition via event so stage_system can process it
stage_events.write(StageTransition::GhostEatenPause { ghost_entity: ghost_ent }); stage_events.write(StageTransition::GhostEatenPause {
ghost_entity: ghost,
ghost_type: ghost_type,
});
// Play eat sound // Play eat sound
events.write(AudioEvent::PlayEat); events.write(AudioEvent::PlayEat);
} else if matches!(*ghost_state, GhostState::Normal) { } else if matches!(*ghost_state, GhostState::Normal) {
// Pac-Man dies // Pac-Man dies
warn!(ghost_entity = ?ghost_ent, "Pacman hit by normal ghost, player dies"); warn!(ghost = ?ghost_type, "Pacman hit by normal ghost, player dies");
*game_state = GameStage::PlayerDying(DyingSequence::Frozen { remaining_ticks: 60 }); *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); events.write(AudioEvent::StopAll);
} else { } else {
trace!(ghost_state = ?*ghost_state, "Ghost collision ignored due to state"); trace!(ghost_state = ?*ghost_state, "Ghost collision ignored due to state");
@@ -165,5 +166,66 @@ pub fn ghost_collision_system(
} }
} }
} }
/// Observer for handling item collisions immediately when they occur
#[allow(clippy::too_many_arguments)]
pub fn item_collision_observer(
trigger: Trigger<CollisionTrigger>,
mut commands: Commands,
mut score: ResMut<ScoreResource>,
mut pellet_count: ResMut<crate::systems::PelletCount>,
item_query: Query<(Entity, &crate::systems::EntityType, &Position), With<ItemCollider>>,
mut ghost_query: Query<&mut GhostState, With<GhostCollider>>,
mut events: EventWriter<AudioEvent>,
) {
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;
// Remove the collected item
commands.entity(item_ent).despawn();
// 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"
);
}
}
}
} }
} }

View File

@@ -1,12 +1,10 @@
use bevy_ecs::{ use bevy_ecs::{
entity::Entity, event::Event,
event::{Event, EventReader, EventWriter},
observer::Trigger, observer::Trigger,
query::With, system::{Commands, NonSendMut, Res},
system::{Commands, NonSendMut, Query, Res, ResMut, Single},
}; };
use strum_macros::IntoStaticStr; use strum_macros::IntoStaticStr;
use tracing::{debug, trace}; use tracing::debug;
use crate::{ use crate::{
constants, constants,
@@ -18,12 +16,7 @@ use crate::{
}, },
}; };
use crate::{ use crate::{systems::common::components::EntityType, systems::ItemCollider};
constants::animation::FRIGHTENED_FLASH_START_TICKS,
events::GameEvent,
systems::common::components::EntityType,
systems::{AudioEvent, GhostCollider, GhostState, ItemCollider, PacmanCollider, ScoreResource},
};
/// Tracks the number of pellets consumed by the player for fruit spawning mechanics. /// Tracks the number of pellets consumed by the player for fruit spawning mechanics.
#[derive(bevy_ecs::resource::Resource, Debug, Default)] #[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<GameEvent>,
mut score: ResMut<ScoreResource>,
mut pellet_count: ResMut<PelletCount>,
pacman: Single<Entity, With<PacmanCollider>>,
item_query: Query<(Entity, &EntityType, &Position), With<ItemCollider>>,
mut ghost_query: Query<&mut GhostState, With<GhostCollider>>,
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 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 /// Trigger to spawn a fruit
#[derive(Event, Clone, Copy, Debug)] #[derive(Event, Clone, Copy, Debug)]
pub enum SpawnTrigger { pub enum SpawnTrigger {

View File

@@ -34,6 +34,7 @@ pub enum GameStage {
GhostEatenPause { GhostEatenPause {
remaining_ticks: u32, remaining_ticks: u32,
ghost_entity: Entity, ghost_entity: Entity,
ghost_type: Ghost,
node: NodeId, node: NodeId,
}, },
/// The player has died and the death sequence is in progress. /// 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 // Handle stage transition requests before normal ticking
for event in stage_event_reader.read() { 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(); let pac_node = player.1.current_node();
debug!(ghost_entity = ?ghost_entity, node = pac_node, "Ghost eaten, entering pause state"); debug!(ghost_entity = ?ghost_entity, node = pac_node, "Ghost eaten, entering pause state");
new_state = Some(GameStage::GhostEatenPause { new_state = Some(GameStage::GhostEatenPause {
remaining_ticks: 30, remaining_ticks: 30,
ghost_entity, ghost_entity,
ghost_type,
node: pac_node, node: pac_node,
}); });
} }
@@ -131,7 +136,6 @@ pub fn stage_system(
remaining_ticks: remaining_ticks - 1, remaining_ticks: remaining_ticks - 1,
}) })
} else { } else {
debug!("Transitioning from text-only to characters visible startup stage");
GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 }) GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 })
} }
} }
@@ -150,12 +154,14 @@ pub fn stage_system(
GameStage::GhostEatenPause { GameStage::GhostEatenPause {
remaining_ticks, remaining_ticks,
ghost_entity, ghost_entity,
ghost_type,
node, node,
} => { } => {
if remaining_ticks > 0 { if remaining_ticks > 0 {
GameStage::GhostEatenPause { GameStage::GhostEatenPause {
remaining_ticks: remaining_ticks.saturating_sub(1), remaining_ticks: remaining_ticks.saturating_sub(1),
ghost_entity, ghost_entity,
ghost_type,
node, node,
} }
} else { } else {

View File

@@ -36,19 +36,17 @@ fn test_check_collision_helper() {
#[test] #[test]
fn test_collision_system_pacman_item() { 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 _pacman = common::spawn_test_pacman(&mut world, 0);
let _item = common::spawn_test_item(&mut world, 0, EntityType::Pellet); let _item = common::spawn_test_item(&mut world, 0, EntityType::Pellet);
// Run collision system - should not panic // Run collision system - should not panic
world schedule.run(&mut world);
.run_system_once(collision_system)
.expect("System should run successfully");
} }
#[test] #[test]
fn test_collision_system_pacman_ghost() { 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 _pacman = common::spawn_test_pacman(&mut world, 0);
let _ghost = common::spawn_test_ghost(&mut world, 0, GhostState::Normal); let _ghost = common::spawn_test_ghost(&mut world, 0, GhostState::Normal);
@@ -60,19 +58,17 @@ fn test_collision_system_pacman_ghost() {
#[test] #[test]
fn test_collision_system_no_collision() { 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 _pacman = common::spawn_test_pacman(&mut world, 0);
let _ghost = common::spawn_test_ghost(&mut world, 1, GhostState::Normal); // Different node let _ghost = common::spawn_test_ghost(&mut world, 1, GhostState::Normal); // Different node
// Run collision system - should not panic // Run collision system - should not panic
world schedule.run(&mut world);
.run_system_once(collision_system)
.expect("System should run successfully");
} }
#[test] #[test]
fn test_collision_system_multiple_entities() { 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 _pacman = common::spawn_test_pacman(&mut world, 0);
let _item = common::spawn_test_item(&mut world, 0, EntityType::Pellet); let _item = common::spawn_test_item(&mut world, 0, EntityType::Pellet);
let _ghost = common::spawn_test_ghost(&mut world, 0, GhostState::Normal); let _ghost = common::spawn_test_ghost(&mut world, 0, GhostState::Normal);

View File

@@ -1,11 +1,11 @@
#![allow(dead_code)] #![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 glam::{U16Vec2, Vec2};
use pacman::{ use pacman::{
asset::{get_asset_bytes, Asset}, asset::{get_asset_bytes, Asset},
constants::RAW_BOARD, constants::RAW_BOARD,
events::GameEvent, events::{CollisionTrigger, GameEvent},
game::ATLAS_FRAMES, game::ATLAS_FRAMES,
map::{ map::{
builder::Map, builder::Map,
@@ -13,9 +13,9 @@ use pacman::{
graph::{Graph, Node}, graph::{Graph, Node},
}, },
systems::{ systems::{
AudioEvent, AudioState, BufferedDirection, Collider, DebugState, DeltaTime, EntityType, Ghost, GhostCollider, GhostState, item_collision_observer, AudioEvent, AudioState, BufferedDirection, Collider, DebugState, DeltaTime, EntityType, Ghost,
GlobalState, ItemCollider, MovementModifiers, PacmanCollider, PelletCount, PlayerControlled, Position, ScoreResource, GhostCollider, GhostState, GlobalState, ItemCollider, MovementModifiers, PacmanCollider, PelletCount, PlayerControlled,
Velocity, Position, ScoreResource, Velocity,
}, },
texture::sprite::{AtlasMapper, AtlasTile, SpriteAtlas}, 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 /// 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(); let mut world = World::new();
// Add required resources // Add required resources
@@ -93,7 +93,11 @@ pub fn create_test_world() -> World {
}); // 60 FPS }); // 60 FPS
world.insert_resource(create_test_map()); 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 /// 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 /// Sends a collision event between two entities
pub fn send_collision_event(world: &mut World, entity1: Entity, entity2: Entity) { pub fn trigger_collision(world: &mut World, event: CollisionTrigger) {
let mut events = world.resource_mut::<Events<GameEvent>>(); world.trigger(event);
events.send(GameEvent::Collision(entity1, entity2));
} }
/// Creates a mock atlas tile for testing /// Creates a mock atlas tile for testing

View File

@@ -1,5 +1,8 @@
use bevy_ecs::{entity::Entity, system::RunSystemOnce}; use bevy_ecs::entity::Entity;
use pacman::systems::{item_system, EntityType, GhostState, Position, ScoreResource}; use pacman::{
events::CollisionTrigger,
systems::{EntityType, GhostState, Position, ScoreResource},
};
use speculoos::prelude::*; use speculoos::prelude::*;
mod common; mod common;
@@ -26,18 +29,17 @@ fn test_is_collectible_item() {
#[test] #[test]
fn test_item_system_pellet_collection() { 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 pacman = common::spawn_test_pacman(&mut world, 0);
let pellet = common::spawn_test_item(&mut world, 1, EntityType::Pellet); let pellet = common::spawn_test_item(&mut world, 1, EntityType::Pellet);
// Send collision event // 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 schedule.run(&mut world);
world.run_system_once(item_system).expect("System should run successfully");
// Check that score was updated // Check that score was updated
let score = world.resource::<ScoreResource>(); let score = world.resource_mut::<ScoreResource>();
assert_that(&score.0).is_equal_to(10); assert_that(&score.0).is_equal_to(10);
// Check that the pellet was despawned (query should return empty) // Check that the pellet was despawned (query should return empty)
@@ -51,13 +53,19 @@ fn test_item_system_pellet_collection() {
#[test] #[test]
fn test_item_system_power_pellet_collection() { 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 pacman = common::spawn_test_pacman(&mut world, 0);
let power_pellet = common::spawn_test_item(&mut world, 1, EntityType::PowerPellet); 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 // Check that score was updated with power pellet value
let score = world.resource::<ScoreResource>(); let score = world.resource::<ScoreResource>();
@@ -74,23 +82,31 @@ fn test_item_system_power_pellet_collection() {
#[test] #[test]
fn test_item_system_multiple_collections() { 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 pacman = common::spawn_test_pacman(&mut world, 0);
let pellet1 = common::spawn_test_item(&mut world, 1, EntityType::Pellet); let pellet1 = common::spawn_test_item(&mut world, 1, EntityType::Pellet);
let pellet2 = common::spawn_test_item(&mut world, 2, 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); let power_pellet = common::spawn_test_item(&mut world, 3, EntityType::PowerPellet);
// Send multiple collision events // Send multiple collision events
common::send_collision_event(&mut world, pacman, pellet1); common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { pacman, item: pellet1 });
common::send_collision_event(&mut world, pacman, pellet2); common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { pacman, item: pellet2 });
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 final score: 2 pellets (20) + 1 power pellet (50) = 70 // Check final score: 2 pellets (20) + 1 power pellet (50) = 70
let score = world.resource::<ScoreResource>(); let score = world.resource::<ScoreResource>();
assert_that(&score.0).is_equal_to(70); assert_that(&score.0).is_equal_to(70);
schedule.run(&mut world);
// Check that all items were despawned // Check that all items were despawned
let pellet_count = world let pellet_count = world
.query::<&EntityType>() .query::<&EntityType>()
@@ -108,7 +124,7 @@ fn test_item_system_multiple_collections() {
#[test] #[test]
fn test_item_system_ignores_non_item_collisions() { 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); let pacman = common::spawn_test_pacman(&mut world, 0);
// Create a ghost entity (not an item) // Create a ghost entity (not an item)
@@ -118,9 +134,9 @@ fn test_item_system_ignores_non_item_collisions() {
let initial_score = world.resource::<ScoreResource>().0; let initial_score = world.resource::<ScoreResource>().0;
// Send collision event between pacman and ghost // 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 // Score should remain unchanged
let score = world.resource::<ScoreResource>(); let score = world.resource::<ScoreResource>();
@@ -137,14 +153,14 @@ fn test_item_system_ignores_non_item_collisions() {
#[test] #[test]
fn test_item_system_no_collision_events() { 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 _pacman = common::spawn_test_pacman(&mut world, 0);
let _pellet = common::spawn_test_item(&mut world, 1, EntityType::Pellet); let _pellet = common::spawn_test_item(&mut world, 1, EntityType::Pellet);
let initial_score = world.resource::<ScoreResource>().0; let initial_score = world.resource::<ScoreResource>().0;
// Run system without any collision events // Run system without any collision events
world.run_system_once(item_system).expect("System should run successfully"); schedule.run(&mut world);
// Nothing should change // Nothing should change
let score = world.resource::<ScoreResource>(); let score = world.resource::<ScoreResource>();
@@ -159,19 +175,22 @@ fn test_item_system_no_collision_events() {
#[test] #[test]
fn test_item_system_collision_with_missing_entity() { 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); let pacman = common::spawn_test_pacman(&mut world, 0);
// Create a fake entity ID that doesn't exist // Create a fake entity ID that doesn't exist
let fake_entity = Entity::from_raw(999); 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 // System should handle gracefully and not crash
world schedule.run(&mut world);
.run_system_once(item_system)
.expect("System should handle missing entities gracefully");
// Score should remain unchanged // Score should remain unchanged
let score = world.resource::<ScoreResource>(); let score = world.resource::<ScoreResource>();
assert_that(&score.0).is_equal_to(0); assert_that(&score.0).is_equal_to(0);
@@ -179,7 +198,7 @@ fn test_item_system_collision_with_missing_entity() {
#[test] #[test]
fn test_item_system_preserves_existing_score() { 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 // Set initial score
world.insert_resource(ScoreResource(100)); 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 pacman = common::spawn_test_pacman(&mut world, 0);
let pellet = common::spawn_test_item(&mut world, 1, EntityType::Pellet); 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 // Score should be initial + pellet value
let score = world.resource::<ScoreResource>(); let score = world.resource::<ScoreResource>();
@@ -198,7 +217,7 @@ fn test_item_system_preserves_existing_score() {
#[test] #[test]
fn test_power_pellet_does_not_affect_ghosts_in_eyes_state() { 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 pacman = common::spawn_test_pacman(&mut world, 0);
let power_pellet = common::spawn_test_item(&mut world, 1, EntityType::PowerPellet); 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 // Spawn a ghost in Normal state
let normal_ghost = common::spawn_test_ghost(&mut world, 3, GhostState::Normal); 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 // Check that the power pellet was collected and score updated
let score = world.resource::<ScoreResource>(); let score = world.resource::<ScoreResource>();

View File

@@ -112,7 +112,7 @@ fn test_entity_type_traversal_flags() {
#[test] #[test]
fn test_player_control_system_move_command() { 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); let _player = common::spawn_test_player(&mut world, 0);
// Send move command // Send move command
@@ -141,7 +141,7 @@ fn test_player_control_system_move_command() {
#[test] #[test]
fn test_player_control_system_exit_command() { 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); let _player = common::spawn_test_player(&mut world, 0);
// Send exit command // Send exit command
@@ -159,7 +159,7 @@ fn test_player_control_system_exit_command() {
#[test] #[test]
fn test_player_control_system_toggle_debug() { 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); let _player = common::spawn_test_player(&mut world, 0);
// Send toggle debug command // Send toggle debug command
@@ -177,7 +177,7 @@ fn test_player_control_system_toggle_debug() {
#[test] #[test]
fn test_player_control_system_mute_audio() { 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); let _player = common::spawn_test_player(&mut world, 0);
// Send mute audio command // Send mute audio command
@@ -206,7 +206,7 @@ fn test_player_control_system_mute_audio() {
#[test] #[test]
fn test_player_control_system_no_player_entity() { 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 // Don't spawn a player entity
common::send_game_event(&mut world, GameEvent::Command(GameCommand::MovePlayer(Direction::Up))); 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] #[test]
fn test_player_movement_system_buffered_direction_expires() { 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); let player = common::spawn_test_player(&mut world, 0);
// Set a buffered direction with short time // Set a buffered direction with short time
@@ -251,7 +251,7 @@ fn test_player_movement_system_buffered_direction_expires() {
#[test] #[test]
fn test_player_movement_system_start_moving_from_stopped() { 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); let _player = common::spawn_test_player(&mut world, 0);
// Player starts at node 0, facing right (towards node 1) // Player starts at node 0, facing right (towards node 1)
@@ -276,7 +276,7 @@ fn test_player_movement_system_start_moving_from_stopped() {
#[test] #[test]
fn test_player_movement_system_buffered_direction_change() { 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); let player = common::spawn_test_player(&mut world, 0);
// Set a buffered direction to go down (towards node 2) // Set a buffered direction to go down (towards node 2)
@@ -307,7 +307,7 @@ fn test_player_movement_system_buffered_direction_change() {
#[test] #[test]
fn test_player_movement_system_no_valid_edge() { 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); let player = common::spawn_test_player(&mut world, 0);
// Set velocity to direction with no edge // Set velocity to direction with no edge
@@ -332,7 +332,7 @@ fn test_player_movement_system_no_valid_edge() {
#[test] #[test]
fn test_player_movement_system_continue_moving() { 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); let player = common::spawn_test_player(&mut world, 0);
// Set player to already be moving // Set player to already be moving
@@ -362,7 +362,7 @@ fn test_player_movement_system_continue_moving() {
#[test] #[test]
fn test_full_player_input_to_movement_flow() { 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); let _player = common::spawn_test_player(&mut world, 0);
// Send move command // Send move command
@@ -396,7 +396,7 @@ fn test_full_player_input_to_movement_flow() {
#[test] #[test]
fn test_buffered_direction_timing() { 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); let _player = common::spawn_test_player(&mut world, 0);
// Send move command // Send move command
@@ -435,7 +435,7 @@ fn test_buffered_direction_timing() {
#[test] #[test]
fn test_multiple_rapid_direction_changes() { 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); let _player = common::spawn_test_player(&mut world, 0);
// Send multiple rapid direction changes // Send multiple rapid direction changes
@@ -468,7 +468,7 @@ fn test_multiple_rapid_direction_changes() {
#[test] #[test]
fn test_player_state_persistence_across_systems() { 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); let _player = common::spawn_test_player(&mut world, 0);
// Test that multiple commands can be processed - but need to handle events properly // Test that multiple commands can be processed - but need to handle events properly