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
+116
View File
@@ -33,6 +33,75 @@ use crate::{
#[derive(Deref, Component, Default, Hash, Debug, Copy, Clone)]
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)]
pub struct FighterDamage(FGi32);
@@ -73,6 +142,53 @@ pub struct FighterLastAttackDebug {
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)]
pub struct HitStop(u32);