Attack system revamp (#82)
CI / build-and-test (push) Successful in 1m35s
CI / production-build (push) Successful in 11m29s

- 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

Reviewed-on: #82
Co-authored-by: Álex Román Núñez <eirexe@eirteam.moe>
This commit was merged in pull request #82.
This commit is contained in:
2026-10-04 18:57:29 +00:00
committed by EIREXE
parent 04aceb05a9
commit d2d649b059
15 changed files with 1046 additions and 449 deletions
+22 -8
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 {
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(())
}
}
+205 -46
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,36 @@ 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. Melee's default collision
// group is shared by all hitbox slots in an attack.
{
let mc = mc.clone();
engine.register_fn(
@@ -214,18 +276,11 @@ 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());
}
let hitbox = attack::AttackHitbox {
add_hitbox(
&mc,
id,
bone: bone.to_owned(),
0,
bone,
damage,
offset,
radius,
@@ -233,28 +288,92 @@ 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);
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<EvalAltResult>> {
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<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,
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 +435,44 @@ 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);
}
#[test]
fn legacy_hitboxes_share_the_default_collision_group() {
let script = compile_script(
r#"
frame(1);
hitbox(0, "hand_l", damage("1"), vec3("0", "0", "0"), fg32("1"), angle("0"), knockback_normal(), knockback("1"), fg32("1"));
hitbox(1, "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.iter().map(|hitbox| hitbox.group).collect::<Vec<_>>(), vec![0, 0]);
}
}