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
+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 {
@@ -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<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