From 80ebf08dd3656128361b13b4cf6bb6646e6a8b0f Mon Sep 17 00:00:00 2001 From: Ryan Walters Date: Thu, 28 Aug 2025 12:40:02 -0500 Subject: [PATCH] feat: stage sequence, ghost collisions & energizer logic, text color method, scheduler ordering --- src/game.rs | 122 +++++++++++++------- src/map/builder.rs | 11 ++ src/systems/components.rs | 228 +++++++++++++++++++++++++++++++++++++- src/systems/ghost.rs | 59 +++++++++- src/systems/item.rs | 17 ++- src/systems/mod.rs | 1 + src/systems/player.rs | 60 ++++++++-- src/systems/profiling.rs | 2 + src/systems/render.rs | 93 +++++++++++++++- src/systems/stage.rs | 33 ++++++ src/texture/text.rs | 42 ++++++- tests/text.rs | 20 ++++ 12 files changed, 624 insertions(+), 64 deletions(-) create mode 100644 src/systems/stage.rs diff --git a/src/game.rs b/src/game.rs index f8d98ac..b6f0769 100644 --- a/src/game.rs +++ b/src/game.rs @@ -7,32 +7,35 @@ use crate::error::{GameError, GameResult, TextureError}; use crate::events::GameEvent; use crate::map::builder::Map; use crate::map::direction::Direction; +use crate::systems; use crate::systems::blinking::Blinking; + use crate::systems::movement::{BufferedDirection, Position, Velocity}; -use crate::systems::player::player_movement_system; use crate::systems::profiling::SystemId; +use crate::systems::render::RenderDirty; use crate::systems::{ audio::{audio_system, AudioEvent, AudioResource}, blinking::blinking_system, collision::collision_system, components::{ - AudioState, Collider, DeltaTime, DirectionalAnimated, EntityType, Ghost, GhostBundle, GhostCollider, GlobalState, - ItemBundle, ItemCollider, PacmanCollider, PlayerBundle, PlayerControlled, RenderDirty, Renderable, ScoreResource, + AudioState, Collider, DeltaTime, DirectionalAnimated, EntityType, Frozen, Ghost, GhostBundle, GhostCollider, GlobalState, + ItemBundle, ItemCollider, LevelTiming, PacmanCollider, PlayerBundle, PlayerControlled, PlayerStateBundle, Renderable, + ScoreResource, StartupSequence, }, debug::{debug_render_system, DebugFontResource, DebugState, DebugTextureResource}, - ghost::ghost_movement_system, - input::input_system, + ghost::{ghost_collision_system, ghost_movement_system}, item::item_system, - player::player_control_system, profiling::{profile, SystemTimings}, render::{ - directional_render_system, dirty_render_system, hud_render_system, render_system, BackbufferResource, MapTextureResource, + directional_render_system, dirty_render_system, hud_render_system, ready_visibility_system, render_system, + BackbufferResource, MapTextureResource, }, }; use crate::texture::animated::AnimatedTexture; use bevy_ecs::event::EventRegistry; use bevy_ecs::observer::Trigger; -use bevy_ecs::schedule::Schedule; +use bevy_ecs::prelude::SystemSet; +use bevy_ecs::schedule::{IntoScheduleConfigs, Schedule}; use bevy_ecs::system::{NonSendMut, Res, ResMut}; use bevy_ecs::world::World; use sdl2::image::LoadTexture; @@ -50,6 +53,10 @@ use crate::{ texture::sprite::{AtlasMapper, SpriteAtlas}, }; +/// System set for all rendering systems to ensure they run after gameplay logic +#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)] +pub struct RenderSet; + /// Core game state manager built on the Bevy ECS architecture. /// /// Orchestrates all game systems through a centralized `World` containing entities, @@ -207,6 +214,11 @@ impl Game { pacman_collider: PacmanCollider, }; + // Spawn player and attach initial state bundle + let player_entity = world.spawn(player).id(); + world.entity_mut(player_entity).insert(PlayerStateBundle::default()); + world.entity_mut(player_entity).insert(Frozen); + world.insert_non_send_resource(atlas); world.insert_non_send_resource(event_pump); world.insert_non_send_resource(canvas); @@ -226,6 +238,7 @@ impl Game { world.insert_resource(DebugState::default()); world.insert_resource(AudioState::default()); world.insert_resource(CursorPosition::default()); + world.insert_resource(LevelTiming::for_level(1)); world.add_observer( |event: Trigger, mut state: ResMut, _score: ResMut| { @@ -234,37 +247,72 @@ impl Game { } }, ); - schedule.add_systems(( - profile(SystemId::Input, input_system), - profile(SystemId::PlayerControls, player_control_system), - profile(SystemId::PlayerMovement, player_movement_system), - profile(SystemId::Ghost, ghost_movement_system), - profile(SystemId::Collision, collision_system), - profile(SystemId::Item, item_system), - profile(SystemId::Audio, audio_system), - profile(SystemId::Blinking, blinking_system), - profile(SystemId::DirectionalRender, directional_render_system), - profile(SystemId::DirtyRender, dirty_render_system), - profile(SystemId::HudRender, hud_render_system), - profile(SystemId::Render, render_system), - profile(SystemId::DebugRender, debug_render_system), - profile( - SystemId::Present, - |mut canvas: NonSendMut<&mut Canvas>, debug_state: Res, mut dirty: ResMut| { - if dirty.0 || debug_state.enabled { - // Only copy backbuffer to main canvas if debug rendering is off - // (debug rendering draws directly to main canvas) - if !debug_state.enabled { - canvas.present(); - } - dirty.0 = false; + + let input_system = profile(SystemId::Input, systems::input::input_system); + let player_control_system = profile(SystemId::PlayerControls, systems::player::player_control_system); + let player_movement_system = profile(SystemId::PlayerMovement, systems::player::player_movement_system); + let startup_stage_system = profile(SystemId::Stage, systems::stage::startup_stage_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 collision_system = profile(SystemId::Collision, collision_system); + let ghost_collision_system = profile(SystemId::GhostCollision, ghost_collision_system); + let item_system = profile(SystemId::Item, item_system); + let audio_system = profile(SystemId::Audio, audio_system); + let blinking_system = profile(SystemId::Blinking, blinking_system); + let directional_render_system = profile(SystemId::DirectionalRender, directional_render_system); + let dirty_render_system = profile(SystemId::DirtyRender, dirty_render_system); + let hud_render_system = profile(SystemId::HudRender, hud_render_system); + let render_system = profile(SystemId::Render, render_system); + let debug_render_system = profile(SystemId::DebugRender, debug_render_system); + + let present_system = profile( + SystemId::Present, + |mut canvas: NonSendMut<&mut Canvas>, debug_state: Res, mut dirty: ResMut| { + if dirty.0 || debug_state.enabled { + // Only copy backbuffer to main canvas if debug rendering is off + // (debug rendering draws directly to main canvas) + if !debug_state.enabled { + canvas.present(); } - }, - ), + dirty.0 = false; + } + }, + ); + + schedule.add_systems(( + ( + input_system, + player_control_system, + player_movement_system, + startup_stage_system, + ) + .chain(), + player_tunnel_slowdown_system, + ghost_movement_system, + (collision_system, ghost_collision_system, item_system).chain(), + audio_system, + blinking_system, + ready_visibility_system, + ( + directional_render_system, + dirty_render_system, + render_system, + hud_render_system, + debug_render_system, + present_system, + ) + .chain(), )); - // Spawn player - world.spawn(player); + // Initialize StartupSequence as a global resource + let ready_duration_ticks = { + let duration = world + .get_resource::() + .map(|t| t.spawn_freeze_duration) + .unwrap_or(1.5); + (duration * 60.0) as u32 // Convert to ticks at 60 FPS + }; + world.insert_resource(StartupSequence::new(ready_duration_ticks, 60)); // Spawn ghosts Self::spawn_ghosts(&mut world)?; @@ -413,7 +461,7 @@ impl Game { } }; - world.spawn(ghost); + world.spawn(ghost).insert(Frozen); } Ok(()) diff --git a/src/map/builder.rs b/src/map/builder.rs index cbdd0f0..0cd9349 100644 --- a/src/map/builder.rs +++ b/src/map/builder.rs @@ -174,6 +174,17 @@ impl Map { }) } + /// Returns the `MapTile` at a given node id. + pub fn tile_at_node(&self, node_id: NodeId) -> Option { + // reverse lookup: node -> grid + for (grid_pos, id) in &self.grid_to_node { + if *id == node_id { + return Some(self.tiles[grid_pos.x as usize][grid_pos.y as usize]); + } + } + None + } + /// Constructs the ghost house area with restricted access and internal navigation. /// /// Creates a multi-level ghost house with entrance control, internal movement diff --git a/src/systems/components.rs b/src/systems/components.rs index 2fd8cec..e185046 100644 --- a/src/systems/components.rs +++ b/src/systems/components.rs @@ -177,9 +177,6 @@ pub struct ScoreResource(pub u32); #[derive(Resource)] 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 { @@ -188,3 +185,228 @@ pub struct AudioState { /// Current sound index for cycling through eat sounds pub sound_index: usize, } + +/// Lifecycle state for the player entity. +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlayerLifecycle { + Spawning, + Alive, + Dying, + Respawning, +} + +impl PlayerLifecycle { + /// Returns true when gameplay input and movement should be active + pub fn is_interactive(self) -> bool { + matches!(self, PlayerLifecycle::Alive) + } +} + +impl Default for PlayerLifecycle { + fn default() -> Self { + PlayerLifecycle::Spawning + } +} + +/// Whether player input should be processed. +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)] +pub enum ControlState { + InputEnabled, + InputLocked, +} + +impl Default for ControlState { + fn default() -> Self { + Self::InputLocked + } +} + +/// Combat-related state for Pac-Man. Tick-based energizer logic. +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)] +pub enum CombatState { + Normal, + Energized { + /// Remaining energizer duration in ticks (frames) + remaining_ticks: u32, + /// Ticks until flashing begins (counts down to 0, then flashing is active) + flash_countdown_ticks: u32, + }, +} + +impl Default for CombatState { + fn default() -> Self { + CombatState::Normal + } +} + +impl CombatState { + pub fn is_energized(&self) -> bool { + matches!(self, CombatState::Energized { .. }) + } + + pub fn is_flashing(&self) -> bool { + matches!(self, CombatState::Energized { flash_countdown_ticks, .. } if *flash_countdown_ticks == 0) + } + + pub fn deactivate_energizer(&mut self) { + *self = CombatState::Normal; + } + + /// Activate energizer using tick-based durations. + pub fn activate_energizer_ticks(&mut self, total_ticks: u32, flash_lead_ticks: u32) { + let flash_countdown_ticks = total_ticks.saturating_sub(flash_lead_ticks); + *self = CombatState::Energized { + remaining_ticks: total_ticks, + flash_countdown_ticks, + }; + } + + /// Advance one frame. When ticks reach zero, returns to Normal. + pub fn tick_frame(&mut self) { + if let CombatState::Energized { + remaining_ticks, + flash_countdown_ticks, + } = self + { + if *remaining_ticks > 0 { + *remaining_ticks -= 1; + if *flash_countdown_ticks > 0 { + *flash_countdown_ticks -= 1; + } + } + if *remaining_ticks == 0 { + *self = CombatState::Normal; + } + } + } +} + +/// Movement modifiers that can affect Pac-Man's speed or handling. +#[derive(Component, Debug, Clone, Copy)] +pub struct MovementModifiers { + /// Multiplier applied to base speed (e.g., tunnels) + pub speed_multiplier: f32, + /// True when currently in a tunnel slowdown region + pub tunnel_slowdown_active: bool, +} + +impl Default for MovementModifiers { + fn default() -> Self { + Self { + speed_multiplier: 1.0, + tunnel_slowdown_active: false, + } + } +} + +/// Level-dependent timing configuration +#[derive(Resource, Debug, Clone, Copy)] +pub struct LevelTiming { + /// Duration of energizer effect in seconds + pub energizer_duration: f32, + /// Freeze duration at spawn/ready in seconds + pub spawn_freeze_duration: f32, + /// When to start flashing relative to energizer end (seconds) + pub energizer_flash_threshold: f32, +} + +impl Default for LevelTiming { + fn default() -> Self { + Self { + energizer_duration: 6.0, + spawn_freeze_duration: 1.5, + energizer_flash_threshold: 2.0, + } + } +} + +impl LevelTiming { + /// Returns timing configuration for a given level. + pub fn for_level(_level: u32) -> Self { + // Placeholder: tune per the Pac-Man Dossier tables + Self::default() + } +} + +/// Tag component for entities that should be frozen during startup +#[derive(Component, Debug, Clone, Copy)] +pub struct Frozen; + +/// Convenience bundle for attaching the hybrid FSM to the player entity +#[derive(Bundle, Default)] +pub struct PlayerStateBundle { + pub lifecycle: PlayerLifecycle, + pub control: ControlState, + pub combat: CombatState, + pub movement_modifiers: MovementModifiers, +} + +#[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, + } + } + + /// Returns true if the timer is still active (not in GameActive state) + pub fn is_active(&self) -> bool { + !matches!(self, StartupSequence::GameActive) + } + + /// Returns true if we're in the game active stage + pub fn is_game_active(&self) -> bool { + matches!(self, StartupSequence::GameActive) + } + + /// 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, + } + } +} diff --git a/src/systems/ghost.rs b/src/systems/ghost.rs index 8ed50f9..c362f9a 100644 --- a/src/systems/ghost.rs +++ b/src/systems/ghost.rs @@ -1,7 +1,15 @@ -use bevy_ecs::system::{Query, Res}; -use rand::prelude::*; +use bevy_ecs::entity::Entity; +use bevy_ecs::event::{EventReader, EventWriter}; +use bevy_ecs::query::{With, Without}; +use bevy_ecs::system::{Commands, Query, Res, ResMut}; +use rand::rngs::SmallRng; +use rand::seq::IndexedRandom; +use rand::SeedableRng; use smallvec::SmallVec; +use crate::events::GameEvent; +use crate::systems::audio::AudioEvent; +use crate::systems::components::{Frozen, GhostCollider, ScoreResource}; use crate::{ map::{ builder::Map, @@ -9,7 +17,7 @@ use crate::{ graph::{Edge, TraversalFlags}, }, systems::{ - components::{DeltaTime, Ghost}, + components::{CombatState, DeltaTime, Ghost, PlayerControlled}, movement::{Position, Velocity}, }, }; @@ -18,7 +26,7 @@ use crate::{ pub fn ghost_movement_system( map: Res, delta_time: Res, - mut ghosts: Query<(&Ghost, &mut Velocity, &mut Position)>, + mut ghosts: Query<(&Ghost, &mut Velocity, &mut Position), Without>, ) { for (_ghost, mut velocity, mut position) in ghosts.iter_mut() { let mut distance = velocity.speed * 60.0 * delta_time.0; @@ -65,3 +73,46 @@ pub fn ghost_movement_system( } } } + +pub fn ghost_collision_system( + mut commands: Commands, + mut collision_events: EventReader, + mut score: ResMut, + pacman_query: Query<&CombatState, With>, + ghost_query: Query<(Entity, &Ghost), With>, + mut events: EventWriter, +) { + for event in collision_events.read() { + if let GameEvent::Collision(entity1, entity2) = event { + // 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() { + (*entity1, *entity2) + } else if pacman_query.get(*entity2).is_ok() && ghost_query.get(*entity1).is_ok() { + (*entity2, *entity1) + } else { + continue; + }; + + // Check if Pac-Man is energized + if let Ok(combat_state) = pacman_query.get(pacman_entity) { + if combat_state.is_energized() { + // Pac-Man eats the ghost + if let Ok((ghost_ent, _ghost_type)) = ghost_query.get(ghost_entity) { + // Add score (200 points per ghost eaten) + score.0 += 200; + + // Remove the ghost + commands.entity(ghost_ent).despawn(); + + // Play eat sound + events.write(AudioEvent::PlayEat); + } + } else { + // Pac-Man dies (this would need a death system) + // For now, just log it + tracing::warn!("Pac-Man collided with ghost while not energized!"); + } + } + } + } +} diff --git a/src/systems/item.rs b/src/systems/item.rs index 5dd6c22..e202c58 100644 --- a/src/systems/item.rs +++ b/src/systems/item.rs @@ -4,7 +4,7 @@ use crate::{ events::GameEvent, systems::{ audio::AudioEvent, - components::{EntityType, ItemCollider, PacmanCollider, ScoreResource}, + components::{CombatState, EntityType, ItemCollider, LevelTiming, PacmanCollider, ScoreResource}, }, }; @@ -24,8 +24,10 @@ pub fn item_system( mut collision_events: EventReader, mut score: ResMut, pacman_query: Query>, + mut combat_q: Query<&mut CombatState, With>, item_query: Query<(Entity, &EntityType), With>, mut events: EventWriter, + level_timing: Res, ) { for event in collision_events.read() { if let GameEvent::Collision(entity1, entity2) = event { @@ -50,6 +52,19 @@ pub fn item_system( if entity_type.is_collectible() { events.write(AudioEvent::PlayEat); } + + // Activate energizer on power pellet using tick-based durations + if *entity_type == EntityType::PowerPellet { + if let Ok(mut combat) = combat_q.single_mut() { + // Convert seconds to frames (assumes 60 FPS) + let total_ticks = (level_timing.energizer_duration * 60.0).round().clamp(0.0, u32::MAX as f32) as u32; + // Flash lead: e.g., 3 seconds (180 ticks) before end; ensure it doesn't underflow + let flash_lead_ticks = (level_timing.energizer_flash_threshold * 60.0) + .round() + .clamp(0.0, u32::MAX as f32) as u32; + combat.activate_energizer_ticks(total_ticks, flash_lead_ticks); + } + } } } } diff --git a/src/systems/mod.rs b/src/systems/mod.rs index fab80f4..861014e 100644 --- a/src/systems/mod.rs +++ b/src/systems/mod.rs @@ -16,3 +16,4 @@ pub mod movement; pub mod player; pub mod profiling; pub mod render; +pub mod stage; diff --git a/src/systems/player.rs b/src/systems/player.rs index f94dc43..f68acdd 100644 --- a/src/systems/player.rs +++ b/src/systems/player.rs @@ -1,6 +1,7 @@ use bevy_ecs::{ + entity::Entity, event::{EventReader, EventWriter}, - prelude::ResMut, + prelude::{Commands, ResMut}, query::With, system::{Query, Res}, }; @@ -11,7 +12,10 @@ use crate::{ map::builder::Map, map::graph::Edge, systems::{ - components::{AudioState, DeltaTime, EntityType, GlobalState, PlayerControlled}, + components::{ + AudioState, ControlState, DeltaTime, EntityType, Frozen, GhostCollider, GlobalState, MovementModifiers, + PlayerControlled, PlayerLifecycle, StartupSequence, + }, debug::DebugState, movement::{BufferedDirection, Position, Velocity}, }, @@ -28,12 +32,12 @@ pub fn player_control_system( mut state: ResMut, mut debug_state: ResMut, mut audio_state: ResMut, - mut players: Query<&mut BufferedDirection, With>, + mut players: Query<(&PlayerLifecycle, &ControlState, &mut BufferedDirection), With>, mut errors: EventWriter, ) { // Get the player's movable component (ensuring there is only one player) - let mut buffered_direction = match players.single_mut() { - Ok(buffered_direction) => buffered_direction, + let (lifecycle, control, mut buffered_direction) = match players.single_mut() { + Ok(tuple) => tuple, Err(e) => { errors.write(GameError::InvalidState(format!( "No/multiple entities queried for player system: {}", @@ -43,15 +47,20 @@ pub fn player_control_system( } }; + // If the player is not interactive or input is locked, ignore movement commands + let allow_input = lifecycle.is_interactive() && matches!(control, ControlState::InputEnabled); + // Handle events for event in events.read() { if let GameEvent::Command(command) = event { match command { GameCommand::MovePlayer(direction) => { - *buffered_direction = BufferedDirection::Some { - direction: *direction, - remaining_time: 0.25, - }; + if allow_input { + *buffered_direction = BufferedDirection::Some { + direction: *direction, + remaining_time: 0.25, + }; + } } GameCommand::Exit => { state.exit = true; @@ -82,10 +91,24 @@ pub fn can_traverse(entity_type: EntityType, edge: Edge) -> bool { pub fn player_movement_system( map: Res, delta_time: Res, - mut entities: Query<(&mut Position, &mut Velocity, &mut BufferedDirection), With>, + mut entities: Query< + ( + &PlayerLifecycle, + &ControlState, + &MovementModifiers, + &mut Position, + &mut Velocity, + &mut BufferedDirection, + ), + With, + >, // mut errors: EventWriter, ) { - for (mut position, mut velocity, mut buffered_direction) in entities.iter_mut() { + for (lifecycle, control, modifiers, mut position, mut velocity, mut buffered_direction) in entities.iter_mut() { + if !lifecycle.is_interactive() || !matches!(control, ControlState::InputEnabled) { + continue; + } + // Decrement the buffered direction remaining time if let BufferedDirection::Some { direction, @@ -102,7 +125,7 @@ pub fn player_movement_system( } } - let mut distance = velocity.speed * 60.0 * delta_time.0; + let mut distance = velocity.speed * modifiers.speed_multiplier * 60.0 * delta_time.0; loop { match *position { @@ -151,3 +174,16 @@ pub fn player_movement_system( } } } + +/// Applies tunnel slowdown based on the current node tile +pub fn player_tunnel_slowdown_system(map: Res, mut q: Query<(&Position, &mut MovementModifiers), With>) { + if let Ok((position, mut modifiers)) = q.single_mut() { + let node = position.current_node(); + let in_tunnel = map + .tile_at_node(node) + .map(|t| t == crate::constants::MapTile::Tunnel) + .unwrap_or(false); + modifiers.tunnel_slowdown_active = in_tunnel; + modifiers.speed_multiplier = if in_tunnel { 0.6 } else { 1.0 }; + } +} diff --git a/src/systems/profiling.rs b/src/systems/profiling.rs index 5c6d8ae..7907e6a 100644 --- a/src/systems/profiling.rs +++ b/src/systems/profiling.rs @@ -34,6 +34,8 @@ pub enum SystemId { Collision, Item, PlayerMovement, + GhostCollision, + Stage, } impl Display for SystemId { diff --git a/src/systems/render.rs b/src/systems/render.rs index 17a8e0e..9c5e4c7 100644 --- a/src/systems/render.rs +++ b/src/systems/render.rs @@ -1,17 +1,26 @@ +use crate::constants::CANVAS_SIZE; use crate::error::{GameError, TextureError}; use crate::map::builder::Map; -use crate::systems::components::{DeltaTime, DirectionalAnimated, RenderDirty, Renderable, ScoreResource}; +use crate::systems::blinking::Blinking; +use crate::systems::components::{ + DeltaTime, DirectionalAnimated, EntityType, GhostCollider, PlayerControlled, Renderable, ScoreResource, StartupSequence, +}; use crate::systems::movement::{Position, Velocity}; use crate::texture::sprite::SpriteAtlas; use crate::texture::text::TextTexture; use bevy_ecs::entity::Entity; use bevy_ecs::event::EventWriter; -use bevy_ecs::prelude::{Changed, Or, RemovedComponents}; +use bevy_ecs::prelude::{Changed, Or, RemovedComponents, With, Without}; +use bevy_ecs::resource::Resource; use bevy_ecs::system::{NonSendMut, Query, Res, ResMut}; +use sdl2::pixels::Color; use sdl2::rect::{Point, Rect}; use sdl2::render::{Canvas, Texture}; use sdl2::video::Window; +#[derive(Resource, Default)] +pub struct RenderDirty(pub bool); + #[allow(clippy::type_complexity)] pub fn dirty_render_system( mut dirty: ResMut, @@ -62,16 +71,67 @@ pub struct MapTextureResource(pub Texture<'static>); /// A non-send resource for the backbuffer texture. This just wraps the texture with a type so it can be differentiated when exposed as a resource. pub struct BackbufferResource(pub Texture<'static>); +/// Updates entity visibility based on StartupSequence stages +pub fn ready_visibility_system( + startup: Res, + mut player_query: Query<&mut Renderable, (With, Without)>, + mut ghost_query: Query<&mut Renderable, (With, Without)>, + mut energizer_query: Query<(&mut Blinking, &EntityType)>, +) { + match *startup { + StartupSequence::TextOnly { .. } => { + // Hide player and ghosts, disable energizer blinking + if let Ok(mut renderable) = player_query.single_mut() { + renderable.visible = false; + } + + for mut renderable in ghost_query.iter_mut() { + renderable.visible = false; + } + + // Disable energizer blinking in text-only stage + for (mut blinking, entity_type) in energizer_query.iter_mut() { + if matches!(entity_type, EntityType::PowerPellet) { + blinking.timer = 0.0; // Reset timer to prevent blinking + } + } + } + StartupSequence::CharactersVisible { .. } => { + // Show player and ghosts, enable energizer blinking + if let Ok(mut renderable) = player_query.single_mut() { + renderable.visible = true; + } + + for mut renderable in ghost_query.iter_mut() { + renderable.visible = true; + } + + // Energizer blinking is handled by the blinking system + } + StartupSequence::GameActive => { + // All entities are visible and blinking is normal + if let Ok(mut renderable) = player_query.single_mut() { + renderable.visible = true; + } + + for mut renderable in ghost_query.iter_mut() { + renderable.visible = true; + } + } + } +} + /// Renders the HUD (score, lives, etc.) on top of the game. pub fn hud_render_system( mut canvas: NonSendMut<&mut Canvas>, mut atlas: NonSendMut, score: Res, + startup: Res, mut errors: EventWriter, ) { let mut text_renderer = TextTexture::new(1.0); - // Render lives and high score text + // Render lives and high score text in white let lives = 3; // TODO: Get from actual lives resource let lives_text = format!("{lives}UP HIGH SCORE "); let lives_position = glam::UVec2::new(4 + 8 * 3, 2); // x_offset + lives_offset * 8, y_offset @@ -80,7 +140,7 @@ pub fn hud_render_system( errors.write(TextureError::RenderFailed(format!("Failed to render lives text: {}", e)).into()); } - // Render score text + // Render score text in yellow (Pac-Man's color) let score_text = format!("{:02}", score.0); let score_offset = 7 - (score_text.len() as i32); let score_position = glam::UVec2::new(4 + 8 * score_offset as u32, 10); // x_offset + score_offset * 8, 8 + y_offset @@ -88,6 +148,31 @@ pub fn hud_render_system( if let Err(e) = text_renderer.render(&mut canvas, &mut atlas, &score_text, score_position) { errors.write(TextureError::RenderFailed(format!("Failed to render score text: {}", e)).into()); } + + // Render text based on StartupSequence stage + if matches!( + *startup, + StartupSequence::TextOnly { .. } | StartupSequence::CharactersVisible { .. } + ) { + let ready_text = "READY!"; + let ready_width = text_renderer.text_width(ready_text); + let ready_position = glam::UVec2::new((CANVAS_SIZE.x - ready_width) / 2, 160); + if let Err(e) = text_renderer.render_with_color(&mut canvas, &mut atlas, ready_text, ready_position, Color::YELLOW) { + errors.write(TextureError::RenderFailed(format!("Failed to render READY text: {}", e)).into()); + } + + if matches!(*startup, StartupSequence::TextOnly { .. }) { + let player_one_text = "PLAYER ONE"; + 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); + + if let Err(e) = + text_renderer.render_with_color(&mut canvas, &mut atlas, player_one_text, player_one_position, Color::CYAN) + { + errors.write(TextureError::RenderFailed(format!("Failed to render PLAYER ONE text: {}", e)).into()); + } + } + } } #[allow(clippy::too_many_arguments)] diff --git a/src/systems/stage.rs b/src/systems/stage.rs new file mode 100644 index 0000000..b835d34 --- /dev/null +++ b/src/systems/stage.rs @@ -0,0 +1,33 @@ +use bevy_ecs::{ + prelude::{Commands, Entity, Query, With}, + system::ResMut, +}; + +use crate::systems::components::{Frozen, GhostCollider, PlayerControlled, StartupSequence}; + +/// Handles startup sequence transitions and component management +pub fn startup_stage_system( + mut startup: ResMut, + mut commands: Commands, + mut player_query: Query>, + mut ghost_query: Query>, +) { + if let Some((from, to)) = startup.tick() { + match (from, to) { + (StartupSequence::TextOnly { .. }, StartupSequence::CharactersVisible { .. }) => { + // TODO: Add TextOnly tag component to hide entities + // TODO: Add CharactersVisible tag component to show entities + // TODO: Remove TextOnly tag component + } + (StartupSequence::CharactersVisible { .. }, StartupSequence::GameActive) => { + // Remove Frozen tag from all entities + for entity in player_query.iter_mut().chain(ghost_query.iter_mut()) { + commands.entity(entity).remove::(); + } + // TODO: Add GameActive tag component + // TODO: Remove CharactersVisible tag component + } + _ => {} + } + } +} diff --git a/src/texture/text.rs b/src/texture/text.rs index abea508..63df3d9 100644 --- a/src/texture/text.rs +++ b/src/texture/text.rs @@ -10,10 +10,20 @@ //! //! ```rust //! use pacman::texture::text::TextTexture; +//! use sdl2::pixels::Color; //! //! // Create a text texture with 1.0 scale (8x8 pixels per character) //! let mut text_renderer = TextTexture::new(1.0); //! +//! // Set default color for all text +//! text_renderer.set_color(Color::WHITE); +//! +//! // Render text with default color +//! text_renderer.render(&mut canvas, &mut atlas, "Hello", position)?; +//! +//! // Render text with specific color +//! text_renderer.render_with_color(&mut canvas, &mut atlas, "World", position, Color::YELLOW)?; +//! //! // Set scale for larger text //! text_renderer.set_scale(2.0); //! @@ -46,6 +56,7 @@ use anyhow::Result; use glam::UVec2; +use sdl2::pixels::Color; use sdl2::render::{Canvas, RenderTarget}; use std::collections::HashMap; @@ -79,6 +90,7 @@ fn char_to_tile_name(c: char) -> Option { pub struct TextTexture { char_map: HashMap, scale: f32, + default_color: Option, } impl Default for TextTexture { @@ -86,6 +98,7 @@ impl Default for TextTexture { Self { scale: 1.0, char_map: Default::default(), + default_color: None, } } } @@ -119,13 +132,26 @@ impl TextTexture { } } - /// Renders a string of text at the given position. + /// Renders a string of text at the given position using the default color. pub fn render( &mut self, canvas: &mut Canvas, atlas: &mut SpriteAtlas, text: &str, position: UVec2, + ) -> Result<()> { + let color = self.default_color.unwrap_or(Color::WHITE); + self.render_with_color(canvas, atlas, text, position, color) + } + + /// Renders a string of text at the given position with a specific color. + pub fn render_with_color( + &mut self, + canvas: &mut Canvas, + atlas: &mut SpriteAtlas, + text: &str, + position: UVec2, + color: Color, ) -> Result<()> { let mut x_offset = 0; let char_width = (8.0 * self.scale) as u32; @@ -134,9 +160,9 @@ impl TextTexture { for c in text.chars() { // Get the tile from the char_map, or insert it if it doesn't exist if let Some(tile) = self.get_tile(c, atlas)? { - // Render the tile if it exists + // Render the tile with the specified color let dest = sdl2::rect::Rect::new((position.x + x_offset) as i32, position.y as i32, char_width, char_height); - tile.render(canvas, atlas, dest)?; + tile.render_with_color(canvas, atlas, dest, color)?; } // Always advance x_offset for all characters (including spaces) @@ -146,6 +172,16 @@ impl TextTexture { Ok(()) } + /// Sets the default color for text rendering. + pub fn set_color(&mut self, color: Color) { + self.default_color = Some(color); + } + + /// Gets the current default color. + pub fn color(&self) -> Option { + self.default_color + } + /// Sets the scale for text rendering. pub fn set_scale(&mut self, scale: f32) { self.scale = scale; diff --git a/tests/text.rs b/tests/text.rs index 3e9664e..30e4284 100644 --- a/tests/text.rs +++ b/tests/text.rs @@ -107,3 +107,23 @@ fn test_text_scale() -> Result<(), String> { Ok(()) } + +#[test] +fn test_text_color() -> Result<(), String> { + let mut text_texture = TextTexture::new(1.0); + + // Test default color (should be None initially) + assert_eq!(text_texture.color(), None); + + // Test setting color + let test_color = sdl2::pixels::Color::YELLOW; + text_texture.set_color(test_color); + assert_eq!(text_texture.color(), Some(test_color)); + + // Test changing color + let new_color = sdl2::pixels::Color::RED; + text_texture.set_color(new_color); + assert_eq!(text_texture.color(), Some(new_color)); + + Ok(()) +}