//! Match selection, asset loading, preparation, and cleanup. use std::collections::HashMap; use bevy::{asset::UntypedHandle, gltf::Gltf, prelude::*}; use bevy_asset_loader::prelude::*; use bevy_ggrs::prelude::Session; use serde::{Deserialize, Serialize}; use crate::{ AppState, args::Args, fighter::{ FighterId, FighterRoster, animation::AnimKind, attack::{AttackKind, FighterAttackScriptAssets}, baked_animation::{BAKED_ANIMATION_FPS, BakedFighterAnimations}, manifest::{FighterManifest, FighterManifestRegistry}, spawn_fighter, visual::FighterAnimations, }, input::{LocalInputAssignments, LocalInputSource}, match_runtime::MatchState, math::{int::FGi32, vec::FGVec2}, netcode::{ GGRSCfg, session::{ConnectionStatus, SessionConfig, create_session}, }, scripting::FighterAttackScript, stage::{ self, manifest::{StageId, StageManifest, StageManifestRegistry}, }, }; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct MatchPlayer { pub handle: usize, pub fighter: FighterId, } #[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 { pub fighter: FighterId, pub model: Handle, pub baked_animations: Handle, pub attack_scripts: HashMap>, } #[derive(Resource)] pub struct MatchAssets { pub stage: Handle, pub fighters: Vec, } impl MatchAssets { fn requested(world: &World) -> Self { let config = world.resource::(); let asset_server = world.resource::(); let registry = world.resource::(); let stage_registry = world.resource::(); let manifests = world.resource::>(); let stage_manifests = world.resource::>(); let stage_handle = stage_registry .get(config.stage) .expect("Startup validation guarantees every stage has a manifest"); let stage_manifest = stage_manifests .get(&stage_handle) .expect("startup-loaded stage manifest should remain available"); Self { stage: asset_server.load(stage_manifest.model_path.clone()), fighters: config .players .iter() .map(|player| { let handle = registry .get(player.fighter) .expect("startup validation guarantees every fighter has a manifest"); let manifest = manifests .get(handle) .expect("startup-loaded fighter manifest should remain available"); SelectedFighterAssets { fighter: player.fighter, model: asset_server.load(manifest.model_path.clone()), baked_animations: asset_server.load(manifest.baked_animation_path.clone()), attack_scripts: manifest .attack_scripts .clone() .into_iter() .map(|(kind, path)| (kind, asset_server.load(path))) .collect(), } }) .collect(), } } fn into_loading_handles(self) -> Vec { let mut handles = Vec::with_capacity( 1 + self .fighters .iter() .map(|fighter| 2 + fighter.attack_scripts.len()) .sum::(), ); handles.push(self.stage.untyped()); for character in self.fighters { handles.push(character.model.untyped()); handles.push(character.baked_animations.untyped()); handles.extend( character .attack_scripts .into_values() .map(|handle| handle.untyped()), ); } handles } } impl AssetCollection for MatchAssets { fn create(world: &mut World) -> Self { Self::requested(world) } fn load(world: &mut World) -> Vec { Self::requested(world).into_loading_handles() } } #[derive(Component)] pub struct MatchRoot; pub fn initiate_match( commands: &mut Commands, next_state: &mut NextState, config: MatchConfig, ) { commands.remove_resource::(); commands.insert_resource(config); next_state.set(AppState::LoadingMatch); } pub fn initiate_default_match( mut commands: Commands, args: Res, gamepads: Query>, mut assigments: ResMut, mut next_state: ResMut>, completed: Option>, ) { if !args.debug_match { next_state.set(AppState::MainMenu); return; } if completed.is_some() { return; } if let Some(path) = &args.play_replay { match crate::replay::load(path) { Ok(replay) => { let config = replay.match_config.clone(); commands.insert_resource(crate::replay::LoadedReplay(replay)); initiate_match(&mut commands, &mut next_state, config); } Err(error) => { error!("Replay load failed: {error}"); next_state.set(AppState::MatchLoadFailed); } } return; } let session_config = match SessionConfig::from_args(&args, args.players) { Ok(config) => config, Err(error) => { error!("Invalid network configuration: {error}"); next_state.set(AppState::MatchLoadFailed); return; } }; let players = (0..args.players) .map(|handle| MatchPlayer { handle, fighter: FighterId::TestFighter, }) .collect(); assigments.clear(); let local_handles = session_config .players .iter() .enumerate() .filter_map(|(handle, player)| { matches!(player, crate::netcode::session::PlayerConnection::Local).then_some(handle) }); let mut local_handles = local_handles; if let Some(handle) = local_handles.next() { assigments.push((handle, LocalInputSource::Keyboard)); } for (entity, handle) in gamepads.iter().zip(local_handles) { assigments.push((handle, LocalInputSource::Gamepad(entity))); } initiate_match( &mut commands, &mut next_state, MatchConfig { stage: StageId::TestStage, players, rules: MatchRules::default(), }, ); } pub fn prepare_match( mut commands: Commands, config: Res, assets: Res, gltfs: Res>, baked_animations: Res>, attack_scripts: Res>, registry: Res, stage_registry: Res, manifests: Res>, stage_manifests: Res>, args: Res, mut next_state: ResMut>, ) { if !valid_player_handles(&config.players) { fail_match( &mut next_state, "player handles must be unique and cover 0..player_count", ); return; } 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", ); return; } let mut prepared = Vec::with_capacity(assets.fighters.len()); for character in &assets.fighters { let manifest_handle = registry .get(character.fighter) .expect("startup validation guarantees every fighter has a manifest"); let Some(manifest) = manifests.get(manifest_handle) else { fail_match(&mut next_state, "a fighter manifest is unavailable"); return; }; let Some(gltf) = gltfs.get(&character.model) else { fail_match(&mut next_state, "a loaded character GLTF is unavailable"); return; }; let Some(baked) = baked_animations.get(&character.baked_animations) else { fail_match( &mut next_state, "a fighter baked animation asset is unavailable", ); return; }; if baked.frame_rate != BAKED_ANIMATION_FPS { fail_match( &mut next_state, &format!( "{:?} baked animation rate is {}, expected {}", character.fighter, baked.frame_rate, BAKED_ANIMATION_FPS ), ); return; } if !baked.has_unique_bone_names() { fail_match( &mut next_state, &format!( "{:?} baked animation contains duplicate bone names", character.fighter ), ); return; } if let Err(error) = manifest.attributes.validate() { fail_match( &mut next_state, &format!("{:?} has invalid attributes: {error}", character.fighter), ); return; } for (index, hurtbox) in manifest.hurtboxes.iter().enumerate() { if let Err(error) = hurtbox.validate() { fail_match( &mut next_state, &format!( "{:?} hurtbox {index} is invalid: {error}", character.fighter ), ); return; } if !baked.contains_bone(&hurtbox.bone) { fail_match( &mut next_state, &format!( "{:?} hurtbox {index} references missing bone {:?}", character.fighter, hurtbox.bone ), ); return; } } for (kind, handle) in &character.attack_scripts { let Some(script) = attack_scripts.get(handle) else { fail_match( &mut next_state, &format!( "{:?} {kind:?} attack script is unavailable", character.fighter ), ); return; }; if let Err(error) = script.validate() { fail_match( &mut next_state, &format!( "{:?} {kind:?} attack script is invalid: {error}", character.fighter ), ); return; } for hitbox in &script.hitboxes { if !baked.contains_bone(&hitbox.bone) { fail_match( &mut next_state, &format!( "{:?} {kind:?} hitbox {} references missing bone {:?}", character.fighter, hitbox.id, hitbox.bone ), ); return; } } } let Some(scene) = gltf.scenes.first().cloned() else { fail_match(&mut next_state, "a character GLTF contains no scene"); return; }; for kind in manifest.animations.keys() { if baked.frame_count(*kind).is_none() { fail_match( &mut next_state, &format!( "{:?} baked data has no {kind:?} animation", character.fighter ), ); return; } } if baked.frame_count(AnimKind::Wait).is_none() { fail_match(&mut next_state, "a character has no Wait animation"); return; } let animations = FighterAnimations { baked: character.baked_animations.clone(), }; let attack_scripts = FighterAttackScriptAssets { scripts: character.attack_scripts.clone(), }; prepared.push(( scene, animations, attack_scripts, manifest_handle, manifest.model_scale, )); } let session_config = match SessionConfig::from_args(&args, config.players.len()) { Ok(config) => config, Err(error) => { fail_match( &mut next_state, &format!("invalid network configuration: {error}"), ); return; } }; let session = match create_session(&args, config.players.len()) { Ok(session) => session, Err(error) => { fail_match( &mut next_state, &format!("failed to create GGRS session: {error}"), ); return; } }; let root = commands.spawn((Name::new("Match"), MatchRoot)).id(); let stage_handle = stage_registry .get(config.stage) .expect("Initialization check should ensure all stages exist"); let stage_manifest = stage_manifests .get(&stage_handle) .expect("Initialization check should ensure stage manifests are kept alive"); stage::spawn_stage( &mut commands, root, assets.stage.clone(), stage_manifest.to_collision(), stage_manifest.camera.clone(), ); let mut fighter_roster = Vec::with_capacity(config.players.len()); for (index, (player, (scene, animations, attack_scripts, manifest_handle, model_scale))) in config.players.iter().zip(prepared).enumerate() { let spawn_x = (index as i32 * 2 + 1 - config.players.len() as i32) * 5; fighter_roster.push(spawn_fighter( &mut commands, player.handle, FGVec2::new(FGi32::from_num(spawn_x), FGi32::lit("12.5")), manifest_handle.clone(), model_scale, animations, attack_scripts, WorldAssetRoot(scene), )); } 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); next_state.set(AppState::InMatch); } fn fail_match(next_state: &mut NextState, message: &str) { error!("Match preparation failed: {message}"); next_state.set(AppState::MatchLoadFailed); } fn valid_player_handles(players: &[MatchPlayer]) -> bool { let mut handles = players .iter() .map(|player| player.handle) .collect::>(); handles.sort_unstable(); handles == (0..players.len()).collect::>() } pub fn cleanup_match( mut commands: Commands, roots: Query>, fighters: Query>, ) { for root in &roots { commands.entity(root).despawn(); } for fighter in &fighters { commands.entity(fighter).despawn(); } commands.remove_resource::>(); commands.remove_resource::(); commands.remove_resource::(); commands.remove_resource::(); commands.remove_resource::(); commands.remove_resource::(); commands.remove_resource::(); commands.remove_resource::(); } #[cfg(test)] mod tests { use super::*; #[test] fn match_config_keeps_duplicate_fighter_slots() { let config = MatchConfig { stage: StageId::TestStage, players: vec![ MatchPlayer { handle: 0, fighter: FighterId::TestFighter, }, MatchPlayer { handle: 1, fighter: FighterId::TestFighter, }, ], rules: MatchRules::default(), }; 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] fn player_handles_must_match_the_session_range() { let players = [ MatchPlayer { handle: 0, fighter: FighterId::TestFighter, }, MatchPlayer { handle: 0, fighter: FighterId::TestFighter, }, ]; 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(); attack_scripts.insert(AttackKind::Jab, Handle::default()); let assets = MatchAssets { stage: Handle::default(), fighters: vec![SelectedFighterAssets { fighter: FighterId::TestFighter, model: Handle::default(), baked_animations: Handle::default(), attack_scripts, }], }; assert_eq!(assets.into_loading_handles().len(), 4); } }