From 3692bcba3f1298de4a4d533448b89f89c19e3f5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lex=20Rom=C3=A1n=20N=C3=BA=C3=B1ez?= Date: Mon, 5 Oct 2026 17:18:26 +0200 Subject: [PATCH 1/3] Implement animation root motion deltas --- src/fighter/baked_animation/mod.rs | 77 ++++++++++++ src/fighter/baked_animation/runtime.rs | 164 ++++++++++++++++++++++++- src/fighter/manifest.rs | 7 ++ src/match_loading.rs | 10 ++ 4 files changed, 252 insertions(+), 6 deletions(-) diff --git a/src/fighter/baked_animation/mod.rs b/src/fighter/baked_animation/mod.rs index 6d5c8b2..0f268bc 100644 --- a/src/fighter/baked_animation/mod.rs +++ b/src/fighter/baked_animation/mod.rs @@ -326,6 +326,83 @@ mod tests { ); } + #[test] + fn root_motion_delta_is_scaled_and_zero_on_initial_sample() { + let names = ["root".into()]; + let parents = [None]; + let mut first = FixedMat4::IDENTITY; + first.translate(FGVec3::lit("1", "0", "0")); + let mut second = FixedMat4::IDENTITY; + second.translate(FGVec3::lit("3", "0", "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, + &[first], + FGi32::lit("2"), + ); + assert_eq!(matrices.root_motion_delta(), FGVec3::ZERO); + + matrices.set_pose_with_frame( + crate::fighter::animation::FighterAnimationFrame { frame: 1, ..frame }, + "root", + FGVec3::ZERO, + None, + AnimKind::Wait, + &names, + &parents, + &[second], + FGi32::lit("2"), + ); + assert_eq!(matrices.root_motion_delta(), FGVec3::lit("4", "0", "0")); + } + + #[test] + fn repeating_root_motion_preserves_cycle_displacement_at_wrap() { + let names = ["root".into()]; + let parents = [None]; + let pose = |x| FixedMat4::IDENTITY.with_translation(FGVec3::lit(x, "0", "0")); + let mut matrices = FighterBoneMatrices::default(); + let frame = |frame| crate::fighter::animation::FighterAnimationFrame { + kind: AnimKind::Wait, + frame, + repeat: true, + }; + + for (frame_number, x) in [(0, "0"), (1, "1")] { + matrices.set_pose_with_frame( + frame(frame_number), + "root", + FGVec3::lit("2", "0", "0"), + Some(2), + AnimKind::Wait, + &names, + &parents, + &[pose(x)], + FGi32::ONE, + ); + } + matrices.set_pose_with_frame( + frame(2), + "root", + FGVec3::lit("2", "0", "0"), + Some(2), + AnimKind::Wait, + &names, + &parents, + &[pose("0")], + FGi32::ONE, + ); + assert_eq!(matrices.root_motion_delta(), FGVec3::lit("1", "0", "0")); + } + #[test] fn fixed_matrix_transforms_points() { let transform = FixedMat4 { diff --git a/src/fighter/baked_animation/runtime.rs b/src/fighter/baked_animation/runtime.rs index 9429515..c2adf66 100644 --- a/src/fighter/baked_animation/runtime.rs +++ b/src/fighter/baked_animation/runtime.rs @@ -14,6 +14,7 @@ use crate::{ }, math::fixed_transform::FixedMat4, math::int::FGi32, + math::vec3::FGVec3, }; #[derive(Clone, Debug, Serialize, Deserialize)] @@ -55,6 +56,31 @@ impl BakedFighterAnimations { pub fn frame_count(&self, kind: AnimKind) -> Option { self.clip(kind).map(|clip| clip.frames.len() as u32) } + pub fn root_motion_cycle_delta( + &self, + kind: AnimKind, + bone: &str, + model_scale: FGi32, + ) -> Option { + let last = self.frame_count(kind)?.saturating_sub(1); + let first = FighterAnimationFrame { + kind, + frame: 0, + repeat: false, + }; + let last = FighterAnimationFrame { + kind, + frame: last, + repeat: false, + }; + Some( + self.sample_bone_model_transform(&last, bone, model_scale)? + .get_translation() + - self + .sample_bone_model_transform(&first, bone, model_scale)? + .get_translation(), + ) + } /// Samples one bone in model space without depending on the runtime's /// previous-frame buffers. Tooling uses this when the user scrubs between /// unrelated frames. @@ -95,6 +121,8 @@ pub struct FighterBoneMatrices { previous_local_matrices: Vec, model_matrices: Vec>, presentation_kind: Option, + previous_animation_frame: Option, + root_motion_delta: crate::math::vec3::FGVec3, } #[derive(Component, Clone, Debug, Default)] @@ -111,7 +139,11 @@ impl FighterBoneMatrices { let Some(pose) = baked.sample_pose(frame) else { return false; }; - self.set_pose( + self.set_pose_with_frame( + *frame, + "root", + FGVec3::ZERO, + None, frame.kind, &baked.bone_names, &baked.parents, @@ -158,6 +190,11 @@ impl FighterBoneMatrices { pub fn get_current_index(&self, index: usize) -> Option { self.matrices.get(index).copied() } + /// Translation of the configured root-motion bone since the previous + /// simulation animation frame, in scaled model-space units. + pub fn root_motion_delta(&self) -> FGVec3 { + self.root_motion_delta + } pub fn iter(&self) -> impl Iterator { self.bone_names .iter() @@ -188,7 +225,42 @@ impl FighterBoneMatrices { local: &[FixedMat4], model_scale: FGi32, ) { - self.sample_pose(kind, bone_names, parents, local, model_scale, true); + self.set_pose_with_frame( + FighterAnimationFrame::new(kind, false), + "", + FGVec3::ZERO, + None, + kind, + bone_names, + parents, + local, + model_scale, + ); + } + pub(super) fn set_pose_with_frame( + &mut self, + animation_frame: FighterAnimationFrame, + root_motion_bone: &str, + root_motion_cycle_delta: FGVec3, + root_motion_loop_frame_count: Option, + kind: AnimKind, + bone_names: &[String], + parents: &[Option], + local: &[FixedMat4], + model_scale: FGi32, + ) { + self.sample_pose( + animation_frame, + root_motion_bone, + root_motion_cycle_delta, + root_motion_loop_frame_count, + kind, + bone_names, + parents, + local, + model_scale, + true, + ); } pub(super) fn resample_pose( &mut self, @@ -198,10 +270,49 @@ impl FighterBoneMatrices { local: &[FixedMat4], model_scale: FGi32, ) { - self.sample_pose(kind, bone_names, parents, local, model_scale, false); + self.resample_pose_with_frame( + FighterAnimationFrame::new(kind, false), + "", + FGVec3::ZERO, + None, + kind, + bone_names, + parents, + local, + model_scale, + ); + } + pub(super) fn resample_pose_with_frame( + &mut self, + animation_frame: FighterAnimationFrame, + root_motion_bone: &str, + _root_motion_cycle_delta: FGVec3, + _root_motion_loop_frame_count: Option, + kind: AnimKind, + bone_names: &[String], + parents: &[Option], + local: &[FixedMat4], + model_scale: FGi32, + ) { + self.sample_pose( + animation_frame, + root_motion_bone, + FGVec3::ZERO, + None, + kind, + bone_names, + parents, + local, + model_scale, + false, + ); } fn sample_pose( &mut self, + animation_frame: FighterAnimationFrame, + root_motion_bone: &str, + root_motion_cycle_delta: FGVec3, + root_motion_loop_frame_count: Option, kind: AnimKind, bone_names: &[String], parents: &[Option], @@ -219,6 +330,7 @@ impl FighterBoneMatrices { parents.len(), "baked bone parents do not match names" ); + let animation_changed = self.presentation_kind != Some(kind); if self.bone_names.is_empty() { self.bone_names.extend_from_slice(bone_names); self.parents.extend_from_slice(parents); @@ -235,7 +347,6 @@ impl FighterBoneMatrices { std::mem::swap(&mut self.matrices, &mut self.previous_frame_matrices); std::mem::swap(&mut self.local_matrices, &mut self.previous_local_matrices); } - let animation_changed = self.presentation_kind != Some(kind); self.local_matrices.clear(); self.local_matrices.extend_from_slice(local); self.model_matrices.resize(local.len(), None); @@ -258,6 +369,33 @@ impl FighterBoneMatrices { model_scale.mul(matrix.expect("baked bone hierarchy should be valid")) }), ); + self.root_motion_delta = if advance_history + && !animation_changed + && self.previous_animation_frame.is_some() + && !root_motion_bone.is_empty() + { + 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(); + if animation_frame.repeat + && self.previous_animation_frame.is_some_and(|previous| { + previous.frame.saturating_add(1) == animation_frame.frame + }) + && root_motion_loop_frame_count == Some(animation_frame.frame) + { + delta = delta + root_motion_cycle_delta; + } + delta + } else { + FGVec3::ZERO + }; + if advance_history { + self.previous_animation_frame = Some(animation_frame); + } // A state transition is a discontinuity, not an animation frame to // interpolate from the previous clip. Keep the presentation pose at // the new clip's first/current frame immediately. @@ -344,8 +482,18 @@ fn sample_fighter_bone_matrices( let manifest = manifests .get(&fighter.manifest) .expect("fighter manifest should be loaded before the match starts"); + let root_motion_cycle_delta = baked + .root_motion_cycle_delta(frame.kind, &manifest.root_motion_bone, manifest.model_scale) + .unwrap_or(FGVec3::ZERO); + let root_motion_loop_frame_count = baked + .frame_count(frame.kind) + .map(|count| count.saturating_sub(1).max(1)); if advance_history { - matrices.set_pose( + matrices.set_pose_with_frame( + *frame, + &manifest.root_motion_bone, + root_motion_cycle_delta, + root_motion_loop_frame_count, frame.kind, &baked.bone_names, &baked.parents, @@ -353,7 +501,11 @@ fn sample_fighter_bone_matrices( manifest.model_scale, ); } else { - matrices.resample_pose( + matrices.resample_pose_with_frame( + *frame, + &manifest.root_motion_bone, + FGVec3::ZERO, + None, frame.kind, &baked.bone_names, &baked.parents, diff --git a/src/fighter/manifest.rs b/src/fighter/manifest.rs index 12344c9..2242531 100644 --- a/src/fighter/manifest.rs +++ b/src/fighter/manifest.rs @@ -62,12 +62,19 @@ pub struct FighterManifest { pub icon: TextureReference, pub attributes: FighterAttributes, pub camera: FighterCameraProfile, + /// Bone whose model-space translation drives gameplay root motion. + #[serde(default = "default_root_motion_bone")] + pub root_motion_bone: String, pub animations: HashMap, pub attack_scripts: HashMap, pub hurtboxes: Vec, pub ecb_bones: Vec, } +fn default_root_motion_bone() -> String { + "root".into() +} + #[derive(Resource)] pub struct FighterManifestAssets(pub Vec>); diff --git a/src/match_loading.rs b/src/match_loading.rs index d60f6eb..e0c9081 100644 --- a/src/match_loading.rs +++ b/src/match_loading.rs @@ -363,6 +363,16 @@ pub fn prepare_match( ); return; } + if !baked.contains_bone(&manifest.root_motion_bone) { + fail_match( + &mut next_state, + &format!( + "{:?} references missing root motion bone {:?}", + character.fighter, manifest.root_motion_bone + ), + ); + return; + } let ecb_bone_indices = baked.bone_names.iter().enumerate().filter_map(|(idx, name)| { if manifest.ecb_bones.contains(name) { -- 2.54.0 From 256485ce44d882b6487975dd355f14134103efbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lex=20Rom=C3=A1n=20N=C3=BA=C3=B1ez?= Date: Mon, 5 Oct 2026 18:57:26 +0200 Subject: [PATCH 2/3] Fix unused animation pose helpers warning --- src/fighter/baked_animation/runtime.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/fighter/baked_animation/runtime.rs b/src/fighter/baked_animation/runtime.rs index c2adf66..9b4ee39 100644 --- a/src/fighter/baked_animation/runtime.rs +++ b/src/fighter/baked_animation/runtime.rs @@ -217,6 +217,7 @@ impl FighterBoneMatrices { .unwrap_or(current); Some(interpolate_transform(previous, current, overstep_fraction)) } + #[cfg(test)] pub(super) fn set_pose( &mut self, kind: AnimKind, @@ -262,6 +263,7 @@ impl FighterBoneMatrices { true, ); } + #[cfg(test)] pub(super) fn resample_pose( &mut self, kind: AnimKind, -- 2.54.0 From 3816e164328760949f33c206d327d2790dad504a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lex=20Rom=C3=A1n=20N=C3=BA=C3=B1ez?= Date: Mon, 5 Oct 2026 21:51:07 +0200 Subject: [PATCH 3/3] Implement root motion into grounded attacks and implement dash attack --- src/fighter/animation.rs | 1 + src/fighter/attack/mod.rs | 2 + src/fighter/baked_animation/mod.rs | 32 +++++++++ src/fighter/baked_animation/runtime.rs | 49 +++++++++++--- src/fighter/state/dash.rs | 12 +++- src/fighter/state/ground_attack.rs | 94 +++++++++++++++++++++++--- src/fighter/state/mod.rs | 18 ++++- src/hitbox_viewer/mod.rs | 5 +- src/schedule.rs | 6 +- src/scripting/mod.rs | 1 + src/scripting/move_compiler.rs | 18 +++++ 11 files changed, 213 insertions(+), 25 deletions(-) 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( -- 2.54.0