diff --git a/Justfile b/Justfile index 3fa10d7..ab62d5b 100644 --- a/Justfile +++ b/Justfile @@ -44,4 +44,5 @@ fix: cargo fmt --all push: - git push origin --tags && git push + git push origin --tags; + git push diff --git a/src/events.rs b/src/events.rs index ac205cc..8c41f52 100644 --- a/src/events.rs +++ b/src/events.rs @@ -6,7 +6,7 @@ use crate::{map::direction::Direction, systems::Ghost}; /// /// Commands are generated by the input system in response to keyboard events /// and processed by appropriate game systems to modify state or behavior. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GameCommand { /// Request immediate game shutdown Exit, @@ -24,6 +24,7 @@ pub enum GameCommand { /// Toggle fullscreen mode (desktop only) #[cfg(not(target_os = "emscripten"))] ToggleFullscreen, + SingleTick, } /// Global events that flow through the ECS event system to coordinate game behavior. diff --git a/src/game.rs b/src/game.rs index 73f0ed1..d78aa43 100644 --- a/src/game.rs +++ b/src/game.rs @@ -12,7 +12,7 @@ use crate::events::{CollisionTrigger, GameEvent, StageTransition}; use crate::map::builder::Map; use crate::map::direction::Direction; use crate::systems::item::PelletCount; -use crate::systems::state::IntroPlayed; +use crate::systems::state::{IntroPlayed, PauseState}; use crate::systems::{ self, audio_system, blinking_system, collision_system, combined_render_system, directional_render_system, dirty_render_system, eaten_ghost_system, fruit_sprite_system, ghost_collision_observer, ghost_movement_system, @@ -21,8 +21,8 @@ use crate::systems::{ BackbufferResource, Blinking, BufferedDirection, Collider, DebugState, DebugTextureResource, DeltaTime, DirectionalAnimation, EntityType, Frozen, FruitSprites, GameStage, Ghost, GhostAnimation, GhostAnimations, GhostBundle, GhostCollider, GhostState, GlobalState, ItemBundle, ItemCollider, LastAnimationState, LinearAnimation, MapTextureResource, MovementModifiers, NodeId, - PacmanCollider, Paused, PlayerAnimation, PlayerBundle, PlayerControlled, PlayerDeathAnimation, PlayerLives, Position, - RenderDirty, Renderable, ScoreResource, StartupSequence, SystemId, SystemTimings, Timing, TouchState, Velocity, Visibility, + PacmanCollider, PlayerAnimation, PlayerBundle, PlayerControlled, PlayerDeathAnimation, PlayerLives, Position, RenderDirty, + Renderable, ScoreResource, StartupSequence, SystemId, SystemTimings, Timing, TouchState, Velocity, Visibility, }; use crate::texture::animated::{DirectionalTiles, TileSequence}; @@ -446,7 +446,7 @@ impl Game { world.insert_resource(GameStage::Starting(StartupSequence::TextOnly { remaining_ticks: constants::startup::STARTUP_FRAMES, })); - world.insert_resource(Paused(false)); + world.insert_resource(PauseState::default()); world.insert_non_send_resource(event_pump); world.insert_non_send_resource::<&mut Canvas>(Box::leak(Box::new(canvas))); @@ -479,6 +479,7 @@ impl Game { let unified_ghost_state_system = profile(SystemId::GhostStateAnimation, ghost_state_system); let eaten_ghost_system = profile(SystemId::EatenGhost, eaten_ghost_system); let time_to_live_system = profile(SystemId::TimeToLive, time_to_live_system); + let manage_pause_state_system = profile(SystemId::PauseManager, systems::state::manage_pause_state_system); // Input system should always run to prevent SDL event pump from blocking let input_systems = ( @@ -530,12 +531,13 @@ impl Game { .chain() .in_set(RenderSet::Draw), (present_system, audio_system).chain().in_set(RenderSet::Present), + manage_pause_state_system.after(GameplaySet::Update), )) .configure_sets(( GameplaySet::Input, - GameplaySet::Update.run_if(|paused: Res| !paused.0), - GameplaySet::Respond.run_if(|paused: Res| !paused.0), - RenderSet::Animation.run_if(|paused: Res| !paused.0), + GameplaySet::Update.run_if(|paused: Res| paused.active()), + GameplaySet::Respond.run_if(|paused: Res| paused.active()), + RenderSet::Animation.run_if(|paused: Res| paused.active()), RenderSet::Draw, RenderSet::Present, )); diff --git a/src/systems/audio.rs b/src/systems/audio.rs index 2a679bc..5f313f7 100644 --- a/src/systems/audio.rs +++ b/src/systems/audio.rs @@ -5,13 +5,13 @@ //! main-thread requirements while maintaining Bevy ECS compatibility. use bevy_ecs::{ - event::{Event, EventReader, EventWriter}, + event::{Event, EventReader}, resource::Resource, system::{NonSendMut, ResMut}, }; use tracing::{debug, trace}; -use crate::{audio::Audio, audio::Sound, error::GameError}; +use crate::{audio::Audio, audio::Sound}; /// Resource for tracking audio state #[derive(Resource, Debug, Clone, Default)] @@ -46,44 +46,39 @@ pub enum AudioEvent { pub struct AudioResource(pub Audio); /// System that processes audio events and plays sounds -pub fn audio_system( - mut audio: NonSendMut, - mut audio_state: ResMut, - mut audio_events: EventReader, - _errors: EventWriter, -) { +pub fn audio_system(mut audio: NonSendMut, mut state: ResMut, mut events: EventReader) { // Set mute state if it has changed - if audio.0.is_muted() != audio_state.muted { - debug!(muted = audio_state.muted, "Audio mute state changed"); - audio.0.set_mute(audio_state.muted); + if audio.0.is_muted() != state.muted { + debug!(muted = state.muted, "Audio mute state changed"); + audio.0.set_mute(state.muted); } // Process audio events - for event in audio_events.read() { + for event in events.read() { match event { AudioEvent::Waka => { - if !audio.0.is_disabled() && !audio_state.muted { - trace!(sound_index = audio_state.sound_index, "Playing eat sound"); + if !audio.0.is_disabled() && !state.muted { + trace!(sound_index = state.sound_index, "Playing eat sound"); audio.0.waka(); // Update the sound index for cycling through sounds - audio_state.sound_index = (audio_state.sound_index + 1) % 4; + state.sound_index = (state.sound_index + 1) % 4; // 4 eat sounds available } else { debug!( disabled = audio.0.is_disabled(), - muted = audio_state.muted, + muted = state.muted, "Skipping eat sound due to audio state" ); } } AudioEvent::PlaySound(sound) => { - if !audio.0.is_disabled() && !audio_state.muted { + if !audio.0.is_disabled() && !state.muted { trace!(?sound, "Playing sound"); audio.0.play(*sound); } else { debug!( disabled = audio.0.is_disabled(), - muted = audio_state.muted, + muted = state.muted, "Skipping sound due to audio state" ); } diff --git a/src/systems/input.rs b/src/systems/input.rs index 9903e26..b73a5e3 100644 --- a/src/systems/input.rs +++ b/src/systems/input.rs @@ -85,6 +85,7 @@ impl Default for Bindings { key_bindings.insert(Keycode::Space, GameCommand::ToggleDebug); key_bindings.insert(Keycode::M, GameCommand::MuteAudio); key_bindings.insert(Keycode::R, GameCommand::ResetLevel); + key_bindings.insert(Keycode::T, GameCommand::SingleTick); #[cfg(not(target_os = "emscripten"))] { diff --git a/src/systems/profiling.rs b/src/systems/profiling.rs index 8941842..547d758 100644 --- a/src/systems/profiling.rs +++ b/src/systems/profiling.rs @@ -163,6 +163,7 @@ pub enum SystemId { GhostStateAnimation, EatenGhost, TimeToLive, + PauseManager, } impl Display for SystemId { diff --git a/src/systems/state.rs b/src/systems/state.rs index b1af2a6..e2829a3 100644 --- a/src/systems/state.rs +++ b/src/systems/state.rs @@ -57,28 +57,103 @@ pub enum GameStage { GameOver, } -#[derive(Resource, Debug, Default)] -pub struct Paused(pub bool); +#[derive(Resource, Debug, PartialEq, Eq, Clone, Copy)] +pub enum PauseState { + Inactive, + Active { remaining_ticks: Option }, +} + +impl Default for PauseState { + fn default() -> Self { + Self::Active { remaining_ticks: None } + } +} + +impl PauseState { + pub fn active(&self) -> bool { + matches!( + self, + PauseState::Active { remaining_ticks: None } + | PauseState::Active { + remaining_ticks: Some(1..=u32::MAX) + } + ) + } + + /// Ticks the pause state + /// # Returns + /// `true` if the state changed significantly (e.g. from active to inactive or vice versa) + pub fn tick(&mut self) -> bool { + match self { + // Permanent states + PauseState::Active { remaining_ticks: None } | PauseState::Inactive => false, + // Last tick of the active state + PauseState::Active { + remaining_ticks: Some(1), + } => { + *self = PauseState::Inactive; + true + } + // Active state with remaining ticks + PauseState::Active { + remaining_ticks: Some(ticks), + } => { + *self = PauseState::Active { + remaining_ticks: Some(*ticks - 1), + }; + false + } + } + } +} pub fn handle_pause_command( mut events: EventReader, - mut paused: ResMut, + mut pause_state: ResMut, mut audio_events: EventWriter, ) { for event in events.read() { - if let GameEvent::Command(GameCommand::TogglePause) = event { - paused.0 = !paused.0; - if paused.0 { - info!("Game paused"); - audio_events.write(AudioEvent::Pause); - } else { - info!("Game resumed"); + match event { + GameEvent::Command(GameCommand::TogglePause) => { + *pause_state = match *pause_state { + PauseState::Active { .. } => { + info!("Game resumed"); + audio_events.write(AudioEvent::Resume); + PauseState::Inactive + } + PauseState::Inactive => { + info!("Game paused"); + audio_events.write(AudioEvent::Pause); + PauseState::Active { remaining_ticks: None } + } + } + } + GameEvent::Command(GameCommand::SingleTick) => { + // Single tick should not function while the game is playing + if matches!(*pause_state, PauseState::Active { remaining_ticks: None }) { + return; + } + + *pause_state = PauseState::Active { + remaining_ticks: Some(1), + }; audio_events.write(AudioEvent::Resume); } + _ => {} } } } +pub fn manage_pause_state_system(mut pause_state: ResMut, mut audio_events: EventWriter) { + let changed = pause_state.tick(); + + // If the pause state changed, send the appropriate audio event + if changed { + // Since the pause state can never go from inactive to active, the only way to get here is if the game is now paused... + audio_events.write(AudioEvent::Pause); + } +} + #[cfg(not(target_os = "emscripten"))] pub fn handle_fullscreen_command(mut events: EventReader, mut canvas: NonSendMut<&mut Canvas>) { for event in events.read() {