feat: proper scheduling via SystemSet, non-conditional game systems, better collision handling

This commit is contained in:
Ryan Walters
2025-09-10 21:36:51 -05:00
parent ae19ca1795
commit d84f0c831e
10 changed files with 270 additions and 191 deletions

View File

@@ -52,10 +52,12 @@ pub mod animation {
pub const GHOST_EATEN_SPEED: u16 = 6; pub const GHOST_EATEN_SPEED: u16 = 6;
/// Frightened ghost animation speed (ticks per frame at 60 ticks/sec) /// Frightened ghost animation speed (ticks per frame at 60 ticks/sec)
pub const GHOST_FRIGHTENED_SPEED: u16 = 12; pub const GHOST_FRIGHTENED_SPEED: u16 = 12;
/// Time in ticks for frightened ghosts to return to normal
/// Time in ticks when frightened ghosts start flashing (2 seconds at 60 FPS) pub const GHOST_FRIGHTENED_TICKS: u32 = 300;
pub const FRIGHTENED_FLASH_START_TICKS: u32 = 120; /// 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. /// The size of the canvas, in pixels.
pub const CANVAS_SIZE: UVec2 = UVec2::new( pub const CANVAS_SIZE: UVec2 = UVec2::new(
(BOARD_CELL_SIZE.x + BOARD_CELL_OFFSET.x + BOARD_BOTTOM_CELL_OFFSET.x) * CELL_SIZE, (BOARD_CELL_SIZE.x + BOARD_CELL_OFFSET.x + BOARD_BOTTOM_CELL_OFFSET.x) * CELL_SIZE,

View File

@@ -54,5 +54,5 @@ pub enum CollisionTrigger {
ghost_type: Ghost, ghost_type: Ghost,
}, },
/// Pac-Man collided with an item /// Pac-Man collided with an item
ItemCollision { pacman: Entity, item: Entity }, ItemCollision { item: Entity },
} }

View File

