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
+370 -114
View File
@@ -1,37 +1,85 @@
use std::collections::{HashMap, VecDeque};
use bevy::prelude::*;
use bevy_ggrs::prelude::GgrsSchedule;
use bevy_ggrs::{Rollback, prelude::GgrsSchedule};
use fixed_math::SinCos;
use serde::{Deserialize, Serialize};
use crate::{
fighter::{
Fighter, FighterAttributes, FighterFacingDirection, FighterHitboxes,
Fighter, FighterAttributes, FighterFacingDirection, FighterHitboxes, FighterHitstop,
FighterPreviousFacingDirection, FighterPreviousTranslation, FighterRoster,
FighterTranslation,
animation::{AnimKind, FighterAnimationFrame},
baked_animation::{FighterBoneIndexMap, FighterBoneMatrices, FixedMat4},
errors::FighterErrors,
hurtbox::{FixedAffineCapsule, FixedCapsule},
manifest::FighterManifest,
},
game_settings::GameSettings,
math::{
int::{FGi32, FGi32Ext},
int::{FGWide, FGi32, FGi32Ext},
vec::FGVec2,
vec3::FGVec3,
},
player::Player,
schedule::GameplaySet,
scripting::FighterAttackScript,
};
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
#[derive(Deref, Component, Default, Hash, Debug, Copy, Clone)]
pub struct FighterKnockbackVelocity(pub FGVec2);
#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, Component, Hash)]
pub struct FighterDamage(FGi32);
#[derive(Debug, Hash, Clone)]
pub enum HitReactionKind {
Flinch,
Tumble,
}
#[derive(Debug, Hash, Clone)]
pub struct FighterHitReaction {
pub launch_velocity: FGVec2,
pub hitstop_frames: HitStop,
pub hitstun_frames: u32,
pub kind: HitReactionKind,
}
#[derive(Component, DerefMut, Deref, Debug, Default, Hash, Clone)]
pub struct FighterPendingHitReaction(pub Option<FighterHitReaction>);
impl FighterDamage {
pub fn lit(text: &str) -> Self {
Self(FGi32::lit(text))
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Component)]
pub struct FighterLastAttackDebug {
pub trajectory_points: Vec<Vec3>,
}
#[derive(Debug, Default, Clone, Deref, DerefMut, Hash)]
pub struct HitStop(u32);
impl HitStop {
pub fn calculate(attack_damage_stalled: FighterDamage, crouch_cancelling: bool) -> Self {
let a = (attack_damage_stalled.0 / FGi32::lit("3.0") + FGi32::lit("4.0")).round_to_zero();
let crouch_cancelling_factor = if crouch_cancelling {
FGi32::lit("0.66666666")
} else {
FGi32::ONE
};
let frames: u32 = ((a * crouch_cancelling_factor).round_to_zero()).to_num();
Self(frames)
}
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
pub enum KnockbackType {
Normal,
Fixed,
@@ -54,6 +102,22 @@ impl Knockback {
pub fn lit(val: &str) -> Self {
Self(FGi32::lit(val))
}
pub fn get_launch_speed(&self, game_settings: &GameSettings) -> FGi32 {
self.0
* game_settings
.fighter_common
.knockback_launch_velocity_multiplier
}
pub fn get_hitstun_frames(&self, game_settings: &GameSettings) -> u32 {
(self.0
* game_settings
.fighter_common
.hitstun_frames_per_unit_knockback)
.floor()
.to_num()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -78,8 +142,9 @@ impl AttackAngle {
}
}
#[derive(Serialize, Deserialize, Reflect, PartialEq, Eq, Hash, Clone, Debug)]
#[derive(Serialize, Deserialize, Reflect, PartialEq, Eq, Hash, Clone, Debug, Default, Copy)]
pub enum AttackKind {
#[default]
NeutralAir,
ForwardAir,
BackAir,
@@ -129,7 +194,51 @@ pub struct AttackHitbox {
pub end_frame: u32,
}
pub struct StaleMoveQueue(pub VecDeque<AttackKind>);
const STALE_MOVE_QUEUE_COUNT: usize = 9;
#[derive(Component, Hash, Clone, Default)]
#[require(Rollback)]
pub struct FighterStaleMoveQueue(VecDeque<AttackKind>);
const STALENESS_REDUCTIONS: [FGi32; 9] = [
FGi32::lit("0.09"),
FGi32::lit("0.08"),
FGi32::lit("0.07"),
FGi32::lit("0.06"),
FGi32::lit("0.05"),
FGi32::lit("0.04"),
FGi32::lit("0.03"),
FGi32::lit("0.02"),
FGi32::lit("0.01"),
];
impl FighterStaleMoveQueue {
pub fn push(&mut self, attack: AttackKind) {
if self.0.len() == STALE_MOVE_QUEUE_COUNT {
self.0.pop_front();
}
self.0.push_back(attack);
}
pub fn get_attack_damage_multiplier(&self, attack: AttackKind) -> FGi32 {
FGi32::ONE
- self
.0
.iter()
.rev()
.enumerate()
.map(|(idx, kind)| {
if *kind == attack {
STALENESS_REDUCTIONS[idx]
} else {
FGi32::ZERO
}
})
.sum::<FGi32>()
.min(FGi32::ONE)
}
}
impl AttackHitbox {
pub fn validate(&self) -> Result<(), String> {
@@ -157,8 +266,6 @@ impl AttackHitbox {
/// Knockback calculation
pub fn calculate_knockback(
&self,
_attack: AttackKind,
receiver_attribs: &FighterAttributes,
receiver_damage: FighterDamage,
total_damage_received_this_frame: FighterDamage,
@@ -195,16 +302,23 @@ impl AttackHitbox {
out
};
let s = KB_GROWTH_SCALE * self.knockback_growth;
let p = receiver_damage.0.floor() + total_damage_received_this_frame.0;
let d = attack_damage_unstalled;
let damage_term = p / DAMAGE_TERM_DIVISOR * (d + DAMAGE_BONUS);
let kb = match self.knockback_type {
KnockbackType::Fixed => FGWide::from_num(self.knockback.0),
KnockbackType::Normal => {
let p = FGWide::from_num(receiver_damage.0.floor())
+ FGWide::from_num(total_damage_received_this_frame.0);
let d = FGWide::from_num(attack_damage_unstalled);
let s = FGWide::from_num(KB_GROWTH_SCALE * self.knockback_growth);
let damage_term = p / FGWide::from_num(DAMAGE_TERM_DIVISOR)
* (d + FGWide::from_num(DAMAGE_BONUS));
let scaled = damage_term * FGWide::from_num(FGi32::lit("2.0") * BASELINE_WEIGHT)
/ FGWide::from_num(receiver_attribs.weight + BASELINE_WEIGHT);
(scaled * FGWide::from_num(KB_GAIN) + FGWide::from_num(KB_OFFSET)) * s
+ FGWide::from_num(self.knockback.0)
}
};
let scaled = damage_term * (FGi32::lit("2.0") * BASELINE_WEIGHT)
/ (receiver_attribs.weight + BASELINE_WEIGHT);
let kb = (scaled * KB_GAIN + KB_OFFSET) * s + self.knockback.0;
Knockback(modifier * kb)
Knockback((FGWide::from_num(modifier) * kb).saturating_to_num())
}
}
@@ -232,9 +346,6 @@ pub struct FighterSolvedHitbox {
pub capsule: FixedCapsule,
}
#[derive(Component, Clone, Debug, Default, Hash)]
pub struct FighterSolvedHitboxes(pub Vec<FighterSolvedHitbox>);
pub fn solve_hitboxes(
mut query: Query<(
&FighterBoneMatrices,
@@ -259,62 +370,57 @@ pub fn solve_hitboxes(
animation_frame,
) in &mut query
{
let FighterHitboxes {
attack_script: script_handle,
active_hitboxes,
active_hitboxes_solved,
..
} = &mut *hitboxes;
active_hitboxes_solved.clear();
if let Some(attack_script) = script_handle {
let attack_script = attack_scripts
.get(attack_script)
.expect("Attack script should be valid");
let fighter_trf = translation.get_3d_transform(facing_direction);
let previous_fighter_trf = FighterTranslation(previous_translation.0)
.get_3d_transform(&previous_facing_direction.0);
for hitbox_idx in active_hitboxes.iter().copied() {
let hitbox = attack_script
.hitboxes
.get(hitbox_idx)
.expect("Hitbox IDX should be valid");
let matrices = bone_indices
.0
.get(&hitbox.bone)
.and_then(|index| matrices.get_index(*index));
let Some(active_attack) = hitboxes.0.as_mut() else {
continue;
};
active_attack.solved_hitboxes.clear();
let attack_script = attack_scripts
.get(&active_attack.script)
.expect("Attack script should be valid");
let fighter_trf = translation.get_3d_transform(facing_direction);
let previous_fighter_trf = FighterTranslation(previous_translation.0)
.get_3d_transform(&previous_facing_direction.0);
for hitbox_idx in active_attack.active_hitboxes.iter().copied() {
let hitbox = attack_script
.hitboxes
.get(hitbox_idx)
.expect("Hitbox IDX should be valid");
let matrices = bone_indices
.0
.get(&hitbox.bone)
.and_then(|index| matrices.get_index(*index));
if matrices.is_none() {
warn!("Bone {} not found!", hitbox.bone);
continue;
}
if matrices.is_none() {
warn!("Bone {} not found!", hitbox.bone);
continue;
}
let (prev_bone_matrix, curr_bone_matrix) = matrices.unwrap();
let (prev_bone_matrix, curr_bone_matrix) = matrices.unwrap();
let curr_hbox_matrix = fighter_trf.mul(curr_bone_matrix);
let curr_hbox_pos = curr_hbox_matrix.transform_point(hitbox.offset);
let curr_hbox_matrix = fighter_trf.mul(curr_bone_matrix);
let curr_hbox_pos = curr_hbox_matrix.transform_point(hitbox.offset);
if hitbox.start_frame == animation_frame.frame {
// Sphere only
active_hitboxes_solved.push(FighterSolvedHitbox {
hitbox_idx,
capsule: FixedCapsule {
start: curr_hbox_pos,
end: curr_hbox_pos,
radius: hitbox.radius,
},
})
} else {
let prev_hbox_matrix = previous_fighter_trf.mul(prev_bone_matrix);
let prev_hbox_pos = prev_hbox_matrix.transform_point(hitbox.offset);
active_hitboxes_solved.push(FighterSolvedHitbox {
hitbox_idx,
capsule: FixedCapsule {
start: prev_hbox_pos,
end: curr_hbox_pos,
radius: hitbox.radius,
},
});
}
if hitbox.start_frame == animation_frame.frame {
// Sphere only
active_attack.solved_hitboxes.push(FighterSolvedHitbox {
hitbox_idx,
capsule: FixedCapsule {
start: curr_hbox_pos,
end: curr_hbox_pos,
radius: hitbox.radius,
},
})
} else {
let prev_hbox_matrix = previous_fighter_trf.mul(prev_bone_matrix);
let prev_hbox_pos = prev_hbox_matrix.transform_point(hitbox.offset);
active_attack.solved_hitboxes.push(FighterSolvedHitbox {
hitbox_idx,
capsule: FixedCapsule {
start: prev_hbox_pos,
end: curr_hbox_pos,
radius: hitbox.radius,
},
});
}
}
}
@@ -326,6 +432,16 @@ pub struct FighterAttackScriptAssets {
#[derive(Component, Clone, Debug, Default, Hash)]
pub struct FighterSolvedHurtboxes(pub Vec<FighterSolvedHurtbox>);
#[derive(Component, Clone, Debug, Default, Hash, DerefMut, Deref)]
pub struct FighterReceivedAttacks(
pub Vec<(
/* attacker */ Entity,
Handle<FighterAttackScript>,
AttackKind,
/* hitbox_idx */ usize,
)>,
);
pub fn solve_hurtboxes(
mut query: Query<(
&FighterBoneMatrices,
@@ -375,63 +491,202 @@ pub fn solve_hurtboxes(
pub fn intersect_attacks_with_hurtboxes(
roster: Res<FighterRoster>,
mut query_attacker: Query<(Entity, &mut FighterHitboxes, &Fighter, &Player)>,
query_attacked: Query<(Entity, &FighterSolvedHurtboxes, &Player)>,
mut query_attacker: Query<(
Entity,
&mut FighterHitboxes,
&Fighter,
&Player,
&FighterStaleMoveQueue,
)>,
mut query_attacked: Query<(
Entity,
&FighterSolvedHurtboxes,
&Player,
&Fighter,
&mut FighterReceivedAttacks,
)>,
attack_scripts: Res<Assets<FighterAttackScript>>,
) {
for &entity_hurting in &roster.0 {
let Ok((_, mut hitboxes, _, _)) = query_attacker.get_mut(entity_hurting) else {
for &entity_receiving in &roster.0 {
let Ok((_, hurtboxes, _, _receiving_fighter, mut received_attacks)) =
query_attacked.get_mut(entity_receiving)
else {
continue;
};
if hitboxes.attack_script.is_none() {
continue;
}
if let Some(ref attack_script) = hitboxes.attack_script {
let attack_script = attack_scripts.get(attack_script);
if attack_script.is_none() {
for &entity_hurting in &roster.0 {
if entity_hurting == entity_receiving {
continue;
}
let Ok((_, mut hitboxes, _, _, _)) = query_attacker.get_mut(entity_hurting) else {
continue;
};
let Some(active_attack) = hitboxes.0.as_mut() else {
continue;
};
if active_attack.hit_fighters.contains(&entity_receiving) {
continue;
}
let attack_script = attack_script.unwrap();
let attack_script = attack_scripts
.get(&active_attack.script)
.expect("Attack script should be valid");
for &entity_receiving in &roster.0 {
if entity_hurting == entity_receiving {
continue;
}
if hitboxes.hit_fighters.contains(&entity_receiving) {
continue;
}
let Ok((_, hurtboxes, _)) = query_attacked.get(entity_receiving) else {
continue;
};
let mut hitting_hitbox_idx = None;
for hurtbox in hurtboxes.0.iter() {
for solved_hitbox in hitboxes.active_hitboxes_solved.iter() {
if hurtbox.capsule.intersects(solved_hitbox.capsule) {
let candidate = solved_hitbox.hitbox_idx;
if hitting_hitbox_idx.is_none_or(|current: usize| {
attack_script.hitboxes[candidate].id
< attack_script.hitboxes[current].id
}) {
hitting_hitbox_idx = Some(candidate);
}
let mut hitting_hitbox_idx = None;
for hurtbox in hurtboxes.0.iter() {
for solved_hitbox in active_attack.solved_hitboxes.iter() {
if hurtbox.capsule.intersects(solved_hitbox.capsule) {
let candidate = solved_hitbox.hitbox_idx;
if hitting_hitbox_idx.is_none_or(|current: usize| {
attack_script.hitboxes[candidate].id
< attack_script.hitboxes[current].id
}) {
hitting_hitbox_idx = Some(candidate);
}
}
}
let Some(hitting_hitbox_idx) = hitting_hitbox_idx else {
continue;
};
hitboxes.hit_fighters.push(entity_receiving);
info!("Hit by hitbox {}!!!!", hitting_hitbox_idx);
}
let Some(hitting_hitbox_idx) = hitting_hitbox_idx else {
continue;
};
received_attacks.push((
entity_hurting,
active_attack.script.clone(),
active_attack.kind,
hitting_hitbox_idx,
));
info!("Hit by hitbox {}!!!!", hitting_hitbox_idx);
active_attack.hit_fighters.push(entity_receiving);
}
}
}
pub fn calculate_received_attacks_trajectories(
mut attacker: Query<(
&mut FighterStaleMoveQueue,
&FighterFacingDirection,
&FighterHitboxes,
&Player,
&mut FighterHitstop,
)>,
receiver: Query<(
&mut FighterReceivedAttacks,
&mut FighterDamage,
&Fighter,
&mut FighterPendingHitReaction,
)>,
attack_scripts: Res<Assets<FighterAttackScript>>,
game_settings: Res<GameSettings>,
fighter_manifests: Res<Assets<FighterManifest>>,
) -> Result {
for (mut received_attacks, mut receiver_damage, fighter, mut pending_reactions) in receiver {
if received_attacks.is_empty() {
continue;
}
// we got attacked, time to calculate the total damage
let damage_data = received_attacks
.iter()
.filter_map(|(from_entity, attack_script, attack_kind, hitbox_idx)| {
let Ok((stale_move_queue, facing_dir, _hitboxes, player, _attacker_hitstop)) =
attacker.get(*from_entity)
else {
return None;
};
let Some(attack_script) = attack_scripts.get(attack_script) else {
return None;
};
let hitbox = &attack_script.hitboxes[*hitbox_idx];
let damage_received =
stale_move_queue.get_attack_damage_multiplier(*attack_kind) * hitbox.damage.0;
Some((
from_entity,
damage_received,
hitbox,
attack_script,
matches!(facing_dir, FighterFacingDirection::Left),
player.handle,
))
})
.collect::<Vec<_>>();
let total_damage_received_this_frame = damage_data
.iter()
.map(|(_, damage, ..)| damage)
.sum::<FGi32>();
let fighter_manifest = fighter_manifests
.get(&fighter.manifest)
.ok_or(FighterErrors::FighterManifestNotFound)?;
let mut reactions = vec![];
for (attacker_entity, attack_damage, hitbox, _attack_script, facing_left, attacker_slot) in
damage_data
{
let knockback = hitbox.calculate_knockback(
&fighter_manifest.attributes,
*receiver_damage,
FighterDamage(total_damage_received_this_frame),
false,
false,
);
let angle = match &hitbox.angle {
AttackAngle::Normal(angle) => *angle,
AttackAngle::Sakurai => FGi32::lit("32.0"),
};
let launch_angle = if facing_left {
FGi32::lit("180.0") - angle
} else {
angle
};
let (sin, cos) = launch_angle.sin_cos();
let launch_direction = FGVec2::new(cos, sin);
let hitstop = HitStop::calculate(FighterDamage(attack_damage), false);
reactions.push((
*attacker_entity,
attacker_slot,
FighterHitReaction {
launch_velocity: launch_direction * knockback.get_launch_speed(&game_settings),
hitstun_frames: knockback.get_hitstun_frames(&game_settings),
hitstop_frames: hitstop,
kind: HitReactionKind::Flinch,
},
));
}
receiver_damage.0 += total_damage_received_this_frame;
for (attacker_entity, _, reaction) in reactions.iter() {
let Ok((.., mut hitstop)) = attacker.get_mut(*attacker_entity) else {
continue;
};
hitstop.remaining_frames = reaction.hitstop_frames.max(hitstop.remaining_frames);
}
// Pick attack by port priority
pending_reactions.0 = reactions
.into_iter()
.min_by(|(_, port_a, _), (_, port_b, _)| port_a.cmp(port_b))
.map(|(_, _, reaction)| reaction);
received_attacks.clear();
}
Ok(())
}
pub struct FighterAttackPlugin;
impl Plugin for FighterAttackPlugin {
@@ -442,10 +697,11 @@ impl Plugin for FighterAttackPlugin {
solve_hurtboxes,
solve_hitboxes,
intersect_attacks_with_hurtboxes,
calculate_received_attacks_trajectories,
)
.chain()
.after(crate::fighter::baked_animation::update_fighter_bone_matrices)
.in_set(GameplaySet::Animation),
.in_set(GameplaySet::Attacks),
);
}
}