mirror of
https://github.com/Xevion/Pac-Man.git
synced 2025-12-10 20:07:53 -06:00
feat: setup pacman collision, level restart, game over, death sequence, switch to Vec for TileSequence
This commit is contained in:
@@ -132,8 +132,6 @@ pub const RAW_BOARD: [&str; BOARD_CELL_SIZE.y as usize] = [
|
|||||||
pub mod startup {
|
pub mod startup {
|
||||||
/// Number of frames for the startup sequence (3 seconds at 60 FPS)
|
/// Number of frames for the startup sequence (3 seconds at 60 FPS)
|
||||||
pub const STARTUP_FRAMES: u32 = 60 * 3;
|
pub const STARTUP_FRAMES: u32 = 60 * 3;
|
||||||
/// Number of ticks per frame during startup
|
|
||||||
pub const STARTUP_TICKS_PER_FRAME: u32 = 60;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Game mechanics constants
|
/// Game mechanics constants
|
||||||
|
|||||||
195
src/game.rs
195
src/game.rs
@@ -9,32 +9,26 @@ use crate::error::{GameError, GameResult};
|
|||||||
use crate::events::GameEvent;
|
use crate::events::GameEvent;
|
||||||
use crate::map::builder::Map;
|
use crate::map::builder::Map;
|
||||||
use crate::map::direction::Direction;
|
use crate::map::direction::Direction;
|
||||||
use crate::systems::blinking::Blinking;
|
|
||||||
use crate::systems::components::{GhostAnimation, GhostState, LastAnimationState};
|
|
||||||
use crate::systems::movement::{BufferedDirection, Position, Velocity};
|
|
||||||
use crate::systems::profiling::{SystemId, Timing};
|
|
||||||
use crate::systems::render::touch_ui_render_system;
|
|
||||||
use crate::systems::render::RenderDirty;
|
|
||||||
use crate::systems::{
|
use crate::systems::{
|
||||||
self, combined_render_system, ghost_collision_system, present_system, Hidden, LinearAnimation, MovementModifiers, NodeId,
|
self, audio_system, blinking_system, collision_system, combined_render_system, directional_render_system,
|
||||||
TouchState,
|
dirty_render_system, eaten_ghost_system, ghost_collision_system, ghost_movement_system, ghost_state_system,
|
||||||
};
|
hud_render_system, item_system, linear_render_system, present_system, profile, touch_ui_render_system, AudioEvent,
|
||||||
use crate::systems::{
|
AudioResource, AudioState, BackbufferResource, Blinking, BufferedDirection, Collider, DebugState, DebugTextureResource,
|
||||||
audio_system, blinking_system, collision_system, directional_render_system, dirty_render_system, eaten_ghost_system,
|
DeltaTime, DirectionalAnimation, EntityType, Frozen, GameStage, Ghost, GhostAnimation, GhostAnimations, GhostBundle,
|
||||||
ghost_movement_system, ghost_state_system, hud_render_system, item_system, linear_render_system, profile, AudioEvent,
|
GhostCollider, GhostState, GlobalState, Hidden, ItemBundle, ItemCollider, LastAnimationState, LinearAnimation,
|
||||||
AudioResource, AudioState, BackbufferResource, Collider, DebugState, DebugTextureResource, DeltaTime, DirectionalAnimation,
|
MapTextureResource, MovementModifiers, NodeId, PacmanCollider, PlayerAnimation, PlayerBundle, PlayerControlled,
|
||||||
EntityType, Frozen, Ghost, GhostAnimations, GhostBundle, GhostCollider, GlobalState, ItemBundle, ItemCollider,
|
PlayerDeathAnimation, PlayerLives, Position, RenderDirty, Renderable, ScoreResource, StartupSequence, SystemId,
|
||||||
MapTextureResource, PacmanCollider, PlayerBundle, PlayerControlled, Renderable, ScoreResource, StartupSequence,
|
SystemTimings, Timing, TouchState, Velocity,
|
||||||
SystemTimings,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::texture::animated::{DirectionalTiles, TileSequence};
|
use crate::texture::animated::{DirectionalTiles, TileSequence};
|
||||||
use crate::texture::sprite::AtlasTile;
|
use crate::texture::sprite::AtlasTile;
|
||||||
use crate::texture::sprites::{FrightenedColor, GameSprite, GhostSprite, MazeSprite, PacmanSprite};
|
use crate::texture::sprites::{FrightenedColor, GameSprite, GhostSprite, MazeSprite, PacmanSprite};
|
||||||
|
use bevy_ecs::change_detection::DetectChanges;
|
||||||
use bevy_ecs::event::EventRegistry;
|
use bevy_ecs::event::EventRegistry;
|
||||||
use bevy_ecs::observer::Trigger;
|
use bevy_ecs::observer::Trigger;
|
||||||
use bevy_ecs::schedule::common_conditions::resource_changed;
|
use bevy_ecs::schedule::{IntoScheduleConfigs, Schedule, SystemSet};
|
||||||
use bevy_ecs::schedule::{Condition, IntoScheduleConfigs, Schedule, SystemSet};
|
use bevy_ecs::system::{Local, Res, ResMut};
|
||||||
use bevy_ecs::system::{Local, ResMut};
|
|
||||||
use bevy_ecs::world::World;
|
use bevy_ecs::world::World;
|
||||||
use sdl2::event::EventType;
|
use sdl2::event::EventType;
|
||||||
use sdl2::image::LoadTexture;
|
use sdl2::image::LoadTexture;
|
||||||
@@ -54,7 +48,9 @@ use crate::{
|
|||||||
|
|
||||||
/// System set for all rendering systems to ensure they run after gameplay logic
|
/// System set for all rendering systems to ensure they run after gameplay logic
|
||||||
#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
|
#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
|
||||||
pub struct RenderSet;
|
enum RenderSet {
|
||||||
|
Animation,
|
||||||
|
}
|
||||||
|
|
||||||
/// Core game state manager built on the Bevy ECS architecture.
|
/// Core game state manager built on the Bevy ECS architecture.
|
||||||
///
|
///
|
||||||
@@ -112,6 +108,8 @@ impl Game {
|
|||||||
let (player_animation, player_start_sprite) = Self::create_player_animations(&atlas)?;
|
let (player_animation, player_start_sprite) = Self::create_player_animations(&atlas)?;
|
||||||
let player_bundle = Self::create_player_bundle(&map, player_animation, player_start_sprite);
|
let player_bundle = Self::create_player_bundle(&map, player_animation, player_start_sprite);
|
||||||
|
|
||||||
|
let death_animation = Self::create_death_animation(&atlas)?;
|
||||||
|
|
||||||
let mut world = World::default();
|
let mut world = World::default();
|
||||||
let mut schedule = Schedule::default();
|
let mut schedule = Schedule::default();
|
||||||
|
|
||||||
@@ -127,6 +125,7 @@ impl Game {
|
|||||||
map_texture,
|
map_texture,
|
||||||
debug_texture,
|
debug_texture,
|
||||||
ttf_atlas,
|
ttf_atlas,
|
||||||
|
death_animation,
|
||||||
)?;
|
)?;
|
||||||
Self::configure_schedule(&mut schedule);
|
Self::configure_schedule(&mut schedule);
|
||||||
|
|
||||||
@@ -310,6 +309,18 @@ impl Game {
|
|||||||
Ok((player_animation, player_start_sprite))
|
Ok((player_animation, player_start_sprite))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn create_death_animation(atlas: &SpriteAtlas) -> GameResult<LinearAnimation> {
|
||||||
|
let mut death_tiles = Vec::new();
|
||||||
|
for i in 0..=10 {
|
||||||
|
// Assuming death animation has 11 frames named pacman/die_0, pacman/die_1, etc.
|
||||||
|
let tile = atlas.get_tile(&GameSprite::Pacman(PacmanSprite::Dying(i)).to_path())?;
|
||||||
|
death_tiles.push(tile);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tile_sequence = TileSequence::new(&death_tiles);
|
||||||
|
Ok(LinearAnimation::new(tile_sequence, 8)) // 8 ticks per frame, non-looping
|
||||||
|
}
|
||||||
|
|
||||||
fn create_player_bundle(map: &Map, player_animation: DirectionalAnimation, player_start_sprite: AtlasTile) -> PlayerBundle {
|
fn create_player_bundle(map: &Map, player_animation: DirectionalAnimation, player_start_sprite: AtlasTile) -> PlayerBundle {
|
||||||
PlayerBundle {
|
PlayerBundle {
|
||||||
player: PlayerControlled,
|
player: PlayerControlled,
|
||||||
@@ -361,13 +372,19 @@ impl Game {
|
|||||||
map_texture: sdl2::render::Texture,
|
map_texture: sdl2::render::Texture,
|
||||||
debug_texture: sdl2::render::Texture,
|
debug_texture: sdl2::render::Texture,
|
||||||
ttf_atlas: crate::texture::ttf::TtfAtlas,
|
ttf_atlas: crate::texture::ttf::TtfAtlas,
|
||||||
|
death_animation: LinearAnimation,
|
||||||
) -> GameResult<()> {
|
) -> GameResult<()> {
|
||||||
world.insert_non_send_resource(atlas);
|
world.insert_non_send_resource(atlas);
|
||||||
world.insert_resource(Self::create_ghost_animations(world.non_send_resource::<SpriteAtlas>())?);
|
world.insert_resource(Self::create_ghost_animations(world.non_send_resource::<SpriteAtlas>())?);
|
||||||
|
let player_animation = Self::create_player_animations(world.non_send_resource::<SpriteAtlas>())?.0;
|
||||||
|
world.insert_resource(PlayerAnimation(player_animation));
|
||||||
|
world.insert_resource(PlayerDeathAnimation(death_animation));
|
||||||
|
|
||||||
world.insert_resource(BatchedLinesResource::new(&map, constants::LARGE_SCALE));
|
world.insert_resource(BatchedLinesResource::new(&map, constants::LARGE_SCALE));
|
||||||
world.insert_resource(map);
|
world.insert_resource(map);
|
||||||
world.insert_resource(GlobalState { exit: false });
|
world.insert_resource(GlobalState { exit: false });
|
||||||
|
world.insert_resource(GameStage::default());
|
||||||
|
world.insert_resource(PlayerLives::default());
|
||||||
world.insert_resource(ScoreResource(0));
|
world.insert_resource(ScoreResource(0));
|
||||||
world.insert_resource(SystemTimings::default());
|
world.insert_resource(SystemTimings::default());
|
||||||
world.insert_resource(Timing::default());
|
world.insert_resource(Timing::default());
|
||||||
@@ -378,10 +395,9 @@ impl Game {
|
|||||||
world.insert_resource(AudioState::default());
|
world.insert_resource(AudioState::default());
|
||||||
world.insert_resource(CursorPosition::default());
|
world.insert_resource(CursorPosition::default());
|
||||||
world.insert_resource(TouchState::default());
|
world.insert_resource(TouchState::default());
|
||||||
world.insert_resource(StartupSequence::new(
|
world.insert_resource(GameStage::Starting(StartupSequence::TextOnly {
|
||||||
constants::startup::STARTUP_FRAMES,
|
remaining_ticks: constants::startup::STARTUP_FRAMES,
|
||||||
constants::startup::STARTUP_TICKS_PER_FRAME,
|
}));
|
||||||
));
|
|
||||||
|
|
||||||
world.insert_non_send_resource(event_pump);
|
world.insert_non_send_resource(event_pump);
|
||||||
world.insert_non_send_resource::<&mut Canvas<Window>>(Box::leak(Box::new(canvas)));
|
world.insert_non_send_resource::<&mut Canvas<Window>>(Box::leak(Box::new(canvas)));
|
||||||
@@ -394,15 +410,14 @@ impl Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn configure_schedule(schedule: &mut Schedule) {
|
fn configure_schedule(schedule: &mut Schedule) {
|
||||||
|
let stage_system = profile(SystemId::Stage, systems::stage_system);
|
||||||
let input_system = profile(SystemId::Input, systems::input::input_system);
|
let input_system = profile(SystemId::Input, systems::input::input_system);
|
||||||
let player_control_system = profile(SystemId::PlayerControls, systems::player_control_system);
|
let player_control_system = profile(SystemId::PlayerControls, systems::player_control_system);
|
||||||
let player_movement_system = profile(SystemId::PlayerMovement, systems::player_movement_system);
|
let player_movement_system = profile(SystemId::PlayerMovement, systems::player_movement_system);
|
||||||
let startup_stage_system = profile(SystemId::Stage, systems::startup_stage_system);
|
|
||||||
let player_tunnel_slowdown_system = profile(SystemId::PlayerMovement, systems::player::player_tunnel_slowdown_system);
|
let player_tunnel_slowdown_system = profile(SystemId::PlayerMovement, systems::player::player_tunnel_slowdown_system);
|
||||||
let ghost_movement_system = profile(SystemId::Ghost, ghost_movement_system);
|
let ghost_movement_system = profile(SystemId::Ghost, ghost_movement_system);
|
||||||
let collision_system = profile(SystemId::Collision, collision_system);
|
let collision_system = profile(SystemId::Collision, collision_system);
|
||||||
let ghost_collision_system = profile(SystemId::GhostCollision, ghost_collision_system);
|
let ghost_collision_system = profile(SystemId::GhostCollision, ghost_collision_system);
|
||||||
|
|
||||||
let item_system = profile(SystemId::Item, item_system);
|
let item_system = profile(SystemId::Item, item_system);
|
||||||
let audio_system = profile(SystemId::Audio, audio_system);
|
let audio_system = profile(SystemId::Audio, audio_system);
|
||||||
let blinking_system = profile(SystemId::Blinking, blinking_system);
|
let blinking_system = profile(SystemId::Blinking, blinking_system);
|
||||||
@@ -412,41 +427,55 @@ impl Game {
|
|||||||
let hud_render_system = profile(SystemId::HudRender, hud_render_system);
|
let hud_render_system = profile(SystemId::HudRender, hud_render_system);
|
||||||
let present_system = profile(SystemId::Present, present_system);
|
let present_system = profile(SystemId::Present, present_system);
|
||||||
let unified_ghost_state_system = profile(SystemId::GhostStateAnimation, ghost_state_system);
|
let unified_ghost_state_system = profile(SystemId::GhostStateAnimation, ghost_state_system);
|
||||||
|
// let death_sequence_system = profile(SystemId::DeathSequence, death_sequence_system);
|
||||||
|
// let game_over_system = profile(SystemId::GameOver, systems::game_over_system);
|
||||||
|
let eaten_ghost_system = profile(SystemId::EatenGhost, eaten_ghost_system);
|
||||||
|
|
||||||
let forced_dirty_system = |mut dirty: ResMut<RenderDirty>| {
|
let forced_dirty_system = |mut dirty: ResMut<RenderDirty>| {
|
||||||
dirty.0 = true;
|
dirty.0 = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
schedule.add_systems((
|
schedule.add_systems(
|
||||||
forced_dirty_system.run_if(resource_changed::<ScoreResource>.or(resource_changed::<StartupSequence>)),
|
forced_dirty_system
|
||||||
(
|
.run_if(|score: Res<ScoreResource>, stage: Res<GameStage>| score.is_changed() || stage.is_changed()),
|
||||||
input_system.run_if(|mut local: Local<u8>| {
|
);
|
||||||
*local = local.wrapping_add(1u8);
|
|
||||||
// run every nth frame
|
// Input system should always run to prevent SDL event pump from blocking
|
||||||
*local % 2 == 0
|
let input_systems = (
|
||||||
}),
|
input_system.run_if(|mut local: Local<u8>| {
|
||||||
player_control_system,
|
*local = local.wrapping_add(1u8);
|
||||||
player_movement_system,
|
// run every nth frame
|
||||||
startup_stage_system,
|
*local % 2 == 0
|
||||||
)
|
}),
|
||||||
.chain(),
|
player_control_system,
|
||||||
player_tunnel_slowdown_system,
|
)
|
||||||
ghost_movement_system,
|
.chain();
|
||||||
profile(SystemId::EatenGhost, eaten_ghost_system),
|
|
||||||
unified_ghost_state_system,
|
let gameplay_systems = (
|
||||||
|
(player_movement_system, player_tunnel_slowdown_system, ghost_movement_system).chain(),
|
||||||
|
eaten_ghost_system,
|
||||||
(collision_system, ghost_collision_system, item_system).chain(),
|
(collision_system, ghost_collision_system, item_system).chain(),
|
||||||
audio_system,
|
unified_ghost_state_system,
|
||||||
blinking_system,
|
)
|
||||||
|
.chain()
|
||||||
|
.run_if(|game_state: Res<GameStage>| matches!(*game_state, GameStage::Playing));
|
||||||
|
|
||||||
|
schedule.add_systems((blinking_system, directional_render_system, linear_render_system).in_set(RenderSet::Animation));
|
||||||
|
|
||||||
|
schedule.add_systems((
|
||||||
|
stage_system,
|
||||||
|
input_systems,
|
||||||
|
gameplay_systems,
|
||||||
(
|
(
|
||||||
directional_render_system,
|
|
||||||
linear_render_system,
|
|
||||||
dirty_render_system,
|
dirty_render_system,
|
||||||
combined_render_system,
|
combined_render_system,
|
||||||
hud_render_system,
|
hud_render_system,
|
||||||
touch_ui_render_system,
|
touch_ui_render_system,
|
||||||
present_system,
|
present_system,
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain()
|
||||||
|
.after(RenderSet::Animation),
|
||||||
|
audio_system,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,7 +541,7 @@ impl Game {
|
|||||||
for (ghost_type, start_node) in ghost_start_positions {
|
for (ghost_type, start_node) in ghost_start_positions {
|
||||||
// Create the ghost bundle in a separate scope to manage borrows
|
// Create the ghost bundle in a separate scope to manage borrows
|
||||||
let ghost = {
|
let ghost = {
|
||||||
let animations = *world.resource::<GhostAnimations>().get_normal(&ghost_type).unwrap();
|
let animations = world.resource::<GhostAnimations>().get_normal(&ghost_type).unwrap().clone();
|
||||||
let atlas = world.non_send_resource::<SpriteAtlas>();
|
let atlas = world.non_send_resource::<SpriteAtlas>();
|
||||||
let sprite_path = GameSprite::Ghost(GhostSprite::Normal(ghost_type, Direction::Left, 0)).to_path();
|
let sprite_path = GameSprite::Ghost(GhostSprite::Normal(ghost_type, Direction::Left, 0)).to_path();
|
||||||
|
|
||||||
@@ -557,7 +586,7 @@ impl Game {
|
|||||||
TileSequence::new(&[left_eye]),
|
TileSequence::new(&[left_eye]),
|
||||||
TileSequence::new(&[right_eye]),
|
TileSequence::new(&[right_eye]),
|
||||||
);
|
);
|
||||||
let eyes = DirectionalAnimation::new(eyes_tiles, eyes_tiles, animation::GHOST_EATEN_SPEED);
|
let eyes = DirectionalAnimation::new(eyes_tiles.clone(), eyes_tiles, animation::GHOST_EATEN_SPEED);
|
||||||
|
|
||||||
let mut animations = HashMap::new();
|
let mut animations = HashMap::new();
|
||||||
|
|
||||||
@@ -586,7 +615,7 @@ impl Game {
|
|||||||
TileSequence::new(&left_tiles),
|
TileSequence::new(&left_tiles),
|
||||||
TileSequence::new(&right_tiles),
|
TileSequence::new(&right_tiles),
|
||||||
);
|
);
|
||||||
let normal = DirectionalAnimation::new(normal_moving, normal_moving, animation::GHOST_NORMAL_SPEED);
|
let normal = DirectionalAnimation::new(normal_moving.clone(), normal_moving, animation::GHOST_NORMAL_SPEED);
|
||||||
|
|
||||||
animations.insert(ghost_type, normal);
|
animations.insert(ghost_type, normal);
|
||||||
}
|
}
|
||||||
@@ -658,68 +687,4 @@ impl Game {
|
|||||||
|
|
||||||
state.exit
|
state.exit
|
||||||
}
|
}
|
||||||
|
|
||||||
// /// Renders pathfinding debug lines from each ghost to Pac-Man.
|
|
||||||
// ///
|
|
||||||
// /// Each ghost's path is drawn in its respective color with a small offset
|
|
||||||
// /// to prevent overlapping lines.
|
|
||||||
// fn render_pathfinding_debug<T: sdl2::render::RenderTarget>(&self, canvas: &mut Canvas<T>) -> GameResult<()> {
|
|
||||||
// let pacman_node = self.state.pacman.current_node_id();
|
|
||||||
|
|
||||||
// for ghost in self.state.ghosts.iter() {
|
|
||||||
// if let Ok(path) = ghost.calculate_path_to_target(&self.state.map.graph, pacman_node) {
|
|
||||||
// if path.len() < 2 {
|
|
||||||
// continue; // Skip if path is too short
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Set the ghost's color
|
|
||||||
// canvas.set_draw_color(ghost.debug_color());
|
|
||||||
|
|
||||||
// // Calculate offset based on ghost index to prevent overlapping lines
|
|
||||||
// // let offset = (i as f32) * 2.0 - 3.0; // Offset range: -3.0 to 3.0
|
|
||||||
|
|
||||||
// // Calculate a consistent offset direction for the entire path
|
|
||||||
// // let first_node = self.map.graph.get_node(path[0]).unwrap();
|
|
||||||
// // let last_node = self.map.graph.get_node(path[path.len() - 1]).unwrap();
|
|
||||||
|
|
||||||
// // Use the overall direction from start to end to determine the perpendicular offset
|
|
||||||
// let offset = match ghost.ghost_type {
|
|
||||||
// GhostType::Blinky => glam::Vec2::new(0.25, 0.5),
|
|
||||||
// GhostType::Pinky => glam::Vec2::new(-0.25, -0.25),
|
|
||||||
// GhostType::Inky => glam::Vec2::new(0.5, -0.5),
|
|
||||||
// GhostType::Clyde => glam::Vec2::new(-0.5, 0.25),
|
|
||||||
// } * 5.0;
|
|
||||||
|
|
||||||
// // Calculate offset positions for all nodes using the same perpendicular direction
|
|
||||||
// let mut offset_positions = Vec::new();
|
|
||||||
// for &node_id in &path {
|
|
||||||
// let node = self
|
|
||||||
// .state
|
|
||||||
// .map
|
|
||||||
// .graph
|
|
||||||
// .get_node(node_id)
|
|
||||||
// .ok_or(crate::error::EntityError::NodeNotFound(node_id))?;
|
|
||||||
// let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
|
|
||||||
// offset_positions.push(pos + offset);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Draw lines between the offset positions
|
|
||||||
// for window in offset_positions.windows(2) {
|
|
||||||
// if let (Some(from), Some(to)) = (window.first(), window.get(1)) {
|
|
||||||
// // Skip if the distance is too far (used for preventing lines between tunnel portals)
|
|
||||||
// if from.distance_squared(*to) > (crate::constants::CELL_SIZE * 16).pow(2) as f32 {
|
|
||||||
// continue;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Draw the line
|
|
||||||
// canvas
|
|
||||||
// .draw_line((from.x as i32, from.y as i32), (to.x as i32, to.y as i32))
|
|
||||||
// .map_err(|e| crate::error::GameError::Sdl(e.to_string()))?;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Ok(())
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
use bevy_ecs::component::Component;
|
use bevy_ecs::{
|
||||||
use bevy_ecs::entity::Entity;
|
component::Component,
|
||||||
use bevy_ecs::event::{EventReader, EventWriter};
|
entity::Entity,
|
||||||
use bevy_ecs::query::With;
|
event::{EventReader, EventWriter},
|
||||||
use bevy_ecs::system::{Query, Res, ResMut};
|
query::With,
|
||||||
|
system::{Commands, Query, Res, ResMut},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::error::GameError;
|
use crate::error::GameError;
|
||||||
use crate::events::GameEvent;
|
use crate::events::GameEvent;
|
||||||
use crate::map::builder::Map;
|
use crate::map::builder::Map;
|
||||||
use crate::systems::movement::Position;
|
use crate::systems::{
|
||||||
use crate::systems::{AudioEvent, Ghost, GhostState, PlayerControlled, ScoreResource};
|
components::GhostState, movement::Position, AudioEvent, DyingSequence, Frozen, GameStage, Ghost, PlayerControlled,
|
||||||
|
ScoreResource,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A component for defining the collision area of an entity.
|
||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
pub struct Collider {
|
pub struct Collider {
|
||||||
pub size: f32,
|
pub size: f32,
|
||||||
@@ -62,6 +67,7 @@ pub fn check_collision(
|
|||||||
///
|
///
|
||||||
/// Also detects collisions between Pac-Man and ghosts for gameplay mechanics like
|
/// Also detects collisions between Pac-Man and ghosts for gameplay mechanics like
|
||||||
/// power pellet effects, ghost eating, and player death.
|
/// power pellet effects, ghost eating, and player death.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn collision_system(
|
pub fn collision_system(
|
||||||
map: Res<Map>,
|
map: Res<Map>,
|
||||||
pacman_query: Query<(Entity, &Position, &Collider), With<PacmanCollider>>,
|
pacman_query: Query<(Entity, &Position, &Collider), With<PacmanCollider>>,
|
||||||
@@ -107,10 +113,13 @@ pub fn collision_system(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn ghost_collision_system(
|
pub fn ghost_collision_system(
|
||||||
|
mut commands: Commands,
|
||||||
mut collision_events: EventReader<GameEvent>,
|
mut collision_events: EventReader<GameEvent>,
|
||||||
mut score: ResMut<ScoreResource>,
|
mut score: ResMut<ScoreResource>,
|
||||||
pacman_query: Query<(), With<PlayerControlled>>,
|
mut game_state: ResMut<GameStage>,
|
||||||
|
pacman_query: Query<Entity, With<PlayerControlled>>,
|
||||||
ghost_query: Query<(Entity, &Ghost), With<GhostCollider>>,
|
ghost_query: Query<(Entity, &Ghost), With<GhostCollider>>,
|
||||||
mut ghost_state_query: Query<&mut GhostState>,
|
mut ghost_state_query: Query<&mut GhostState>,
|
||||||
mut events: EventWriter<AudioEvent>,
|
mut events: EventWriter<AudioEvent>,
|
||||||
@@ -118,7 +127,7 @@ pub fn ghost_collision_system(
|
|||||||
for event in collision_events.read() {
|
for event in collision_events.read() {
|
||||||
if let GameEvent::Collision(entity1, entity2) = event {
|
if let GameEvent::Collision(entity1, entity2) = event {
|
||||||
// Check if one is Pacman and the other is a ghost
|
// Check if one is Pacman and the other is a ghost
|
||||||
let (_pacman_entity, ghost_entity) = if pacman_query.get(*entity1).is_ok() && ghost_query.get(*entity2).is_ok() {
|
let (pacman_entity, ghost_entity) = if pacman_query.get(*entity1).is_ok() && ghost_query.get(*entity2).is_ok() {
|
||||||
(*entity1, *entity2)
|
(*entity1, *entity2)
|
||||||
} else if pacman_query.get(*entity2).is_ok() && ghost_query.get(*entity1).is_ok() {
|
} else if pacman_query.get(*entity2).is_ok() && ghost_query.get(*entity1).is_ok() {
|
||||||
(*entity2, *entity1)
|
(*entity2, *entity1)
|
||||||
@@ -140,8 +149,12 @@ pub fn ghost_collision_system(
|
|||||||
|
|
||||||
// Play eat sound
|
// Play eat sound
|
||||||
events.write(AudioEvent::PlayEat);
|
events.write(AudioEvent::PlayEat);
|
||||||
} else {
|
} else if matches!(*ghost_state, GhostState::Normal) {
|
||||||
// Pac-Man dies (this would need a death system)
|
// Pac-Man dies
|
||||||
|
*game_state = GameStage::PlayerDying(DyingSequence::Frozen { remaining_ticks: 60 });
|
||||||
|
commands.entity(pacman_entity).insert(Frozen);
|
||||||
|
commands.entity(ghost_entity).insert(Frozen);
|
||||||
|
events.write(AudioEvent::StopAll);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ pub struct Renderable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Directional animation component with shared timing across all directions
|
/// Directional animation component with shared timing across all directions
|
||||||
#[derive(Component, Clone, Copy)]
|
#[derive(Component, Clone)]
|
||||||
pub struct DirectionalAnimation {
|
pub struct DirectionalAnimation {
|
||||||
pub moving_tiles: DirectionalTiles,
|
pub moving_tiles: DirectionalTiles,
|
||||||
pub stopped_tiles: DirectionalTiles,
|
pub stopped_tiles: DirectionalTiles,
|
||||||
@@ -123,13 +123,18 @@ impl DirectionalAnimation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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)
|
/// Linear animation component for non-directional animations (frightened ghosts)
|
||||||
#[derive(Component, Clone, Copy)]
|
#[derive(Component, Resource, Clone)]
|
||||||
pub struct LinearAnimation {
|
pub struct LinearAnimation {
|
||||||
pub tiles: TileSequence,
|
pub tiles: TileSequence,
|
||||||
pub current_frame: usize,
|
pub current_frame: usize,
|
||||||
pub time_bank: u16,
|
pub time_bank: u16,
|
||||||
pub frame_duration: u16,
|
pub frame_duration: u16,
|
||||||
|
pub finished: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LinearAnimation {
|
impl LinearAnimation {
|
||||||
@@ -140,6 +145,7 @@ impl LinearAnimation {
|
|||||||
current_frame: 0,
|
current_frame: 0,
|
||||||
time_bank: 0,
|
time_bank: 0,
|
||||||
frame_duration,
|
frame_duration,
|
||||||
|
finished: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,6 +224,11 @@ pub struct Frozen;
|
|||||||
#[derive(Component, Debug, Clone, Copy)]
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
pub struct Eaten;
|
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)]
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
pub enum GhostState {
|
pub enum GhostState {
|
||||||
/// Normal ghost behavior - chasing Pac-Man
|
/// Normal ghost behavior - chasing Pac-Man
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use crate::platform;
|
use crate::platform;
|
||||||
use crate::systems::components::{DirectionalAnimation, Frozen, GhostAnimation, GhostState, LastAnimationState, LinearAnimation};
|
use crate::systems::components::{
|
||||||
|
DirectionalAnimation, Frozen, GhostAnimation, GhostState, LastAnimationState, LinearAnimation, Looping,
|
||||||
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
map::{
|
map::{
|
||||||
builder::Map,
|
builder::Map,
|
||||||
@@ -194,22 +196,26 @@ pub fn ghost_state_system(
|
|||||||
if last_animation_state.0 != current_animation_state {
|
if last_animation_state.0 != current_animation_state {
|
||||||
match current_animation_state {
|
match current_animation_state {
|
||||||
GhostAnimation::Frightened { flash } => {
|
GhostAnimation::Frightened { flash } => {
|
||||||
// Remove DirectionalAnimation, add LinearAnimation
|
// Remove DirectionalAnimation, add LinearAnimation with Looping component
|
||||||
commands
|
commands
|
||||||
.entity(entity)
|
.entity(entity)
|
||||||
.remove::<DirectionalAnimation>()
|
.remove::<DirectionalAnimation>()
|
||||||
.insert(*animations.frightened(flash));
|
.insert(animations.frightened(flash).clone())
|
||||||
|
.insert(Looping);
|
||||||
}
|
}
|
||||||
GhostAnimation::Normal => {
|
GhostAnimation::Normal => {
|
||||||
// Remove LinearAnimation, add DirectionalAnimation
|
// Remove LinearAnimation and Looping, add DirectionalAnimation
|
||||||
commands
|
commands
|
||||||
.entity(entity)
|
.entity(entity)
|
||||||
.remove::<LinearAnimation>()
|
.remove::<(LinearAnimation, Looping)>()
|
||||||
.insert(*animations.get_normal(ghost_type).unwrap());
|
.insert(animations.get_normal(ghost_type).unwrap().clone());
|
||||||
}
|
}
|
||||||
GhostAnimation::Eyes => {
|
GhostAnimation::Eyes => {
|
||||||
// Remove LinearAnimation, add DirectionalAnimation (eyes animation)
|
// Remove LinearAnimation and Looping, add DirectionalAnimation (eyes animation)
|
||||||
commands.entity(entity).remove::<LinearAnimation>().insert(*animations.eyes());
|
commands
|
||||||
|
.entity(entity)
|
||||||
|
.remove::<(LinearAnimation, Looping)>()
|
||||||
|
.insert(animations.eyes().clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
last_animation_state.0 = current_animation_state;
|
last_animation_state.0 = current_animation_state;
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
//! The Entity-Component-System (ECS) module.
|
//! This module contains all the systems in the game.
|
||||||
//!
|
|
||||||
//! This module contains all the ECS-related logic, including components, systems,
|
|
||||||
//! and resources.
|
|
||||||
|
|
||||||
#[cfg_attr(coverage_nightly, coverage(off))]
|
#[cfg_attr(coverage_nightly, coverage(off))]
|
||||||
pub mod audio;
|
pub mod audio;
|
||||||
|
#[cfg_attr(coverage_nightly, coverage(off))]
|
||||||
|
pub mod debug;
|
||||||
|
#[cfg_attr(coverage_nightly, coverage(off))]
|
||||||
|
pub mod profiling;
|
||||||
|
#[cfg_attr(coverage_nightly, coverage(off))]
|
||||||
|
pub mod render;
|
||||||
|
|
||||||
pub mod blinking;
|
pub mod blinking;
|
||||||
pub mod collision;
|
pub mod collision;
|
||||||
pub mod components;
|
pub mod components;
|
||||||
#[cfg_attr(coverage_nightly, coverage(off))]
|
|
||||||
pub mod debug;
|
|
||||||
pub mod ghost;
|
pub mod ghost;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
pub mod item;
|
pub mod item;
|
||||||
pub mod movement;
|
pub mod movement;
|
||||||
pub mod player;
|
pub mod player;
|
||||||
#[cfg_attr(coverage_nightly, coverage(off))]
|
pub mod state;
|
||||||
pub mod profiling;
|
|
||||||
#[cfg_attr(coverage_nightly, coverage(off))]
|
// Re-export all the modules. Do not fine-tune the exports.
|
||||||
pub mod render;
|
|
||||||
pub mod stage;
|
|
||||||
|
|
||||||
pub use self::audio::*;
|
pub use self::audio::*;
|
||||||
pub use self::blinking::*;
|
pub use self::blinking::*;
|
||||||
@@ -33,4 +33,4 @@ pub use self::movement::*;
|
|||||||
pub use self::player::*;
|
pub use self::player::*;
|
||||||
pub use self::profiling::*;
|
pub use self::profiling::*;
|
||||||
pub use self::render::*;
|
pub use self::render::*;
|
||||||
pub use self::stage::*;
|
pub use self::state::*;
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
use crate::constants::CANVAS_SIZE;
|
|
||||||
use crate::error::{GameError, TextureError};
|
|
||||||
use crate::map::builder::Map;
|
use crate::map::builder::Map;
|
||||||
use crate::systems::input::TouchState;
|
use crate::systems::input::TouchState;
|
||||||
use crate::systems::{
|
use crate::systems::{
|
||||||
debug_render_system, BatchedLinesResource, Collider, CursorPosition, DebugState, DebugTextureResource, DeltaTime,
|
debug_render_system, BatchedLinesResource, Collider, CursorPosition, DebugState, DebugTextureResource, DeltaTime,
|
||||||
DirectionalAnimation, LinearAnimation, Position, Renderable, ScoreResource, StartupSequence, SystemId, SystemTimings,
|
DirectionalAnimation, Dying, Frozen, GameStage, LinearAnimation, Looping, Position, Renderable, ScoreResource,
|
||||||
TtfAtlasResource, Velocity,
|
StartupSequence, SystemId, SystemTimings, TtfAtlasResource, Velocity,
|
||||||
};
|
};
|
||||||
use crate::texture::sprite::SpriteAtlas;
|
use crate::texture::sprite::SpriteAtlas;
|
||||||
use crate::texture::text::TextTexture;
|
use crate::texture::text::TextTexture;
|
||||||
|
use crate::{
|
||||||
|
constants::CANVAS_SIZE,
|
||||||
|
error::{GameError, TextureError},
|
||||||
|
};
|
||||||
use bevy_ecs::component::Component;
|
use bevy_ecs::component::Component;
|
||||||
use bevy_ecs::entity::Entity;
|
use bevy_ecs::entity::Entity;
|
||||||
use bevy_ecs::event::EventWriter;
|
use bevy_ecs::event::EventWriter;
|
||||||
use bevy_ecs::query::{Changed, Or, Without};
|
use bevy_ecs::query::{Changed, Has, Or, With, Without};
|
||||||
use bevy_ecs::removal_detection::RemovedComponents;
|
use bevy_ecs::removal_detection::RemovedComponents;
|
||||||
use bevy_ecs::resource::Resource;
|
use bevy_ecs::resource::Resource;
|
||||||
use bevy_ecs::system::{NonSendMut, Query, Res, ResMut};
|
use bevy_ecs::system::{NonSendMut, Query, Res, ResMut};
|
||||||
@@ -53,7 +55,7 @@ pub fn dirty_render_system(
|
|||||||
/// All directions share the same frame timing to ensure perfect synchronization.
|
/// All directions share the same frame timing to ensure perfect synchronization.
|
||||||
pub fn directional_render_system(
|
pub fn directional_render_system(
|
||||||
dt: Res<DeltaTime>,
|
dt: Res<DeltaTime>,
|
||||||
mut query: Query<(&Position, &Velocity, &mut DirectionalAnimation, &mut Renderable)>,
|
mut query: Query<(&Position, &Velocity, &mut DirectionalAnimation, &mut Renderable), Without<Frozen>>,
|
||||||
) {
|
) {
|
||||||
let ticks = (dt.seconds * 60.0).round() as u16; // Convert from seconds to ticks at 60 ticks/sec
|
let ticks = (dt.seconds * 60.0).round() as u16; // Convert from seconds to ticks at 60 ticks/sec
|
||||||
|
|
||||||
@@ -86,26 +88,35 @@ pub fn directional_render_system(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates linear animated entities (used for non-directional animations like frightened ghosts).
|
/// System that updates `Renderable` sprites for entities with `LinearAnimation`.
|
||||||
///
|
#[allow(clippy::type_complexity)]
|
||||||
/// This system handles entities that use LinearAnimation component for simple frame cycling.
|
pub fn linear_render_system(
|
||||||
pub fn linear_render_system(dt: Res<DeltaTime>, mut query: Query<(&mut LinearAnimation, &mut Renderable)>) {
|
dt: Res<DeltaTime>,
|
||||||
let ticks = (dt.seconds * 60.0).round() as u16; // Convert from seconds to ticks at 60 ticks/sec
|
mut query: Query<(&mut LinearAnimation, &mut Renderable, Has<Looping>), Or<(Without<Frozen>, With<Dying>)>>,
|
||||||
|
) {
|
||||||
for (mut anim, mut renderable) in query.iter_mut() {
|
for (mut anim, mut renderable, looping) in query.iter_mut() {
|
||||||
// Tick animation
|
if anim.finished {
|
||||||
anim.time_bank += ticks;
|
continue;
|
||||||
while anim.time_bank >= anim.frame_duration {
|
|
||||||
anim.time_bank -= anim.frame_duration;
|
|
||||||
anim.current_frame += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !anim.tiles.is_empty() {
|
anim.time_bank += dt.ticks as u16;
|
||||||
let new_tile = anim.tiles.get_tile(anim.current_frame);
|
let frames_to_advance = (anim.time_bank / anim.frame_duration) as usize;
|
||||||
if renderable.sprite != new_tile {
|
|
||||||
renderable.sprite = new_tile;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +205,7 @@ pub fn hud_render_system(
|
|||||||
mut canvas: NonSendMut<&mut Canvas<Window>>,
|
mut canvas: NonSendMut<&mut Canvas<Window>>,
|
||||||
mut atlas: NonSendMut<SpriteAtlas>,
|
mut atlas: NonSendMut<SpriteAtlas>,
|
||||||
score: Res<ScoreResource>,
|
score: Res<ScoreResource>,
|
||||||
startup: Res<StartupSequence>,
|
stage: Res<GameStage>,
|
||||||
mut errors: EventWriter<GameError>,
|
mut errors: EventWriter<GameError>,
|
||||||
) {
|
) {
|
||||||
let _ = canvas.with_texture_canvas(&mut backbuffer.0, |canvas| {
|
let _ = canvas.with_texture_canvas(&mut backbuffer.0, |canvas| {
|
||||||
@@ -226,10 +237,21 @@ pub fn hud_render_system(
|
|||||||
errors.write(TextureError::RenderFailed(format!("Failed to render high score text: {}", e)).into());
|
errors.write(TextureError::RenderFailed(format!("Failed to render high score text: {}", e)).into());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render GAME OVER text
|
||||||
|
if matches!(*stage, GameStage::GameOver) {
|
||||||
|
let game_over_text = "GAME OVER";
|
||||||
|
let game_over_width = text_renderer.text_width(game_over_text);
|
||||||
|
let game_over_position = glam::UVec2::new((CANVAS_SIZE.x - game_over_width) / 2, 160);
|
||||||
|
if let Err(e) = text_renderer.render_with_color(canvas, &mut atlas, game_over_text, game_over_position, Color::RED) {
|
||||||
|
errors.write(TextureError::RenderFailed(format!("Failed to render GAME OVER text: {}", e)).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Render text based on StartupSequence stage
|
// Render text based on StartupSequence stage
|
||||||
if matches!(
|
if matches!(
|
||||||
*startup,
|
*stage,
|
||||||
StartupSequence::TextOnly { .. } | StartupSequence::CharactersVisible { .. }
|
GameStage::Starting(StartupSequence::TextOnly { .. })
|
||||||
|
| GameStage::Starting(StartupSequence::CharactersVisible { .. })
|
||||||
) {
|
) {
|
||||||
let ready_text = "READY!";
|
let ready_text = "READY!";
|
||||||
let ready_width = text_renderer.text_width(ready_text);
|
let ready_width = text_renderer.text_width(ready_text);
|
||||||
@@ -238,7 +260,7 @@ pub fn hud_render_system(
|
|||||||
errors.write(TextureError::RenderFailed(format!("Failed to render READY text: {}", e)).into());
|
errors.write(TextureError::RenderFailed(format!("Failed to render READY text: {}", e)).into());
|
||||||
}
|
}
|
||||||
|
|
||||||
if matches!(*startup, StartupSequence::TextOnly { .. }) {
|
if matches!(*stage, GameStage::Starting(StartupSequence::TextOnly { .. })) {
|
||||||
let player_one_text = "PLAYER ONE";
|
let player_one_text = "PLAYER ONE";
|
||||||
let player_one_width = text_renderer.text_width(player_one_text);
|
let player_one_width = text_renderer.text_width(player_one_text);
|
||||||
let player_one_position = glam::UVec2::new((CANVAS_SIZE.x - player_one_width) / 2, 113);
|
let player_one_position = glam::UVec2::new((CANVAS_SIZE.x - player_one_width) / 2, 113);
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
use bevy_ecs::{
|
|
||||||
entity::Entity,
|
|
||||||
query::With,
|
|
||||||
resource::Resource,
|
|
||||||
system::{Commands, Query, ResMut},
|
|
||||||
};
|
|
||||||
use tracing::debug;
|
|
||||||
|
|
||||||
use crate::systems::{Blinking, Frozen, GhostCollider, Hidden, PlayerControlled};
|
|
||||||
|
|
||||||
#[derive(Resource, Debug, Clone, Copy)]
|
|
||||||
pub enum StartupSequence {
|
|
||||||
/// Stage 1: Text-only stage
|
|
||||||
/// - Player & ghosts are hidden
|
|
||||||
/// - READY! and PLAYER ONE text are shown
|
|
||||||
/// - Energizers do not blink
|
|
||||||
TextOnly {
|
|
||||||
/// Remaining ticks in this stage
|
|
||||||
remaining_ticks: u32,
|
|
||||||
},
|
|
||||||
/// Stage 2: Characters visible stage
|
|
||||||
/// - PLAYER ONE text is hidden, READY! text remains
|
|
||||||
/// - Ghosts and Pac-Man are now shown
|
|
||||||
CharactersVisible {
|
|
||||||
/// Remaining ticks in this stage
|
|
||||||
remaining_ticks: u32,
|
|
||||||
},
|
|
||||||
/// Stage 3: Game begins
|
|
||||||
/// - Final state, game is fully active
|
|
||||||
GameActive,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StartupSequence {
|
|
||||||
/// Creates a new StartupSequence with the specified duration in ticks
|
|
||||||
pub fn new(text_only_ticks: u32, _characters_visible_ticks: u32) -> Self {
|
|
||||||
Self::TextOnly {
|
|
||||||
remaining_ticks: text_only_ticks,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ticks the timer by one frame, returning transition information if state changes
|
|
||||||
pub fn tick(&mut self) -> Option<(StartupSequence, StartupSequence)> {
|
|
||||||
match self {
|
|
||||||
StartupSequence::TextOnly { remaining_ticks } => {
|
|
||||||
if *remaining_ticks > 0 {
|
|
||||||
*remaining_ticks -= 1;
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
let from = *self;
|
|
||||||
*self = StartupSequence::CharactersVisible {
|
|
||||||
remaining_ticks: 60, // 1 second at 60 FPS
|
|
||||||
};
|
|
||||||
Some((from, *self))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
StartupSequence::CharactersVisible { remaining_ticks } => {
|
|
||||||
if *remaining_ticks > 0 {
|
|
||||||
*remaining_ticks -= 1;
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
let from = *self;
|
|
||||||
*self = StartupSequence::GameActive;
|
|
||||||
Some((from, *self))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
StartupSequence::GameActive => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handles startup sequence transitions and component management
|
|
||||||
pub fn startup_stage_system(
|
|
||||||
mut startup: ResMut<StartupSequence>,
|
|
||||||
mut commands: Commands,
|
|
||||||
mut blinking_query: Query<Entity, With<Blinking>>,
|
|
||||||
mut player_query: Query<Entity, With<PlayerControlled>>,
|
|
||||||
mut ghost_query: Query<Entity, With<GhostCollider>>,
|
|
||||||
) {
|
|
||||||
if let Some((from, to)) = startup.tick() {
|
|
||||||
debug!("StartupSequence transition from {from:?} to {to:?}");
|
|
||||||
match (from, to) {
|
|
||||||
(StartupSequence::TextOnly { .. }, StartupSequence::CharactersVisible { .. }) => {
|
|
||||||
// Unhide the player & ghosts
|
|
||||||
for entity in player_query.iter_mut().chain(ghost_query.iter_mut()) {
|
|
||||||
commands.entity(entity).remove::<Hidden>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(StartupSequence::CharactersVisible { .. }, StartupSequence::GameActive) => {
|
|
||||||
// Unfreeze the player & ghosts & pellet blinking
|
|
||||||
for entity in player_query
|
|
||||||
.iter_mut()
|
|
||||||
.chain(ghost_query.iter_mut())
|
|
||||||
.chain(blinking_query.iter_mut())
|
|
||||||
{
|
|
||||||
commands.entity(entity).remove::<Frozen>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
315
src/systems/state.rs
Normal file
315
src/systems/state.rs
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
use std::mem::discriminant;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
map::builder::Map,
|
||||||
|
systems::{
|
||||||
|
AudioEvent, Blinking, DirectionalAnimation, Dying, Eaten, Frozen, Ghost, GhostCollider, GhostState, Hidden,
|
||||||
|
LinearAnimation, Looping, PlayerControlled, Position,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use bevy_ecs::{
|
||||||
|
entity::Entity,
|
||||||
|
event::EventWriter,
|
||||||
|
query::{With, Without},
|
||||||
|
resource::Resource,
|
||||||
|
system::{Commands, Query, Res, ResMut},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Resource, Clone)]
|
||||||
|
pub struct PlayerAnimation(pub DirectionalAnimation);
|
||||||
|
|
||||||
|
#[derive(Resource, Clone)]
|
||||||
|
pub struct PlayerDeathAnimation(pub LinearAnimation);
|
||||||
|
|
||||||
|
/// A resource to track the overall stage of the game from a high-level perspective.
|
||||||
|
#[derive(Resource, Debug, PartialEq, Eq, Clone, Copy)]
|
||||||
|
pub enum GameStage {
|
||||||
|
Starting(StartupSequence),
|
||||||
|
/// The main gameplay loop is active.
|
||||||
|
Playing,
|
||||||
|
/// The player has died and the death sequence is in progress.
|
||||||
|
PlayerDying(DyingSequence),
|
||||||
|
/// The level is restarting after a death.
|
||||||
|
LevelRestarting,
|
||||||
|
/// The game has ended.
|
||||||
|
GameOver,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A resource that manages the multi-stage startup sequence of the game.
|
||||||
|
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||||
|
pub enum StartupSequence {
|
||||||
|
/// Stage 1: Text-only stage
|
||||||
|
/// - Player & ghosts are hidden
|
||||||
|
/// - READY! and PLAYER ONE text are shown
|
||||||
|
/// - Energizers do not blink
|
||||||
|
TextOnly {
|
||||||
|
/// Remaining ticks in this stage
|
||||||
|
remaining_ticks: u32,
|
||||||
|
},
|
||||||
|
/// Stage 2: Characters visible stage
|
||||||
|
/// - PLAYER ONE text is hidden, READY! text remains
|
||||||
|
/// - Ghosts and Pac-Man are now shown
|
||||||
|
CharactersVisible {
|
||||||
|
/// Remaining ticks in this stage
|
||||||
|
remaining_ticks: u32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GameStage {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Playing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The state machine for the multi-stage death sequence.
|
||||||
|
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||||
|
pub enum DyingSequence {
|
||||||
|
/// Initial stage: entities are frozen, waiting for a delay.
|
||||||
|
Frozen { remaining_ticks: u32 },
|
||||||
|
/// Second stage: Pac-Man's death animation is playing.
|
||||||
|
Animating { remaining_ticks: u32 },
|
||||||
|
/// Third stage: Pac-Man is now gone, waiting a moment before the level restarts.
|
||||||
|
Hidden { remaining_ticks: u32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A resource to store the number of player lives.
|
||||||
|
#[derive(Resource, Debug)]
|
||||||
|
pub struct PlayerLives(pub u8);
|
||||||
|
|
||||||
|
impl Default for PlayerLives {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles startup sequence transitions and component management
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
pub fn stage_system(
|
||||||
|
mut game_state: ResMut<GameStage>,
|
||||||
|
player_death_animation: Res<PlayerDeathAnimation>,
|
||||||
|
player_animation: Res<PlayerAnimation>,
|
||||||
|
mut player_lives: ResMut<PlayerLives>,
|
||||||
|
map: Res<Map>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut audio_events: EventWriter<AudioEvent>,
|
||||||
|
mut blinking_query: Query<Entity, With<Blinking>>,
|
||||||
|
mut player_query: Query<(Entity, &mut Position), With<PlayerControlled>>,
|
||||||
|
mut ghost_query: Query<(Entity, &Ghost, &mut Position), (With<GhostCollider>, Without<PlayerControlled>)>,
|
||||||
|
) {
|
||||||
|
let old_state = *game_state;
|
||||||
|
let new_state: GameStage = match &mut *game_state {
|
||||||
|
GameStage::Starting(startup) => match startup {
|
||||||
|
StartupSequence::TextOnly { remaining_ticks } => {
|
||||||
|
if *remaining_ticks > 0 {
|
||||||
|
GameStage::Starting(StartupSequence::TextOnly {
|
||||||
|
remaining_ticks: *remaining_ticks - 1,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StartupSequence::CharactersVisible { remaining_ticks } => {
|
||||||
|
if *remaining_ticks > 0 {
|
||||||
|
GameStage::Starting(StartupSequence::CharactersVisible {
|
||||||
|
remaining_ticks: *remaining_ticks - 1,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
GameStage::Playing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
GameStage::Playing => GameStage::Playing,
|
||||||
|
GameStage::PlayerDying(dying) => match dying {
|
||||||
|
DyingSequence::Frozen { remaining_ticks } => {
|
||||||
|
if *remaining_ticks > 0 {
|
||||||
|
GameStage::PlayerDying(DyingSequence::Frozen {
|
||||||
|
remaining_ticks: *remaining_ticks - 1,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
let death_animation = &player_death_animation.0;
|
||||||
|
let remaining_ticks = (death_animation.tiles.len() * death_animation.frame_duration as usize) as u32;
|
||||||
|
GameStage::PlayerDying(DyingSequence::Animating { remaining_ticks })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DyingSequence::Animating { remaining_ticks } => {
|
||||||
|
if *remaining_ticks > 0 {
|
||||||
|
GameStage::PlayerDying(DyingSequence::Animating {
|
||||||
|
remaining_ticks: *remaining_ticks - 1,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
GameStage::PlayerDying(DyingSequence::Hidden { remaining_ticks: 60 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DyingSequence::Hidden { remaining_ticks } => {
|
||||||
|
if *remaining_ticks > 0 {
|
||||||
|
GameStage::PlayerDying(DyingSequence::Hidden {
|
||||||
|
remaining_ticks: *remaining_ticks - 1,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
player_lives.0 = player_lives.0.saturating_sub(1);
|
||||||
|
|
||||||
|
if player_lives.0 > 0 {
|
||||||
|
GameStage::LevelRestarting
|
||||||
|
} else {
|
||||||
|
GameStage::GameOver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
GameStage::LevelRestarting => GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 }),
|
||||||
|
GameStage::GameOver => GameStage::GameOver,
|
||||||
|
};
|
||||||
|
|
||||||
|
if old_state == new_state {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match (old_state, new_state) {
|
||||||
|
(GameStage::Playing, GameStage::PlayerDying(DyingSequence::Frozen { .. })) => {
|
||||||
|
// Freeze the player & ghosts
|
||||||
|
for entity in player_query
|
||||||
|
.iter_mut()
|
||||||
|
.map(|(e, _)| e)
|
||||||
|
.chain(ghost_query.iter_mut().map(|(e, _, _)| e))
|
||||||
|
{
|
||||||
|
commands.entity(entity).insert(Frozen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(GameStage::PlayerDying(DyingSequence::Frozen { .. }), GameStage::PlayerDying(DyingSequence::Animating { .. })) => {
|
||||||
|
// Hide the ghosts
|
||||||
|
for (entity, _, _) in ghost_query.iter_mut() {
|
||||||
|
commands.entity(entity).insert(Hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start Pac-Man's death animation
|
||||||
|
if let Ok((player_entity, _)) = player_query.single_mut() {
|
||||||
|
commands
|
||||||
|
.entity(player_entity)
|
||||||
|
.insert((Dying, player_death_animation.0.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play the death sound
|
||||||
|
audio_events.write(AudioEvent::PlayDeath);
|
||||||
|
}
|
||||||
|
(GameStage::PlayerDying(DyingSequence::Animating { .. }), GameStage::PlayerDying(DyingSequence::Hidden { .. })) => {
|
||||||
|
// Hide the player
|
||||||
|
if let Ok((player_entity, _)) = player_query.single_mut() {
|
||||||
|
commands.entity(player_entity).insert(Hidden);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(_, GameStage::LevelRestarting) => {
|
||||||
|
if let Ok((player_entity, mut pos)) = player_query.single_mut() {
|
||||||
|
*pos = Position::Stopped {
|
||||||
|
node: map.start_positions.pacman,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Freeze the blinking, force them to be visible (if they were hidden by blinking)
|
||||||
|
for entity in blinking_query.iter_mut() {
|
||||||
|
commands.entity(entity).insert(Frozen).remove::<Hidden>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the player animation
|
||||||
|
commands
|
||||||
|
.entity(player_entity)
|
||||||
|
.remove::<(Frozen, Dying, Hidden, LinearAnimation, Looping)>()
|
||||||
|
.insert(player_animation.0.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset ghost positions and state
|
||||||
|
for (ghost_entity, ghost, mut ghost_pos) in ghost_query.iter_mut() {
|
||||||
|
*ghost_pos = Position::Stopped {
|
||||||
|
node: match ghost {
|
||||||
|
Ghost::Blinky => map.start_positions.blinky,
|
||||||
|
Ghost::Pinky => map.start_positions.pinky,
|
||||||
|
Ghost::Inky => map.start_positions.inky,
|
||||||
|
Ghost::Clyde => map.start_positions.clyde,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
commands
|
||||||
|
.entity(ghost_entity)
|
||||||
|
.remove::<(Frozen, Hidden, Eaten)>()
|
||||||
|
.insert(GhostState::Normal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(
|
||||||
|
GameStage::Starting(StartupSequence::TextOnly { .. }),
|
||||||
|
GameStage::Starting(StartupSequence::CharactersVisible { .. }),
|
||||||
|
) => {
|
||||||
|
// Unhide the player & ghosts
|
||||||
|
for entity in player_query
|
||||||
|
.iter_mut()
|
||||||
|
.map(|(e, _)| e)
|
||||||
|
.chain(ghost_query.iter_mut().map(|(e, _, _)| e))
|
||||||
|
{
|
||||||
|
commands.entity(entity).remove::<Hidden>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(GameStage::Starting(StartupSequence::CharactersVisible { .. }), GameStage::Playing) => {
|
||||||
|
// Unfreeze the player & ghosts & blinking
|
||||||
|
for entity in player_query
|
||||||
|
.iter_mut()
|
||||||
|
.map(|(e, _)| e)
|
||||||
|
.chain(ghost_query.iter_mut().map(|(e, _, _)| e))
|
||||||
|
.chain(blinking_query.iter_mut())
|
||||||
|
{
|
||||||
|
commands.entity(entity).remove::<Frozen>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(GameStage::PlayerDying(..), GameStage::GameOver) => {
|
||||||
|
// Freeze blinking
|
||||||
|
for entity in blinking_query.iter_mut() {
|
||||||
|
commands.entity(entity).insert(Frozen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let different = discriminant(&old_state) != discriminant(&new_state);
|
||||||
|
if different {
|
||||||
|
tracing::warn!(
|
||||||
|
new_state = ?new_state,
|
||||||
|
old_state = ?old_state,
|
||||||
|
"Unhandled game stage transition");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*game_state = new_state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if let GameState::LevelRestarting = &*game_state {
|
||||||
|
// // When restarting, jump straight to the CharactersVisible stage
|
||||||
|
// // and unhide the entities.
|
||||||
|
// *startup = StartupSequence::new(0, 60 * 2); // 2 seconds for READY! text
|
||||||
|
// if let StartupSequence::TextOnly { .. } = *startup {
|
||||||
|
// // This will immediately transition to CharactersVisible on the next line
|
||||||
|
// } else {
|
||||||
|
// // Should be unreachable as we just set it
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Freeze Pac-Man and ghosts
|
||||||
|
// for entity in player_query.iter().chain(ghost_query.iter()) {
|
||||||
|
// commands.entity(entity).insert(Frozen);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// *game_state = GameState::Playing;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if let Some((old_state, new_state)) = startup.tick() {
|
||||||
|
// debug!("StartupSequence transition from {old_state:?} to {new_state:?}");
|
||||||
|
// match (old_state, new_state) {
|
||||||
|
// (StartupSequence::TextOnly { .. }, StartupSequence::CharactersVisible { .. }) => {
|
||||||
|
// // Unhide the player & ghosts
|
||||||
|
// for entity in player_query.iter().chain(ghost_query.iter()) {
|
||||||
|
// commands.entity(entity).remove::<Hidden>();
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// (StartupSequence::CharactersVisible { .. }, StartupSequence::GameActive) => {
|
||||||
|
// // Unfreeze Pac-Man, ghosts and energizers
|
||||||
|
// for entity in player_query.iter().chain(ghost_query.iter()).chain(blinking_query.iter()) {
|
||||||
|
// commands.entity(entity).remove::<Frozen>();
|
||||||
|
// }
|
||||||
|
// *game_state = GameState::Playing;
|
||||||
|
// }
|
||||||
|
// _ => {}
|
||||||
|
// }
|
||||||
|
// }
|
||||||
@@ -1,53 +1,45 @@
|
|||||||
use crate::map::direction::Direction;
|
use glam::U16Vec2;
|
||||||
use crate::texture::sprite::AtlasTile;
|
|
||||||
|
|
||||||
/// Fixed-size tile sequence that avoids heap allocation
|
use crate::{map::direction::Direction, texture::sprite::AtlasTile};
|
||||||
#[derive(Clone, Copy, Debug)]
|
|
||||||
|
/// A sequence of tiles for animation, backed by a vector.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
pub struct TileSequence {
|
pub struct TileSequence {
|
||||||
tiles: [AtlasTile; 4], // Fixed array, max 4 frames
|
tiles: Vec<AtlasTile>,
|
||||||
count: usize, // Actual number of frames used
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TileSequence {
|
impl TileSequence {
|
||||||
/// Creates a new tile sequence from a slice of tiles
|
/// Creates a new tile sequence from a slice of tiles.
|
||||||
pub fn new(tiles: &[AtlasTile]) -> Self {
|
pub fn new(tiles: &[AtlasTile]) -> Self {
|
||||||
let mut tile_array = [AtlasTile {
|
Self { tiles: tiles.to_vec() }
|
||||||
pos: glam::U16Vec2::ZERO,
|
|
||||||
size: glam::U16Vec2::ZERO,
|
|
||||||
color: None,
|
|
||||||
}; 4];
|
|
||||||
|
|
||||||
let count = tiles.len().min(4);
|
|
||||||
tile_array[..count].copy_from_slice(&tiles[..count]);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
tiles: tile_array,
|
|
||||||
count,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the tile at the given frame index, wrapping if necessary
|
/// Returns the tile at the given frame index, wrapping if necessary
|
||||||
pub fn get_tile(&self, frame: usize) -> AtlasTile {
|
pub fn get_tile(&self, frame: usize) -> AtlasTile {
|
||||||
if self.count == 0 {
|
if self.tiles.is_empty() {
|
||||||
// Return a default empty tile if no tiles
|
// Return a default or handle the error appropriately
|
||||||
AtlasTile {
|
// For now, let's return a default tile, assuming it's a sensible default
|
||||||
pos: glam::U16Vec2::ZERO,
|
return AtlasTile {
|
||||||
size: glam::U16Vec2::ZERO,
|
pos: U16Vec2::ZERO,
|
||||||
|
size: U16Vec2::ZERO,
|
||||||
color: None,
|
color: None,
|
||||||
}
|
};
|
||||||
} else {
|
|
||||||
self.tiles[frame % self.count]
|
|
||||||
}
|
}
|
||||||
|
self.tiles[frame % self.tiles.len()]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if this sequence has no tiles
|
pub fn len(&self) -> usize {
|
||||||
|
self.tiles.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if the sequence contains any tiles.
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
self.count == 0
|
self.tiles.is_empty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Type-safe directional tile storage with named fields
|
/// A collection of tile sequences for each cardinal direction.
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DirectionalTiles {
|
pub struct DirectionalTiles {
|
||||||
pub up: TileSequence,
|
pub up: TileSequence,
|
||||||
pub down: TileSequence,
|
pub down: TileSequence,
|
||||||
|
|||||||
Reference in New Issue
Block a user