Attack system revamp

- Add crouch cancelling
- Add IASA for aerial attacks
- Add scaffolding for autocancel
- Add hitbox group system
- Add new ergonomic features for attack scripting
- Add more information to the hitbox viewer
- Improve the looks of debug capsules
This commit is contained in:
2026-10-04 17:16:42 +02:00
parent 04aceb05a9
commit a0f6813387
14 changed files with 983 additions and 428 deletions
+445 -112
View File
@@ -1,28 +1,30 @@
//! Standalone, read-only attack-script previewer used by `hitbox-editor`.
use bevy::{gltf::Gltf, prelude::*, world_serialization::WorldAssetRoot};
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,
fighter::{
animation::FighterAnimationFrame,
attack::{AttackHitbox, AttackKind},
baked_animation::{
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},
math::{
}, manifest::{FighterHurtbox, FighterManifest}, visual::FighterAnimations,
}, filled_gizmos::{FilledGizmos, FilledGizmosPlugin}, game_settings::GameSettings, math::{
int::{FGi32, FGi32Ext},
vec3::FGVec3,
},
scripting::{FighterAttackScript, importer::FighterScriptImportPlugin},
}, scripting::{FighterAttackScript, importer::FighterScriptImportPlugin},
};
#[derive(Clone, Copy, Debug, ValueEnum)]
@@ -67,7 +69,7 @@ pub struct HitboxViewerArgs {
#[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)]
#[arg(long, value_enum, default_value_t = ViewerAttack::NeutralAir)]
pub attack: ViewerAttack,
/// Start with fighter hurtboxes hidden.
#[arg(long)]
@@ -76,10 +78,14 @@ pub struct HitboxViewerArgs {
#[derive(Resource)]
struct ViewerAssets {
settings: Handle<GameSettings>,
manifest: Handle<FighterManifest>,
manifest_path: String,
manifest_choices: Vec<(String, Handle<FighterManifest>)>,
model: Option<Handle<Gltf>>,
baked: Option<Handle<BakedFighterAnimations>>,
script: Option<Handle<FighterAttackScript>>,
script_attack: Option<AttackKind>,
fighter: Option<Entity>,
error: Option<String>,
}
@@ -91,33 +97,57 @@ struct ViewerState {
playing: bool,
show_hurtboxes: bool,
elapsed: f32,
manifest_path: String,
}
const VIEWER_ATTACKS: [AttackKind; 12] = [
AttackKind::NeutralAir,
AttackKind::ForwardAir,
AttackKind::BackAir,
AttackKind::DownAir,
AttackKind::UpAir,
AttackKind::Jab,
AttackKind::UpTilt,
AttackKind::DownTilt,
AttackKind::ForwardTilt,
AttackKind::UpSmash,
AttackKind::DownSmash,
AttackKind::ForwardSmash,
];
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;
let (attack, show_hurtboxes, manifest_path) = {
let args = app.world().resource::<HitboxViewerArgs>();
(
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: true,
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,
@@ -133,17 +163,28 @@ impl Plugin for HitboxViewerPlugin {
}
fn setup(mut commands: Commands, asset_server: Res<AssetServer>, args: Res<HitboxViewerArgs>) {
let manifest_path = args.manifest.clone();
commands.insert_resource(ViewerAssets {
manifest: asset_server.load(args.manifest.clone()),
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((
@@ -160,30 +201,102 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>, args: Res<Hitbo
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<AssetServer>,
manifests: Res<Assets<FighterManifest>>,
mut viewer: ResMut<ViewerAssets>,
mut state: ResMut<ViewerState>,
) {
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<AssetServer>,
settings: Res<Assets<GameSettings>>,
manifests: Res<Assets<FighterManifest>>,
mut viewer: ResMut<ViewerAssets>,
state: Res<ViewerState>,
mut state: ResMut<ViewerState>,
) {
if viewer.model.is_some() {
return;
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;
};
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()));
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;
}
@@ -349,7 +462,6 @@ fn draw_preview(
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 {
@@ -398,7 +510,6 @@ fn draw_preview(
&previous_animation,
manifest.model_scale,
fighter_transform,
&mut gizmos,
&mut filled,
);
}
@@ -411,7 +522,6 @@ fn draw_hitbox(
previous_animation: &FighterAnimationFrame,
model_scale: FGi32,
fighter_transform: FixedMat4,
gizmos: &mut Gizmos,
filled: &mut FilledGizmos,
) {
let Some(current_bone) =
@@ -451,13 +561,16 @@ fn draw_hitbox(
transform.to_isometry(),
radius,
length * 0.5,
Color::srgba(1.0, 0.1, 0.05, 0.32),
bevy::color::Srgba {
alpha: 0.32,
..hitbox.get_debug_draw_color()
},
);
gizmos.primitive_3d(
/*gizmos.primitive_3d(
&Capsule3d::new(radius, length),
transform.to_isometry(),
Color::srgb(1.0, 0.15, 0.1),
);
);*/
}
fn draw_hurtbox(
@@ -487,10 +600,204 @@ fn draw_hurtbox(
);
}
fn draw_summary_ui(
ui: &mut egui::Ui,
script: &FighterAttackScript,
end: u32,
) {
egui::Grid::new("Summary Grid")
.num_columns(4)
.striped(true)
.show(ui, |ui| {
ui.label("First active");
ui.label("Last active");
ui.label("Total");
ui.label("IASA");
ui.end_row();
ui.label(
script
.hitboxes
.iter()
.map(|hb| hb.start_frame)
.min()
.map(|frame| format!("{}", frame))
.unwrap_or("-".to_owned()),
);
ui.label(
script
.hitboxes
.iter()
.map(|hb| hb.end_frame)
.max()
.map(|frame| format!("{}", frame - 1))
.unwrap_or("-".to_owned()),
);
ui.label(format!("{}", end));
let iasa_frame_label = if let Some(iasa_frame) = script.iasa_frame {
format!("{}", iasa_frame)
} else {
"-".to_owned()
};
ui.label(iasa_frame_label);
ui.end_row();
ui.label("Landing lag");
ui.label("Autocancel");
ui.label("Damage");
ui.end_row();
let damages = script
.hitboxes
.iter()
.map(|hb| hb.damage)
.sorted()
.dedup()
.map(|d| format!("{}", d))
.join(" / ");
ui.label(
script
.landing_duration
.map(|duration| format!("{}", duration))
.unwrap_or("-".to_string()),
);
let autocancel_text =
if let Some(wont_autocancel_window) = script.wont_autocancel_window {
let autocancel_start = if wont_autocancel_window.0 > 0 {
format!("{}", wont_autocancel_window.0)
} else {
"-".to_string()
};
let autocancel_end = if wont_autocancel_window.1 < end {
format!("{}", wont_autocancel_window.1)
} else {
"-".to_string()
};
format!("<{} {}>", autocancel_start, autocancel_end)
} else {
"-".to_string()
};
ui.label(autocancel_text);
ui.label(format!("{} %", damages));
ui.end_row();
ui.label("Base knockback");
ui.label("Knockback Growth");
ui.label("Set knockback");
ui.label("Angle");
ui.end_row();
let base_knockbacks = script
.hitboxes
.iter()
.map(|hb| hb.knockback)
.sorted()
.dedup()
.map(|d| format!("{}", d))
.join(" / ");
ui.label(base_knockbacks);
let knockback_growths = script
.hitboxes
.iter()
.map(|hb| hb.knockback_growth)
.sorted()
.dedup()
.map(|d| format!("{}", d))
.join(" / ");
ui.label(knockback_growths);
ui.label("-");
let angles = script
.hitboxes
.iter()
.map(|hb| hb.angle.to_melee_angle_representation())
.sorted()
.dedup()
.map(|d| {
format!("{}", d)
})
.join(" / ");
ui.label(angles);
});
ui.separator();
const COLS: [&str; 8] = [
"Hitbox",
"Damage",
"Angle",
"Base Knockback",
"Knockback Growth",
"Set Knockback",
"Hitstop Attacker",
"Hitstop Defender",
];
let table_builder = egui_extras::TableBuilder::new(ui)
.striped(true)
.resizable(true)
.cell_layout(egui::Layout::left_to_right(egui::Align::Center))
.columns(Column::remainder(), COLS.len());
table_builder.header(20.0, |mut header| {
for col in COLS {
header.col(|ui| {
ui.label(col);
});
}
}).body(|body| {
body.rows(18.0, script.hitboxes.len(), |mut row| {
let hitbox = &script.hitboxes[row.index()];
let hitbox_color = hitbox.get_debug_draw_color().to_egui();
row.col(|ui| {
let (response, painter) = ui.allocate_painter(egui::Vec2::new(16.0, 16.0), egui::Sense::empty());
painter.rect_filled(
response.rect,
2.0,
hitbox_color,
);
ui.label(format!("ID {}", hitbox.id));
});
row.col(|ui| {
ui.label(format!("{} %", hitbox.damage));
});
row.col(|ui| {
ui.label(format!("{}", hitbox.angle));
});
row.col(|ui| {
ui.label(format!("{}", hitbox.knockback));
});
row.col(|ui| {
ui.label(format!("{}", hitbox.knockback_growth));
});
// TODO: Set knockback
row.col(|ui| {
ui.label("-");
});
row.col(|ui| {
ui.label(format!("{}", HitStop::calculate(hitbox.damage, false)));
});
row.col(|ui| {
ui.label(format!("{} ({} crouched)", HitStop::calculate(hitbox.damage, false), HitStop::calculate(hitbox.damage, true)));
});
});
});
}
fn draw_ui(
mut contexts: EguiContexts,
mut state: ResMut<ViewerState>,
viewer: Res<ViewerAssets>,
manifests: Res<Assets<FighterManifest>>,
scripts: Res<Assets<FighterAttackScript>>,
baked_assets: Res<Assets<BakedFighterAnimations>>,
) -> Result {
@@ -499,10 +806,62 @@ fn draw_ui(
.script
.as_ref()
.and_then(|handle| scripts.get(handle));
egui::Window::new("Attack")
.anchor(egui::Align2::RIGHT_TOP, egui::vec2(-10.0, 10.0))
.resizable(true)
.show(contexts.ctx_mut()?, |ui| {
ui.horizontal(|ui| {
egui::ComboBox::from_id_salt("viewer-fighter")
.selected_text(
manifests
.get(&viewer.manifest)
.map(|manifest| format!("{:?}", manifest.id))
.unwrap_or_else(|| state.manifest_path.clone()),
)
.show_ui(ui, |ui| {
for (path, handle) in &viewer.manifest_choices {
let label = manifests
.get(handle)
.map(|manifest| format!("{:?}", manifest.id))
.unwrap_or_else(|| path.clone());
if ui
.selectable_label(state.manifest_path == *path, label)
.clicked()
{
state.manifest_path = path.clone();
}
}
});
egui::ComboBox::from_id_salt("viewer-attack")
.selected_text(format!("{:?}", state.attack))
.show_ui(ui, |ui| {
if let Some(manifest) = manifests.get(&viewer.manifest) {
for attack in VIEWER_ATTACKS {
if manifest.attack_scripts.contains_key(&attack) {
ui.selectable_value(
&mut state.attack,
attack,
format!("{:?}", attack),
);
}
}
}
});
});
ui.vertical(|ui| {
let Some(script) = script else {
ui.spinner();
ui.label("Waiting for the processed attack script…");
return;
};
draw_summary_ui(ui, script, end);
});
});
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))
.resizable(false)
.default_size(egui::vec2(780.0, 250.0))
.show(contexts.ctx_mut()?, |ui| {
ui.horizontal(|ui| {
if ui
@@ -521,8 +880,6 @@ fn draw_ui(
}
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);
@@ -532,82 +889,12 @@ fn draw_ui(
ui.label("Waiting for the processed attack script…");
return;
};
draw_summary_timeline(ui, &mut state, script, end);
//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,
@@ -616,8 +903,9 @@ fn draw_timeline(
) {
let row_height = 20.0;
let label_width = 55.0;
// +1 for the summary layer
let height =
(script.hitboxes.len().max(1) as f32 * row_height + 25.0).min(ui.available_height());
((script.hitboxes.len().max(1) + 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(),
@@ -652,12 +940,56 @@ fn draw_timeline(
egui::Color32::GRAY,
);
}
// Draw summary row
for frame in 0..end {
let top = rect.top() + 20.0 as f32;
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, active_hitbox.get_debug_draw_color().to_egui());
}
let stroke_color = if let Some(iasa_frame) = script.iasa_frame
&& frame == iasa_frame
{
Some(bevy::color::palettes::tailwind::CYAN_300)
} else if let Some(wont_autocancel_window) = script.wont_autocancel_window
&& (frame < wont_autocancel_window.0 || frame > wont_autocancel_window.1)
{
Some(bevy::color::palettes::tailwind::LIME_300)
} else if let None = active_hitbox {
Some(bevy::color::palettes::tailwind::GRAY_600)
} else {
None
};
if let Some(stroke_color) = stroke_color {
painter.rect_stroke(
bar,
2.0,
egui::Stroke::new(2.0f32, stroke_color.to_egui()),
egui::StrokeKind::Inside,
);
}
}
// Draw hitbox rows
for (row, hitbox) in script.hitboxes.iter().enumerate() {
let row = row + 1;
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),
format!("{}", hitbox.id),
egui::FontId::monospace(12.0),
egui::Color32::LIGHT_GRAY,
);
@@ -673,10 +1005,11 @@ fn draw_timeline(
bar,
2.0,
if active {
egui::Color32::from_rgb(245, 75, 55)
hitbox.get_debug_draw_color()
} else {
egui::Color32::from_rgb(125, 48, 42)
},
hitbox.get_debug_draw_color().darker(0.2)
}
.to_egui(),
);
}
let playhead = frame_x(timeline, state.frame, end);