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
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
/// 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.

View File

@@ -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<Window>>(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>| !paused.0),
GameplaySet::Respond.run_if(|paused: Res<Paused>| !paused.0),
RenderSet::Animation.run_if(|paused: Res<Paused>| !paused.0),
GameplaySet::Update.run_if(|paused: Res<PauseState>| paused.active()),
GameplaySet::Respond.run_if(|paused: Res<PauseState>| paused.active()),
RenderSet::Animation.run_if(|paused: Res<PauseState>| paused.active()),
RenderSet::Draw,
RenderSet::Present,
));

View File

@@ -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<AudioResource>,
mut audio_state: ResMut<AudioState>,
mut audio_events: EventReader<AudioEvent>,
_errors: EventWriter<GameError>,
) {
pub fn audio_system(mut audio: NonSendMut<AudioResource>, mut state: ResMut<AudioState>, mut events: EventReader<AudioEvent>) {
// 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"
);
}

View File

@@ -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"))]
{

View File

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

View File

@@ -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<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(
mut events: EventReader<GameEvent>,
mut paused: ResMut<Paused>,
mut pause_state: ResMut<PauseState>,
mut audio_events: EventWriter<AudioEvent>,
) {
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<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"))]
pub fn handle_fullscreen_command(mut events: EventReader<GameEvent>, mut canvas: NonSendMut<&mut Canvas<Window>>) {
for event in events.read() {