use std::collections::{HashMap, VecDeque}; mod reaction; pub mod directional_influence; use bevy::ecs::entity::{EntityMapper, MapEntities}; use bevy::prelude::*; use bevy_ggrs::{Rollback, RollbackApp, prelude::GgrsSchedule}; use serde::{Deserialize, Serialize}; use crate::fighter::FighterInvulnerabilityState; 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, PartialEq, Eq, PartialOrd, Ord)] pub struct FighterDamage(FGi32); impl std::fmt::Display for FighterDamage { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{:.2}", self.0) } } #[derive(Debug, Hash, Clone)] pub enum HitReactionKind { Flinch, Tumble, } #[derive(Debug, Hash, Clone)] pub struct FighterHitReaction { pub launch_velocity: FGi32, pub launch_angle: FGi32, pub victim_hitstop_frames: HitStop, pub attacker_hitstop_frames: HitStop, pub hitstun_frames: u32, pub kind: HitReactionKind, pub damage: FighterDamage, } #[derive(Component, DerefMut, Deref, Debug, Default, Hash, Clone)] pub struct FighterPendingHitReaction(pub Option); impl FighterDamage { pub fn lit(text: &str) -> Self { Self(FGi32::lit(text)) } } #[derive(Component)] pub struct FighterLastAttackDebug { pub trajectory_points: Vec, } #[derive(Debug, Default, Clone, Deref, DerefMut, Hash, PartialEq, PartialOrd)] pub struct HitStop(u32); impl HitStop { pub fn new(hitstop: u32) -> Self { HitStop(hitstop) } pub fn calculate(attack_damage_stalled: FighterDamage, crouch_cancelling: bool) -> Self { let a = (attack_damage_stalled.0 / FGi32::lit("3.0") + FGi32::lit("3.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) } pub fn calculate_shake_frames(&self, game_settings: &GameSettings) -> u32 { (FGi32::from_num(self.0) * game_settings.fighter_common.visual_shake_frames_per_hitstop_frame).floor().to_num() } } impl std::fmt::Display for HitStop { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } #[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, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub struct Knockback(FGi32); impl std::fmt::Display for Knockback { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{:.2}", self.0) } } 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 std::fmt::Display for AttackAngle { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { AttackAngle::Normal(fixed_i32) => write!(f, "{:.2}ยบ", fixed_i32), AttackAngle::Sakurai => write!(f, "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 } pub fn get_angle(&self, knockback: &Knockback, is_receiver_grounded: bool, game_settings: &GameSettings) -> FGi32 { match self { AttackAngle::Normal(angle) => { *angle }, AttackAngle::Sakurai => { if !is_receiver_grounded { game_settings.fighter_common.sakurai_angle_aerial_angle } else if knockback.0 > game_settings.fighter_common.sakurai_angle_knockback_threshold { game_settings.fighter_common.sakurai_angle_high_knockback_angle } else { game_settings.fighter_common.sakurai_angle_low_knockback_angle } }, } } pub fn to_melee_angle_representation(&self) -> FGi32 { match self { AttackAngle::Normal(fixed_i32) => *fixed_i32, AttackAngle::Sakurai => FGi32::lit("361"), } } } #[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, DashAttack, } impl AttackKind { pub fn get_animation(&self) -> AnimKind { match self { AttackKind::NeutralAir => AnimKind::AttackNeutralAir, AttackKind::Jab => AnimKind::AttackJab1, AttackKind::UpTilt => AnimKind::AttackUpTilt, AttackKind::ForwardAir => AnimKind::AttackForwardAir, 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, AttackKind::DashAttack => AnimKind::AttackDash, } } } #[derive(Debug, Clone, Reflect, Serialize, Deserialize)] #[reflect(opaque)] pub struct AttackHitbox { pub id: u32, pub group: 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, } static ATTACK_HITBOX_ID_PALETTE: [bevy::color::Srgba; 5] = [ bevy::color::palettes::tailwind::RED_500, bevy::color::palettes::tailwind::GREEN_500, bevy::color::palettes::tailwind::BLUE_500, bevy::color::palettes::tailwind::FUCHSIA_500, bevy::color::palettes::tailwind::ORANGE_500, ]; const STALE_MOVE_QUEUE_COUNT: usize = 9; #[derive(Component, Hash, Clone, Default)] #[require(Rollback)] pub struct FighterStaleMoveQueue(VecDeque); 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::() .min(FGi32::ONE) } } impl AttackHitbox { pub fn get_debug_draw_color(&self) -> bevy::color::Srgba { ATTACK_HITBOX_ID_PALETTE[self.id as usize % ATTACK_HITBOX_ID_PALETTE.len()] } 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, frame: u32) { list.clear(); list.extend( script .hitboxes .iter() .enumerate() .filter(|(_, hb)| hb.start_frame <= frame && hb.end_frame > frame) .map(|(i, _)| i), ); } /// Updates both the currently active hitbox intervals and the lifetime of /// their per-group victim histories. A group remains immune only while at /// least one hitbox belonging to that group is active. pub fn update_active_attack( script: &FighterAttackScript, attack: &mut super::FighterActiveAttack, frame: u32, ) { update_active_hitbox_list(script, &mut attack.active_hitboxes, frame); let mut active_groups = Vec::new(); for &index in &attack.active_hitboxes { let group = script.hitboxes[index].group; if !active_groups.contains(&group) { active_groups.push(group); } } attack.hit_groups.retain(|state| active_groups.contains(&state.group)); for group in active_groups { if !attack.hit_groups.iter().any(|state| state.group == group) { attack.hit_groups.push(super::FighterHitGroupState { group, hit_fighters: Vec::new(), }); } } attack.hit_groups.sort_unstable_by_key(|state| state.group); } #[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>, ) { 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>, } #[derive(Component, Clone, Debug, Default, Hash)] pub struct FighterSolvedHurtboxes(pub Vec); #[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<( &FighterBoneMatrices, &FighterBoneIndexMap, &FighterTranslation, &FighterFacingDirection, &mut FighterSolvedHurtboxes, &Fighter, )>, manifests: Res>, ) { 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, mut query_attacker: Query<( Entity, &mut FighterHitboxes, &Fighter, &Player, &FighterStaleMoveQueue, )>, query_attacked: Query<(Entity, &FighterSolvedHurtboxes, &FighterInvulnerabilityState)>, mut pending_hits: ResMut, attack_scripts: Res>, ) { pending_hits.0.clear(); for &entity_receiving in &roster.0 { let Ok((_, hurtboxes, invuln_state)) = query_attacked.get(entity_receiving) else { continue; }; if matches!(*invuln_state, FighterInvulnerabilityState::Intangible { .. }) { 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; }; 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; let group = attack_script.hitboxes[candidate].group; let already_hit = active_attack .hit_groups .iter() .find(|state| state.group == group) .is_some_and(|state| state.hit_fighters.contains(&entity_receiving)); if already_hit { continue; } 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, }); let group = attack_script.hitboxes[hitting_hitbox_idx].group; if let Some(state) = active_attack .hit_groups .iter_mut() .find(|state| state.group == group) { state.hit_fighters.push(entity_receiving); } } } } 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, ) .chain() .in_set(GameplaySet::Attacks), ); } }