mirror of
https://github.com/Xevion/Pac-Man.git
synced 2025-12-06 11:15:46 -06:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d0bc66e40 | |||
| e0a15c1ca8 | |||
| fa12611c69 |
129
src/game/mod.rs
129
src/game/mod.rs
@@ -10,14 +10,17 @@ use crate::map::builder::Map;
|
||||
use crate::systems::blinking::Blinking;
|
||||
use crate::systems::movement::{Movable, MovementState, Position};
|
||||
use crate::systems::{
|
||||
audio::{audio_system, AudioEvent, AudioResource},
|
||||
blinking::blinking_system,
|
||||
collision::collision_system,
|
||||
components::{
|
||||
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,
|
||||
@@ -70,6 +73,7 @@ impl Game {
|
||||
|
||||
EventRegistry::register_event::<GameError>(&mut world);
|
||||
EventRegistry::register_event::<GameEvent>(&mut world);
|
||||
EventRegistry::register_event::<AudioEvent>(&mut world);
|
||||
|
||||
let mut backbuffer = texture_creator
|
||||
.create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y)
|
||||
@@ -88,6 +92,9 @@ impl Game {
|
||||
.map_err(|e| GameError::Sdl(e.to_string()))?;
|
||||
debug_texture.set_scale_mode(ScaleMode::Nearest);
|
||||
|
||||
// Initialize audio system
|
||||
let audio = crate::audio::Audio::new();
|
||||
|
||||
// Load atlas and create map texture
|
||||
let atlas_bytes = get_asset_bytes(Asset::Atlas)?;
|
||||
let atlas_texture = texture_creator.load_texture_bytes(&atlas_bytes).map_err(|e| {
|
||||
@@ -184,6 +191,7 @@ impl Game {
|
||||
world.insert_non_send_resource(BackbufferResource(backbuffer));
|
||||
world.insert_non_send_resource(MapTextureResource(map_texture));
|
||||
world.insert_non_send_resource(DebugTextureResource(debug_texture));
|
||||
world.insert_non_send_resource(AudioResource(audio));
|
||||
|
||||
world.insert_resource(map);
|
||||
world.insert_resource(GlobalState { exit: false });
|
||||
@@ -193,6 +201,7 @@ impl Game {
|
||||
world.insert_resource(DeltaTime(0f32));
|
||||
world.insert_resource(RenderDirty::default());
|
||||
world.insert_resource(DebugState::default());
|
||||
world.insert_resource(AudioState::default());
|
||||
|
||||
world.add_observer(
|
||||
|event: Trigger<GameEvent>, mut state: ResMut<GlobalState>, _score: ResMut<ScoreResource>| {
|
||||
@@ -205,9 +214,11 @@ 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),
|
||||
profile("audio", audio_system),
|
||||
profile("blinking", blinking_system),
|
||||
profile("directional_render", directional_render_system),
|
||||
profile("dirty_render", dirty_render_system),
|
||||
@@ -237,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())))?;
|
||||
@@ -280,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.
|
||||
|
||||
54
src/systems/audio.rs
Normal file
54
src/systems/audio.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
//! Audio system for handling sound playback in the Pac-Man game.
|
||||
//!
|
||||
//! This module provides an ECS-based audio system that integrates with SDL2_mixer
|
||||
//! for playing sound effects. The system uses NonSendMut resources to handle SDL2's
|
||||
//! main-thread requirements while maintaining Bevy ECS compatibility.
|
||||
|
||||
use bevy_ecs::{
|
||||
event::{Event, EventReader, EventWriter},
|
||||
system::{NonSendMut, ResMut},
|
||||
};
|
||||
|
||||
use crate::{audio::Audio, error::GameError, systems::components::AudioState};
|
||||
|
||||
/// Events for triggering audio playback
|
||||
#[derive(Event, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AudioEvent {
|
||||
/// Play the "eat" sound when Pac-Man consumes a pellet
|
||||
PlayEat,
|
||||
}
|
||||
|
||||
/// Non-send resource wrapper for SDL2 audio system
|
||||
///
|
||||
/// This wrapper is needed because SDL2 audio components are not Send,
|
||||
/// but Bevy ECS requires Send for regular resources. Using NonSendMut
|
||||
/// allows us to use SDL2 audio on the main thread while integrating
|
||||
/// with the ECS system.
|
||||
pub struct AudioResource(pub Audio);
|
||||
|
||||
/// System that processes audio events and plays sounds
|
||||
pub fn audio_system(
|
||||
mut audio: NonSendMut<AudioResource>,
|
||||
mut audio_state: ResMut<AudioState>,
|
||||
mut audio_events: EventReader<AudioEvent>,
|
||||
_errors: EventWriter<GameError>,
|
||||
) {
|
||||
// Set mute state if it has changed
|
||||
if audio.0.is_muted() != audio_state.muted {
|
||||
audio.0.set_mute(audio_state.muted);
|
||||
}
|
||||
|
||||
// Process audio events
|
||||
for event in audio_events.read() {
|
||||
match event {
|
||||
AudioEvent::PlayEat => {
|
||||
if !audio.0.is_disabled() && !audio_state.muted {
|
||||
audio.0.eat();
|
||||
// Update the sound index for cycling through sounds
|
||||
audio_state.sound_index = (audio_state.sound_index + 1) % 4;
|
||||
// 4 eat sounds available
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -107,3 +180,12 @@ pub struct DeltaTime(pub f32);
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
pub struct RenderDirty(pub bool);
|
||||
|
||||
/// Resource for tracking audio state
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
pub struct AudioState {
|
||||
/// Whether audio is currently muted
|
||||
pub muted: bool,
|
||||
/// Current sound index for cycling through eat sounds
|
||||
pub sound_index: usize,
|
||||
}
|
||||
|
||||
@@ -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
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@ use bevy_ecs::{event::EventReader, prelude::*, query::With, system::Query};
|
||||
|
||||
use crate::{
|
||||
events::GameEvent,
|
||||
systems::components::{EntityType, ItemCollider, PacmanCollider, ScoreResource},
|
||||
systems::{
|
||||
audio::AudioEvent,
|
||||
components::{EntityType, ItemCollider, PacmanCollider, ScoreResource},
|
||||
},
|
||||
};
|
||||
|
||||
pub fn item_system(
|
||||
@@ -11,6 +14,7 @@ pub fn item_system(
|
||||
mut score: ResMut<ScoreResource>,
|
||||
pacman_query: Query<Entity, With<PacmanCollider>>,
|
||||
item_query: Query<(Entity, &EntityType), With<ItemCollider>>,
|
||||
mut events: EventWriter<AudioEvent>,
|
||||
) {
|
||||
for event in collision_events.read() {
|
||||
if let GameEvent::Collision(entity1, entity2) = event {
|
||||
@@ -37,6 +41,8 @@ pub fn item_system(
|
||||
|
||||
// Remove the collected item
|
||||
commands.entity(item_ent).despawn();
|
||||
|
||||
events.write(AudioEvent::PlayEat);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
//! This module contains all the ECS-related logic, including components, systems,
|
||||
//! and resources.
|
||||
|
||||
pub mod audio;
|
||||
pub mod blinking;
|
||||
pub mod collision;
|
||||
pub mod components;
|
||||
pub mod control;
|
||||
pub mod debug;
|
||||
pub mod formatting;
|
||||
pub mod ghost;
|
||||
pub mod input;
|
||||
pub mod item;
|
||||
pub mod movement;
|
||||
|
||||
@@ -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 = 11;
|
||||
const MAX_SYSTEMS: usize = 13;
|
||||
/// The number of durations to keep in the circular buffer.
|
||||
const TIMING_WINDOW_SIZE: usize = 30;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user