Implement hitstun and knockback.

This commit is contained in:
2026-09-13 23:01:25 +02:00
parent 811578af2f
commit 4570860d64
26 changed files with 840 additions and 246 deletions
+5 -5
View File
@@ -18,11 +18,11 @@ pub fn apply_air_drift(
velocity.x += (-velocity_sign) * attribs.air_friction;
}
} else {
let target_velocity =
input.get_last_frame().movement.x * attribs.air_drift_max;
let mut acceleration =
input.get_last_frame().movement.x.signum() * attribs.air_acceleration_base + input.get_last_frame().movement.x * attribs.air_acceleration_stick;
let target_velocity = input.get_last_frame().movement.x * attribs.air_drift_max;
let mut acceleration = input.get_last_frame().movement.x.signum()
* attribs.air_acceleration_base
+ input.get_last_frame().movement.x * attribs.air_acceleration_stick;
// Acceleration opposes velocity
if acceleration * velocity.x < 0 {
// do nothing
+14 -4
View File
@@ -1,9 +1,16 @@
use crate::{
fighter::{
animation::AnimKind, collision, state::{
FighterState, FighterStateContext, FighterStateImpl, air, fall::FallState, ground::GroundedStateCommon, land::{LandingState, LandingType},
animation::AnimKind,
collision,
state::{
FighterState, FighterStateContext, FighterStateImpl, air,
fall::FallState,
ground::GroundedStateCommon,
land::{LandingState, LandingType},
},
}, input, math::vec::FGVec2,
},
input,
math::vec::FGVec2,
};
#[derive(Debug, Clone, Hash)]
@@ -42,7 +49,10 @@ impl FighterStateImpl for AirDodgeState {
let grounded_common = GroundedStateCommon {
current_line_id: res.line_id,
};
Some(FighterState::Land(LandingState { grounded_common, landing_type: LandingType::Special }))
Some(FighterState::Land(LandingState {
grounded_common,
landing_type: LandingType::Special,
}))
} else {
None
}
+1 -1
View File
@@ -11,7 +11,7 @@ use crate::input::FighterCommands::{SmashMoveLeft, SmashMoveRight};
#[derive(Debug, Clone)]
pub struct DashState {
direction: FighterFacingDirection,
ground_common: GroundedStateCommon,
pub ground_common: GroundedStateCommon,
}
impl std::hash::Hash for DashState {
+4 -1
View File
@@ -45,7 +45,10 @@ impl FighterStateImpl for FallState {
let grounded_common = GroundedStateCommon {
current_line_id: res.line_id,
};
Some(FighterState::Land(LandingState::create(grounded_common, LandingType::Normal)))
Some(FighterState::Land(LandingState::create(
grounded_common,
LandingType::Normal,
)))
} else {
None
}
+17 -6
View File
@@ -26,14 +26,19 @@ impl FighterStateImpl for GroundAttackState {
}
fn update(&mut self, state_context: &mut super::FighterStateContext) {
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 {
let animation_frame = state_context.get_current_animation_frame();
update_active_hitbox_list(
script,
&mut state_context.hitboxes.active_hitboxes,
&mut state_context
.hitboxes
.0
.as_mut()
.expect("ground attack must have active hitboxes")
.active_hitboxes,
animation_frame,
);
}
@@ -41,13 +46,19 @@ impl FighterStateImpl for GroundAttackState {
fn on_enter(&mut self, state_context: &mut super::FighterStateContext) {
state_context.play_animation(self.attack_kind.get_animation(), false);
state_context.hitboxes.attack_script =
Some(state_context.attack_script_assets.scripts[&self.attack_kind].clone());
state_context.hitboxes.active_hitboxes.clear();
let script = state_context.attack_script_assets.scripts[&self.attack_kind].clone();
state_context.stale_move_queue.push(self.attack_kind);
state_context.hitboxes.0 = Some(crate::fighter::FighterActiveAttack {
kind: self.attack_kind,
script,
active_hitboxes: Vec::new(),
solved_hitboxes: Vec::new(),
hit_fighters: Vec::new(),
});
}
fn on_exit(&mut self, state_context: &mut super::FighterStateContext) {
state_context.hitboxes.clear();
state_context.hitboxes.0 = None;
}
fn check_collision_interrupt(
+117
View File
@@ -0,0 +1,117 @@
use crate::{
fighter::{
animation::AnimKind,
attack::FighterHitReaction,
collision,
state::{
FighterState, FighterStateContext, FighterStateImpl, air,
fall::FallState,
ground::{GroundedMotionResult, GroundedStateCommon, collide_with_stage_grounded},
land::{LandingState, LandingType},
wait::WaitState,
},
},
math::int::FGi32,
};
#[derive(Clone, Debug, Hash)]
pub enum HitstopStateKind {
Grounded(GroundedStateCommon),
Airborne,
}
#[derive(Clone, Debug, Hash)]
pub struct HitstunState {
pub hit_reaction: FighterHitReaction,
pub hitstop_kind: HitstopStateKind,
}
impl FighterStateImpl for HitstunState {
const NAME: &'static str = "Hitstun";
fn name(&self) -> String {
let hitstop_kind = match self.hitstop_kind {
HitstopStateKind::Grounded(_) => "Grounded",
HitstopStateKind::Airborne => "Airborne",
};
let hit_reaction_type = match &self.hit_reaction.kind {
crate::fighter::attack::HitReactionKind::Flinch => "Flinch",
crate::fighter::attack::HitReactionKind::Tumble => "Tumble",
};
format!("Hitstun ({hitstop_kind}, {hit_reaction_type})")
}
fn check_interrupt(&self, state_context: &mut FighterStateContext) -> Option<FighterState> {
if state_context.get_current_animation_frame() > self.hit_reaction.hitstun_frames {
match &self.hitstop_kind {
HitstopStateKind::Grounded(grounded_state_common) => {
Some(FighterState::Wait(WaitState {
grounded_common: grounded_state_common.clone(),
}))
}
HitstopStateKind::Airborne => Some(FighterState::Fall(FallState)),
}
} else {
None
}
}
fn update(&mut self, ctx: &mut FighterStateContext) {
air::integrate_gravity(ctx.velocity, &ctx.fighter_manifest.attributes);
let speed = ctx.knockback_velocity.length();
let next_speed = speed
.saturating_sub(ctx.game_settings.fighter_common.knockback_decay_per_frame)
.max(FGi32::ZERO);
ctx.knockback_velocity.0 = ctx.knockback_velocity.normalize_or_zero() * next_speed;
ctx.translation.0 += ctx.velocity.0 + ctx.knockback_velocity.0;
}
fn on_enter(&mut self, state_context: &mut FighterStateContext) {
let animation = match self.hitstop_kind {
HitstopStateKind::Grounded(_) => AnimKind::FlinchGround,
HitstopStateKind::Airborne => AnimKind::FlinchAir,
};
state_context.play_animation(animation, false);
state_context.knockback_velocity.0 = self.hit_reaction.launch_velocity;
}
fn on_exit(&mut self, _state_context: &mut FighterStateContext) {}
fn check_collision_interrupt(
&mut self,
state_context: &mut FighterStateContext,
) -> Option<FighterState> {
match &mut self.hitstop_kind {
HitstopStateKind::Grounded(grounded_state_common) => {
if let GroundedMotionResult::InAir =
collide_with_stage_grounded(state_context, grounded_state_common, true)
{
Some(FighterState::Fall(FallState {}))
} else {
None
}
}
HitstopStateKind::Airborne => {
if let Some(res) = collision::air_collide_with_stage(state_context) {
// Adjust translation
state_context.translation.0 =
res.hit_position - state_context.ecb.get_bottom_point();
let grounded_common = GroundedStateCommon {
current_line_id: res.line_id,
};
Some(FighterState::Land(LandingState::create(
grounded_common,
LandingType::Normal,
)))
} else {
None
}
}
}
}
}
+15 -4
View File
@@ -2,10 +2,18 @@ use bevy::prelude::*;
use crate::{
fighter::{
animation::AnimKind, collision, state::{
FighterState, FighterStateContext, FighterStateImpl, air, air_dodge::{self, AirDodgeState}, fall::FallState, ground::{self, GroundedMotionResult, GroundedStateCommon}, land::{LandingState, LandingType},
animation::AnimKind,
collision,
state::{
FighterState, FighterStateContext, FighterStateImpl, air,
air_dodge::{self, AirDodgeState},
fall::FallState,
ground::{self, GroundedMotionResult, GroundedStateCommon},
land::{LandingState, LandingType},
},
}, input::FighterCommands, math::{int::FGi32, vec::FGVec2},
},
input::FighterCommands,
math::{int::FGi32, vec::FGVec2},
};
#[derive(Debug, Hash, Clone, Copy)]
pub enum JumpType {
@@ -231,7 +239,10 @@ impl FighterStateImpl for JumpState {
let grounded_common = GroundedStateCommon {
current_line_id: res.line_id,
};
Some(FighterState::Land(LandingState::create(grounded_common, LandingType::Normal)))
Some(FighterState::Land(LandingState::create(
grounded_common,
LandingType::Normal,
)))
} else {
None
}
+9 -5
View File
@@ -14,7 +14,7 @@ use crate::fighter::{
#[derive(Debug, Clone, Hash)]
pub enum LandingType {
Normal,
Special
Special,
}
#[derive(Debug, Clone, Hash)]
@@ -25,7 +25,10 @@ pub struct LandingState {
impl LandingState {
pub fn create(grounded_common: GroundedStateCommon, landing_type: LandingType) -> Self {
Self { grounded_common, landing_type }
Self {
grounded_common,
landing_type,
}
}
}
@@ -36,7 +39,8 @@ impl FighterStateImpl for LandingState {
if state_context.is_current_animation_finished() {
grounded_movement_standstill_common_interrupts(state_context, &self.grounded_common)
} else if state_context.get_current_animation_frame()
>= state_context.fighter_manifest.attributes.landing_iasa && let LandingType::Normal = self.landing_type
>= state_context.fighter_manifest.attributes.landing_iasa
&& let LandingType::Normal = self.landing_type
{
dash::check_input(state_context, &self.grounded_common).or_else(|| {
ground::grounded_movement_common_interrupts(state_context, &self.grounded_common)
@@ -88,10 +92,10 @@ impl FighterStateImpl for LandingState {
match self.landing_type {
LandingType::Normal => {
state_context.play_animation(AnimKind::Landing, false);
},
}
LandingType::Special => {
state_context.play_animation(AnimKind::LandingSpecial, false);
},
}
};
}
}
+86 -37
View File
@@ -6,14 +6,18 @@ use bevy::ecs::query::QueryData;
use bevy::prelude::*;
use crate::fighter::animation::{AnimKind, FighterAnimationFrame};
use crate::fighter::attack::FighterAttackScriptAssets;
use crate::fighter::attack::{
FighterAttackScriptAssets, FighterKnockbackVelocity, FighterPendingHitReaction,
FighterStaleMoveQueue,
};
use crate::fighter::baked_animation::BakedFighterAnimations;
use crate::fighter::ecb::FighterPreviousECB;
use crate::fighter::manifest::FighterManifest;
use crate::fighter::state::hitstun::HitstunState;
use crate::fighter::visual::FighterAnimations;
use crate::fighter::{
Fighter, FighterECB, FighterFacingDirection, FighterHitboxes, FighterPreviousTranslation,
FighterTranslation, FighterVelocity,
Fighter, FighterECB, FighterFacingDirection, FighterHitboxes, FighterHitstop,
FighterPreviousTranslation, FighterTranslation, FighterVelocity,
};
use crate::game_settings::GameSettings;
use crate::input::FighterInput;
@@ -26,6 +30,7 @@ pub mod dash;
pub mod fall;
pub mod ground;
pub mod ground_attack;
pub mod hitstun;
pub mod jump;
pub mod land;
pub mod run;
@@ -74,6 +79,9 @@ pub trait FighterStateImpl: Sized {
fn on_enter(&mut self, _state_context: &mut FighterStateContext) {}
fn on_exit(&mut self, _state_context: &mut FighterStateContext) {}
fn name(&self) -> String {
Self::NAME.to_owned()
}
fn update(&mut self, _state_context: &mut FighterStateContext);
fn check_collision_interrupt(
@@ -90,6 +98,8 @@ pub struct FighterStateContext<'a> {
pub input: &'a mut FighterInput,
pub hitboxes: &'a mut FighterHitboxes,
pub velocity: &'a mut FighterVelocity,
pub knockback_velocity: &'a mut FighterKnockbackVelocity,
pub stale_move_queue: &'a mut FighterStaleMoveQueue,
pub ecb: &'a mut FighterECB,
pub prev_ecb: &'a mut FighterPreviousECB,
pub facing_direction: &'a mut FighterFacingDirection,
@@ -136,6 +146,7 @@ fighter_states! {
AirDodge => air_dodge::AirDodgeState,
Turn => turn::TurnState,
GroundAttack => ground_attack::GroundAttackState,
Hitstun => hitstun::HitstunState,
}
#[derive(QueryData)]
@@ -146,6 +157,8 @@ pub struct FighterFrameQuery {
pub input: &'static mut FighterInput,
pub hitboxes: &'static mut FighterHitboxes,
pub velocity: &'static mut FighterVelocity,
pub knockback_velocity: &'static mut FighterKnockbackVelocity,
pub stale_move_queue: &'static mut FighterStaleMoveQueue,
pub ecb: &'static mut FighterECB,
pub prev_ecb: &'static mut FighterPreviousECB,
pub facing_direction: &'static mut FighterFacingDirection,
@@ -170,6 +183,8 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> {
input: &mut self.input,
hitboxes: &mut self.hitboxes,
velocity: &mut self.velocity,
knockback_velocity: &mut self.knockback_velocity,
stale_move_queue: &mut self.stale_move_queue,
ecb: &mut self.ecb,
prev_ecb: &mut self.prev_ecb,
facing_direction: &mut self.facing_direction,
@@ -188,21 +203,13 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> {
#[derive(QueryData)]
#[query_data(mutable)]
pub struct StatefulFighterQuery {
pub entity: Entity,
pub state: &'static mut FighterState,
pub frame: FighterFrameQuery,
pub animations: &'static FighterAnimations,
pub fighter: &'static Fighter,
pub attack_script_assets: &'static FighterAttackScriptAssets,
}
#[derive(Component)]
pub struct StateNameDebug(pub &'static str);
impl std::fmt::Display for StateNameDebug {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
pub pending_hit_reaction: &'static mut FighterPendingHitReaction,
pub hitstop: &'static mut FighterHitstop,
}
pub fn state_update_system(
@@ -218,9 +225,14 @@ pub fn state_update_system(
mut state,
mut frame,
animations,
hitstop,
..
} = fighter;
if hitstop.remaining_frames > 0 {
continue;
}
let mut update_ctx = frame.context(
&game_settings,
&stage_collision,
@@ -236,6 +248,20 @@ pub fn state_update_system(
}
}
fn transition_state(
current: &mut FighterState,
mut next: FighterState,
ctx: &mut FighterStateContext,
) {
let old_name = current.name();
let new_name = next.name();
current.on_exit(ctx);
next.on_enter(ctx);
*current = next;
info!("{} -> {}", old_name, new_name);
}
pub fn state_interrupt_system(
mut query: Query<StatefulFighterQuery>,
stage_collision: Res<StageCollision>,
@@ -243,16 +269,16 @@ pub fn state_interrupt_system(
baked_animations: Res<Assets<BakedFighterAnimations>>,
fighter_manifests: Res<Assets<FighterManifest>>,
attack_scripts: Res<Assets<FighterAttackScript>>,
mut commands: Commands,
) {
for fighter in &mut query {
let StatefulFighterQueryItem {
entity,
mut state,
mut frame,
animations,
fighter,
attack_script_assets,
mut pending_hit_reaction,
mut hitstop,
} = fighter;
let mut state_context = frame.context(
&game_settings,
@@ -265,17 +291,43 @@ pub fn state_interrupt_system(
.get(&fighter.manifest)
.expect("Fighter manifest should be valid"),
);
if let Some(new_state) = state.check_interrupt(&mut state_context) {
let mut new_state = new_state;
let old_state_name = state.name();
let new_state_name = new_state.name();
state.on_exit(&mut state_context);
new_state.on_enter(&mut state_context);
commands
.entity(entity)
.insert((new_state, StateNameDebug(new_state_name)));
info!("State {} -> {}", old_state_name, new_state_name);
}
let new_state = if let Some(reaction) = pending_hit_reaction.take() {
hitstop.remaining_frames = *reaction.hitstop_frames;
let hitstop_kind = match &*state {
FighterState::Wait(value) => {
hitstun::HitstopStateKind::Grounded(value.grounded_common.clone())
}
FighterState::Walk(value) => {
hitstun::HitstopStateKind::Grounded(value.grounded_common.clone())
}
FighterState::Dash(value) => {
hitstun::HitstopStateKind::Grounded(value.ground_common.clone())
}
FighterState::Run(value) => {
hitstun::HitstopStateKind::Grounded(value.grounded_common.clone())
}
FighterState::Turn(value) => {
hitstun::HitstopStateKind::Grounded(value.grounded_common.clone())
}
FighterState::GroundAttack(value) => {
hitstun::HitstopStateKind::Grounded(value.grounded_common.clone())
}
_ => hitstun::HitstopStateKind::Airborne,
};
Some(FighterState::Hitstun(HitstunState {
hit_reaction: reaction,
hitstop_kind,
}))
} else if hitstop.remaining_frames > 0 {
None
} else {
state.check_interrupt(&mut state_context)
};
if let Some(new_state) = new_state {
transition_state(&mut state, new_state, &mut state_context);
};
}
}
@@ -286,16 +338,16 @@ pub fn state_collision_interrupt_system(
baked_animations: Res<Assets<BakedFighterAnimations>>,
fighter_manifests: Res<Assets<FighterManifest>>,
attack_scripts: Res<Assets<FighterAttackScript>>,
mut commands: Commands,
) {
for fighter in &mut query {
let StatefulFighterQueryItem {
entity,
mut state,
mut frame,
animations,
fighter,
attack_script_assets,
hitstop,
..
} = fighter;
let mut state_context = frame.context(
&game_settings,
@@ -308,16 +360,13 @@ pub fn state_collision_interrupt_system(
.get(&fighter.manifest)
.expect("Fighter manifest should be valid"),
);
if hitstop.remaining_frames > 0 {
continue;
}
if let Some(new_state) = state.check_collision_interrupt(&mut state_context) {
let mut new_state = new_state;
let old_state_name = state.name();
let new_state_name = new_state.name();
state.on_exit(&mut state_context);
new_state.on_enter(&mut state_context);
commands
.entity(entity)
.insert((new_state, StateNameDebug(new_state_name)));
info!("State {} -> {}", old_state_name, new_state_name);
transition_state(&mut state, new_state, &mut state_context);
}
}
}
+13 -4
View File
@@ -7,14 +7,19 @@ use crate::input::FighterCommands;
#[derive(Debug, Clone, Hash)]
pub struct TurnState {
grounded_common: GroundedStateCommon,
pub grounded_common: GroundedStateCommon,
}
impl FighterStateImpl for TurnState {
const NAME: &'static str = "Turn";
fn check_interrupt(&self, state_context: &mut FighterStateContext) -> Option<FighterState> {
// State chain that results in the turn completing
if state_context.get_current_animation_frame() <= state_context.game_settings.input_common.turn_into_dashback_window as u32 {
if state_context.get_current_animation_frame()
<= state_context
.game_settings
.input_common
.turn_into_dashback_window as u32
{
if let Some(state) = dash::check_input(state_context, &self.grounded_common) {
return Some(state);
}
@@ -86,13 +91,17 @@ pub fn check_input(
) -> Option<FighterState> {
let input_frame = state_context.input.get_last_frame();
let has_smash_input = state_context.input.has_command(FighterCommands::SmashMoveLeft) || state_context.input.has_command(FighterCommands::SmashMoveRight);
let has_smash_input = state_context
.input
.has_command(FighterCommands::SmashMoveLeft)
|| state_context
.input
.has_command(FighterCommands::SmashMoveRight);
if !input_frame.movement.x.is_zero()
&& input_frame.movement.x.signum() != state_context.facing_direction.to_sign()
&& !has_smash_input
{
dbg!(input_frame.movement.x);
return Some(FighterState::Turn(TurnState {
grounded_common: grounded_common.clone(),
}));
-1
View File
@@ -3,7 +3,6 @@ use crate::fighter::animation::AnimKind;
use crate::fighter::state::FighterState;
use crate::fighter::state::fall::FallState;
use crate::fighter::state::ground::{GroundedMotionResult, GroundedStateCommon};
use crate::fighter::state::turn;
#[derive(Debug, Clone, Hash)]
pub struct WalkState {