Compare commits

..

2 Commits

Author SHA1 Message Date
3d0bc66e40 feat: ghosts system 2025-08-15 20:38:18 -05:00
e0a15c1ca8 feat: implement audio muting functionality 2025-08-15 20:30:41 -05:00
6 changed files with 277 additions and 4 deletions

View File

@@ -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::<SpriteAtlas>(), "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::<Map>();
[
(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::<SpriteAtlas>();
// 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.

View File

@@ -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,

View File

@@ -8,7 +8,7 @@ use bevy_ecs::{
use crate::{
error::GameError,
events::{GameCommand, GameEvent},
systems::components::{GlobalState, PlayerControlled},
systems::components::{AudioState, GlobalState, PlayerControlled},
systems::debug::DebugState,
systems::movement::Movable,
};
@@ -18,6 +18,7 @@ pub fn player_system(
mut events: EventReader<GameEvent>,
mut state: ResMut<GlobalState>,
mut debug_state: ResMut<DebugState>,
mut audio_state: ResMut<AudioState>,
mut players: Query<&mut Movable, With<PlayerControlled>>,
mut errors: EventWriter<GameError>,
) {
@@ -46,6 +47,10 @@ pub fn player_system(
GameCommand::ToggleDebug => {
*debug_state = debug_state.next();
}
GameCommand::MuteAudio => {
audio_state.muted = !audio_state.muted;
tracing::info!("Audio {}", if audio_state.muted { "muted" } else { "unmuted" });
}
_ => {}
}
}

77
src/systems/ghost.rs Normal file
View File

@@ -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<Map>,
delta_time: Res<DeltaTime>,
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);
}
}
}

View File

@@ -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;

View File

@@ -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;