Make combat reaction state rollback-safe (#34)
CI / build-and-test (push) Successful in 13m10s

## Summary

- replace the per-fighter received-attack inbox with a typed rollback-registered pending-hit resource
- split combat reaction resolution into its own attack module
- remap entity references during rollback and include pending hits in debug save states

Fixes #21

Reviewed-on: #34
Co-authored-by: Álex Román Núñez <eirexe@eirteam.moe>
This commit was merged in pull request #34.
This commit is contained in:
2026-09-16 17:50:40 +00:00
committed by EIREXE
parent 352c6fcb6c
commit 43ee6d8153
4 changed files with 195 additions and 163 deletions
+596
View File
@@ -0,0 +1,596 @@
use std::collections::{HashMap, VecDeque};
mod reaction;
use bevy::ecs::entity::{EntityMapper, MapEntities};
use bevy::prelude::*;
use bevy_ggrs::{Rollback, RollbackApp, prelude::GgrsSchedule};
use serde::{Deserialize, Serialize};
use crate::{
fighter::{
Fighter, FighterAttributes, FighterFacingDirection, FighterHitboxes,
FighterPreviousFacingDirection, FighterPreviousTranslation, FighterRoster,
FighterTranslation,
animation::{AnimKind, FighterAnimationFrame},
baked_animation::{FighterBoneIndexMap, FighterBoneMatrices, FixedMat4},
hurtbox::{FixedAffineCapsule, FixedCapsule},
manifest::FighterManifest,
},
game_settings::GameSettings,
math::{
int::{FGWide, FGi32, FGi32Ext},
vec::FGVec2,
vec3::FGVec3,
},
player::Player,
schedule::GameplaySet,
scripting::FighterAttackScript,
};
#[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(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,
}
impl KnockbackType {
pub fn make_normal() -> Self {
Self::Normal
}
pub fn make_fixed() -> Self {
Self::Fixed
}
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Knockback(FGi32);
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)]
pub enum AttackAngle {
Normal(FGi32),
Sakurai,
}
impl Default for AttackAngle {
fn default() -> Self {
AttackAngle::Normal(FGi32::lit("0"))
}
}
impl AttackAngle {
pub fn make_normal(angle: FGi32) -> Self {
Self::Normal(angle)
}
pub fn make_sakurai() -> Self {
Self::Sakurai
}
}
#[derive(Serialize, Deserialize, Reflect, PartialEq, Eq, Hash, Clone, Debug, Default, Copy)]
pub enum AttackKind {
#[default]
NeutralAir,
ForwardAir,
BackAir,
DownAir,
UpAir,
Jab,
UpTilt,
DownTilt,
ForwardTilt,
UpSmash,
DownSmash,
ForwardSmash,
}
impl AttackKind {
pub fn get_animation(&self) -> AnimKind {
match self {
AttackKind::NeutralAir => AnimKind::AttackJab1,
AttackKind::Jab => AnimKind::AttackJab1,
AttackKind::UpTilt => AnimKind::AttackUpTilt,
AttackKind::ForwardAir => AnimKind::AttackUpTilt,
AttackKind::BackAir => AnimKind::AttackUpTilt,
AttackKind::DownAir => AnimKind::AttackUpTilt,
AttackKind::UpAir => AnimKind::AttackUpTilt,
AttackKind::DownTilt => AnimKind::AttackDownTilt,
AttackKind::ForwardTilt => AnimKind::AttackForwardTilt,
AttackKind::UpSmash => AnimKind::AttackUpTilt,
AttackKind::DownSmash => AnimKind::AttackUpTilt,
AttackKind::ForwardSmash => AnimKind::AttackUpTilt,
}
}
}
#[derive(Debug, Clone, Reflect, Serialize, Deserialize)]
#[reflect(opaque)]
pub struct AttackHitbox {
pub id: u32,
pub bone: String,
pub damage: FighterDamage,
pub offset: FGVec3,
pub radius: FGi32,
pub angle: AttackAngle,
pub knockback_type: KnockbackType,
pub knockback: Knockback,
pub knockback_growth: FGi32,
pub start_frame: u32,
pub end_frame: u32,
}
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> {
if self.bone.trim().is_empty() {
return Err("bone name is empty".into());
}
if self.radius <= FGi32::ZERO {
return Err("radius must be positive".into());
}
if self.damage.0 < FGi32::ZERO {
return Err("damage must be non-negative".into());
}
if self.knockback.0 < FGi32::ZERO {
return Err("knockback must be non-negative".into());
}
if self.knockback_growth < FGi32::ZERO {
return Err("knockback growth must be non-negative".into());
}
if self.end_frame <= self.start_frame {
return Err("end frame must be greater than start frame".into());
}
Ok(())
}
/// Knockback calculation
pub fn calculate_knockback(
&self,
receiver_attribs: &FighterAttributes,
receiver_damage: FighterDamage,
total_damage_received_this_frame: FighterDamage,
interrupted_smash_charge: bool,
receiver_crouching: bool,
) -> Knockback {
/// Weight that produces a weight modifier of exactly 1.0. Weight-independent
/// attacks pass this value in place of the target's real weight.
const BASELINE_WEIGHT: FGi32 = FGi32::lit("100.0");
/// Applied to the weight-scaled damage term.
const KB_GAIN: FGi32 = FGi32::lit("1.4");
/// Constant floor added to the damage term before knockback growth scales it.
/// At 0% and 0 damage, knockback reduces to KB_OFFSET * s + b.
const KB_OFFSET: FGi32 = FGi32::lit("18.0");
/// Knockback growth is authored in percent (110 means 1.1x).
const KB_GROWTH_SCALE: FGi32 = FGi32::lit("0.01");
const DAMAGE_TERM_DIVISOR: FGi32 = FGi32::lit("20.0");
const DAMAGE_BONUS: FGi32 = FGi32::lit("2.0"); // effective damage floor in the damage term
const CHARGE_SMASH_INTERRUPTION_MODIFIER: FGi32 = FGi32::lit("1.2");
const CROUCH_CANCEL_MODIFIER: FGi32 = FGi32::lit("0.666667");
let attack_damage_unstalled = self.damage.0;
let modifier = {
let mut out = FGi32::ONE;
if interrupted_smash_charge {
out *= CHARGE_SMASH_INTERRUPTION_MODIFIER
} else if receiver_crouching {
out *= CROUCH_CANCEL_MODIFIER
}
out
};
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)
}
};
Knockback((FGWide::from_num(modifier) * kb).saturating_to_num())
}
}
pub fn update_active_hitbox_list(script: &FighterAttackScript, list: &mut Vec<usize>, frame: u32) {
list.clear();
list.extend(
script
.hitboxes
.iter()
.enumerate()
.filter(|(_, hb)| hb.start_frame <= frame && hb.end_frame > frame)
.map(|(i, _)| i),
);
}
#[derive(Clone, Debug, PartialEq, Hash)]
pub struct FighterSolvedHurtbox {
pub hurtbox_idx: usize,
pub capsule: FixedAffineCapsule,
}
#[derive(Clone, Debug, PartialEq, Hash)]
pub struct FighterSolvedHitbox {
pub hitbox_idx: usize,
pub capsule: FixedCapsule,
}
pub fn solve_hitboxes(
mut query: Query<(
&FighterBoneMatrices,
&FighterBoneIndexMap,
&FighterTranslation,
&FighterPreviousTranslation,
&FighterFacingDirection,
&FighterPreviousFacingDirection,
&mut FighterHitboxes,
&FighterAnimationFrame,
)>,
attack_scripts: Res<Assets<FighterAttackScript>>,
) {
for (
matrices,
bone_indices,
translation,
previous_translation,
facing_direction,
previous_facing_direction,
mut hitboxes,
animation_frame,
) in &mut query
{
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;
}
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);
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,
},
});
}
}
}
}
#[derive(Component)]
pub struct FighterAttackScriptAssets {
pub scripts: HashMap<AttackKind, Handle<FighterAttackScript>>,
}
#[derive(Component, Clone, Debug, Default, Hash)]
pub struct FighterSolvedHurtboxes(pub Vec<FighterSolvedHurtbox>);
#[derive(Clone, Debug, Hash)]
pub struct FighterHit {
pub attacker: Entity,
pub defender: Entity,
pub attack_script: Handle<FighterAttackScript>,
pub attack_kind: AttackKind,
pub hitbox_index: usize,
}
#[derive(Resource, Clone, Debug, Default, Hash)]
pub struct FighterPendingHits(pub Vec<FighterHit>);
impl MapEntities for FighterPendingHits {
fn map_entities<M: EntityMapper>(&mut self, entity_mapper: &mut M) {
for hit in &mut self.0 {
hit.attacker = entity_mapper.get_mapped(hit.attacker);
hit.defender = entity_mapper.get_mapped(hit.defender);
}
}
}
pub fn solve_hurtboxes(
mut query: Query<(
&FighterBoneMatrices,
&FighterBoneIndexMap,
&FighterTranslation,
&FighterFacingDirection,
&mut FighterSolvedHurtboxes,
&Fighter,
)>,
manifests: Res<Assets<FighterManifest>>,
) {
for (matrices, bone_indices, translation, facing_direction, mut solved_hurtboxes, fighter) in
&mut query
{
let manifest = manifests
.get(&fighter.manifest)
.expect("Manifest should be valid");
let fighter_trf = translation.get_3d_transform(facing_direction);
solved_hurtboxes.0.clear();
for (hurtbox_idx, hurtbox) in manifest.hurtboxes.iter().enumerate() {
let bone_matrix = bone_indices
.0
.get(&hurtbox.bone)
.and_then(|index| matrices.get_current_index(*index));
if let Some(bone_matrix) = bone_matrix {
let to_global = fighter_trf.mul(bone_matrix);
let hurtbox_trf = FixedMat4::IDENTITY
.with_rotation(FGVec3::new(
hurtbox.rotation.x.to_radians(),
hurtbox.rotation.y.to_radians(),
hurtbox.rotation.z.to_radians(),
))
.with_translation(hurtbox.offset);
solved_hurtboxes.0.push(FighterSolvedHurtbox {
hurtbox_idx,
capsule: FixedAffineCapsule::new(
to_global.mul(hurtbox_trf),
hurtbox.half_length,
hurtbox.radius,
),
});
}
}
}
}
pub fn intersect_attacks_with_hurtboxes(
roster: Res<FighterRoster>,
mut query_attacker: Query<(
Entity,
&mut FighterHitboxes,
&Fighter,
&Player,
&FighterStaleMoveQueue,
)>,
query_attacked: Query<(Entity, &FighterSolvedHurtboxes)>,
mut pending_hits: ResMut<FighterPendingHits>,
attack_scripts: Res<Assets<FighterAttackScript>>,
) {
pending_hits.0.clear();
for &entity_receiving in &roster.0 {
let Ok((_, hurtboxes)) = query_attacked.get(entity_receiving) else {
continue;
};
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_scripts
.get(&active_attack.script)
.expect("Attack script should be valid");
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;
};
pending_hits.0.push(FighterHit {
attacker: entity_hurting,
defender: entity_receiving,
attack_script: active_attack.script.clone(),
attack_kind: active_attack.kind,
hitbox_index: hitting_hitbox_idx,
});
info!("Hit by hitbox {}!!!!", hitting_hitbox_idx);
active_attack.hit_fighters.push(entity_receiving);
}
}
}
pub struct FighterAttackPlugin;
impl Plugin for FighterAttackPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<FighterPendingHits>()
.add_plugins(reaction::FighterReactionPlugin)
.rollback_resource_with_clone::<FighterPendingHits>()
.update_resource_with_map_entities::<FighterPendingHits>()
.checksum_resource_with_hash::<FighterPendingHits>();
app.add_systems(
GgrsSchedule,
(
solve_hurtboxes,
solve_hitboxes,
intersect_attacks_with_hurtboxes,
)
.chain()
.after(crate::fighter::baked_animation::update_fighter_bone_matrices)
.in_set(GameplaySet::Attacks),
);
}
}