//! Standalone, read-only attack-script previewer used by `hitbox-editor`. use std::f32::consts::PI; use bevy::{ camera::ScalingMode, dev_tools::infinite_grid::{InfiniteGrid, InfiniteGridPlugin, InfiniteGridSettings}, gltf::Gltf, prelude::*, world_serialization::WorldAssetRoot, }; use bevy_egui::{EguiContexts, EguiPrimaryContextPass, egui}; use clap::{Parser, ValueEnum}; use egui_extras::Column; use itertools::Itertools; use crate::{ GameAssetPlugin, egui_color_convert::ColorToEgui, fighter::{ animation::FighterAnimationFrame, attack::{AttackHitbox, AttackKind, HitStop}, baked_animation::{ BakedAnimationAssetProcessingPlugin, BakedAnimationSamplingSet, BakedAnimationVisualPlugin, BakedFighterAnimations, FighterBoneIndexMap, FighterBoneMatrices, FixedMat4, }, manifest::{FighterHurtbox, FighterManifest}, visual::FighterAnimations, }, filled_gizmos::{FilledGizmos, FilledGizmosPlugin}, game_settings::GameSettings, math::{ int::{FGi32, FGi32Ext}, vec3::FGVec3, }, scripting::{FighterAttackScript, importer::FighterScriptImportPlugin}, }; #[derive(Clone, Copy, Debug, ValueEnum)] pub enum ViewerAttack { NeutralAir, ForwardAir, BackAir, DownAir, UpAir, Jab, UpTilt, DownTilt, ForwardTilt, UpSmash, DownSmash, ForwardSmash, DashAttack, } impl From for AttackKind { fn from(value: ViewerAttack) -> Self { match value { ViewerAttack::NeutralAir => Self::NeutralAir, ViewerAttack::ForwardAir => Self::ForwardAir, ViewerAttack::BackAir => Self::BackAir, ViewerAttack::DownAir => Self::DownAir, ViewerAttack::UpAir => Self::UpAir, ViewerAttack::Jab => Self::Jab, ViewerAttack::UpTilt => Self::UpTilt, ViewerAttack::DownTilt => Self::DownTilt, ViewerAttack::ForwardTilt => Self::ForwardTilt, ViewerAttack::UpSmash => Self::UpSmash, ViewerAttack::DownSmash => Self::DownSmash, ViewerAttack::ForwardSmash => Self::ForwardSmash, ViewerAttack::DashAttack => Self::DashAttack, } } } #[derive(Parser, Resource, Debug)] #[command(version, about = "Live preview for Shinespark attack hitboxes")] pub struct HitboxViewerArgs { /// Fighter manifest path, relative to the assets directory. #[arg(long, default_value = "fighters/test/test.fighter.ron")] pub manifest: String, /// Attack script and matching animation to preview. #[arg(long, value_enum, default_value_t = ViewerAttack::NeutralAir)] pub attack: ViewerAttack, /// Start with fighter hurtboxes hidden. #[arg(long)] pub hide_hurtboxes: bool, } #[derive(Resource)] struct ViewerAssets { settings: Handle, manifest: Handle, manifest_path: String, manifest_choices: Vec<(String, Handle)>, model: Option>, baked: Option>, script: Option>, script_attack: Option, fighter: Option, error: Option, } #[derive(Resource)] struct ViewerState { attack: AttackKind, frame: u32, playing: bool, show_hurtboxes: bool, elapsed: f32, manifest_path: String, } const VIEWER_ATTACKS: [AttackKind; 13] = [ AttackKind::NeutralAir, AttackKind::ForwardAir, AttackKind::BackAir, AttackKind::DownAir, AttackKind::UpAir, AttackKind::Jab, AttackKind::UpTilt, AttackKind::DownTilt, AttackKind::ForwardTilt, AttackKind::UpSmash, AttackKind::DownSmash, AttackKind::ForwardSmash, AttackKind::DashAttack, ]; pub struct HitboxViewerPlugin; impl Plugin for HitboxViewerPlugin { fn build(&self, app: &mut App) { let (attack, show_hurtboxes, manifest_path) = { let args = app.world().resource::(); ( args.attack.into(), !args.hide_hurtboxes, args.manifest.clone(), ) }; app.add_plugins(( GameAssetPlugin, FighterScriptImportPlugin, BakedAnimationAssetProcessingPlugin, BakedAnimationVisualPlugin, FilledGizmosPlugin, InfiniteGridPlugin, )) .insert_resource(ViewerState { attack, frame: 0, playing: false, show_hurtboxes, elapsed: 0.0, manifest_path, }) .add_systems(Startup, setup) .add_systems( FixedUpdate, ( apply_viewer_selection, request_fighter_dependencies, spawn_preview_fighter, handle_keyboard, advance_preview, sync_preview_frame, sample_preview_pose.in_set(BakedAnimationSamplingSet), draw_preview, ) .chain(), ) .add_systems(EguiPrimaryContextPass, draw_ui); } } fn setup(mut commands: Commands, asset_server: Res, args: Res) { let manifest_path = args.manifest.clone(); commands.insert_resource(ViewerAssets { settings: asset_server.load("game_settings.ron"), manifest: asset_server.load(manifest_path.clone()), manifest_path: manifest_path.clone(), manifest_choices: vec![(manifest_path.clone(), asset_server.load(manifest_path))], model: None, baked: None, script: None, script_attack: None, fighter: None, error: None, }); commands.spawn(( Name::new("Hitbox viewer camera"), Camera3d::default(), Projection::from(OrthographicProjection { scaling_mode: ScalingMode::FixedVertical { viewport_height: 60.0, }, ..OrthographicProjection::default_3d() }), Transform::from_xyz(0.0, 9.0, 38.0).looking_at(Vec3::new(0.0, 8.0, 0.0), Vec3::Y), )); commands.spawn(( Name::new("Hitbox viewer light"), DirectionalLight { illuminance: 8_000.0, shadow_maps_enabled: true, ..default() }, Transform::from_rotation(Quat::from_euler(EulerRot::XYZ, -0.7, -0.5, 0.0)), )); commands.insert_resource(GlobalAmbientLight { color: Color::WHITE, brightness: 250.0, affects_lightmapped_meshes: true, }); let mut trf = Transform::IDENTITY; trf.rotate_local_x(PI * 0.5); commands.spawn((InfiniteGrid, InfiniteGridSettings { ..default() }, trf)); } fn apply_viewer_selection( mut commands: Commands, asset_server: Res, manifests: Res>, mut viewer: ResMut, mut state: ResMut, ) { if viewer.manifest_path == state.manifest_path { return; } viewer.manifest_path = state.manifest_path.clone(); viewer.manifest = asset_server.load(viewer.manifest_path.clone()); viewer.model = None; viewer.baked = None; viewer.script = None; viewer.script_attack = None; viewer.error = None; if let Some(fighter) = viewer.fighter.take() { commands.entity(fighter).despawn(); } state.frame = 0; state.elapsed = 0.0; state.playing = false; if let Some(manifest) = manifests.get(&viewer.manifest) { if !manifest.attack_scripts.contains_key(&state.attack) { if let Some(attack) = VIEWER_ATTACKS .iter() .copied() .find(|attack| manifest.attack_scripts.contains_key(attack)) { state.attack = attack; } } } } fn request_fighter_dependencies( asset_server: Res, settings: Res>, manifests: Res>, mut viewer: ResMut, mut state: ResMut, ) { if let Some(settings) = settings.get(&viewer.settings) { for path in &settings.fighter_manifest_paths { if !viewer .manifest_choices .iter() .any(|(known, _)| known == path) { viewer .manifest_choices .push((path.clone(), asset_server.load(path.clone()))); } } } let Some(manifest) = manifests.get(&viewer.manifest) else { return; }; let Some(script_path) = manifest.attack_scripts.get(&state.attack) else { if let Some(attack) = VIEWER_ATTACKS .iter() .copied() .find(|attack| manifest.attack_scripts.contains_key(attack)) { state.attack = attack; return; } viewer.error = Some(format!( "{:?} has no {:?} attack script", manifest.id, state.attack )); return; }; if viewer.script_attack != Some(state.attack) { viewer.script = None; viewer.script_attack = None; state.frame = 0; state.elapsed = 0.0; state.playing = false; } if viewer.model.is_none() { viewer.model = Some(asset_server.load(manifest.model_path.clone())); } if viewer.baked.is_none() { viewer.baked = Some(asset_server.load(manifest.baked_animation_path.clone())); } if viewer.script.is_none() { viewer.script = Some(asset_server.load(script_path.clone())); viewer.script_attack = Some(state.attack); } viewer.error = None; } fn spawn_preview_fighter( mut commands: Commands, gltfs: Res>, baked_assets: Res>, scripts: Res>, manifests: Res>, mut viewer: ResMut, state: Res, ) { if viewer.fighter.is_some() { return; } let (Some(model_handle), Some(baked_handle), Some(script_handle)) = (&viewer.model, &viewer.baked, &viewer.script) else { return; }; let (Some(gltf), Some(_), Some(_), Some(manifest)) = ( gltfs.get(model_handle), baked_assets.get(baked_handle), scripts.get(script_handle), manifests.get(&viewer.manifest), ) else { return; }; let Some(scene) = gltf.scenes.first().cloned() else { viewer.error = Some("fighter model contains no scene".into()); return; }; let entity = commands .spawn(( Name::new("Hitbox preview fighter"), FighterAnimations { baked: baked_handle.clone(), }, FighterAnimationFrame { kind: state.attack.get_animation(), frame: state.frame, repeat: false, }, FighterBoneMatrices::default(), FighterBoneIndexMap::default(), WorldAssetRoot(scene), Transform::from_scale(Vec3::splat(manifest.model_scale.to_num())) .with_rotation(Quat::from_rotation_y(std::f32::consts::FRAC_PI_2)), )) .id(); viewer.fighter = Some(entity); } fn timeline_end( state: &ViewerState, viewer: &ViewerAssets, scripts: &Assets, baked_assets: &Assets, ) -> u32 { let animation_end = viewer .baked .as_ref() .and_then(|handle| baked_assets.get(handle)) .and_then(|baked| baked.frame_count(state.attack.get_animation())) .unwrap_or(1); let hitbox_end = viewer .script .as_ref() .and_then(|handle| scripts.get(handle)) .and_then(|script| script.hitboxes.iter().map(|hitbox| hitbox.end_frame).max()) .unwrap_or(1); animation_end.max(hitbox_end).max(1) } fn handle_keyboard( keys: Res>, mut state: ResMut, viewer: Res, scripts: Res>, baked_assets: Res>, ) { let end = timeline_end(&state, &viewer, &scripts, &baked_assets); if keys.just_pressed(KeyCode::Space) { state.playing = !state.playing; } if keys.just_pressed(KeyCode::ArrowLeft) { state.playing = false; state.frame = state.frame.saturating_sub(1); } if keys.just_pressed(KeyCode::ArrowRight) { state.playing = false; state.frame = (state.frame + 1).min(end.saturating_sub(1)); } if keys.just_pressed(KeyCode::Home) { state.playing = false; state.frame = 0; } } fn advance_preview( time: Res