From ac0216ff00a923922a86604585b09ea1b2f06034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lex=20Rom=C3=A1n=20N=C3=BA=C3=B1ez?= Date: Tue, 15 Sep 2026 23:05:26 +0200 Subject: [PATCH] Separate rollback-safe combat reactions --- src/debug_tools/save_state.rs | 11 +- src/fighter/{attack.rs => attack/mod.rs} | 195 +++++------------------ src/fighter/attack/reaction.rs | 145 +++++++++++++++++ src/fighter/mod.rs | 7 +- 4 files changed, 195 insertions(+), 163 deletions(-) rename src/fighter/{attack.rs => attack/mod.rs} (77%) create mode 100644 src/fighter/attack/reaction.rs diff --git a/src/debug_tools/save_state.rs b/src/debug_tools/save_state.rs index ec6fe33..36769cb 100644 --- a/src/debug_tools/save_state.rs +++ b/src/debug_tools/save_state.rs @@ -8,8 +8,8 @@ use crate::{ FighterVelocity, Grounded, animation::FighterAnimationFrame, attack::{ - FighterDamage, FighterKnockbackVelocity, FighterPendingHitReaction, - FighterReceivedAttacks, FighterSolvedHurtboxes, FighterStaleMoveQueue, + FighterDamage, FighterKnockbackVelocity, FighterPendingHitReaction, FighterPendingHits, + FighterSolvedHurtboxes, FighterStaleMoveQueue, }, baked_animation::FighterBoneMatrices, state::FighterState, @@ -28,6 +28,7 @@ pub(super) struct DebugSaveState { struct Checkpoint { fighters: Vec, stage_collision: StageCollision, + pending_hits: FighterPendingHits, } #[derive(Clone)] @@ -50,7 +51,6 @@ struct FighterCheckpoint { knockback_velocity: FighterKnockbackVelocity, stale_moves: FighterStaleMoveQueue, hitboxes: FighterHitboxes, - received_attacks: FighterReceivedAttacks, solved_hurtboxes: FighterSolvedHurtboxes, bone_matrices: FighterBoneMatrices, grounded: bool, @@ -97,9 +97,11 @@ pub(super) fn save_or_restore(world: &mut World) { }; checkpoint_fighters.push(checkpoint); } + let pending_hits = world.resource::().clone(); world.resource_mut::().checkpoint = Some(Checkpoint { fighters: checkpoint_fighters, stage_collision, + pending_hits, }); info!("Saved debug game state"); return; @@ -147,7 +149,6 @@ pub(super) fn save_or_restore(world: &mut World) { entity.insert(saved.knockback_velocity); entity.insert(saved.stale_moves.clone()); entity.insert(saved.hitboxes.clone()); - entity.insert(saved.received_attacks.clone()); entity.insert(saved.solved_hurtboxes.clone()); entity.insert(saved.bone_matrices.clone()); if saved.grounded { @@ -156,6 +157,7 @@ pub(super) fn save_or_restore(world: &mut World) { entity.remove::(); } } + world.insert_resource(checkpoint.pending_hits.clone()); world.insert_resource(checkpoint.stage_collision); for mut match_camera in world.query::<&mut MatchCamera>().iter_mut(world) { match_camera.needs_snap = true; @@ -184,7 +186,6 @@ fn capture_fighter(world: &World, entity: Entity) -> Option { knockback_velocity: *fighter.get::()?, stale_moves: fighter.get::()?.clone(), hitboxes: fighter.get::()?.clone(), - received_attacks: fighter.get::()?.clone(), solved_hurtboxes: fighter.get::()?.clone(), bone_matrices: fighter.get::()?.clone(), grounded: fighter.contains::(), diff --git a/src/fighter/attack.rs b/src/fighter/attack/mod.rs similarity index 77% rename from src/fighter/attack.rs rename to src/fighter/attack/mod.rs index da49d0c..316ac4f 100644 --- a/src/fighter/attack.rs +++ b/src/fighter/attack/mod.rs @@ -1,18 +1,19 @@ use std::collections::{HashMap, VecDeque}; +mod reaction; + +use bevy::ecs::entity::{EntityMapper, MapEntities}; use bevy::prelude::*; -use bevy_ggrs::{Rollback, prelude::GgrsSchedule}; -use fixed_math::SinCos; +use bevy_ggrs::{Rollback, RollbackApp, prelude::GgrsSchedule}; use serde::{Deserialize, Serialize}; use crate::{ fighter::{ - Fighter, FighterAttributes, FighterFacingDirection, FighterHitboxes, FighterHitstop, + Fighter, FighterAttributes, FighterFacingDirection, FighterHitboxes, FighterPreviousFacingDirection, FighterPreviousTranslation, FighterRoster, FighterTranslation, animation::{AnimKind, FighterAnimationFrame}, baked_animation::{FighterBoneIndexMap, FighterBoneMatrices, FixedMat4}, - errors::FighterErrors, hurtbox::{FixedAffineCapsule, FixedCapsule}, manifest::FighterManifest, }, @@ -432,15 +433,26 @@ pub struct FighterAttackScriptAssets { #[derive(Component, Clone, Debug, Default, Hash)] pub struct FighterSolvedHurtboxes(pub Vec); -#[derive(Component, Clone, Debug, Default, Hash, DerefMut, Deref)] -pub struct FighterReceivedAttacks( - pub Vec<( - /* attacker */ Entity, - Handle, - AttackKind, - /* hitbox_idx */ usize, - )>, -); +#[derive(Clone, Debug, Hash)] +pub struct FighterHit { + pub attacker: Entity, + pub defender: Entity, + pub attack_script: Handle, + pub attack_kind: AttackKind, + pub hitbox_index: usize, +} + +#[derive(Resource, Clone, Debug, Default, Hash)] +pub struct FighterPendingHits(pub Vec); + +impl MapEntities for FighterPendingHits { + fn map_entities(&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<( @@ -498,19 +510,14 @@ pub fn intersect_attacks_with_hurtboxes( &Player, &FighterStaleMoveQueue, )>, - mut query_attacked: Query<( - Entity, - &FighterSolvedHurtboxes, - &Player, - &Fighter, - &mut FighterReceivedAttacks, - )>, + query_attacked: Query<(Entity, &FighterSolvedHurtboxes)>, + mut pending_hits: ResMut, attack_scripts: Res>, ) { + pending_hits.0.clear(); + for &entity_receiving in &roster.0 { - let Ok((_, hurtboxes, _, _receiving_fighter, mut received_attacks)) = - query_attacked.get_mut(entity_receiving) - else { + let Ok((_, hurtboxes)) = query_attacked.get(entity_receiving) else { continue; }; @@ -551,12 +558,13 @@ pub fn intersect_attacks_with_hurtboxes( continue; }; - received_attacks.push(( - entity_hurting, - active_attack.script.clone(), - active_attack.kind, - hitting_hitbox_idx, - )); + 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); @@ -564,140 +572,21 @@ pub fn intersect_attacks_with_hurtboxes( } } -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>, - game_settings: Res, - fighter_manifests: Res>, -) -> 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::>(); - - let total_damage_received_this_frame = damage_data - .iter() - .map(|(_, damage, ..)| damage) - .sum::(); - - 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 { fn build(&self, app: &mut App) { + app.init_resource::() + .add_plugins(reaction::FighterReactionPlugin) + .rollback_resource_with_clone::() + .update_resource_with_map_entities::() + .checksum_resource_with_hash::(); app.add_systems( GgrsSchedule, ( solve_hurtboxes, solve_hitboxes, intersect_attacks_with_hurtboxes, - calculate_received_attacks_trajectories, ) .chain() .after(crate::fighter::baked_animation::update_fighter_bone_matrices) diff --git a/src/fighter/attack/reaction.rs b/src/fighter/attack/reaction.rs new file mode 100644 index 0000000..ca7f27a --- /dev/null +++ b/src/fighter/attack/reaction.rs @@ -0,0 +1,145 @@ +use bevy::prelude::*; +use bevy_ggrs::prelude::GgrsSchedule; +use fixed_math::SinCos; + +use super::*; +use crate::{ + fighter::{ + Fighter, FighterFacingDirection, FighterHitboxes, FighterHitstop, + FighterPendingHitReaction, FighterStaleMoveQueue, errors::FighterErrors, + manifest::FighterManifest, + }, + game_settings::GameSettings, + math::{int::FGi32, vec::FGVec2}, + player::Player, + schedule::GameplaySet, + scripting::FighterAttackScript, +}; + +pub fn resolve_fighter_hits( + mut pending_hits: ResMut, + mut attacker: Query<( + &FighterStaleMoveQueue, + &FighterFacingDirection, + &FighterHitboxes, + &Player, + &mut FighterHitstop, + )>, + mut receiver: Query<(&mut FighterDamage, &Fighter, &mut FighterPendingHitReaction)>, + attack_scripts: Res>, + game_settings: Res, + fighter_manifests: Res>, +) -> Result { + let hits = std::mem::take(&mut pending_hits.0); + let mut hit_offset = 0; + + while hit_offset < hits.len() { + let defender = hits[hit_offset].defender; + let next_defender = hits[hit_offset..] + .iter() + .position(|hit| hit.defender != defender) + .map_or(hits.len(), |offset| hit_offset + offset); + + let Ok((mut receiver_damage, fighter, mut pending_reaction)) = receiver.get_mut(defender) + else { + hit_offset = next_defender; + continue; + }; + + let damage_data = hits[hit_offset..next_defender] + .iter() + .filter_map(|hit| { + let Ok((stale_move_queue, facing_dir, _hitboxes, player, _)) = + attacker.get(hit.attacker) + else { + return None; + }; + let attack_script = attack_scripts.get(&hit.attack_script)?; + let hitbox = attack_script.hitboxes.get(hit.hitbox_index)?; + let damage_received = stale_move_queue + .get_attack_damage_multiplier(hit.attack_kind) + * hitbox.damage.0; + + Some(( + hit.attacker, + damage_received, + hitbox, + matches!(facing_dir, FighterFacingDirection::Left), + player.handle, + )) + }) + .collect::>(); + + let total_damage_received_this_frame = damage_data + .iter() + .map(|(_, damage, ..)| damage) + .sum::(); + let fighter_manifest = fighter_manifests + .get(&fighter.manifest) + .ok_or(FighterErrors::FighterManifestNotFound)?; + let mut reactions = Vec::with_capacity(damage_data.len()); + + for (attacker_entity, attack_damage, hitbox, 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 { + let Ok((.., mut hitstop)) = attacker.get_mut(*attacker_entity) else { + continue; + }; + hitstop.remaining_frames = reaction.hitstop_frames.max(hitstop.remaining_frames); + } + + pending_reaction.0 = reactions + .into_iter() + .min_by(|(_, port_a, _), (_, port_b, _)| port_a.cmp(port_b)) + .map(|(_, _, reaction)| reaction); + + hit_offset = next_defender; + } + + Ok(()) +} + +pub struct FighterReactionPlugin; + +impl Plugin for FighterReactionPlugin { + fn build(&self, app: &mut App) { + app.add_systems( + GgrsSchedule, + resolve_fighter_hits + .in_set(GameplaySet::Attacks) + .after(super::intersect_attacks_with_hurtboxes), + ); + } +} diff --git a/src/fighter/mod.rs b/src/fighter/mod.rs index e34ad1a..0d338a5 100644 --- a/src/fighter/mod.rs +++ b/src/fighter/mod.rs @@ -37,8 +37,8 @@ use crate::{ fighter::{ attack::{ AttackKind, FighterAttackPlugin, FighterAttackScriptAssets, FighterDamage, - FighterKnockbackVelocity, FighterPendingHitReaction, FighterReceivedAttacks, - FighterSolvedHurtboxes, FighterStaleMoveQueue, + FighterKnockbackVelocity, FighterPendingHitReaction, FighterSolvedHurtboxes, + FighterStaleMoveQueue, }, baked_animation::{FighterBoneIndexMap, FighterBoneMatrices}, manifest::FighterManifest, @@ -116,7 +116,6 @@ pub struct FighterVisual; #[derive(Component)] #[require( - FighterReceivedAttacks, FighterStaleMoveQueue, FighterDamage, FighterPendingHitReaction, @@ -199,8 +198,6 @@ impl Plugin for FighterPlugin { .checksum_component_with_hash::() .rollback_component_with_copy::() .checksum_component_with_hash::() - .rollback_component_with_clone::() - .checksum_component_with_hash::() .rollback_resource_with_reflect::() // Debug views. .add_systems( -- 2.54.0