mirror of
https://github.com/Xevion/Pac-Man.git
synced 2025-12-06 05:15:49 -06:00
feat: implement pause state management and single tick command
This commit is contained in:
3
Justfile
3
Justfile
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
16
src/game.rs
16
src/game.rs
@@ -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,
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"))]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ pub enum SystemId {
|
|||||||
GhostStateAnimation,
|
GhostStateAnimation,
|
||||||
EatenGhost,
|
EatenGhost,
|
||||||
TimeToLive,
|
TimeToLive,
|
||||||
|
PauseManager,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Display for SystemId {
|
impl Display for SystemId {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user