mirror of
https://github.com/Xevion/Pac-Man.git
synced 2025-12-06 11:15:46 -06:00
feat: ghosts system
This commit is contained in:
121
src/game/mod.rs
121
src/game/mod.rs
@@ -14,11 +14,13 @@ use crate::systems::{
|
|||||||
blinking::blinking_system,
|
blinking::blinking_system,
|
||||||
collision::collision_system,
|
collision::collision_system,
|
||||||
components::{
|
components::{
|
||||||
AudioState, Collider, DeltaTime, DirectionalAnimated, EntityType, GlobalState, ItemBundle, ItemCollider, PacmanCollider,
|
AudioState, Collider, DeltaTime, DirectionalAnimated, EntityType, GhostBehavior, GhostBundle, GhostCollider, GhostType,
|
||||||
PlayerBundle, PlayerControlled, RenderDirty, Renderable, ScoreResource,
|
GlobalState, ItemBundle, ItemCollider, PacmanCollider, PlayerBundle, PlayerControlled, RenderDirty, Renderable,
|
||||||
|
ScoreResource,
|
||||||
},
|
},
|
||||||
control::player_system,
|
control::player_system,
|
||||||
debug::{debug_render_system, DebugState, DebugTextureResource},
|
debug::{debug_render_system, DebugState, DebugTextureResource},
|
||||||
|
ghost::ghost_ai_system,
|
||||||
input::input_system,
|
input::input_system,
|
||||||
item::item_system,
|
item::item_system,
|
||||||
movement::movement_system,
|
movement::movement_system,
|
||||||
@@ -212,6 +214,7 @@ impl Game {
|
|||||||
(
|
(
|
||||||
profile("input", input_system),
|
profile("input", input_system),
|
||||||
profile("player", player_system),
|
profile("player", player_system),
|
||||||
|
profile("ghost_ai", ghost_ai_system),
|
||||||
profile("movement", movement_system),
|
profile("movement", movement_system),
|
||||||
profile("collision", collision_system),
|
profile("collision", collision_system),
|
||||||
profile("item", item_system),
|
profile("item", item_system),
|
||||||
@@ -245,6 +248,9 @@ impl Game {
|
|||||||
// Spawn player
|
// Spawn player
|
||||||
world.spawn(player);
|
world.spawn(player);
|
||||||
|
|
||||||
|
// Spawn ghosts
|
||||||
|
Self::spawn_ghosts(&mut world)?;
|
||||||
|
|
||||||
// Spawn items
|
// Spawn items
|
||||||
let pellet_sprite = SpriteAtlas::get_tile(world.non_send_resource::<SpriteAtlas>(), "maze/pellet.png")
|
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())))?;
|
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("maze/pellet.png".to_string())))?;
|
||||||
@@ -288,6 +294,117 @@ impl Game {
|
|||||||
Ok(Game { world, schedule })
|
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.
|
/// Ticks the game state.
|
||||||
///
|
///
|
||||||
/// Returns true if the game should exit.
|
/// Returns true if the game should exit.
|
||||||
|
|||||||
@@ -11,6 +11,65 @@ use crate::{
|
|||||||
#[derive(Default, Component)]
|
#[derive(Default, Component)]
|
||||||
pub struct PlayerControlled;
|
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.
|
/// A tag component denoting the type of entity.
|
||||||
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum EntityType {
|
pub enum EntityType {
|
||||||
@@ -94,6 +153,20 @@ pub struct ItemBundle {
|
|||||||
pub item_collider: ItemCollider,
|
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)]
|
#[derive(Resource)]
|
||||||
pub struct GlobalState {
|
pub struct GlobalState {
|
||||||
pub exit: bool,
|
pub exit: bool,
|
||||||
|
|||||||
77
src/systems/ghost.rs
Normal file
77
src/systems/ghost.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ pub mod components;
|
|||||||
pub mod control;
|
pub mod control;
|
||||||
pub mod debug;
|
pub mod debug;
|
||||||
pub mod formatting;
|
pub mod formatting;
|
||||||
|
pub mod ghost;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
pub mod item;
|
pub mod item;
|
||||||
pub mod movement;
|
pub mod movement;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use std::time::Duration;
|
|||||||
use thousands::Separable;
|
use thousands::Separable;
|
||||||
|
|
||||||
/// The maximum number of systems that can be profiled. Must not be exceeded, or it will panic.
|
/// 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.
|
/// The number of durations to keep in the circular buffer.
|
||||||
const TIMING_WINDOW_SIZE: usize = 30;
|
const TIMING_WINDOW_SIZE: usize = 30;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user