From 752950ff1680bb296ef578b60c9c50228283a727 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lex=20Rom=C3=A1n=20N=C3=BA=C3=B1ez?= Date: Sat, 26 Sep 2026 17:59:20 +0200 Subject: [PATCH] Add the ability to pause singleplayer matches --- src/match_runtime.rs | 62 ++++++++++++++++++++++++++++++++++++++++-- src/menus/input.rs | 2 +- src/netcode/session.rs | 17 ++++++++++++ src/ui/mod.rs | 57 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 131 insertions(+), 7 deletions(-) diff --git a/src/match_runtime.rs b/src/match_runtime.rs index a6a4c74..677c943 100644 --- a/src/match_runtime.rs +++ b/src/match_runtime.rs @@ -1,9 +1,14 @@ //! Rollback-simulated state for an active match. +use bevy::input::InputSystems; use bevy::prelude::*; -use bevy_ggrs::prelude::*; +use bevy_ggrs::{RunGgrsSystems, prelude::*}; -use crate::match_loading::{MatchConfig, MatchRules}; +use crate::{ + AppState, + match_loading::{MatchConfig, MatchRules}, + netcode::session::SessionConfig, +}; #[derive(Resource, Clone, Debug, Hash, PartialEq, Eq)] pub struct MatchState { @@ -19,6 +24,11 @@ pub struct PlayerMatchState { pub deaths: u32, } +#[derive(Resource, Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct PauseState { + pub paused: bool, +} + impl MatchState { pub fn from_config(config: &MatchConfig) -> Self { let stocks_remaining = match &config.rules { @@ -46,10 +56,56 @@ pub struct MatchRuntimePlugin; impl Plugin for MatchRuntimePlugin { fn build(&self, app: &mut App) { app.rollback_resource_with_clone::() - .checksum_resource_with_hash::(); + .checksum_resource_with_hash::() + .init_resource::() + .configure_sets(PreUpdate, RunGgrsSystems.run_if(ggrs_should_run)) + .add_systems(OnEnter(AppState::InMatch), reset_pause) + .add_systems(OnExit(AppState::InMatch), reset_pause) + .add_systems( + PreUpdate, + toggle_pause + .after(InputSystems) + .before(RunGgrsSystems) + .run_if(in_state(AppState::InMatch)), + ); } } +pub fn ggrs_should_run(pause: Option>) -> bool { + !pause.is_some_and(|pause| pause.paused) +} + +fn toggle_pause( + key: Res>, + gamepads: Query<&Gamepad>, + session: Res, + mut pause: ResMut, + mut virtual_time: ResMut>, +) { + if session.is_multiplayer() { + return; + } + + let pressed = key.just_pressed(KeyCode::Escape) + || gamepads + .iter() + .any(|gamepad| gamepad.just_pressed(GamepadButton::Start)); + if !pressed { + return; + } + + pause.paused = !pause.paused; + virtual_time.toggle(); +} + +fn reset_pause( + mut pause: ResMut, + mut virtual_time: ResMut>, +) { + pause.paused = false; + virtual_time.unpause(); +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/menus/input.rs b/src/menus/input.rs index 9d06dff..c37c762 100644 --- a/src/menus/input.rs +++ b/src/menus/input.rs @@ -226,7 +226,7 @@ fn gamepad_input( input.accept.accumulate(&status); } MenuInputMapAction::Start => { - input.accept.accumulate(&status); + input.start.accumulate(&status); } } } diff --git a/src/netcode/session.rs b/src/netcode/session.rs index 7193aba..897e485 100644 --- a/src/netcode/session.rs +++ b/src/netcode/session.rs @@ -42,6 +42,15 @@ pub enum SessionConfigError { } impl SessionConfig { + /// Returns whether this match has more than one remote participant. + pub fn is_multiplayer(&self) -> bool { + self.players.len() > 1 + || self + .players + .iter() + .any(|player| matches!(player, PlayerConnection::Remote(_))) + } + pub fn from_args(args: &Args, player_count: usize) -> Result { if player_count == 0 { return Err(SessionConfigError::NoPlayers); @@ -196,6 +205,13 @@ mod tests { config.players, [PlayerConnection::Local, PlayerConnection::Local] ); + assert!(config.is_multiplayer()); + } + + #[test] + fn single_local_player_is_not_multiplayer() { + let config = SessionConfig::from_args(&args(&["game", "--players", "1"]), 1).unwrap(); + assert!(!config.is_multiplayer()); } #[test] @@ -207,6 +223,7 @@ mod tests { .unwrap(); assert!(matches!(config.players[0], PlayerConnection::Local)); assert!(matches!(config.players[1], PlayerConnection::Remote(_))); + assert!(config.is_multiplayer()); } #[test] diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 2b7c6b9..3d6561b 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -3,7 +3,7 @@ use bevy::prelude::*; use crate::{ AppState, match_loading::{MatchConfig, MatchRules}, - match_runtime::MatchState, + match_runtime::{MatchState, PauseState}, }; pub mod battle_ui; @@ -11,6 +11,9 @@ pub mod errors; pub mod fighter_info; pub mod stock_icon; +#[derive(Component)] +pub struct PauseOverlay; + #[derive(Default)] pub struct InGameUIPlugin; @@ -30,8 +33,56 @@ impl Plugin for InGameUIPlugin { } else { false } - }), + }), ), - ); + ) + .add_systems(Update, sync_pause_overlay); + } +} + +pub fn sync_pause_overlay( + pause: Res, + overlay: Query>, + mut commands: Commands, +) { + if !pause.is_changed() { + return; + } + + if pause.paused { + if overlay.is_empty() { + commands.spawn(( + PauseOverlay, + Node { + position_type: PositionType::Absolute, + left: px(0), + right: px(0), + top: px(0), + bottom: px(0), + align_items: AlignItems::Center, + justify_content: JustifyContent::Center, + flex_direction: FlexDirection::Column, + ..default() + }, + BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.7)), + ZIndex(100), + children![ + ( + Text::new("Paused"), + TextFont::from_font_size(crate::menus::style::H1_SIZE as f32), + TextColor(Color::WHITE), + ), + ( + Text::new("Press Esc or Start to resume"), + TextFont::from_font_size(crate::menus::style::TEXT_SIZE as f32), + TextColor(Color::WHITE), + ), + ], + )); + } + } else { + for entity in &overlay { + commands.entity(entity).despawn(); + } } } -- 2.54.0