diff --git a/src/constants.rs b/src/constants.rs index 9e51ac1..f53c825 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -30,6 +30,21 @@ pub const BOARD_CELL_OFFSET: UVec2 = UVec2::new(0, 3); /// Automatically calculated from the cell offset to maintain consistency /// when the cell size changes. Used for positioning sprites and debug overlays. pub const BOARD_PIXEL_OFFSET: UVec2 = UVec2::new(BOARD_CELL_OFFSET.x * CELL_SIZE, BOARD_CELL_OFFSET.y * CELL_SIZE); + +/// Animation timing constants for ghost state management +pub mod animation { + /// Normal ghost movement animation speed (frames per second) + pub const GHOST_NORMAL_SPEED: f32 = 0.2; + /// Eaten ghost (eyes) animation speed (frames per second) + pub const GHOST_EATEN_SPEED: f32 = 0.1; + /// Frightened ghost animation speed (frames per second) + pub const GHOST_FRIGHTENED_SPEED: f32 = 0.2; + /// Frightened ghost flashing animation speed (frames per second) + pub const GHOST_FLASHING_SPEED: f32 = 0.15; + + /// Time in ticks when frightened ghosts start flashing (2 seconds at 60 FPS) + pub const FRIGHTENED_FLASH_START_TICKS: u32 = 120; +} /// The size of the canvas, in pixels. pub const CANVAS_SIZE: UVec2 = UVec2::new( (BOARD_CELL_SIZE.x + BOARD_CELL_OFFSET.x) * CELL_SIZE, diff --git a/src/game.rs b/src/game.rs index 74a00f4..703e09b 100644 --- a/src/game.rs +++ b/src/game.rs @@ -2,23 +2,23 @@ include!(concat!(env!("OUT_DIR"), "/atlas_data.rs")); -use crate::constants::{MapTile, CANVAS_SIZE}; +use crate::constants::{animation, MapTile, CANVAS_SIZE}; use crate::error::{GameError, GameResult, TextureError}; use crate::events::GameEvent; use crate::map::builder::Map; use crate::map::direction::Direction; use crate::systems::blinking::Blinking; -use crate::systems::{self, ghost_collision_system, present_system, Hidden, MovementModifiers}; - use crate::systems::movement::{BufferedDirection, Position, Velocity}; use crate::systems::profiling::SystemId; use crate::systems::render::RenderDirty; +use crate::systems::{self, ghost_collision_system, present_system, Hidden, MovementModifiers}; use crate::systems::{ audio_system, blinking_system, collision_system, debug_render_system, directional_render_system, dirty_render_system, - ghost_movement_system, hud_render_system, item_system, profile, render_system, AudioEvent, AudioResource, AudioState, - BackbufferResource, Collider, DebugFontResource, DebugState, DebugTextureResource, DeltaTime, DirectionalAnimated, - EntityType, Frozen, Ghost, GhostBundle, GhostCollider, GlobalState, ItemBundle, ItemCollider, MapTextureResource, - PacmanCollider, PlayerBundle, PlayerControlled, Renderable, ScoreResource, StartupSequence, SystemTimings, + eaten_ghost_system, ghost_movement_system, ghost_state_animation_system, hud_render_system, item_system, profile, + render_system, AudioEvent, AudioResource, AudioState, BackbufferResource, Collider, DebugFontResource, DebugState, + DebugTextureResource, DeltaTime, DirectionalAnimated, EntityType, Frozen, Ghost, GhostAnimationSet, GhostAnimations, + GhostBundle, GhostCollider, GlobalState, ItemBundle, ItemCollider, MapTextureResource, PacmanCollider, PlayerBundle, + PlayerControlled, Renderable, ScoreResource, StartupSequence, SystemTimings, }; use crate::texture::animated::AnimatedTexture; use crate::texture::sprite::AtlasTile; @@ -33,6 +33,7 @@ use sdl2::render::{BlendMode, Canvas, ScaleMode, TextureCreator}; use sdl2::rwops::RWops; use sdl2::video::{Window, WindowContext}; use sdl2::EventPump; +use smallvec::smallvec; use crate::{ asset::{get_asset_bytes, Asset}, @@ -158,7 +159,7 @@ impl Game { Direction::Left => "pacman/left", Direction::Right => "pacman/right", }; - let moving_tiles = vec![ + let moving_tiles = smallvec![ SpriteAtlas::get_tile(&atlas, &format!("{moving_prefix}_a.png")) .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_a.png"))))?, SpriteAtlas::get_tile(&atlas, &format!("{moving_prefix}_b.png")) @@ -167,7 +168,7 @@ impl Game { .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?, ]; - let stopped_tiles = vec![SpriteAtlas::get_tile(&atlas, &format!("{moving_prefix}_b.png")) + let stopped_tiles = smallvec![SpriteAtlas::get_tile(&atlas, &format!("{moving_prefix}_b.png")) .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_b.png"))))?]; textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.08)?); @@ -208,15 +209,7 @@ impl Game { EventRegistry::register_event::(&mut world); EventRegistry::register_event::(&mut world); - world.insert_non_send_resource(atlas); - world.insert_non_send_resource(event_pump); - world.insert_non_send_resource(canvas); - world.insert_non_send_resource(BackbufferResource(backbuffer)); - world.insert_non_send_resource(MapTextureResource(map_texture)); - world.insert_non_send_resource(DebugTextureResource(debug_texture)); - world.insert_non_send_resource(DebugFontResource(debug_font)); - world.insert_non_send_resource(AudioResource(audio)); - + world.insert_resource(Self::create_ghost_animations(&atlas)?); world.insert_resource(map); world.insert_resource(GlobalState { exit: false }); world.insert_resource(ScoreResource(0)); @@ -229,6 +222,15 @@ impl Game { world.insert_resource(CursorPosition::default()); world.insert_resource(StartupSequence::new(60 * 3, 60)); + world.insert_non_send_resource(atlas); + world.insert_non_send_resource(event_pump); + world.insert_non_send_resource(canvas); + world.insert_non_send_resource(BackbufferResource(backbuffer)); + world.insert_non_send_resource(MapTextureResource(map_texture)); + world.insert_non_send_resource(DebugTextureResource(debug_texture)); + world.insert_non_send_resource(DebugFontResource(debug_font)); + world.insert_non_send_resource(AudioResource(audio)); + world.add_observer( |event: Trigger, mut state: ResMut, _score: ResMut| { if matches!(*event, GameEvent::Command(GameCommand::Exit)) { @@ -255,6 +257,7 @@ impl Game { let hud_render_system = profile(SystemId::HudRender, hud_render_system); let debug_render_system = profile(SystemId::DebugRender, debug_render_system); let present_system = profile(SystemId::Present, present_system); + let ghost_state_animation_system = profile(SystemId::GhostStateAnimation, ghost_state_animation_system); let forced_dirty_system = |mut dirty: ResMut| { dirty.0 = true; @@ -271,7 +274,9 @@ impl Game { .chain(), player_tunnel_slowdown_system, ghost_movement_system, + profile(SystemId::EatenGhost, eaten_ghost_system), vulnerable_tick_system, + ghost_state_animation_system, (collision_system, ghost_collision_system, item_system).chain(), audio_system, blinking_system, @@ -353,58 +358,9 @@ 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::().0.get(&ghost_type).unwrap().clone(); let atlas = world.non_send_resource::(); - // Create directional animated textures for the ghost - let mut textures = [None, None, None, None]; - let mut stopped_textures = [None, None, None, None]; - - for direction in Direction::DIRECTIONS { - let moving_prefix = match direction { - Direction::Up => "up", - Direction::Down => "down", - Direction::Left => "left", - Direction::Right => "right", - }; - - let moving_tiles = vec![ - SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a")) - .ok_or_else(|| { - GameError::Texture(TextureError::AtlasTileNotFound(format!( - "ghost/{}/{}_{}.png", - ghost_type.as_str(), - moving_prefix, - "a" - ))) - })?, - SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "b")) - .ok_or_else(|| { - GameError::Texture(TextureError::AtlasTileNotFound(format!( - "ghost/{}/{}_{}.png", - ghost_type.as_str(), - moving_prefix, - "b" - ))) - })?, - ]; - - let stopped_tiles = vec![SpriteAtlas::get_tile( - atlas, - &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a"), - ) - .ok_or_else(|| { - GameError::Texture(TextureError::AtlasTileNotFound(format!( - "ghost/{}/{}_{}.png", - ghost_type.as_str(), - moving_prefix, - "a" - ))) - })?]; - - textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.2)?); - stopped_textures[direction.as_usize()] = Some(AnimatedTexture::new(stopped_tiles, 0.1)?); - } - GhostBundle { ghost: ghost_type, position: Position::Stopped { node: start_node }, @@ -423,10 +379,7 @@ impl Game { )?, layer: 0, }, - directional_animated: DirectionalAnimated { - textures, - stopped_textures, - }, + directional_animated: animations.normal().unwrap().clone(), entity_type: EntityType::Ghost, collider: Collider { size: crate::constants::CELL_SIZE as f32 * 1.375, @@ -441,6 +394,106 @@ impl Game { Ok(()) } + fn create_ghost_animations(atlas: &SpriteAtlas) -> GameResult { + let mut animations = std::collections::HashMap::new(); + + for ghost_type in [Ghost::Blinky, Ghost::Pinky, Ghost::Inky, Ghost::Clyde] { + // Normal animations + let mut normal_textures = [None, None, None, None]; + for direction in Direction::DIRECTIONS { + let dir_str = direction.as_ref(); + let tile_a = atlas + .get_tile(&format!("ghost/{}/{}_a.png", ghost_type.as_str(), dir_str)) + .ok_or_else(|| { + GameError::Texture(TextureError::AtlasTileNotFound(format!( + "ghost/{}/{}_a.png", + ghost_type.as_str(), + dir_str + ))) + })?; + let tile_b = atlas + .get_tile(&format!("ghost/{}/{}_b.png", ghost_type.as_str(), dir_str)) + .ok_or_else(|| { + GameError::Texture(TextureError::AtlasTileNotFound(format!( + "ghost/{}/{}_b.png", + ghost_type.as_str(), + dir_str + ))) + })?; + let tiles = smallvec![tile_a, tile_b]; + normal_textures[direction.as_usize()] = Some(AnimatedTexture::new(tiles, animation::GHOST_NORMAL_SPEED)?); + } + let normal = DirectionalAnimated { + textures: normal_textures.clone(), + stopped_textures: normal_textures, + }; + + // Eaten (eyes) animations + let mut eaten_textures = [None, None, None, None]; + for direction in Direction::DIRECTIONS { + let dir_str = direction.as_ref(); + let tile = atlas + .get_tile(&format!("ghost/eyes/{}.png", dir_str)) + .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("ghost/eyes/{}.png", dir_str))))?; + eaten_textures[direction.as_usize()] = Some(AnimatedTexture::new(smallvec![tile], animation::GHOST_EATEN_SPEED)?); + } + let eaten = DirectionalAnimated { + textures: eaten_textures.clone(), + stopped_textures: eaten_textures, + }; + + animations.insert( + ghost_type, + GhostAnimationSet::new( + normal, + DirectionalAnimated::default(), // Placeholder for frightened + DirectionalAnimated::default(), // Placeholder for frightened_flashing + eaten, + ), + ); + } + + // Frightened animations (same for all ghosts) + let frightened_blue_a = atlas + .get_tile("ghost/frightened/blue_a.png") + .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("ghost/frightened/blue_a.png".to_string())))?; + let frightened_blue_b = atlas + .get_tile("ghost/frightened/blue_b.png") + .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("ghost/frightened/blue_b.png".to_string())))?; + let frightened_white_a = atlas + .get_tile("ghost/frightened/white_a.png") + .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("ghost/frightened/white_a.png".to_string())))?; + let frightened_white_b = atlas + .get_tile("ghost/frightened/white_b.png") + .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("ghost/frightened/white_b.png".to_string())))?; + + let frightened_anim = AnimatedTexture::new( + smallvec![frightened_blue_a, frightened_blue_b], + animation::GHOST_FRIGHTENED_SPEED, + )?; + let flashing_anim = AnimatedTexture::new( + smallvec![frightened_blue_a, frightened_white_a, frightened_blue_b, frightened_white_b], + animation::GHOST_FLASHING_SPEED, + )?; + + let frightened_da = DirectionalAnimated::from_animation(frightened_anim); + let frightened_flashing_da = DirectionalAnimated::from_animation(flashing_anim); + + for ghost_type in [Ghost::Blinky, Ghost::Pinky, Ghost::Inky, Ghost::Clyde] { + let entry = animations.get_mut(&ghost_type).unwrap(); + entry.animations.insert( + crate::systems::GhostAnimation::Frightened { flash: false }, + frightened_da.clone(), + ); + entry.animations.insert( + crate::systems::GhostAnimation::Frightened { flash: true }, + frightened_flashing_da.clone(), + ); + } + + Ok(GhostAnimations(animations)) + } + /// Executes one frame of game logic by running all scheduled ECS systems. /// /// Updates the world's delta time resource and runs the complete system pipeline: diff --git a/src/map/direction.rs b/src/map/direction.rs index f981076..137cc26 100644 --- a/src/map/direction.rs +++ b/src/map/direction.rs @@ -1,8 +1,10 @@ use glam::IVec2; +use strum_macros::AsRefStr; /// The four cardinal directions. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, AsRefStr)] #[repr(usize)] +#[strum(serialize_all = "lowercase")] pub enum Direction { Up, Down, diff --git a/src/systems/collision.rs b/src/systems/collision.rs index ee776a2..6e7b8d5 100644 --- a/src/systems/collision.rs +++ b/src/systems/collision.rs @@ -8,7 +8,7 @@ use crate::error::GameError; use crate::events::GameEvent; use crate::map::builder::Map; use crate::systems::movement::Position; -use crate::systems::{AudioEvent, Ghost, PlayerControlled, ScoreResource, Vulnerable}; +use crate::systems::{AudioEvent, Eaten, Ghost, PlayerControlled, ScoreResource, Vulnerable}; #[derive(Component)] pub struct Collider { @@ -136,7 +136,7 @@ pub fn ghost_collision_system( score.0 += 200; // Remove the ghost - commands.entity(ghost_ent).despawn(); + commands.entity(ghost_ent).remove::().insert(Eaten); // Play eat sound events.write(AudioEvent::PlayEat); diff --git a/src/systems/components.rs b/src/systems/components.rs index 2d8a6c4..c2ba494 100644 --- a/src/systems/components.rs +++ b/src/systems/components.rs @@ -9,12 +9,13 @@ use crate::{ }, texture::{animated::AnimatedTexture, sprite::AtlasTile}, }; +use micromap::Map; /// A tag component for entities that are controlled by the player. #[derive(Default, Component)] pub struct PlayerControlled; -#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Ghost { Blinky, Pinky, @@ -96,12 +97,33 @@ pub struct Renderable { } /// A component for entities that have a directional animated texture. -#[derive(Component)] +#[derive(Component, Clone, Default)] pub struct DirectionalAnimated { pub textures: [Option; 4], pub stopped_textures: [Option; 4], } +impl DirectionalAnimated { + pub fn from_animation(animation: AnimatedTexture) -> Self { + // Create 4 copies of the animation - necessary for independent state per direction + // This is initialization-time only, so the cloning cost is acceptable + Self { + textures: [ + Some(animation.clone()), + Some(animation.clone()), + Some(animation.clone()), + Some(animation.clone()), + ], + stopped_textures: [ + Some(animation.clone()), + Some(animation.clone()), + Some(animation.clone()), + Some(animation), + ], + } + } +} + bitflags! { #[derive(Component, Default, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct CollisionLayer: u8 { @@ -144,12 +166,92 @@ impl Default for MovementModifiers { #[derive(Component, Debug, Clone, Copy)] pub struct Frozen; +/// Tag component for eaten ghosts +#[derive(Component, Debug, Clone, Copy)] +pub struct Eaten; + /// Component for ghosts that are vulnerable to Pac-Man #[derive(Component, Debug, Clone, Copy)] pub struct Vulnerable { pub remaining_ticks: u32, } +/// Enumeration of different ghost animation states. +/// Note that this is used in micromap which has a fixed size based on the number of variants, +/// so extending this should be done with caution, and will require updating the micromap's capacity. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum GhostAnimation { + /// Normal ghost appearance with directional movement animations + Normal, + /// Blue ghost appearance when vulnerable (power pellet active) + Frightened { flash: bool }, + /// Eyes-only animation when ghost has been consumed by Pac-Man (Eaten state) + Eyes, +} + +/// A complete set of animations for a ghost in different behavioral states. +/// +/// Each ghost maintains animations mapped by their current gameplay state. +/// The animation system automatically switches between these states based on +/// the presence of `Vulnerable` and `Eaten` components on the ghost entity. +#[derive(Component, Clone)] +pub struct GhostAnimationSet { + pub animations: Map, +} + +impl GhostAnimationSet { + /// Creates a new GhostAnimationSet with the provided animations. + pub fn new( + normal: DirectionalAnimated, + frightened: DirectionalAnimated, + frightened_flashing: DirectionalAnimated, + eyes: DirectionalAnimated, + ) -> Self { + let mut animations = Map::new(); + animations.insert(GhostAnimation::Normal, normal); + animations.insert(GhostAnimation::Frightened { flash: false }, frightened); + animations.insert(GhostAnimation::Frightened { flash: true }, frightened_flashing); + animations.insert(GhostAnimation::Eyes, eyes); + Self { animations } + } + + /// Gets the animation for the specified ghost animation state. + pub fn get(&self, animation: GhostAnimation) -> Option<&DirectionalAnimated> { + self.animations.get(&animation) + } + + /// Gets the normal animation state. + pub fn normal(&self) -> Option<&DirectionalAnimated> { + self.get(GhostAnimation::Normal) + } + + /// Gets the frightened animation state (non-flashing). + pub fn frightened(&self) -> Option<&DirectionalAnimated> { + self.get(GhostAnimation::Frightened { flash: false }) + } + + /// Gets the frightened flashing animation state. + pub fn frightened_flashing(&self) -> Option<&DirectionalAnimated> { + self.get(GhostAnimation::Frightened { flash: true }) + } + + /// Gets the eyes animation state (for eaten ghosts). + pub fn eyes(&self) -> Option<&DirectionalAnimated> { + self.get(GhostAnimation::Eyes) + } +} + +/// Global resource containing pre-loaded animation sets for all ghost types. +/// +/// This resource is initialized once during game startup and provides O(1) access +/// to animation sets for each ghost type. The animation system uses this resource +/// to efficiently switch between different ghost states without runtime asset loading. +/// +/// The HashMap is keyed by `Ghost` enum variants (Blinky, Pinky, Inky, Clyde) and +/// contains complete animation sets mapped by GhostAnimation states. +#[derive(Resource)] +pub struct GhostAnimations(pub std::collections::HashMap); + #[derive(Bundle)] pub struct PlayerBundle { pub player: PlayerControlled, diff --git a/src/systems/ghost.rs b/src/systems/ghost.rs index eef4f8e..863352d 100644 --- a/src/systems/ghost.rs +++ b/src/systems/ghost.rs @@ -10,8 +10,16 @@ use crate::{ movement::{Position, Velocity}, }, }; -use bevy_ecs::query::Without; -use bevy_ecs::system::{Query, Res}; + +use bevy_ecs::{ + query::Added, + removal_detection::RemovedComponents, + system::{Commands, Query, Res}, +}; + +use crate::systems::{Eaten, GhostAnimations, Vulnerable}; + +use bevy_ecs::query::{With, Without}; use rand::rngs::SmallRng; use rand::seq::IndexedRandom; use rand::SeedableRng; @@ -68,3 +76,160 @@ pub fn ghost_movement_system( } } } + +/// System that manages ghost animation state transitions based on ghost behavior. +/// +/// This system handles the following animation state changes: +/// - When a ghost becomes vulnerable (power pellet eaten): switches to frightened animation +/// - When a ghost is eaten by Pac-Man: switches to eaten (eyes) animation +/// - When vulnerability ends: switches back to normal animation +/// +/// The system uses ECS change detection to efficiently track state transitions: +/// - `Added` detects when ghosts become frightened +/// - `Added` detects when ghosts are consumed +/// - `RemovedComponents` detects when fright period ends +/// +/// This ensures smooth visual feedback for gameplay state changes while maintaining +/// separation between game logic and animation state. +pub fn ghost_state_animation_system( + mut commands: Commands, + animations: Res, + mut vulnerable_added: Query<(bevy_ecs::entity::Entity, &Ghost), Added>, + mut eaten_added: Query<(bevy_ecs::entity::Entity, &Ghost), Added>, + mut vulnerable_removed: RemovedComponents, + ghosts: Query<&Ghost>, +) { + // When a ghost becomes vulnerable, switch to the frightened animation + for (entity, ghost_type) in vulnerable_added.iter_mut() { + if let Some(animation_set) = animations.0.get(ghost_type) { + if let Some(animation) = animation_set.frightened() { + commands.entity(entity).insert(animation.clone()); + } + } + } + + // When a ghost is eaten, switch to the eaten animation + for (entity, ghost_type) in eaten_added.iter_mut() { + if let Some(animation_set) = animations.0.get(ghost_type) { + if let Some(animation) = animation_set.eyes() { + commands.entity(entity).insert(animation.clone()); + } + } + } + + // When a ghost is no longer vulnerable, switch back to the normal animation + for entity in vulnerable_removed.read() { + if let Ok(ghost_type) = ghosts.get(entity) { + if let Some(animation_set) = animations.0.get(ghost_type) { + if let Some(animation) = animation_set.normal() { + commands.entity(entity).insert(animation.clone()); + } + } + } + } +} + +/// System that handles eaten ghost behavior and respawn logic. +/// +/// When a ghost is eaten by Pac-Man, it enters an "eaten" state where: +/// 1. It displays eyes-only animation +/// 2. It moves directly back to the ghost house at increased speed +/// 3. Once it reaches the ghost house center, it respawns as a normal ghost +/// +/// This system runs after the main movement system to override eaten ghost movement. +pub fn eaten_ghost_system( + map: Res, + delta_time: Res, + animations: Res, + mut commands: Commands, + mut eaten_ghosts: Query<(bevy_ecs::entity::Entity, &Ghost, &mut Position, &mut Velocity), With>, +) { + for (entity, ghost_type, mut position, mut velocity) in eaten_ghosts.iter_mut() { + // Set higher speed for eaten ghosts returning to ghost house + let original_speed = velocity.speed; + velocity.speed = ghost_type.base_speed() * 2.0; // Move twice as fast when eaten + + // Calculate direction towards ghost house center (using Clyde's start position) + let ghost_house_center = map.start_positions.clyde; + + match *position { + Position::Stopped { node: current_node } => { + // Find path to ghost house center and start moving + if let Some(direction) = find_direction_to_target(&map, current_node, ghost_house_center) { + velocity.direction = direction; + *position = Position::Moving { + from: current_node, + to: map.graph.adjacency_list[current_node].get(direction).unwrap().target, + remaining_distance: map.graph.adjacency_list[current_node].get(direction).unwrap().distance, + }; + } + } + Position::Moving { to, .. } => { + let distance = velocity.speed * 60.0 * delta_time.0; + if let Some(_overflow) = position.tick(distance) { + // Reached target node, check if we're at ghost house center + if to == ghost_house_center { + // Respawn the ghost - remove Eaten component and switch to normal animation + commands.entity(entity).remove::(); + if let Some(animation_set) = animations.0.get(ghost_type) { + if let Some(animation) = animation_set.normal() { + commands.entity(entity).insert(animation.clone()); + } + } + // Reset to stopped at ghost house center + *position = Position::Stopped { + node: ghost_house_center, + }; + } else { + // Continue pathfinding to ghost house + if let Some(next_direction) = find_direction_to_target(&map, to, ghost_house_center) { + velocity.direction = next_direction; + *position = Position::Moving { + from: to, + to: map.graph.adjacency_list[to].get(next_direction).unwrap().target, + remaining_distance: map.graph.adjacency_list[to].get(next_direction).unwrap().distance, + }; + } + } + } + } + } + + // Restore original speed + velocity.speed = original_speed; + } +} + +/// Helper function to find the direction from a node towards a target node. +/// Uses simple greedy pathfinding - prefers straight lines when possible. +fn find_direction_to_target(map: &Map, from_node: usize, target_node: usize) -> Option { + let from_pos = map.graph.get_node(from_node).unwrap().position; + let target_pos = map.graph.get_node(target_node).unwrap().position; + + let dx = target_pos.x as i32 - from_pos.x as i32; + let dy = target_pos.y as i32 - from_pos.y as i32; + + // Prefer horizontal movement first, then vertical + let preferred_dirs = if dx.abs() > dy.abs() { + if dx > 0 { + [Direction::Right, Direction::Up, Direction::Down, Direction::Left] + } else { + [Direction::Left, Direction::Up, Direction::Down, Direction::Right] + } + } else if dy > 0 { + [Direction::Down, Direction::Left, Direction::Right, Direction::Up] + } else { + [Direction::Up, Direction::Left, Direction::Right, Direction::Down] + }; + + // Return first available direction towards target + for direction in preferred_dirs { + if let Some(edge) = map.graph.adjacency_list[from_node].get(direction) { + if edge.traversal_flags.contains(TraversalFlags::GHOST) { + return Some(direction); + } + } + } + + None +} diff --git a/src/systems/profiling.rs b/src/systems/profiling.rs index e2526f6..7e62497 100644 --- a/src/systems/profiling.rs +++ b/src/systems/profiling.rs @@ -35,6 +35,8 @@ pub enum SystemId { PlayerMovement, GhostCollision, Stage, + GhostStateAnimation, + EatenGhost, } impl Display for SystemId { @@ -144,15 +146,13 @@ impl SystemTimings { }; // Collect timing data for formatting - let mut timing_data = Vec::new(); + let mut timing_data = vec![(effective_fps, total_avg, total_std)]; - // Add total stats - timing_data.push((effective_fps, total_avg, total_std)); - - // Add top 5 most expensive systems + // Sort the stats by average duration let mut sorted_stats: Vec<_> = stats.iter().collect(); sorted_stats.sort_by(|a, b| b.1 .0.cmp(&a.1 .0)); + // Add the top 5 most expensive systems for (name, (avg, std_dev)) in sorted_stats.iter().take(5) { timing_data.push((name.to_string(), *avg, *std_dev)); } diff --git a/src/systems/vulnerable.rs b/src/systems/vulnerable.rs index 41d4c59..4a5da4c 100644 --- a/src/systems/vulnerable.rs +++ b/src/systems/vulnerable.rs @@ -1,18 +1,31 @@ -use bevy_ecs::query::With; -use bevy_ecs::system::{Commands, Query}; +use bevy_ecs::{ + query::With, + system::{Commands, Query, Res}, +}; -use crate::systems::{GhostCollider, Vulnerable}; +use crate::constants::animation::FRIGHTENED_FLASH_START_TICKS; +use crate::systems::{Ghost, GhostAnimations, GhostCollider, Vulnerable}; /// System that decrements the remaining_ticks on Vulnerable components and removes them when they reach zero pub fn vulnerable_tick_system( mut commands: Commands, - mut vulnerable_query: Query<(bevy_ecs::entity::Entity, &mut Vulnerable), With>, + animations: Res, + mut vulnerable_query: Query<(bevy_ecs::entity::Entity, &mut Vulnerable, &Ghost), With>, ) { - for (entity, mut vulnerable) in vulnerable_query.iter_mut() { + for (entity, mut vulnerable, ghost_type) in vulnerable_query.iter_mut() { if vulnerable.remaining_ticks > 0 { vulnerable.remaining_ticks -= 1; } + // When 2 seconds are remaining, start flashing + if vulnerable.remaining_ticks == FRIGHTENED_FLASH_START_TICKS { + if let Some(animation_set) = animations.0.get(ghost_type) { + if let Some(animation) = animation_set.frightened_flashing() { + commands.entity(entity).insert(animation.clone()); + } + } + } + if vulnerable.remaining_ticks == 0 { commands.entity(entity).remove::(); } diff --git a/src/texture/animated.rs b/src/texture/animated.rs index c5cc52a..de55997 100644 --- a/src/texture/animated.rs +++ b/src/texture/animated.rs @@ -1,3 +1,5 @@ +use smallvec::SmallVec; + use crate::error::{AnimatedTextureError, GameError, GameResult, TextureError}; use crate::texture::sprite::AtlasTile; @@ -8,7 +10,7 @@ use crate::texture::sprite::AtlasTile; #[derive(Debug, Clone)] pub struct AnimatedTexture { /// Sequence of sprite tiles that make up the animation frames - tiles: Vec, + tiles: SmallVec<[AtlasTile; 4]>, /// Duration each frame should be displayed (in seconds) frame_duration: f32, /// Index of the currently active frame in the tiles vector @@ -18,7 +20,7 @@ pub struct AnimatedTexture { } impl AnimatedTexture { - pub fn new(tiles: Vec, frame_duration: f32) -> GameResult { + pub fn new(tiles: SmallVec<[AtlasTile; 4]>, frame_duration: f32) -> GameResult { if frame_duration <= 0.0 { return Err(GameError::Texture(TextureError::Animated( AnimatedTextureError::InvalidFrameDuration(frame_duration), diff --git a/tests/animated.rs b/tests/animated.rs index 9657a63..1645bf3 100644 --- a/tests/animated.rs +++ b/tests/animated.rs @@ -3,6 +3,7 @@ use pacman::error::{AnimatedTextureError, GameError, TextureError}; use pacman::texture::animated::AnimatedTexture; use pacman::texture::sprite::AtlasTile; use sdl2::pixels::Color; +use smallvec::smallvec; fn mock_atlas_tile(id: u32) -> AtlasTile { AtlasTile { @@ -14,7 +15,7 @@ fn mock_atlas_tile(id: u32) -> AtlasTile { #[test] fn test_animated_texture_creation_errors() { - let tiles = vec![mock_atlas_tile(1), mock_atlas_tile(2)]; + let tiles = smallvec![mock_atlas_tile(1), mock_atlas_tile(2)]; assert!(matches!( AnimatedTexture::new(tiles.clone(), 0.0).unwrap_err(), @@ -29,7 +30,7 @@ fn test_animated_texture_creation_errors() { #[test] fn test_animated_texture_advancement() { - let tiles = vec![mock_atlas_tile(1), mock_atlas_tile(2), mock_atlas_tile(3)]; + let tiles = smallvec![mock_atlas_tile(1), mock_atlas_tile(2), mock_atlas_tile(3)]; let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap(); assert_eq!(texture.current_frame(), 0); @@ -41,7 +42,7 @@ fn test_animated_texture_advancement() { #[test] fn test_animated_texture_wrap_around() { - let tiles = vec![mock_atlas_tile(1), mock_atlas_tile(2)]; + let tiles = smallvec![mock_atlas_tile(1), mock_atlas_tile(2)]; let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap(); texture.tick(0.1); @@ -53,7 +54,7 @@ fn test_animated_texture_wrap_around() { #[test] fn test_animated_texture_single_frame() { - let tiles = vec![mock_atlas_tile(1)]; + let tiles = smallvec![mock_atlas_tile(1)]; let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap(); texture.tick(0.1);