From 71820a25dd1907c7346a735aa3ef3f50dfa94f39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80lex=20Rom=C3=A0n?= Date: Tue, 8 Sep 2026 11:49:58 +0200 Subject: [PATCH] initial work on hitbox and hurtbox collisions --- src/fighter/animation.rs | 2 + src/fighter/attack.rs | 129 +++++++++++++++++++++++++--- src/fighter/debug.rs | 99 +++++----------------- src/fighter/hurtbox.rs | 2 +- src/fighter/mod.rs | 16 ++-- src/fighter/state/ground_attack.rs | 7 +- src/input/buffer.rs | 132 +++++++++++++++++++++++++++-- 7 files changed, 271 insertions(+), 116 deletions(-) diff --git a/src/fighter/animation.rs b/src/fighter/animation.rs index 6e63bd0..95c9dba 100644 --- a/src/fighter/animation.rs +++ b/src/fighter/animation.rs @@ -83,4 +83,6 @@ pub enum AnimKind { // Attacks AttackJab1, AttackUpTilt, + AttackForwardTilt, + AttackDownTilt, } diff --git a/src/fighter/attack.rs b/src/fighter/attack.rs index 3eba7ac..1d01133 100644 --- a/src/fighter/attack.rs +++ b/src/fighter/attack.rs @@ -6,18 +6,11 @@ use serde::{Deserialize, Serialize}; use crate::{ fighter::{ - Fighter, FighterAttributes, FighterFacingDirection, FighterTranslation, - animation::AnimKind, - baked_animation::{FighterBoneMatrices, FixedMat4}, - hurtbox::FixedAffineCapsule, - manifest::FighterManifest, - }, - math::{ + Fighter, FighterAttributes, FighterFacingDirection, FighterHitboxes, FighterTranslation, animation::{AnimKind, FighterAnimationFrame}, baked_animation::{FighterBoneMatrices, FixedMat4}, hurtbox::{FixedAffineCapsule, FixedCapsule}, manifest::FighterManifest, + }, math::{ int::{FGi32, FGi32Ext}, vec3::FGVec3, - }, - schedule::GameplaySet, - scripting::FighterAttackScript, + }, schedule::GameplaySet, scripting::FighterAttackScript, }; #[derive(Debug, Default, Clone, Serialize, Deserialize)] pub struct FighterDamage(FGi32); @@ -77,17 +70,35 @@ impl AttackAngle { #[derive(Serialize, Deserialize, Reflect, PartialEq, Eq, Hash, Clone, Debug)] pub enum AttackKind { - NAir, + NeutralAir, + ForwardAir, + BackAir, + DownAir, + UpAir, Jab, UpTilt, + DownTilt, + ForwardTilt, + UpSmash, + DownSmash, + ForwardSmash, } impl AttackKind { pub fn get_animation(&self) -> AnimKind { match self { - AttackKind::NAir => AnimKind::AttackJab1, + 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, } } } @@ -181,6 +192,69 @@ pub struct FighterSolvedHurtbox { pub capsule: FixedAffineCapsule, } +#[derive(Clone, Debug, PartialEq, Hash)] +pub struct FighterSolvedHitbox { + pub hitbox_idx: usize, + pub capsule: FixedCapsule, +} + +#[derive(Component, Clone, Debug, Default, Hash)] +pub struct FighterSolvedHitboxes(pub Vec); + +pub fn solve_hitboxes( + mut query: Query<( + &FighterBoneMatrices, + &FighterTranslation, + &FighterFacingDirection, + &mut FighterHitboxes, + &FighterAnimationFrame + )>, + attack_scripts: Res>, +) { + for (matrices, translation, facing_direction, mut hitboxes, animation_frame) in &mut query { + hitboxes.active_hitboxes_solved.clear(); + if let Some(ref attack_script) = hitboxes.attack_script { + let attack_script = attack_scripts.get(attack_script).expect("Attack script should be valid"); + let fighter_trf = translation.get_3d_transform(facing_direction); + let active_hitboxes = hitboxes.active_hitboxes.clone(); + for hitbox_idx in active_hitboxes { + let hitbox = attack_script.hitboxes.get(hitbox_idx).expect("Hitbox IDX should be valid"); + let matrices = matrices.get(&hitbox.bone); + + if let None = matrices { + 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 + hitboxes.active_hitboxes_solved.push( + FixedCapsule { + start: curr_hbox_pos, + end: curr_hbox_pos, + radius: hitbox.radius + } + ) + } else { + let prev_hbox_matrix = fighter_trf.mul(prev_bone_matrix); + let prev_hbox_pos = prev_hbox_matrix.transform_point(hitbox.offset); + hitboxes.active_hitboxes_solved.push( + FixedCapsule { + start: prev_hbox_pos, + end: curr_hbox_pos, + radius: hitbox.radius + } + ); + } + } + } + } +} #[derive(Component)] pub struct FighterAttackScriptAssets { pub scripts: HashMap>, @@ -229,13 +303,42 @@ pub fn solve_hurtboxes( } } +pub fn intersect_attacks_with_hurtboxes(query: Query<(Entity, &FighterSolvedHurtboxes, &FighterHitboxes)>, attack_scripts: Res>) { + /*for (entity_hurting, _, hitboxes) in query { + 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() { + continue; + } + + let attack_script = attack_script.unwrap(); + + let capsules: Vec<(&AttackHitbox, FixedCapsule)> = hitboxes.active_hitboxes.iter().map(|idx| { + (attack_script.hitboxes.get(*idx).unwrap(), ) + }).collect(); + + for (entity_to_hurt, hurtboxes, _) in query { + // Self collisions not allowed + if entity_to_hurt == entity_hurting { + continue; + } + } + } + }*/ +} + pub struct FighterAttackPlugin; impl Plugin for FighterAttackPlugin { fn build(&self, app: &mut App) { app.add_systems( GgrsSchedule, - solve_hurtboxes + (solve_hurtboxes, solve_hitboxes).chain() .after(crate::fighter::baked_animation::update_fighter_bone_matrices) .in_set(GameplaySet::Animation), ); diff --git a/src/fighter/debug.rs b/src/fighter/debug.rs index 7c5abf7..556f022 100644 --- a/src/fighter/debug.rs +++ b/src/fighter/debug.rs @@ -19,6 +19,7 @@ use crate::fighter::{ FighterTranslation, FighterVelocity, }; use crate::input::FighterInput; +use crate::math::int::FGi32; use crate::math::vec3::FGVec3; use crate::player::Player; use crate::scripting::FighterAttackScript; @@ -250,8 +251,7 @@ fn capsule_wireframe() -> &'static CapsuleWireframe { let mut lower_previous = lower_ring[index]; let mut upper_previous = upper_ring[index]; for step in 1..HEMISPHERE_STEPS { - let latitude = step as f32 * std::f32::consts::FRAC_PI_2 - / HEMISPHERE_STEPS as f32; + let latitude = step as f32 * std::f32::consts::FRAC_PI_2 / HEMISPHERE_STEPS as f32; let (sin_latitude, cos_latitude) = latitude.sin_cos(); let lower = vertices.len(); @@ -324,86 +324,31 @@ pub fn attack_debug( ) { for (hitboxes, matrices, translation, facing_direction, frame) in query { if let Some(script) = &hitboxes.attack_script { - let script = attack_scripts - .get(script) - .expect("Script ref should be valid"); - let player_trf = translation.get_3d_transform(facing_direction); + for solved_hitbox in hitboxes.active_hitboxes_solved.iter() { + let start = solved_hitbox.start.to_vec3(); + let end = solved_hitbox.end.to_vec3(); + let center = (solved_hitbox.start + solved_hitbox.end) * FGi32::lit("0.5"); + let length = (start - end).length(); - for i in hitboxes.active_hitboxes.iter() { - let offset = script.hitboxes[*i].offset; - let radius = script.hitboxes[*i].radius; - let bone = &script.hitboxes[*i].bone; + let attack_capsule = Capsule3d { + half_length: length * 0.5, + radius: solved_hitbox.radius.to_num(), + }; - let trfs = matrices.get(bone); + let aim_dir = (end - start).normalize_or(Vec3::Y); - if let Some((prev_trf, curr_trf)) = trfs { - let curr_trf = player_trf.mul(curr_trf); - let curr_pos = curr_trf.transform_point(offset); + let mut capsule_draw_trf = Transform::IDENTITY; + capsule_draw_trf.translation = center.to_vec3(); + capsule_draw_trf.align(Dir3::Y, aim_dir, Dir3::X, aim_dir.any_orthonormal_vector()); - let curr_pos_draw = Vec3::new( - curr_pos.x.to_num(), - curr_pos.y.to_num(), - curr_pos.z.to_num(), - ); + gizmos.primitive_3d( + &attack_capsule, + capsule_draw_trf.to_isometry(), + bevy::color::palettes::css::RED, + ); - if script.hitboxes[*i].start_frame == frame.frame { - gizmos.sphere( - curr_pos_draw, - radius.to_num(), - bevy::color::palettes::css::RED, - ); - continue; - } - - let prev_trf = player_trf.mul(prev_trf); - - let prev_pos = prev_trf.transform_point(offset); - - let prev_pos_draw = Vec3::new( - prev_pos.x.to_num(), - prev_pos.y.to_num(), - prev_pos.z.to_num(), - ); - - let dist = Dir3::new_and_length(curr_pos_draw - prev_pos_draw); - - if let Ok((aim_dir, dist)) = dist { - let attack_capsule = Capsule3d { - half_length: dist * 0.5, - radius: radius.to_num(), - }; - let mut capsule_draw_trf = Transform::IDENTITY; - capsule_draw_trf.align( - Dir3::Y, - aim_dir, - Dir3::X, - aim_dir.any_orthonormal_vector(), - ); - capsule_draw_trf.translation = (prev_pos_draw + curr_pos_draw) * 0.5; - gizmos.primitive_3d( - &attack_capsule, - capsule_draw_trf.to_isometry(), - bevy::color::palettes::css::RED, - ); - } - - gizmos.cross( - curr_trf - .transform_point(FGVec3::lit("0.0", "0.0", "0.0")) - .to_vec3(), - 1.0, - bevy::color::palettes::css::BLUE, - ); - gizmos.cross( - curr_trf.to_mat4().transform_point(Vec3::ZERO), - 1.0, - bevy::color::palettes::css::HOT_PINK, - ); - - gizmos.axes(curr_trf.to_mat4(), 1.0); - } else { - warn!("Bone not found required by attack script: {}", bone); - } + gizmos.cross(start, 1.0, bevy::color::palettes::css::GREEN); + gizmos.cross(end, 1.0, bevy::color::palettes::css::GREEN); } } } diff --git a/src/fighter/hurtbox.rs b/src/fighter/hurtbox.rs index 45f69b7..e0d6890 100644 --- a/src/fighter/hurtbox.rs +++ b/src/fighter/hurtbox.rs @@ -18,7 +18,7 @@ pub struct FixedAffineCapsule { } /// An ordinary world-space capsule, used for swept attack volumes. -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Copy, Debug, PartialEq, Hash)] pub struct FixedCapsule { pub start: FGVec3, pub end: FGVec3, diff --git a/src/fighter/mod.rs b/src/fighter/mod.rs index 9c1b7ce..0ea3bb8 100644 --- a/src/fighter/mod.rs +++ b/src/fighter/mod.rs @@ -30,17 +30,8 @@ pub use motion::{FighterPreviousTranslation, FighterTranslation, FighterVelocity use crate::{ fighter::{ - attack::{FighterAttackPlugin, FighterAttackScriptAssets, FighterSolvedHurtboxes}, - manifest::FighterManifest, - state::{FighterState, fall::FallState}, - visual::FighterAnimations, - }, - input::FighterInput, - math::{int::FGi32, vec::FGVec2}, - player::Player, - schedule::GameplaySet, - scripting::FighterAttackScript, - stage::line::StageCollision, + attack::{FighterAttackPlugin, FighterAttackScriptAssets, FighterSolvedHurtboxes}, baked_animation::FighterBoneMatrices, hurtbox::FixedCapsule, manifest::FighterManifest, state::{FighterState, fall::FallState}, visual::FighterAnimations, + }, input::FighterInput, math::{int::FGi32, vec::FGVec2}, player::Player, schedule::GameplaySet, scripting::FighterAttackScript, stage::line::StageCollision, }; use state::state_interrupt_system; @@ -182,6 +173,7 @@ impl Plugin for FighterPlugin { ) .rollback_component_with_clone::() .rollback_component_with_clone::() + .rollback_component_with_clone::() .checksum_component_with_hash::() .rollback_component_with_clone::() .checksum_component_with_hash::() @@ -195,6 +187,7 @@ impl Plugin for FighterPlugin { pub struct FighterHitboxes { pub attack_script: Option>, pub active_hitboxes: Vec, + pub active_hitboxes_solved: Vec, } impl FighterHitboxes { @@ -228,6 +221,7 @@ pub fn spawn_fighter( FighterHitboxes { attack_script: None, active_hitboxes: vec![], + active_hitboxes_solved: vec![] }, ), ( diff --git a/src/fighter/state/ground_attack.rs b/src/fighter/state/ground_attack.rs index 1e39241..be6aa81 100644 --- a/src/fighter/state/ground_attack.rs +++ b/src/fighter/state/ground_attack.rs @@ -71,12 +71,7 @@ pub fn check_interrupt( .input .has_command(crate::input::FighterCommands::Attack) { - let attack_kind = if state_context.input.get_last_frame().movement.y > FGi32::ZERO { - AttackKind::UpTilt - } else { - AttackKind::Jab - }; - + let attack_kind = state_context.input.get_attack_command_type().to_attack_kind_grounded(); Some(FighterState::GroundAttack(GroundAttackState { attack_kind, grounded_common: grounded_common.clone(), diff --git a/src/input/buffer.rs b/src/input/buffer.rs index 0c95838..82c5241 100644 --- a/src/input/buffer.rs +++ b/src/input/buffer.rs @@ -9,18 +9,50 @@ use bevy_ggrs::{PlayerInputs, Rollback}; use strum::IntoEnumIterator; use strum_macros::EnumIter; +use crate::fighter::attack::AttackKind; use crate::game_settings::GameSettings; use crate::input::FighterInputFrame; +use crate::math::int::FGi32; use crate::netcode::GGRSCfg; use crate::player::Player; #[derive(Clone, Copy, EnumIter, Debug)] pub enum FighterCommands { - SmashMoveLeft, - SmashMoveRight, Jump, Shield, Attack, + SmashMoveLeft, + SmashMoveRight, +} + +#[derive(Default, Clone, Hash, Copy)] +pub enum FighterAttackCommandType { + #[default] + Neutral, + SmashAttackUp, + SmashAttackDown, + SmashAttackLeft, + SmashAttackRight, + TiltAttackUp, + TiltAttackDown, + TiltAttackLeft, + TiltAttackRight, +} + +impl FighterAttackCommandType { + pub fn to_attack_kind_grounded(&self) -> AttackKind { + match self { + FighterAttackCommandType::Neutral => AttackKind::Jab, + FighterAttackCommandType::SmashAttackUp => AttackKind::UpSmash, + FighterAttackCommandType::SmashAttackDown => AttackKind::DownSmash, + FighterAttackCommandType::SmashAttackLeft => AttackKind::ForwardSmash, + FighterAttackCommandType::SmashAttackRight => AttackKind::ForwardSmash, + FighterAttackCommandType::TiltAttackUp => AttackKind::UpTilt, + FighterAttackCommandType::TiltAttackDown => AttackKind::DownTilt, + FighterAttackCommandType::TiltAttackLeft => AttackKind::ForwardTilt, + FighterAttackCommandType::TiltAttackRight => AttackKind::ForwardTilt, + } + } } /// Frames of life remaining for each buffered command. Indexed by @@ -66,7 +98,9 @@ pub struct FighterInput { command_lifetimes: FighterCommandLifetimes, prev_frame: FighterInputFrame, current_frame: FighterInputFrame, - frames_in_smash_move_deadzone: u8, + frames_in_x_flick_deadzone: u8, + frames_in_y_flick_deadzone: u8, + attack_command: FighterAttackCommandType, } impl FighterInput { @@ -100,7 +134,15 @@ impl FighterInput { } pub fn get_frames_in_smash_move_deadzone(&self) -> u8 { - self.frames_in_smash_move_deadzone + self.frames_in_x_flick_deadzone + } + + pub fn set_attack_command_type(&mut self, command: FighterAttackCommandType) { + self.attack_command = command; + } + + pub fn get_attack_command_type(&self) -> FighterAttackCommandType { + self.attack_command } } @@ -120,21 +162,35 @@ pub fn postprocess_input( // Directional smash detection let x_abs = input.current_frame.movement.x.abs(); + let y_abs = input.current_frame.movement.y.abs(); let reset_deadzone = game_settings.input_common.smash_input_reset_axis_threshold; let axis_threshold = game_settings.input_common.smash_input_axis_threshold; let frame_threshold = game_settings.input_common.smash_input_frame_threshold; if x_abs < reset_deadzone { - input.frames_in_smash_move_deadzone = 0xFE; + input.frames_in_x_flick_deadzone = 0xFE; } else { let prev_x_abs = input.prev_frame.movement.x.abs(); if prev_x_abs < reset_deadzone || input.prev_frame.movement.x.signum() != input.current_frame.movement.x.signum() { - input.frames_in_smash_move_deadzone = 1; + input.frames_in_x_flick_deadzone = 1; } else { - input.frames_in_smash_move_deadzone = input.frames_in_smash_move_deadzone.saturating_add(1); + input.frames_in_x_flick_deadzone = input.frames_in_x_flick_deadzone.saturating_add(1); + } + } + + if y_abs < reset_deadzone { + input.frames_in_y_flick_deadzone = 0xFE; + } else { + let prev_y_abs = input.prev_frame.movement.y.abs(); + if prev_y_abs < reset_deadzone + || input.prev_frame.movement.y.signum() != input.current_frame.movement.y.signum() + { + input.frames_in_y_flick_deadzone = 1; + } else { + input.frames_in_y_flick_deadzone = input.frames_in_y_flick_deadzone.saturating_add(1); } } @@ -159,7 +215,67 @@ pub fn postprocess_input( ); } - if x_abs >= axis_threshold && input.frames_in_smash_move_deadzone <= frame_threshold { + if x_abs >= axis_threshold && input.frames_in_x_flick_deadzone <= frame_threshold { + input.clear_command(FighterCommands::SmashMoveLeft); + input.clear_command(FighterCommands::SmashMoveRight); + + let command_to_set = if input.current_frame.movement.x > 0.0 { + FighterCommands::SmashMoveRight + } else { + FighterCommands::SmashMoveLeft + }; + + input.set_lifetime(command_to_set, game_settings.input_common.input_buffer_size); + } + + let mut y_flick_detected = y_abs >= axis_threshold && input.frames_in_y_flick_deadzone <= frame_threshold; + let mut x_flick_detected = x_abs >= axis_threshold && input.frames_in_x_flick_deadzone <= frame_threshold; + + // if both flicks are detected, only one wins + + if x_flick_detected && y_flick_detected { + x_flick_detected = x_abs > y_abs; + y_flick_detected = !x_flick_detected; + } + + let attack_detected = input.current_frame.attack && !input.prev_frame.attack; + + // Tilt/smash up/down + if attack_detected { + let attack_type = if y_flick_detected { + if input.current_frame.movement.y > 0.0 { + FighterAttackCommandType::SmashAttackUp + } else { + FighterAttackCommandType::SmashAttackDown + } + } else if x_flick_detected { + if input.current_frame.movement.x > 0.0 { + FighterAttackCommandType::SmashAttackRight + } else { + FighterAttackCommandType::SmashAttackLeft + } + } else if x_abs > y_abs { + if input.current_frame.movement.x.is_positive() { + FighterAttackCommandType::TiltAttackRight + } else { + FighterAttackCommandType::TiltAttackLeft + } + } else if y_abs > x_abs { + if input.current_frame.movement.y.is_positive() { + FighterAttackCommandType::TiltAttackUp + } else { + FighterAttackCommandType::TiltAttackDown + } + } else { + FighterAttackCommandType::Neutral + }; + + input.set_lifetime(FighterCommands::Attack, game_settings.input_common.input_buffer_size); + input.set_attack_command_type(attack_type); + } + + + if x_flick_detected { input.clear_command(FighterCommands::SmashMoveLeft); input.clear_command(FighterCommands::SmashMoveRight);