diff --git a/src/lib.rs b/src/lib.rs index 349c081..9430fcf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -32,13 +32,9 @@ use clap::Parser; use ron_asset_manager::RonAssetPlugin; use crate::{ - camera::MatchCameraPlugin, - fighter::manifest::FighterManifest, - game_settings::GameSettings, - menus::style::FGUiStyle, - schedule::GameplaySchedulePlugin, - stage::manifest::StageManifest, - ui::InGameUIPlugin, + camera::MatchCameraPlugin, fighter::manifest::FighterManifest, game_settings::GameSettings, + match_runtime::MatchRuntimePlugin, menus::style::FGUiStyle, schedule::GameplaySchedulePlugin, + stage::manifest::StageManifest, ui::InGameUIPlugin, }; mod args; @@ -48,6 +44,7 @@ pub mod fighter; pub mod game_settings; pub mod input; pub mod match_loading; +pub mod match_runtime; mod math; pub mod menus; pub mod netcode; @@ -111,6 +108,7 @@ impl Plugin for GamePlugin { // Brings up GGRS (and with it `GgrsSchedule`), so it goes first. netcode::FighterNetcodePlugin::default(), GameplaySchedulePlugin, + MatchRuntimePlugin, input::FighterInputPlugin, replay::ReplayPlugin, debug_tools::DebugToolsPlugin, diff --git a/src/match_loading.rs b/src/match_loading.rs index 6dda130..5abcef2 100644 --- a/src/match_loading.rs +++ b/src/match_loading.rs @@ -8,28 +8,29 @@ use bevy_ggrs::prelude::Session; use serde::{Deserialize, Serialize}; use crate::{ + AppState, args::Args, fighter::{ + FighterId, FighterRoster, animation::AnimKind, attack::{AttackKind, FighterAttackScriptAssets}, - baked_animation::{BakedFighterAnimations, BAKED_ANIMATION_FPS}, + baked_animation::{BAKED_ANIMATION_FPS, BakedFighterAnimations}, manifest::{FighterManifest, FighterManifestRegistry}, spawn_fighter, visual::FighterAnimations, - FighterId, FighterRoster, }, input::{LocalInputAssignments, LocalInputSource}, + match_runtime::MatchState, math::{int::FGi32, vec::FGVec2}, netcode::{ - session::{create_session, ConnectionStatus, SessionConfig}, GGRSCfg, + session::{ConnectionStatus, SessionConfig, create_session}, }, scripting::FighterAttackScript, stage::{ self, manifest::{StageId, StageManifest, StageManifestRegistry}, }, - AppState, }; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -38,10 +39,49 @@ pub struct MatchPlayer { pub fighter: FighterId, } -#[derive(Resource, Debug, Clone, PartialEq, Eq)] -pub struct PendingMatch { +#[derive(Resource, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MatchConfig { pub stage: StageId, pub players: Vec, + pub rules: MatchRules, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum MatchRules { + Stock { + starting_stocks: u8, + time_limit_frames: Option, + }, + Timed { + time_limit_frames: u32, + }, +} + +impl Default for MatchRules { + fn default() -> Self { + Self::Stock { + starting_stocks: 3, + time_limit_frames: None, + } + } +} + +impl MatchRules { + fn validate(&self) -> Result<(), &'static str> { + match self { + Self::Stock { + starting_stocks: 0, .. + } => Err("stock matches must start with at least one stock"), + Self::Stock { + time_limit_frames: Some(0), + .. + } => Err("stock match time limits must be greater than zero frames"), + Self::Timed { + time_limit_frames: 0, + } => Err("timed match limits must be greater than zero frames"), + _ => Ok(()), + } + } } pub struct SelectedFighterAssets { @@ -59,7 +99,7 @@ pub struct MatchAssets { impl MatchAssets { fn requested(world: &World) -> Self { - let request = world.resource::(); + let config = world.resource::(); let asset_server = world.resource::(); let registry = world.resource::(); let stage_registry = world.resource::(); @@ -67,7 +107,7 @@ impl MatchAssets { let stage_manifests = world.resource::>(); let stage_handle = stage_registry - .get(request.stage) + .get(config.stage) .expect("Startup validation guarantees every stage has a manifest"); let stage_manifest = stage_manifests .get(&stage_handle) @@ -75,7 +115,7 @@ impl MatchAssets { Self { stage: asset_server.load(stage_manifest.model_path.clone()), - fighters: request + fighters: config .players .iter() .map(|player| { @@ -141,10 +181,10 @@ pub struct MatchRoot; pub fn initiate_match( commands: &mut Commands, next_state: &mut NextState, - request: PendingMatch, + config: MatchConfig, ) { commands.remove_resource::(); - commands.insert_resource(request); + commands.insert_resource(config); next_state.set(AppState::LoadingMatch); } @@ -166,12 +206,9 @@ pub fn initiate_default_match( if let Some(path) = &args.play_replay { match crate::replay::load(path) { Ok(replay) => { - let request = PendingMatch { - stage: replay.match_config.stage, - players: replay.match_config.players.clone(), - }; + let config = replay.match_config.clone(); commands.insert_resource(crate::replay::LoadedReplay(replay)); - initiate_match(&mut commands, &mut next_state, request); + initiate_match(&mut commands, &mut next_state, config); } Err(error) => { error!("Replay load failed: {error}"); @@ -215,16 +252,17 @@ pub fn initiate_default_match( initiate_match( &mut commands, &mut next_state, - PendingMatch { + MatchConfig { stage: StageId::TestStage, players, + rules: MatchRules::default(), }, ); } pub fn prepare_match( mut commands: Commands, - request: Res, + config: Res, assets: Res, gltfs: Res>, baked_animations: Res>, @@ -236,7 +274,7 @@ pub fn prepare_match( args: Res, mut next_state: ResMut>, ) { - if !valid_player_handles(&request.players) { + if !valid_player_handles(&config.players) { fail_match( &mut next_state, "player handles must be unique and cover 0..player_count", @@ -244,7 +282,12 @@ pub fn prepare_match( return; } - if assets.fighters.len() != request.players.len() { + if let Err(error) = config.rules.validate() { + fail_match(&mut next_state, error); + return; + } + + if assets.fighters.len() != config.players.len() { fail_match( &mut next_state, "loaded character assets do not match the requested player slots", @@ -396,7 +439,7 @@ pub fn prepare_match( )); } - let session_config = match SessionConfig::from_args(&args, request.players.len()) { + let session_config = match SessionConfig::from_args(&args, config.players.len()) { Ok(config) => config, Err(error) => { fail_match( @@ -406,7 +449,7 @@ pub fn prepare_match( return; } }; - let session = match create_session(&args, request.players.len()) { + let session = match create_session(&args, config.players.len()) { Ok(session) => session, Err(error) => { fail_match( @@ -420,7 +463,7 @@ pub fn prepare_match( let root = commands.spawn((Name::new("Match"), MatchRoot)).id(); let stage_handle = stage_registry - .get(request.stage) + .get(config.stage) .expect("Initialization check should ensure all stages exist"); let stage_manifest = stage_manifests .get(&stage_handle) @@ -433,11 +476,11 @@ pub fn prepare_match( stage_manifest.camera.clone(), ); - let mut fighter_roster = Vec::with_capacity(request.players.len()); + let mut fighter_roster = Vec::with_capacity(config.players.len()); for (index, (player, (scene, animations, attack_scripts, manifest_handle, model_scale))) in - request.players.iter().zip(prepared).enumerate() + config.players.iter().zip(prepared).enumerate() { - let spawn_x = (index as i32 * 2 + 1 - request.players.len() as i32) * 5; + let spawn_x = (index as i32 * 2 + 1 - config.players.len() as i32) * 5; fighter_roster.push(spawn_fighter( &mut commands, player.handle, @@ -451,6 +494,7 @@ pub fn prepare_match( } commands.insert_resource(FighterRoster(fighter_roster)); + commands.insert_resource(MatchState::from_config(&config)); commands.insert_resource(session_config); commands.insert_resource(ConnectionStatus::default()); commands.insert_resource::>(session); @@ -487,7 +531,8 @@ pub fn cleanup_match( commands.remove_resource::(); commands.remove_resource::(); commands.remove_resource::(); - commands.remove_resource::(); + commands.remove_resource::(); + commands.remove_resource::(); commands.remove_resource::(); } @@ -496,8 +541,8 @@ mod tests { use super::*; #[test] - fn match_request_keeps_duplicate_fighter_slots() { - let request = PendingMatch { + fn match_config_keeps_duplicate_fighter_slots() { + let config = MatchConfig { stage: StageId::TestStage, players: vec![ MatchPlayer { @@ -509,12 +554,13 @@ mod tests { fighter: FighterId::TestFighter, }, ], + rules: MatchRules::default(), }; - assert_eq!(request.players.len(), 2); - assert_eq!(request.players[0].handle, 0); - assert_eq!(request.players[1].handle, 1); - assert!(valid_player_handles(&request.players)); + assert_eq!(config.players.len(), 2); + assert_eq!(config.players[0].handle, 0); + assert_eq!(config.players[1].handle, 1); + assert!(valid_player_handles(&config.players)); } #[test] @@ -533,6 +579,53 @@ mod tests { assert!(!valid_player_handles(&players)); } + #[test] + fn match_rules_reject_zero_limits() { + assert!( + MatchRules::Stock { + starting_stocks: 0, + time_limit_frames: None, + } + .validate() + .is_err() + ); + assert!( + MatchRules::Stock { + starting_stocks: 3, + time_limit_frames: Some(0), + } + .validate() + .is_err() + ); + assert!( + MatchRules::Timed { + time_limit_frames: 0, + } + .validate() + .is_err() + ); + } + + #[test] + fn match_rules_accept_valid_modes() { + assert!(MatchRules::default().validate().is_ok()); + assert!( + MatchRules::Stock { + starting_stocks: 3, + time_limit_frames: Some(60), + } + .validate() + .is_ok() + ); + assert!( + MatchRules::Timed { + time_limit_frames: 60, + } + .validate() + .is_ok() + ); + } + #[test] fn match_loading_waits_for_attack_scripts() { let mut attack_scripts = HashMap::new(); diff --git a/src/match_runtime.rs b/src/match_runtime.rs new file mode 100644 index 0000000..a6a4c74 --- /dev/null +++ b/src/match_runtime.rs @@ -0,0 +1,102 @@ +//! Rollback-simulated state for an active match. + +use bevy::prelude::*; +use bevy_ggrs::prelude::*; + +use crate::match_loading::{MatchConfig, MatchRules}; + +#[derive(Resource, Clone, Debug, Hash, PartialEq, Eq)] +pub struct MatchState { + pub elapsed_frames: u32, + /// Indexed by the player's session handle. + pub players: Vec, +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub struct PlayerMatchState { + pub stocks_remaining: Option, + pub score: i32, + pub deaths: u32, +} + +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(), + } + } +} + +pub struct MatchRuntimePlugin; + +impl Plugin for MatchRuntimePlugin { + fn build(&self, app: &mut App) { + app.rollback_resource_with_clone::() + .checksum_resource_with_hash::(); + } +} + +#[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, + fighter: FighterId::TestFighter, + }, + MatchPlayer { + handle: 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.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()) + ); + } +} diff --git a/src/menus/match_config.rs b/src/menus/match_config.rs index b2db81f..b5e1308 100644 --- a/src/menus/match_config.rs +++ b/src/menus/match_config.rs @@ -3,7 +3,7 @@ use thiserror::Error; use crate::{ fighter::FighterId, - match_loading::{MatchPlayer, PendingMatch}, + match_loading::{MatchConfig, MatchPlayer, MatchRules}, stage::manifest::StageId, }; @@ -60,7 +60,7 @@ impl MenuMatchConfig { !has_unselected_fighter } - pub fn to_pending_match(&self) -> Result { + pub fn to_match_config(&self) -> Result { let mut match_players = vec![]; let stage = self.stage.ok_or(MenuMatchConfigErrors::StageNotSelected)?; @@ -78,9 +78,28 @@ impl MenuMatchConfig { }); } - Ok(PendingMatch { + Ok(MatchConfig { players: match_players, stage, + rules: MatchRules::default(), }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn menu_config_uses_default_match_rules() { + let menu = MenuMatchConfig { + stage: Some(StageId::TestStage), + players: vec![MenuMatchPlayerSlot { + slot: 0, + fighter: Some(FighterId::TestFighter), + }], + }; + + assert_eq!(menu.to_match_config().unwrap().rules, MatchRules::default()); + } +} diff --git a/src/menus/stage_select/mod.rs b/src/menus/stage_select/mod.rs index 5108a0c..8a3a87d 100644 --- a/src/menus/stage_select/mod.rs +++ b/src/menus/stage_select/mod.rs @@ -77,7 +77,7 @@ pub fn stage_selected( initiate_match( &mut commands, &mut next_state, - match_config.to_pending_match()?, + match_config.to_match_config()?, ); } Ok(()) diff --git a/src/replay.rs b/src/replay.rs index 9b439a0..95055c4 100644 --- a/src/replay.rs +++ b/src/replay.rs @@ -12,16 +12,12 @@ use clap::ValueEnum; use serde::{Deserialize, Serialize}; use crate::{ - AppState, - args::Args, - input::FighterInputFrame, - match_loading::{MatchPlayer, PendingMatch}, - netcode::GGRSCfg, + AppState, args::Args, input::FighterInputFrame, match_loading::MatchConfig, netcode::GGRSCfg, schedule::GameplaySet, }; const MAGIC: &[u8; 8] = b"SPRPLY01"; -pub const REPLAY_VERSION: u32 = 1; +pub const REPLAY_VERSION: u32 = 2; #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)] pub enum ReplayFormat { @@ -34,17 +30,11 @@ pub enum ReplayFormat { pub struct Replay { pub replay_version: u32, pub game_version: String, - pub match_config: ReplayMatch, + pub match_config: MatchConfig, pub frames: Vec, pub checksums: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct ReplayMatch { - pub stage: crate::stage::manifest::StageId, - pub players: Vec, -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ReplayFrame { pub frame: i32, @@ -267,27 +257,27 @@ fn stop_recording( mut commands: Commands, stop_request: Option>, runtime: Option>, - request: Option>, + config: Option>, ) { if stop_request.is_none() { return; } commands.remove_resource::(); - finish_recording_inner(&mut commands, runtime, request); + finish_recording_inner(&mut commands, runtime, config); } fn finish_recording( mut commands: Commands, runtime: Option>, - request: Option>, + config: Option>, ) { - finish_recording_inner(&mut commands, runtime, request); + finish_recording_inner(&mut commands, runtime, config); } fn finish_recording_inner( commands: &mut Commands, runtime: Option>, - request: Option>, + config: Option>, ) { let Some(mut runtime) = runtime else { return }; let ReplayMode::Record { path, format } = &runtime.mode else { @@ -296,16 +286,13 @@ fn finish_recording_inner( if runtime.finished || runtime.frames.is_empty() { return; } - let Some(request) = request else { return }; + let Some(config) = config else { return }; let path = path.clone(); let format = *format; let replay = Replay { replay_version: REPLAY_VERSION, game_version: env!("CARGO_PKG_VERSION").into(), - match_config: ReplayMatch { - stage: request.stage, - players: request.players.clone(), - }, + match_config: config.as_ref().clone(), frames: runtime .frames .iter() @@ -355,7 +342,7 @@ mod tests { use super::*; use crate::{ fighter::FighterId, - match_loading::MatchPlayer, + match_loading::{MatchPlayer, MatchRules}, math::{int::FGi32, vec::FGVec2}, stage::manifest::StageId, }; @@ -364,12 +351,15 @@ mod tests { Replay { replay_version: REPLAY_VERSION, game_version: "test".into(), - match_config: ReplayMatch { + match_config: MatchConfig { stage: StageId::TestStage, players: vec![MatchPlayer { handle: 0, fighter: FighterId::TestFighter, }], + rules: MatchRules::Timed { + time_limit_frames: 21_600, + }, }, frames: vec![ReplayFrame { frame: 1, @@ -406,4 +396,15 @@ mod tests { replay.frames[0].inputs.clear(); assert!(validate(&replay).is_err()); } + + #[test] + fn unsupported_replay_version_is_rejected() { + let mut replay = sample(); + replay.replay_version = 1; + + assert_eq!( + validate(&replay).unwrap_err(), + "unsupported replay version 1" + ); + } } diff --git a/src/ui/battle_ui.rs b/src/ui/battle_ui.rs index 3013b58..04a427f 100644 --- a/src/ui/battle_ui.rs +++ b/src/ui/battle_ui.rs @@ -1,20 +1,29 @@ use bevy::prelude::*; use crate::{ - fighter::manifest::{FighterManifest, FighterManifestRegistry}, match_loading::{PendingMatch}, ui::{errors::UIErrors, fighter_info::FighterInfoUI}, + fighter::manifest::{FighterManifest, FighterManifestRegistry}, + match_loading::MatchConfig, + ui::{errors::UIErrors, fighter_info::FighterInfoUI}, }; #[derive(Component, FromTemplate)] -pub struct BattleUI { -} - -pub fn spawn_battle_ui(mut commands: Commands, request: Res, fighter_registry: Res, manifests: Res>) -> Result { +pub struct BattleUI {} +pub fn spawn_battle_ui( + mut commands: Commands, + config: Res, + fighter_registry: Res, + manifests: Res>, +) -> Result { let mut fighter_uis = vec![]; - for player in &request.players { - let manifest = fighter_registry.get(player.fighter).ok_or_else(|| UIErrors::FighterManifestHandleNotFound(player.fighter))?; - let manifest = manifests.get(manifest).ok_or_else(|| UIErrors::FighterManifestNotFound(player.fighter))?; + for player in &config.players { + let manifest = fighter_registry + .get(player.fighter) + .ok_or_else(|| UIErrors::FighterManifestHandleNotFound(player.fighter))?; + let manifest = manifests + .get(manifest) + .ok_or_else(|| UIErrors::FighterManifestNotFound(player.fighter))?; fighter_uis.push(FighterInfoUI::build(manifest, player)); } @@ -54,4 +63,4 @@ pub fn cleanup_battle_ui(mut commands: Commands, battle_ui: Query