Add live hitbox viewer
This commit is contained in:
@@ -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
|
--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
|
## Production Build
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -154,6 +154,7 @@ type FighterBakeProcessor = LoadTransformAndSave<
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
pub struct BakedAnimationPlugin;
|
pub struct BakedAnimationPlugin;
|
||||||
|
pub struct BakedAnimationVisualPlugin;
|
||||||
|
|
||||||
pub struct BakedAnimationAssetProcessingPlugin;
|
pub struct BakedAnimationAssetProcessingPlugin;
|
||||||
|
|
||||||
@@ -172,15 +173,25 @@ impl Plugin for BakedAnimationAssetProcessingPlugin {
|
|||||||
|
|
||||||
impl Plugin for BakedAnimationPlugin {
|
impl Plugin for BakedAnimationPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.add_plugins(BakedAnimationAssetProcessingPlugin)
|
app.add_plugins((
|
||||||
|
BakedAnimationAssetProcessingPlugin,
|
||||||
|
BakedAnimationVisualPlugin,
|
||||||
|
))
|
||||||
.add_systems(
|
.add_systems(
|
||||||
bevy_ggrs::prelude::GgrsSchedule,
|
bevy_ggrs::prelude::GgrsSchedule,
|
||||||
super::runtime::update_fighter_bone_matrices
|
super::runtime::update_fighter_bone_matrices
|
||||||
|
.in_set(super::BakedAnimationSamplingSet)
|
||||||
.in_set(crate::schedule::GameplaySet::Animation),
|
.in_set(crate::schedule::GameplaySet::Animation),
|
||||||
)
|
);
|
||||||
.add_systems(
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Plugin for BakedAnimationVisualPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.add_systems(
|
||||||
Update,
|
Update,
|
||||||
super::visual_binding::apply_baked_fighter_visual_pose,
|
super::visual_binding::apply_baked_fighter_visual_pose
|
||||||
|
.after(super::BakedAnimationSamplingSet),
|
||||||
)
|
)
|
||||||
.add_observer(super::visual_binding::bind_fighter_visual_bones);
|
.add_observer(super::visual_binding::bind_fighter_visual_bones);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,17 @@ mod runtime;
|
|||||||
mod visual_binding;
|
mod visual_binding;
|
||||||
|
|
||||||
pub use crate::math::fixed_transform::FixedMat4;
|
pub use crate::math::fixed_transform::FixedMat4;
|
||||||
pub use asset_baking::{BakedAnimationAssetProcessingPlugin, BakedAnimationPlugin};
|
pub use asset_baking::{
|
||||||
|
BakedAnimationAssetProcessingPlugin, BakedAnimationPlugin, BakedAnimationVisualPlugin,
|
||||||
|
};
|
||||||
pub use runtime::{
|
pub use runtime::{
|
||||||
BakedAnimationClip, BakedFighterAnimations, FighterBoneIndexMap, FighterBoneMatrices,
|
BakedAnimationClip, BakedFighterAnimations, FighterBoneIndexMap, FighterBoneMatrices,
|
||||||
resample_fighter_bone_matrices, update_fighter_bone_matrices,
|
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;
|
pub const BAKED_ANIMATION_FPS: u32 = 60;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -55,6 +55,29 @@ impl BakedFighterAnimations {
|
|||||||
pub fn frame_count(&self, kind: AnimKind) -> Option<u32> {
|
pub fn frame_count(&self, kind: AnimKind) -> Option<u32> {
|
||||||
self.clip(kind).map(|clip| clip.frames.len() as u32)
|
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<FixedMat4> {
|
||||||
|
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> {
|
pub(super) fn clip(&self, kind: AnimKind) -> Option<&BakedAnimationClip> {
|
||||||
self.clip_indices
|
self.clip_indices
|
||||||
.get(&kind)
|
.get(&kind)
|
||||||
@@ -78,6 +101,26 @@ pub struct FighterBoneMatrices {
|
|||||||
pub struct FighterBoneIndexMap(pub HashMap<String, usize>);
|
pub struct FighterBoneIndexMap(pub HashMap<String, usize>);
|
||||||
|
|
||||||
impl FighterBoneMatrices {
|
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)]
|
#[cfg(test)]
|
||||||
pub(super) fn buffer_capacities(&self) -> [usize; 5] {
|
pub(super) fn buffer_capacities(&self) -> [usize; 5] {
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -92,9 +92,12 @@ pub(super) fn bind_fighter_visual_bones(
|
|||||||
pub(super) fn apply_baked_fighter_visual_pose(
|
pub(super) fn apply_baked_fighter_visual_pose(
|
||||||
mut nodes: Query<(&mut Transform, &BakedFighterBone)>,
|
mut nodes: Query<(&mut Transform, &BakedFighterBone)>,
|
||||||
fighters: Query<&FighterBoneMatrices>,
|
fighters: Query<&FighterBoneMatrices>,
|
||||||
timing: Res<GgrsFrameTiming>,
|
timing: Option<Res<GgrsFrameTiming>>,
|
||||||
) {
|
) {
|
||||||
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 {
|
for (mut transform, bone) in &mut nodes {
|
||||||
let Ok(matrices) = fighters.get(bone.fighter) else {
|
let Ok(matrices) = fighters.get(bone.fighter) else {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -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<ViewerAttack> 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<FighterManifest>,
|
||||||
|
model: Option<Handle<Gltf>>,
|
||||||
|
baked: Option<Handle<BakedFighterAnimations>>,
|
||||||
|
script: Option<Handle<FighterAttackScript>>,
|
||||||
|
fighter: Option<Entity>,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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::<HitboxViewerArgs>();
|
||||||
|
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<AssetServer>, args: Res<HitboxViewerArgs>) {
|
||||||
|
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<AssetServer>,
|
||||||
|
manifests: Res<Assets<FighterManifest>>,
|
||||||
|
mut viewer: ResMut<ViewerAssets>,
|
||||||
|
state: Res<ViewerState>,
|
||||||
|
) {
|
||||||
|
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<Assets<Gltf>>,
|
||||||
|
baked_assets: Res<Assets<BakedFighterAnimations>>,
|
||||||
|
scripts: Res<Assets<FighterAttackScript>>,
|
||||||
|
manifests: Res<Assets<FighterManifest>>,
|
||||||
|
mut viewer: ResMut<ViewerAssets>,
|
||||||
|
state: Res<ViewerState>,
|
||||||
|
) {
|
||||||
|
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<FighterAttackScript>,
|
||||||
|
baked_assets: &Assets<BakedFighterAnimations>,
|
||||||
|
) -> 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<ButtonInput<KeyCode>>,
|
||||||
|
mut state: ResMut<ViewerState>,
|
||||||
|
viewer: Res<ViewerAssets>,
|
||||||
|
scripts: Res<Assets<FighterAttackScript>>,
|
||||||
|
baked_assets: Res<Assets<BakedFighterAnimations>>,
|
||||||
|
) {
|
||||||
|
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<Time>,
|
||||||
|
mut state: ResMut<ViewerState>,
|
||||||
|
viewer: Res<ViewerAssets>,
|
||||||
|
scripts: Res<Assets<FighterAttackScript>>,
|
||||||
|
baked_assets: Res<Assets<BakedFighterAnimations>>,
|
||||||
|
) {
|
||||||
|
let end = timeline_end(&state, &viewer, &scripts, &baked_assets);
|
||||||
|
state.frame = state.frame.min(end.saturating_sub(1));
|
||||||
|
if !state.playing {
|
||||||
|
state.elapsed = 0.0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.elapsed += time.delta_secs();
|
||||||
|
while state.elapsed >= 1.0 / 60.0 {
|
||||||
|
state.elapsed -= 1.0 / 60.0;
|
||||||
|
state.frame = (state.frame + 1) % end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_preview_frame(state: Res<ViewerState>, mut frames: Query<&mut FighterAnimationFrame>) {
|
||||||
|
for mut frame in &mut frames {
|
||||||
|
frame.kind = state.attack.get_animation();
|
||||||
|
frame.frame = state.frame;
|
||||||
|
frame.repeat = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_preview_pose(
|
||||||
|
viewer: Res<ViewerAssets>,
|
||||||
|
manifests: Res<Assets<FighterManifest>>,
|
||||||
|
baked_assets: Res<Assets<BakedFighterAnimations>>,
|
||||||
|
mut preview: Query<(
|
||||||
|
&FighterAnimationFrame,
|
||||||
|
&FighterAnimations,
|
||||||
|
&mut FighterBoneMatrices,
|
||||||
|
&mut FighterBoneIndexMap,
|
||||||
|
)>,
|
||||||
|
) {
|
||||||
|
let Some(manifest) = manifests.get(&viewer.manifest) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for (frame, animations, mut matrices, mut bone_indices) in &mut preview {
|
||||||
|
let Some(baked) = baked_assets.get(&animations.baked) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if matrices.sample_baked_pose(baked, frame, manifest.model_scale)
|
||||||
|
&& bone_indices.0.is_empty()
|
||||||
|
{
|
||||||
|
bone_indices.0.extend(
|
||||||
|
baked
|
||||||
|
.bone_names
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, name)| (name.clone(), index)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_preview(
|
||||||
|
viewer: Res<ViewerAssets>,
|
||||||
|
state: Res<ViewerState>,
|
||||||
|
scripts: Res<Assets<FighterAttackScript>>,
|
||||||
|
baked_assets: Res<Assets<BakedFighterAnimations>>,
|
||||||
|
manifests: Res<Assets<FighterManifest>>,
|
||||||
|
mut gizmos: Gizmos,
|
||||||
|
mut filled: FilledGizmos,
|
||||||
|
) {
|
||||||
|
let (Some(script_handle), Some(baked_handle)) = (&viewer.script, &viewer.baked) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (Some(script), Some(baked), Some(manifest)) = (
|
||||||
|
scripts.get(script_handle),
|
||||||
|
baked_assets.get(baked_handle),
|
||||||
|
manifests.get(&viewer.manifest),
|
||||||
|
) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let animation = FighterAnimationFrame {
|
||||||
|
kind: state.attack.get_animation(),
|
||||||
|
frame: state.frame,
|
||||||
|
repeat: false,
|
||||||
|
};
|
||||||
|
let previous_animation = FighterAnimationFrame {
|
||||||
|
frame: state.frame.saturating_sub(1),
|
||||||
|
..animation
|
||||||
|
};
|
||||||
|
let fighter_transform = FixedMat4::IDENTITY.rotate_y(1);
|
||||||
|
|
||||||
|
if state.show_hurtboxes {
|
||||||
|
for hurtbox in &manifest.hurtboxes {
|
||||||
|
draw_hurtbox(
|
||||||
|
hurtbox,
|
||||||
|
baked,
|
||||||
|
&animation,
|
||||||
|
manifest.model_scale,
|
||||||
|
fighter_transform,
|
||||||
|
&mut filled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for hitbox in script
|
||||||
|
.hitboxes
|
||||||
|
.iter()
|
||||||
|
.filter(|hitbox| hitbox.start_frame <= state.frame && state.frame < hitbox.end_frame)
|
||||||
|
{
|
||||||
|
draw_hitbox(
|
||||||
|
hitbox,
|
||||||
|
baked,
|
||||||
|
&animation,
|
||||||
|
&previous_animation,
|
||||||
|
manifest.model_scale,
|
||||||
|
fighter_transform,
|
||||||
|
&mut gizmos,
|
||||||
|
&mut filled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_hitbox(
|
||||||
|
hitbox: &AttackHitbox,
|
||||||
|
baked: &BakedFighterAnimations,
|
||||||
|
animation: &FighterAnimationFrame,
|
||||||
|
previous_animation: &FighterAnimationFrame,
|
||||||
|
model_scale: FGi32,
|
||||||
|
fighter_transform: FixedMat4,
|
||||||
|
gizmos: &mut Gizmos,
|
||||||
|
filled: &mut FilledGizmos,
|
||||||
|
) {
|
||||||
|
let Some(current_bone) =
|
||||||
|
baked.sample_bone_model_transform(animation, &hitbox.bone, model_scale)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let current = fighter_transform
|
||||||
|
.mul(current_bone)
|
||||||
|
.transform_point(hitbox.offset)
|
||||||
|
.to_vec3();
|
||||||
|
let previous = if animation.frame == hitbox.start_frame {
|
||||||
|
current
|
||||||
|
} else {
|
||||||
|
baked
|
||||||
|
.sample_bone_model_transform(previous_animation, &hitbox.bone, model_scale)
|
||||||
|
.map(|bone| {
|
||||||
|
fighter_transform
|
||||||
|
.mul(bone)
|
||||||
|
.transform_point(hitbox.offset)
|
||||||
|
.to_vec3()
|
||||||
|
})
|
||||||
|
.unwrap_or(current)
|
||||||
|
};
|
||||||
|
let center = (previous + current) * 0.5;
|
||||||
|
let length = previous.distance(current);
|
||||||
|
let direction = (current - previous).normalize_or(Vec3::Y);
|
||||||
|
let mut transform = Transform::from_translation(center);
|
||||||
|
transform.align(
|
||||||
|
Dir3::Y,
|
||||||
|
direction,
|
||||||
|
Dir3::X,
|
||||||
|
direction.any_orthonormal_vector(),
|
||||||
|
);
|
||||||
|
let radius = hitbox.radius.to_num();
|
||||||
|
filled.capsule(
|
||||||
|
transform.to_isometry(),
|
||||||
|
radius,
|
||||||
|
length * 0.5,
|
||||||
|
Color::srgba(1.0, 0.1, 0.05, 0.32),
|
||||||
|
);
|
||||||
|
gizmos.primitive_3d(
|
||||||
|
&Capsule3d::new(radius, length),
|
||||||
|
transform.to_isometry(),
|
||||||
|
Color::srgb(1.0, 0.15, 0.1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_hurtbox(
|
||||||
|
hurtbox: &FighterHurtbox,
|
||||||
|
baked: &BakedFighterAnimations,
|
||||||
|
animation: &FighterAnimationFrame,
|
||||||
|
model_scale: FGi32,
|
||||||
|
fighter_transform: FixedMat4,
|
||||||
|
filled: &mut FilledGizmos,
|
||||||
|
) {
|
||||||
|
let Some(bone) = baked.sample_bone_model_transform(animation, &hurtbox.bone, model_scale)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let local = FixedMat4::IDENTITY
|
||||||
|
.with_rotation(FGVec3::new(
|
||||||
|
hurtbox.rotation.x.to_radians(),
|
||||||
|
hurtbox.rotation.y.to_radians(),
|
||||||
|
hurtbox.rotation.z.to_radians(),
|
||||||
|
))
|
||||||
|
.with_translation(hurtbox.offset);
|
||||||
|
filled.affine_capsule(
|
||||||
|
fighter_transform.mul(bone).mul(local).to_mat4(),
|
||||||
|
hurtbox.radius.to_num(),
|
||||||
|
hurtbox.half_length.to_num(),
|
||||||
|
Color::srgba(0.15, 0.55, 1.0, 0.22),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_ui(
|
||||||
|
mut contexts: EguiContexts,
|
||||||
|
mut state: ResMut<ViewerState>,
|
||||||
|
viewer: Res<ViewerAssets>,
|
||||||
|
scripts: Res<Assets<FighterAttackScript>>,
|
||||||
|
baked_assets: Res<Assets<BakedFighterAnimations>>,
|
||||||
|
) -> Result {
|
||||||
|
let end = timeline_end(&state, &viewer, &scripts, &baked_assets);
|
||||||
|
let script = viewer
|
||||||
|
.script
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|handle| scripts.get(handle));
|
||||||
|
egui::Window::new("Timeline")
|
||||||
|
.anchor(egui::Align2::CENTER_BOTTOM, egui::vec2(0.0, -10.0))
|
||||||
|
.resizable(true)
|
||||||
|
.default_size(egui::vec2(780.0, 190.0))
|
||||||
|
.show(contexts.ctx_mut()?, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui
|
||||||
|
.button(if state.playing { "Pause" } else { "Play" })
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
state.playing = !state.playing;
|
||||||
|
}
|
||||||
|
if ui.button("<").clicked() {
|
||||||
|
state.playing = false;
|
||||||
|
state.frame = state.frame.saturating_sub(1);
|
||||||
|
}
|
||||||
|
if ui.button(">").clicked() {
|
||||||
|
state.playing = false;
|
||||||
|
state.frame = (state.frame + 1).min(end.saturating_sub(1));
|
||||||
|
}
|
||||||
|
ui.label(format!("Frame {} / {}", state.frame, end.saturating_sub(1)));
|
||||||
|
ui.checkbox(&mut state.show_hurtboxes, "Hurtboxes");
|
||||||
|
ui.separator();
|
||||||
|
ui.label(format!("{:?}", state.attack));
|
||||||
|
});
|
||||||
|
if let Some(error) = &viewer.error {
|
||||||
|
ui.colored_label(egui::Color32::RED, error);
|
||||||
|
}
|
||||||
|
let Some(script) = script else {
|
||||||
|
ui.spinner();
|
||||||
|
ui.label("Waiting for the processed attack script…");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
draw_timeline(ui, &mut state, script, end);
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_timeline(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
state: &mut ViewerState,
|
||||||
|
script: &FighterAttackScript,
|
||||||
|
end: u32,
|
||||||
|
) {
|
||||||
|
let row_height = 20.0;
|
||||||
|
let label_width = 55.0;
|
||||||
|
let height =
|
||||||
|
(script.hitboxes.len().max(1) as f32 * row_height + 25.0).min(ui.available_height());
|
||||||
|
let (response, painter) = ui.allocate_painter(
|
||||||
|
egui::vec2(ui.available_width(), height),
|
||||||
|
egui::Sense::click_and_drag(),
|
||||||
|
);
|
||||||
|
let rect = response.rect;
|
||||||
|
let timeline = egui::Rect::from_min_max(
|
||||||
|
egui::pos2(rect.left() + label_width, rect.top()),
|
||||||
|
rect.right_bottom(),
|
||||||
|
);
|
||||||
|
if (response.clicked() || response.dragged())
|
||||||
|
&& let Some(pointer) = response.interact_pointer_pos()
|
||||||
|
&& timeline.contains(pointer)
|
||||||
|
{
|
||||||
|
let fraction = ((pointer.x - timeline.left()) / timeline.width()).clamp(0.0, 1.0);
|
||||||
|
state.frame = ((end.saturating_sub(1)) as f32 * fraction).round() as u32;
|
||||||
|
state.playing = false;
|
||||||
|
}
|
||||||
|
for frame in 0..end {
|
||||||
|
if frame % 5 != 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let x = frame_x(timeline, frame, end);
|
||||||
|
painter.line_segment(
|
||||||
|
[egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())],
|
||||||
|
egui::Stroke::new(1.0_f32, egui::Color32::from_gray(55)),
|
||||||
|
);
|
||||||
|
painter.text(
|
||||||
|
egui::pos2(x + 2.0, rect.top()),
|
||||||
|
egui::Align2::LEFT_TOP,
|
||||||
|
frame.to_string(),
|
||||||
|
egui::FontId::monospace(10.0),
|
||||||
|
egui::Color32::GRAY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (row, hitbox) in script.hitboxes.iter().enumerate() {
|
||||||
|
let top = rect.top() + 20.0 + row as f32 * row_height;
|
||||||
|
painter.text(
|
||||||
|
egui::pos2(rect.left(), top + 2.0),
|
||||||
|
egui::Align2::LEFT_TOP,
|
||||||
|
format!("HB {}", hitbox.id),
|
||||||
|
egui::FontId::monospace(12.0),
|
||||||
|
egui::Color32::LIGHT_GRAY,
|
||||||
|
);
|
||||||
|
let active = hitbox.start_frame <= state.frame && state.frame < hitbox.end_frame;
|
||||||
|
let bar = egui::Rect::from_min_max(
|
||||||
|
egui::pos2(frame_x(timeline, hitbox.start_frame, end), top + 2.0),
|
||||||
|
egui::pos2(
|
||||||
|
frame_x(timeline, hitbox.end_frame, end),
|
||||||
|
top + row_height - 2.0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
painter.rect_filled(
|
||||||
|
bar,
|
||||||
|
2.0,
|
||||||
|
if active {
|
||||||
|
egui::Color32::from_rgb(245, 75, 55)
|
||||||
|
} else {
|
||||||
|
egui::Color32::from_rgb(125, 48, 42)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let playhead = frame_x(timeline, state.frame, end);
|
||||||
|
painter.line_segment(
|
||||||
|
[
|
||||||
|
egui::pos2(playhead, rect.top()),
|
||||||
|
egui::pos2(playhead, rect.bottom()),
|
||||||
|
],
|
||||||
|
egui::Stroke::new(2.0_f32, egui::Color32::WHITE),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frame_x(rect: egui::Rect, frame: u32, end: u32) -> f32 {
|
||||||
|
rect.left() + rect.width() * frame.min(end) as f32 / end.max(1) as f32
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ pub mod fighter;
|
|||||||
pub mod filled_gizmos;
|
pub mod filled_gizmos;
|
||||||
pub mod game_settings;
|
pub mod game_settings;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
|
pub mod hitbox_viewer;
|
||||||
pub mod match_loading;
|
pub mod match_loading;
|
||||||
pub mod match_runtime;
|
pub mod match_runtime;
|
||||||
pub mod materials;
|
pub mod materials;
|
||||||
|
|||||||
Reference in New Issue
Block a user