Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7beb22cdfe | ||
|
|
6c0d266daf |
Generated
+407
-151
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -13,7 +13,7 @@ debug_features = ["bevy/free_camera"]
|
||||
gamecube-adapter = ["dep:gc-adapter-neo"]
|
||||
|
||||
[dependencies]
|
||||
bevy = { version = "0.19", features = ["asset_processor", "basis-universal", "compressed_image_saver", "bevy_settings"] }
|
||||
bevy = { version = "0.19.1", features = ["asset_processor", "basis-universal", "compressed_image_saver", "bevy_settings"] }
|
||||
# Loads the scenes you author in the editor (assets/*.bsn) and builds
|
||||
# physics colliders from them. An ordinary dependency; remove it if you
|
||||
# stop using authored scenes.
|
||||
@@ -24,6 +24,7 @@ arrayvec = "0.7.8"
|
||||
bevy-inspector-egui = "0.37.0"
|
||||
bevy_egui = "0.40.0"
|
||||
bevy_ggrs = { git = "https://github.com/gschup/bevy_ggrs" }
|
||||
bevygraph = { git = "https://github.com/tbillington/bevy_debug_view" }
|
||||
serde = "1.0.229"
|
||||
serde_json = "1.0.145"
|
||||
bincode = "1.3.3"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -99,4 +99,5 @@ pub enum AnimKind {
|
||||
AttackForwardTilt,
|
||||
AttackDownTilt,
|
||||
AttackNeutralAir,
|
||||
AttackForwardAir,
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ impl AttackKind {
|
||||
AttackKind::NeutralAir => AnimKind::AttackNeutralAir,
|
||||
AttackKind::Jab => AnimKind::AttackJab1,
|
||||
AttackKind::UpTilt => AnimKind::AttackUpTilt,
|
||||
AttackKind::ForwardAir => AnimKind::AttackUpTilt,
|
||||
AttackKind::ForwardAir => AnimKind::AttackForwardAir,
|
||||
AttackKind::BackAir => AnimKind::AttackUpTilt,
|
||||
AttackKind::DownAir => AnimKind::AttackUpTilt,
|
||||
AttackKind::UpAir => AnimKind::AttackUpTilt,
|
||||
|
||||
@@ -154,6 +154,7 @@ type FighterBakeProcessor = LoadTransformAndSave<
|
||||
>;
|
||||
|
||||
pub struct BakedAnimationPlugin;
|
||||
pub struct BakedAnimationVisualPlugin;
|
||||
|
||||
pub struct BakedAnimationAssetProcessingPlugin;
|
||||
|
||||
@@ -172,15 +173,25 @@ impl Plugin for BakedAnimationAssetProcessingPlugin {
|
||||
|
||||
impl Plugin for BakedAnimationPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_plugins(BakedAnimationAssetProcessingPlugin)
|
||||
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),
|
||||
)
|
||||
.add_systems(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for BakedAnimationVisualPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -55,6 +55,29 @@ impl BakedFighterAnimations {
|
||||
pub fn frame_count(&self, kind: AnimKind) -> Option<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> {
|
||||
self.clip_indices
|
||||
.get(&kind)
|
||||
@@ -78,6 +101,26 @@ pub struct FighterBoneMatrices {
|
||||
pub struct FighterBoneIndexMap(pub HashMap<String, usize>);
|
||||
|
||||
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] {
|
||||
[
|
||||
|
||||
@@ -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<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 {
|
||||
let Ok(matrices) = fighters.get(bone.fighter) else {
|
||||
continue;
|
||||
|
||||
@@ -0,0 +1,694 @@
|
||||
//! Standalone, read-only attack-script previewer used by `hitbox-editor`.
|
||||
|
||||
use bevy::{camera::CameraProjection, 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(
|
||||
FixedUpdate,
|
||||
(
|
||||
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_summary_timeline(ui, &mut state, script, end);
|
||||
draw_timeline(ui, &mut state, script, end);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn draw_summary_timeline(
|
||||
ui: &mut egui::Ui,
|
||||
state: &mut ViewerState,
|
||||
script: &FighterAttackScript,
|
||||
end: u32,
|
||||
) {
|
||||
let row_height = 20.0;
|
||||
let label_width = 55.0;
|
||||
let height = (row_height + 25.0f32).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 frame in 0..end {
|
||||
let top = rect.top() + 20.0 as f32;
|
||||
painter.text(
|
||||
egui::pos2(rect.left(), top + 2.0),
|
||||
egui::Align2::LEFT_TOP,
|
||||
format!("HB {}", 2),
|
||||
egui::FontId::monospace(12.0),
|
||||
egui::Color32::LIGHT_GRAY,
|
||||
);
|
||||
let active_hitbox = script
|
||||
.hitboxes
|
||||
.iter()
|
||||
.filter(|hitbox| hitbox.start_frame <= frame && hitbox.end_frame > frame)
|
||||
.max_by(|a, b| a.id.cmp(&b.id));
|
||||
let bar = egui::Rect::from_min_max(
|
||||
egui::pos2(frame_x(timeline, frame, end), top + 2.0),
|
||||
egui::pos2(frame_x(timeline, frame + 1, end), top + row_height - 2.0),
|
||||
);
|
||||
|
||||
if let Some(_active_hitbox) = active_hitbox {
|
||||
painter.rect_filled(bar, 2.0, egui::Color32::from_rgb(245, 75, 55));
|
||||
} else {
|
||||
painter.rect_stroke(bar, 2.0, egui::Stroke::new(2.0f32, egui::Color32::from_rgb(75, 75, 75)), egui::StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 game_settings;
|
||||
pub mod input;
|
||||
pub mod hitbox_viewer;
|
||||
pub mod match_loading;
|
||||
pub mod match_runtime;
|
||||
pub mod materials;
|
||||
|
||||
Reference in New Issue
Block a user