From 823f480916419f0025159c4bd70b83928043d66e Mon Sep 17 00:00:00 2001 From: Ryan Walters Date: Mon, 8 Sep 2025 01:14:32 -0500 Subject: [PATCH] feat: setup pacman collision, level restart, game over, death sequence, switch to Vec for TileSequence --- src/constants.rs | 2 - src/game.rs | 195 ++++++++++------------- src/systems/collision.rs | 35 +++-- src/systems/components.rs | 15 +- src/systems/ghost.rs | 22 ++- src/systems/mod.rs | 24 +-- src/systems/render.rs | 76 +++++---- src/systems/stage.rs | 101 ------------ src/systems/state.rs | 315 ++++++++++++++++++++++++++++++++++++++ src/texture/animated.rs | 56 +++---- 10 files changed, 531 insertions(+), 310 deletions(-) delete mode 100644 src/systems/stage.rs create mode 100644 src/systems/state.rs diff --git a/src/constants.rs b/src/constants.rs index 0d8eeba..d3be1bd 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -132,8 +132,6 @@ pub const RAW_BOARD: [&str; BOARD_CELL_SIZE.y as usize] = [ pub mod startup { /// Number of frames for the startup sequence (3 seconds at 60 FPS) pub const STARTUP_FRAMES: u32 = 60 * 3; - /// Number of ticks per frame during startup - pub const STARTUP_TICKS_PER_FRAME: u32 = 60; } /// Game mechanics constants diff --git a/src/game.rs b/src/game.rs index 38ee8b6..a1f894e 100644 --- a/src/game.rs +++ b/src/game.rs @@ -9,32 +9,26 @@ use crate::error::{GameError, GameResult}; use crate::events::GameEvent; use crate::map::builder::Map; use crate::map::direction::Direction; -use crate::systems::blinking::Blinking; -use crate::systems::components::{GhostAnimation, GhostState, LastAnimationState}; -use crate::systems::movement::{BufferedDirection, Position, Velocity}; -use crate::systems::profiling::{SystemId, Timing}; -use crate::systems::render::touch_ui_render_system; -use crate::systems::render::RenderDirty; use crate::systems::{ - self, combined_render_system, ghost_collision_system, present_system, Hidden, LinearAnimation, MovementModifiers, NodeId, - TouchState, -}; -use crate::systems::{ - audio_system, blinking_system, collision_system, directional_render_system, dirty_render_system, eaten_ghost_system, - ghost_movement_system, ghost_state_system, hud_render_system, item_system, linear_render_system, profile, AudioEvent, - AudioResource, AudioState, BackbufferResource, Collider, DebugState, DebugTextureResource, DeltaTime, DirectionalAnimation, - EntityType, Frozen, Ghost, GhostAnimations, GhostBundle, GhostCollider, GlobalState, ItemBundle, ItemCollider, - MapTextureResource, PacmanCollider, PlayerBundle, PlayerControlled, Renderable, ScoreResource, StartupSequence, - SystemTimings, + 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, + MapTextureResource, MovementModifiers, NodeId, PacmanCollider, PlayerAnimation, PlayerBundle, PlayerControlled, + PlayerDeathAnimation, PlayerLives, Position, RenderDirty, Renderable, ScoreResource, StartupSequence, SystemId, + SystemTimings, Timing, TouchState, Velocity, }; + use crate::texture::animated::{DirectionalTiles, TileSequence}; use crate::texture::sprite::AtlasTile; use crate::texture::sprites::{FrightenedColor, GameSprite, GhostSprite, MazeSprite, PacmanSprite}; +use bevy_ecs::change_detection::DetectChanges; use bevy_ecs::event::EventRegistry; use bevy_ecs::observer::Trigger; -use bevy_ecs::schedule::common_conditions::resource_changed; -use bevy_ecs::schedule::{Condition, IntoScheduleConfigs, Schedule, SystemSet}; -use bevy_ecs::system::{Local, ResMut}; +use bevy_ecs::schedule::{IntoScheduleConfigs, Schedule, SystemSet}; +use bevy_ecs::system::{Local, Res, ResMut}; use bevy_ecs::world::World; use sdl2::event::EventType; use sdl2::image::LoadTexture; @@ -54,7 +48,9 @@ use crate::{ /// System set for all rendering systems to ensure they run after gameplay logic #[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)] -pub struct RenderSet; +enum RenderSet { + Animation, +} /// Core game state manager built on the Bevy ECS architecture. /// @@ -112,6 +108,8 @@ impl Game { let (player_animation, player_start_sprite) = Self::create_player_animations(&atlas)?; let player_bundle = Self::create_player_bundle(&map, player_animation, player_start_sprite); + let death_animation = Self::create_death_animation(&atlas)?; + let mut world = World::default(); let mut schedule = Schedule::default(); @@ -127,6 +125,7 @@ impl Game { map_texture, debug_texture, ttf_atlas, + death_animation, )?; Self::configure_schedule(&mut schedule); @@ -310,6 +309,18 @@ impl Game { Ok((player_animation, player_start_sprite)) } + fn create_death_animation(atlas: &SpriteAtlas) -> GameResult { + let mut death_tiles = Vec::new(); + for i in 0..=10 { + // Assuming death animation has 11 frames named pacman/die_0, pacman/die_1, etc. + let tile = atlas.get_tile(&GameSprite::Pacman(PacmanSprite::Dying(i)).to_path())?; + death_tiles.push(tile); + } + + let tile_sequence = TileSequence::new(&death_tiles); + Ok(LinearAnimation::new(tile_sequence, 8)) // 8 ticks per frame, non-looping + } + fn create_player_bundle(map: &Map, player_animation: DirectionalAnimation, player_start_sprite: AtlasTile) -> PlayerBundle { PlayerBundle { player: PlayerControlled, @@ -361,13 +372,19 @@ impl Game { map_texture: sdl2::render::Texture, debug_texture: sdl2::render::Texture, ttf_atlas: crate::texture::ttf::TtfAtlas, + death_animation: LinearAnimation, ) -> GameResult<()> { world.insert_non_send_resource(atlas); world.insert_resource(Self::create_ghost_animations(world.non_send_resource::())?); + let player_animation = Self::create_player_animations(world.non_send_resource::())?.0; + world.insert_resource(PlayerAnimation(player_animation)); + world.insert_resource(PlayerDeathAnimation(death_animation)); 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()); world.insert_resource(Timing::default()); @@ -378,10 +395,9 @@ impl Game { world.insert_resource(AudioState::default()); world.insert_resource(CursorPosition::default()); world.insert_resource(TouchState::default()); - world.insert_resource(StartupSequence::new( - constants::startup::STARTUP_FRAMES, - constants::startup::STARTUP_TICKS_PER_FRAME, - )); + world.insert_resource(GameStage::Starting(StartupSequence::TextOnly { + remaining_ticks: constants::startup::STARTUP_FRAMES, + })); world.insert_non_send_resource(event_pump); world.insert_non_send_resource::<&mut Canvas>(Box::leak(Box::new(canvas))); @@ -394,15 +410,14 @@ impl Game { } fn configure_schedule(schedule: &mut Schedule) { + let stage_system = profile(SystemId::Stage, systems::stage_system); let input_system = profile(SystemId::Input, systems::input::input_system); let player_control_system = profile(SystemId::PlayerControls, systems::player_control_system); let player_movement_system = profile(SystemId::PlayerMovement, systems::player_movement_system); - let startup_stage_system = profile(SystemId::Stage, systems::startup_stage_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 collision_system = profile(SystemId::Collision, collision_system); let ghost_collision_system = profile(SystemId::GhostCollision, ghost_collision_system); - let item_system = profile(SystemId::Item, item_system); let audio_system = profile(SystemId::Audio, audio_system); let blinking_system = profile(SystemId::Blinking, blinking_system); @@ -412,41 +427,55 @@ impl Game { let hud_render_system = profile(SystemId::HudRender, hud_render_system); let present_system = profile(SystemId::Present, present_system); let unified_ghost_state_system = profile(SystemId::GhostStateAnimation, ghost_state_system); + // 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 forced_dirty_system = |mut dirty: ResMut| { dirty.0 = true; }; - schedule.add_systems(( - forced_dirty_system.run_if(resource_changed::.or(resource_changed::)), - ( - input_system.run_if(|mut local: Local| { - *local = local.wrapping_add(1u8); - // run every nth frame - *local % 2 == 0 - }), - player_control_system, - player_movement_system, - startup_stage_system, - ) - .chain(), - player_tunnel_slowdown_system, - ghost_movement_system, - profile(SystemId::EatenGhost, eaten_ghost_system), - unified_ghost_state_system, + schedule.add_systems( + forced_dirty_system + .run_if(|score: Res, stage: Res| score.is_changed() || stage.is_changed()), + ); + + // Input system should always run to prevent SDL event pump from blocking + let input_systems = ( + input_system.run_if(|mut local: Local| { + *local = local.wrapping_add(1u8); + // run every nth frame + *local % 2 == 0 + }), + player_control_system, + ) + .chain(); + + let gameplay_systems = ( + (player_movement_system, player_tunnel_slowdown_system, ghost_movement_system).chain(), + eaten_ghost_system, (collision_system, ghost_collision_system, item_system).chain(), - audio_system, - blinking_system, + unified_ghost_state_system, + ) + .chain() + .run_if(|game_state: Res| matches!(*game_state, GameStage::Playing)); + + schedule.add_systems((blinking_system, directional_render_system, linear_render_system).in_set(RenderSet::Animation)); + + schedule.add_systems(( + stage_system, + input_systems, + gameplay_systems, ( - directional_render_system, - linear_render_system, dirty_render_system, combined_render_system, hud_render_system, touch_ui_render_system, present_system, ) - .chain(), + .chain() + .after(RenderSet::Animation), + audio_system, )); } @@ -512,7 +541,7 @@ impl Game { for (ghost_type, start_node) in ghost_start_positions { // Create the ghost bundle in a separate scope to manage borrows let ghost = { - let animations = *world.resource::().get_normal(&ghost_type).unwrap(); + let animations = world.resource::().get_normal(&ghost_type).unwrap().clone(); let atlas = world.non_send_resource::(); let sprite_path = GameSprite::Ghost(GhostSprite::Normal(ghost_type, Direction::Left, 0)).to_path(); @@ -557,7 +586,7 @@ impl Game { TileSequence::new(&[left_eye]), TileSequence::new(&[right_eye]), ); - let eyes = DirectionalAnimation::new(eyes_tiles, eyes_tiles, animation::GHOST_EATEN_SPEED); + let eyes = DirectionalAnimation::new(eyes_tiles.clone(), eyes_tiles, animation::GHOST_EATEN_SPEED); let mut animations = HashMap::new(); @@ -586,7 +615,7 @@ impl Game { TileSequence::new(&left_tiles), TileSequence::new(&right_tiles), ); - let normal = DirectionalAnimation::new(normal_moving, normal_moving, animation::GHOST_NORMAL_SPEED); + let normal = DirectionalAnimation::new(normal_moving.clone(), normal_moving, animation::GHOST_NORMAL_SPEED); animations.insert(ghost_type, normal); } @@ -658,68 +687,4 @@ impl Game { state.exit } - - // /// Renders pathfinding debug lines from each ghost to Pac-Man. - // /// - // /// Each ghost's path is drawn in its respective color with a small offset - // /// to prevent overlapping lines. - // fn render_pathfinding_debug(&self, canvas: &mut Canvas) -> GameResult<()> { - // let pacman_node = self.state.pacman.current_node_id(); - - // for ghost in self.state.ghosts.iter() { - // if let Ok(path) = ghost.calculate_path_to_target(&self.state.map.graph, pacman_node) { - // if path.len() < 2 { - // continue; // Skip if path is too short - // } - - // // Set the ghost's color - // canvas.set_draw_color(ghost.debug_color()); - - // // Calculate offset based on ghost index to prevent overlapping lines - // // let offset = (i as f32) * 2.0 - 3.0; // Offset range: -3.0 to 3.0 - - // // Calculate a consistent offset direction for the entire path - // // let first_node = self.map.graph.get_node(path[0]).unwrap(); - // // let last_node = self.map.graph.get_node(path[path.len() - 1]).unwrap(); - - // // Use the overall direction from start to end to determine the perpendicular offset - // let offset = match ghost.ghost_type { - // GhostType::Blinky => glam::Vec2::new(0.25, 0.5), - // GhostType::Pinky => glam::Vec2::new(-0.25, -0.25), - // GhostType::Inky => glam::Vec2::new(0.5, -0.5), - // GhostType::Clyde => glam::Vec2::new(-0.5, 0.25), - // } * 5.0; - - // // Calculate offset positions for all nodes using the same perpendicular direction - // let mut offset_positions = Vec::new(); - // for &node_id in &path { - // let node = self - // .state - // .map - // .graph - // .get_node(node_id) - // .ok_or(crate::error::EntityError::NodeNotFound(node_id))?; - // let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2(); - // offset_positions.push(pos + offset); - // } - - // // Draw lines between the offset positions - // for window in offset_positions.windows(2) { - // if let (Some(from), Some(to)) = (window.first(), window.get(1)) { - // // Skip if the distance is too far (used for preventing lines between tunnel portals) - // if from.distance_squared(*to) > (crate::constants::CELL_SIZE * 16).pow(2) as f32 { - // continue; - // } - - // // Draw the line - // canvas - // .draw_line((from.x as i32, from.y as i32), (to.x as i32, to.y as i32)) - // .map_err(|e| crate::error::GameError::Sdl(e.to_string()))?; - // } - // } - // } - // } - - // Ok(()) - // } } diff --git a/src/systems/collision.rs b/src/systems/collision.rs index e132a8f..f4c3418 100644 --- a/src/systems/collision.rs +++ b/src/systems/collision.rs @@ -1,15 +1,20 @@ -use bevy_ecs::component::Component; -use bevy_ecs::entity::Entity; -use bevy_ecs::event::{EventReader, EventWriter}; -use bevy_ecs::query::With; -use bevy_ecs::system::{Query, Res, ResMut}; +use bevy_ecs::{ + component::Component, + entity::Entity, + event::{EventReader, EventWriter}, + query::With, + system::{Commands, Query, Res, ResMut}, +}; use crate::error::GameError; use crate::events::GameEvent; use crate::map::builder::Map; -use crate::systems::movement::Position; -use crate::systems::{AudioEvent, Ghost, GhostState, PlayerControlled, ScoreResource}; +use crate::systems::{ + components::GhostState, movement::Position, AudioEvent, DyingSequence, Frozen, GameStage, Ghost, PlayerControlled, + ScoreResource, +}; +/// A component for defining the collision area of an entity. #[derive(Component)] pub struct Collider { pub size: f32, @@ -62,6 +67,7 @@ pub fn check_collision( /// /// Also detects collisions between Pac-Man and ghosts for gameplay mechanics like /// power pellet effects, ghost eating, and player death. +#[allow(clippy::too_many_arguments)] pub fn collision_system( map: Res, pacman_query: Query<(Entity, &Position, &Collider), With>, @@ -107,10 +113,13 @@ pub fn collision_system( } } +#[allow(clippy::too_many_arguments)] pub fn ghost_collision_system( + mut commands: Commands, mut collision_events: EventReader, mut score: ResMut, - pacman_query: Query<(), With>, + mut game_state: ResMut, + pacman_query: Query>, ghost_query: Query<(Entity, &Ghost), With>, mut ghost_state_query: Query<&mut GhostState>, mut events: EventWriter, @@ -118,7 +127,7 @@ pub fn ghost_collision_system( for event in collision_events.read() { if let GameEvent::Collision(entity1, entity2) = event { // Check if one is Pacman and the other is a ghost - let (_pacman_entity, ghost_entity) = if pacman_query.get(*entity1).is_ok() && ghost_query.get(*entity2).is_ok() { + let (pacman_entity, ghost_entity) = if pacman_query.get(*entity1).is_ok() && ghost_query.get(*entity2).is_ok() { (*entity1, *entity2) } else if pacman_query.get(*entity2).is_ok() && ghost_query.get(*entity1).is_ok() { (*entity2, *entity1) @@ -140,8 +149,12 @@ pub fn ghost_collision_system( // Play eat sound events.write(AudioEvent::PlayEat); - } else { - // Pac-Man dies (this would need a death system) + } else if matches!(*ghost_state, GhostState::Normal) { + // Pac-Man dies + *game_state = GameStage::PlayerDying(DyingSequence::Frozen { remaining_ticks: 60 }); + commands.entity(pacman_entity).insert(Frozen); + commands.entity(ghost_entity).insert(Frozen); + events.write(AudioEvent::StopAll); } } } diff --git a/src/systems/components.rs b/src/systems/components.rs index 489083e..7057c05 100644 --- a/src/systems/components.rs +++ b/src/systems/components.rs @@ -101,7 +101,7 @@ pub struct Renderable { } /// Directional animation component with shared timing across all directions -#[derive(Component, Clone, Copy)] +#[derive(Component, Clone)] pub struct DirectionalAnimation { pub moving_tiles: DirectionalTiles, pub stopped_tiles: DirectionalTiles, @@ -123,13 +123,18 @@ impl DirectionalAnimation { } } +/// Tag component to mark animations that should loop when they reach the end +#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)] +pub struct Looping; + /// Linear animation component for non-directional animations (frightened ghosts) -#[derive(Component, Clone, Copy)] +#[derive(Component, Resource, Clone)] pub struct LinearAnimation { pub tiles: TileSequence, pub current_frame: usize, pub time_bank: u16, pub frame_duration: u16, + pub finished: bool, } impl LinearAnimation { @@ -140,6 +145,7 @@ impl LinearAnimation { current_frame: 0, time_bank: 0, frame_duration, + finished: false, } } } @@ -218,6 +224,11 @@ pub struct Frozen; #[derive(Component, Debug, Clone, Copy)] pub struct Eaten; +/// Tag component for Pac-Man during his death animation. +/// This is mainly because the Frozen tag would stop both movement and animation, while the Dying tag can signal that the animation should continue despite being frozen. +#[derive(Component, Debug, Clone, Copy)] +pub struct Dying; + #[derive(Component, Debug, Clone, Copy)] pub enum GhostState { /// Normal ghost behavior - chasing Pac-Man diff --git a/src/systems/ghost.rs b/src/systems/ghost.rs index 1d705ee..0fae9d5 100644 --- a/src/systems/ghost.rs +++ b/src/systems/ghost.rs @@ -1,5 +1,7 @@ use crate::platform; -use crate::systems::components::{DirectionalAnimation, Frozen, GhostAnimation, GhostState, LastAnimationState, LinearAnimation}; +use crate::systems::components::{ + DirectionalAnimation, Frozen, GhostAnimation, GhostState, LastAnimationState, LinearAnimation, Looping, +}; use crate::{ map::{ builder::Map, @@ -194,22 +196,26 @@ pub fn ghost_state_system( if last_animation_state.0 != current_animation_state { match current_animation_state { GhostAnimation::Frightened { flash } => { - // Remove DirectionalAnimation, add LinearAnimation + // Remove DirectionalAnimation, add LinearAnimation with Looping component commands .entity(entity) .remove::() - .insert(*animations.frightened(flash)); + .insert(animations.frightened(flash).clone()) + .insert(Looping); } GhostAnimation::Normal => { - // Remove LinearAnimation, add DirectionalAnimation + // Remove LinearAnimation and Looping, add DirectionalAnimation commands .entity(entity) - .remove::() - .insert(*animations.get_normal(ghost_type).unwrap()); + .remove::<(LinearAnimation, Looping)>() + .insert(animations.get_normal(ghost_type).unwrap().clone()); } GhostAnimation::Eyes => { - // Remove LinearAnimation, add DirectionalAnimation (eyes animation) - commands.entity(entity).remove::().insert(*animations.eyes()); + // Remove LinearAnimation and Looping, add DirectionalAnimation (eyes animation) + commands + .entity(entity) + .remove::<(LinearAnimation, Looping)>() + .insert(animations.eyes().clone()); } } last_animation_state.0 = current_animation_state; diff --git a/src/systems/mod.rs b/src/systems/mod.rs index 63d89dc..6945afd 100644 --- a/src/systems/mod.rs +++ b/src/systems/mod.rs @@ -1,25 +1,25 @@ -//! The Entity-Component-System (ECS) module. -//! -//! This module contains all the ECS-related logic, including components, systems, -//! and resources. +//! This module contains all the systems in the game. #[cfg_attr(coverage_nightly, coverage(off))] pub mod audio; +#[cfg_attr(coverage_nightly, coverage(off))] +pub mod debug; +#[cfg_attr(coverage_nightly, coverage(off))] +pub mod profiling; +#[cfg_attr(coverage_nightly, coverage(off))] +pub mod render; + pub mod blinking; pub mod collision; pub mod components; -#[cfg_attr(coverage_nightly, coverage(off))] -pub mod debug; pub mod ghost; pub mod input; pub mod item; pub mod movement; pub mod player; -#[cfg_attr(coverage_nightly, coverage(off))] -pub mod profiling; -#[cfg_attr(coverage_nightly, coverage(off))] -pub mod render; -pub mod stage; +pub mod state; + +// Re-export all the modules. Do not fine-tune the exports. pub use self::audio::*; pub use self::blinking::*; @@ -33,4 +33,4 @@ pub use self::movement::*; pub use self::player::*; pub use self::profiling::*; pub use self::render::*; -pub use self::stage::*; +pub use self::state::*; diff --git a/src/systems/render.rs b/src/systems/render.rs index 31c5851..f1b8bef 100644 --- a/src/systems/render.rs +++ b/src/systems/render.rs @@ -1,18 +1,20 @@ -use crate::constants::CANVAS_SIZE; -use crate::error::{GameError, TextureError}; use crate::map::builder::Map; use crate::systems::input::TouchState; use crate::systems::{ debug_render_system, BatchedLinesResource, Collider, CursorPosition, DebugState, DebugTextureResource, DeltaTime, - DirectionalAnimation, LinearAnimation, Position, Renderable, ScoreResource, StartupSequence, SystemId, SystemTimings, - TtfAtlasResource, Velocity, + DirectionalAnimation, Dying, Frozen, GameStage, LinearAnimation, Looping, Position, Renderable, ScoreResource, + StartupSequence, SystemId, SystemTimings, TtfAtlasResource, Velocity, }; use crate::texture::sprite::SpriteAtlas; use crate::texture::text::TextTexture; +use crate::{ + constants::CANVAS_SIZE, + error::{GameError, TextureError}, +}; use bevy_ecs::component::Component; use bevy_ecs::entity::Entity; use bevy_ecs::event::EventWriter; -use bevy_ecs::query::{Changed, Or, Without}; +use bevy_ecs::query::{Changed, Has, Or, With, Without}; use bevy_ecs::removal_detection::RemovedComponents; use bevy_ecs::resource::Resource; use bevy_ecs::system::{NonSendMut, Query, Res, ResMut}; @@ -53,7 +55,7 @@ pub fn dirty_render_system( /// All directions share the same frame timing to ensure perfect synchronization. pub fn directional_render_system( dt: Res, - mut query: Query<(&Position, &Velocity, &mut DirectionalAnimation, &mut Renderable)>, + mut query: Query<(&Position, &Velocity, &mut DirectionalAnimation, &mut Renderable), Without>, ) { let ticks = (dt.seconds * 60.0).round() as u16; // Convert from seconds to ticks at 60 ticks/sec @@ -86,26 +88,35 @@ pub fn directional_render_system( } } -/// Updates linear animated entities (used for non-directional animations like frightened ghosts). -/// -/// This system handles entities that use LinearAnimation component for simple frame cycling. -pub fn linear_render_system(dt: Res, mut query: Query<(&mut LinearAnimation, &mut Renderable)>) { - let ticks = (dt.seconds * 60.0).round() as u16; // Convert from seconds to ticks at 60 ticks/sec - - for (mut anim, mut renderable) in query.iter_mut() { - // Tick animation - anim.time_bank += ticks; - while anim.time_bank >= anim.frame_duration { - anim.time_bank -= anim.frame_duration; - anim.current_frame += 1; +/// System that updates `Renderable` sprites for entities with `LinearAnimation`. +#[allow(clippy::type_complexity)] +pub fn linear_render_system( + dt: Res, + mut query: Query<(&mut LinearAnimation, &mut Renderable, Has), Or<(Without, With)>>, +) { + for (mut anim, mut renderable, looping) in query.iter_mut() { + if anim.finished { + continue; } - if !anim.tiles.is_empty() { - let new_tile = anim.tiles.get_tile(anim.current_frame); - if renderable.sprite != new_tile { - renderable.sprite = new_tile; - } + anim.time_bank += dt.ticks as u16; + let frames_to_advance = (anim.time_bank / anim.frame_duration) as usize; + + if frames_to_advance == 0 { + continue; } + + let total_frames = anim.tiles.len(); + + if !looping && anim.current_frame + frames_to_advance >= total_frames { + anim.finished = true; + anim.current_frame = total_frames - 1; + } else { + anim.current_frame += frames_to_advance; + } + + anim.time_bank %= anim.frame_duration; + renderable.sprite = anim.tiles.get_tile(anim.current_frame); } } @@ -194,7 +205,7 @@ pub fn hud_render_system( mut canvas: NonSendMut<&mut Canvas>, mut atlas: NonSendMut, score: Res, - startup: Res, + stage: Res, mut errors: EventWriter, ) { let _ = canvas.with_texture_canvas(&mut backbuffer.0, |canvas| { @@ -226,10 +237,21 @@ pub fn hud_render_system( errors.write(TextureError::RenderFailed(format!("Failed to render high score text: {}", e)).into()); } + // Render GAME OVER text + if matches!(*stage, GameStage::GameOver) { + let game_over_text = "GAME OVER"; + let game_over_width = text_renderer.text_width(game_over_text); + let game_over_position = glam::UVec2::new((CANVAS_SIZE.x - game_over_width) / 2, 160); + if let Err(e) = text_renderer.render_with_color(canvas, &mut atlas, game_over_text, game_over_position, Color::RED) { + errors.write(TextureError::RenderFailed(format!("Failed to render GAME OVER text: {}", e)).into()); + } + } + // Render text based on StartupSequence stage if matches!( - *startup, - StartupSequence::TextOnly { .. } | StartupSequence::CharactersVisible { .. } + *stage, + GameStage::Starting(StartupSequence::TextOnly { .. }) + | GameStage::Starting(StartupSequence::CharactersVisible { .. }) ) { let ready_text = "READY!"; let ready_width = text_renderer.text_width(ready_text); @@ -238,7 +260,7 @@ pub fn hud_render_system( errors.write(TextureError::RenderFailed(format!("Failed to render READY text: {}", e)).into()); } - if matches!(*startup, StartupSequence::TextOnly { .. }) { + if matches!(*stage, GameStage::Starting(StartupSequence::TextOnly { .. })) { let player_one_text = "PLAYER ONE"; let player_one_width = text_renderer.text_width(player_one_text); let player_one_position = glam::UVec2::new((CANVAS_SIZE.x - player_one_width) / 2, 113); diff --git a/src/systems/stage.rs b/src/systems/stage.rs deleted file mode 100644 index 3a88144..0000000 --- a/src/systems/stage.rs +++ /dev/null @@ -1,101 +0,0 @@ -use bevy_ecs::{ - entity::Entity, - query::With, - resource::Resource, - system::{Commands, Query, ResMut}, -}; -use tracing::debug; - -use crate::systems::{Blinking, Frozen, GhostCollider, Hidden, PlayerControlled}; - -#[derive(Resource, Debug, Clone, Copy)] -pub enum StartupSequence { - /// Stage 1: Text-only stage - /// - Player & ghosts are hidden - /// - READY! and PLAYER ONE text are shown - /// - Energizers do not blink - TextOnly { - /// Remaining ticks in this stage - remaining_ticks: u32, - }, - /// Stage 2: Characters visible stage - /// - PLAYER ONE text is hidden, READY! text remains - /// - Ghosts and Pac-Man are now shown - CharactersVisible { - /// Remaining ticks in this stage - remaining_ticks: u32, - }, - /// Stage 3: Game begins - /// - Final state, game is fully active - GameActive, -} - -impl StartupSequence { - /// Creates a new StartupSequence with the specified duration in ticks - pub fn new(text_only_ticks: u32, _characters_visible_ticks: u32) -> Self { - Self::TextOnly { - remaining_ticks: text_only_ticks, - } - } - - /// Ticks the timer by one frame, returning transition information if state changes - pub fn tick(&mut self) -> Option<(StartupSequence, StartupSequence)> { - match self { - StartupSequence::TextOnly { remaining_ticks } => { - if *remaining_ticks > 0 { - *remaining_ticks -= 1; - None - } else { - let from = *self; - *self = StartupSequence::CharactersVisible { - remaining_ticks: 60, // 1 second at 60 FPS - }; - Some((from, *self)) - } - } - StartupSequence::CharactersVisible { remaining_ticks } => { - if *remaining_ticks > 0 { - *remaining_ticks -= 1; - None - } else { - let from = *self; - *self = StartupSequence::GameActive; - Some((from, *self)) - } - } - StartupSequence::GameActive => None, - } - } -} - -/// Handles startup sequence transitions and component management -pub fn startup_stage_system( - mut startup: ResMut, - mut commands: Commands, - mut blinking_query: Query>, - mut player_query: Query>, - mut ghost_query: Query>, -) { - if let Some((from, to)) = startup.tick() { - debug!("StartupSequence transition from {from:?} to {to:?}"); - match (from, to) { - (StartupSequence::TextOnly { .. }, StartupSequence::CharactersVisible { .. }) => { - // Unhide the player & ghosts - for entity in player_query.iter_mut().chain(ghost_query.iter_mut()) { - commands.entity(entity).remove::(); - } - } - (StartupSequence::CharactersVisible { .. }, StartupSequence::GameActive) => { - // Unfreeze the player & ghosts & pellet blinking - for entity in player_query - .iter_mut() - .chain(ghost_query.iter_mut()) - .chain(blinking_query.iter_mut()) - { - commands.entity(entity).remove::(); - } - } - _ => {} - } - } -} diff --git a/src/systems/state.rs b/src/systems/state.rs new file mode 100644 index 0000000..b79eca8 --- /dev/null +++ b/src/systems/state.rs @@ -0,0 +1,315 @@ +use std::mem::discriminant; + +use crate::{ + map::builder::Map, + systems::{ + AudioEvent, Blinking, DirectionalAnimation, Dying, Eaten, Frozen, Ghost, GhostCollider, GhostState, Hidden, + LinearAnimation, Looping, PlayerControlled, Position, + }, +}; +use bevy_ecs::{ + entity::Entity, + event::EventWriter, + query::{With, Without}, + resource::Resource, + system::{Commands, Query, Res, ResMut}, +}; + +#[derive(Resource, Clone)] +pub struct PlayerAnimation(pub DirectionalAnimation); + +#[derive(Resource, Clone)] +pub struct PlayerDeathAnimation(pub LinearAnimation); + +/// A resource to track the overall stage of the game from a high-level perspective. +#[derive(Resource, Debug, PartialEq, Eq, Clone, Copy)] +pub enum GameStage { + Starting(StartupSequence), + /// The main gameplay loop is active. + Playing, + /// The player has died and the death sequence is in progress. + PlayerDying(DyingSequence), + /// The level is restarting after a death. + LevelRestarting, + /// The game has ended. + GameOver, +} + +/// A resource that manages the multi-stage startup sequence of the game. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum StartupSequence { + /// Stage 1: Text-only stage + /// - Player & ghosts are hidden + /// - READY! and PLAYER ONE text are shown + /// - Energizers do not blink + TextOnly { + /// Remaining ticks in this stage + remaining_ticks: u32, + }, + /// Stage 2: Characters visible stage + /// - PLAYER ONE text is hidden, READY! text remains + /// - Ghosts and Pac-Man are now shown + CharactersVisible { + /// Remaining ticks in this stage + remaining_ticks: u32, + }, +} + +impl Default for GameStage { + fn default() -> Self { + Self::Playing + } +} + +/// The state machine for the multi-stage death sequence. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum DyingSequence { + /// Initial stage: entities are frozen, waiting for a delay. + Frozen { remaining_ticks: u32 }, + /// Second stage: Pac-Man's death animation is playing. + Animating { remaining_ticks: u32 }, + /// Third stage: Pac-Man is now gone, waiting a moment before the level restarts. + Hidden { remaining_ticks: u32 }, +} + +/// A resource to store the number of player lives. +#[derive(Resource, Debug)] +pub struct PlayerLives(pub u8); + +impl Default for PlayerLives { + fn default() -> Self { + Self(1) + } +} + +/// Handles startup sequence transitions and component management +#[allow(clippy::too_many_arguments)] +#[allow(clippy::type_complexity)] +pub fn stage_system( + mut game_state: ResMut, + player_death_animation: Res, + player_animation: Res, + mut player_lives: ResMut, + map: Res, + mut commands: Commands, + mut audio_events: EventWriter, + mut blinking_query: Query>, + mut player_query: Query<(Entity, &mut Position), With>, + mut ghost_query: Query<(Entity, &Ghost, &mut Position), (With, Without)>, +) { + let old_state = *game_state; + let new_state: GameStage = match &mut *game_state { + GameStage::Starting(startup) => match startup { + StartupSequence::TextOnly { remaining_ticks } => { + if *remaining_ticks > 0 { + GameStage::Starting(StartupSequence::TextOnly { + remaining_ticks: *remaining_ticks - 1, + }) + } else { + GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 }) + } + } + StartupSequence::CharactersVisible { remaining_ticks } => { + if *remaining_ticks > 0 { + GameStage::Starting(StartupSequence::CharactersVisible { + remaining_ticks: *remaining_ticks - 1, + }) + } else { + GameStage::Playing + } + } + }, + GameStage::Playing => GameStage::Playing, + GameStage::PlayerDying(dying) => match dying { + DyingSequence::Frozen { remaining_ticks } => { + if *remaining_ticks > 0 { + GameStage::PlayerDying(DyingSequence::Frozen { + remaining_ticks: *remaining_ticks - 1, + }) + } else { + let death_animation = &player_death_animation.0; + let remaining_ticks = (death_animation.tiles.len() * death_animation.frame_duration as usize) as u32; + GameStage::PlayerDying(DyingSequence::Animating { remaining_ticks }) + } + } + DyingSequence::Animating { remaining_ticks } => { + if *remaining_ticks > 0 { + GameStage::PlayerDying(DyingSequence::Animating { + remaining_ticks: *remaining_ticks - 1, + }) + } else { + GameStage::PlayerDying(DyingSequence::Hidden { remaining_ticks: 60 }) + } + } + DyingSequence::Hidden { remaining_ticks } => { + if *remaining_ticks > 0 { + GameStage::PlayerDying(DyingSequence::Hidden { + remaining_ticks: *remaining_ticks - 1, + }) + } else { + player_lives.0 = player_lives.0.saturating_sub(1); + + if player_lives.0 > 0 { + GameStage::LevelRestarting + } else { + GameStage::GameOver + } + } + } + }, + GameStage::LevelRestarting => GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 }), + GameStage::GameOver => GameStage::GameOver, + }; + + if old_state == new_state { + return; + } + + match (old_state, new_state) { + (GameStage::Playing, GameStage::PlayerDying(DyingSequence::Frozen { .. })) => { + // 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); + } + } + (GameStage::PlayerDying(DyingSequence::Frozen { .. }), GameStage::PlayerDying(DyingSequence::Animating { .. })) => { + // Hide the ghosts + for (entity, _, _) in ghost_query.iter_mut() { + commands.entity(entity).insert(Hidden); + } + + // Start Pac-Man's death animation + if let Ok((player_entity, _)) = player_query.single_mut() { + commands + .entity(player_entity) + .insert((Dying, player_death_animation.0.clone())); + } + + // Play the death sound + audio_events.write(AudioEvent::PlayDeath); + } + (GameStage::PlayerDying(DyingSequence::Animating { .. }), GameStage::PlayerDying(DyingSequence::Hidden { .. })) => { + // Hide the player + if let Ok((player_entity, _)) = player_query.single_mut() { + commands.entity(player_entity).insert(Hidden); + } + } + (_, GameStage::LevelRestarting) => { + if let Ok((player_entity, mut pos)) = player_query.single_mut() { + *pos = Position::Stopped { + node: map.start_positions.pacman, + }; + + // Freeze the blinking, force them to be visible (if they were hidden by blinking) + for entity in blinking_query.iter_mut() { + commands.entity(entity).insert(Frozen).remove::(); + } + + // Reset the player animation + commands + .entity(player_entity) + .remove::<(Frozen, Dying, Hidden, LinearAnimation, Looping)>() + .insert(player_animation.0.clone()); + } + + // Reset ghost positions and state + for (ghost_entity, ghost, mut ghost_pos) in ghost_query.iter_mut() { + *ghost_pos = Position::Stopped { + node: match ghost { + Ghost::Blinky => map.start_positions.blinky, + Ghost::Pinky => map.start_positions.pinky, + Ghost::Inky => map.start_positions.inky, + Ghost::Clyde => map.start_positions.clyde, + }, + }; + commands + .entity(ghost_entity) + .remove::<(Frozen, Hidden, Eaten)>() + .insert(GhostState::Normal); + } + } + ( + GameStage::Starting(StartupSequence::TextOnly { .. }), + GameStage::Starting(StartupSequence::CharactersVisible { .. }), + ) => { + // Unhide the player & ghosts + for entity in player_query + .iter_mut() + .map(|(e, _)| e) + .chain(ghost_query.iter_mut().map(|(e, _, _)| e)) + { + commands.entity(entity).remove::(); + } + } + (GameStage::Starting(StartupSequence::CharactersVisible { .. }), GameStage::Playing) => { + // Unfreeze the player & ghosts & blinking + for entity in player_query + .iter_mut() + .map(|(e, _)| e) + .chain(ghost_query.iter_mut().map(|(e, _, _)| e)) + .chain(blinking_query.iter_mut()) + { + commands.entity(entity).remove::(); + } + } + (GameStage::PlayerDying(..), GameStage::GameOver) => { + // Freeze blinking + for entity in blinking_query.iter_mut() { + 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; +} + +// 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::(); +// } +// } +// (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::(); +// } +// *game_state = GameState::Playing; +// } +// _ => {} +// } +// } diff --git a/src/texture/animated.rs b/src/texture/animated.rs index d56be87..50ba15f 100644 --- a/src/texture/animated.rs +++ b/src/texture/animated.rs @@ -1,53 +1,45 @@ -use crate::map::direction::Direction; -use crate::texture::sprite::AtlasTile; +use glam::U16Vec2; -/// Fixed-size tile sequence that avoids heap allocation -#[derive(Clone, Copy, Debug)] +use crate::{map::direction::Direction, texture::sprite::AtlasTile}; + +/// A sequence of tiles for animation, backed by a vector. +#[derive(Debug, Clone)] pub struct TileSequence { - tiles: [AtlasTile; 4], // Fixed array, max 4 frames - count: usize, // Actual number of frames used + tiles: Vec, } impl TileSequence { - /// Creates a new tile sequence from a slice of tiles + /// Creates a new tile sequence from a slice of tiles. pub fn new(tiles: &[AtlasTile]) -> Self { - let mut tile_array = [AtlasTile { - pos: glam::U16Vec2::ZERO, - size: glam::U16Vec2::ZERO, - color: None, - }; 4]; - - let count = tiles.len().min(4); - tile_array[..count].copy_from_slice(&tiles[..count]); - - Self { - tiles: tile_array, - count, - } + Self { tiles: tiles.to_vec() } } /// Returns the tile at the given frame index, wrapping if necessary pub fn get_tile(&self, frame: usize) -> AtlasTile { - if self.count == 0 { - // Return a default empty tile if no tiles - AtlasTile { - pos: glam::U16Vec2::ZERO, - size: glam::U16Vec2::ZERO, + if self.tiles.is_empty() { + // Return a default or handle the error appropriately + // For now, let's return a default tile, assuming it's a sensible default + return AtlasTile { + pos: U16Vec2::ZERO, + size: U16Vec2::ZERO, color: None, - } - } else { - self.tiles[frame % self.count] + }; } + self.tiles[frame % self.tiles.len()] } - /// Returns true if this sequence has no tiles + pub fn len(&self) -> usize { + self.tiles.len() + } + + /// Checks if the sequence contains any tiles. pub fn is_empty(&self) -> bool { - self.count == 0 + self.tiles.is_empty() } } -/// Type-safe directional tile storage with named fields -#[derive(Clone, Copy, Debug)] +/// A collection of tile sequences for each cardinal direction. +#[derive(Debug, Clone)] pub struct DirectionalTiles { pub up: TileSequence, pub down: TileSequence,