Compare commits

...
Author SHA1 Message Date
EIREXE f8aa76bb52 Fix prod build 2026-10-04 18:51:29 +02:00
EIREXE a0f6813387 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
2026-10-04 17:16:42 +02:00
14 changed files with 990 additions and 428 deletions
Generated
+114 -210
View File
@@ -531,7 +531,7 @@ dependencies = [
"bevy_color",
"bevy_core_pipeline",
"bevy_ecs",
"bevy_egui 0.40.1",
"bevy_egui",
"bevy_gizmos",
"bevy_image",
"bevy_light",
@@ -549,7 +549,7 @@ dependencies = [
"bevy_window",
"bytemuck",
"disqualified",
"egui 0.34.3",
"egui",
"fuzzy-matcher",
"image",
"opener",
@@ -1061,55 +1061,7 @@ dependencies = [
"bevy_winit",
"bytemuck",
"crossbeam-channel",
"egui 0.34.3",
"encase",
"getrandom 0.4.3",
"image",
"itertools 0.14.0",
"js-sys",
"smithay-clipboard",
"thread_local",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webbrowser",
"wgpu-types",
"winit",
]
[[package]]
name = "bevy_egui"
version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e99e2d018c7e567a268638d3058e57895e914e5650dcf7bd80a5fead9e6413a9"
dependencies = [
"arboard",
"bevy_app",
"bevy_asset",
"bevy_camera",
"bevy_color",
"bevy_core_pipeline",
"bevy_derive",
"bevy_ecs",
"bevy_image",
"bevy_input",
"bevy_log",
"bevy_math",
"bevy_mesh",
"bevy_picking",
"bevy_platform",
"bevy_reflect",
"bevy_render",
"bevy_shader",
"bevy_time",
"bevy_transform",
"bevy_ui_render",
"bevy_utils",
"bevy_window",
"bevy_winit",
"bytemuck",
"crossbeam-channel",
"egui 0.36.2",
"egui",
"encase",
"getrandom 0.4.3",
"image",
@@ -1324,7 +1276,7 @@ dependencies = [
"bitflags 2.13.1",
"bytemuck",
"futures-lite",
"guillotiere 0.6.2",
"guillotiere",
"half",
"image",
"ktx2",
@@ -2214,17 +2166,6 @@ dependencies = [
"uuid",
]
[[package]]
name = "bevygraph"
version = "0.1.0"
source = "git+https://github.com/tbillington/bevy_debug_view#0d520f5f4e8693166408f082e8d25dd377dabc3e"
dependencies = [
"bevy",
"bevy_egui 0.42.0",
"indexmap",
"log",
]
[[package]]
name = "bincode"
version = "1.3.3"
@@ -3041,17 +2982,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a05fbfa222ffb51989d5ccf33e5f7aebfcf96c5023413856b0c3618a7f79896e"
dependencies = [
"bytemuck",
"emath 0.34.3",
]
[[package]]
name = "ecolor"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34"
dependencies = [
"bytemuck",
"emath 0.36.2",
"emath",
]
[[package]]
@@ -3063,8 +2994,8 @@ dependencies = [
"accesskit",
"ahash",
"bitflags 2.13.1",
"emath 0.34.3",
"epaint 0.34.3",
"emath",
"epaint",
"log",
"nohash-hasher",
"profiling",
@@ -3073,23 +3004,17 @@ dependencies = [
]
[[package]]
name = "egui"
version = "0.36.2"
name = "egui_extras"
version = "0.34.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba"
checksum = "598d8675f6fd9088db8a93d8c7aacda936b2f3d0c2b0660ad1744a45b5caf922"
dependencies = [
"accesskit",
"ahash",
"bitflags 2.13.1",
"emath 0.36.2",
"epaint 0.36.2",
"itertools 0.15.0",
"egui",
"enum-map",
"log",
"nohash-hasher",
"mime_guess2",
"profiling",
"smallvec",
"unicode-segmentation",
"web-sys",
]
[[package]]
@@ -3107,15 +3032,6 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "emath"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1"
dependencies = [
"bytemuck",
]
[[package]]
name = "ena"
version = "0.14.4"
@@ -3156,6 +3072,26 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "enum-map"
version = "2.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9"
dependencies = [
"enum-map-derive",
]
[[package]]
name = "enum-map-derive"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "enum_cycling"
version = "0.2.1"
@@ -3181,9 +3117,9 @@ checksum = "6675898a291ec212fc3df04f537d177fce8496120244590e6359dcaa4c25da79"
dependencies = [
"ahash",
"bytemuck",
"ecolor 0.34.3",
"emath 0.34.3",
"epaint_default_fonts 0.34.3",
"ecolor",
"emath",
"epaint_default_fonts",
"font-types 0.11.3",
"log",
"nohash-hasher",
@@ -3192,32 +3128,7 @@ dependencies = [
"self_cell",
"skrifa 0.40.0",
"smallvec",
"vello_cpu 0.0.6",
]
[[package]]
name = "epaint"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3"
dependencies = [
"ahash",
"bytemuck",
"ecolor 0.36.2",
"emath 0.36.2",
"epaint_default_fonts 0.36.2",
"font-types 0.12.2",
"harfrust 0.12.0",
"log",
"nohash-hasher",
"parking_lot",
"profiling",
"self_cell",
"skrifa 0.44.0",
"smallvec",
"unicode-general-category",
"unicode-segmentation",
"vello_cpu 0.1.0",
"vello_cpu",
]
[[package]]
@@ -3226,12 +3137,6 @@ version = "0.34.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8970033a4282a7bcf899b38b5ed3a58b732fe093d03785d58648515d8d309da"
[[package]]
name = "epaint_default_fonts"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13"
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -3324,12 +3229,6 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "fearless_simd"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "file-id"
version = "0.2.3"
@@ -3715,22 +3614,6 @@ dependencies = [
"simba",
]
[[package]]
name = "glifo"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
dependencies = [
"bytemuck",
"foldhash 0.2.0",
"hashbrown 0.17.1",
"log",
"peniko",
"skrifa 0.44.0",
"smallvec",
"vello_common 0.1.0",
]
[[package]]
name = "glow"
version = "0.17.0"
@@ -3841,15 +3724,6 @@ dependencies = [
"svg_fmt",
]
[[package]]
name = "guillotiere"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab"
dependencies = [
"euclid",
]
[[package]]
name = "half"
version = "2.7.1"
@@ -3876,18 +3750,6 @@ dependencies = [
"smallvec",
]
[[package]]
name = "harfrust"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9"
dependencies = [
"bitflags 2.13.1",
"bytemuck",
"read-fonts 0.41.0",
"smallvec",
]
[[package]]
name = "hash32"
version = "0.3.1"
@@ -4552,6 +4414,24 @@ dependencies = [
"libc",
]
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess2"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1706dc14a2e140dec0a7a07109d9a3d5890b81e85bd6c60b906b249a77adf0ca"
dependencies = [
"mime",
"phf",
"phf_shared",
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -5370,7 +5250,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fad031076f48f0d4d85ce1aea9b94b4e715a4d636a030a123038f8f5b5e4343"
dependencies = [
"fontique",
"harfrust 0.6.2",
"harfrust",
"hashbrown 0.17.1",
"icu_normalizer",
"icu_properties",
@@ -5497,6 +5377,50 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"rand 0.8.7",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
"syn 2.0.119",
"unicase",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
"unicase",
]
[[package]]
name = "pin-project"
version = "1.1.13"
@@ -6272,18 +6196,19 @@ dependencies = [
"bevy",
"bevy-inspector-egui",
"bevy_asset_loader",
"bevy_egui 0.40.1",
"bevy_egui",
"bevy_ggrs",
"bevygraph",
"bincode",
"blake3",
"clap",
"egui_extras",
"enum_cycling",
"enum_cycling_derive",
"fixed",
"fixed_math",
"gc-adapter-neo",
"gltf",
"itertools 0.15.0",
"rhai",
"ron",
"ron_asset_manager",
@@ -6335,6 +6260,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
[[package]]
name = "skrifa"
version = "0.40.0"
@@ -6992,10 +6923,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6"
[[package]]
name = "unicode-general-category"
version = "1.1.0"
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
@@ -7105,7 +7036,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd1a4c633ce09e7d713df1a6e036644a125e15e0c169cfb5180ddf5836ca04b"
dependencies = [
"bytemuck",
"fearless_simd 0.3.0",
"fearless_simd",
"hashbrown 0.16.1",
"log",
"peniko",
@@ -7113,21 +7044,6 @@ dependencies = [
"smallvec",
]
[[package]]
name = "vello_common"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
dependencies = [
"bytemuck",
"fearless_simd 0.4.1",
"guillotiere 0.7.0",
"log",
"peniko",
"smallvec",
"thiserror 2.0.19",
]
[[package]]
name = "vello_cpu"
version = "0.0.6"
@@ -7136,19 +7052,7 @@ checksum = "0162bfe48aabf6a9fdcd401b628c7d9f260c2cbabb343c70a65feba6f7849edc"
dependencies = [
"bytemuck",
"hashbrown 0.16.1",
"vello_common 0.0.6",
]
[[package]]
name = "vello_cpu"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea"
dependencies = [
"bytemuck",
"glifo",
"hashbrown 0.17.1",
"vello_common 0.1.0",
"vello_common",
]
[[package]]
+9 -5
View File
@@ -8,23 +8,22 @@ authors = [""]
[features]
default = ["development", "debug_features", "gamecube-adapter"]
development = ["bevy/dynamic_linking", "bevy/file_watcher", "bevy/embedded_watcher"]
development = ["bevy/dynamic_linking", "bevy/file_watcher", "bevy/embedded_watcher", "hitbox-viewer"]
debug_features = ["bevy/free_camera"]
gamecube-adapter = ["dep:gc-adapter-neo"]
hitbox-viewer = ["bevy/bevy_dev_tools", "dep:egui_extras"]
[dependencies]
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.
blake3 = { version = "1.8.5", features = [ "pure" ] }
itertools = "0.15.0"
thiserror = "2"
avian3d = "0.7"
arrayvec = "0.7.8"
bevy-inspector-egui = "0.37.0"
bevy_egui = "0.40.0"
egui_extras = { version = "0.34.3", optional = true}
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"
@@ -53,3 +52,8 @@ strip = "symbols"
[target.'cfg(not(target_os = "windows"))'.dependencies]
gc-adapter-neo = { version = "1.1.1", features = ["libusb"], optional = true }
[[bin]]
name = "hitbox-editor"
path = "src/bin/hitbox-editor.rs"
required-features = ["hitbox-viewer"]
+16
View File
@@ -0,0 +1,16 @@
use bevy_egui::egui::Color32;
pub trait ColorToEgui {
fn to_egui(&self) -> Color32;
}
impl ColorToEgui for bevy::color::Srgba {
fn to_egui(&self) -> Color32 {
Color32::from_rgba_unmultiplied(
(self.red * 255.0) as u8,
(self.green * 255.0) as u8,
(self.blue * 255.0) as u8,
(self.alpha * 255.0) as u8,
)
}
}
+91 -8
View File
@@ -33,7 +33,7 @@ use crate::{
#[derive(Deref, Component, Default, Hash, Debug, Copy, Clone)]
pub struct FighterKnockbackVelocity(pub FGVec2);
#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, Component, Hash, PartialEq)]
#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, Component, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub struct FighterDamage(FGi32);
impl std::fmt::Display for FighterDamage {
@@ -80,7 +80,7 @@ impl HitStop {
HitStop(hitstop)
}
pub fn calculate(attack_damage_stalled: FighterDamage, crouch_cancelling: bool) -> Self {
let a = (attack_damage_stalled.0 / FGi32::lit("3.0") + FGi32::lit("4.0")).round_to_zero();
let a = (attack_damage_stalled.0 / FGi32::lit("3.0") + FGi32::lit("3.0")).round_to_zero();
let crouch_cancelling_factor = if crouch_cancelling {
FGi32::lit("0.66666666")
} else {
@@ -97,6 +97,12 @@ impl HitStop {
}
}
impl std::fmt::Display for HitStop {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
pub enum KnockbackType {
Normal,
@@ -113,9 +119,15 @@ impl KnockbackType {
}
}
#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize)]
#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct Knockback(FGi32);
impl std::fmt::Display for Knockback {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:.2}", self.0)
}
}
impl Knockback {
pub fn lit(val: &str) -> Self {
Self(FGi32::lit(val))
@@ -144,6 +156,15 @@ pub enum AttackAngle {
Sakurai,
}
impl std::fmt::Display for AttackAngle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AttackAngle::Normal(fixed_i32) => write!(f, "{:.2}º", fixed_i32),
AttackAngle::Sakurai => write!(f, "Sakurai"),
}
}
}
impl Default for AttackAngle {
fn default() -> Self {
AttackAngle::Normal(FGi32::lit("0"))
@@ -175,6 +196,13 @@ impl AttackAngle {
},
}
}
pub fn to_melee_angle_representation(&self) -> FGi32 {
match self {
AttackAngle::Normal(fixed_i32) => *fixed_i32,
AttackAngle::Sakurai => FGi32::lit("361"),
}
}
}
#[derive(Serialize, Deserialize, Reflect, PartialEq, Eq, Hash, Clone, Debug, Default, Copy)]
@@ -217,6 +245,7 @@ impl AttackKind {
#[reflect(opaque)]
pub struct AttackHitbox {
pub id: u32,
pub group: u32,
pub bone: String,
pub damage: FighterDamage,
pub offset: FGVec3,
@@ -229,6 +258,14 @@ pub struct AttackHitbox {
pub end_frame: u32,
}
static ATTACK_HITBOX_ID_PALETTE: [bevy::color::Srgba; 5] = [
bevy::color::palettes::tailwind::RED_500,
bevy::color::palettes::tailwind::GREEN_500,
bevy::color::palettes::tailwind::BLUE_500,
bevy::color::palettes::tailwind::FUCHSIA_500,
bevy::color::palettes::tailwind::ORANGE_500,
];
const STALE_MOVE_QUEUE_COUNT: usize = 9;
#[derive(Component, Hash, Clone, Default)]
@@ -276,6 +313,10 @@ impl FighterStaleMoveQueue {
}
impl AttackHitbox {
pub fn get_debug_draw_color(&self) -> bevy::color::Srgba {
ATTACK_HITBOX_ID_PALETTE[self.id as usize % ATTACK_HITBOX_ID_PALETTE.len()]
}
pub fn validate(&self) -> Result<(), String> {
if self.bone.trim().is_empty() {
return Err("bone name is empty".into());
@@ -369,6 +410,36 @@ pub fn update_active_hitbox_list(script: &FighterAttackScript, list: &mut Vec<us
);
}
/// Updates both the currently active hitbox intervals and the lifetime of
/// their per-group victim histories. A group remains immune only while at
/// least one hitbox belonging to that group is active.
pub fn update_active_attack(
script: &FighterAttackScript,
attack: &mut super::FighterActiveAttack,
frame: u32,
) {
update_active_hitbox_list(script, &mut attack.active_hitboxes, frame);
let mut active_groups = Vec::new();
for &index in &attack.active_hitboxes {
let group = script.hitboxes[index].group;
if !active_groups.contains(&group) {
active_groups.push(group);
}
}
attack.hit_groups.retain(|state| active_groups.contains(&state.group));
for group in active_groups {
if !attack.hit_groups.iter().any(|state| state.group == group) {
attack.hit_groups.push(super::FighterHitGroupState {
group,
hit_fighters: Vec::new(),
});
}
}
attack.hit_groups.sort_unstable_by_key(|state| state.group);
}
#[derive(Clone, Debug, PartialEq, Hash)]
pub struct FighterSolvedHurtbox {
pub hurtbox_idx: usize,
@@ -571,10 +642,6 @@ pub fn intersect_attacks_with_hurtboxes(
let Some(active_attack) = hitboxes.0.as_mut() else {
continue;
};
if active_attack.hit_fighters.contains(&entity_receiving) {
continue;
}
let attack_script = attack_scripts
.get(&active_attack.script)
.expect("Attack script should be valid");
@@ -584,6 +651,15 @@ pub fn intersect_attacks_with_hurtboxes(
for solved_hitbox in active_attack.solved_hitboxes.iter() {
if hurtbox.capsule.intersects(solved_hitbox.capsule) {
let candidate = solved_hitbox.hitbox_idx;
let group = attack_script.hitboxes[candidate].group;
let already_hit = active_attack
.hit_groups
.iter()
.find(|state| state.group == group)
.is_some_and(|state| state.hit_fighters.contains(&entity_receiving));
if already_hit {
continue;
}
if hitting_hitbox_idx.is_none_or(|current: usize| {
attack_script.hitboxes[candidate].id
< attack_script.hitboxes[current].id
@@ -606,7 +682,14 @@ pub fn intersect_attacks_with_hurtboxes(
hitbox_index: hitting_hitbox_idx,
});
active_attack.hit_fighters.push(entity_receiving);
let group = attack_script.hitboxes[hitting_hitbox_idx].group;
if let Some(state) = active_attack
.hit_groups
.iter_mut()
.find(|state| state.group == group)
{
state.hit_fighters.push(entity_receiving);
}
}
}
}
+1 -1
View File
@@ -114,7 +114,7 @@ pub fn resolve_fighter_hits(
};
// Attacker hitstop and data
let hitstop = HitStop::calculate(FighterDamage(attack_damage), false);
let hitstop = HitStop::calculate(FighterDamage(attack_damage), matches!(receiver_state, FighterState::Crouch(..)));
reactions.push((
attacker_entity,
attacker_slot,
+18 -8
View File
@@ -28,6 +28,7 @@ use crate::math::int::FGi32;
use crate::math::vec::FGVec2;
use crate::math::vec3::FGVec3;
use crate::player::Player;
use crate::scripting::FighterAttackScript;
pub fn debug_draw_ecb(
ecbs: Query<(
@@ -368,6 +369,7 @@ pub fn attack_debug(
)>,
mut gizmos: Gizmos,
mut filled_gizmos: FilledGizmos,
attack_scripts: Res<Assets<FighterAttackScript>>
) {
for (hitboxes, _matrices, _translation, _facing_direction, _frame) in query {
if let Some(active_attack) = &hitboxes.0 {
@@ -378,27 +380,35 @@ pub fn attack_debug(
(solved_hitbox.capsule.start + solved_hitbox.capsule.end) * FGi32::lit("0.5");
let length = (start - end).length();
let attack_capsule = Capsule3d {
half_length: length * 0.5,
radius: solved_hitbox.capsule.radius.to_num(),
};
let aim_dir = (end - start).normalize_or(Vec3::Y);
let mut capsule_draw_trf = Transform::IDENTITY;
capsule_draw_trf.translation = center.to_vec3();
capsule_draw_trf.align(Dir3::Y, aim_dir, Dir3::X, aim_dir.any_orthonormal_vector());
let Some(script) = attack_scripts.get(&active_attack.script) else {
continue;
};
let hitbox_color = script.hitboxes.get(solved_hitbox.hitbox_idx).expect("This hitbox should be valid").get_debug_draw_color();
/*
let attack_capsule = Capsule3d {
half_length: length * 0.5,
radius: solved_hitbox.capsule.radius.to_num(),
};
gizmos.primitive_3d(
&attack_capsule,
capsule_draw_trf.to_isometry(),
bevy::color::palettes::css::RED,
);
hitbox_color,
);*/
let hitbox_inner_color = bevy::color::Srgba { alpha: 0.18, ..hitbox_color };
filled_gizmos.capsule(
capsule_draw_trf.to_isometry(),
solved_hitbox.capsule.radius.to_num(),
length * 0.5,
Color::srgba(1.0, 0.0, 0.0, 0.18),
hitbox_inner_color,
);
gizmos.cross(start, 1.0, bevy::color::palettes::css::GREEN);
+7 -1
View File
@@ -293,10 +293,16 @@ pub struct FighterActiveAttack {
pub script: Handle<FighterAttackScript>,
pub active_hitboxes: Vec<usize>,
pub solved_hitboxes: Vec<attack::FighterSolvedHitbox>,
pub hit_fighters: Vec<Entity>,
pub hit_groups: Vec<FighterHitGroupState>,
pub stalled: bool,
}
#[derive(Clone, Debug, Hash)]
pub struct FighterHitGroupState {
pub group: u32,
pub hit_fighters: Vec<Entity>,
}
#[derive(Component, Clone, Debug, Default, Hash)]
pub struct FighterHitboxes(pub Option<FighterActiveAttack>);
+64 -23
View File
@@ -1,23 +1,42 @@
use bevy::log::error;
use crate::fighter::{
attack::{AttackKind, FighterAttackScriptAssets, update_active_hitbox_list}, state::{
CollisionContext, EnterContext, ExitContext, FighterState, FighterStateImpl, InterruptContext, UpdateContext, air::{self, AirborneStateCommon}, fall::FallState,
attack::{AttackKind, FighterAttackScriptAssets, update_active_attack}, state::{
CollisionContext, EnterContext, ExitContext, FighterState, FighterStateImpl, InterruptContext, UpdateContext, air::{self, AirborneStateCommon}, air_attack, fall::FallState, jump,
},
};
#[derive(Clone, Debug, Hash)]
pub struct AirAttackState {
pub attack_kind: AttackKind,
pub airborne_state_common: AirborneStateCommon
pub airborne_state_common: AirborneStateCommon,
pub is_in_autocancel_window: bool,
}
impl FighterStateImpl for AirAttackState {
const NAME: &'static str = "AirAttack";
fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option<FighterState> {
let animation_frame = state_context.get_current_animation_frame();
let script = state_context
.attack_scripts
.get(&state_context.attack_script_assets.scripts[&self.attack_kind]);
if let Some(script) = script {
if let Some(iasa_frame) = script.iasa_frame {
if animation_frame >= iasa_frame {
let new_state = jump::check_input_double_jump(state_context).or_else(|| air_attack::check_interrupt(state_context, &self.airborne_state_common));
if let Some(new_state) = new_state {
return Some(new_state);
}
}
}
}
if state_context.is_current_animation_finished() {
Some(FighterState::Fall(FallState::new(self.airborne_state_common)))
Some(FighterState::Fall(FallState::new(
self.airborne_state_common,
)))
} else {
None
}
@@ -29,16 +48,19 @@ impl FighterStateImpl for AirAttackState {
.attack_scripts
.get(&state_context.attack_script_assets.scripts[&self.attack_kind]);
if let Some(script) = script {
update_active_hitbox_list(
update_active_attack(
script,
&mut state_context
state_context
.hitboxes
.0
.as_mut()
.expect("ground attack must have active hitboxes")
.active_hitboxes,
.expect("ground attack must have active hitboxes"),
animation_frame,
);
if let Some((autocancel_start, autocancel_end)) = script.wont_autocancel_window {
self.is_in_autocancel_window =
animation_frame < autocancel_start || animation_frame > autocancel_end;
}
}
air::update_standard_motion(state_context, &mut self.airborne_state_common);
@@ -55,7 +77,7 @@ impl FighterStateImpl for AirAttackState {
script,
active_hitboxes: Vec::new(),
solved_hitboxes: Vec::new(),
hit_fighters: Vec::new(),
hit_groups: Vec::new(),
stalled: false,
});
}
@@ -68,11 +90,20 @@ impl FighterStateImpl for AirAttackState {
&mut self,
state_context: &mut CollisionContext,
) -> Option<FighterState> {
air::collision_interrupt(
let coll_interrupt = air::collision_interrupt(
state_context,
crate::fighter::state::land::LandingType::Normal,
false,
)
);
coll_interrupt.map(|state| {
if self.is_in_autocancel_window {
state
} else {
// TODO: Implement proper landing lag
state
}
})
}
fn is_visually_grounded<'a>(&'a self) -> bool {
@@ -87,21 +118,29 @@ pub enum AirAttackStateCreateError {
}
impl AirAttackState {
pub fn create(attack_kind: AttackKind, attack_script_assets: &FighterAttackScriptAssets, airborne_state_common: &AirborneStateCommon) -> Result<Self, AirAttackStateCreateError> {
pub fn create(
attack_kind: AttackKind,
attack_script_assets: &FighterAttackScriptAssets,
airborne_state_common: &AirborneStateCommon,
) -> Result<Self, AirAttackStateCreateError> {
if attack_script_assets.scripts.contains_key(&attack_kind) {
Ok(AirAttackState {
airborne_state_common: airborne_state_common.clone(),
attack_kind
attack_kind,
is_in_autocancel_window: false,
})
} else {
Err(AirAttackStateCreateError::AttackScriptNotFound { attack: attack_kind })
Err(AirAttackStateCreateError::AttackScriptNotFound {
attack: attack_kind,
})
}
}
}
}
pub fn check_interrupt(state_context: &InterruptContext, airborne_state_common: &AirborneStateCommon) -> Option<FighterState> {
pub fn check_interrupt(
state_context: &InterruptContext,
airborne_state_common: &AirborneStateCommon,
) -> Option<FighterState> {
if state_context
.input
.has_command(crate::input::FighterCommands::Attack)
@@ -110,16 +149,18 @@ pub fn check_interrupt(state_context: &InterruptContext, airborne_state_common:
.input
.get_attack_command_type()
.to_attack_kind_airborne(state_context.facing_direction);
let state = AirAttackState::create(attack_kind, state_context.attack_script_assets, airborne_state_common);
let state = AirAttackState::create(
attack_kind,
state_context.attack_script_assets,
airborne_state_common,
);
match state {
Ok(state) => {
Some(FighterState::AirAttack(state))
},
Ok(state) => Some(FighterState::AirAttack(state)),
Err(create_error) => {
error!("{}", create_error);
None
},
}
}
} else {
None
+5 -6
View File
@@ -1,5 +1,5 @@
use crate::fighter::{
attack::{AttackKind, update_active_hitbox_list},
attack::{AttackKind, update_active_attack},
state::{
CollisionContext, EnterContext, ExitContext, FighterState, FighterStateImpl,
InterruptContext, UpdateContext, wait::WaitState,
@@ -28,14 +28,13 @@ impl FighterStateImpl for GroundAttackState {
.attack_scripts
.get(&state_context.attack_script_assets.scripts[&self.attack_kind]);
if let Some(script) = script {
update_active_hitbox_list(
update_active_attack(
script,
&mut state_context
state_context
.hitboxes
.0
.as_mut()
.expect("ground attack must have active hitboxes")
.active_hitboxes,
.expect("ground attack must have active hitboxes"),
animation_frame,
);
}
@@ -52,7 +51,7 @@ impl FighterStateImpl for GroundAttackState {
script,
active_hitboxes: Vec::new(),
solved_hitboxes: Vec::new(),
hit_fighters: Vec::new(),
hit_groups: Vec::new(),
stalled: false,
});
}
+5
View File
@@ -139,6 +139,7 @@ pub struct InterruptContext<'a> {
pub surface: &'a FighterSurfaceState,
pub animation: AnimationStatus,
pub stage_collision: &'a StageCollision,
pub attack_scripts: &'a Assets<FighterAttackScript>,
pub attack_script_assets: &'a FighterAttackScriptAssets,
}
@@ -300,6 +301,7 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> {
baked_animations: &'a Assets<BakedFighterAnimations>,
attributes: &'a FighterAttributes,
stage_collision: &'a StageCollision,
attack_scripts: &'a Assets<FighterAttackScript>,
attack_script_assets: &'a FighterAttackScriptAssets,
) -> InterruptContext<'a> {
let animation = self.animation_status(animations, baked_animations);
@@ -312,6 +314,7 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> {
surface: &self.surface,
stage_collision,
attack_script_assets: attack_script_assets,
attack_scripts,
}
}
@@ -471,6 +474,7 @@ pub fn state_interrupt_system(
game_settings: Res<GameSettings>,
baked_animations: Res<Assets<BakedFighterAnimations>>,
fighter_manifests: Res<Assets<FighterManifest>>,
attack_scripts: Res<Assets<FighterAttackScript>>,
stage_collision: Res<StageCollision>,
) {
for fighter in &mut query {
@@ -547,6 +551,7 @@ pub fn state_interrupt_system(
&baked_animations,
&manifest.attributes,
&stage_collision,
&attack_scripts,
attack_script_assets,
))
};
+441 -108
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 (attack, show_hurtboxes, manifest_path) = {
let args = app.world().resource::<HitboxViewerArgs>();
let attack = args.attack.into();
let show_hurtboxes = !args.hide_hurtboxes;
(
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;
};
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);
+2
View File
@@ -48,6 +48,7 @@ pub mod fighter;
pub mod filled_gizmos;
pub mod game_settings;
pub mod input;
#[cfg(feature = "hitbox-viewer")]
pub mod hitbox_viewer;
pub mod match_loading;
pub mod match_runtime;
@@ -62,6 +63,7 @@ pub mod scripting;
pub mod stage;
pub mod texture_reference;
pub mod ui;
pub mod egui_color_convert;
#[derive(States, Debug, Clone, Copy, Eq, PartialEq, Hash, Default)]
pub enum AppState {
+21 -7
View File
@@ -1,6 +1,5 @@
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use crate::{fighter::attack::AttackHitbox, math::int::FGi32};
@@ -15,22 +14,37 @@ pub enum MoveAngleKind {
#[derive(Asset, Reflect, Serialize, Deserialize)]
pub struct FighterAttackScript {
pub hitboxes: Vec<AttackHitbox>,
pub iasa_frame: u32,
pub wont_autocancel_window: (u32, u32),
pub iasa_frame: Option<u32>,
pub wont_autocancel_window: Option<(u32, u32)>,
pub landing_duration: Option<u32>,
}
impl FighterAttackScript {
pub fn validate(&self) -> Result<(), String> {
let mut ids = HashSet::with_capacity(self.hitboxes.len());
for hitbox in &self.hitboxes {
hitbox.validate()?;
if !ids.insert(hitbox.id) {
return Err(format!("duplicate hitbox id {}", hitbox.id));
}
for (index, left) in self.hitboxes.iter().enumerate() {
for right in self.hitboxes.iter().skip(index + 1) {
if left.id == right.id
&& left.start_frame < right.end_frame
&& right.start_frame < left.end_frame
{
return Err(format!(
"hitbox id {} has overlapping intervals {}..{} and {}..{}",
left.id, left.start_frame, left.end_frame, right.start_frame,
right.end_frame
));
}
}
if self.wont_autocancel_window.0 > self.wont_autocancel_window.1 {
}
if let Some(wont_autocancel_window) = self.wont_autocancel_window {
if wont_autocancel_window.0 > wont_autocancel_window.1 {
return Err("autocancel window end precedes its start".into());
}
}
Ok(())
}
}
+199 -54
View File
@@ -1,6 +1,7 @@
use std::{cell::RefCell, collections::HashMap, rc::Rc};
use rhai::EvalAltResult;
use serde::{Deserialize, Serialize};
use crate::{
fighter::attack::{self, AttackAngle, FighterDamage, Knockback, KnockbackType},
@@ -10,13 +11,41 @@ use crate::{
#[derive(Debug, Default)]
struct MoveCompiler {
hitboxes: HashMap<u32, attack::AttackHitbox>,
active_hitboxes: Vec<u32>,
hitboxes: Vec<attack::AttackHitbox>,
active_hitboxes: HashMap<u32, usize>,
wont_autocancel_window: Option<(u32, u32)>,
iasa_frame: Option<u32>,
landing_duration: Option<u32>,
cursor: u32,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SharedHitboxInfo {
damage: FighterDamage,
angle: AttackAngle,
knockback_type: KnockbackType,
knockback: Knockback,
knockback_growth: FGi32,
}
impl SharedHitboxInfo {
pub fn make(
damage: FighterDamage,
angle: AttackAngle,
knockback_type: KnockbackType,
knockback: Knockback,
knockback_growth: FGi32,
) -> Self {
Self {
damage,
angle,
knockback_type,
knockback,
knockback_growth,
}
}
}
fn rhai_u32(value: i64, parameter: &str) -> Result<u32, Box<EvalAltResult>> {
u32::try_from(value).map_err(|_| {
format!(
@@ -43,28 +72,18 @@ impl MoveCompiler {
}
fn remove_hitbox(&mut self, id: u32) -> Result<(), Box<EvalAltResult>> {
let hb = self
.hitboxes
.get_mut(&id)
let hitbox_index = *self
.active_hitboxes
.get(&id)
.ok_or(format!("Can't remove hitbox with {id}: Hitbox not found"))?;
let active_hbox_position =
self.active_hitboxes
.iter()
.position(|i| *i == id)
.ok_or(format!(
"Can't remove hitbox with ID {id}: Hitbox was not active"
))?;
hb.end_frame = self.cursor;
self.active_hitboxes.remove(active_hbox_position);
self.hitboxes[hitbox_index].end_frame = self.cursor;
self.active_hitboxes.remove(&id);
Ok(())
}
fn remove_all_hitboxes(&mut self) -> Result<(), Box<EvalAltResult>> {
// We are gonna modify the array, so take a copy...
for hitbox_id in self.active_hitboxes.clone() {
for hitbox_id in self.active_hitboxes.keys().copied().collect::<Vec<_>>() {
self.remove_hitbox(hitbox_id)?;
}
@@ -97,14 +116,14 @@ impl MoveCompiler {
fn into_attack_script(mut self) -> Result<FighterAttackScript, String> {
self.remove_all_hitboxes().map_err(|err| err.to_string())?;
let mut hitboxes: Vec<_> = self.hitboxes.into_values().collect();
hitboxes.sort_unstable_by_key(|hitbox| hitbox.id);
let iasa_frame = self.iasa_frame.unwrap_or(100000);
let wont_autocancel_window = self.wont_autocancel_window.unwrap_or((0, 100000));
let mut hitboxes = self.hitboxes;
hitboxes.sort_unstable_by_key(|hitbox| (hitbox.id, hitbox.start_frame));
Ok(FighterAttackScript {
hitboxes,
iasa_frame,
wont_autocancel_window,
iasa_frame: self.iasa_frame,
wont_autocancel_window: self.wont_autocancel_window,
landing_duration: self.landing_duration,
})
}
}
@@ -117,7 +136,9 @@ pub fn register_common_types(engine: &mut rhai::Engine) {
.register_fn("knockback_normal", KnockbackType::make_normal)
.register_fn("knockback_fixed", KnockbackType::make_fixed)
.register_type_with_name::<AttackAngle>("Angle")
.register_fn("angle", |str: &str| AttackAngle::make_normal(FGi32::lit(str)))
.register_fn("angle", |str: &str| {
AttackAngle::make_normal(FGi32::lit(str))
})
.register_fn("angle_sakurai", AttackAngle::make_sakurai)
.register_type_with_name::<Knockback>("Knockback")
.register_fn("knockback", Knockback::lit)
@@ -128,6 +149,17 @@ pub fn register_common_types(engine: &mut rhai::Engine) {
.register_type_with_name::<FGVec3>("Vec3")
.register_fn("vec3", FGVec3::lit)
.register_type_with_name::<FighterDamage>("FighterDamage")
.register_type_with_name::<SharedHitboxInfo>("SharedHitboxInfo")
.register_fn(
"make_hitbox_info",
|damage: FighterDamage,
angle: AttackAngle,
knockback_type: KnockbackType,
knockback: Knockback,
knockback_growth: FGi32| {
SharedHitboxInfo::make(damage, angle, knockback_type, knockback, knockback_growth)
},
)
.register_fn("damage", FighterDamage::lit);
}
@@ -200,6 +232,37 @@ pub fn compile_script(text: &str) -> Result<FighterAttackScript, String> {
);
}
{
let mc = mc.clone();
engine.register_fn(
"hitbox",
move |id: i64,
group: i64,
bone: &str,
offset: FGVec3,
radius: FGi32,
shared: SharedHitboxInfo|
-> Result<(), Box<EvalAltResult>> {
add_hitbox(
&mc,
id,
group,
bone,
shared.damage,
offset,
radius,
shared.angle.clone(),
shared.knockback_type,
shared.knockback,
shared.knockback_growth,
)
},
);
}
// Keep the original script syntax working. A hitbox without an
// explicit group behaves as its own group, which preserves the old
// one-hit-per-target behavior.
{
let mc = mc.clone();
engine.register_fn(
@@ -214,17 +277,91 @@ pub fn compile_script(text: &str) -> Result<FighterAttackScript, String> {
knockback: Knockback,
knockback_growth: FGi32|
-> Result<(), Box<EvalAltResult>> {
let mut b = mc.borrow_mut();
// Rhai does not have first class u32 support, so we have to do this.
let id = rhai_u32(id, "hitbox id")?;
if b.hitboxes.contains_key(&id) {
return Err(format!("Hitbox with ID {} already existed!", id).into());
add_hitbox(
&mc,
id,
id,
bone,
damage,
offset,
radius,
angle,
knockback_type,
knockback,
knockback_growth,
)
},
);
}
let hitbox = attack::AttackHitbox {
{
let mc = mc.clone();
engine.register_fn(
"hitbox",
move |id: i64,
group: i64,
bone: &str,
damage: FighterDamage,
offset: FGVec3,
radius: FGi32,
angle: AttackAngle,
knockback_type: KnockbackType,
knockback: Knockback,
knockback_growth: FGi32|
-> Result<(), Box<EvalAltResult>> {
add_hitbox(
&mc,
id,
group,
bone,
damage,
offset,
radius,
angle,
knockback_type,
knockback,
knockback_growth,
)
},
);
}
engine.run(&text)
};
let script = result
.map(|_| {
Rc::into_inner(mc)
.unwrap()
.into_inner()
.into_attack_script()
})
.map_err(|err| err.to_string())??;
script.validate().map(|()| script)
}
fn add_hitbox(
mc: &Rc<RefCell<MoveCompiler>>,
raw_id: i64,
raw_group: i64,
bone: &str,
damage: FighterDamage,
offset: FGVec3,
radius: FGi32,
angle: AttackAngle,
knockback_type: KnockbackType,
knockback: Knockback,
knockback_growth: FGi32,
) -> Result<(), Box<EvalAltResult>> {
let mut b = mc.borrow_mut();
let id = rhai_u32(raw_id, "hitbox id")?;
let group = rhai_u32(raw_group, "hitbox group")?;
if b.active_hitboxes.contains_key(&id) {
return Err(format!("Hitbox with ID {} is already active!", id).into());
}
let hitbox_index = b.hitboxes.len();
let frame = b.cursor;
b.hitboxes.push(attack::AttackHitbox {
id,
group,
bone: bone.to_owned(),
damage,
offset,
@@ -233,28 +370,11 @@ pub fn compile_script(text: &str) -> Result<FighterAttackScript, String> {
knockback_type,
knockback,
knockback_growth,
start_frame: b.cursor,
end_frame: b.cursor,
};
b.hitboxes.insert(id, hitbox);
b.active_hitboxes.push(id);
start_frame: frame,
end_frame: frame,
});
b.active_hitboxes.insert(id, hitbox_index);
Ok(())
},
);
}
engine.run(&text)
};
result
.map(|_| {
Rc::into_inner(mc)
.unwrap()
.into_inner()
.into_attack_script()
})
.map_err(|err| err.to_string())?
}
#[cfg(test)]
@@ -316,4 +436,29 @@ mod tests {
assert_eq!(script.hitboxes[0].start_frame, 1);
assert_eq!(script.hitboxes[0].end_frame, 2);
}
#[test]
fn hitbox_ids_can_be_reused_after_removal() {
let script = compile_script(&format!(
"frame(1); {HITBOX} frame(2); remove_hitbox(0); frame(4); {HITBOX} frame(5);"
))
.unwrap();
assert_eq!(script.hitboxes.len(), 2);
assert_eq!(script.hitboxes[0].start_frame, 1);
assert_eq!(script.hitboxes[0].end_frame, 2);
assert_eq!(script.hitboxes[1].start_frame, 4);
assert_eq!(script.hitboxes[1].end_frame, 5);
}
#[test]
fn explicit_hitbox_group_is_compiled() {
let script = compile_script(
r#"frame(1); hitbox(7, 3, "hand_l", damage("1"), vec3("0", "0", "0"), fg32("1"), angle("0"), knockback_normal(), knockback("1"), fg32("1")); frame(2);"#,
)
.unwrap();
assert_eq!(script.hitboxes[0].id, 7);
assert_eq!(script.hitboxes[0].group, 3);
}
}