feat: implement stage transition for ghost eaten pause and add TimeToLive component

- `StageTransition` enum allows for collision system to apply state transition for ghost pausing.
- Added `TimeToLive` component & `time_to_live_system` to provide temporary sprite rendering of bonus sprites.
- Updated `stage_system` to handle the new ghost eaten pause state, including freezing entities and spawning bonus points.
This commit is contained in:
Ryan Walters
2025-09-08 13:01:40 -05:00
parent ca50d0f3d8
commit 49a6a5cc39
9 changed files with 187 additions and 67 deletions

View File

@@ -40,3 +40,9 @@ impl From<GameCommand> for GameEvent {
GameEvent::Command(command)
}
}
/// Data for requesting stage transitions; processed centrally in stage_system
#[derive(Event, Clone, Copy, Debug, PartialEq, Eq)]
pub enum StageTransition {
GhostEatenPause { ghost_entity: Entity },
}

View File

@@ -6,16 +6,16 @@ use std::collections::HashMap;
use crate::constants::{self, animation, MapTile, CANVAS_SIZE};
use crate::error::{GameError, GameResult};
use crate::events::GameEvent;
use crate::events::{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, present_system, profile, 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, Hidden, ItemBundle, ItemCollider, LastAnimationState, LinearAnimation,
hud_render_system, item_system, linear_render_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, Hidden, ItemBundle, ItemCollider, LastAnimationState, LinearAnimation,
MapTextureResource, MovementModifiers, NodeId, PacmanCollider, PlayerAnimation, PlayerBundle, PlayerControlled,
PlayerDeathAnimation, PlayerLives, Position, RenderDirty, Renderable, ScoreResource, StartupSequence, SystemId,
SystemTimings, Timing, TouchState, Velocity,
@@ -350,6 +350,7 @@ impl Game {
EventRegistry::register_event::<GameError>(world);
EventRegistry::register_event::<GameEvent>(world);
EventRegistry::register_event::<AudioEvent>(world);
EventRegistry::register_event::<StageTransition>(world);
world.add_observer(
|event: Trigger<GameEvent>, mut state: ResMut<GlobalState>, _score: ResMut<ScoreResource>| {
@@ -383,7 +384,6 @@ impl Game {
world.insert_resource(BatchedLinesResource::new(&map, constants::LARGE_SCALE));
world.insert_resource(map);
world.insert_resource(GlobalState { exit: false });
world.insert_resource(GameStage::default());
world.insert_resource(PlayerLives::default());
world.insert_resource(ScoreResource(0));
world.insert_resource(SystemTimings::default());
@@ -430,6 +430,7 @@ impl Game {
// let death_sequence_system = profile(SystemId::DeathSequence, death_sequence_system);
// let game_over_system = profile(SystemId::GameOver, systems::game_over_system);
let eaten_ghost_system = profile(SystemId::EatenGhost, eaten_ghost_system);
let time_to_live_system = profile(SystemId::TimeToLive, time_to_live_system);
let forced_dirty_system = |mut dirty: ResMut<RenderDirty>| {
dirty.0 = true;
@@ -463,6 +464,7 @@ impl Game {
schedule.add_systems((blinking_system, directional_render_system, linear_render_system).in_set(RenderSet::Animation));
schedule.add_systems((
time_to_live_system,
stage_system,
input_systems,
gameplay_systems,

View File

@@ -7,7 +7,7 @@ use bevy_ecs::{
};
use crate::error::GameError;
use crate::events::GameEvent;
use crate::events::{GameEvent, StageTransition};
use crate::map::builder::Map;
use crate::systems::{
components::GhostState, movement::Position, AudioEvent, DyingSequence, Frozen, GameStage, Ghost, PlayerControlled,
@@ -117,6 +117,7 @@ pub fn collision_system(
pub fn ghost_collision_system(
mut commands: Commands,
mut collision_events: EventReader<GameEvent>,
mut stage_events: EventWriter<StageTransition>,
mut score: ResMut<ScoreResource>,
mut game_state: ResMut<GameStage>,
pacman_query: Query<Entity, With<PlayerControlled>>,
@@ -137,15 +138,16 @@ pub fn ghost_collision_system(
// Check if the ghost is frightened
if let Ok((ghost_ent, _ghost_type)) = ghost_query.get(ghost_entity) {
if let Ok(mut ghost_state) = ghost_state_query.get_mut(ghost_ent) {
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)
score.0 += 200;
// Set ghost state to Eyes
*ghost_state = GhostState::Eyes;
// Enter short pause to show bonus points, hide ghost, then set Eyes after pause
// Request transition via event so stage_system can process it
stage_events.write(StageTransition::GhostEatenPause { ghost_entity: ghost_ent });
// Play eat sound
events.write(AudioEvent::PlayEat);

33
src/systems/lifetime.rs Normal file
View File

@@ -0,0 +1,33 @@
use bevy_ecs::{
component::Component,
entity::Entity,
system::{Commands, Query, Res},
};
use crate::systems::components::DeltaTime;
/// Component for entities that should be automatically deleted after a certain number of ticks
#[derive(Component, Debug, Clone, Copy)]
pub struct TimeToLive {
pub remaining_ticks: u32,
}
impl TimeToLive {
pub fn new(ticks: u32) -> Self {
Self { remaining_ticks: ticks }
}
}
/// System that manages entities with TimeToLive components, decrementing their remaining ticks
/// and despawning them when they expire
pub fn time_to_live_system(mut commands: Commands, dt: Res<DeltaTime>, mut query: Query<(Entity, &mut TimeToLive)>) {
for (entity, mut ttl) in query.iter_mut() {
if ttl.remaining_ticks <= dt.ticks {
// Entity has expired, despawn it
commands.entity(entity).despawn();
} else {
// Decrement remaining time
ttl.remaining_ticks = ttl.remaining_ticks.saturating_sub(dt.ticks);
}
}
}

View File

@@ -15,6 +15,7 @@ pub mod components;
pub mod ghost;
pub mod input;
pub mod item;
pub mod lifetime;
pub mod movement;
pub mod player;
pub mod state;
@@ -29,6 +30,7 @@ pub use self::debug::*;
pub use self::ghost::*;
pub use self::input::*;
pub use self::item::*;
pub use self::lifetime::*;
pub use self::movement::*;
pub use self::player::*;
pub use self::profiling::*;

View File

@@ -157,6 +157,7 @@ pub enum SystemId {
Stage,
GhostStateAnimation,
EatenGhost,
TimeToLive,
}
impl Display for SystemId {

View File

@@ -200,6 +200,7 @@ pub fn touch_ui_render_system(
}
/// Renders the HUD (score, lives, etc.) on top of the game.
#[allow(clippy::too_many_arguments)]
pub fn hud_render_system(
mut backbuffer: NonSendMut<BackbufferResource>,
mut canvas: NonSendMut<&mut Canvas<Window>>,

View File

@@ -1,18 +1,20 @@
use std::mem::discriminant;
use crate::events::StageTransition;
use crate::{
map::builder::Map,
systems::{
AudioEvent, Blinking, DirectionalAnimation, Dying, Eaten, Frozen, Ghost, GhostCollider, GhostState, Hidden,
LinearAnimation, Looping, PlayerControlled, Position,
LinearAnimation, Looping, NodeId, PlayerControlled, Position, Renderable, TimeToLive,
},
texture::{animated::TileSequence, sprite::SpriteAtlas},
};
use bevy_ecs::{
entity::Entity,
event::EventWriter,
event::{EventReader, EventWriter},
query::{With, Without},
resource::Resource,
system::{Commands, Query, Res, ResMut},
system::{Commands, NonSendMut, Query, Res, ResMut},
};
#[derive(Resource, Clone)]
@@ -27,6 +29,12 @@ pub enum GameStage {
Starting(StartupSequence),
/// The main gameplay loop is active.
Playing,
/// Short freeze after Pac-Man eats a ghost to display bonus score
GhostEatenPause {
remaining_ticks: u32,
ghost_entity: Entity,
node: NodeId,
},
/// The player has died and the death sequence is in progress.
PlayerDying(DyingSequence),
/// The level is restarting after a death.
@@ -83,6 +91,24 @@ impl Default for PlayerLives {
}
/// Handles startup sequence transitions and component management
/// Maps sprite index to the corresponding effect sprite path
fn sprite_index_to_path(index: u8) -> &'static str {
match index {
0 => "effects/100.png",
1 => "effects/200.png",
2 => "effects/300.png",
3 => "effects/400.png",
4 => "effects/700.png",
5 => "effects/800.png",
6 => "effects/1000.png",
7 => "effects/1600.png",
8 => "effects/2000.png",
9 => "effects/3000.png",
10 => "effects/5000.png",
_ => "effects/200.png", // fallback to index 1
}
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::type_complexity)]
pub fn stage_system(
@@ -93,26 +119,46 @@ pub fn stage_system(
map: Res<Map>,
mut commands: Commands,
mut audio_events: EventWriter<AudioEvent>,
mut stage_event_reader: EventReader<StageTransition>,
mut blinking_query: Query<Entity, With<Blinking>>,
mut player_query: Query<(Entity, &mut Position), With<PlayerControlled>>,
mut ghost_query: Query<(Entity, &Ghost, &mut Position), (With<GhostCollider>, Without<PlayerControlled>)>,
atlas: NonSendMut<SpriteAtlas>,
) {
let old_state = *game_state;
let new_state: GameStage = match &mut *game_state {
let mut new_state: Option<GameStage> = None;
// Handle stage transition requests before normal ticking
for event in stage_event_reader.read() {
let StageTransition::GhostEatenPause { ghost_entity } = *event;
let pac_node = player_query
.single_mut()
.ok()
.map(|(_, pos)| pos.current_node())
.unwrap_or(map.start_positions.pacman);
new_state = Some(GameStage::GhostEatenPause {
remaining_ticks: 30,
ghost_entity,
node: pac_node,
});
}
let new_state: GameStage = match new_state.unwrap_or(*game_state) {
GameStage::Starting(startup) => match startup {
StartupSequence::TextOnly { remaining_ticks } => {
if *remaining_ticks > 0 {
if remaining_ticks > 0 {
GameStage::Starting(StartupSequence::TextOnly {
remaining_ticks: *remaining_ticks - 1,
remaining_ticks: remaining_ticks - 1,
})
} else {
GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 })
}
}
StartupSequence::CharactersVisible { remaining_ticks } => {
if *remaining_ticks > 0 {
if remaining_ticks > 0 {
GameStage::Starting(StartupSequence::CharactersVisible {
remaining_ticks: *remaining_ticks - 1,
remaining_ticks: remaining_ticks - 1,
})
} else {
GameStage::Playing
@@ -120,11 +166,26 @@ pub fn stage_system(
}
},
GameStage::Playing => GameStage::Playing,
GameStage::GhostEatenPause {
remaining_ticks,
ghost_entity,
node,
} => {
if remaining_ticks > 0 {
GameStage::GhostEatenPause {
remaining_ticks: remaining_ticks.saturating_sub(1),
ghost_entity,
node,
}
} else {
GameStage::Playing
}
}
GameStage::PlayerDying(dying) => match dying {
DyingSequence::Frozen { remaining_ticks } => {
if *remaining_ticks > 0 {
if remaining_ticks > 0 {
GameStage::PlayerDying(DyingSequence::Frozen {
remaining_ticks: *remaining_ticks - 1,
remaining_ticks: remaining_ticks - 1,
})
} else {
let death_animation = &player_death_animation.0;
@@ -133,18 +194,18 @@ pub fn stage_system(
}
}
DyingSequence::Animating { remaining_ticks } => {
if *remaining_ticks > 0 {
if remaining_ticks > 0 {
GameStage::PlayerDying(DyingSequence::Animating {
remaining_ticks: *remaining_ticks - 1,
remaining_ticks: remaining_ticks - 1,
})
} else {
GameStage::PlayerDying(DyingSequence::Hidden { remaining_ticks: 60 })
}
}
DyingSequence::Hidden { remaining_ticks } => {
if *remaining_ticks > 0 {
if remaining_ticks > 0 {
GameStage::PlayerDying(DyingSequence::Hidden {
remaining_ticks: *remaining_ticks - 1,
remaining_ticks: remaining_ticks - 1,
})
} else {
player_lives.0 = player_lives.0.saturating_sub(1);
@@ -166,6 +227,54 @@ pub fn stage_system(
}
match (old_state, new_state) {
(GameStage::Playing, GameStage::GhostEatenPause { ghost_entity, node, .. }) => {
// Freeze the player & ghosts
for entity in player_query
.iter_mut()
.map(|(e, _)| e)
.chain(ghost_query.iter_mut().map(|(e, _, _)| e))
{
commands.entity(entity).insert(Frozen);
}
// Hide the player & eaten ghost
for (player_entity, _) in player_query.iter_mut() {
commands.entity(player_entity).insert(Hidden);
}
commands.entity(ghost_entity).insert(Hidden);
// Spawn bonus points entity at Pac-Man's position
let sprite_index = 1; // Index 1 = 200 points (default for ghost eating)
let sprite_path = sprite_index_to_path(sprite_index);
if let Ok(sprite_tile) = SpriteAtlas::get_tile(&atlas, sprite_path) {
let tile_sequence = TileSequence::single(sprite_tile);
let animation = LinearAnimation::new(tile_sequence, 1);
commands.spawn((
Position::Stopped { node },
Renderable {
sprite: sprite_tile,
layer: 2, // Above other entities
},
animation,
TimeToLive::new(30),
));
}
}
(GameStage::GhostEatenPause { ghost_entity, .. }, GameStage::Playing) => {
// Unfreeze and reveal the player & all ghosts
for entity in player_query
.iter_mut()
.map(|(e, _)| e)
.chain(ghost_query.iter_mut().map(|(e, _, _)| e))
{
commands.entity(entity).remove::<(Frozen, Hidden)>();
}
// Reveal the eaten ghost and switch it to Eyes state
commands.entity(ghost_entity).insert(GhostState::Eyes);
}
(GameStage::Playing, GameStage::PlayerDying(DyingSequence::Frozen { .. })) => {
// Freeze the player & ghosts
for entity in player_query
@@ -212,7 +321,7 @@ pub fn stage_system(
// Reset the player animation
commands
.entity(player_entity)
.remove::<(Frozen, Dying, Hidden, LinearAnimation, Looping)>()
.remove::<(Frozen, Dying, LinearAnimation, Looping)>()
.insert(player_animation.0.clone());
}
@@ -232,10 +341,7 @@ pub fn stage_system(
.insert(GhostState::Normal);
}
}
(
GameStage::Starting(StartupSequence::TextOnly { .. }),
GameStage::Starting(StartupSequence::CharactersVisible { .. }),
) => {
(_, GameStage::Starting(StartupSequence::CharactersVisible { .. })) => {
// Unhide the player & ghosts
for entity in player_query
.iter_mut()
@@ -275,41 +381,3 @@ pub fn stage_system(
*game_state = new_state;
}
// if let GameState::LevelRestarting = &*game_state {
// // When restarting, jump straight to the CharactersVisible stage
// // and unhide the entities.
// *startup = StartupSequence::new(0, 60 * 2); // 2 seconds for READY! text
// if let StartupSequence::TextOnly { .. } = *startup {
// // This will immediately transition to CharactersVisible on the next line
// } else {
// // Should be unreachable as we just set it
// }
// // Freeze Pac-Man and ghosts
// for entity in player_query.iter().chain(ghost_query.iter()) {
// commands.entity(entity).insert(Frozen);
// }
// *game_state = GameState::Playing;
// }
// if let Some((old_state, new_state)) = startup.tick() {
// debug!("StartupSequence transition from {old_state:?} to {new_state:?}");
// match (old_state, new_state) {
// (StartupSequence::TextOnly { .. }, StartupSequence::CharactersVisible { .. }) => {
// // Unhide the player & ghosts
// for entity in player_query.iter().chain(ghost_query.iter()) {
// commands.entity(entity).remove::<Hidden>();
// }
// }
// (StartupSequence::CharactersVisible { .. }, StartupSequence::GameActive) => {
// // Unfreeze Pac-Man, ghosts and energizers
// for entity in player_query.iter().chain(ghost_query.iter()).chain(blinking_query.iter()) {
// commands.entity(entity).remove::<Frozen>();
// }
// *game_state = GameState::Playing;
// }
// _ => {}
// }
// }

View File

@@ -14,6 +14,11 @@ impl TileSequence {
Self { tiles: tiles.to_vec() }
}
/// Creates a tile sequence with a single tile.
pub fn single(tile: AtlasTile) -> Self {
Self { tiles: vec![tile] }
}
/// Returns the tile at the given frame index, wrapping if necessary
pub fn get_tile(&self, frame: usize) -> AtlasTile {
if self.tiles.is_empty() {