From 08c964c32e5a000a0fb6f92ba1f85ea88d84ed70 Mon Sep 17 00:00:00 2001 From: Ryan Walters Date: Thu, 11 Sep 2025 00:03:14 -0500 Subject: [PATCH] feat: re-implement pausing mechanism with tick-perfect audio & state pauses --- src/audio.rs | 14 +++++++ src/game.rs | 13 ++++--- src/systems/audio.rs | 20 ++++++++++ src/systems/state.rs | 93 ++++++++++++++++++++++++++++---------------- 4 files changed, 102 insertions(+), 38 deletions(-) diff --git a/src/audio.rs b/src/audio.rs index e33fe58..13516da 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -191,6 +191,20 @@ impl Audio { } } + /// Pauses all currently playing audio channels. + pub fn pause_all(&mut self) { + if !self.disabled { + mixer::Channel::all().pause(); + } + } + + /// Resumes all currently playing audio channels. + pub fn resume_all(&mut self) { + if !self.disabled { + mixer::Channel::all().resume(); + } + } + /// Instantly mutes or unmutes all audio channels by adjusting their volume. /// /// Sets all 4 mixer channels to zero volume when muting, or restores them to diff --git a/src/game.rs b/src/game.rs index 08d6e87..1b5efe2 100644 --- a/src/game.rs +++ b/src/game.rs @@ -19,8 +19,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, PlayerAnimation, PlayerBundle, PlayerControlled, PlayerDeathAnimation, PlayerLives, Position, RenderDirty, - Renderable, ScoreResource, StartupSequence, SystemId, SystemTimings, Timing, TouchState, Velocity, Visibility, + PacmanCollider, Paused, PlayerAnimation, PlayerBundle, PlayerControlled, PlayerDeathAnimation, PlayerLives, Position, + RenderDirty, Renderable, ScoreResource, StartupSequence, SystemId, SystemTimings, Timing, TouchState, Velocity, Visibility, }; use crate::texture::animated::{DirectionalTiles, TileSequence}; @@ -443,6 +443,7 @@ impl Game { world.insert_resource(GameStage::Starting(StartupSequence::TextOnly { remaining_ticks: constants::startup::STARTUP_FRAMES, })); + world.insert_resource(Paused(false)); world.insert_non_send_resource(event_pump); world.insert_non_send_resource::<&mut Canvas>(Box::leak(Box::new(canvas))); @@ -457,6 +458,7 @@ impl Game { 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 pause_system = profile(SystemId::Input, systems::handle_pause_command); let player_control_system = profile(SystemId::PlayerControls, systems::player_control_system); let player_movement_system = profile(SystemId::PlayerMovement, systems::player_movement_system); let player_tunnel_slowdown_system = profile(SystemId::PlayerMovement, systems::player::player_tunnel_slowdown_system); @@ -483,6 +485,7 @@ impl Game { *local % 2 == 0 }), player_control_system, + pause_system, ) .chain(); @@ -525,9 +528,9 @@ impl Game { )) .configure_sets(( GameplaySet::Input, - GameplaySet::Update, - GameplaySet::Respond, - RenderSet::Animation, + GameplaySet::Update.run_if(|paused: Res| !paused.0), + GameplaySet::Respond.run_if(|paused: Res| !paused.0), + RenderSet::Animation.run_if(|paused: Res| !paused.0), RenderSet::Draw, RenderSet::Present, )); diff --git a/src/systems/audio.rs b/src/systems/audio.rs index 94aa7c0..b2ad2b3 100644 --- a/src/systems/audio.rs +++ b/src/systems/audio.rs @@ -31,6 +31,10 @@ pub enum AudioEvent { PlayDeath, /// Stop all currently playing sounds StopAll, + /// Pause all sounds + Pause, + /// Resume all sounds + Resume, } /// Non-send resource wrapper for SDL2 audio system @@ -92,6 +96,22 @@ pub fn audio_system( debug!("Audio disabled, ignoring stop all request"); } } + AudioEvent::Pause => { + if !audio.0.is_disabled() { + debug!("Pausing all audio"); + audio.0.pause_all(); + } else { + debug!("Audio disabled, ignoring pause all request"); + } + } + AudioEvent::Resume => { + if !audio.0.is_disabled() { + debug!("Resuming all audio"); + audio.0.resume_all(); + } else { + debug!("Audio disabled, ignoring resume all request"); + } + } } } } diff --git a/src/systems/state.rs b/src/systems/state.rs index 314e75c..8f077de 100644 --- a/src/systems/state.rs +++ b/src/systems/state.rs @@ -1,5 +1,5 @@ use std::mem::discriminant; -use tracing::{debug, info, warn}; +use tracing::{debug, info}; use crate::constants; use crate::events::StageTransition; @@ -20,6 +20,8 @@ use bevy_ecs::{ system::{Commands, Query, Res, ResMut, Single}, }; +use crate::events::{GameCommand, GameEvent}; + #[derive(Resource, Clone)] pub struct PlayerAnimation(pub DirectionalAnimation); @@ -45,6 +47,28 @@ pub enum GameStage { GameOver, } +#[derive(Resource, Debug, Default)] +pub struct Paused(pub bool); + +pub fn handle_pause_command( + mut events: EventReader, + mut paused: 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"); + audio_events.write(AudioEvent::Resume); + } + } + } +} + pub trait TooSimilar { fn too_similar(&self, other: &Self) -> bool; } @@ -161,7 +185,7 @@ pub fn stage_system( mut ghost_query: Query<(Entity, &Ghost, &mut Position, &mut GhostState), (With, Without)>, ) { let old_state = *game_state; - let mut new_state: Option = None; + let mut new_state_opt: Option = None; // Handle stage transition requests before normal ticking for event in stage_event_reader.read() { @@ -172,7 +196,7 @@ pub fn stage_system( let pac_node = player.1.current_node(); debug!(ghost = ?ghost_type, node = pac_node, "Ghost eaten, entering pause state"); - new_state = Some(GameStage::GhostEatenPause { + new_state_opt = Some(GameStage::GhostEatenPause { remaining_ticks: 30, ghost_entity, ghost_type, @@ -180,29 +204,11 @@ pub fn stage_system( }); } - let new_state: GameStage = match new_state.unwrap_or(*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 { - info!("Startup sequence completed, beginning gameplay"); - GameStage::Playing - } - } - }, - GameStage::Playing => GameStage::Playing, + let new_state: GameStage = new_state_opt.unwrap_or_else(|| match *game_state { + GameStage::Playing => { + // This is the default state, do nothing + *game_state + } GameStage::GhostEatenPause { remaining_ticks, ghost_entity, @@ -221,11 +227,32 @@ pub fn stage_system( GameStage::Playing } } - GameStage::PlayerDying(dying) => match dying { + GameStage::Starting(sequence) => match sequence { + StartupSequence::TextOnly { remaining_ticks } => { + if remaining_ticks > 0 { + GameStage::Starting(StartupSequence::TextOnly { + remaining_ticks: remaining_ticks.saturating_sub(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.saturating_sub(1), + }) + } else { + info!("Startup sequence completed, beginning gameplay"); + GameStage::Playing + } + } + }, + GameStage::PlayerDying(sequence) => match sequence { DyingSequence::Frozen { remaining_ticks } => { if remaining_ticks > 0 { GameStage::PlayerDying(DyingSequence::Frozen { - remaining_ticks: remaining_ticks - 1, + remaining_ticks: remaining_ticks.saturating_sub(1), }) } else { let death_animation = &player_death_animation.0; @@ -237,7 +264,7 @@ pub fn stage_system( DyingSequence::Animating { remaining_ticks } => { if remaining_ticks > 0 { GameStage::PlayerDying(DyingSequence::Animating { - remaining_ticks: remaining_ticks - 1, + remaining_ticks: remaining_ticks.saturating_sub(1), }) } else { GameStage::PlayerDying(DyingSequence::Hidden { remaining_ticks: 60 }) @@ -246,7 +273,7 @@ pub fn stage_system( DyingSequence::Hidden { remaining_ticks } => { if remaining_ticks > 0 { GameStage::PlayerDying(DyingSequence::Hidden { - remaining_ticks: remaining_ticks - 1, + remaining_ticks: remaining_ticks.saturating_sub(1), }) } else { player_lives.0 = player_lives.0.saturating_sub(1); @@ -255,14 +282,14 @@ pub fn stage_system( info!(remaining_lives = player_lives.0, "Player died, returning to startup sequence"); GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 }) } else { - warn!("All lives lost, game over"); + info!("All lives lost, game over"); GameStage::GameOver } } } }, - GameStage::GameOver => GameStage::GameOver, - }; + GameStage::GameOver => *game_state, + }); if old_state == new_state { return;