Implement Melee-style knockback stacking (#97)
## 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:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user