diff --git a/src/game/mod.rs b/src/game/mod.rs index 3876384..1fafca6 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -14,11 +14,13 @@ use crate::systems::{ blinking::blinking_system, collision::collision_system, components::{ - AudioState, Collider, DeltaTime, DirectionalAnimated, EntityType, GlobalState, ItemBundle, ItemCollider, PacmanCollider, - PlayerBundle, PlayerControlled, RenderDirty, Renderable, ScoreResource, + AudioState, Collider, DeltaTime, DirectionalAnimated, EntityType, GhostBehavior, GhostBundle, GhostCollider, GhostType, + GlobalState, ItemBundle, ItemCollider, PacmanCollider, PlayerBundle, PlayerControlled, RenderDirty, Renderable, + ScoreResource, }, control::player_system, debug::{debug_render_system, DebugState, DebugTextureResource}, + ghost::ghost_ai_system, input::input_system, item::item_system, movement::movement_system, @@ -212,6 +214,7 @@ impl Game { ( profile("input", input_system), profile("player", player_system), + profile("ghost_ai", ghost_ai_system), profile("movement", movement_system), profile("collision", collision_system), profile("item", item_system), @@ -245,6 +248,9 @@ impl Game { // Spawn player world.spawn(player); + // Spawn ghosts + Self::spawn_ghosts(&mut world)?; + // Spawn items let pellet_sprite = SpriteAtlas::get_tile(world.non_send_resource::(), "maze/pellet.png") .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("maze/pellet.png".to_string())))?; @@ -288,6 +294,117 @@ impl Game { Ok(Game { world, schedule }) } + /// Spawns all four ghosts at their starting positions with appropriate textures. + fn spawn_ghosts(world: &mut World) -> GameResult<()> { + // Extract the data we need first to avoid borrow conflicts + let ghost_start_positions = { + let map = world.resource::(); + [ + (GhostType::Blinky, map.start_positions.blinky), + (GhostType::Pinky, map.start_positions.pinky), + (GhostType::Inky, map.start_positions.inky), + (GhostType::Clyde, map.start_positions.clyde), + ] + }; + + for (ghost_type, start_node) in ghost_start_positions { + // Create the ghost bundle in a separate scope to manage borrows + let ghost = { + 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_type, + ghost_behavior: GhostBehavior::default(), + position: Position { + node: start_node, + edge_progress: None, + }, + movement_state: MovementState::Stopped, + movable: Movable { + speed: ghost_type.base_speed(), + current_direction: Direction::Left, + requested_direction: Some(Direction::Left), // Start with some movement + }, + sprite: Renderable { + sprite: SpriteAtlas::get_tile(atlas, &format!("ghost/{}/left_a.png", ghost_type.as_str())).ok_or_else( + || { + GameError::Texture(TextureError::AtlasTileNotFound(format!( + "ghost/{}/left_a.png", + ghost_type.as_str() + ))) + }, + )?, + layer: 0, + visible: true, + }, + directional_animated: DirectionalAnimated { + textures, + stopped_textures, + }, + entity_type: EntityType::Ghost, + collider: Collider { + size: crate::constants::CELL_SIZE as f32 * 1.375, + }, + ghost_collider: GhostCollider, + } + }; + + world.spawn(ghost); + } + + Ok(()) + } + /// Ticks the game state. /// /// Returns true if the game should exit. diff --git a/src/systems/components.rs b/src/systems/components.rs index f80d22d..91cec2b 100644 --- a/src/systems/components.rs +++ b/src/systems/components.rs @@ -11,6 +11,65 @@ use crate::{ #[derive(Default, Component)] pub struct PlayerControlled; +/// The four classic ghost types. +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)] +pub enum GhostType { + Blinky, + Pinky, + Inky, + Clyde, +} + +impl GhostType { + /// Returns the ghost type name for atlas lookups. + pub fn as_str(self) -> &'static str { + match self { + GhostType::Blinky => "blinky", + GhostType::Pinky => "pinky", + GhostType::Inky => "inky", + GhostType::Clyde => "clyde", + } + } + + /// Returns the base movement speed for this ghost type. + pub fn base_speed(self) -> f32 { + match self { + GhostType::Blinky => 1.0, + GhostType::Pinky => 0.95, + GhostType::Inky => 0.9, + GhostType::Clyde => 0.85, + } + } + + /// Returns the ghost's color for debug rendering. + pub fn debug_color(&self) -> sdl2::pixels::Color { + match self { + GhostType::Blinky => sdl2::pixels::Color::RGB(255, 0, 0), // Red + GhostType::Pinky => sdl2::pixels::Color::RGB(255, 182, 255), // Pink + GhostType::Inky => sdl2::pixels::Color::RGB(0, 255, 255), // Cyan + GhostType::Clyde => sdl2::pixels::Color::RGB(255, 182, 85), // Orange + } + } +} + +/// Ghost AI behavior component - controls randomized movement decisions. +#[derive(Component)] +pub struct GhostBehavior { + /// Timer for making new direction decisions + pub decision_timer: f32, + /// Interval between direction decisions (in seconds) + pub decision_interval: f32, +} + +impl Default for GhostBehavior { + fn default() -> Self { + Self { + decision_timer: 0.0, + decision_interval: 0.5, // Make decisions every half second + } + } +} + /// A tag component denoting the type of entity. #[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum EntityType { @@ -94,6 +153,20 @@ pub struct ItemBundle { pub item_collider: ItemCollider, } +#[derive(Bundle)] +pub struct GhostBundle { + pub ghost_type: GhostType, + pub ghost_behavior: GhostBehavior, + pub position: Position, + pub movement_state: MovementState, + pub movable: Movable, + pub sprite: Renderable, + pub directional_animated: DirectionalAnimated, + pub entity_type: EntityType, + pub collider: Collider, + pub ghost_collider: GhostCollider, +} + #[derive(Resource)] pub struct GlobalState { pub exit: bool, diff --git a/src/systems/ghost.rs b/src/systems/ghost.rs new file mode 100644 index 0000000..74139fc --- /dev/null +++ b/src/systems/ghost.rs @@ -0,0 +1,77 @@ +use bevy_ecs::system::{Query, Res}; +use rand::prelude::*; +use smallvec::SmallVec; + +use crate::{ + entity::direction::Direction, + map::builder::Map, + systems::{ + components::{DeltaTime, EntityType, GhostBehavior, GhostType}, + movement::{Movable, Position}, + }, +}; + +/// Ghost AI system that handles randomized movement decisions. +/// +/// This system runs on all ghosts and makes periodic decisions about +/// which direction to move in when they reach intersections. +pub fn ghost_ai_system( + map: Res, + delta_time: Res, + mut ghosts: Query<(&mut GhostBehavior, &mut Movable, &Position, &EntityType, &GhostType)>, +) { + for (mut ghost_behavior, mut movable, position, entity_type, _ghost_type) in ghosts.iter_mut() { + // Only process ghosts + if *entity_type != EntityType::Ghost { + continue; + } + + // Update decision timer + ghost_behavior.decision_timer += delta_time.0; + + // Check if we should make a new direction decision + let should_decide = ghost_behavior.decision_timer >= ghost_behavior.decision_interval; + let at_intersection = position.is_at_node(); + + if should_decide && at_intersection { + choose_random_direction(&map, &mut movable, position); + ghost_behavior.decision_timer = 0.0; + } + } +} + +/// Chooses a random available direction for a ghost at an intersection. +/// +/// This function mirrors the behavior from the old ghost implementation, +/// preferring not to reverse direction unless it's the only option. +fn choose_random_direction(map: &Map, movable: &mut Movable, position: &Position) { + let current_node = position.current_node(); + let intersection = &map.graph.adjacency_list[current_node]; + + // Collect all available directions that ghosts can traverse + let mut available_directions = SmallVec::<[Direction; 4]>::new(); + for direction in Direction::DIRECTIONS { + if let Some(edge) = intersection.get(direction) { + // Check if ghosts can traverse this edge + if edge.traversal_flags.contains(crate::entity::graph::TraversalFlags::GHOST) { + available_directions.push(direction); + } + } + } + + // Choose a random direction (avoid reversing unless necessary) + if !available_directions.is_empty() { + let mut rng = SmallRng::from_os_rng(); + + // Filter out the opposite direction if possible, but allow it if we have limited options + let opposite = movable.current_direction.opposite(); + let filtered_directions: Vec<_> = available_directions + .iter() + .filter(|&&dir| dir != opposite || available_directions.len() <= 2) + .collect(); + + if let Some(&random_direction) = filtered_directions.choose(&mut rng) { + movable.requested_direction = Some(*random_direction); + } + } +} diff --git a/src/systems/mod.rs b/src/systems/mod.rs index b065230..a85e74b 100644 --- a/src/systems/mod.rs +++ b/src/systems/mod.rs @@ -10,6 +10,7 @@ pub mod components; pub mod control; pub mod debug; pub mod formatting; +pub mod ghost; pub mod input; pub mod item; pub mod movement; diff --git a/src/systems/profiling.rs b/src/systems/profiling.rs index 51f7f11..955fbc4 100644 --- a/src/systems/profiling.rs +++ b/src/systems/profiling.rs @@ -7,7 +7,7 @@ use std::time::Duration; use thousands::Separable; /// The maximum number of systems that can be profiled. Must not be exceeded, or it will panic. -const MAX_SYSTEMS: usize = 12; +const MAX_SYSTEMS: usize = 13; /// The number of durations to keep in the circular buffer. const TIMING_WINDOW_SIZE: usize = 30;