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
+93 -9
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 {
@@ -52,7 +52,8 @@ pub enum HitReactionKind {
pub struct FighterHitReaction {
pub launch_velocity: FGi32,
pub launch_angle: FGi32,
pub hitstop_frames: HitStop,
pub victim_hitstop_frames: HitStop,
pub attacker_hitstop_frames: HitStop,
pub hitstun_frames: u32,
pub kind: HitReactionKind,
pub damage: FighterDamage,
@@ -80,7 +81,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 +98,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 +120,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 +157,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 +197,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 +246,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 +259,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 +314,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 +411,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 +643,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 +652,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 +683,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);
}
}
}
}