diff --git a/src/debug_tools/save_state.rs b/src/debug_tools/save_state.rs index 65eb93e..f3e9ca4 100644 --- a/src/debug_tools/save_state.rs +++ b/src/debug_tools/save_state.rs @@ -9,8 +9,9 @@ use crate::{ FighterTranslation, FighterVelocity, animation::FighterAnimationFrame, attack::{ - FighterDamage, FighterKnockbackVelocity, FighterPendingHitReaction, FighterPendingHits, - FighterSolvedHurtboxes, FighterStaleMoveQueue, + FighterDamage, FighterKnockbackState, FighterKnockbackVelocity, + FighterPendingHitReaction, FighterPendingHits, FighterSolvedHurtboxes, + FighterStaleMoveQueue, }, baked_animation::FighterBoneMatrices, ecb::FighterDesiredECB, @@ -55,6 +56,7 @@ struct FighterCheckpoint { hitstop: FighterHitstop, hitstop_shake: FighterHitstopShake, knockback_velocity: FighterKnockbackVelocity, + knockback_state: FighterKnockbackState, stale_moves: FighterStaleMoveQueue, hitboxes: FighterHitboxes, solved_hurtboxes: FighterSolvedHurtboxes, @@ -160,6 +162,7 @@ pub(super) fn save_or_restore(world: &mut World) { entity.insert(saved.hitstop); entity.insert(saved.hitstop_shake); entity.insert(saved.knockback_velocity); + entity.insert(saved.knockback_state); entity.insert(saved.stale_moves.clone()); entity.insert(saved.hitboxes.clone()); entity.insert(saved.solved_hurtboxes.clone()); @@ -199,6 +202,7 @@ fn capture_fighter(world: &World, entity: Entity) -> Option { hitstop: *fighter.get::()?, hitstop_shake: *fighter.get::()?, knockback_velocity: *fighter.get::()?, + knockback_state: *fighter.get::()?, stale_moves: fighter.get::()?.clone(), hitboxes: fighter.get::()?.clone(), solved_hurtboxes: fighter.get::()?.clone(), diff --git a/src/fighter/attack/directional_influence.rs b/src/fighter/attack/directional_influence.rs index cd62c50..421482e 100644 --- a/src/fighter/attack/directional_influence.rs +++ b/src/fighter/attack/directional_influence.rs @@ -15,6 +15,16 @@ pub fn apply_directional_influence( let (sin, cos) = attack_angle.sin_cos(); 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 { return FGVec2::ZERO; }; @@ -30,14 +40,20 @@ pub fn apply_directional_influence( -di_max_angle.to_fixed::(), di_max_angle.to_fixed::(), ); - let (sin, cos) = (attack_angle + angle_change.to_fixed::()).sin_cos(); - FGVec2::new(cos, sin).normalize_or_zero() + let (sin, cos) = angle_change.to_fixed::().sin_cos(); + 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)] mod test { 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}, }; @@ -66,4 +82,17 @@ mod test { assert!(di.x > 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); + } } diff --git a/src/fighter/attack/mod.rs b/src/fighter/attack/mod.rs index ae18791..ab92f36 100644 --- a/src/fighter/attack/mod.rs +++ b/src/fighter/attack/mod.rs @@ -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, } +#[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); diff --git a/src/fighter/mod.rs b/src/fighter/mod.rs index 84f4566..9199171 100644 --- a/src/fighter/mod.rs +++ b/src/fighter/mod.rs @@ -41,8 +41,8 @@ use crate::{ fighter::{ attack::{ AttackKind, FighterAttackPlugin, FighterAttackScriptAssets, FighterDamage, - FighterKnockbackVelocity, FighterPendingHitReaction, FighterSolvedHurtboxes, - FighterStaleMoveQueue, + FighterKnockbackState, FighterKnockbackVelocity, FighterPendingHitReaction, + FighterSolvedHurtboxes, FighterStaleMoveQueue, }, baked_animation::FighterBoneIndexMap, ecb::{FighterDesiredECB, FighterECBBoneIndices}, @@ -212,6 +212,7 @@ pub struct FighterPendingRespawn(bool); FighterHitstop, FighterLedgeInformation, FighterKnockbackVelocity, + FighterKnockbackState, FighterTranslation, FighterInvulnerabilityState, FighterInvulnerabilityVisualState @@ -330,6 +331,8 @@ impl Plugin for FighterPlugin { .checksum_component_with_hash::() .rollback_component_with_copy::() .checksum_component_with_hash::() + .rollback_component_with_copy::() + .checksum_component_with_hash::() .rollback_component_with_clone::() .checksum_component_with_hash::() // Debug views. diff --git a/src/fighter/state/air.rs b/src/fighter/state/air.rs index 6caf4b5..d9b895f 100644 --- a/src/fighter/state/air.rs +++ b/src/fighter/state/air.rs @@ -93,6 +93,19 @@ pub fn apply_air_motion( 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( ctx: &mut crate::fighter::state::UpdateContext, airborne_common: &mut AirborneStateCommon, @@ -109,7 +122,7 @@ pub fn update_standard_motion( airborne_common.is_fast_falling, ); 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( @@ -141,6 +154,8 @@ pub fn collision_interrupt( *ctx.desired_ecb = crate::fighter::ecb::FighterDesiredECB::from(*ctx.ecb); ctx.presentation_snap.0 = true; 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))) } diff --git a/src/fighter/state/air_dodge.rs b/src/fighter/state/air_dodge.rs index 2c9f71d..742e8ce 100644 --- a/src/fighter/state/air_dodge.rs +++ b/src/fighter/state/air_dodge.rs @@ -33,11 +33,7 @@ impl FighterStateImpl for AirDodgeState { } state_context.velocity.0 *= state_context.fighter_attributes.air_dodge_decay; - air::apply_air_motion( - state_context.velocity, - state_context.translation, - state_context.prev_translation, - ); + air::apply_air_motion_with_knockback(state_context); } 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.rebase_onto_bottom(); 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))); } } diff --git a/src/fighter/state/ground.rs b/src/fighter/state/ground.rs index 9b124d6..86d0f15 100644 --- a/src/fighter/state/ground.rs +++ b/src/fighter/state/ground.rs @@ -260,6 +260,7 @@ pub fn collision_interrupt(ctx: &mut CollisionContext, can_walk_off: bool) -> Op #[cfg(test)] mod tests { use super::*; + use crate::fighter::attack::{FighterKnockbackState, FighterKnockbackVelocity}; use crate::fighter::ecb::{FighterDesiredECB, FighterPreviousECB}; use crate::fighter::{ FighterECB, FighterLedgeInformation, FighterPreviousTranslation, FighterSurfaceState, @@ -388,6 +389,8 @@ mod tests { let mut translation = FighterTranslation(FGVec2::lit("11", "0")); let previous_translation = FighterPreviousTranslation(translation.0); 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 ecb = FighterECB { bottom: FGVec2::lit("0.0", "-0.5"), @@ -403,6 +406,8 @@ mod tests { prev_translation: &previous_translation, input: &FighterInput::default(), velocity: &mut velocity, + knockback_velocity: &mut knockback_velocity, + knockback_state: &mut knockback_state, surface: &mut surface, ecb: &mut ecb, desired_ecb: &mut desired_ecb, @@ -453,6 +458,8 @@ mod tests { let mut translation = FighterTranslation(FGVec2::lit("11", "0")); let previous_translation = FighterPreviousTranslation(translation.0); let mut velocity = FighterVelocity::default(); + let mut knockback_velocity = FighterKnockbackVelocity::default(); + let mut knockback_state = FighterKnockbackState::default(); let mut ecb = FighterECB { bottom: FGVec2::lit("0.0", "-0.5"), top: FGVec2::lit("0.0", "0.5"), @@ -468,6 +475,8 @@ mod tests { prev_translation: &previous_translation, input: &FighterInput::default(), velocity: &mut velocity, + knockback_velocity: &mut knockback_velocity, + knockback_state: &mut knockback_state, surface: &mut surface, ecb: &mut ecb, desired_ecb: &mut desired_ecb, diff --git a/src/fighter/state/hitstun.rs b/src/fighter/state/hitstun.rs index c97d741..74f2658 100644 --- a/src/fighter/state/hitstun.rs +++ b/src/fighter/state/hitstun.rs @@ -1,6 +1,6 @@ use crate::{ fighter::{ - FighterVelocity, animation::AnimKind, attack::{self, FighterHitReaction}, state::{ + animation::AnimKind, attack::{self, FighterHitReaction}, state::{ CollisionContext, EnterContext, ExitContext, FighterState, FighterStateImpl, InterruptContext, UpdateContext, air::{self, AirborneStateCommon}, @@ -9,7 +9,7 @@ use crate::{ land::LandingType, wait::WaitState, }, - }, math::{int::FGi32, vec::FGVec2}, + }, math::vec::FGVec2, }; #[derive(Clone, Debug, Hash)] @@ -23,14 +23,20 @@ pub struct HitstunState { pub hit_reaction: FighterHitReaction, pub hitstop_kind: HitstopStateKind, knockback_applied: bool, + stack_previous_knockback: bool, } 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 { hit_reaction, hitstop_kind, knockback_applied: false, + stack_previous_knockback, } } } @@ -60,7 +66,12 @@ impl FighterStateImpl for HitstunState { 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 { let hitstop_kind = match self.hitstop_kind { @@ -78,13 +89,27 @@ impl FighterStateImpl for HitstunState { fn update(&mut self, ctx: &mut UpdateContext) { if !self.knockback_applied { - let launch_direction = attack::directional_influence::apply_directional_influence( - ctx.input.get_last_frame().movement, + let new_launch_direction = attack::directional_influence::apply_directional_influence( + FGVec2::ZERO, self.hit_reaction.launch_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; } @@ -92,17 +117,17 @@ impl FighterStateImpl for HitstunState { HitstopStateKind::Grounded => {} HitstopStateKind::Airborne => { 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(&vel_with_kb, ctx.translation, ctx.prev_translation); + air::apply_air_motion_with_knockback(ctx); } } - 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; + if matches!(self.hitstop_kind, HitstopStateKind::Grounded) { + attack::decay_knockback( + ctx.knockback_velocity, + ctx.knockback_state, + ctx.game_settings.fighter_common.knockback_decay_per_frame, + ); + } } fn check_collision_interrupt( diff --git a/src/fighter/state/ledge_action.rs b/src/fighter/state/ledge_action.rs index 4c2dca5..63c1c9f 100644 --- a/src/fighter/state/ledge_action.rs +++ b/src/fighter/state/ledge_action.rs @@ -84,6 +84,7 @@ impl FighterStateImpl for LedgeActionState { state_context.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 { return; }; diff --git a/src/fighter/state/ledge_grab.rs b/src/fighter/state/ledge_grab.rs index 133bbe8..1d3a9ef 100644 --- a/src/fighter/state/ledge_grab.rs +++ b/src/fighter/state/ledge_grab.rs @@ -36,6 +36,8 @@ impl FighterStateImpl for LedgeGrabState { fn on_enter(&mut self, state_context: &mut EnterContext) { 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 { frames_remaining: None, diff --git a/src/fighter/state/ledge_wait.rs b/src/fighter/state/ledge_wait.rs index c401dfe..7ec5443 100644 --- a/src/fighter/state/ledge_wait.rs +++ b/src/fighter/state/ledge_wait.rs @@ -65,6 +65,7 @@ impl FighterStateImpl for LedgeWaitState { fn on_enter(&mut self, state_context: &mut EnterContext) { state_context.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 { *state_context.vulnerability = FighterInvulnerabilityState::Intangible { diff --git a/src/fighter/state/mod.rs b/src/fighter/state/mod.rs index 3be0e3b..c44b652 100644 --- a/src/fighter/state/mod.rs +++ b/src/fighter/state/mod.rs @@ -7,8 +7,8 @@ use bevy::prelude::*; use crate::fighter::animation::{AnimKind, FighterAnimationFrame}; use crate::fighter::attack::{ - FighterAttackScriptAssets, FighterKnockbackVelocity, FighterPendingHitReaction, - FighterStaleMoveQueue, HitStop, + FighterAttackScriptAssets, FighterKnockbackState, FighterKnockbackVelocity, + FighterPendingHitReaction, FighterStaleMoveQueue, HitStop, }; use crate::fighter::attributes::FighterAttributes; use crate::fighter::baked_animation::{BakedFighterAnimations, FighterBoneMatrices}; @@ -167,6 +167,7 @@ pub struct EnterContext<'a> { pub velocity: &'a mut FighterVelocity, pub translation: &'a mut FighterTranslation, pub knockback_velocity: &'a mut FighterKnockbackVelocity, + pub knockback_state: &'a mut FighterKnockbackState, pub stale_move_queue: &'a mut FighterStaleMoveQueue, pub facing_direction: &'a mut FighterFacingDirection, pub animation_frame: &'a mut FighterAnimationFrame, @@ -204,6 +205,8 @@ impl EnterContext<'_> { pub struct ExitContext<'a> { pub input: &'a mut FighterInput, pub hitboxes: &'a mut FighterHitboxes, + pub knockback_velocity: &'a mut FighterKnockbackVelocity, + pub knockback_state: &'a mut FighterKnockbackState, pub surface: &'a mut FighterSurfaceState, pub vulnerability: &'a mut FighterInvulnerabilityState, } @@ -216,6 +219,7 @@ pub struct UpdateContext<'a> { pub velocity: &'a mut FighterVelocity, pub surface: &'a mut FighterSurfaceState, pub knockback_velocity: &'a mut FighterKnockbackVelocity, + pub knockback_state: &'a mut FighterKnockbackState, pub vulnerability: &'a mut FighterInvulnerabilityState, pub ledge_info: &'a mut FighterLedgeInformation, pub game_settings: &'a GameSettings, @@ -251,6 +255,8 @@ pub struct CollisionContext<'a> { pub prev_translation: &'a FighterPreviousTranslation, pub input: &'a FighterInput, pub velocity: &'a mut FighterVelocity, + pub knockback_velocity: &'a mut FighterKnockbackVelocity, + pub knockback_state: &'a mut FighterKnockbackState, pub surface: &'a mut FighterSurfaceState, pub ecb: &'a mut FighterECB, pub desired_ecb: &'a mut FighterDesiredECB, @@ -335,6 +341,7 @@ pub struct FighterFrameQuery { pub velocity: &'static mut FighterVelocity, pub surface: &'static mut FighterSurfaceState, pub knockback_velocity: &'static mut FighterKnockbackVelocity, + pub knockback_state: &'static mut FighterKnockbackState, pub stale_move_queue: &'static mut FighterStaleMoveQueue, pub ecb: &'static mut FighterECB, pub desired_ecb: &'static mut FighterDesiredECB, @@ -403,6 +410,7 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> { hitboxes: &mut self.hitboxes, velocity: &mut self.velocity, knockback_velocity: &mut self.knockback_velocity, + knockback_state: &mut self.knockback_state, translation: &mut self.translation, stale_move_queue: &mut self.stale_move_queue, facing_direction: &mut self.facing_direction, @@ -424,6 +432,8 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> { ExitContext { input: &mut self.input, hitboxes: &mut self.hitboxes, + knockback_velocity: &mut self.knockback_velocity, + knockback_state: &mut self.knockback_state, surface: &mut self.surface, vulnerability: &mut self.vulnerability, } @@ -453,6 +463,7 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> { velocity: &mut self.velocity, surface: &mut self.surface, knockback_velocity: &mut self.knockback_velocity, + knockback_state: &mut self.knockback_state, game_settings, animation_root_motion_delta, animation_root_position, @@ -483,6 +494,8 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> { presentation_snap: &mut self.presentation_snap, input: &self.input, velocity: &mut self.velocity, + knockback_velocity: &mut self.knockback_velocity, + knockback_state: &mut self.knockback_state, surface: &mut self.surface, ecb: &mut self.ecb, desired_ecb: &mut self.desired_ecb, @@ -655,9 +668,16 @@ pub fn state_interrupt_system( } 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( reaction, hitstop_kind, + stack_previous_knockback, ))) } else if hitstop.remaining_frames > 0 { None @@ -753,6 +773,8 @@ pub fn state_collision_interrupt_system( // Clear hitstop hitstop.remaining_frames = 0; + frame.knockback_velocity.0 = FGVec2::ZERO; + frame.knockback_state.clear(); // subtract one stock @@ -846,6 +868,8 @@ mod tests { let input = FighterInput::default(); let mut translation = FighterTranslation::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 ecb = FighterECB::default(); let mut desired_ecb = FighterDesiredECB { @@ -865,6 +889,8 @@ mod tests { prev_translation: &previous_translation, input: &input, velocity: &mut velocity, + knockback_velocity: &mut knockback_velocity, + knockback_state: &mut knockback_state, surface: &mut surface, ecb: &mut ecb, desired_ecb: &mut desired_ecb, diff --git a/src/game_settings.rs b/src/game_settings.rs index 09c922d..0998c23 100644 --- a/src/game_settings.rs +++ b/src/game_settings.rs @@ -18,6 +18,7 @@ pub struct FighterSettingsCommon { /// Ground friction applied when the fighter is above the walk speed 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_decay_per_frame: FGi32,