Add the ability to pause singleplayer matches #68

Merged
EIREXE merged 1 commits from pausing into master 2026-09-26 16:25:36 +00:00
4 changed files with 131 additions and 7 deletions
+59 -3
View File
@@ -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::<MatchState>()
.checksum_resource_with_hash::<MatchState>();
.checksum_resource_with_hash::<MatchState>()
.init_resource::<PauseState>()
.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<Res<PauseState>>) -> bool {
!pause.is_some_and(|pause| pause.paused)
}
fn toggle_pause(
key: Res<ButtonInput<KeyCode>>,
gamepads: Query<&Gamepad>,
session: Res<SessionConfig>,
mut pause: ResMut<PauseState>,
mut virtual_time: ResMut<Time<Virtual>>,
) {
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<PauseState>,
mut virtual_time: ResMut<Time<Virtual>>,
) {
pause.paused = false;
virtual_time.unpause();
}
#[cfg(test)]
mod tests {
use super::*;
+1 -1
View File
@@ -226,7 +226,7 @@ fn gamepad_input(
input.accept.accumulate(&status);
}
MenuInputMapAction::Start => {
input.accept.accumulate(&status);
input.start.accumulate(&status);
}
}
}
+17
View File
@@ -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<Self, SessionConfigError> {
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]
+54 -3
View File
@@ -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<PauseState>,
overlay: Query<Entity, With<PauseOverlay>>,
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();
}
}
}