feat: implement pause state management and single tick command

This commit is contained in:
Ryan Walters
2025-09-11 17:01:35 -05:00
parent a887fae00f
commit 841cf5b83e
7 changed files with 113 additions and 37 deletions

View File

@@ -44,4 +44,5 @@ fix:
cargo fmt --all cargo fmt --all
push: push:
git push origin --tags && git push git push origin --tags;
git push

View File

@@ -6,7 +6,7 @@ use crate::{map::direction::Direction, systems::Ghost};
/// ///
/// Commands are generated by the input system in response to keyboard events /// Commands are generated by the input system in response to keyboard events
/// and processed by appropriate game systems to modify state or behavior. /// 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 { pub enum GameCommand {
/// Request immediate game shutdown /// Request immediate game shutdown
Exit, Exit,
@@ -24,6 +24,7 @@ pub enum GameCommand {
/// Toggle fullscreen mode (desktop only) /// Toggle fullscreen mode (desktop only)
#[cfg(not(target_os = "emscripten"))] #[cfg(not(target_os = "emscripten"))]
ToggleFullscreen, ToggleFullscreen,
SingleTick,
} }
/// Global events that flow through the ECS event system to coordinate game behavior. /// Global events that flow through the ECS event system to coordinate game behavior.

View File

@@ -12,7 +12,7 @@ use crate::events::{CollisionTrigger, GameEvent, StageTransition};
use crate::map::builder::Map; use crate::map::builder::Map;
use crate::map::direction::Direction; use crate::map::direction::Direction;
use crate::systems::item::PelletCount; use crate::systems::item::PelletCount;
use crate::systems::state::IntroPlayed; use crate::systems::state::{IntroPlayed, PauseState};
use crate::systems::{ use crate::systems::{
self, audio_system, blinking_system, collision_system, combined_render_system, directional_render_system, 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, 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, BackbufferResource, Blinking, BufferedDirection, Collider, DebugState, DebugTextureResource, DeltaTime, DirectionalAnimation,
EntityType, Frozen, FruitSprites, GameStage, Ghost, GhostAnimation, GhostAnimations, GhostBundle, GhostCollider, GhostState, EntityType, Frozen, FruitSprites, GameStage, Ghost, GhostAnimation, GhostAnimations, GhostBundle, GhostCollider, GhostState,
GlobalState, ItemBundle, ItemCollider, LastAnimationState, LinearAnimation, MapTextureResource, MovementModifiers, NodeId, GlobalState, ItemBundle, ItemCollider, LastAnimationState, LinearAnimation, MapTextureResource, MovementModifiers, NodeId,
PacmanCollider, Paused, PlayerAnimation, PlayerBundle, PlayerControlled, PlayerDeathAnimation, PlayerLives, Position, PacmanCollider, PlayerAnimation, PlayerBundle, PlayerControlled, PlayerDeathAnimation, PlayerLives, Position, RenderDirty,
RenderDirty, Renderable, ScoreResource, StartupSequence, SystemId, SystemTimings, Timing, TouchState, Velocity, Visibility, Renderable, ScoreResource, StartupSequence, SystemId, SystemTimings, Timing, TouchState, Velocity, Visibility,
}; };
use crate::texture::animated::{DirectionalTiles, TileSequence}; use crate::texture::animated::{DirectionalTiles, TileSequence};
@@ -446,7 +446,7 @@ impl Game {
world.insert_resource(GameStage::Starting(StartupSequence::TextOnly { world.insert_resource(GameStage::Starting(StartupSequence::TextOnly {
remaining_ticks: constants::startup::STARTUP_FRAMES, 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(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)));
@@ -479,6 +479,7 @@ impl Game {
let unified_ghost_state_system = profile(SystemId::GhostStateAnimation, ghost_state_system); let unified_ghost_state_system = profile(SystemId::GhostStateAnimation, ghost_state_system);
let eaten_ghost_system = profile(SystemId::EatenGhost, eaten_ghost_system); let eaten_ghost_system = profile(SystemId::EatenGhost, eaten_ghost_system);
let time_to_live_system = profile(SystemId::TimeToLive, time_to_live_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 // Input system should always run to prevent SDL event pump from blocking
let input_systems = ( let input_systems = (
@@ -530,12 +531,13 @@ impl Game {
.chain() .chain()
.in_set(RenderSet::Draw), .in_set(RenderSet::Draw),
(present_system, audio_system).chain().in_set(RenderSet::Present), (present_system, audio_system).chain().in_set(RenderSet::Present),
manage_pause_state_system.after(GameplaySet::Update),
)) ))
.configure_sets(( .configure_sets((
GameplaySet::Input, GameplaySet::Input,
GameplaySet::Update.run_if(|paused: Res<Paused>| !paused.0), GameplaySet::Update.run_if(|paused: Res<PauseState>| paused.active()),
GameplaySet::Respond.run_if(|paused: Res<Paused>| !paused.0), GameplaySet::Respond.run_if(|paused: Res<PauseState>| paused.active()),
RenderSet::Animation.run_if(|paused: Res<Paused>| !paused.0), RenderSet::Animation.run_if(|paused: Res<PauseState>| paused.active()),
RenderSet::Draw, RenderSet::Draw,
RenderSet::Present, RenderSet::Present,
)); ));

View File

@@ -5,13 +5,13 @@
//! main-thread requirements while maintaining Bevy ECS compatibility. //! main-thread requirements while maintaining Bevy ECS compatibility.
use bevy_ecs::{ use bevy_ecs::{
event::{Event, EventReader, EventWriter}, event::{Event, EventReader},
resource::Resource, resource::Resource,
system::{NonSendMut, ResMut}, system::{NonSendMut, ResMut},
}; };
use tracing::{debug, trace}; use tracing::{debug, trace};
use crate::{audio::Audio, audio::Sound, error::GameError}; use crate::{audio::Audio, audio::Sound};
/// Resource for tracking audio state /// Resource for tracking audio state
#[derive(Resource, Debug, Clone, Default)] #[derive(Resource, Debug, Clone, Default)]
@@ -46,44 +46,39 @@ pub enum AudioEvent {
pub struct AudioResource(pub Audio); pub struct AudioResource(pub Audio);
/// System that processes audio events and plays sounds /// System that processes audio events and plays sounds
pub fn audio_system( pub fn audio_system(mut audio: NonSendMut<AudioResource>, mut state: ResMut<AudioState>, mut events: EventReader<AudioEvent>) {
mut audio: NonSendMut<AudioResource>,
mut audio_state: ResMut<AudioState>,
mut audio_events: EventReader<AudioEvent>,
_errors: EventWriter<GameError>,
) {
// Set mute state if it has changed // Set mute state if it has changed
if audio.0.is_muted() != audio_state.muted { if audio.0.is_muted() != state.muted {
debug!(muted = audio_state.muted, "Audio mute state changed"); debug!(muted = state.muted, "Audio mute state changed");
audio.0.set_mute(audio_state.muted); audio.0.set_mute(state.muted);
} }
// Process audio events // Process audio events
for event in audio_events.read() { for event in events.read() {
match event { match event {
AudioEvent::Waka => { AudioEvent::Waka => {
if !audio.0.is_disabled() && !audio_state.muted { if !audio.0.is_disabled() && !state.muted {
trace!(sound_index = audio_state.sound_index, "Playing eat sound"); trace!(sound_index = state.sound_index, "Playing eat sound");
audio.0.waka(); audio.0.waka();
// Update the sound index for cycling through sounds // 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 // 4 eat sounds available
} else { } else {
debug!( debug!(
disabled = audio.0.is_disabled(), disabled = audio.0.is_disabled(),
muted = audio_state.muted, muted = state.muted,
"Skipping eat sound due to audio state" "Skipping eat sound due to audio state"
); );
} }
} }
AudioEvent::PlaySound(sound) => { AudioEvent::PlaySound(sound) => {
if !audio.0.is_disabled() && !audio_state.muted { if !audio.0.is_disabled() && !state.muted {
trace!(?sound, "Playing sound"); trace!(?sound, "Playing sound");
audio.0.play(*sound); audio.0.play(*sound);
} else { } else {
debug!( debug!(
disabled = audio.0.is_disabled(), disabled = audio.0.is_disabled(),
muted = audio_state.muted, muted = state.muted,
"Skipping sound due to audio state" "Skipping sound due to audio state"
); );
} }

View File

@@ -85,6 +85,7 @@ impl Default for Bindings {
key_bindings.insert(Keycode::Space, GameCommand::ToggleDebug); key_bindings.insert(Keycode::Space, GameCommand::ToggleDebug);
key_bindings.insert(Keycode::M, GameCommand::MuteAudio); key_bindings.insert(Keycode::M, GameCommand::MuteAudio);
key_bindings.insert(Keycode::R, GameCommand::ResetLevel); key_bindings.insert(Keycode::R, GameCommand::ResetLevel);
key_bindings.insert(Keycode::T, GameCommand::SingleTick);
#[cfg(not(target_os = "emscripten"))] #[cfg(not(target_os = "emscripten"))]
{ {

View File

@@ -163,6 +163,7 @@ pub enum SystemId {
GhostStateAnimation, GhostStateAnimation,
EatenGhost, EatenGhost,
TimeToLive, TimeToLive,
PauseManager,
} }
impl Display for SystemId { impl Display for SystemId {

View File

@@ -57,28 +57,103 @@ pub enum GameStage {
GameOver, GameOver,
} }
#[derive(Resource, Debug, Default)] #[derive(Resource, Debug, PartialEq, Eq, Clone, Copy)]
pub struct Paused(pub bool); pub enum PauseState {
Inactive,
Active { remaining_ticks: Option<u32> },
}
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( pub fn handle_pause_command(
mut events: EventReader<GameEvent>, mut events: EventReader<GameEvent>,
mut paused: ResMut<Paused>, mut pause_state: ResMut<PauseState>,
mut audio_events: EventWriter<AudioEvent>, mut audio_events: EventWriter<AudioEvent>,
) { ) {
for event in events.read() { for event in events.read() {
if let GameEvent::Command(GameCommand::TogglePause) = event { match event {
paused.0 = !paused.0; GameEvent::Command(GameCommand::TogglePause) => {
if paused.0 { *pause_state = match *pause_state {
info!("Game paused"); PauseState::Active { .. } => {
audio_events.write(AudioEvent::Pause); info!("Game resumed");
} else { audio_events.write(AudioEvent::Resume);
info!("Game resumed"); 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); audio_events.write(AudioEvent::Resume);
} }
_ => {}
} }
} }
} }
pub fn manage_pause_state_system(mut pause_state: ResMut<PauseState>, mut audio_events: EventWriter<AudioEvent>) {
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"))] #[cfg(not(target_os = "emscripten"))]
pub fn handle_fullscreen_command(mut events: EventReader<GameEvent>, mut canvas: NonSendMut<&mut Canvas<Window>>) { pub fn handle_fullscreen_command(mut events: EventReader<GameEvent>, mut canvas: NonSendMut<&mut Canvas<Window>>) {
for event in events.read() { for event in events.read() {