From b53db3788d85da2d21815e4c6313c8a538d7be72 Mon Sep 17 00:00:00 2001 From: Ryan Walters Date: Mon, 1 Sep 2025 14:27:48 -0500 Subject: [PATCH] refactor: unify ghost state management and animation handling, use integers for texture animation --- src/constants.rs | 16 +++--- src/error.rs | 2 +- src/game.rs | 33 ++++++----- src/systems/collision.rs | 37 +++++++------ src/systems/components.rs | 109 ++++++++++++++++++++++++++++++------ src/systems/ghost.rs | 113 ++++++++++++-------------------------- src/systems/item.rs | 16 +++--- src/systems/mod.rs | 2 - src/systems/render.rs | 3 +- src/systems/vulnerable.rs | 33 ----------- src/texture/animated.rs | 43 +++++++++------ tests/animated.rs | 25 ++++----- tests/error.rs | 6 +- 13 files changed, 221 insertions(+), 217 deletions(-) delete mode 100644 src/systems/vulnerable.rs diff --git a/src/constants.rs b/src/constants.rs index f53c825..ba73685 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -33,14 +33,14 @@ pub const BOARD_PIXEL_OFFSET: UVec2 = UVec2::new(BOARD_CELL_OFFSET.x * 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; + /// Normal ghost movement animation speed (ticks per frame at 60 ticks/sec) + pub const GHOST_NORMAL_SPEED: u16 = 12; + /// Eaten ghost (eyes) animation speed (ticks per frame at 60 ticks/sec) + pub const GHOST_EATEN_SPEED: u16 = 6; + /// Frightened ghost animation speed (ticks per frame at 60 ticks/sec) + pub const GHOST_FRIGHTENED_SPEED: u16 = 12; + /// Frightened ghost flashing animation speed (ticks per frame at 60 ticks/sec) + pub const GHOST_FLASHING_SPEED: u16 = 9; /// Time in ticks when frightened ghosts start flashing (2 seconds at 60 FPS) pub const FRIGHTENED_FLASH_START_TICKS: u32 = 120; diff --git a/src/error.rs b/src/error.rs index c559160..ef3d919 100644 --- a/src/error.rs +++ b/src/error.rs @@ -97,7 +97,7 @@ pub enum TextureError { #[derive(thiserror::Error, Debug)] pub enum AnimatedTextureError { #[error("Frame duration must be positive, got {0}")] - InvalidFrameDuration(f32), + InvalidFrameDuration(u16), } /// Errors related to entity operations. diff --git a/src/game.rs b/src/game.rs index 7c57996..18cd9ef 100644 --- a/src/game.rs +++ b/src/game.rs @@ -14,11 +14,11 @@ use crate::systems::render::RenderDirty; use crate::systems::{self, ghost_collision_system, present_system, Hidden, MovementModifiers, NodeId}; use crate::systems::{ audio_system, blinking_system, collision_system, debug_render_system, directional_render_system, dirty_render_system, - 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, + eaten_ghost_system, ghost_movement_system, ghost_state_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; @@ -171,8 +171,8 @@ impl Game { 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)?); - stopped_textures[direction.as_usize()] = Some(AnimatedTexture::new(stopped_tiles, 0.1)?); + textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 5)?); + stopped_textures[direction.as_usize()] = Some(AnimatedTexture::new(stopped_tiles, 6)?); } let player = PlayerBundle { @@ -247,7 +247,7 @@ impl Game { 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 vulnerable_tick_system = profile(SystemId::Ghost, systems::vulnerable_tick_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); @@ -257,7 +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 unified_ghost_state_system = profile(SystemId::GhostStateAnimation, ghost_state_system); let forced_dirty_system = |mut dirty: ResMut| { dirty.0 = true; @@ -275,8 +275,7 @@ impl Game { player_tunnel_slowdown_system, ghost_movement_system, profile(SystemId::EatenGhost, eaten_ghost_system), - vulnerable_tick_system, - ghost_state_animation_system, + unified_ghost_state_system, (collision_system, ghost_collision_system, item_system).chain(), audio_system, blinking_system, @@ -385,6 +384,10 @@ impl Game { size: crate::constants::CELL_SIZE as f32 * 1.375, }, ghost_collider: GhostCollider, + ghost_state: crate::systems::components::GhostState::Normal, + last_animation_state: crate::systems::components::LastAnimationState( + crate::systems::components::GhostAnimation::Normal, + ), } }; @@ -476,18 +479,18 @@ impl Game { animation::GHOST_FLASHING_SPEED, )?; - let frightened_da = DirectionalAnimated::from_animation(frightened_anim); - let frightened_flashing_da = DirectionalAnimated::from_animation(flashing_anim); + let frightened_animation = DirectionalAnimated::from_animation(frightened_anim); + let frightened_flashing_animation = 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(), + frightened_animation.clone(), ); entry.animations.insert( crate::systems::GhostAnimation::Frightened { flash: true }, - frightened_flashing_da.clone(), + frightened_flashing_animation.clone(), ); } diff --git a/src/systems/collision.rs b/src/systems/collision.rs index 6e7b8d5..c3b680d 100644 --- a/src/systems/collision.rs +++ b/src/systems/collision.rs @@ -2,13 +2,13 @@ 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::{Commands, Query, Res, ResMut}; +use bevy_ecs::system::{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, Eaten, Ghost, PlayerControlled, ScoreResource, Vulnerable}; +use crate::systems::{AudioEvent, Ghost, GhostState, PlayerControlled, ScoreResource}; #[derive(Component)] pub struct Collider { @@ -108,12 +108,11 @@ pub fn collision_system( } pub fn ghost_collision_system( - mut commands: Commands, mut collision_events: EventReader, mut score: ResMut, pacman_query: Query<(), With>, ghost_query: Query<(Entity, &Ghost), With>, - vulnerable_query: Query>, + mut ghost_state_query: Query<&mut GhostState>, mut events: EventWriter, ) { for event in collision_events.read() { @@ -127,23 +126,25 @@ pub fn ghost_collision_system( continue; }; - // Check if the ghost is vulnerable + // Check if the ghost is frightened if let Ok((ghost_ent, _ghost_type)) = ghost_query.get(ghost_entity) { - // Check if ghost has Vulnerable component - if vulnerable_query.get(ghost_ent).is_ok() { - // Pac-Man eats the ghost - // Add score (200 points per ghost eaten) - score.0 += 200; + if let Ok(mut 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; - // Remove the ghost - commands.entity(ghost_ent).remove::().insert(Eaten); + // Set ghost state to Eyes + *ghost_state = GhostState::Eyes; - // Play eat sound - events.write(AudioEvent::PlayEat); - } else { - // Pac-Man dies (this would need a death system) - // For now, just log it - tracing::warn!("Pac-Man collided with ghost while not vulnerable!"); + // Play eat sound + events.write(AudioEvent::PlayEat); + } else { + // Pac-Man dies (this would need a death system) + // For now, just log it + tracing::warn!("Pac-Man collided with ghost while not frightened!"); + } } } } diff --git a/src/systems/components.rs b/src/systems/components.rs index c2ba494..4e4ec39 100644 --- a/src/systems/components.rs +++ b/src/systems/components.rs @@ -1,5 +1,6 @@ use bevy_ecs::{bundle::Bundle, component::Component, resource::Resource}; use bitflags::bitflags; +use tracing::debug; use crate::{ map::graph::TraversalFlags, @@ -122,6 +123,20 @@ impl DirectionalAnimated { ], } } + + /// Resets all directional animations to frame 0 for synchronization + pub fn reset_all_animations(&mut self) { + for texture in &mut self.textures { + if let Some(anim) = texture { + anim.reset(); + } + } + for texture in &mut self.stopped_textures { + if let Some(anim) = texture { + anim.reset(); + } + } + } } bitflags! { @@ -170,10 +185,82 @@ pub struct Frozen; #[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, +pub enum GhostState { + /// Normal ghost behavior - chasing Pac-Man + Normal, + /// Frightened state after power pellet - ghost can be eaten + Frightened { + remaining_ticks: u32, + flash: bool, + remaining_flash_ticks: u32, + }, + /// Eyes state - ghost has been eaten and is returning to ghost house + Eyes, +} + +/// Component to track the last animation state for efficient change detection +#[derive(Component, Debug, Clone, Copy, PartialEq)] +pub struct LastAnimationState(pub GhostAnimation); + +impl GhostState { + /// Creates a new frightened state with the specified duration + pub fn new_frightened(total_ticks: u32, flash_start_ticks: u32) -> Self { + Self::Frightened { + remaining_ticks: total_ticks, + flash: false, + remaining_flash_ticks: flash_start_ticks, // Time until flashing starts + } + } + + /// Ticks the ghost state, returning true if the state changed. + pub fn tick(&mut self) -> bool { + match self { + GhostState::Frightened { .. } => { + debug!("{:?}", self); + } + _ => {} + } + + if let GhostState::Frightened { + remaining_ticks, + flash, + remaining_flash_ticks, + } = self + { + // Transition out of frightened state + if *remaining_ticks == 0 { + *self = GhostState::Normal; + return true; + } + + *remaining_ticks -= 1; + + if *remaining_flash_ticks > 0 { + *remaining_flash_ticks = remaining_flash_ticks.saturating_sub(1); + if *remaining_flash_ticks == 0 { + *flash = true; + true + } else { + false + } + } else { + false + } + } else { + false + } + } + + /// Returns the appropriate animation state for this ghost state + pub fn animation_state(&self) -> GhostAnimation { + match self { + GhostState::Normal => GhostAnimation::Normal, + GhostState::Eyes => GhostAnimation::Eyes, + GhostState::Frightened { flash: false, .. } => GhostAnimation::Frightened { flash: false }, + GhostState::Frightened { flash: true, .. } => GhostAnimation::Frightened { flash: true }, + } + } } /// Enumeration of different ghost animation states. @@ -190,10 +277,6 @@ pub enum GhostAnimation { } /// 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, @@ -225,16 +308,6 @@ impl GhostAnimationSet { 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) @@ -285,4 +358,6 @@ pub struct GhostBundle { pub entity_type: EntityType, pub collider: Collider, pub ghost_collider: GhostCollider, + pub ghost_state: GhostState, + pub last_animation_state: LastAnimationState, } diff --git a/src/systems/ghost.rs b/src/systems/ghost.rs index d28d7d6..018d3ac 100644 --- a/src/systems/ghost.rs +++ b/src/systems/ghost.rs @@ -1,4 +1,4 @@ -use crate::systems::components::Frozen; +use crate::systems::components::{DirectionalAnimated, Frozen, GhostState, LastAnimationState}; use crate::{ map::{ builder::Map, @@ -11,15 +11,9 @@ use crate::{ }, }; -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 crate::systems::GhostAnimations; +use bevy_ecs::query::Without; +use bevy_ecs::system::{Query, Res}; use rand::rngs::SmallRng; use rand::seq::IndexedRandom; use rand::SeedableRng; @@ -77,64 +71,6 @@ 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>, - eaten_ghosts: Query<&Ghost, With>, -) { - // 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 - // But don't switch if the ghost is currently eaten (should keep eyes animation) - for entity in vulnerable_removed.read() { - if let Ok(ghost_type) = ghosts.get(entity) { - // Check if this ghost is currently eaten - if so, don't switch to normal animation - let is_eaten = eaten_ghosts.get(entity).is_ok(); - if !is_eaten { - 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: @@ -146,11 +82,13 @@ pub fn ghost_state_animation_system( 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>, + mut eaten_ghosts: Query<(&Ghost, &mut Position, &mut Velocity, &mut GhostState)>, ) { - for (entity, ghost_type, mut position, mut velocity) in eaten_ghosts.iter_mut() { + for (ghost_type, mut position, mut velocity, mut ghost_state) in eaten_ghosts.iter_mut() { + // Only process ghosts that are in Eyes state + if !matches!(*ghost_state, GhostState::Eyes) { + continue; + } // 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 @@ -178,13 +116,8 @@ pub fn eaten_ghost_system( 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()); - } - } + // Respawn the ghost - set state back to normal + *ghost_state = GhostState::Normal; // Reset to stopped at ghost house center *position = Position::Stopped { node: ghost_house_center, @@ -246,3 +179,25 @@ fn find_direction_to_target( None } + +/// Unified system that manages ghost state transitions and animations +pub fn ghost_state_system( + animations: Res, + mut ghosts: Query<(&Ghost, &mut GhostState, &mut DirectionalAnimated, &mut LastAnimationState)>, +) { + for (ghost_type, mut ghost_state, mut directional_animated, mut last_animation_state) in ghosts.iter_mut() { + // Tick the ghost state to handle internal transitions (like flashing) + let _ = ghost_state.tick(); + + // Only update animation if the animation state actually changed + let current_animation_state = ghost_state.animation_state(); + if last_animation_state.0 != current_animation_state { + let animation_set = animations.0.get(ghost_type).unwrap(); + let animation = animation_set.get(current_animation_state).unwrap(); + *directional_animated = (*animation).clone(); + // Reset animation timers to synchronize all ghosts + directional_animated.reset_all_animations(); + last_animation_state.0 = current_animation_state; + } + } +} diff --git a/src/systems/item.rs b/src/systems/item.rs index 6aefd46..d00e2fc 100644 --- a/src/systems/item.rs +++ b/src/systems/item.rs @@ -6,8 +6,9 @@ use bevy_ecs::{ }; use crate::{ + constants::animation::FRIGHTENED_FLASH_START_TICKS, events::GameEvent, - systems::{AudioEvent, EntityType, GhostCollider, ItemCollider, PacmanCollider, ScoreResource, Vulnerable}, + systems::{AudioEvent, EntityType, GhostCollider, GhostState, ItemCollider, PacmanCollider, ScoreResource}, }; /// Determines if a collision between two entity types should be handled by the item system. @@ -28,6 +29,7 @@ pub fn item_system( pacman_query: Query>, item_query: Query<(Entity, &EntityType), With>, ghost_query: Query>, + mut ghost_state_query: Query<&mut GhostState>, mut events: EventWriter, ) { for event in collision_events.read() { @@ -54,16 +56,16 @@ pub fn item_system( events.write(AudioEvent::PlayEat); } - // Make ghosts vulnerable when power pellet is collected + // Make ghosts frightened when power pellet is collected if *entity_type == EntityType::PowerPellet { // Convert seconds to frames (assumes 60 FPS) - let total_ticks = 60 * 5; + let total_ticks = 60 * 5; // 5 seconds total - // Add Vulnerable component to all ghosts + // Set all ghosts to frightened state for ghost_entity in ghost_query.iter() { - commands.entity(ghost_entity).insert(Vulnerable { - remaining_ticks: total_ticks, - }); + if let Ok(mut ghost_state) = ghost_state_query.get_mut(ghost_entity) { + *ghost_state = GhostState::new_frightened(total_ticks, FRIGHTENED_FLASH_START_TICKS); + } } } } diff --git a/src/systems/mod.rs b/src/systems/mod.rs index 55bffc4..7ddff9f 100644 --- a/src/systems/mod.rs +++ b/src/systems/mod.rs @@ -16,7 +16,6 @@ pub mod player; pub mod profiling; pub mod render; pub mod stage; -pub mod vulnerable; pub use self::audio::*; pub use self::blinking::*; @@ -31,4 +30,3 @@ pub use self::player::*; pub use self::profiling::*; pub use self::render::*; pub use self::stage::*; -pub use self::vulnerable::*; diff --git a/src/systems/render.rs b/src/systems/render.rs index c323842..ce0c067 100644 --- a/src/systems/render.rs +++ b/src/systems/render.rs @@ -57,7 +57,8 @@ pub fn directional_render_system( if let Some(texture) = texture { if !stopped { - texture.tick(dt.0); + let ticks = (dt.0 * 60.0).round() as u16; // Convert from seconds to ticks at 60 ticks/sec + texture.tick(ticks); } let new_tile = *texture.current_tile(); if renderable.sprite != new_tile { diff --git a/src/systems/vulnerable.rs b/src/systems/vulnerable.rs deleted file mode 100644 index 4a5da4c..0000000 --- a/src/systems/vulnerable.rs +++ /dev/null @@ -1,33 +0,0 @@ -use bevy_ecs::{ - query::With, - system::{Commands, Query, Res}, -}; - -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, - animations: Res, - mut vulnerable_query: Query<(bevy_ecs::entity::Entity, &mut Vulnerable, &Ghost), With>, -) { - 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 de55997..b3e6afb 100644 --- a/src/texture/animated.rs +++ b/src/texture/animated.rs @@ -5,23 +5,23 @@ use crate::texture::sprite::AtlasTile; /// Frame-based animation system for cycling through multiple sprite tiles. /// -/// Manages automatic frame progression based on elapsed time. -/// Uses a time banking system to ensure consistent animation speed regardless of frame rate variations. +/// Manages automatic frame progression based on elapsed ticks. +/// Uses a tick banking system to ensure consistent animation speed regardless of frame rate variations. #[derive(Debug, Clone)] pub struct AnimatedTexture { /// Sequence of sprite tiles that make up the animation frames tiles: SmallVec<[AtlasTile; 4]>, - /// Duration each frame should be displayed (in seconds) - frame_duration: f32, + /// Duration each frame should be displayed (in ticks) + frame_duration: u16, /// Index of the currently active frame in the tiles vector current_frame: usize, - /// Accumulated time since the last frame change (for smooth timing) - time_bank: f32, + /// Accumulated ticks since the last frame change (for smooth timing) + time_bank: u16, } impl AnimatedTexture { - pub fn new(tiles: SmallVec<[AtlasTile; 4]>, frame_duration: f32) -> GameResult { - if frame_duration <= 0.0 { + pub fn new(tiles: SmallVec<[AtlasTile; 4]>, frame_duration: u16) -> GameResult { + if frame_duration == 0 { return Err(GameError::Texture(TextureError::Animated( AnimatedTextureError::InvalidFrameDuration(frame_duration), ))); @@ -31,22 +31,22 @@ impl AnimatedTexture { tiles, frame_duration, current_frame: 0, - time_bank: 0.0, + time_bank: 0, }) } - /// Advances the animation by the specified time delta with automatic frame cycling. + /// Advances the animation by the specified number of ticks with automatic frame cycling. /// - /// Accumulates time in the time bank and progresses through frames when enough - /// time has elapsed. Supports frame rates independent of game frame rate by - /// potentially advancing multiple frames in a single call if `dt` is large. + /// Accumulates ticks in the time bank and progresses through frames when enough + /// ticks have elapsed. Supports frame rates independent of game frame rate by + /// potentially advancing multiple frames in a single call if `ticks` is large. /// Animation loops automatically when reaching the final frame. /// /// # Arguments /// - /// * `dt` - Time elapsed since the last tick (typically frame delta time) - pub fn tick(&mut self, dt: f32) { - self.time_bank += dt; + /// * `ticks` - Number of ticks elapsed since the last update + pub fn tick(&mut self, ticks: u16) { + self.time_bank += ticks; while self.time_bank >= self.frame_duration { self.time_bank -= self.frame_duration; self.current_frame = (self.current_frame + 1) % self.tiles.len(); @@ -65,13 +65,13 @@ impl AnimatedTexture { /// Returns the time bank. #[allow(dead_code)] - pub fn time_bank(&self) -> f32 { + pub fn time_bank(&self) -> u16 { self.time_bank } /// Returns the frame duration. #[allow(dead_code)] - pub fn frame_duration(&self) -> f32 { + pub fn frame_duration(&self) -> u16 { self.frame_duration } @@ -80,4 +80,11 @@ impl AnimatedTexture { pub fn tiles_len(&self) -> usize { self.tiles.len() } + + /// Resets the animation to the first frame and clears the time bank. + /// Useful for synchronizing animations when they are assigned. + pub fn reset(&mut self) { + self.current_frame = 0; + self.time_bank = 0; + } } diff --git a/tests/animated.rs b/tests/animated.rs index 1645bf3..d7f161d 100644 --- a/tests/animated.rs +++ b/tests/animated.rs @@ -18,46 +18,41 @@ fn test_animated_texture_creation_errors() { let tiles = smallvec![mock_atlas_tile(1), mock_atlas_tile(2)]; assert!(matches!( - AnimatedTexture::new(tiles.clone(), 0.0).unwrap_err(), - GameError::Texture(TextureError::Animated(AnimatedTextureError::InvalidFrameDuration(0.0))) - )); - - assert!(matches!( - AnimatedTexture::new(tiles, -0.1).unwrap_err(), - GameError::Texture(TextureError::Animated(AnimatedTextureError::InvalidFrameDuration(-0.1))) + AnimatedTexture::new(tiles.clone(), 0).unwrap_err(), + GameError::Texture(TextureError::Animated(AnimatedTextureError::InvalidFrameDuration(0))) )); } #[test] fn test_animated_texture_advancement() { let tiles = smallvec![mock_atlas_tile(1), mock_atlas_tile(2), mock_atlas_tile(3)]; - let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap(); + let mut texture = AnimatedTexture::new(tiles, 10).unwrap(); assert_eq!(texture.current_frame(), 0); - texture.tick(0.25); + texture.tick(25); assert_eq!(texture.current_frame(), 2); - assert!((texture.time_bank() - 0.05).abs() < 0.001); + assert_eq!(texture.time_bank(), 5); } #[test] fn test_animated_texture_wrap_around() { let tiles = smallvec![mock_atlas_tile(1), mock_atlas_tile(2)]; - let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap(); + let mut texture = AnimatedTexture::new(tiles, 10).unwrap(); - texture.tick(0.1); + texture.tick(10); assert_eq!(texture.current_frame(), 1); - texture.tick(0.1); + texture.tick(10); assert_eq!(texture.current_frame(), 0); } #[test] fn test_animated_texture_single_frame() { let tiles = smallvec![mock_atlas_tile(1)]; - let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap(); + let mut texture = AnimatedTexture::new(tiles, 10).unwrap(); - texture.tick(0.1); + texture.tick(10); assert_eq!(texture.current_frame(), 0); assert_eq!(texture.current_tile().color.unwrap().r, 1); } diff --git a/tests/error.rs b/tests/error.rs index f2cd1d3..069cdc7 100644 --- a/tests/error.rs +++ b/tests/error.rs @@ -48,7 +48,7 @@ fn test_game_error_from_io_error() { #[test] fn test_texture_error_from_animated_error() { - let animated_error = AnimatedTextureError::InvalidFrameDuration(-1.0); + let animated_error = AnimatedTextureError::InvalidFrameDuration(0); let texture_error: TextureError = animated_error.into(); assert!(matches!(texture_error, TextureError::Animated(_))); } @@ -80,7 +80,7 @@ fn test_entity_error_display() { #[test] fn test_animated_texture_error_display() { - let error = AnimatedTextureError::InvalidFrameDuration(0.0); + let error = AnimatedTextureError::InvalidFrameDuration(0); assert_eq!(error.to_string(), "Frame duration must be positive, got 0"); } @@ -150,7 +150,7 @@ fn test_result_ext_error() { #[test] fn test_error_chain_conversions() { // Test that we can convert through multiple levels - let animated_error = AnimatedTextureError::InvalidFrameDuration(-5.0); + let animated_error = AnimatedTextureError::InvalidFrameDuration(0); let texture_error: TextureError = animated_error.into(); let game_error: GameError = texture_error.into();