Implement Melee-style knockback stacking (#97)
CI / build-and-test (push) Successful in 2m11s
CI / production-build (push) Successful in 12m25s

## Summary

- stack residual airborne knockback per axis after the configured launch-frame threshold
- apply DI after combining the launch vectors while retaining the second hit's hitstun
- preserve and decay residual knockback after hitstun
- make stacking state rollback-safe and include it in debug save states
- clear launch state on landing, ledge interactions, KO, and respawn

## Testing

- `cargo check --tests`
- `cargo test` (159 tests passed)
- `git diff --check`

## Asset configuration

For Melee timing, `knockback_stacking_window` should be set to `10`. The local ignored asset package has been updated accordingly.

Reviewed-on: #97
Co-authored-by: Álex Román Núñez <eirexe@eirteam.moe>
This commit was merged in pull request #97.
This commit is contained in:
2026-10-25 21:02:13 +00:00
committed by EIREXE
parent 7f720119fa
commit cbb357d30a
13 changed files with 260 additions and 30 deletions
+6 -2
View File
@@ -9,8 +9,9 @@ use crate::{
FighterTranslation, FighterVelocity, FighterTranslation, FighterVelocity,
animation::FighterAnimationFrame, animation::FighterAnimationFrame,
attack::{ attack::{
FighterDamage, FighterKnockbackVelocity, FighterPendingHitReaction, FighterPendingHits, FighterDamage, FighterKnockbackState, FighterKnockbackVelocity,
FighterSolvedHurtboxes, FighterStaleMoveQueue, FighterPendingHitReaction, FighterPendingHits, FighterSolvedHurtboxes,
FighterStaleMoveQueue,
}, },
baked_animation::FighterBoneMatrices, baked_animation::FighterBoneMatrices,
ecb::FighterDesiredECB, ecb::FighterDesiredECB,
@@ -55,6 +56,7 @@ struct FighterCheckpoint {
hitstop: FighterHitstop, hitstop: FighterHitstop,
hitstop_shake: FighterHitstopShake, hitstop_shake: FighterHitstopShake,
knockback_velocity: FighterKnockbackVelocity, knockback_velocity: FighterKnockbackVelocity,
knockback_state: FighterKnockbackState,
stale_moves: FighterStaleMoveQueue, stale_moves: FighterStaleMoveQueue,
hitboxes: FighterHitboxes, hitboxes: FighterHitboxes,
solved_hurtboxes: FighterSolvedHurtboxes, solved_hurtboxes: FighterSolvedHurtboxes,
@@ -160,6 +162,7 @@ pub(super) fn save_or_restore(world: &mut World) {
entity.insert(saved.hitstop); entity.insert(saved.hitstop);
entity.insert(saved.hitstop_shake); entity.insert(saved.hitstop_shake);
entity.insert(saved.knockback_velocity); entity.insert(saved.knockback_velocity);
entity.insert(saved.knockback_state);
entity.insert(saved.stale_moves.clone()); entity.insert(saved.stale_moves.clone());
entity.insert(saved.hitboxes.clone()); entity.insert(saved.hitboxes.clone());
entity.insert(saved.solved_hurtboxes.clone()); entity.insert(saved.solved_hurtboxes.clone());
@@ -199,6 +202,7 @@ fn capture_fighter(world: &World, entity: Entity) -> Option<FighterCheckpoint> {
hitstop: *fighter.get::<FighterHitstop>()?, hitstop: *fighter.get::<FighterHitstop>()?,
hitstop_shake: *fighter.get::<FighterHitstopShake>()?, hitstop_shake: *fighter.get::<FighterHitstopShake>()?,
knockback_velocity: *fighter.get::<FighterKnockbackVelocity>()?, knockback_velocity: *fighter.get::<FighterKnockbackVelocity>()?,
knockback_state: *fighter.get::<FighterKnockbackState>()?,
stale_moves: fighter.get::<FighterStaleMoveQueue>()?.clone(), stale_moves: fighter.get::<FighterStaleMoveQueue>()?.clone(),
hitboxes: fighter.get::<FighterHitboxes>()?.clone(), hitboxes: fighter.get::<FighterHitboxes>()?.clone(),
solved_hurtboxes: fighter.get::<FighterSolvedHurtboxes>()?.clone(), solved_hurtboxes: fighter.get::<FighterSolvedHurtboxes>()?.clone(),
+32 -3
View File
@@ -15,6 +15,16 @@ pub fn apply_directional_influence(
let (sin, cos) = attack_angle.sin_cos(); let (sin, cos) = attack_angle.sin_cos();
let base_launch_direction = FGVec2::new(cos, sin); let base_launch_direction = FGVec2::new(cos, sin);
apply_directional_influence_to_direction(receiver_stick, base_launch_direction, di_max_angle)
}
/// Applies DI to an already-computed launch direction. This is used after
/// knockback stacking, where the resulting angle may differ from either hit.
pub fn apply_directional_influence_to_direction(
receiver_stick: FGVec2,
base_launch_direction: FGVec2,
di_max_angle: FGi32,
) -> FGVec2 {
let Some(base_launch_direction) = base_launch_direction.normalize() else { let Some(base_launch_direction) = base_launch_direction.normalize() else {
return FGVec2::ZERO; return FGVec2::ZERO;
}; };
@@ -30,14 +40,20 @@ pub fn apply_directional_influence(
-di_max_angle.to_fixed::<FGWide>(), -di_max_angle.to_fixed::<FGWide>(),
di_max_angle.to_fixed::<FGWide>(), di_max_angle.to_fixed::<FGWide>(),
); );
let (sin, cos) = (attack_angle + angle_change.to_fixed::<FGi32>()).sin_cos(); let (sin, cos) = angle_change.to_fixed::<FGi32>().sin_cos();
FGVec2::new(cos, sin).normalize_or_zero() FGVec2::new(
base_launch_direction.x * cos - base_launch_direction.y * sin,
base_launch_direction.x * sin + base_launch_direction.y * cos,
)
.normalize_or_zero()
} }
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use crate::{ use crate::{
fighter::attack::directional_influence::apply_directional_influence, fighter::attack::directional_influence::{
apply_directional_influence, apply_directional_influence_to_direction,
},
math::{int::FGi32, vec::FGVec2}, math::{int::FGi32, vec::FGVec2},
}; };
@@ -66,4 +82,17 @@ mod test {
assert!(di.x > FGi32::ZERO); assert!(di.x > FGi32::ZERO);
assert!(di.y < FGi32::ZERO); assert!(di.y < FGi32::ZERO);
} }
#[test]
fn vector_di_preserves_a_stacked_direction_without_input() {
let direction = FGVec2::lit("-0.6", "0.8");
let result = apply_directional_influence_to_direction(
FGVec2::ZERO,
direction,
FGi32::lit("18.0"),
);
const EPSILON: FGi32 = FGi32::from_bits(6);
assert!(result.x.abs_diff(direction.x) < EPSILON);
assert!(result.y.abs_diff(direction.y) < EPSILON);
}
} }
+116
View File
@@ -33,6 +33,75 @@ use crate::{
#[derive(Deref, Component, Default, Hash, Debug, Copy, Clone)] #[derive(Deref, Component, Default, Hash, Debug, Copy, Clone)]
pub struct FighterKnockbackVelocity(pub FGVec2); pub struct FighterKnockbackVelocity(pub FGVec2);
/// Lifecycle state for the currently active launch velocity.
///
/// This counts frames where knockback was actually applied to movement; hitstop
/// therefore does not advance it. Melee uses this age to decide whether a
/// subsequent airborne hit replaces or stacks with the previous launch.
#[derive(Component, Default, Hash, Debug, Copy, Clone)]
pub struct FighterKnockbackState {
pub frames_since_launch: u32,
}
impl FighterKnockbackState {
pub fn restart(&mut self) {
self.frames_since_launch = 0;
}
pub fn clear(&mut self) {
self.frames_since_launch = 0;
}
pub fn advance(&mut self) {
self.frames_since_launch = self.frames_since_launch.saturating_add(1);
}
pub fn can_stack(&self, minimum_frames: u32) -> bool {
self.frames_since_launch >= minimum_frames
}
}
fn stack_knockback_component(previous: FGi32, new: FGi32) -> FGi32 {
if previous.is_zero() {
return new;
}
if new.is_zero() {
return previous;
}
if previous.signum() != new.signum() {
previous + new
} else if previous.abs() > new.abs() {
previous
} else {
new
}
}
/// Applies Melee's knockback-stacking rule independently to both axes.
pub fn stack_knockback(previous: FGVec2, new: FGVec2) -> FGVec2 {
FGVec2::new(
stack_knockback_component(previous.x, new.x),
stack_knockback_component(previous.y, new.y),
)
}
pub fn decay_knockback(
velocity: &mut FighterKnockbackVelocity,
state: &mut FighterKnockbackState,
decay_per_frame: FGi32,
) {
let speed = velocity.length();
let next_speed = speed.saturating_sub(decay_per_frame).max(FGi32::ZERO);
velocity.0 = velocity.normalize_or_zero() * next_speed;
if next_speed.is_zero() {
state.clear();
} else {
state.advance();
}
}
#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, Component, Hash, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, Component, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub struct FighterDamage(FGi32); pub struct FighterDamage(FGi32);
@@ -73,6 +142,53 @@ pub struct FighterLastAttackDebug {
pub trajectory_points: Vec<Vec3>, pub trajectory_points: Vec<Vec3>,
} }
#[cfg(test)]
mod knockback_stacking_tests {
use super::*;
#[test]
fn same_direction_keeps_the_stronger_component() {
assert_eq!(
stack_knockback(FGVec2::lit("3", "8"), FGVec2::lit("5", "4")),
FGVec2::lit("5", "8")
);
}
#[test]
fn opposing_components_add_independently() {
assert_eq!(
stack_knockback(FGVec2::lit("3", "8"), FGVec2::lit("-5", "-3")),
FGVec2::lit("-2", "5")
);
}
#[test]
fn opposing_components_can_cancel() {
assert_eq!(
stack_knockback(FGVec2::lit("3", "-4"), FGVec2::lit("-3", "4")),
FGVec2::ZERO
);
}
#[test]
fn zero_components_preserve_the_nonzero_value() {
assert_eq!(
stack_knockback(FGVec2::lit("0", "2"), FGVec2::lit("-3", "0")),
FGVec2::lit("-3", "2")
);
}
#[test]
fn stacking_threshold_is_inclusive() {
let mut state = FighterKnockbackState {
frames_since_launch: 9,
};
assert!(!state.can_stack(10));
state.advance();
assert!(state.can_stack(10));
}
}
#[derive(Debug, Default, Clone, Deref, DerefMut, Hash, PartialEq, PartialOrd)] #[derive(Debug, Default, Clone, Deref, DerefMut, Hash, PartialEq, PartialOrd)]
pub struct HitStop(u32); pub struct HitStop(u32);
+5 -2
View File
@@ -41,8 +41,8 @@ use crate::{
fighter::{ fighter::{
attack::{ attack::{
AttackKind, FighterAttackPlugin, FighterAttackScriptAssets, FighterDamage, AttackKind, FighterAttackPlugin, FighterAttackScriptAssets, FighterDamage,
FighterKnockbackVelocity, FighterPendingHitReaction, FighterSolvedHurtboxes, FighterKnockbackState, FighterKnockbackVelocity, FighterPendingHitReaction,
FighterStaleMoveQueue, FighterSolvedHurtboxes, FighterStaleMoveQueue,
}, },
baked_animation::FighterBoneIndexMap, baked_animation::FighterBoneIndexMap,
ecb::{FighterDesiredECB, FighterECBBoneIndices}, ecb::{FighterDesiredECB, FighterECBBoneIndices},
@@ -212,6 +212,7 @@ pub struct FighterPendingRespawn(bool);
FighterHitstop, FighterHitstop,
FighterLedgeInformation, FighterLedgeInformation,
FighterKnockbackVelocity, FighterKnockbackVelocity,
FighterKnockbackState,
FighterTranslation, FighterTranslation,
FighterInvulnerabilityState, FighterInvulnerabilityState,
FighterInvulnerabilityVisualState FighterInvulnerabilityVisualState
@@ -330,6 +331,8 @@ impl Plugin for FighterPlugin {
.checksum_component_with_hash::<FighterInvulnerabilityState>() .checksum_component_with_hash::<FighterInvulnerabilityState>()
.rollback_component_with_copy::<FighterKnockbackVelocity>() .rollback_component_with_copy::<FighterKnockbackVelocity>()
.checksum_component_with_hash::<FighterKnockbackVelocity>() .checksum_component_with_hash::<FighterKnockbackVelocity>()
.rollback_component_with_copy::<FighterKnockbackState>()
.checksum_component_with_hash::<FighterKnockbackState>()
.rollback_component_with_clone::<baked_animation::FighterBoneMatrices>() .rollback_component_with_clone::<baked_animation::FighterBoneMatrices>()
.checksum_component_with_hash::<baked_animation::FighterBoneMatrices>() .checksum_component_with_hash::<baked_animation::FighterBoneMatrices>()
// Debug views. // Debug views.
+16 -1
View File
@@ -93,6 +93,19 @@ pub fn apply_air_motion(
translation.0 += velocity.0; translation.0 += velocity.0;
} }
/// Applies ordinary and launch velocity together, then advances launch decay.
/// Hitstop skips state updates entirely, so neither decay nor launch age moves
/// while the fighter is frozen.
pub fn apply_air_motion_with_knockback(ctx: &mut crate::fighter::state::UpdateContext) {
let combined_velocity = FighterVelocity(ctx.velocity.0 + ctx.knockback_velocity.0);
apply_air_motion(&combined_velocity, ctx.translation, ctx.prev_translation);
attack::decay_knockback(
ctx.knockback_velocity,
ctx.knockback_state,
ctx.game_settings.fighter_common.knockback_decay_per_frame,
);
}
pub fn update_standard_motion( pub fn update_standard_motion(
ctx: &mut crate::fighter::state::UpdateContext, ctx: &mut crate::fighter::state::UpdateContext,
airborne_common: &mut AirborneStateCommon, airborne_common: &mut AirborneStateCommon,
@@ -109,7 +122,7 @@ pub fn update_standard_motion(
airborne_common.is_fast_falling, airborne_common.is_fast_falling,
); );
apply_air_drift(ctx.velocity, ctx.fighter_attributes, ctx.input); apply_air_drift(ctx.velocity, ctx.fighter_attributes, ctx.input);
apply_air_motion(ctx.velocity, ctx.translation, ctx.prev_translation); apply_air_motion_with_knockback(ctx);
} }
pub fn air_interrupts_common( pub fn air_interrupts_common(
@@ -141,6 +154,8 @@ pub fn collision_interrupt(
*ctx.desired_ecb = crate::fighter::ecb::FighterDesiredECB::from(*ctx.ecb); *ctx.desired_ecb = crate::fighter::ecb::FighterDesiredECB::from(*ctx.ecb);
ctx.presentation_snap.0 = true; ctx.presentation_snap.0 = true;
ctx.surface.land(res.line_id, ctx.ledge_info); ctx.surface.land(res.line_id, ctx.ledge_info);
ctx.knockback_velocity.0 = FGVec2::ZERO;
ctx.knockback_state.clear();
Some(FighterState::Land(LandingState::create(landing_type))) Some(FighterState::Land(LandingState::create(landing_type)))
} }
+3 -5
View File
@@ -33,11 +33,7 @@ impl FighterStateImpl for AirDodgeState {
} }
state_context.velocity.0 *= state_context.fighter_attributes.air_dodge_decay; state_context.velocity.0 *= state_context.fighter_attributes.air_dodge_decay;
air::apply_air_motion( air::apply_air_motion_with_knockback(state_context);
state_context.velocity,
state_context.translation,
state_context.prev_translation,
);
} }
fn check_collision_interrupt( fn check_collision_interrupt(
@@ -59,6 +55,8 @@ impl FighterStateImpl for AirDodgeState {
state_context.ecb.bottom = point_in_segment - state_context.translation.0; state_context.ecb.bottom = point_in_segment - state_context.translation.0;
state_context.ecb.rebase_onto_bottom(); state_context.ecb.rebase_onto_bottom();
state_context.surface.land(StageLineID { polygon: poly_idx, segment: segment_idx }, state_context.ledge_info); state_context.surface.land(StageLineID { polygon: poly_idx, segment: segment_idx }, state_context.ledge_info);
state_context.knockback_velocity.0 = FGVec2::ZERO;
state_context.knockback_state.clear();
return Some(FighterState::Land(LandingState::create(LandingType::Special))); return Some(FighterState::Land(LandingState::create(LandingType::Special)));
} }
} }
+9
View File
@@ -260,6 +260,7 @@ pub fn collision_interrupt(ctx: &mut CollisionContext, can_walk_off: bool) -> Op
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::fighter::attack::{FighterKnockbackState, FighterKnockbackVelocity};
use crate::fighter::ecb::{FighterDesiredECB, FighterPreviousECB}; use crate::fighter::ecb::{FighterDesiredECB, FighterPreviousECB};
use crate::fighter::{ use crate::fighter::{
FighterECB, FighterLedgeInformation, FighterPreviousTranslation, FighterSurfaceState, FighterECB, FighterLedgeInformation, FighterPreviousTranslation, FighterSurfaceState,
@@ -388,6 +389,8 @@ mod tests {
let mut translation = FighterTranslation(FGVec2::lit("11", "0")); let mut translation = FighterTranslation(FGVec2::lit("11", "0"));
let previous_translation = FighterPreviousTranslation(translation.0); let previous_translation = FighterPreviousTranslation(translation.0);
let mut velocity = FighterVelocity::default(); let mut velocity = FighterVelocity::default();
let mut knockback_velocity = FighterKnockbackVelocity::default();
let mut knockback_state = FighterKnockbackState::default();
let mut ledge_info = FighterLedgeInformation::default(); let mut ledge_info = FighterLedgeInformation::default();
let mut ecb = FighterECB { let mut ecb = FighterECB {
bottom: FGVec2::lit("0.0", "-0.5"), bottom: FGVec2::lit("0.0", "-0.5"),
@@ -403,6 +406,8 @@ mod tests {
prev_translation: &previous_translation, prev_translation: &previous_translation,
input: &FighterInput::default(), input: &FighterInput::default(),
velocity: &mut velocity, velocity: &mut velocity,
knockback_velocity: &mut knockback_velocity,
knockback_state: &mut knockback_state,
surface: &mut surface, surface: &mut surface,
ecb: &mut ecb, ecb: &mut ecb,
desired_ecb: &mut desired_ecb, desired_ecb: &mut desired_ecb,
@@ -453,6 +458,8 @@ mod tests {
let mut translation = FighterTranslation(FGVec2::lit("11", "0")); let mut translation = FighterTranslation(FGVec2::lit("11", "0"));
let previous_translation = FighterPreviousTranslation(translation.0); let previous_translation = FighterPreviousTranslation(translation.0);
let mut velocity = FighterVelocity::default(); let mut velocity = FighterVelocity::default();
let mut knockback_velocity = FighterKnockbackVelocity::default();
let mut knockback_state = FighterKnockbackState::default();
let mut ecb = FighterECB { let mut ecb = FighterECB {
bottom: FGVec2::lit("0.0", "-0.5"), bottom: FGVec2::lit("0.0", "-0.5"),
top: FGVec2::lit("0.0", "0.5"), top: FGVec2::lit("0.0", "0.5"),
@@ -468,6 +475,8 @@ mod tests {
prev_translation: &previous_translation, prev_translation: &previous_translation,
input: &FighterInput::default(), input: &FighterInput::default(),
velocity: &mut velocity, velocity: &mut velocity,
knockback_velocity: &mut knockback_velocity,
knockback_state: &mut knockback_state,
surface: &mut surface, surface: &mut surface,
ecb: &mut ecb, ecb: &mut ecb,
desired_ecb: &mut desired_ecb, desired_ecb: &mut desired_ecb,
+40 -15
View File
@@ -1,6 +1,6 @@
use crate::{ use crate::{
fighter::{ fighter::{
FighterVelocity, animation::AnimKind, attack::{self, FighterHitReaction}, state::{ animation::AnimKind, attack::{self, FighterHitReaction}, state::{
CollisionContext, EnterContext, ExitContext, FighterState, FighterStateImpl, CollisionContext, EnterContext, ExitContext, FighterState, FighterStateImpl,
InterruptContext, UpdateContext, InterruptContext, UpdateContext,
air::{self, AirborneStateCommon}, air::{self, AirborneStateCommon},
@@ -9,7 +9,7 @@ use crate::{
land::LandingType, land::LandingType,
wait::WaitState, wait::WaitState,
}, },
}, math::{int::FGi32, vec::FGVec2}, }, math::vec::FGVec2,
}; };
#[derive(Clone, Debug, Hash)] #[derive(Clone, Debug, Hash)]
@@ -23,14 +23,20 @@ pub struct HitstunState {
pub hit_reaction: FighterHitReaction, pub hit_reaction: FighterHitReaction,
pub hitstop_kind: HitstopStateKind, pub hitstop_kind: HitstopStateKind,
knockback_applied: bool, knockback_applied: bool,
stack_previous_knockback: bool,
} }
impl HitstunState { impl HitstunState {
pub fn new(hit_reaction: FighterHitReaction, hitstop_kind: HitstopStateKind) -> Self { pub fn new(
hit_reaction: FighterHitReaction,
hitstop_kind: HitstopStateKind,
stack_previous_knockback: bool,
) -> Self {
Self { Self {
hit_reaction, hit_reaction,
hitstop_kind, hitstop_kind,
knockback_applied: false, knockback_applied: false,
stack_previous_knockback,
} }
} }
} }
@@ -60,7 +66,12 @@ impl FighterStateImpl for HitstunState {
state_context.velocity.0 = FGVec2::ZERO; state_context.velocity.0 = FGVec2::ZERO;
} }
fn on_exit(&mut self, _state_context: &mut ExitContext) {} fn on_exit(&mut self, state_context: &mut ExitContext) {
if matches!(self.hitstop_kind, HitstopStateKind::Grounded) {
state_context.knockback_velocity.0 = FGVec2::ZERO;
state_context.knockback_state.clear();
}
}
fn name(&self) -> String { fn name(&self) -> String {
let hitstop_kind = match self.hitstop_kind { let hitstop_kind = match self.hitstop_kind {
@@ -78,13 +89,27 @@ impl FighterStateImpl for HitstunState {
fn update(&mut self, ctx: &mut UpdateContext) { fn update(&mut self, ctx: &mut UpdateContext) {
if !self.knockback_applied { if !self.knockback_applied {
let launch_direction = attack::directional_influence::apply_directional_influence( let new_launch_direction = attack::directional_influence::apply_directional_influence(
ctx.input.get_last_frame().movement, FGVec2::ZERO,
self.hit_reaction.launch_angle, self.hit_reaction.launch_angle,
ctx.game_settings.fighter_common.di_max_angle, ctx.game_settings.fighter_common.di_max_angle,
); );
let new_knockback = new_launch_direction * self.hit_reaction.launch_velocity;
let combined_knockback = if self.stack_previous_knockback {
attack::stack_knockback(ctx.knockback_velocity.0, new_knockback)
} else {
new_knockback
};
let launch_speed = combined_knockback.length();
let launch_direction =
attack::directional_influence::apply_directional_influence_to_direction(
ctx.input.get_last_frame().movement,
combined_knockback.normalize_or_zero(),
ctx.game_settings.fighter_common.di_max_angle,
);
ctx.knockback_velocity.0 = launch_direction * self.hit_reaction.launch_velocity; ctx.knockback_velocity.0 = launch_direction * launch_speed;
ctx.knockback_state.restart();
self.knockback_applied = true; self.knockback_applied = true;
} }
@@ -92,17 +117,17 @@ impl FighterStateImpl for HitstunState {
HitstopStateKind::Grounded => {} HitstopStateKind::Grounded => {}
HitstopStateKind::Airborne => { HitstopStateKind::Airborne => {
air::integrate_gravity(ctx.velocity, ctx.fighter_attributes, false); air::integrate_gravity(ctx.velocity, ctx.fighter_attributes, false);
let vel_with_kb = FighterVelocity(ctx.velocity.0 + ctx.knockback_velocity.0); air::apply_air_motion_with_knockback(ctx);
air::apply_air_motion(&vel_with_kb, ctx.translation, ctx.prev_translation);
} }
} }
let speed = ctx.knockback_velocity.length(); if matches!(self.hitstop_kind, HitstopStateKind::Grounded) {
let next_speed = speed attack::decay_knockback(
.saturating_sub(ctx.game_settings.fighter_common.knockback_decay_per_frame) ctx.knockback_velocity,
.max(FGi32::ZERO); ctx.knockback_state,
ctx.game_settings.fighter_common.knockback_decay_per_frame,
ctx.knockback_velocity.0 = ctx.knockback_velocity.normalize_or_zero() * next_speed; );
}
} }
fn check_collision_interrupt( fn check_collision_interrupt(
+1
View File
@@ -84,6 +84,7 @@ impl FighterStateImpl for LedgeActionState {
state_context.velocity.0 = FGVec2::ZERO; state_context.velocity.0 = FGVec2::ZERO;
state_context.knockback_velocity.0 = FGVec2::ZERO; state_context.knockback_velocity.0 = FGVec2::ZERO;
state_context.knockback_state.clear();
let Some(ledge) = state_context.stage_collision.find_ledge_by_vertex(self.ledge_id) else { let Some(ledge) = state_context.stage_collision.find_ledge_by_vertex(self.ledge_id) else {
return; return;
}; };
+2
View File
@@ -36,6 +36,8 @@ impl FighterStateImpl for LedgeGrabState {
fn on_enter(&mut self, state_context: &mut EnterContext) { fn on_enter(&mut self, state_context: &mut EnterContext) {
state_context.play_animation(AnimKind::LedgeGrab, false); state_context.play_animation(AnimKind::LedgeGrab, false);
state_context.knockback_velocity.0 = crate::math::vec::FGVec2::ZERO;
state_context.knockback_state.clear();
*state_context.vulnerability = FighterInvulnerabilityState::Intangible { *state_context.vulnerability = FighterInvulnerabilityState::Intangible {
frames_remaining: None, frames_remaining: None,
+1
View File
@@ -65,6 +65,7 @@ impl FighterStateImpl for LedgeWaitState {
fn on_enter(&mut self, state_context: &mut EnterContext) { fn on_enter(&mut self, state_context: &mut EnterContext) {
state_context.velocity.0 = FGVec2::ZERO; state_context.velocity.0 = FGVec2::ZERO;
state_context.knockback_velocity.0 = FGVec2::ZERO; state_context.knockback_velocity.0 = FGVec2::ZERO;
state_context.knockback_state.clear();
if state_context.ledge_info.grab_count < state_context.game_settings.fighter_common.max_ledge_grab_count { if state_context.ledge_info.grab_count < state_context.game_settings.fighter_common.max_ledge_grab_count {
*state_context.vulnerability = FighterInvulnerabilityState::Intangible { *state_context.vulnerability = FighterInvulnerabilityState::Intangible {
+28 -2
View File
@@ -7,8 +7,8 @@ use bevy::prelude::*;
use crate::fighter::animation::{AnimKind, FighterAnimationFrame}; use crate::fighter::animation::{AnimKind, FighterAnimationFrame};
use crate::fighter::attack::{ use crate::fighter::attack::{
FighterAttackScriptAssets, FighterKnockbackVelocity, FighterPendingHitReaction, FighterAttackScriptAssets, FighterKnockbackState, FighterKnockbackVelocity,
FighterStaleMoveQueue, HitStop, FighterPendingHitReaction, FighterStaleMoveQueue, HitStop,
}; };
use crate::fighter::attributes::FighterAttributes; use crate::fighter::attributes::FighterAttributes;
use crate::fighter::baked_animation::{BakedFighterAnimations, FighterBoneMatrices}; use crate::fighter::baked_animation::{BakedFighterAnimations, FighterBoneMatrices};
@@ -167,6 +167,7 @@ pub struct EnterContext<'a> {
pub velocity: &'a mut FighterVelocity, pub velocity: &'a mut FighterVelocity,
pub translation: &'a mut FighterTranslation, pub translation: &'a mut FighterTranslation,
pub knockback_velocity: &'a mut FighterKnockbackVelocity, pub knockback_velocity: &'a mut FighterKnockbackVelocity,
pub knockback_state: &'a mut FighterKnockbackState,
pub stale_move_queue: &'a mut FighterStaleMoveQueue, pub stale_move_queue: &'a mut FighterStaleMoveQueue,
pub facing_direction: &'a mut FighterFacingDirection, pub facing_direction: &'a mut FighterFacingDirection,
pub animation_frame: &'a mut FighterAnimationFrame, pub animation_frame: &'a mut FighterAnimationFrame,
@@ -204,6 +205,8 @@ impl EnterContext<'_> {
pub struct ExitContext<'a> { pub struct ExitContext<'a> {
pub input: &'a mut FighterInput, pub input: &'a mut FighterInput,
pub hitboxes: &'a mut FighterHitboxes, pub hitboxes: &'a mut FighterHitboxes,
pub knockback_velocity: &'a mut FighterKnockbackVelocity,
pub knockback_state: &'a mut FighterKnockbackState,
pub surface: &'a mut FighterSurfaceState, pub surface: &'a mut FighterSurfaceState,
pub vulnerability: &'a mut FighterInvulnerabilityState, pub vulnerability: &'a mut FighterInvulnerabilityState,
} }
@@ -216,6 +219,7 @@ pub struct UpdateContext<'a> {
pub velocity: &'a mut FighterVelocity, pub velocity: &'a mut FighterVelocity,
pub surface: &'a mut FighterSurfaceState, pub surface: &'a mut FighterSurfaceState,
pub knockback_velocity: &'a mut FighterKnockbackVelocity, pub knockback_velocity: &'a mut FighterKnockbackVelocity,
pub knockback_state: &'a mut FighterKnockbackState,
pub vulnerability: &'a mut FighterInvulnerabilityState, pub vulnerability: &'a mut FighterInvulnerabilityState,
pub ledge_info: &'a mut FighterLedgeInformation, pub ledge_info: &'a mut FighterLedgeInformation,
pub game_settings: &'a GameSettings, pub game_settings: &'a GameSettings,
@@ -251,6 +255,8 @@ pub struct CollisionContext<'a> {
pub prev_translation: &'a FighterPreviousTranslation, pub prev_translation: &'a FighterPreviousTranslation,
pub input: &'a FighterInput, pub input: &'a FighterInput,
pub velocity: &'a mut FighterVelocity, pub velocity: &'a mut FighterVelocity,
pub knockback_velocity: &'a mut FighterKnockbackVelocity,
pub knockback_state: &'a mut FighterKnockbackState,
pub surface: &'a mut FighterSurfaceState, pub surface: &'a mut FighterSurfaceState,
pub ecb: &'a mut FighterECB, pub ecb: &'a mut FighterECB,
pub desired_ecb: &'a mut FighterDesiredECB, pub desired_ecb: &'a mut FighterDesiredECB,
@@ -335,6 +341,7 @@ pub struct FighterFrameQuery {
pub velocity: &'static mut FighterVelocity, pub velocity: &'static mut FighterVelocity,
pub surface: &'static mut FighterSurfaceState, pub surface: &'static mut FighterSurfaceState,
pub knockback_velocity: &'static mut FighterKnockbackVelocity, pub knockback_velocity: &'static mut FighterKnockbackVelocity,
pub knockback_state: &'static mut FighterKnockbackState,
pub stale_move_queue: &'static mut FighterStaleMoveQueue, pub stale_move_queue: &'static mut FighterStaleMoveQueue,
pub ecb: &'static mut FighterECB, pub ecb: &'static mut FighterECB,
pub desired_ecb: &'static mut FighterDesiredECB, pub desired_ecb: &'static mut FighterDesiredECB,
@@ -403,6 +410,7 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> {
hitboxes: &mut self.hitboxes, hitboxes: &mut self.hitboxes,
velocity: &mut self.velocity, velocity: &mut self.velocity,
knockback_velocity: &mut self.knockback_velocity, knockback_velocity: &mut self.knockback_velocity,
knockback_state: &mut self.knockback_state,
translation: &mut self.translation, translation: &mut self.translation,
stale_move_queue: &mut self.stale_move_queue, stale_move_queue: &mut self.stale_move_queue,
facing_direction: &mut self.facing_direction, facing_direction: &mut self.facing_direction,
@@ -424,6 +432,8 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> {
ExitContext { ExitContext {
input: &mut self.input, input: &mut self.input,
hitboxes: &mut self.hitboxes, hitboxes: &mut self.hitboxes,
knockback_velocity: &mut self.knockback_velocity,
knockback_state: &mut self.knockback_state,
surface: &mut self.surface, surface: &mut self.surface,
vulnerability: &mut self.vulnerability, vulnerability: &mut self.vulnerability,
} }
@@ -453,6 +463,7 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> {
velocity: &mut self.velocity, velocity: &mut self.velocity,
surface: &mut self.surface, surface: &mut self.surface,
knockback_velocity: &mut self.knockback_velocity, knockback_velocity: &mut self.knockback_velocity,
knockback_state: &mut self.knockback_state,
game_settings, game_settings,
animation_root_motion_delta, animation_root_motion_delta,
animation_root_position, animation_root_position,
@@ -483,6 +494,8 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> {
presentation_snap: &mut self.presentation_snap, presentation_snap: &mut self.presentation_snap,
input: &self.input, input: &self.input,
velocity: &mut self.velocity, velocity: &mut self.velocity,
knockback_velocity: &mut self.knockback_velocity,
knockback_state: &mut self.knockback_state,
surface: &mut self.surface, surface: &mut self.surface,
ecb: &mut self.ecb, ecb: &mut self.ecb,
desired_ecb: &mut self.desired_ecb, desired_ecb: &mut self.desired_ecb,
@@ -655,9 +668,16 @@ pub fn state_interrupt_system(
} }
info!("Entering hitstop kind: {:?}", hitstop_kind); info!("Entering hitstop kind: {:?}", hitstop_kind);
let stack_previous_knockback = !frame.surface.is_grounded()
&& !frame.knockback_velocity.0.length_squared().is_zero()
&& frame
.knockback_state
.can_stack(game_settings.fighter_common.knockback_stacking_window);
Some(FighterState::Hitstun(HitstunState::new( Some(FighterState::Hitstun(HitstunState::new(
reaction, reaction,
hitstop_kind, hitstop_kind,
stack_previous_knockback,
))) )))
} else if hitstop.remaining_frames > 0 { } else if hitstop.remaining_frames > 0 {
None None
@@ -753,6 +773,8 @@ pub fn state_collision_interrupt_system(
// Clear hitstop // Clear hitstop
hitstop.remaining_frames = 0; hitstop.remaining_frames = 0;
frame.knockback_velocity.0 = FGVec2::ZERO;
frame.knockback_state.clear();
// subtract one stock // subtract one stock
@@ -846,6 +868,8 @@ mod tests {
let input = FighterInput::default(); let input = FighterInput::default();
let mut translation = FighterTranslation::default(); let mut translation = FighterTranslation::default();
let mut velocity = FighterVelocity::default(); let mut velocity = FighterVelocity::default();
let mut knockback_velocity = FighterKnockbackVelocity::default();
let mut knockback_state = FighterKnockbackState::default();
let mut surface = FighterSurfaceState::default(); let mut surface = FighterSurfaceState::default();
let mut ecb = FighterECB::default(); let mut ecb = FighterECB::default();
let mut desired_ecb = FighterDesiredECB { let mut desired_ecb = FighterDesiredECB {
@@ -865,6 +889,8 @@ mod tests {
prev_translation: &previous_translation, prev_translation: &previous_translation,
input: &input, input: &input,
velocity: &mut velocity, velocity: &mut velocity,
knockback_velocity: &mut knockback_velocity,
knockback_state: &mut knockback_state,
surface: &mut surface, surface: &mut surface,
ecb: &mut ecb, ecb: &mut ecb,
desired_ecb: &mut desired_ecb, desired_ecb: &mut desired_ecb,
+1
View File
@@ -18,6 +18,7 @@ pub struct FighterSettingsCommon {
/// Ground friction applied when the fighter is above the walk speed /// Ground friction applied when the fighter is above the walk speed
pub ground_friction_over_walk_speed_multiplier: FGi32, pub ground_friction_over_walk_speed_multiplier: FGi32,
/// Minimum active launch frames between hits before airborne knockback stacks.
pub knockback_stacking_window: u32, pub knockback_stacking_window: u32,
pub knockback_decay_per_frame: FGi32, pub knockback_decay_per_frame: FGi32,