diff --git a/src/fighter/animation.rs b/src/fighter/animation.rs index 98dbb7c..4dfab3a 100644 --- a/src/fighter/animation.rs +++ b/src/fighter/animation.rs @@ -100,4 +100,5 @@ pub enum AnimKind { AttackDownTilt, AttackNeutralAir, AttackForwardAir, + AttackDash, } diff --git a/src/fighter/attack/mod.rs b/src/fighter/attack/mod.rs index 5f60914..1e03d9d 100644 --- a/src/fighter/attack/mod.rs +++ b/src/fighter/attack/mod.rs @@ -221,6 +221,7 @@ pub enum AttackKind { UpSmash, DownSmash, ForwardSmash, + DashAttack, } impl AttackKind { @@ -238,6 +239,7 @@ impl AttackKind { AttackKind::UpSmash => AnimKind::AttackUpTilt, AttackKind::DownSmash => AnimKind::AttackUpTilt, AttackKind::ForwardSmash => AnimKind::AttackUpTilt, + AttackKind::DashAttack => AnimKind::AttackDash, } } } diff --git a/src/fighter/baked_animation/mod.rs b/src/fighter/baked_animation/mod.rs index 0f268bc..644c991 100644 --- a/src/fighter/baked_animation/mod.rs +++ b/src/fighter/baked_animation/mod.rs @@ -364,6 +364,38 @@ mod tests { assert_eq!(matrices.root_motion_delta(), FGVec3::lit("4", "0", "0")); } + #[test] + fn root_motion_translation_is_removed_from_published_pose() { + let names = ["root".into(), "hand".into()]; + let parents = [None, Some(0)]; + let mut root = FixedMat4::IDENTITY; + root.translate(FGVec3::lit("3", "4", "5")); + let mut hand = FixedMat4::IDENTITY; + hand.translate(FGVec3::lit("1", "2", "0")); + let mut matrices = FighterBoneMatrices::default(); + let frame = crate::fighter::animation::FighterAnimationFrame::new(AnimKind::Wait, false); + + matrices.set_pose_with_frame( + frame, + "root", + FGVec3::ZERO, + None, + AnimKind::Wait, + &names, + &parents, + &[root, hand], + FGi32::ONE, + ); + + assert_eq!(matrices.root_motion_delta(), FGVec3::ZERO); + assert_eq!(matrices.local(0).unwrap().get_translation(), FGVec3::ZERO); + assert_eq!(matrices.get_current("root").unwrap().get_translation(), FGVec3::ZERO); + assert_eq!( + matrices.get_current("hand").unwrap().get_translation(), + FGVec3::lit("1", "2", "0") + ); + } + #[test] fn repeating_root_motion_preserves_cycle_displacement_at_wrap() { let names = ["root".into()]; diff --git a/src/fighter/baked_animation/runtime.rs b/src/fighter/baked_animation/runtime.rs index 9b4ee39..c66d28b 100644 --- a/src/fighter/baked_animation/runtime.rs +++ b/src/fighter/baked_animation/runtime.rs @@ -122,6 +122,10 @@ pub struct FighterBoneMatrices { model_matrices: Vec>, presentation_kind: Option, previous_animation_frame: Option, + /// The raw, scaled model-space root translation used as the baseline for + /// root-motion extraction. This is kept separately because the published + /// pose has the root translation removed. + previous_root_motion_translation: Option, root_motion_delta: crate::math::vec3::FGVec3, } @@ -351,6 +355,17 @@ impl FighterBoneMatrices { } self.local_matrices.clear(); self.local_matrices.extend_from_slice(local); + + let root_index = if root_motion_bone.is_empty() { + None + } else { + Some( + self.bone_names + .iter() + .position(|name| name == root_motion_bone) + .expect("configured root motion bone should exist"), + ) + }; self.model_matrices.resize(local.len(), None); self.model_matrices.fill(None); for index in 0..local.len() { @@ -365,6 +380,26 @@ impl FighterBoneMatrices { [FGi32::ZERO, FGi32::ZERO, FGi32::ZERO, FGi32::ONE], ], }; + let raw_root_motion_translation = root_index.map(|root_index| { + model_scale + .mul( + self.model_matrices[root_index] + .expect("baked bone hierarchy should be valid"), + ) + .get_translation() + }); + + // Gameplay owns the root's displacement. Keep rotation and scale in + // the animation, but remove its translation before publishing the pose + // used by visuals and gameplay volumes. + if let Some(root_index) = root_index { + self.local_matrices[root_index].translate(FGVec3::ZERO); + self.model_matrices.fill(None); + for index in 0..local.len() { + resolve_model_transform(index, parents, &self.local_matrices, &mut self.model_matrices) + .expect("baked bone hierarchy should be valid"); + } + } self.matrices.clear(); self.matrices.extend( self.model_matrices.iter().map(|matrix| { @@ -374,15 +409,12 @@ impl FighterBoneMatrices { self.root_motion_delta = if advance_history && !animation_changed && self.previous_animation_frame.is_some() - && !root_motion_bone.is_empty() + && let (Some(current), Some(previous)) = ( + raw_root_motion_translation, + self.previous_root_motion_translation, + ) { - let root_index = self - .bone_names - .iter() - .position(|name| name == root_motion_bone) - .expect("configured root motion bone should exist"); - let mut delta = self.matrices[root_index].get_translation() - - self.previous_frame_matrices[root_index].get_translation(); + let mut delta = current - previous; if animation_frame.repeat && self.previous_animation_frame.is_some_and(|previous| { previous.frame.saturating_add(1) == animation_frame.frame @@ -395,6 +427,7 @@ impl FighterBoneMatrices { } else { FGVec3::ZERO }; + self.previous_root_motion_translation = raw_root_motion_translation; if advance_history { self.previous_animation_frame = Some(animation_frame); } diff --git a/src/fighter/state/dash.rs b/src/fighter/state/dash.rs index a2858de..61c04cf 100644 --- a/src/fighter/state/dash.rs +++ b/src/fighter/state/dash.rs @@ -4,7 +4,7 @@ use super::{ }; use crate::fighter::FighterFacingDirection; use crate::fighter::animation::AnimKind; -use crate::fighter::state::FighterState; +use crate::fighter::state::{FighterState, ground_attack}; use crate::input::FighterCommands::{SmashMoveLeft, SmashMoveRight}; #[derive(Debug, Clone, Hash)] @@ -23,6 +23,16 @@ impl FighterStateImpl for DashState { fn check_interrupt(&self, ctx: &mut InterruptContext) -> Option { let attributes = ctx.fighter_attributes; + if ctx.get_current_animation_frame() <= 2 { + if let Some(state) = ground_attack::check_interrupt(ctx) { + return Some(state); + } + } else { + if let Some(state) = ground_attack::check_interrupt_dash_attack(ctx) { + return Some(state); + } + } + // A reverse dash can interrupt before IASA if let Some(direction) = check_smash_input_with_dir(ctx).filter(|direction| *direction != self.direction) diff --git a/src/fighter/state/ground_attack.rs b/src/fighter/state/ground_attack.rs index 06a535c..80f941b 100644 --- a/src/fighter/state/ground_attack.rs +++ b/src/fighter/state/ground_attack.rs @@ -1,14 +1,20 @@ -use crate::fighter::{ - attack::{AttackKind, update_active_attack}, - state::{ - CollisionContext, EnterContext, ExitContext, FighterState, FighterStateImpl, - InterruptContext, UpdateContext, wait::WaitState, +use bevy::{asset::Assets, log::error}; + +use crate::{ + fighter::{ + attack::{AttackKind, FighterAttackScriptAssets, update_active_attack}, + state::{ + CollisionContext, EnterContext, ExitContext, FighterState, FighterStateImpl, + InterruptContext, UpdateContext, ground, wait::WaitState, + }, }, + scripting::FighterAttackScript, }; #[derive(Clone, Debug, Hash)] pub struct GroundAttackState { pub attack_kind: AttackKind, + pub use_root_motion: bool, } impl FighterStateImpl for GroundAttackState { @@ -37,6 +43,18 @@ impl FighterStateImpl for GroundAttackState { .expect("ground attack must have active hitboxes"), animation_frame, ); + + // root motion + if script.use_root_motion { + state_context.velocity.0.x = state_context.animation_root_motion_delta.x; + ground::apply_grounded_motion( + state_context.velocity, + state_context.translation, + state_context.prev_translation, + ); + } else { + ground::update_friction_motion(state_context); + } } } @@ -48,7 +66,7 @@ impl FighterStateImpl for GroundAttackState { let script = state_context.attack_script_assets.scripts[&self.attack_kind].clone(); state_context.hitboxes.0 = Some(crate::fighter::FighterActiveAttack { kind: self.attack_kind, - script, + script: script.clone(), active_hitboxes: Vec::new(), solved_hitboxes: Vec::new(), hit_groups: Vec::new(), @@ -62,9 +80,9 @@ impl FighterStateImpl for GroundAttackState { fn check_collision_interrupt( &mut self, - _state_context: &mut CollisionContext, + state_context: &mut CollisionContext, ) -> Option { - None + ground::collision_interrupt(state_context, !self.use_root_motion) } fn is_visually_grounded<'a>(&'a self) -> bool { @@ -72,6 +90,51 @@ impl FighterStateImpl for GroundAttackState { } } +impl GroundAttackState { + fn create( + attack_kind: AttackKind, + attack_script_assets: &FighterAttackScriptAssets, + attack_scripts: &Assets, + ) -> Result { + let asset_script = attack_script_assets.scripts.get(&attack_kind).ok_or( + AirAttackStateCreateError::AttackScriptAssetNotFound { + attack: attack_kind, + }, + )?; + + let script = attack_scripts.get(asset_script).ok_or( + AirAttackStateCreateError::AttackScriptNotFound { + attack: attack_kind, + }, + )?; + + Ok(Self { + attack_kind, + use_root_motion: script.use_root_motion + }) + } +} + +#[derive(thiserror::Error, Debug)] +pub enum AirAttackStateCreateError { + #[error("Attack script: {attack:?} not found")] + AttackScriptNotFound { attack: AttackKind }, + #[error("Attack script asset: {attack:?} not found")] + AttackScriptAssetNotFound { attack: AttackKind }, +} + +pub fn check_interrupt_shared(state_context: &InterruptContext, attack_kind: AttackKind) -> Option { + let state = GroundAttackState::create(attack_kind, state_context.attack_script_assets, state_context.attack_scripts); + + match state { + Ok(state) => Some(FighterState::GroundAttack(state)), + Err(create_error) => { + error!("{}", create_error); + None + }, + } +} + pub fn check_interrupt(state_context: &InterruptContext) -> Option { if state_context .input @@ -81,9 +144,18 @@ pub fn check_interrupt(state_context: &InterruptContext) -> Option .input .get_attack_command_type() .to_attack_kind_grounded(); - Some(FighterState::GroundAttack(GroundAttackState { - attack_kind, - })) + check_interrupt_shared(state_context, attack_kind) + } else { + None + } +} + +pub fn check_interrupt_dash_attack(state_context: &InterruptContext) -> Option { + if state_context + .input + .has_command(crate::input::FighterCommands::Attack) + { + check_interrupt_shared(state_context, AttackKind::DashAttack) } else { None } diff --git a/src/fighter/state/mod.rs b/src/fighter/state/mod.rs index 0090ce5..c045966 100644 --- a/src/fighter/state/mod.rs +++ b/src/fighter/state/mod.rs @@ -11,7 +11,7 @@ use crate::fighter::attack::{ FighterStaleMoveQueue, HitStop, }; use crate::fighter::attributes::FighterAttributes; -use crate::fighter::baked_animation::BakedFighterAnimations; +use crate::fighter::baked_animation::{BakedFighterAnimations, FighterBoneMatrices}; use crate::fighter::ecb::FighterPreviousECB; use crate::fighter::manifest::FighterManifest; use crate::fighter::state::hitstun::HitstunState; @@ -29,6 +29,7 @@ use crate::match_loading::MatchConfig; use crate::match_runtime::MatchState; use crate::math::int::FGi32; use crate::math::vec::FGVec2; +use crate::math::vec3::FGVec3; use crate::player::Player; use crate::scripting::FighterAttackScript; use crate::stage::line::StageCollision; @@ -191,6 +192,9 @@ pub struct UpdateContext<'a> { pub knockback_velocity: &'a mut FighterKnockbackVelocity, pub vulnerability: &'a mut FighterInvulnerabilityState, pub game_settings: &'a GameSettings, + /// Root motion in the fighter's gameplay plane: X is world-right and Y is + /// world-up. Animation +Z is forward and is mirrored by facing direction. + pub animation_root_motion_delta: FGVec2, pub animation_frame: u32, pub fighter_attributes: &'a FighterAttributes, pub attack_scripts: &'a Assets, @@ -198,6 +202,13 @@ pub struct UpdateContext<'a> { pub facing_direction: &'a FighterFacingDirection, } +fn animation_root_motion_to_gameplay( + delta: FGVec3, + facing_direction: &FighterFacingDirection, +) -> FGVec2 { + FGVec2::new(delta.z * facing_direction.to_sign(), delta.y) +} + impl UpdateContext<'_> { pub fn get_current_animation_frame(&self) -> u32 { self.animation_frame @@ -275,6 +286,7 @@ pub struct FighterFrameQuery { pub animation_frame: &'static mut FighterAnimationFrame, pub vulnerability: &'static mut FighterInvulnerabilityState, pub fighter: &'static Fighter, + pub bone_matrices: &'static FighterBoneMatrices, } impl<'w, 's> FighterFrameQueryItem<'w, 's> { @@ -365,6 +377,10 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> { surface: &mut self.surface, knockback_velocity: &mut self.knockback_velocity, game_settings, + animation_root_motion_delta: animation_root_motion_to_gameplay( + self.bone_matrices.root_motion_delta(), + &self.facing_direction, + ), animation_frame: self.animation_frame.frame, vulnerability: &mut self.vulnerability, fighter_attributes: attributes, diff --git a/src/hitbox_viewer/mod.rs b/src/hitbox_viewer/mod.rs index f03bf73..409b756 100644 --- a/src/hitbox_viewer/mod.rs +++ b/src/hitbox_viewer/mod.rs @@ -41,6 +41,7 @@ pub enum ViewerAttack { UpSmash, DownSmash, ForwardSmash, + DashAttack, } impl From for AttackKind { @@ -58,6 +59,7 @@ impl From for AttackKind { ViewerAttack::UpSmash => Self::UpSmash, ViewerAttack::DownSmash => Self::DownSmash, ViewerAttack::ForwardSmash => Self::ForwardSmash, + ViewerAttack::DashAttack => Self::DashAttack, } } } @@ -100,7 +102,7 @@ struct ViewerState { manifest_path: String, } -const VIEWER_ATTACKS: [AttackKind; 12] = [ +const VIEWER_ATTACKS: [AttackKind; 13] = [ AttackKind::NeutralAir, AttackKind::ForwardAir, AttackKind::BackAir, @@ -113,6 +115,7 @@ const VIEWER_ATTACKS: [AttackKind; 12] = [ AttackKind::UpSmash, AttackKind::DownSmash, AttackKind::ForwardSmash, + AttackKind::DashAttack, ]; pub struct HitboxViewerPlugin; diff --git a/src/schedule.rs b/src/schedule.rs index 7966b81..c0f60bf 100644 --- a/src/schedule.rs +++ b/src/schedule.rs @@ -17,14 +17,14 @@ pub enum GameplaySet { Input, /// Decide whether the current fighter state hands off to another one. Interrupt, + /// Sample the animation pose selected by the interrupt pass. + Animation, /// Run the fighter state that survived the interrupt pass. StateUpdate, /// Integrate velocities into positions. Physics, /// Resolve the integrated positions against the stage. Collision, - /// Solve collision boxes - Animation, /// Resolve attacks Attacks, /// Advance match rules and detect the end of the match. @@ -42,9 +42,9 @@ impl Plugin for GameplaySchedulePlugin { ( GameplaySet::Input, GameplaySet::Interrupt, + GameplaySet::Animation, GameplaySet::StateUpdate, GameplaySet::Physics, - GameplaySet::Animation, GameplaySet::Collision, GameplaySet::Attacks, GameplaySet::MatchRules, diff --git a/src/scripting/mod.rs b/src/scripting/mod.rs index 9727e8e..2c5eb43 100644 --- a/src/scripting/mod.rs +++ b/src/scripting/mod.rs @@ -17,6 +17,7 @@ pub struct FighterAttackScript { pub iasa_frame: Option, pub wont_autocancel_window: Option<(u32, u32)>, pub landing_duration: Option, + pub use_root_motion: bool } impl FighterAttackScript { diff --git a/src/scripting/move_compiler.rs b/src/scripting/move_compiler.rs index ff17355..01600bb 100644 --- a/src/scripting/move_compiler.rs +++ b/src/scripting/move_compiler.rs @@ -17,6 +17,7 @@ struct MoveCompiler { iasa_frame: Option, landing_duration: Option, cursor: u32, + use_root_motion: bool, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -114,6 +115,10 @@ impl MoveCompiler { self.iasa_frame = Some(iasa_frame); } + fn enable_root_motion(&mut self) { + self.use_root_motion = true; + } + fn into_attack_script(mut self) -> Result { self.remove_all_hitboxes().map_err(|err| err.to_string())?; let mut hitboxes = self.hitboxes; @@ -124,6 +129,7 @@ impl MoveCompiler { iasa_frame: self.iasa_frame, wont_autocancel_window: self.wont_autocancel_window, landing_duration: self.landing_duration, + use_root_motion: self.use_root_motion, }) } } @@ -232,6 +238,18 @@ pub fn compile_script(text: &str) -> Result { ); } + { + let mc = mc.clone(); + engine.register_fn( + "enable_root_motion", + move || -> Result<(), Box> { + let mut b = mc.borrow_mut(); + b.enable_root_motion(); + Ok(()) + }, + ); + } + { let mc = mc.clone(); engine.register_fn(