From 088c496ad919b4ad0a85b029f856d359010e8a18 Mon Sep 17 00:00:00 2001 From: Ryan Walters Date: Mon, 8 Sep 2025 23:21:58 -0500 Subject: [PATCH] refactor: store common components & bundles in 'common' submodule, move others directly into relevant files, create 'animation' submodule --- src/game.rs | 3 +- src/map/builder.rs | 2 +- src/map/graph.rs | 2 +- src/systems/animation.rs | 132 ++++++++++ src/systems/blinking.rs | 5 +- src/systems/collision.rs | 7 +- src/systems/common/bundles.rs | 43 ++++ src/systems/common/components.rs | 103 ++++++++ src/systems/common/mod.rs | 5 + src/systems/components.rs | 411 ------------------------------- src/systems/ghost.rs | 197 ++++++++++++++- src/systems/input.rs | 2 +- src/systems/item.rs | 12 +- src/systems/lifetime.rs | 2 +- src/systems/mod.rs | 6 +- src/systems/player.rs | 7 +- src/systems/render.rs | 90 +------ src/texture/sprites.rs | 3 +- tests/blinking.rs | 6 +- tests/sprites.rs | 2 +- 20 files changed, 515 insertions(+), 525 deletions(-) create mode 100644 src/systems/animation.rs create mode 100644 src/systems/common/bundles.rs create mode 100644 src/systems/common/components.rs create mode 100644 src/systems/common/mod.rs delete mode 100644 src/systems/components.rs diff --git a/src/game.rs b/src/game.rs index fe2010e..e5b66e8 100644 --- a/src/game.rs +++ b/src/game.rs @@ -42,8 +42,7 @@ use crate::{ asset::{get_asset_bytes, Asset}, events::GameCommand, map::render::MapRenderer, - systems::debug::{BatchedLinesResource, TtfAtlasResource}, - systems::input::{Bindings, CursorPosition}, + systems::{BatchedLinesResource, Bindings, CursorPosition, TtfAtlasResource}, texture::sprite::{AtlasMapper, SpriteAtlas}, }; diff --git a/src/map/builder.rs b/src/map/builder.rs index 410b842..4595077 100644 --- a/src/map/builder.rs +++ b/src/map/builder.rs @@ -3,7 +3,7 @@ use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE}; use crate::map::direction::Direction; use crate::map::graph::{Graph, Node, TraversalFlags}; use crate::map::parser::MapTileParser; -use crate::systems::movement::NodeId; +use crate::systems::NodeId; use bevy_ecs::resource::Resource; use glam::{I8Vec2, IVec2, Vec2}; use std::collections::{HashMap, VecDeque}; diff --git a/src/map/graph.rs b/src/map/graph.rs index 286eadf..b9f8ad1 100644 --- a/src/map/graph.rs +++ b/src/map/graph.rs @@ -1,6 +1,6 @@ use glam::Vec2; -use crate::systems::movement::NodeId; +use crate::systems::NodeId; use super::direction::Direction; diff --git a/src/systems/animation.rs b/src/systems/animation.rs new file mode 100644 index 0000000..ea858d7 --- /dev/null +++ b/src/systems/animation.rs @@ -0,0 +1,132 @@ +use bevy_ecs::{ + component::Component, + query::{Has, Or, With, Without}, + resource::Resource, + system::{Query, Res}, +}; + +use crate::{ + systems::{DeltaTime, Dying, Frozen, Position, Renderable, Velocity}, + texture::animated::{DirectionalTiles, TileSequence}, +}; + +/// Directional animation component with shared timing across all directions +#[derive(Component, Clone)] +pub struct DirectionalAnimation { + pub moving_tiles: DirectionalTiles, + pub stopped_tiles: DirectionalTiles, + pub current_frame: usize, + pub time_bank: u16, + pub frame_duration: u16, +} + +impl DirectionalAnimation { + /// Creates a new directional animation with the given tiles and frame duration + pub fn new(moving_tiles: DirectionalTiles, stopped_tiles: DirectionalTiles, frame_duration: u16) -> Self { + Self { + moving_tiles, + stopped_tiles, + current_frame: 0, + time_bank: 0, + frame_duration, + } + } +} + +/// 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, 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 { + /// Creates a new linear animation with the given tiles and frame duration + pub fn new(tiles: TileSequence, frame_duration: u16) -> Self { + Self { + tiles, + current_frame: 0, + time_bank: 0, + frame_duration, + finished: false, + } + } +} + +/// Updates directional animated entities with synchronized timing across directions. +/// +/// This runs before the render system to update sprites based on current direction and movement state. +/// 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), Without>, +) { + let ticks = (dt.seconds * 60.0).round() as u16; // Convert from seconds to ticks at 60 ticks/sec + + for (position, velocity, mut anim, mut renderable) in query.iter_mut() { + let stopped = matches!(position, Position::Stopped { .. }); + + // Only tick animation when moving to preserve stopped frame + if !stopped { + // Tick shared animation state + anim.time_bank += ticks; + while anim.time_bank >= anim.frame_duration { + anim.time_bank -= anim.frame_duration; + anim.current_frame += 1; + } + } + + // Get tiles for current direction and movement state + let tiles = if stopped { + anim.stopped_tiles.get(velocity.direction) + } else { + anim.moving_tiles.get(velocity.direction) + }; + + if !tiles.is_empty() { + let new_tile = tiles.get_tile(anim.current_frame); + if renderable.sprite != new_tile { + renderable.sprite = new_tile; + } + } + } +} + +/// 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; + } + + 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); + } +} diff --git a/src/systems/blinking.rs b/src/systems/blinking.rs index 8c8ef07..5498baa 100644 --- a/src/systems/blinking.rs +++ b/src/systems/blinking.rs @@ -5,10 +5,7 @@ use bevy_ecs::{ system::{Commands, Query, Res}, }; -use crate::systems::{ - components::{DeltaTime, Renderable}, - Frozen, Hidden, -}; +use crate::systems::{DeltaTime, Frozen, Hidden, Renderable}; #[derive(Component, Debug)] pub struct Blinking { diff --git a/src/systems/collision.rs b/src/systems/collision.rs index a39cab4..63412de 100644 --- a/src/systems/collision.rs +++ b/src/systems/collision.rs @@ -7,13 +7,10 @@ use bevy_ecs::{ }; use tracing::{debug, trace, warn}; -use crate::error::GameError; use crate::events::{GameEvent, StageTransition}; use crate::map::builder::Map; -use crate::systems::{ - components::GhostState, movement::Position, AudioEvent, DyingSequence, Frozen, GameStage, Ghost, PlayerControlled, - ScoreResource, -}; +use crate::systems::{movement::Position, AudioEvent, DyingSequence, Frozen, GameStage, Ghost, PlayerControlled, ScoreResource}; +use crate::{error::GameError, systems::GhostState}; /// A component for defining the collision area of an entity. #[derive(Component)] diff --git a/src/systems/common/bundles.rs b/src/systems/common/bundles.rs new file mode 100644 index 0000000..96e0575 --- /dev/null +++ b/src/systems/common/bundles.rs @@ -0,0 +1,43 @@ +use bevy_ecs::bundle::Bundle; + +use crate::systems::{ + BufferedDirection, Collider, DirectionalAnimation, EntityType, Ghost, GhostCollider, GhostState, ItemCollider, + LastAnimationState, MovementModifiers, PacmanCollider, PlayerControlled, Position, Renderable, Velocity, +}; + +#[derive(Bundle)] +pub struct PlayerBundle { + pub player: PlayerControlled, + pub position: Position, + pub velocity: Velocity, + pub buffered_direction: BufferedDirection, + pub sprite: Renderable, + pub directional_animation: DirectionalAnimation, + pub entity_type: EntityType, + pub collider: Collider, + pub movement_modifiers: MovementModifiers, + pub pacman_collider: PacmanCollider, +} + +#[derive(Bundle)] +pub struct ItemBundle { + pub position: Position, + pub sprite: Renderable, + pub entity_type: EntityType, + pub collider: Collider, + pub item_collider: ItemCollider, +} + +#[derive(Bundle)] +pub struct GhostBundle { + pub ghost: Ghost, + pub position: Position, + pub velocity: Velocity, + pub sprite: Renderable, + pub directional_animation: DirectionalAnimation, + 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/common/components.rs b/src/systems/common/components.rs new file mode 100644 index 0000000..a9dd3f0 --- /dev/null +++ b/src/systems/common/components.rs @@ -0,0 +1,103 @@ +use bevy_ecs::{component::Component, resource::Resource}; + +use crate::map::graph::TraversalFlags; + +/// A tag component denoting the type of entity. +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum EntityType { + Player, + Ghost, + Pellet, + PowerPellet, +} + +impl EntityType { + /// Returns the traversal flags for this entity type. + pub fn traversal_flags(&self) -> TraversalFlags { + match self { + EntityType::Player => TraversalFlags::PACMAN, + EntityType::Ghost => TraversalFlags::GHOST, + _ => TraversalFlags::empty(), // Static entities don't traverse + } + } + pub fn score_value(&self) -> Option { + match self { + EntityType::Pellet => Some(10), + EntityType::PowerPellet => Some(50), + _ => None, + } + } + + pub fn is_collectible(&self) -> bool { + matches!(self, EntityType::Pellet | EntityType::PowerPellet) + } +} + +#[derive(Resource)] +pub struct GlobalState { + pub exit: bool, +} + +#[derive(Resource)] +pub struct ScoreResource(pub u32); + +#[derive(Resource)] +pub struct DeltaTime { + /// Floating-point delta time in seconds + pub seconds: f32, + /// Integer tick delta (usually 1, but can be different for testing) + pub ticks: u32, +} + +#[allow(dead_code)] +impl DeltaTime { + /// Creates a new DeltaTime from a floating-point delta time in seconds + /// + /// While this method exists as a helper, it does not mean that seconds and ticks are interchangeable. + pub fn from_seconds(seconds: f32) -> Self { + Self { + seconds, + ticks: (seconds * 60.0).round() as u32, + } + } + + /// Creates a new DeltaTime from an integer tick delta + /// + /// While this method exists as a helper, it does not mean that seconds and ticks are interchangeable. + pub fn from_ticks(ticks: u32) -> Self { + Self { + seconds: ticks as f32 / 60.0, + ticks, + } + } +} + +/// Movement modifiers that can affect Pac-Man's speed or handling. +#[derive(Component, Debug, Clone, Copy)] +pub struct MovementModifiers { + /// Multiplier applied to base speed (e.g., tunnels) + pub speed_multiplier: f32, + /// True when currently in a tunnel slowdown region + pub tunnel_slowdown_active: bool, +} + +impl Default for MovementModifiers { + fn default() -> Self { + Self { + speed_multiplier: 1.0, + tunnel_slowdown_active: false, + } + } +} + +/// Tag component for entities that should be frozen during startup +#[derive(Component, Debug, Clone, Copy)] +pub struct Frozen; + +/// Component for HUD life sprite entities. +/// Each life sprite entity has an index indicating its position from left to right (0, 1, 2, etc.). +/// This mostly functions as a tag component for sprites. +#[derive(Component, Debug, Clone, Copy)] +pub struct PlayerLife { + pub index: u32, +} diff --git a/src/systems/common/mod.rs b/src/systems/common/mod.rs new file mode 100644 index 0000000..330b0b7 --- /dev/null +++ b/src/systems/common/mod.rs @@ -0,0 +1,5 @@ +pub mod bundles; +pub mod components; + +pub use self::bundles::*; +pub use self::components::*; diff --git a/src/systems/components.rs b/src/systems/components.rs deleted file mode 100644 index 6a6595c..0000000 --- a/src/systems/components.rs +++ /dev/null @@ -1,411 +0,0 @@ -use std::collections::HashMap; - -use bevy_ecs::{bundle::Bundle, component::Component, resource::Resource}; -use bitflags::bitflags; - -use crate::{ - map::graph::TraversalFlags, - systems::{ - movement::{BufferedDirection, Position, Velocity}, - Collider, GhostCollider, ItemCollider, PacmanCollider, - }, - texture::{ - animated::{DirectionalTiles, TileSequence}, - sprite::AtlasTile, - }, -}; - -/// A tag component for entities that are controlled by the player. -#[derive(Default, Component)] -pub struct PlayerControlled; - -#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum Ghost { - Blinky, - Pinky, - Inky, - Clyde, -} - -impl Ghost { - /// Returns the ghost type name for atlas lookups. - pub fn as_str(self) -> &'static str { - match self { - Ghost::Blinky => "blinky", - Ghost::Pinky => "pinky", - Ghost::Inky => "inky", - Ghost::Clyde => "clyde", - } - } - - /// Returns the base movement speed for this ghost type. - pub fn base_speed(self) -> f32 { - match self { - Ghost::Blinky => 1.0, - Ghost::Pinky => 0.95, - Ghost::Inky => 0.9, - Ghost::Clyde => 0.85, - } - } - - /// Returns the ghost's color for debug rendering. - #[allow(dead_code)] - pub fn debug_color(&self) -> sdl2::pixels::Color { - match self { - Ghost::Blinky => sdl2::pixels::Color::RGB(255, 0, 0), // Red - Ghost::Pinky => sdl2::pixels::Color::RGB(255, 182, 255), // Pink - Ghost::Inky => sdl2::pixels::Color::RGB(0, 255, 255), // Cyan - Ghost::Clyde => sdl2::pixels::Color::RGB(255, 182, 85), // Orange - } - } -} - -/// A tag component denoting the type of entity. -#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum EntityType { - Player, - Ghost, - Pellet, - PowerPellet, -} - -impl EntityType { - /// Returns the traversal flags for this entity type. - pub fn traversal_flags(&self) -> TraversalFlags { - match self { - EntityType::Player => TraversalFlags::PACMAN, - EntityType::Ghost => TraversalFlags::GHOST, - _ => TraversalFlags::empty(), // Static entities don't traverse - } - } - pub fn score_value(&self) -> Option { - match self { - EntityType::Pellet => Some(10), - EntityType::PowerPellet => Some(50), - _ => None, - } - } - - pub fn is_collectible(&self) -> bool { - matches!(self, EntityType::Pellet | EntityType::PowerPellet) - } -} - -/// A component for entities that have a sprite, with a layer for ordering. -/// -/// This is intended to be modified by other entities allowing animation. -#[derive(Component)] -pub struct Renderable { - pub sprite: AtlasTile, - pub layer: u8, -} - -/// Directional animation component with shared timing across all directions -#[derive(Component, Clone)] -pub struct DirectionalAnimation { - pub moving_tiles: DirectionalTiles, - pub stopped_tiles: DirectionalTiles, - pub current_frame: usize, - pub time_bank: u16, - pub frame_duration: u16, -} - -impl DirectionalAnimation { - /// Creates a new directional animation with the given tiles and frame duration - pub fn new(moving_tiles: DirectionalTiles, stopped_tiles: DirectionalTiles, frame_duration: u16) -> Self { - Self { - moving_tiles, - stopped_tiles, - current_frame: 0, - time_bank: 0, - frame_duration, - } - } -} - -/// 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, 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 { - /// Creates a new linear animation with the given tiles and frame duration - pub fn new(tiles: TileSequence, frame_duration: u16) -> Self { - Self { - tiles, - current_frame: 0, - time_bank: 0, - frame_duration, - finished: false, - } - } -} - -bitflags! { - #[derive(Component, Default, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] - pub struct CollisionLayer: u8 { - const PACMAN = 1 << 0; - const GHOST = 1 << 1; - const ITEM = 1 << 2; - } -} - -#[derive(Resource)] -pub struct GlobalState { - pub exit: bool, -} - -#[derive(Resource)] -pub struct ScoreResource(pub u32); - -#[derive(Resource)] -pub struct DeltaTime { - /// Floating-point delta time in seconds - pub seconds: f32, - /// Integer tick delta (usually 1, but can be different for testing) - pub ticks: u32, -} - -#[allow(dead_code)] -impl DeltaTime { - /// Creates a new DeltaTime from a floating-point delta time in seconds - /// - /// While this method exists as a helper, it does not mean that seconds and ticks are interchangeable. - pub fn from_seconds(seconds: f32) -> Self { - Self { - seconds, - ticks: (seconds * 60.0).round() as u32, - } - } - - /// Creates a new DeltaTime from an integer tick delta - /// - /// While this method exists as a helper, it does not mean that seconds and ticks are interchangeable. - pub fn from_ticks(ticks: u32) -> Self { - Self { - seconds: ticks as f32 / 60.0, - ticks, - } - } -} - -/// Movement modifiers that can affect Pac-Man's speed or handling. -#[derive(Component, Debug, Clone, Copy)] -pub struct MovementModifiers { - /// Multiplier applied to base speed (e.g., tunnels) - pub speed_multiplier: f32, - /// True when currently in a tunnel slowdown region - pub tunnel_slowdown_active: bool, -} - -impl Default for MovementModifiers { - fn default() -> Self { - Self { - speed_multiplier: 1.0, - tunnel_slowdown_active: false, - } - } -} - -/// Tag component for entities that should be frozen during startup -#[derive(Component, Debug, Clone, Copy)] -pub struct Frozen; - -/// Tag component for eaten ghosts -#[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; - -/// Component for HUD life sprite entities. -/// Each life sprite entity has an index indicating its position from left to right (0, 1, 2, etc.). -/// This mostly functions as a tag component for sprites. -#[derive(Component, Debug, Clone, Copy)] -pub struct PlayerLife { - pub index: u32, -} - -#[derive(Component, Debug, Clone, Copy)] -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 { - 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. -/// 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, -} - -/// 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 the normal directional animation for each ghost type. -#[derive(Resource)] -pub struct GhostAnimations { - pub normal: HashMap, - pub eyes: DirectionalAnimation, - pub frightened: LinearAnimation, - pub frightened_flashing: LinearAnimation, -} - -impl GhostAnimations { - /// Creates a new GhostAnimations resource with the provided data. - pub fn new( - normal: HashMap, - eyes: DirectionalAnimation, - frightened: LinearAnimation, - frightened_flashing: LinearAnimation, - ) -> Self { - Self { - normal, - eyes, - frightened, - frightened_flashing, - } - } - - /// Gets the normal directional animation for the specified ghost type. - pub fn get_normal(&self, ghost_type: &Ghost) -> Option<&DirectionalAnimation> { - self.normal.get(ghost_type) - } - - /// Gets the eyes animation (shared across all ghosts). - pub fn eyes(&self) -> &DirectionalAnimation { - &self.eyes - } - - /// Gets the frightened animations (shared across all ghosts). - pub fn frightened(&self, flash: bool) -> &LinearAnimation { - if flash { - &self.frightened_flashing - } else { - &self.frightened - } - } -} - -#[derive(Bundle)] -pub struct PlayerBundle { - pub player: PlayerControlled, - pub position: Position, - pub velocity: Velocity, - pub buffered_direction: BufferedDirection, - pub sprite: Renderable, - pub directional_animation: DirectionalAnimation, - pub entity_type: EntityType, - pub collider: Collider, - pub movement_modifiers: MovementModifiers, - pub pacman_collider: PacmanCollider, -} - -#[derive(Bundle)] -pub struct ItemBundle { - pub position: Position, - pub sprite: Renderable, - pub entity_type: EntityType, - pub collider: Collider, - pub item_collider: ItemCollider, -} - -#[derive(Bundle)] -pub struct GhostBundle { - pub ghost: Ghost, - pub position: Position, - pub velocity: Velocity, - pub sprite: Renderable, - pub directional_animation: DirectionalAnimation, - 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 432b8d8..fe6725a 100644 --- a/src/systems/ghost.rs +++ b/src/systems/ghost.rs @@ -1,7 +1,7 @@ +use std::collections::HashMap; + use crate::platform; -use crate::systems::components::{ - DirectionalAnimation, Frozen, GhostAnimation, GhostState, LastAnimationState, LinearAnimation, Looping, -}; +use crate::systems::{DirectionalAnimation, Frozen, LinearAnimation, Looping}; use crate::{ map::{ builder::Map, @@ -9,18 +9,201 @@ use crate::{ graph::{Edge, TraversalFlags}, }, systems::{ - components::{DeltaTime, Ghost}, + components::DeltaTime, movement::{Position, Velocity}, }, }; +use bevy_ecs::component::Component; +use bevy_ecs::resource::Resource; use tracing::{debug, trace, warn}; -use crate::systems::GhostAnimations; use bevy_ecs::query::Without; use bevy_ecs::system::{Commands, Query, Res}; use rand::seq::IndexedRandom; use smallvec::SmallVec; +/// Tag component for eaten ghosts +#[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, PartialEq, Eq, Hash)] +pub enum Ghost { + Blinky, + Pinky, + Inky, + Clyde, +} + +impl Ghost { + /// Returns the ghost type name for atlas lookups. + pub fn as_str(self) -> &'static str { + match self { + Ghost::Blinky => "blinky", + Ghost::Pinky => "pinky", + Ghost::Inky => "inky", + Ghost::Clyde => "clyde", + } + } + + /// Returns the base movement speed for this ghost type. + pub fn base_speed(self) -> f32 { + match self { + Ghost::Blinky => 1.0, + Ghost::Pinky => 0.95, + Ghost::Inky => 0.9, + Ghost::Clyde => 0.85, + } + } + + /// Returns the ghost's color for debug rendering. + #[allow(dead_code)] + pub fn debug_color(&self) -> sdl2::pixels::Color { + match self { + Ghost::Blinky => sdl2::pixels::Color::RGB(255, 0, 0), // Red + Ghost::Pinky => sdl2::pixels::Color::RGB(255, 182, 255), // Pink + Ghost::Inky => sdl2::pixels::Color::RGB(0, 255, 255), // Cyan + Ghost::Clyde => sdl2::pixels::Color::RGB(255, 182, 85), // Orange + } + } +} + +#[derive(Component, Debug, Clone, Copy)] +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, +} + +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 { + 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. +/// 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, +} + +/// 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 the normal directional animation for each ghost type. +#[derive(Resource)] +pub struct GhostAnimations { + pub normal: HashMap, + pub eyes: DirectionalAnimation, + pub frightened: LinearAnimation, + pub frightened_flashing: LinearAnimation, +} + +impl GhostAnimations { + /// Creates a new GhostAnimations resource with the provided data. + pub fn new( + normal: HashMap, + eyes: DirectionalAnimation, + frightened: LinearAnimation, + frightened_flashing: LinearAnimation, + ) -> Self { + Self { + normal, + eyes, + frightened, + frightened_flashing, + } + } + + /// Gets the normal directional animation for the specified ghost type. + pub fn get_normal(&self, ghost_type: &Ghost) -> Option<&DirectionalAnimation> { + self.normal.get(ghost_type) + } + + /// Gets the eyes animation (shared across all ghosts). + pub fn eyes(&self) -> &DirectionalAnimation { + &self.eyes + } + + /// Gets the frightened animations (shared across all ghosts). + pub fn frightened(&self, flash: bool) -> &LinearAnimation { + if flash { + &self.frightened_flashing + } else { + &self.frightened + } + } +} + /// Autonomous ghost AI system implementing randomized movement with backtracking avoidance. pub fn ghost_movement_system( map: Res, @@ -185,6 +368,10 @@ fn find_direction_to_target( None } +/// Component to track the last animation state for efficient change detection +#[derive(Component, Debug, Clone, Copy, PartialEq)] +pub struct LastAnimationState(pub GhostAnimation); + /// Unified system that manages ghost state transitions and animations with component swapping pub fn ghost_state_system( mut commands: Commands, diff --git a/src/systems/input.rs b/src/systems/input.rs index cd4646f..fe5e20a 100644 --- a/src/systems/input.rs +++ b/src/systems/input.rs @@ -13,7 +13,7 @@ use sdl2::{ }; use smallvec::{smallvec, SmallVec}; -use crate::systems::components::DeltaTime; +use crate::systems::DeltaTime; use crate::{ events::{GameCommand, GameEvent}, map::direction::Direction, diff --git a/src/systems/item.rs b/src/systems/item.rs index 9186709..5335a73 100644 --- a/src/systems/item.rs +++ b/src/systems/item.rs @@ -2,7 +2,7 @@ use bevy_ecs::{ entity::Entity, event::{EventReader, EventWriter}, query::With, - system::{Commands, Query, ResMut}, + system::{Commands, Query, ResMut, Single}, }; use tracing::{debug, trace}; @@ -27,7 +27,7 @@ pub fn item_system( mut commands: Commands, mut collision_events: EventReader, mut score: ResMut, - pacman_query: Query>, + pacman: Single>, item_query: Query<(Entity, &EntityType), With>, mut ghost_query: Query<&mut GhostState, With>, mut events: EventWriter, @@ -35,10 +35,10 @@ pub fn item_system( for event in collision_events.read() { if let GameEvent::Collision(entity1, entity2) = event { // Check if one is Pacman and the other is an item - let (_pacman_entity, item_entity) = if pacman_query.get(*entity1).is_ok() && item_query.get(*entity2).is_ok() { - (*entity1, *entity2) - } else if pacman_query.get(*entity2).is_ok() && item_query.get(*entity1).is_ok() { - (*entity2, *entity1) + let (_, item_entity) = if *pacman == *entity1 && item_query.get(*entity2).is_ok() { + (*pacman, *entity2) + } else if *pacman == *entity2 && item_query.get(*entity1).is_ok() { + (*pacman, *entity1) } else { continue; }; diff --git a/src/systems/lifetime.rs b/src/systems/lifetime.rs index f47a484..bcb618b 100644 --- a/src/systems/lifetime.rs +++ b/src/systems/lifetime.rs @@ -4,7 +4,7 @@ use bevy_ecs::{ system::{Commands, Query, Res}, }; -use crate::systems::components::DeltaTime; +use crate::systems::DeltaTime; /// Component for entities that should be automatically deleted after a certain number of ticks #[derive(Component, Debug, Clone, Copy)] diff --git a/src/systems/mod.rs b/src/systems/mod.rs index 6979601..22d9cb9 100644 --- a/src/systems/mod.rs +++ b/src/systems/mod.rs @@ -9,9 +9,10 @@ pub mod profiling; #[cfg_attr(coverage_nightly, coverage(off))] pub mod render; +pub mod animation; pub mod blinking; pub mod collision; -pub mod components; +pub mod common; pub mod ghost; pub mod input; pub mod item; @@ -22,10 +23,11 @@ pub mod state; // Re-export all the modules. Do not fine-tune the exports. +pub use self::animation::*; pub use self::audio::*; pub use self::blinking::*; pub use self::collision::*; -pub use self::components::*; +pub use self::common::*; pub use self::debug::*; pub use self::ghost::*; pub use self::input::*; diff --git a/src/systems/player.rs b/src/systems/player.rs index cfb32cf..06b841c 100644 --- a/src/systems/player.rs +++ b/src/systems/player.rs @@ -1,4 +1,5 @@ use bevy_ecs::{ + component::Component, event::EventReader, query::{With, Without}, system::{Query, Res, ResMut, Single}, @@ -9,13 +10,17 @@ use crate::{ events::{GameCommand, GameEvent}, map::{builder::Map, graph::Edge}, systems::{ - components::{DeltaTime, EntityType, Frozen, GlobalState, MovementModifiers, PlayerControlled}, + components::{DeltaTime, EntityType, Frozen, GlobalState, MovementModifiers}, debug::DebugState, movement::{BufferedDirection, Position, Velocity}, AudioState, }, }; +/// A tag component for entities that are controlled by the player. +#[derive(Default, Component)] +pub struct PlayerControlled; + pub fn can_traverse(entity_type: EntityType, edge: Edge) -> bool { let entity_flags = entity_type.traversal_flags(); edge.traversal_flags.contains(entity_flags) diff --git a/src/systems/render.rs b/src/systems/render.rs index 284d704..33ca1b8 100644 --- a/src/systems/render.rs +++ b/src/systems/render.rs @@ -1,12 +1,10 @@ use crate::map::builder::Map; use crate::map::direction::Direction; -use crate::systems::input::TouchState; use crate::systems::{ - debug_render_system, BatchedLinesResource, Collider, CursorPosition, DebugState, DebugTextureResource, DeltaTime, - DirectionalAnimation, Dying, Frozen, GameStage, LinearAnimation, Looping, PlayerLife, PlayerLives, Position, Renderable, - ScoreResource, StartupSequence, SystemId, SystemTimings, TtfAtlasResource, Velocity, + debug_render_system, BatchedLinesResource, Collider, CursorPosition, DebugState, DebugTextureResource, GameStage, PlayerLife, + PlayerLives, Position, ScoreResource, StartupSequence, SystemId, SystemTimings, TouchState, TtfAtlasResource, }; -use crate::texture::sprite::SpriteAtlas; +use crate::texture::sprite::{AtlasTile, SpriteAtlas}; use crate::texture::sprites::{GameSprite, PacmanSprite}; use crate::texture::text::TextTexture; use crate::{ @@ -16,7 +14,7 @@ use crate::{ use bevy_ecs::component::Component; use bevy_ecs::entity::Entity; use bevy_ecs::event::EventWriter; -use bevy_ecs::query::{Changed, Has, Or, With, Without}; +use bevy_ecs::query::{Changed, Or, With, Without}; use bevy_ecs::removal_detection::RemovedComponents; use bevy_ecs::resource::Resource; use bevy_ecs::system::{Commands, NonSendMut, Query, Res, ResMut}; @@ -27,6 +25,15 @@ use sdl2::render::{BlendMode, Canvas, Texture}; use sdl2::video::Window; use std::time::Instant; +/// A component for entities that have a sprite, with a layer for ordering. +/// +/// This is intended to be modified by other entities allowing animation. +#[derive(Component)] +pub struct Renderable { + pub sprite: AtlasTile, + pub layer: u8, +} + #[derive(Resource, Default)] pub struct RenderDirty(pub bool); @@ -56,77 +63,6 @@ pub fn dirty_render_system( } } -/// Updates directional animated entities with synchronized timing across directions. -/// -/// This runs before the render system to update sprites based on current direction and movement state. -/// 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), Without>, -) { - let ticks = (dt.seconds * 60.0).round() as u16; // Convert from seconds to ticks at 60 ticks/sec - - for (position, velocity, mut anim, mut renderable) in query.iter_mut() { - let stopped = matches!(position, Position::Stopped { .. }); - - // Only tick animation when moving to preserve stopped frame - if !stopped { - // Tick shared animation state - anim.time_bank += ticks; - while anim.time_bank >= anim.frame_duration { - anim.time_bank -= anim.frame_duration; - anim.current_frame += 1; - } - } - - // Get tiles for current direction and movement state - let tiles = if stopped { - anim.stopped_tiles.get(velocity.direction) - } else { - anim.moving_tiles.get(velocity.direction) - }; - - if !tiles.is_empty() { - let new_tile = tiles.get_tile(anim.current_frame); - if renderable.sprite != new_tile { - renderable.sprite = new_tile; - } - } - } -} - -/// 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; - } - - 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); - } -} - /// System that manages player life sprite entities. /// Spawns and despawns life sprite entities based on changes to PlayerLives resource. /// Each life sprite is positioned based on its index (0, 1, 2, etc. from left to right). diff --git a/src/texture/sprites.rs b/src/texture/sprites.rs index 6eca64c..aea3e14 100644 --- a/src/texture/sprites.rs +++ b/src/texture/sprites.rs @@ -5,8 +5,7 @@ //! The `GameSprite` enum is the main entry point, and its `to_path` method //! generates the correct path for a given sprite in the texture atlas. -use crate::map::direction::Direction; -use crate::systems::components::Ghost; +use crate::{map::direction::Direction, systems::Ghost}; /// Represents the different sprites for Pac-Man. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] diff --git a/tests/blinking.rs b/tests/blinking.rs index 5bcfe1d..f0c9e4b 100644 --- a/tests/blinking.rs +++ b/tests/blinking.rs @@ -1,9 +1,5 @@ use bevy_ecs::{entity::Entity, system::RunSystemOnce, world::World}; -use pacman::systems::{ - blinking::{blinking_system, Blinking}, - components::{DeltaTime, Renderable}, - Frozen, Hidden, -}; +use pacman::systems::{blinking_system, Blinking, DeltaTime, Frozen, Hidden, Renderable}; use speculoos::prelude::*; mod common; diff --git a/tests/sprites.rs b/tests/sprites.rs index c7cfce4..0df87cd 100644 --- a/tests/sprites.rs +++ b/tests/sprites.rs @@ -2,7 +2,7 @@ use pacman::{ game::ATLAS_FRAMES, map::direction::Direction, - systems::components::Ghost, + systems::Ghost, texture::sprites::{FrightenedColor, GameSprite, GhostSprite, MazeSprite, PacmanSprite}, };