diff --git a/src/match_runtime.rs b/src/match_runtime.rs index b66f7bb..975f3cc 100644 --- a/src/match_runtime.rs +++ b/src/match_runtime.rs @@ -2,7 +2,7 @@ use bevy::input::InputSystems; use bevy::prelude::*; -use bevy_ggrs::{RunGgrsSystems, prelude::*}; +use bevy_ggrs::{ConfirmedFrameCount, RollbackFrameCount, RunGgrsSystems, prelude::*}; use crate::{ AppState, @@ -15,6 +15,8 @@ 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)] @@ -47,6 +49,7 @@ impl MatchState { deaths: 0, }) .collect(), + finished_at_frame: None, } } @@ -65,10 +68,11 @@ impl MatchState { } } - if let MatchRules::Stock { - starting_stocks: _, - time_limit_frames: _, - } = config.rules + if config.players.len() > 1 + && let MatchRules::Stock { + starting_stocks: _, + time_limit_frames: _, + } = config.rules { let alive_fighter_count = self .players @@ -88,6 +92,19 @@ impl MatchState { false } + + fn advance_frame(&mut self, config: &MatchConfig, frame: i32) { + self.elapsed_frames = self.elapsed_frames.saturating_add(1); + + if self.finished_at_frame.is_none() && 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; @@ -100,13 +117,20 @@ impl Plugin for MatchRuntimePlugin { .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, check_match_finished) - .chain() + 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)), ); } } @@ -143,12 +167,20 @@ fn reset_pause(mut pause: ResMut, mut virtual_time: ResMut, +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.is_match_finished(&match_config) { + if match_state.finish_is_confirmed(confirmed_frame.0) { next_state.set(AppState::MatchResults); } } @@ -185,6 +217,7 @@ mod tests { })); 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 @@ -204,4 +237,73 @@ mod tests { .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)); + } + + #[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)); + } } diff --git a/src/schedule.rs b/src/schedule.rs index 7c35a16..cab5ab3 100644 --- a/src/schedule.rs +++ b/src/schedule.rs @@ -25,6 +25,8 @@ pub enum GameplaySet { Animation, /// Resolve attacks Attacks, + /// Advance match rules and detect the end of the match. + MatchRules, } /// Declares the order of [`GameplaySet`] inside [`GgrsSchedule`]. Added by @@ -43,6 +45,7 @@ impl Plugin for GameplaySchedulePlugin { GameplaySet::Animation, GameplaySet::Collision, GameplaySet::Attacks, + GameplaySet::MatchRules, ) .chain(), );