From 4570860d64807b3900b5975e58490eb731d34b46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lex=20Rom=C3=A1n=20N=C3=BA=C3=B1ez?= Date: Sun, 13 Sep 2026 23:01:25 +0200 Subject: [PATCH] Implement hitstun and knockback. --- src/args.rs | 2 + src/fighter/animation.rs | 15 +- src/fighter/attack.rs | 484 ++++++++++++++++++++++------- src/fighter/baked_animation.rs | 6 +- src/fighter/debug.rs | 86 ++++- src/fighter/errors.rs | 7 + src/fighter/mod.rs | 87 ++++-- src/fighter/state/air.rs | 10 +- src/fighter/state/air_dodge.rs | 18 +- src/fighter/state/dash.rs | 2 +- src/fighter/state/fall.rs | 5 +- src/fighter/state/ground_attack.rs | 23 +- src/fighter/state/hitstun.rs | 117 +++++++ src/fighter/state/jump.rs | 19 +- src/fighter/state/land.rs | 14 +- src/fighter/state/mod.rs | 123 +++++--- src/fighter/state/turn.rs | 17 +- src/fighter/state/walk.rs | 1 - src/game_settings.rs | 4 + src/input/gamecube/bridge.rs | 8 +- src/input/mod.rs | 2 +- src/match_loading.rs | 19 +- src/math/int.rs | 4 +- src/math/segment.rs | 3 - src/schedule.rs | 3 + src/stage/line.rs | 7 + 26 files changed, 840 insertions(+), 246 deletions(-) create mode 100644 src/fighter/errors.rs create mode 100644 src/fighter/state/hitstun.rs diff --git a/src/args.rs b/src/args.rs index cb38f21..ef93291 100644 --- a/src/args.rs +++ b/src/args.rs @@ -5,6 +5,8 @@ use std::path::PathBuf; #[derive(Parser, Debug, Clone, Resource)] #[command(version, about, long_about = None)] pub struct Args { + #[clap(long)] + pub debug_match: bool, #[clap(long)] pub synctest: bool, #[clap(long, default_value_t = 2)] diff --git a/src/fighter/animation.rs b/src/fighter/animation.rs index c11a159..567c223 100644 --- a/src/fighter/animation.rs +++ b/src/fighter/animation.rs @@ -1,7 +1,7 @@ use bevy::prelude::*; use strum_macros::EnumIter; -use crate::fighter::{FighterFacingDirection, FighterVisual}; +use crate::fighter::{FighterFacingDirection, FighterHitstop, FighterVisual}; /// The simulation-owned animation clock. Rendering may interpolate freely, but /// gameplay samples baked data only with this rollback-tracked value. @@ -32,9 +32,13 @@ impl FighterAnimationFrame { } } -pub fn advance_fighter_animation_frames(mut fighters: Query<&mut FighterAnimationFrame>) { - for mut animation in &mut fighters { - animation.advance(); +pub fn advance_fighter_animation_frames( + mut fighters: Query<(&mut FighterAnimationFrame, &FighterHitstop)>, +) { + for (mut animation, hitstop) in &mut fighters { + if hitstop.remaining_frames == 0 { + animation.advance(); + } } } @@ -81,6 +85,9 @@ pub enum AnimKind { Fall, Turn, + FlinchGround, + FlinchAir, + // Attacks AttackJab1, AttackUpTilt, diff --git a/src/fighter/attack.rs b/src/fighter/attack.rs index 7d6d0c1..da49d0c 100644 --- a/src/fighter/attack.rs +++ b/src/fighter/attack.rs @@ -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); + 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, +} + +#[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); +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 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); - 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); +#[derive(Component, Clone, Debug, Default, Hash, DerefMut, Deref)] +pub struct FighterReceivedAttacks( + pub Vec<( + /* attacker */ Entity, + Handle, + 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, - 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>, ) { - 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>, + 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 { @@ -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), ); } } diff --git a/src/fighter/baked_animation.rs b/src/fighter/baked_animation.rs index 4b0fc2a..12eeaab 100644 --- a/src/fighter/baked_animation.rs +++ b/src/fighter/baked_animation.rs @@ -115,9 +115,9 @@ impl FixedMat4 { /// Replaces the rotation with XYZ Euler angles expressed in radians. pub fn with_rotation(mut self, rotation: FGVec3) -> Self { - let (sin_x, cos_x) = rotation.x.sin_cos(); - let (sin_y, cos_y) = rotation.y.sin_cos(); - let (sin_z, cos_z) = rotation.z.sin_cos(); + let (sin_x, cos_x) = rotation.x.sin_cos_rads(); + let (sin_y, cos_y) = rotation.y.sin_cos_rads(); + let (sin_z, cos_z) = rotation.z.sin_cos_rads(); self.cols[0][0] = cos_y * cos_z; self.cols[0][1] = cos_x * sin_z + sin_x * sin_y * cos_z; self.cols[0][2] = sin_x * sin_z - cos_x * sin_y * cos_z; diff --git a/src/fighter/debug.rs b/src/fighter/debug.rs index 3033310..2143c5d 100644 --- a/src/fighter/debug.rs +++ b/src/fighter/debug.rs @@ -8,20 +8,26 @@ use bevy_egui::{EguiContexts, egui}; use crate::debug_tools::{DebugSettings, FighterDebugRowKind}; use crate::fighter::animation::FighterAnimationFrame; -use crate::fighter::attack::FighterSolvedHurtboxes; +use crate::fighter::attack::{ + FighterKnockbackVelocity, FighterLastAttackDebug, FighterPendingHitReaction, + FighterSolvedHurtboxes, +}; use crate::fighter::baked_animation::{BakedFighterAnimations, FighterBoneMatrices}; use crate::fighter::ecb::FighterPreviousECB; +use crate::fighter::errors::FighterErrors; use crate::fighter::hurtbox::FixedAffineCapsule; -use crate::fighter::state::StateNameDebug; +use crate::fighter::manifest::FighterManifest; +use crate::fighter::state::{FighterState, air}; use crate::fighter::visual::FighterAnimations; use crate::fighter::{ - FighterECB, FighterFacingDirection, FighterHitboxes, FighterPreviousTranslation, + Fighter, FighterECB, FighterFacingDirection, FighterHitboxes, FighterPreviousTranslation, FighterTranslation, FighterVelocity, }; +use crate::game_settings::GameSettings; use crate::input::FighterInput; use crate::math::int::FGi32; +use crate::math::vec::FGVec2; use crate::player::Player; -use crate::scripting::FighterAttackScript; pub fn debug_draw_ecb( ecbs: Query<( @@ -51,7 +57,7 @@ pub fn fighter_debug( &FighterVelocity, &FighterInput, &FighterAnimationFrame, - &StateNameDebug, + &FighterState, &FighterAnimations, &FighterTranslation, &FighterFacingDirection, @@ -62,7 +68,7 @@ pub fn fighter_debug( let mut fighters = query.iter().collect::>(); fighters.sort_unstable_by_key(|(player, ..)| player.handle); - for (player, velocity, input, animation_frame, state_name, animations, translation, facing) in + for (player, velocity, input, animation_frame, state, animations, translation, facing) in fighters { let Some(rows) = settings.player_rows(player.handle) else { @@ -85,7 +91,7 @@ pub fn fighter_debug( if let Some(frame_count) = frame_count { ui.label(format!( "State: {:?} ({:?} {}/{})", - state_name.0, + state.name(), animation_frame.kind, animation_frame.frame, frame_count @@ -93,7 +99,8 @@ pub fn fighter_debug( } else { ui.label(format!( "State: {:?} ({:?})", - state_name.0, animation_frame.kind + state.name(), + animation_frame.kind )); } } @@ -334,12 +341,11 @@ pub fn attack_debug( &FighterFacingDirection, &FighterAnimationFrame, )>, - _attack_scripts: Res>, mut gizmos: Gizmos, ) { for (hitboxes, _matrices, _translation, _facing_direction, _frame) in query { - if let Some(_script) = &hitboxes.attack_script { - for solved_hitbox in hitboxes.active_hitboxes_solved.iter() { + if let Some(active_attack) = &hitboxes.0 { + for solved_hitbox in active_attack.solved_hitboxes.iter() { let start = solved_hitbox.capsule.start.to_vec3(); let end = solved_hitbox.capsule.end.to_vec3(); let center = @@ -369,3 +375,61 @@ pub fn attack_debug( } } } + +pub fn attack_debug_gather( + query: Query<( + Entity, + &FighterPendingHitReaction, + &FighterTranslation, + &Fighter, + )>, + mut commands: Commands, + manifests: Res>, + game_settings: Res, +) -> Result { + for (entity, reaction, translation, fighter) in query { + let Some(ref hit) = reaction.0 else { + commands.entity(entity).remove::(); + continue; + }; + + let manifest = manifests + .get(&fighter.manifest) + .ok_or(FighterErrors::FighterManifestNotFound)?; + + let mut knockback_vel = FighterKnockbackVelocity(hit.launch_velocity); + let mut fighter_vel = FighterVelocity(FGVec2::ZERO); + let mut translation = Vec3::new(translation.x.to_num(), translation.y.to_num(), 0.0); + + let mut points = vec![]; + points.push(translation); + points.reserve(hit.hitstun_frames as usize); + + for _ in 1..hit.hitstun_frames { + air::integrate_gravity(&mut fighter_vel, &manifest.attributes); + + let vel = knockback_vel.length(); + let new_vel = vel + .saturating_sub(game_settings.fighter_common.knockback_decay_per_frame) + .max(FGi32::ZERO); + knockback_vel = FighterKnockbackVelocity(knockback_vel.0.normalize_or_zero() * new_vel); + translation += Vec3::new(knockback_vel.x.to_num(), knockback_vel.y.to_num(), 0.0); + translation += Vec3::new(fighter_vel.x.to_num(), fighter_vel.y.to_num(), 0.0); + points.push(translation); + } + commands.entity(entity).insert(FighterLastAttackDebug { + trajectory_points: points, + }); + } + + Ok(()) +} + +pub fn attack_launch_debug_draw(query: Query<&FighterLastAttackDebug>, mut gizmos: Gizmos) { + for debug_attack in query { + gizmos.linestrip( + debug_attack.trajectory_points.iter().copied(), + bevy::color::palettes::css::GREEN, + ); + } +} diff --git a/src/fighter/errors.rs b/src/fighter/errors.rs new file mode 100644 index 0000000..2d82b7c --- /dev/null +++ b/src/fighter/errors.rs @@ -0,0 +1,7 @@ +use bevy::reflect::Reflect; + +#[derive(thiserror::Error, Debug, Reflect)] +pub enum FighterErrors { + #[error("Fighter manifest not found")] + FighterManifestNotFound, +} diff --git a/src/fighter/mod.rs b/src/fighter/mod.rs index 7c2edb8..7db4ffd 100644 --- a/src/fighter/mod.rs +++ b/src/fighter/mod.rs @@ -13,6 +13,7 @@ pub mod baked_animation; pub mod collision; pub mod debug; pub mod ecb; +pub mod errors; pub mod hurtbox; pub mod manifest; pub mod motion; @@ -32,22 +33,17 @@ pub use motion::{ }; use crate::{ - fighter::{ + AppState, fighter::{ attack::{ - FighterAttackPlugin, FighterAttackScriptAssets, FighterSolvedHitboxes, - FighterSolvedHurtboxes, + AttackKind, FighterAttackPlugin, FighterAttackScriptAssets, FighterDamage, + FighterKnockbackVelocity, FighterPendingHitReaction, FighterReceivedAttacks, + FighterSolvedHurtboxes, FighterStaleMoveQueue, }, baked_animation::{FighterBoneIndexMap, FighterBoneMatrices}, manifest::FighterManifest, state::{FighterState, fall::FallState}, visual::FighterAnimations, - }, - input::FighterInput, - math::{int::FGi32, vec::FGVec2}, - player::Player, - schedule::GameplaySet, - scripting::FighterAttackScript, - stage::line::StageCollision, + }, input::FighterInput, math::{int::FGi32, vec::FGVec2}, player::Player, schedule::GameplaySet, scripting::FighterAttackScript, stage::line::StageCollision, }; use state::state_interrupt_system; @@ -62,6 +58,11 @@ pub enum FighterFacingDirection { Right, } +#[derive(Component, Default, Copy, Clone, Hash)] +pub struct FighterHitstop { + pub remaining_frames: u32, +} + impl FighterFacingDirection { fn reverse(&self) -> Self { match self { @@ -107,6 +108,14 @@ impl FighterFacingDirection { pub struct FighterVisual; #[derive(Component)] +#[require( + FighterReceivedAttacks, + FighterStaleMoveQueue, + FighterDamage, + FighterPendingHitReaction, + FighterHitstop, + FighterKnockbackVelocity +)] pub struct Fighter { pub manifest: Handle, } @@ -130,7 +139,9 @@ impl Plugin for FighterPlugin { ) .add_systems( GgrsSchedule, - (state::state_update_system,).in_set(GameplaySet::StateUpdate), + (state::state_update_system, decay_hitstop) + .chain() + .in_set(GameplaySet::StateUpdate), ) /*.add_systems( GgrsSchedule, @@ -156,6 +167,8 @@ impl Plugin for FighterPlugin { // is a desync waiting to happen. .rollback_component_with_copy::() .checksum_component_with_hash::() + .rollback_component_with_clone::() + .checksum_component_with_hash::() .rollback_component_with_copy::() .checksum_component_with_hash::() .rollback_component_with_copy::() @@ -171,6 +184,16 @@ impl Plugin for FighterPlugin { .rollback_component_with_copy::() .checksum_component_with_hash::() .rollback_component_with_copy::() + .rollback_component_with_copy::() + .checksum_component_with_hash::() + .rollback_component_with_clone::() + .checksum_component_with_hash::() + .rollback_component_with_copy::() + .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( @@ -187,7 +210,13 @@ impl Plugin for FighterPlugin { ) .add_systems( FixedPostUpdate, - debug::attack_debug.run_if(crate::debug_tools::attack_hitboxes_enabled), + ( + debug::attack_debug_gather, + (debug::attack_debug, debug::attack_launch_debug_draw) + .after(debug::attack_debug_gather), + ) + .chain() + .run_if(crate::debug_tools::attack_hitboxes_enabled).run_if(in_state(AppState::InMatch)), ) .add_systems( FixedPostUpdate, @@ -204,23 +233,17 @@ impl Plugin for FighterPlugin { .register_type::(); } } - -#[derive(Component, Clone, Hash)] -pub struct FighterHitboxes { - pub attack_script: Option>, +#[derive(Clone, Debug, Hash)] +pub struct FighterActiveAttack { + pub kind: AttackKind, + pub script: Handle, pub active_hitboxes: Vec, - pub active_hitboxes_solved: Vec, + pub solved_hitboxes: Vec, pub hit_fighters: Vec, } -impl FighterHitboxes { - pub fn clear(&mut self) { - self.attack_script = None; - self.active_hitboxes.clear(); - self.active_hitboxes_solved.clear(); - self.hit_fighters.clear(); - } -} +#[derive(Component, Clone, Debug, Default, Hash)] +pub struct FighterHitboxes(pub Option); pub fn spawn_fighter( commands: &mut Commands, @@ -247,12 +270,7 @@ pub fn spawn_fighter( vertical_half: FGi32::lit("5.0"), horizontal_half: FGi32::lit("2.5"), }, - FighterHitboxes { - attack_script: None, - active_hitboxes: vec![], - active_hitboxes_solved: vec![], - hit_fighters: vec![], - }, + FighterHitboxes::default(), ), ( FighterVelocity::default(), @@ -268,7 +286,6 @@ pub fn spawn_fighter( baked_animation::FighterBoneMatrices::default(), FighterBoneIndexMap::default(), FighterSolvedHurtboxes::default(), - FighterSolvedHitboxes::default(), FighterVisual, FighterInput::default(), crate::camera::FighterCameraExtents::default(), @@ -276,3 +293,9 @@ pub fn spawn_fighter( )) .id() } + +pub fn decay_hitstop(query: Query<&mut FighterHitstop>) { + for mut hitstop in query { + hitstop.remaining_frames = hitstop.remaining_frames.saturating_sub(1); + } +} diff --git a/src/fighter/state/air.rs b/src/fighter/state/air.rs index 9e6ac76..9557897 100644 --- a/src/fighter/state/air.rs +++ b/src/fighter/state/air.rs @@ -18,11 +18,11 @@ pub fn apply_air_drift( velocity.x += (-velocity_sign) * attribs.air_friction; } } else { - let target_velocity = - input.get_last_frame().movement.x * attribs.air_drift_max; - let mut acceleration = - input.get_last_frame().movement.x.signum() * attribs.air_acceleration_base + input.get_last_frame().movement.x * attribs.air_acceleration_stick; - + let target_velocity = input.get_last_frame().movement.x * attribs.air_drift_max; + let mut acceleration = input.get_last_frame().movement.x.signum() + * attribs.air_acceleration_base + + input.get_last_frame().movement.x * attribs.air_acceleration_stick; + // Acceleration opposes velocity if acceleration * velocity.x < 0 { // do nothing diff --git a/src/fighter/state/air_dodge.rs b/src/fighter/state/air_dodge.rs index 379c1e3..2399059 100644 --- a/src/fighter/state/air_dodge.rs +++ b/src/fighter/state/air_dodge.rs @@ -1,9 +1,16 @@ use crate::{ fighter::{ - animation::AnimKind, collision, state::{ - FighterState, FighterStateContext, FighterStateImpl, air, fall::FallState, ground::GroundedStateCommon, land::{LandingState, LandingType}, + animation::AnimKind, + collision, + state::{ + FighterState, FighterStateContext, FighterStateImpl, air, + fall::FallState, + ground::GroundedStateCommon, + land::{LandingState, LandingType}, }, - }, input, math::vec::FGVec2, + }, + input, + math::vec::FGVec2, }; #[derive(Debug, Clone, Hash)] @@ -42,7 +49,10 @@ impl FighterStateImpl for AirDodgeState { let grounded_common = GroundedStateCommon { current_line_id: res.line_id, }; - Some(FighterState::Land(LandingState { grounded_common, landing_type: LandingType::Special })) + Some(FighterState::Land(LandingState { + grounded_common, + landing_type: LandingType::Special, + })) } else { None } diff --git a/src/fighter/state/dash.rs b/src/fighter/state/dash.rs index 872feab..71b9b2a 100644 --- a/src/fighter/state/dash.rs +++ b/src/fighter/state/dash.rs @@ -11,7 +11,7 @@ use crate::input::FighterCommands::{SmashMoveLeft, SmashMoveRight}; #[derive(Debug, Clone)] pub struct DashState { direction: FighterFacingDirection, - ground_common: GroundedStateCommon, + pub ground_common: GroundedStateCommon, } impl std::hash::Hash for DashState { diff --git a/src/fighter/state/fall.rs b/src/fighter/state/fall.rs index 117ee4a..5c02437 100644 --- a/src/fighter/state/fall.rs +++ b/src/fighter/state/fall.rs @@ -45,7 +45,10 @@ impl FighterStateImpl for FallState { let grounded_common = GroundedStateCommon { current_line_id: res.line_id, }; - Some(FighterState::Land(LandingState::create(grounded_common, LandingType::Normal))) + Some(FighterState::Land(LandingState::create( + grounded_common, + LandingType::Normal, + ))) } else { None } diff --git a/src/fighter/state/ground_attack.rs b/src/fighter/state/ground_attack.rs index e78d099..046b0db 100644 --- a/src/fighter/state/ground_attack.rs +++ b/src/fighter/state/ground_attack.rs @@ -26,14 +26,19 @@ impl FighterStateImpl for GroundAttackState { } fn update(&mut self, state_context: &mut super::FighterStateContext) { + let animation_frame = state_context.get_current_animation_frame(); let script = state_context .attack_scripts .get(&state_context.attack_script_assets.scripts[&self.attack_kind]); if let Some(script) = script { - let animation_frame = state_context.get_current_animation_frame(); update_active_hitbox_list( script, - &mut state_context.hitboxes.active_hitboxes, + &mut state_context + .hitboxes + .0 + .as_mut() + .expect("ground attack must have active hitboxes") + .active_hitboxes, animation_frame, ); } @@ -41,13 +46,19 @@ impl FighterStateImpl for GroundAttackState { fn on_enter(&mut self, state_context: &mut super::FighterStateContext) { state_context.play_animation(self.attack_kind.get_animation(), false); - state_context.hitboxes.attack_script = - Some(state_context.attack_script_assets.scripts[&self.attack_kind].clone()); - state_context.hitboxes.active_hitboxes.clear(); + let script = state_context.attack_script_assets.scripts[&self.attack_kind].clone(); + state_context.stale_move_queue.push(self.attack_kind); + state_context.hitboxes.0 = Some(crate::fighter::FighterActiveAttack { + kind: self.attack_kind, + script, + active_hitboxes: Vec::new(), + solved_hitboxes: Vec::new(), + hit_fighters: Vec::new(), + }); } fn on_exit(&mut self, state_context: &mut super::FighterStateContext) { - state_context.hitboxes.clear(); + state_context.hitboxes.0 = None; } fn check_collision_interrupt( diff --git a/src/fighter/state/hitstun.rs b/src/fighter/state/hitstun.rs new file mode 100644 index 0000000..2e0af3a --- /dev/null +++ b/src/fighter/state/hitstun.rs @@ -0,0 +1,117 @@ +use crate::{ + fighter::{ + animation::AnimKind, + attack::FighterHitReaction, + collision, + state::{ + FighterState, FighterStateContext, FighterStateImpl, air, + fall::FallState, + ground::{GroundedMotionResult, GroundedStateCommon, collide_with_stage_grounded}, + land::{LandingState, LandingType}, + wait::WaitState, + }, + }, + math::int::FGi32, +}; + +#[derive(Clone, Debug, Hash)] +pub enum HitstopStateKind { + Grounded(GroundedStateCommon), + Airborne, +} + +#[derive(Clone, Debug, Hash)] +pub struct HitstunState { + pub hit_reaction: FighterHitReaction, + pub hitstop_kind: HitstopStateKind, +} + +impl FighterStateImpl for HitstunState { + const NAME: &'static str = "Hitstun"; + + fn name(&self) -> String { + let hitstop_kind = match self.hitstop_kind { + HitstopStateKind::Grounded(_) => "Grounded", + HitstopStateKind::Airborne => "Airborne", + }; + + let hit_reaction_type = match &self.hit_reaction.kind { + crate::fighter::attack::HitReactionKind::Flinch => "Flinch", + crate::fighter::attack::HitReactionKind::Tumble => "Tumble", + }; + + format!("Hitstun ({hitstop_kind}, {hit_reaction_type})") + } + + fn check_interrupt(&self, state_context: &mut FighterStateContext) -> Option { + if state_context.get_current_animation_frame() > self.hit_reaction.hitstun_frames { + match &self.hitstop_kind { + HitstopStateKind::Grounded(grounded_state_common) => { + Some(FighterState::Wait(WaitState { + grounded_common: grounded_state_common.clone(), + })) + } + HitstopStateKind::Airborne => Some(FighterState::Fall(FallState)), + } + } else { + None + } + } + + fn update(&mut self, ctx: &mut FighterStateContext) { + air::integrate_gravity(ctx.velocity, &ctx.fighter_manifest.attributes); + + let speed = ctx.knockback_velocity.length(); + let next_speed = speed + .saturating_sub(ctx.game_settings.fighter_common.knockback_decay_per_frame) + .max(FGi32::ZERO); + + ctx.knockback_velocity.0 = ctx.knockback_velocity.normalize_or_zero() * next_speed; + + ctx.translation.0 += ctx.velocity.0 + ctx.knockback_velocity.0; + } + + fn on_enter(&mut self, state_context: &mut FighterStateContext) { + let animation = match self.hitstop_kind { + HitstopStateKind::Grounded(_) => AnimKind::FlinchGround, + HitstopStateKind::Airborne => AnimKind::FlinchAir, + }; + state_context.play_animation(animation, false); + state_context.knockback_velocity.0 = self.hit_reaction.launch_velocity; + } + + fn on_exit(&mut self, _state_context: &mut FighterStateContext) {} + + fn check_collision_interrupt( + &mut self, + state_context: &mut FighterStateContext, + ) -> Option { + match &mut self.hitstop_kind { + HitstopStateKind::Grounded(grounded_state_common) => { + if let GroundedMotionResult::InAir = + collide_with_stage_grounded(state_context, grounded_state_common, true) + { + Some(FighterState::Fall(FallState {})) + } else { + None + } + } + HitstopStateKind::Airborne => { + if let Some(res) = collision::air_collide_with_stage(state_context) { + // Adjust translation + state_context.translation.0 = + res.hit_position - state_context.ecb.get_bottom_point(); + let grounded_common = GroundedStateCommon { + current_line_id: res.line_id, + }; + Some(FighterState::Land(LandingState::create( + grounded_common, + LandingType::Normal, + ))) + } else { + None + } + } + } + } +} diff --git a/src/fighter/state/jump.rs b/src/fighter/state/jump.rs index 53ecc87..5aa6449 100644 --- a/src/fighter/state/jump.rs +++ b/src/fighter/state/jump.rs @@ -2,10 +2,18 @@ use bevy::prelude::*; use crate::{ fighter::{ - animation::AnimKind, collision, state::{ - FighterState, FighterStateContext, FighterStateImpl, air, air_dodge::{self, AirDodgeState}, fall::FallState, ground::{self, GroundedMotionResult, GroundedStateCommon}, land::{LandingState, LandingType}, + animation::AnimKind, + collision, + state::{ + FighterState, FighterStateContext, FighterStateImpl, air, + air_dodge::{self, AirDodgeState}, + fall::FallState, + ground::{self, GroundedMotionResult, GroundedStateCommon}, + land::{LandingState, LandingType}, }, - }, input::FighterCommands, math::{int::FGi32, vec::FGVec2}, + }, + input::FighterCommands, + math::{int::FGi32, vec::FGVec2}, }; #[derive(Debug, Hash, Clone, Copy)] pub enum JumpType { @@ -231,7 +239,10 @@ impl FighterStateImpl for JumpState { let grounded_common = GroundedStateCommon { current_line_id: res.line_id, }; - Some(FighterState::Land(LandingState::create(grounded_common, LandingType::Normal))) + Some(FighterState::Land(LandingState::create( + grounded_common, + LandingType::Normal, + ))) } else { None } diff --git a/src/fighter/state/land.rs b/src/fighter/state/land.rs index 0b37788..195c920 100644 --- a/src/fighter/state/land.rs +++ b/src/fighter/state/land.rs @@ -14,7 +14,7 @@ use crate::fighter::{ #[derive(Debug, Clone, Hash)] pub enum LandingType { Normal, - Special + Special, } #[derive(Debug, Clone, Hash)] @@ -25,7 +25,10 @@ pub struct LandingState { impl LandingState { pub fn create(grounded_common: GroundedStateCommon, landing_type: LandingType) -> Self { - Self { grounded_common, landing_type } + Self { + grounded_common, + landing_type, + } } } @@ -36,7 +39,8 @@ impl FighterStateImpl for LandingState { if state_context.is_current_animation_finished() { grounded_movement_standstill_common_interrupts(state_context, &self.grounded_common) } else if state_context.get_current_animation_frame() - >= state_context.fighter_manifest.attributes.landing_iasa && let LandingType::Normal = self.landing_type + >= state_context.fighter_manifest.attributes.landing_iasa + && let LandingType::Normal = self.landing_type { dash::check_input(state_context, &self.grounded_common).or_else(|| { ground::grounded_movement_common_interrupts(state_context, &self.grounded_common) @@ -88,10 +92,10 @@ impl FighterStateImpl for LandingState { match self.landing_type { LandingType::Normal => { state_context.play_animation(AnimKind::Landing, false); - }, + } LandingType::Special => { state_context.play_animation(AnimKind::LandingSpecial, false); - }, + } }; } } diff --git a/src/fighter/state/mod.rs b/src/fighter/state/mod.rs index b17e62a..b68118b 100644 --- a/src/fighter/state/mod.rs +++ b/src/fighter/state/mod.rs @@ -6,14 +6,18 @@ use bevy::ecs::query::QueryData; use bevy::prelude::*; use crate::fighter::animation::{AnimKind, FighterAnimationFrame}; -use crate::fighter::attack::FighterAttackScriptAssets; +use crate::fighter::attack::{ + FighterAttackScriptAssets, FighterKnockbackVelocity, FighterPendingHitReaction, + FighterStaleMoveQueue, +}; use crate::fighter::baked_animation::BakedFighterAnimations; use crate::fighter::ecb::FighterPreviousECB; use crate::fighter::manifest::FighterManifest; +use crate::fighter::state::hitstun::HitstunState; use crate::fighter::visual::FighterAnimations; use crate::fighter::{ - Fighter, FighterECB, FighterFacingDirection, FighterHitboxes, FighterPreviousTranslation, - FighterTranslation, FighterVelocity, + Fighter, FighterECB, FighterFacingDirection, FighterHitboxes, FighterHitstop, + FighterPreviousTranslation, FighterTranslation, FighterVelocity, }; use crate::game_settings::GameSettings; use crate::input::FighterInput; @@ -26,6 +30,7 @@ pub mod dash; pub mod fall; pub mod ground; pub mod ground_attack; +pub mod hitstun; pub mod jump; pub mod land; pub mod run; @@ -74,6 +79,9 @@ pub trait FighterStateImpl: Sized { fn on_enter(&mut self, _state_context: &mut FighterStateContext) {} fn on_exit(&mut self, _state_context: &mut FighterStateContext) {} + fn name(&self) -> String { + Self::NAME.to_owned() + } fn update(&mut self, _state_context: &mut FighterStateContext); fn check_collision_interrupt( @@ -90,6 +98,8 @@ pub struct FighterStateContext<'a> { pub input: &'a mut FighterInput, pub hitboxes: &'a mut FighterHitboxes, pub velocity: &'a mut FighterVelocity, + pub knockback_velocity: &'a mut FighterKnockbackVelocity, + pub stale_move_queue: &'a mut FighterStaleMoveQueue, pub ecb: &'a mut FighterECB, pub prev_ecb: &'a mut FighterPreviousECB, pub facing_direction: &'a mut FighterFacingDirection, @@ -136,6 +146,7 @@ fighter_states! { AirDodge => air_dodge::AirDodgeState, Turn => turn::TurnState, GroundAttack => ground_attack::GroundAttackState, + Hitstun => hitstun::HitstunState, } #[derive(QueryData)] @@ -146,6 +157,8 @@ pub struct FighterFrameQuery { pub input: &'static mut FighterInput, pub hitboxes: &'static mut FighterHitboxes, pub velocity: &'static mut FighterVelocity, + pub knockback_velocity: &'static mut FighterKnockbackVelocity, + pub stale_move_queue: &'static mut FighterStaleMoveQueue, pub ecb: &'static mut FighterECB, pub prev_ecb: &'static mut FighterPreviousECB, pub facing_direction: &'static mut FighterFacingDirection, @@ -170,6 +183,8 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> { input: &mut self.input, hitboxes: &mut self.hitboxes, velocity: &mut self.velocity, + knockback_velocity: &mut self.knockback_velocity, + stale_move_queue: &mut self.stale_move_queue, ecb: &mut self.ecb, prev_ecb: &mut self.prev_ecb, facing_direction: &mut self.facing_direction, @@ -188,21 +203,13 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> { #[derive(QueryData)] #[query_data(mutable)] pub struct StatefulFighterQuery { - pub entity: Entity, pub state: &'static mut FighterState, pub frame: FighterFrameQuery, pub animations: &'static FighterAnimations, pub fighter: &'static Fighter, pub attack_script_assets: &'static FighterAttackScriptAssets, -} - -#[derive(Component)] -pub struct StateNameDebug(pub &'static str); - -impl std::fmt::Display for StateNameDebug { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } + pub pending_hit_reaction: &'static mut FighterPendingHitReaction, + pub hitstop: &'static mut FighterHitstop, } pub fn state_update_system( @@ -218,9 +225,14 @@ pub fn state_update_system( mut state, mut frame, animations, + hitstop, .. } = fighter; + if hitstop.remaining_frames > 0 { + continue; + } + let mut update_ctx = frame.context( &game_settings, &stage_collision, @@ -236,6 +248,20 @@ pub fn state_update_system( } } +fn transition_state( + current: &mut FighterState, + mut next: FighterState, + ctx: &mut FighterStateContext, +) { + let old_name = current.name(); + let new_name = next.name(); + + current.on_exit(ctx); + next.on_enter(ctx); + *current = next; + info!("{} -> {}", old_name, new_name); +} + pub fn state_interrupt_system( mut query: Query, stage_collision: Res, @@ -243,16 +269,16 @@ pub fn state_interrupt_system( baked_animations: Res>, fighter_manifests: Res>, attack_scripts: Res>, - mut commands: Commands, ) { for fighter in &mut query { let StatefulFighterQueryItem { - entity, mut state, mut frame, animations, fighter, attack_script_assets, + mut pending_hit_reaction, + mut hitstop, } = fighter; let mut state_context = frame.context( &game_settings, @@ -265,17 +291,43 @@ pub fn state_interrupt_system( .get(&fighter.manifest) .expect("Fighter manifest should be valid"), ); - if let Some(new_state) = state.check_interrupt(&mut state_context) { - let mut new_state = new_state; - let old_state_name = state.name(); - let new_state_name = new_state.name(); - state.on_exit(&mut state_context); - new_state.on_enter(&mut state_context); - commands - .entity(entity) - .insert((new_state, StateNameDebug(new_state_name))); - info!("State {} -> {}", old_state_name, new_state_name); - } + + let new_state = if let Some(reaction) = pending_hit_reaction.take() { + hitstop.remaining_frames = *reaction.hitstop_frames; + let hitstop_kind = match &*state { + FighterState::Wait(value) => { + hitstun::HitstopStateKind::Grounded(value.grounded_common.clone()) + } + FighterState::Walk(value) => { + hitstun::HitstopStateKind::Grounded(value.grounded_common.clone()) + } + FighterState::Dash(value) => { + hitstun::HitstopStateKind::Grounded(value.ground_common.clone()) + } + FighterState::Run(value) => { + hitstun::HitstopStateKind::Grounded(value.grounded_common.clone()) + } + FighterState::Turn(value) => { + hitstun::HitstopStateKind::Grounded(value.grounded_common.clone()) + } + FighterState::GroundAttack(value) => { + hitstun::HitstopStateKind::Grounded(value.grounded_common.clone()) + } + _ => hitstun::HitstopStateKind::Airborne, + }; + Some(FighterState::Hitstun(HitstunState { + hit_reaction: reaction, + hitstop_kind, + })) + } else if hitstop.remaining_frames > 0 { + None + } else { + state.check_interrupt(&mut state_context) + }; + + if let Some(new_state) = new_state { + transition_state(&mut state, new_state, &mut state_context); + }; } } @@ -286,16 +338,16 @@ pub fn state_collision_interrupt_system( baked_animations: Res>, fighter_manifests: Res>, attack_scripts: Res>, - mut commands: Commands, ) { for fighter in &mut query { let StatefulFighterQueryItem { - entity, mut state, mut frame, animations, fighter, attack_script_assets, + hitstop, + .. } = fighter; let mut state_context = frame.context( &game_settings, @@ -308,16 +360,13 @@ pub fn state_collision_interrupt_system( .get(&fighter.manifest) .expect("Fighter manifest should be valid"), ); + + if hitstop.remaining_frames > 0 { + continue; + } + if let Some(new_state) = state.check_collision_interrupt(&mut state_context) { - let mut new_state = new_state; - let old_state_name = state.name(); - let new_state_name = new_state.name(); - state.on_exit(&mut state_context); - new_state.on_enter(&mut state_context); - commands - .entity(entity) - .insert((new_state, StateNameDebug(new_state_name))); - info!("State {} -> {}", old_state_name, new_state_name); + transition_state(&mut state, new_state, &mut state_context); } } } diff --git a/src/fighter/state/turn.rs b/src/fighter/state/turn.rs index 1d53462..9b660e1 100644 --- a/src/fighter/state/turn.rs +++ b/src/fighter/state/turn.rs @@ -7,14 +7,19 @@ use crate::input::FighterCommands; #[derive(Debug, Clone, Hash)] pub struct TurnState { - grounded_common: GroundedStateCommon, + pub grounded_common: GroundedStateCommon, } impl FighterStateImpl for TurnState { const NAME: &'static str = "Turn"; fn check_interrupt(&self, state_context: &mut FighterStateContext) -> Option { // State chain that results in the turn completing - if state_context.get_current_animation_frame() <= state_context.game_settings.input_common.turn_into_dashback_window as u32 { + if state_context.get_current_animation_frame() + <= state_context + .game_settings + .input_common + .turn_into_dashback_window as u32 + { if let Some(state) = dash::check_input(state_context, &self.grounded_common) { return Some(state); } @@ -86,13 +91,17 @@ pub fn check_input( ) -> Option { let input_frame = state_context.input.get_last_frame(); - let has_smash_input = state_context.input.has_command(FighterCommands::SmashMoveLeft) || state_context.input.has_command(FighterCommands::SmashMoveRight); + let has_smash_input = state_context + .input + .has_command(FighterCommands::SmashMoveLeft) + || state_context + .input + .has_command(FighterCommands::SmashMoveRight); if !input_frame.movement.x.is_zero() && input_frame.movement.x.signum() != state_context.facing_direction.to_sign() && !has_smash_input { - dbg!(input_frame.movement.x); return Some(FighterState::Turn(TurnState { grounded_common: grounded_common.clone(), })); diff --git a/src/fighter/state/walk.rs b/src/fighter/state/walk.rs index 7f2def9..693b3dc 100644 --- a/src/fighter/state/walk.rs +++ b/src/fighter/state/walk.rs @@ -3,7 +3,6 @@ use crate::fighter::animation::AnimKind; use crate::fighter::state::FighterState; use crate::fighter::state::fall::FallState; use crate::fighter::state::ground::{GroundedMotionResult, GroundedStateCommon}; -use crate::fighter::state::turn; #[derive(Debug, Clone, Hash)] pub struct WalkState { diff --git a/src/game_settings.rs b/src/game_settings.rs index 7457ee2..9f2f2dd 100644 --- a/src/game_settings.rs +++ b/src/game_settings.rs @@ -11,6 +11,10 @@ pub struct FighterSettingsCommon { pub walk_speed_ease: FGi32, pub ground_max_horizontal_velocity: FGi32, pub ground_friction_over_walk_speed_multiplier: FGi32, + pub knockback_stacking_window: u32, + pub knockback_decay_per_frame: FGi32, + pub knockback_launch_velocity_multiplier: FGi32, + pub hitstun_frames_per_unit_knockback: FGi32, } #[derive(Reflect, Serialize, Deserialize, Clone)] #[reflect(opaque)] diff --git a/src/input/gamecube/bridge.rs b/src/input/gamecube/bridge.rs index e5fb9dc..97e9030 100644 --- a/src/input/gamecube/bridge.rs +++ b/src/input/gamecube/bridge.rs @@ -171,10 +171,10 @@ pub fn gc_event_system( } }; } - digital!(a, GamepadButton::South); - digital!(b, GamepadButton::West); - digital!(x, GamepadButton::East); - digital!(y, GamepadButton::North); + digital!(a, GamepadButton::East); + digital!(b, GamepadButton::South); + digital!(x, GamepadButton::North); + digital!(y, GamepadButton::West); digital!(z, GamepadButton::Z); digital!(start, GamepadButton::Start); digital!(dpad_up, GamepadButton::DPadUp); diff --git a/src/input/mod.rs b/src/input/mod.rs index d4ba78a..2d577d3 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -272,7 +272,7 @@ fn read_local_inputs( Some((_, LocalInputSource::Gamepad(_))) => gamepad .map(|pad| gamepad::sample_gamepad(pad, &input_map)) .unwrap_or_default(), - None => FighterInputFrame::default() + None => FighterInputFrame::default(), }; // preprocess frame diff --git a/src/match_loading.rs b/src/match_loading.rs index 2a309f3..1ba5881 100644 --- a/src/match_loading.rs +++ b/src/match_loading.rs @@ -8,7 +8,9 @@ use bevy_ggrs::prelude::Session; use serde::{Deserialize, Serialize}; use crate::{ - AppState, args::Args, fighter::{ + AppState, + args::Args, + fighter::{ FighterId, FighterRoster, animation::AnimKind, attack::{AttackKind, FighterAttackScriptAssets}, @@ -16,7 +18,12 @@ use crate::{ manifest::{FighterManifest, FighterManifestRegistry}, spawn_fighter, visual::FighterAnimations, - }, input::{LocalInputAssignments, LocalInputSource}, math::{int::FGi32, vec::FGVec2}, netcode::{GGRSCfg, session::create_session}, scripting::FighterAttackScript, stage::{ + }, + input::{LocalInputAssignments, LocalInputSource}, + math::{int::FGi32, vec::FGVec2}, + netcode::{GGRSCfg, session::create_session}, + scripting::FighterAttackScript, + stage::{ self, manifest::{StageId, StageManifest, StageManifestRegistry}, }, @@ -145,6 +152,10 @@ pub fn initiate_default_match( mut next_state: ResMut>, completed: Option>, ) { + if !args.debug_match { + next_state.set(AppState::MainMenu); + return; + } if completed.is_some() { return; } @@ -174,8 +185,8 @@ pub fn initiate_default_match( assigments.push((0, LocalInputSource::Keyboard)); - for (idx, entity) in gamepads.iter().take(args.players-1).enumerate() { - assigments.push((idx+1, LocalInputSource::Gamepad(entity))); + for (idx, entity) in gamepads.iter().take(args.players - 1).enumerate() { + assigments.push((idx + 1, LocalInputSource::Gamepad(entity))); } initiate_match( diff --git a/src/math/int.rs b/src/math/int.rs index f8a6460..9e2476b 100644 --- a/src/math/int.rs +++ b/src/math/int.rs @@ -4,7 +4,7 @@ pub type FGWide = fixed::FixedI128; /// Angle conversions for the game's Q16.16 fixed-point values. pub trait FGi32Ext { fn to_radians(self) -> FGi32; - fn sin_cos(self) -> (FGi32, FGi32); + fn sin_cos_rads(self) -> (FGi32, FGi32); } impl FGi32Ext for FGi32 { @@ -12,7 +12,7 @@ impl FGi32Ext for FGi32 { FGi32::from_num(FGWide::from_num(self) * FGWide::lit("0.01745329251994329576923690768489")) } - fn sin_cos(self) -> (FGi32, FGi32) { + fn sin_cos_rads(self) -> (FGi32, FGi32) { const PI: FGi32 = FGi32::lit("3.14159265358979323846"); const HALF_PI: FGi32 = FGi32::lit("1.57079632679489661923"); const TWO_PI: FGi32 = FGi32::lit("6.28318530717958647692"); diff --git a/src/math/segment.rs b/src/math/segment.rs index f7b390a..1ab3bd1 100644 --- a/src/math/segment.rs +++ b/src/math/segment.rs @@ -130,9 +130,6 @@ impl FGSegment2d { let t = (s2_x * (p0_y - p2_y) - s2_y * (p0_x - p2_x)) / denom; if s >= FGWide::ZERO && s <= FGWide::ONE && t >= FGWide::ZERO && t <= FGWide::ONE { - if self.left_normal().dot(rhs.point2() - rhs.point1()) >= FGi32::ZERO { - return None; - } Some(FGVec2::new( FGi32::from_num(p0_x + t * s1_x), FGi32::from_num(p0_y + t * s1_y), diff --git a/src/schedule.rs b/src/schedule.rs index 6af02d0..3bdb687 100644 --- a/src/schedule.rs +++ b/src/schedule.rs @@ -23,6 +23,8 @@ pub enum GameplaySet { Animation, /// Resolve the integrated positions against the stage. Collision, + /// Resolve attacks + Attacks, } /// Declares the order of [`GameplaySet`] inside [`GgrsSchedule`]. Added by @@ -40,6 +42,7 @@ impl Plugin for GameplaySchedulePlugin { GameplaySet::Physics, GameplaySet::Collision, GameplaySet::Animation, + GameplaySet::Attacks, ) .chain(), ); diff --git a/src/stage/line.rs b/src/stage/line.rs index 3661378..a0a50fb 100644 --- a/src/stage/line.rs +++ b/src/stage/line.rs @@ -58,6 +58,13 @@ impl StagePoly { .iter() .enumerate() .filter_map(|(segment_idx, segment)| { + if segment + .normal + .dot(ray_segment.point2() - ray_segment.point1()) + >= crate::math::int::FGWide::ZERO + { + return None; + } segment .segment .segment_intersection(&ray_segment)