diff --git a/Cargo.lock b/Cargo.lock index a741171..9f50d95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/Cargo.toml b/Cargo.toml index cd225ee..9db1575 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,23 +8,21 @@ 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", "bevy/bevy_dev_tools"] debug_features = ["bevy/free_camera"] gamecube-adapter = ["dep:gc-adapter-neo"] [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 = "0.34.3" 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" diff --git a/src/egui_color_convert.rs b/src/egui_color_convert.rs new file mode 100644 index 0000000..931b057 --- /dev/null +++ b/src/egui_color_convert.rs @@ -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, + ) + } +} diff --git a/src/fighter/attack/mod.rs b/src/fighter/attack/mod.rs index 0bcc754..8a9d7c4 100644 --- a/src/fighter/attack/mod.rs +++ b/src/fighter/attack/mod.rs @@ -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, mut gizmos: Gizmos, mut filled_gizmos: FilledGizmos, + attack_scripts: Res> ) { 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); diff --git a/src/fighter/mod.rs b/src/fighter/mod.rs index 137ffbf..5ba5dc4 100644 --- a/src/fighter/mod.rs +++ b/src/fighter/mod.rs @@ -293,10 +293,16 @@ pub struct FighterActiveAttack { pub script: Handle, pub active_hitboxes: Vec, pub solved_hitboxes: Vec, - pub hit_fighters: Vec, + pub hit_groups: Vec, pub stalled: bool, } +#[derive(Clone, Debug, Hash)] +pub struct FighterHitGroupState { + pub group: u32, + pub hit_fighters: Vec, +} + #[derive(Component, Clone, Debug, Default, Hash)] pub struct FighterHitboxes(pub Option); diff --git a/src/fighter/state/air_attack.rs b/src/fighter/state/air_attack.rs index dcd0c71..6252a7a 100644 --- a/src/fighter/state/air_attack.rs +++ b/src/fighter/state/air_attack.rs @@ -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 { + 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 { - 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 { @@ -83,25 +114,33 @@ impl FighterStateImpl for AirAttackState { #[derive(thiserror::Error, Debug)] pub enum AirAttackStateCreateError { #[error("Attack script: {attack:?} not found")] - AttackScriptNotFound{attack: AttackKind}, + AttackScriptNotFound { attack: AttackKind }, } impl AirAttackState { - pub fn create(attack_kind: AttackKind, attack_script_assets: &FighterAttackScriptAssets, airborne_state_common: &AirborneStateCommon) -> Result { + pub fn create( + attack_kind: AttackKind, + attack_script_assets: &FighterAttackScriptAssets, + airborne_state_common: &AirborneStateCommon, + ) -> Result { 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 { +pub fn check_interrupt( + state_context: &InterruptContext, + airborne_state_common: &AirborneStateCommon, +) -> Option { 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 diff --git a/src/fighter/state/ground_attack.rs b/src/fighter/state/ground_attack.rs index ee40146..06a535c 100644 --- a/src/fighter/state/ground_attack.rs +++ b/src/fighter/state/ground_attack.rs @@ -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, }); } diff --git a/src/fighter/state/mod.rs b/src/fighter/state/mod.rs index f4e2647..b5879ad 100644 --- a/src/fighter/state/mod.rs +++ b/src/fighter/state/mod.rs @@ -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, pub attack_script_assets: &'a FighterAttackScriptAssets, } @@ -300,6 +301,7 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> { baked_animations: &'a Assets, attributes: &'a FighterAttributes, stage_collision: &'a StageCollision, + attack_scripts: &'a Assets, 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, baked_animations: Res>, fighter_manifests: Res>, + attack_scripts: Res>, stage_collision: Res, ) { 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, )) }; diff --git a/src/hitbox_viewer/mod.rs b/src/hitbox_viewer/mod.rs index f407d69..f03bf73 100644 --- a/src/hitbox_viewer/mod.rs +++ b/src/hitbox_viewer/mod.rs @@ -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, manifest: Handle, + manifest_path: String, + manifest_choices: Vec<(String, Handle)>, model: Option>, baked: Option>, script: Option>, + script_attack: Option, fighter: Option, error: Option, } @@ -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::(); - let attack = args.attack.into(); - let show_hurtboxes = !args.hide_hurtboxes; + let (attack, show_hurtboxes, manifest_path) = { + let args = app.world().resource::(); + ( + 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, args: Res) { + 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, args: Res, + manifests: Res>, + mut viewer: ResMut, + mut state: ResMut, +) { + 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, + settings: Res>, manifests: Res>, mut viewer: ResMut, - state: Res, + mut state: ResMut, ) { - 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>, baked_assets: Res>, manifests: Res>, - 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, viewer: Res, + manifests: Res>, scripts: Res>, baked_assets: Res>, ) -> 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); diff --git a/src/lib.rs b/src/lib.rs index c78aa77..4db5f7f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -62,6 +62,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 { diff --git a/src/scripting/mod.rs b/src/scripting/mod.rs index de83d1b..9727e8e 100644 --- a/src/scripting/mod.rs +++ b/src/scripting/mod.rs @@ -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, - pub iasa_frame: u32, - pub wont_autocancel_window: (u32, u32), + pub iasa_frame: Option, + pub wont_autocancel_window: Option<(u32, u32)>, + pub landing_duration: Option, } 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 { - return Err("autocancel window end precedes its start".into()); + + 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(()) } } diff --git a/src/scripting/move_compiler.rs b/src/scripting/move_compiler.rs index 65e1b98..622c779 100644 --- a/src/scripting/move_compiler.rs +++ b/src/scripting/move_compiler.rs @@ -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, - active_hitboxes: Vec, + hitboxes: Vec, + active_hitboxes: HashMap, wont_autocancel_window: Option<(u32, u32)>, iasa_frame: Option, + landing_duration: Option, 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::try_from(value).map_err(|_| { format!( @@ -43,28 +72,18 @@ impl MoveCompiler { } fn remove_hitbox(&mut self, id: u32) -> Result<(), Box> { - 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> { // 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::>() { self.remove_hitbox(hitbox_id)?; } @@ -97,14 +116,14 @@ impl MoveCompiler { fn into_attack_script(mut self) -> Result { 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::("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") .register_fn("knockback", Knockback::lit) @@ -128,6 +149,17 @@ pub fn register_common_types(engine: &mut rhai::Engine) { .register_type_with_name::("Vec3") .register_fn("vec3", FGVec3::lit) .register_type_with_name::("FighterDamage") + .register_type_with_name::("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 { ); } + { + let mc = mc.clone(); + engine.register_fn( + "hitbox", + move |id: i64, + group: i64, + bone: &str, + offset: FGVec3, + radius: FGi32, + shared: SharedHitboxInfo| + -> Result<(), Box> { + 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,18 +277,11 @@ pub fn compile_script(text: &str) -> Result { knockback: Knockback, knockback_growth: FGi32| -> Result<(), Box> { - 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()); - } - - let hitbox = attack::AttackHitbox { + add_hitbox( + &mc, id, - bone: bone.to_owned(), + id, + bone, damage, offset, radius, @@ -233,28 +289,92 @@ pub fn compile_script(text: &str) -> Result { knockback_type, knockback, knockback_growth, - start_frame: b.cursor, - end_frame: b.cursor, - }; + ) + }, + ); + } - b.hitboxes.insert(id, hitbox); - b.active_hitboxes.push(id); - - Ok(()) + { + 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> { + add_hitbox( + &mc, + id, + group, + bone, + damage, + offset, + radius, + angle, + knockback_type, + knockback, + knockback_growth, + ) }, ); } engine.run(&text) }; - - result + let script = result .map(|_| { Rc::into_inner(mc) .unwrap() .into_inner() .into_attack_script() }) - .map_err(|err| err.to_string())? + .map_err(|err| err.to_string())??; + script.validate().map(|()| script) +} + +fn add_hitbox( + mc: &Rc>, + 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> { + 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, + radius, + angle, + knockback_type, + knockback, + knockback_growth, + start_frame: frame, + end_frame: frame, + }); + b.active_hitboxes.insert(id, hitbox_index); + Ok(()) } #[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); + } }