@@ -46,10 +46,23 @@ use crate::{
texture::sprite::{AtlasMapper, SpriteAtlas}, 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 /// System set for all rendering systems to ensure they run after gameplay logic
#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)] #[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
enum RenderSet { enum RenderSet {
Animation, Animation,
Draw,
Present,
} }
/// Core game state manager built on the Bevy ECS architecture. /// 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 eaten_ghost_system = profile(SystemId::EatenGhost, eaten_ghost_system);
let time_to_live_system = profile(SystemId::TimeToLive, time_to_live_system); let time_to_live_system = profile(SystemId::TimeToLive, time_to_live_system);
let forced_dirty_system = |mut dirty: ResMut<RenderDirty>| {
dirty.0 = true;
};
schedule.add_systems((forced_dirty_system
.run_if(|score: Res<ScoreResource>, stage: Res<GameStage>| score.is_changed() || stage.is_changed()),));
// Input system should always run to prevent SDL event pump from blocking // Input system should always run to prevent SDL event pump from blocking
let input_systems = ( let input_systems = (
input_system.run_if(|mut local: Local<u8>| { input_system.run_if(|mut local: Local<u8>| {
@@ -477,34 +483,50 @@ impl Game {
) )
.chain(); .chain();
let gameplay_systems = ( // .run_if(|game_state: Res<GameStage>| matches!(*game_state, GameStage::Playing));
(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<GameStage>| matches!(*game_state, GameStage::Playing));
schedule.add_systems((blinking_system, directional_render_system, linear_render_system).in_set(RenderSet::Animation)); schedule
.add_systems((
schedule.add_systems(( input_systems.in_set(GameplaySet::Input),
time_to_live_system, time_to_live_system.before(GameplaySet::Update),
stage_system, (
input_systems, player_movement_system,
gameplay_systems, player_tunnel_slowdown_system,
( ghost_movement_system,
dirty_render_system, eaten_ghost_system,
combined_render_system, collision_system,
hud_render_system, unified_ghost_state_system,
player_life_sprite_system, )
touch_ui_render_system, .in_set(GameplaySet::Update),
present_system, (
) blinking_system,
.chain() directional_render_system,
.after(RenderSet::Animation), linear_render_system,
audio_system, player_life_sprite_system,
)); )
.in_set(RenderSet::Animation),
stage_system.in_set(GameplaySet::Respond),
(
(|mut dirty: ResMut<RenderDirty>, score: Res<ScoreResource>, stage: Res<GameStage>| {
dirty.0 = score.is_changed() || stage.is_changed();
}),
dirty_render_system.run_if(|dirty: Res<RenderDirty>| 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<()> { fn spawn_items(world: &mut World) -> GameResult<()> {

View File

@@ -38,15 +38,15 @@ impl DirectionalAnimation {
/// All directions share the same frame timing to ensure perfect synchronization. /// All directions share the same frame timing to ensure perfect synchronization.
pub fn directional_render_system( pub fn directional_render_system(
dt: Res<DeltaTime>, dt: Res<DeltaTime>,
mut query: Query<(&Position, &Velocity, &mut DirectionalAnimation, &mut Renderable), Without<Frozen>>, mut query: Query<(&Position, &Velocity, &mut DirectionalAnimation, &mut Renderable, Has<Frozen>)>,
) { ) {
let ticks = (dt.seconds * 60.0).round() as u16; // Convert from seconds to ticks at 60 ticks/sec 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 { .. }); let stopped = matches!(position, Position::Stopped { .. });
// Only tick animation when moving to preserve stopped frame // Only tick animation when moving to preserve stopped frame
if !stopped { if !stopped && !frozen {
// Tick shared animation state // Tick shared animation state
anim.time_bank += ticks; anim.time_bank += ticks;
while anim.time_bank >= anim.frame_duration { while anim.time_bank >= anim.frame_duration {

View File

@@ -8,10 +8,16 @@ use bevy_ecs::{
}; };
use tracing::{debug, trace, warn}; use tracing::{debug, trace, warn};
use crate::events::{CollisionTrigger, StageTransition}; use crate::{
use crate::map::builder::Map; constants,
use crate::systems::{movement::Position, AudioEvent, DyingSequence, GameStage, Ghost, ScoreResource}; systems::{movement::Position, AudioEvent, DyingSequence, GameStage, Ghost, ScoreResource, SpawnTrigger},
};
use crate::{error::GameError, systems::GhostState}; 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. /// A component for defining the collision area of an entity.
#[derive(Component)] #[derive(Component)]
@@ -71,7 +77,7 @@ 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, &Ghost), With<GhostCollider>>, ghost_query: Query<(Entity, &Position, &Collider, &Ghost, &GhostState), With<GhostCollider>>,
mut commands: Commands, mut commands: Commands,
mut errors: EventWriter<GameError>, mut errors: EventWriter<GameError>,
) { ) {
@@ -82,10 +88,7 @@ pub fn collision_system(
Ok(colliding) => { Ok(colliding) => {
if colliding { if colliding {
trace!("Item collision detected"); trace!("Item collision detected");
commands.trigger(CollisionTrigger::ItemCollision { commands.trigger(CollisionTrigger::ItemCollision { item: item_entity });
pacman: pacman_entity,
item: item_entity,
});
} }
} }
Err(e) => { Err(e) => {
@@ -98,17 +101,19 @@ pub fn collision_system(
} }
// Check PACMAN × GHOST collisions // 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) { match check_collision(pacman_pos, pacman_collider, ghost_pos, ghost_collider, &map) {
Ok(colliding) => { Ok(colliding) => {
if colliding { if !colliding || matches!(*ghost_state, GhostState::Eyes) {
trace!(ghost = ?ghost, "Ghost collision detected"); continue;
commands.trigger(CollisionTrigger::GhostCollision {
pacman: pacman_entity,
ghost: ghost_entity,
ghost_type: ghost.clone(),
});
} }
trace!(ghost = ?ghost, "Ghost collision detected");
commands.trigger(CollisionTrigger::GhostCollision {
pacman: pacman_entity,
ghost: ghost_entity,
ghost_type: *ghost,
});
} }
Err(e) => { Err(e) => {
errors.write(GameError::InvalidState(format!( errors.write(GameError::InvalidState(format!(
@@ -137,8 +142,13 @@ pub fn ghost_collision_observer(
ghost_type, ghost_type,
} = *trigger } = *trigger
{ {
// Check if Pac-Man is already dying
if matches!(*game_state, GameStage::PlayerDying(_)) {
return;
}
// Check if the ghost is frightened // 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 // 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
@@ -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"); debug!(ghost = ?ghost_type, score_added = 200, new_score = score.0 + 200, "Pacman ate frightened ghost");
score.0 += 200; score.0 += 200;
*ghost_state = GhostState::Eyes;
// 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 { stage_events.write(StageTransition::GhostEatenPause {
@@ -173,12 +185,12 @@ pub fn item_collision_observer(
trigger: Trigger<CollisionTrigger>, trigger: Trigger<CollisionTrigger>,
mut commands: Commands, mut commands: Commands,
mut score: ResMut<ScoreResource>, mut score: ResMut<ScoreResource>,
mut pellet_count: ResMut<crate::systems::PelletCount>, mut pellet_count: ResMut<PelletCount>,
item_query: Query<(Entity, &crate::systems::EntityType, &Position), With<ItemCollider>>, item_query: Query<(Entity, &EntityType, &Position), With<ItemCollider>>,
mut ghost_query: Query<&mut GhostState, With<GhostCollider>>, mut ghost_query: Query<&mut GhostState, With<GhostCollider>>,
mut events: EventWriter<AudioEvent>, mut events: EventWriter<AudioEvent>,
) { ) {
if let CollisionTrigger::ItemCollision { pacman: _pacman, item } = *trigger { if let CollisionTrigger::ItemCollision { item } = *trigger {
// Get the item type and update score // Get the item type and update score
if let Ok((item_ent, entity_type, position)) = item_query.get(item) { if let Ok((item_ent, entity_type, position)) = item_query.get(item) {
if let Some(score_value) = entity_type.score_value() { if let Some(score_value) = entity_type.score_value() {
@@ -189,20 +201,20 @@ pub fn item_collision_observer(
commands.entity(item_ent).despawn(); commands.entity(item_ent).despawn();
// Track pellet consumption for fruit spawning // Track pellet consumption for fruit spawning
if *entity_type == crate::systems::EntityType::Pellet { if *entity_type == EntityType::Pellet {
pellet_count.0 += 1; pellet_count.0 += 1;
trace!(pellet_count = pellet_count.0, "Pellet consumed"); trace!(pellet_count = pellet_count.0, "Pellet consumed");
// Check if we should spawn a fruit // Check if we should spawn a fruit
if pellet_count.0 == 5 || pellet_count.0 == 170 { if pellet_count.0 == 5 || pellet_count.0 == 170 {
debug!(pellet_count = pellet_count.0, "Fruit spawn milestone reached"); 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 // Trigger bonus points effect if a fruit is collected
if matches!(*entity_type, crate::systems::EntityType::Fruit(_)) { if matches!(*entity_type, EntityType::Fruit(_)) {
commands.trigger(crate::systems::SpawnTrigger::Bonus { commands.trigger(SpawnTrigger::Bonus {
position: *position, position: *position,
value: entity_type.score_value().unwrap(), value: entity_type.score_value().unwrap(),
ttl: 60 * 2, ttl: 60 * 2,
@@ -214,11 +226,19 @@ pub fn item_collision_observer(
events.write(AudioEvent::PlayEat); events.write(AudioEvent::PlayEat);
} }
// Make ghosts frightened when power pellet is collected // Make non-eaten ghosts frightened when power pellet is collected
if matches!(*entity_type, crate::systems::EntityType::PowerPellet) { if matches!(*entity_type, EntityType::PowerPellet) {
debug!(duration_ticks = 300, "Power pellet collected, frightening ghosts"); debug!(
duration_ticks = constants::animation::GHOST_FRIGHTENED_TICKS,
"Power pellet collected, frightening ghosts"
);
for mut ghost_state in ghost_query.iter_mut() { 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!( debug!(
frightened_count = ghost_query.iter().count(), frightened_count = ghost_query.iter().count(),

View File

@@ -94,7 +94,7 @@ impl Default for MovementModifiers {
} }
/// Tag component for entities that should be frozen during startup /// 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; pub struct Frozen;
/// Component for HUD life sprite entities. /// Component for HUD life sprite entities.

View File

@@ -57,7 +57,7 @@ impl Ghost {
} }
} }
#[derive(Component, Debug, Clone, Copy)] #[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub enum GhostState { pub enum GhostState {
/// Normal ghost behavior - chasing Pac-Man /// Normal ghost behavior - chasing Pac-Man
Normal, Normal,
@@ -254,7 +254,7 @@ pub fn ghost_movement_system(
pub fn eaten_ghost_system( pub fn eaten_ghost_system(
map: Res<Map>, map: Res<Map>,
delta_time: Res<DeltaTime>, delta_time: Res<DeltaTime>,
mut eaten_ghosts: Query<(&Ghost, &mut Position, &mut Velocity, &mut GhostState)>, mut eaten_ghosts: Query<(&Ghost, &mut Position, &mut Velocity, &mut GhostState), Without<Frozen>>,
) { ) {
for (ghost_type, mut position, mut velocity, mut ghost_state) in eaten_ghosts.iter_mut() { for (ghost_type, mut position, mut velocity, mut ghost_state) in eaten_ghosts.iter_mut() {
// Only process ghosts that are in Eyes state // Only process ghosts that are in Eyes state

View File

@@ -42,30 +42,30 @@ pub fn player_control_system(
) { ) {
// Handle events // Handle events
for event in events.read() { for event in events.read() {
if let GameEvent::Command(command) = event { let GameEvent::Command(command) = event;
match command {
GameCommand::MovePlayer(direction) => { match command {
// Only handle movement if there's an unfrozen player GameCommand::MovePlayer(direction) => {
if let Some(player_single) = player.as_mut() { // Only handle movement if there's an unfrozen player
trace!(direction = ?*direction, "Player direction buffered for movement"); if let Some(player_single) = player.as_mut() {
***player_single = BufferedDirection::Some { trace!(direction = ?*direction, "Player direction buffered for movement");
direction: *direction, ***player_single = BufferedDirection::Some {
remaining_time: 0.25, 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" });
}
_ => {}
} }
} }
} }

View File

@@ -1,8 +1,10 @@
use std::mem::discriminant; use std::mem::discriminant;
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use crate::constants;
use crate::events::StageTransition; use crate::events::StageTransition;
use crate::systems::SpawnTrigger; use crate::map::direction::Direction;
use crate::systems::{EntityType, ItemCollider, SpawnTrigger, Velocity};
use crate::{ use crate::{
map::builder::Map, map::builder::Map,
systems::{ systems::{
@@ -37,14 +39,51 @@ pub enum GameStage {
ghost_type: Ghost, 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. At the end, the player will return to the startup sequence or game over.
PlayerDying(DyingSequence), PlayerDying(DyingSequence),
/// The level is restarting after a death.
LevelRestarting,
/// The game has ended. /// The game has ended.
GameOver, 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. /// A resource that manages the multi-stage startup sequence of the game.
#[derive(Debug, PartialEq, Eq, Clone, Copy)] #[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum StartupSequence { 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. /// The state machine for the multi-stage death sequence.
#[derive(Debug, PartialEq, Eq, Clone, Copy)] #[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum DyingSequence { pub enum DyingSequence {
@@ -82,6 +127,12 @@ pub enum DyingSequence {
Hidden { remaining_ticks: u32 }, 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. /// A resource to store the number of player lives.
#[derive(Resource, Debug)] #[derive(Resource, Debug)]
pub struct PlayerLives(pub u8); pub struct PlayerLives(pub u8);
@@ -106,7 +157,8 @@ pub fn stage_system(
mut stage_event_reader: EventReader<StageTransition>, mut stage_event_reader: EventReader<StageTransition>,
mut blinking_query: Query<Entity, With<Blinking>>, mut blinking_query: Query<Entity, With<Blinking>>,
player: Single<(Entity, &mut Position), With<PlayerControlled>>, player: Single<(Entity, &mut Position), With<PlayerControlled>>,
mut ghost_query: Query<(Entity, &Ghost, &mut Position), (With<GhostCollider>, Without<PlayerControlled>)>, mut item_query: Query<(Entity, &EntityType), With<ItemCollider>>,
mut ghost_query: Query<(Entity, &Ghost, &mut Position, &mut GhostState), (With<GhostCollider>, Without<PlayerControlled>)>,
) { ) {
let old_state = *game_state; let old_state = *game_state;
let mut new_state: Option<GameStage> = None; let mut new_state: Option<GameStage> = None;
@@ -119,7 +171,7 @@ pub fn stage_system(
} = *event; } = *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 = ?ghost_type, 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,
@@ -200,8 +252,8 @@ pub fn stage_system(
player_lives.0 = player_lives.0.saturating_sub(1); player_lives.0 = player_lives.0.saturating_sub(1);
if player_lives.0 > 0 { if player_lives.0 > 0 {
info!(remaining_lives = player_lives.0, "Player died, restarting level"); info!(remaining_lives = player_lives.0, "Player died, returning to startup sequence");
GameStage::LevelRestarting GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 })
} else { } else {
warn!("All lives lost, game over"); warn!("All lives lost, game over");
GameStage::GameOver 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, GameStage::GameOver => GameStage::GameOver,
}; };
@@ -220,12 +268,21 @@ pub fn stage_system(
return; 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) { match (old_state, new_state) {
(GameStage::Playing, GameStage::GhostEatenPause { ghost_entity, node, .. }) => { (GameStage::Playing, GameStage::GhostEatenPause { ghost_entity, node, .. }) => {
// Freeze the player & ghosts // Freeze the player & non-eaten ghosts
commands.entity(player.0).insert(Frozen); commands.entity(player.0).insert(Frozen);
for (entity, _, _) in ghost_query.iter_mut() { commands.entity(ghost_entity).insert(Frozen);
commands.entity(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 // Hide the player & eaten ghost
@@ -243,101 +300,110 @@ pub fn stage_system(
(GameStage::GhostEatenPause { ghost_entity, .. }, GameStage::Playing) => { (GameStage::GhostEatenPause { ghost_entity, .. }, GameStage::Playing) => {
// Unfreeze and reveal the player & all ghosts // Unfreeze and reveal the player & all ghosts
commands.entity(player.0).remove::<Frozen>().insert(Visibility::visible()); commands.entity(player.0).remove::<Frozen>().insert(Visibility::visible());
for (entity, _, _) in ghost_query.iter_mut() { for (entity, _, _, _) in ghost_query.iter_mut() {
commands.entity(entity).remove::<Frozen>().insert(Visibility::visible()); commands.entity(entity).remove::<Frozen>().insert(Visibility::visible());
} }
// Reveal the eaten ghost and switch it to Eyes state // Reveal the eaten ghost and switch it to Eyes state
commands.entity(ghost_entity).insert(GhostState::Eyes); commands.entity(ghost_entity).insert(GhostState::Eyes);
} }
(GameStage::Playing, GameStage::PlayerDying(DyingSequence::Frozen { .. })) => { (_, GameStage::PlayerDying(DyingSequence::Frozen { .. })) => {
// Freeze the player & ghosts // Freeze the player & ghosts
commands.entity(player.0).insert(Frozen); 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); commands.entity(entity).insert(Frozen);
} }
} }
(GameStage::PlayerDying(DyingSequence::Frozen { .. }), GameStage::PlayerDying(DyingSequence::Animating { .. })) => { (GameStage::PlayerDying(DyingSequence::Frozen { .. }), GameStage::PlayerDying(DyingSequence::Animating { .. })) => {
// Hide the ghosts // Hide the ghosts
for (entity, _, _) in ghost_query.iter_mut() { for (entity, _, _, _) in ghost_query.iter_mut() {
commands.entity(entity).insert(Visibility::hidden()); commands.entity(entity).insert(Visibility::hidden());
} }
// Start Pac-Man's death animation // Start Pac-Man's death animation
commands.entity(player.0).insert((Dying, player_death_animation.0.clone())); commands
.entity(player.0)
.remove::<DirectionalAnimation>()
.insert((Dying, player_death_animation.0.clone()));
// Play the death sound // Play the death sound
audio_events.write(AudioEvent::PlayDeath); audio_events.write(AudioEvent::PlayDeath);
} }
(GameStage::PlayerDying(DyingSequence::Animating { .. }), GameStage::PlayerDying(DyingSequence::Hidden { .. })) => { (_, GameStage::PlayerDying(DyingSequence::Hidden { .. })) => {
// Hide the player // Pac-Man's death animation is complete, so he should be hidden just like the ghosts.
commands.entity(player.0).insert(Visibility::hidden()); // Then, we reset them all back to their original positions and states.
}
(_, GameStage::LevelRestarting) => {
let (player_entity, mut pos) = player.into_inner();
*pos = Position::Stopped {
node: map.start_positions.pacman,
};
// 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() { for entity in blinking_query.iter_mut() {
commands.entity(entity).insert(Frozen).insert(Visibility::visible()); 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 // Reset the player animation
commands commands
.entity(player_entity) .entity(player.0)
.remove::<(Frozen, Dying, LinearAnimation, Looping)>() .remove::<(Dying, LinearAnimation, Looping)>()
.insert(player_animation.0.clone()); .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 // Reset ghost positions and state
for (ghost_entity, ghost, mut ghost_pos) in ghost_query.iter_mut() { for (ghost_entity, ghost, _, _) in ghost_query.iter_mut() {
*ghost_pos = Position::Stopped { commands.entity(ghost_entity).insert((
node: match ghost { GhostState::Normal,
Ghost::Blinky => map.start_positions.blinky, Position::Stopped {
Ghost::Pinky => map.start_positions.pinky, node: match ghost {
Ghost::Inky => map.start_positions.inky, Ghost::Blinky => map.start_positions.blinky,
Ghost::Clyde => map.start_positions.clyde, Ghost::Pinky => map.start_positions.pinky,
Ghost::Inky => map.start_positions.inky,
Ghost::Clyde => map.start_positions.clyde,
},
}, },
}; Frozen,
commands Visibility::hidden(),
.entity(ghost_entity) ));
.remove::<Frozen>()
.insert((Visibility::visible(), GhostState::Normal));
} }
} }
(_, GameStage::Starting(StartupSequence::CharactersVisible { .. })) => { (_, GameStage::Starting(StartupSequence::CharactersVisible { .. })) => {
// Unhide the player & ghosts // Unhide the player & ghosts
commands.entity(player.0).insert(Visibility::visible()); 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()); commands.entity(entity).insert(Visibility::visible());
} }
} }
(GameStage::Starting(StartupSequence::CharactersVisible { .. }), GameStage::Playing) => { (GameStage::Starting(StartupSequence::CharactersVisible { .. }), GameStage::Playing) => {
// Unfreeze the player & ghosts & blinking // Unfreeze the player & ghosts & blinking
commands.entity(player.0).remove::<Frozen>(); commands.entity(player.0).remove::<Frozen>();
for (entity, _, _) in ghost_query.iter_mut() { for (entity, _, _, _) in ghost_query.iter_mut() {
commands.entity(entity).remove::<Frozen>(); commands.entity(entity).remove::<Frozen>();
} }
for entity in blinking_query.iter_mut() { for entity in blinking_query.iter_mut() {
commands.entity(entity).remove::<Frozen>(); commands.entity(entity).remove::<Frozen>();
} }
} }
(GameStage::PlayerDying(..), GameStage::GameOver) => { (_, GameStage::GameOver) => {
// Freeze blinking // Freeze blinking
for entity in blinking_query.iter_mut() { for entity in blinking_query.iter_mut() {
commands.entity(entity).insert(Frozen); 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; *game_state = new_state;

View File

@@ -30,11 +30,10 @@ fn test_is_collectible_item() {
#[test] #[test]
fn test_item_system_pellet_collection() { fn test_item_system_pellet_collection() {
let (mut world, mut schedule) = 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 pellet = common::spawn_test_item(&mut world, 1, EntityType::Pellet);
// Send collision event // 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); schedule.run(&mut world);
@@ -54,16 +53,9 @@ 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, mut schedule) = 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); let power_pellet = common::spawn_test_item(&mut world, 1, EntityType::PowerPellet);
common::trigger_collision( common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: power_pellet });
&mut world,
CollisionTrigger::ItemCollision {
pacman,
item: power_pellet,
},
);
schedule.run(&mut world); schedule.run(&mut world);
@@ -83,21 +75,14 @@ fn test_item_system_power_pellet_collection() {
#[test] #[test]
fn test_item_system_multiple_collections() { fn test_item_system_multiple_collections() {
let (mut world, mut schedule) = 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 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::trigger_collision(&mut world, CollisionTrigger::ItemCollision { pacman, item: pellet1 }); common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: pellet1 });
common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { pacman, item: pellet2 }); common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: pellet2 });
common::trigger_collision( common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: power_pellet });
&mut world,
CollisionTrigger::ItemCollision {
pacman,
item: power_pellet,
},
);
schedule.run(&mut world); schedule.run(&mut world);
@@ -125,7 +110,6 @@ 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, mut schedule) = 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) // Create a ghost entity (not an item)
let ghost = world.spawn((Position::Stopped { node: 2 }, EntityType::Ghost)).id(); 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::<ScoreResource>().0; let initial_score = world.resource::<ScoreResource>().0;
// Send collision event between pacman and ghost // 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); schedule.run(&mut world);
@@ -176,18 +160,11 @@ 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, mut schedule) = 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 // Create a fake entity ID that doesn't exist
let fake_entity = Entity::from_raw(999); let fake_entity = Entity::from_raw(999);
common::trigger_collision( common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: fake_entity });
&mut world,
CollisionTrigger::ItemCollision {
pacman,
item: fake_entity,
},
);
// System should handle gracefully and not crash // System should handle gracefully and not crash
schedule.run(&mut world); schedule.run(&mut world);
@@ -203,10 +180,9 @@ fn test_item_system_preserves_existing_score() {
// Set initial score // Set initial score
world.insert_resource(ScoreResource(100)); 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); 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); schedule.run(&mut world);
@@ -218,7 +194,6 @@ 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, mut schedule) = 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); let power_pellet = common::spawn_test_item(&mut world, 1, EntityType::PowerPellet);
// Spawn a ghost in Eyes state (returning to ghost house) // 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 // 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::trigger_collision( common::trigger_collision(&mut world, CollisionTrigger::ItemCollision { item: power_pellet });
&mut world,
CollisionTrigger::ItemCollision {
pacman,
item: power_pellet,
},
);
schedule.run(&mut world); schedule.run(&mut world);