//! Rollback-simulated state for an active match. use bevy::input::InputSystems; use bevy::prelude::*; use bevy_ggrs::{ConfirmedFrameCount, RollbackFrameCount, RunGgrsSystems, prelude::*}; use crate::{ AppState, match_loading::{MatchConfig, MatchRules}, netcode::session::SessionConfig, }; #[derive(Resource, Clone, Debug, Hash, PartialEq, Eq)] pub struct MatchState { pub elapsed_frames: u32, /// Indexed by the player's session handle. pub players: Vec, /// The rollback frame on which the match first finished. pub finished_at_frame: Option, } #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub struct PlayerMatchState { pub stocks_remaining: Option, pub score: i32, 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 { MatchRules::Stock { starting_stocks, .. } => Some(*starting_stocks), MatchRules::Timed { .. } => None, }; Self { elapsed_frames: 0, players: (0..config.players.len()) .map(|_| PlayerMatchState { stocks_remaining, score: 0, deaths: 0, }) .collect(), finished_at_frame: None, } } pub fn is_match_finished(&self, config: &MatchConfig) -> bool { let time_limit = match config.rules { MatchRules::Stock { starting_stocks: _, time_limit_frames, } => time_limit_frames, MatchRules::Timed { time_limit_frames } => Some(time_limit_frames), }; if let Some(time_limit) = time_limit { if self.elapsed_frames >= time_limit { return true; } } if config.players.len() > 1 && let MatchRules::Stock { starting_stocks: _, time_limit_frames: _, } = config.rules { let alive_fighter_count = self .players .iter() .filter(|fighter| { fighter .stocks_remaining .map(|stocks| stocks) .unwrap_or_default() > 0 }) .count(); if alive_fighter_count <= 1 { return true; } } false } fn advance_frame(&mut self, config: &MatchConfig, frame: i32) { if self.finished_at_frame.is_some() { return; } self.elapsed_frames = self.elapsed_frames.saturating_add(1); if self.is_match_finished(config) { self.finished_at_frame = Some(frame); } } fn finish_is_confirmed(&self, confirmed_frame: i32) -> bool { self.finished_at_frame .is_some_and(|finished_at| confirmed_frame >= finished_at) } } pub struct MatchRuntimePlugin; impl Plugin for MatchRuntimePlugin { fn build(&self, app: &mut App) { app.rollback_resource_with_clone::() .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( GgrsSchedule, update_match_rules.in_set(crate::schedule::GameplaySet::MatchRules), ) .add_systems( PreUpdate, toggle_pause .after(InputSystems) .before(RunGgrsSystems) .run_if(in_state(AppState::InMatch)), ) .add_systems( Update, transition_after_confirmed_finish.run_if(in_state(AppState::InMatch)), ); } } pub fn ggrs_should_run(pause: Option>) -> bool { !pause.is_some_and(|pause| pause.paused) } pub(crate) fn match_is_ongoing(match_state: Option>) -> bool { match_state.is_none_or(|match_state| match_state.finished_at_frame.is_none()) } 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(); } fn update_match_rules( mut match_state: ResMut, match_config: Res, frame: Res, ) { match_state.advance_frame(&match_config, frame.0); } fn transition_after_confirmed_finish( match_state: Res, confirmed_frame: Res, mut next_state: ResMut>, ) { if match_state.finish_is_confirmed(confirmed_frame.0) { next_state.set(AppState::MatchResults); } } #[cfg(test)] mod tests { use super::*; use crate::{fighter::FighterId, match_loading::MatchPlayer, stage::manifest::StageId}; fn config(rules: MatchRules) -> MatchConfig { MatchConfig { stage: StageId::TestStage, players: vec![ MatchPlayer { handle: 1, controller_port: 3, fighter: FighterId::TestFighter, }, MatchPlayer { handle: 0, controller_port: 0, fighter: FighterId::TestFighter, }, ], rules, } } #[test] fn stock_state_starts_with_configured_stocks() { let state = MatchState::from_config(&config(MatchRules::Stock { starting_stocks: 4, time_limit_frames: Some(60), })); assert_eq!(state.elapsed_frames, 0); assert_eq!(state.finished_at_frame, None); assert_eq!(state.players.len(), 2); assert!(state.players.iter().all(|player| { player.stocks_remaining == Some(4) && player.score == 0 && player.deaths == 0 })); } #[test] fn timed_state_has_no_stock_counter() { let state = MatchState::from_config(&config(MatchRules::Timed { time_limit_frames: 60, })); assert!( state .players .iter() .all(|player| player.stocks_remaining.is_none()) ); } #[test] fn timed_match_finishes_on_its_rollback_frame() { let config = config(MatchRules::Timed { time_limit_frames: 2, }); let mut state = MatchState::from_config(&config); state.advance_frame(&config, 10); assert_eq!(state.elapsed_frames, 1); assert_eq!(state.finished_at_frame, None); state.advance_frame(&config, 11); assert_eq!(state.elapsed_frames, 2); assert_eq!(state.finished_at_frame, Some(11)); assert!(!state.finish_is_confirmed(10)); assert!(state.finish_is_confirmed(11)); state.advance_frame(&config, 12); assert_eq!(state.finished_at_frame, Some(11)); assert_eq!(state.elapsed_frames, 2); } #[test] fn corrected_rollback_state_does_not_keep_speculative_finish() { let config = config(MatchRules::Stock { starting_stocks: 1, time_limit_frames: None, }); let state_before_prediction = MatchState::from_config(&config); let mut predicted_state = state_before_prediction.clone(); predicted_state.players[1].stocks_remaining = Some(0); predicted_state.advance_frame(&config, 20); assert_eq!(predicted_state.finished_at_frame, Some(20)); let mut corrected_state = state_before_prediction; corrected_state.advance_frame(&config, 20); assert_eq!(corrected_state.finished_at_frame, None); assert!(!corrected_state.finish_is_confirmed(20)); } #[test] fn single_player_stock_match_does_not_finish_by_elimination() { let mut config = config(MatchRules::Stock { starting_stocks: 1, time_limit_frames: None, }); config.players.truncate(1); let mut state = MatchState::from_config(&config); state.advance_frame(&config, 1); assert_eq!(state.finished_at_frame, None); state.players[0].stocks_remaining = Some(0); state.advance_frame(&config, 2); assert_eq!(state.finished_at_frame, None); } #[test] fn single_player_stock_match_can_finish_by_time_limit() { let mut config = config(MatchRules::Stock { starting_stocks: 1, time_limit_frames: Some(1), }); config.players.truncate(1); let mut state = MatchState::from_config(&config); state.advance_frame(&config, 7); assert_eq!(state.finished_at_frame, Some(7)); } }