diff --git a/README.md b/README.md index 99c36e8..a0d69ee 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,19 @@ cargo run -- --debug-match --players 2 --local-port 7001 \ --net-player 0=127.0.0.1:7000 --net-player 1=local ``` +## Hitbox viewer + +Run the live attack-script previewer with a fighter manifest and attack: + +```sh +cargo run --bin hitbox-editor -- \ + --manifest fighters/test/test.fighter.ron --attack jab +``` + +The timeline is read-only and refreshes through Bevy's asset hot reloading. +Press Space to pause or resume, use the arrow keys to step, or click and drag +the timeline to scrub. + ## Production Build ```sh diff --git a/src/bin/hitbox-editor.rs b/src/bin/hitbox-editor.rs new file mode 100644 index 0000000..23aa194 --- /dev/null +++ b/src/bin/hitbox-editor.rs @@ -0,0 +1,37 @@ +use bevy::{ + asset::{AssetMode, AssetPlugin}, + gltf::GltfPlugin, + image::{ImageAddressMode, ImagePlugin, ImageSamplerDescriptor}, + prelude::*, +}; +use bevy_egui::EguiPlugin; +use clap::Parser; +use shinespark::hitbox_viewer::{HitboxViewerArgs, HitboxViewerPlugin}; + +fn main() -> AppExit { + let args = HitboxViewerArgs::parse(); + let default_sampler = ImageSamplerDescriptor::linear() + .set_address_mode(ImageAddressMode::Repeat) + .set_anisotropic_filter(16) + .clone(); + let default_plugins = DefaultPlugins + .set(ImagePlugin { + default_sampler: default_sampler.clone(), + }) + .set(GltfPlugin { + default_sampler, + ..default() + }) + .set(AssetPlugin { + mode: AssetMode::Processed, + use_asset_processor_override: Some(cfg!(feature = "development")), + ..default() + }); + + App::new() + .add_plugins(default_plugins) + .add_plugins(EguiPlugin::default()) + .insert_resource(args) + .add_plugins(HitboxViewerPlugin) + .run() +} diff --git a/src/fighter/baked_animation/asset_baking.rs b/src/fighter/baked_animation/asset_baking.rs index 37fe861..6a639c6 100644 --- a/src/fighter/baked_animation/asset_baking.rs +++ b/src/fighter/baked_animation/asset_baking.rs @@ -154,6 +154,7 @@ type FighterBakeProcessor = LoadTransformAndSave< >; pub struct BakedAnimationPlugin; +pub struct BakedAnimationVisualPlugin; pub struct BakedAnimationAssetProcessingPlugin; @@ -172,17 +173,27 @@ impl Plugin for BakedAnimationAssetProcessingPlugin { impl Plugin for BakedAnimationPlugin { fn build(&self, app: &mut App) { - app.add_plugins(BakedAnimationAssetProcessingPlugin) - .add_systems( - bevy_ggrs::prelude::GgrsSchedule, - super::runtime::update_fighter_bone_matrices - .in_set(crate::schedule::GameplaySet::Animation), - ) - .add_systems( - Update, - super::visual_binding::apply_baked_fighter_visual_pose, - ) - .add_observer(super::visual_binding::bind_fighter_visual_bones); + app.add_plugins(( + BakedAnimationAssetProcessingPlugin, + BakedAnimationVisualPlugin, + )) + .add_systems( + bevy_ggrs::prelude::GgrsSchedule, + super::runtime::update_fighter_bone_matrices + .in_set(super::BakedAnimationSamplingSet) + .in_set(crate::schedule::GameplaySet::Animation), + ); + } +} + +impl Plugin for BakedAnimationVisualPlugin { + fn build(&self, app: &mut App) { + app.add_systems( + Update, + super::visual_binding::apply_baked_fighter_visual_pose + .after(super::BakedAnimationSamplingSet), + ) + .add_observer(super::visual_binding::bind_fighter_visual_bones); } } diff --git a/src/fighter/baked_animation/mod.rs b/src/fighter/baked_animation/mod.rs index 2f87f62..6d5c8b2 100644 --- a/src/fighter/baked_animation/mod.rs +++ b/src/fighter/baked_animation/mod.rs @@ -5,12 +5,17 @@ mod runtime; mod visual_binding; pub use crate::math::fixed_transform::FixedMat4; -pub use asset_baking::{BakedAnimationAssetProcessingPlugin, BakedAnimationPlugin}; +pub use asset_baking::{ + BakedAnimationAssetProcessingPlugin, BakedAnimationPlugin, BakedAnimationVisualPlugin, +}; pub use runtime::{ BakedAnimationClip, BakedFighterAnimations, FighterBoneIndexMap, FighterBoneMatrices, resample_fighter_bone_matrices, update_fighter_bone_matrices, }; +#[derive(bevy::prelude::SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct BakedAnimationSamplingSet; + pub const BAKED_ANIMATION_FPS: u32 = 60; #[cfg(test)] diff --git a/src/fighter/baked_animation/runtime.rs b/src/fighter/baked_animation/runtime.rs index fe05f0b..9429515 100644 --- a/src/fighter/baked_animation/runtime.rs +++ b/src/fighter/baked_animation/runtime.rs @@ -55,6 +55,29 @@ impl BakedFighterAnimations { pub fn frame_count(&self, kind: AnimKind) -> Option { self.clip(kind).map(|clip| clip.frames.len() as u32) } + /// Samples one bone in model space without depending on the runtime's + /// previous-frame buffers. Tooling uses this when the user scrubs between + /// unrelated frames. + pub fn sample_bone_model_transform( + &self, + frame: &FighterAnimationFrame, + bone: &str, + model_scale: FGi32, + ) -> Option { + let local = self.sample_pose(frame)?; + let index = self.bone_names.iter().position(|name| name == bone)?; + let mut model = vec![None; local.len()]; + resolve_model_transform(index, &self.parents, local, &mut model).ok()?; + let scale = FixedMat4 { + cols: [ + [model_scale, FGi32::ZERO, FGi32::ZERO, FGi32::ZERO], + [FGi32::ZERO, model_scale, FGi32::ZERO, FGi32::ZERO], + [FGi32::ZERO, FGi32::ZERO, model_scale, FGi32::ZERO], + [FGi32::ZERO, FGi32::ZERO, FGi32::ZERO, FGi32::ONE], + ], + }; + Some(scale.mul(model[index]?)) + } pub(super) fn clip(&self, kind: AnimKind) -> Option<&BakedAnimationClip> { self.clip_indices .get(&kind) @@ -78,6 +101,26 @@ pub struct FighterBoneMatrices { pub struct FighterBoneIndexMap(pub HashMap); impl FighterBoneMatrices { + /// Samples a baked pose for a preview or other non-gameplay caller. + pub fn sample_baked_pose( + &mut self, + baked: &BakedFighterAnimations, + frame: &FighterAnimationFrame, + model_scale: FGi32, + ) -> bool { + let Some(pose) = baked.sample_pose(frame) else { + return false; + }; + self.set_pose( + frame.kind, + &baked.bone_names, + &baked.parents, + pose, + model_scale, + ); + true + } + #[cfg(test)] pub(super) fn buffer_capacities(&self) -> [usize; 5] { [ diff --git a/src/fighter/baked_animation/visual_binding.rs b/src/fighter/baked_animation/visual_binding.rs index 3aa053a..088177a 100644 --- a/src/fighter/baked_animation/visual_binding.rs +++ b/src/fighter/baked_animation/visual_binding.rs @@ -92,9 +92,12 @@ pub(super) fn bind_fighter_visual_bones( pub(super) fn apply_baked_fighter_visual_pose( mut nodes: Query<(&mut Transform, &BakedFighterBone)>, fighters: Query<&FighterBoneMatrices>, - timing: Res, + timing: Option>, ) { - let overstep_fraction = timing.overstep_fraction(); + let overstep_fraction = timing + .as_deref() + .map(GgrsFrameTiming::overstep_fraction) + .unwrap_or(0.0); for (mut transform, bone) in &mut nodes { let Ok(matrices) = fighters.get(bone.fighter) else { continue; diff --git a/src/hitbox_viewer/mod.rs b/src/hitbox_viewer/mod.rs new file mode 100644 index 0000000..69833d2 --- /dev/null +++ b/src/hitbox_viewer/mod.rs @@ -0,0 +1,623 @@ +//! Standalone, read-only attack-script previewer used by `hitbox-editor`. + +use bevy::{gltf::Gltf, prelude::*, world_serialization::WorldAssetRoot}; +use bevy_egui::{EguiContexts, EguiPrimaryContextPass, egui}; +use clap::{Parser, ValueEnum}; + +use crate::{ + GameAssetPlugin, + fighter::{ + animation::FighterAnimationFrame, + attack::{AttackHitbox, AttackKind}, + baked_animation::{ + BakedAnimationAssetProcessingPlugin, BakedAnimationSamplingSet, + BakedAnimationVisualPlugin, BakedFighterAnimations, FighterBoneIndexMap, + FighterBoneMatrices, FixedMat4, + }, + manifest::{FighterHurtbox, FighterManifest}, + visual::FighterAnimations, + }, + filled_gizmos::{FilledGizmos, FilledGizmosPlugin}, + 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, +} + +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, + } + } +} + +#[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::Jab)] + pub attack: ViewerAttack, + /// Start with fighter hurtboxes hidden. + #[arg(long)] + pub hide_hurtboxes: bool, +} + +#[derive(Resource)] +struct ViewerAssets { + manifest: Handle, + model: Option>, + baked: Option>, + script: Option>, + fighter: Option, + error: Option, +} + +#[derive(Resource)] +struct ViewerState { + attack: AttackKind, + frame: u32, + playing: bool, + show_hurtboxes: bool, + elapsed: f32, +} + +pub struct HitboxViewerPlugin; + +impl Plugin for HitboxViewerPlugin { + fn build(&self, app: &mut App) { + let args = app.world().resource::(); + let attack = args.attack.into(); + let show_hurtboxes = !args.hide_hurtboxes; + app.add_plugins(( + GameAssetPlugin, + FighterScriptImportPlugin, + BakedAnimationAssetProcessingPlugin, + BakedAnimationVisualPlugin, + FilledGizmosPlugin, + )) + .insert_resource(ViewerState { + attack, + frame: 0, + playing: true, + show_hurtboxes, + elapsed: 0.0, + }) + .add_systems(Startup, setup) + .add_systems( + Update, + ( + 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) { + commands.insert_resource(ViewerAssets { + manifest: asset_server.load(args.manifest.clone()), + model: None, + baked: None, + script: None, + fighter: None, + error: None, + }); + commands.spawn(( + Name::new("Hitbox viewer camera"), + Camera3d::default(), + 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, + }); +} + +fn request_fighter_dependencies( + asset_server: Res, + manifests: Res>, + mut viewer: ResMut, + state: Res, +) { + if viewer.model.is_some() { + return; + } + let Some(manifest) = manifests.get(&viewer.manifest) else { + return; + }; + let Some(script_path) = manifest.attack_scripts.get(&state.attack) else { + viewer.error = Some(format!( + "{:?} has no {:?} attack script", + manifest.id, state.attack + )); + return; + }; + viewer.model = Some(asset_server.load(manifest.model_path.clone())); + viewer.baked = Some(asset_server.load(manifest.baked_animation_path.clone())); + viewer.script = Some(asset_server.load(script_path.clone())); + 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