mirror of
https://github.com/Xevion/Pac-Man.git
synced 2025-12-15 04:12:34 -06:00
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:
@@ -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 },
|
||||
}
|
||||
|
||||
14
src/game.rs
14
src/game.rs
@@ -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,
|
||||
|
||||
@@ -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
33
src/systems/lifetime.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -157,6 +157,7 @@ pub enum SystemId {
|
||||
Stage,
|
||||
GhostStateAnimation,
|
||||
EatenGhost,
|
||||
TimeToLive,
|
||||
}
|
||||
|
||||
impl Display for SystemId {
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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;
|
||||
// }
|
||||
// _ => {}
|
||||
// }
|
||||
// }
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user