Implement animation root motion deltas (#84)
CI / build-and-test (push) Successful in 1m33s
CI / production-build (push) Successful in 12m5s

Implements root motion and adds a dash attack.

Reviewed-on: #84
Co-authored-by: Álex Román Núñez <eirexe@eirteam.moe>
This commit was merged in pull request #84.
This commit is contained in:
2026-10-05 20:04:35 +00:00
committed by EIREXE
parent 5c277a00f0
commit 7a9adbd6a4
13 changed files with 459 additions and 23 deletions
+1
View File
@@ -100,4 +100,5 @@ pub enum AnimKind {
AttackDownTilt, AttackDownTilt,
AttackNeutralAir, AttackNeutralAir,
AttackForwardAir, AttackForwardAir,
AttackDash,
} }
+2
View File
@@ -221,6 +221,7 @@ pub enum AttackKind {
UpSmash, UpSmash,
DownSmash, DownSmash,
ForwardSmash, ForwardSmash,
DashAttack,
} }
impl AttackKind { impl AttackKind {
@@ -238,6 +239,7 @@ impl AttackKind {
AttackKind::UpSmash => AnimKind::AttackUpTilt, AttackKind::UpSmash => AnimKind::AttackUpTilt,
AttackKind::DownSmash => AnimKind::AttackUpTilt, AttackKind::DownSmash => AnimKind::AttackUpTilt,
AttackKind::ForwardSmash => AnimKind::AttackUpTilt, AttackKind::ForwardSmash => AnimKind::AttackUpTilt,
AttackKind::DashAttack => AnimKind::AttackDash,
} }
} }
} }
+109
View File
@@ -326,6 +326,115 @@ 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 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()];
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] #[test]
fn fixed_matrix_transforms_points() { fn fixed_matrix_transforms_points() {
let transform = FixedMat4 { let transform = FixedMat4 {
+193 -6
View File
@@ -14,6 +14,7 @@ use crate::{
}, },
math::fixed_transform::FixedMat4, math::fixed_transform::FixedMat4,
math::int::FGi32, math::int::FGi32,
math::vec3::FGVec3,
}; };
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
@@ -55,6 +56,31 @@ impl BakedFighterAnimations {
pub fn frame_count(&self, kind: AnimKind) -> Option<u32> { pub fn frame_count(&self, kind: AnimKind) -> Option<u32> {
self.clip(kind).map(|clip| clip.frames.len() as u32) 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<FGVec3> {
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 /// Samples one bone in model space without depending on the runtime's
/// previous-frame buffers. Tooling uses this when the user scrubs between /// previous-frame buffers. Tooling uses this when the user scrubs between
/// unrelated frames. /// unrelated frames.
@@ -95,6 +121,12 @@ pub struct FighterBoneMatrices {
previous_local_matrices: Vec<FixedMat4>, previous_local_matrices: Vec<FixedMat4>,
model_matrices: Vec<Option<FixedMat4>>, model_matrices: Vec<Option<FixedMat4>>,
presentation_kind: Option<AnimKind>, presentation_kind: Option<AnimKind>,
previous_animation_frame: Option<FighterAnimationFrame>,
/// 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<FGVec3>,
root_motion_delta: crate::math::vec3::FGVec3,
} }
#[derive(Component, Clone, Debug, Default)] #[derive(Component, Clone, Debug, Default)]
@@ -111,7 +143,11 @@ impl FighterBoneMatrices {
let Some(pose) = baked.sample_pose(frame) else { let Some(pose) = baked.sample_pose(frame) else {
return false; return false;
}; };
self.set_pose( self.set_pose_with_frame(
*frame,
"root",
FGVec3::ZERO,
None,
frame.kind, frame.kind,
&baked.bone_names, &baked.bone_names,
&baked.parents, &baked.parents,
@@ -158,6 +194,11 @@ impl FighterBoneMatrices {
pub fn get_current_index(&self, index: usize) -> Option<FixedMat4> { pub fn get_current_index(&self, index: usize) -> Option<FixedMat4> {
self.matrices.get(index).copied() 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<Item = (&str, FixedMat4)> { pub fn iter(&self) -> impl Iterator<Item = (&str, FixedMat4)> {
self.bone_names self.bone_names
.iter() .iter()
@@ -180,6 +221,7 @@ impl FighterBoneMatrices {
.unwrap_or(current); .unwrap_or(current);
Some(interpolate_transform(previous, current, overstep_fraction)) Some(interpolate_transform(previous, current, overstep_fraction))
} }
#[cfg(test)]
pub(super) fn set_pose( pub(super) fn set_pose(
&mut self, &mut self,
kind: AnimKind, kind: AnimKind,
@@ -188,8 +230,44 @@ impl FighterBoneMatrices {
local: &[FixedMat4], local: &[FixedMat4],
model_scale: FGi32, 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<u32>,
kind: AnimKind,
bone_names: &[String],
parents: &[Option<usize>],
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,
);
}
#[cfg(test)]
pub(super) fn resample_pose( pub(super) fn resample_pose(
&mut self, &mut self,
kind: AnimKind, kind: AnimKind,
@@ -198,10 +276,49 @@ impl FighterBoneMatrices {
local: &[FixedMat4], local: &[FixedMat4],
model_scale: FGi32, 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<u32>,
kind: AnimKind,
bone_names: &[String],
parents: &[Option<usize>],
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( fn sample_pose(
&mut self, &mut self,
animation_frame: FighterAnimationFrame,
root_motion_bone: &str,
root_motion_cycle_delta: FGVec3,
root_motion_loop_frame_count: Option<u32>,
kind: AnimKind, kind: AnimKind,
bone_names: &[String], bone_names: &[String],
parents: &[Option<usize>], parents: &[Option<usize>],
@@ -219,6 +336,7 @@ impl FighterBoneMatrices {
parents.len(), parents.len(),
"baked bone parents do not match names" "baked bone parents do not match names"
); );
let animation_changed = self.presentation_kind != Some(kind);
if self.bone_names.is_empty() { if self.bone_names.is_empty() {
self.bone_names.extend_from_slice(bone_names); self.bone_names.extend_from_slice(bone_names);
self.parents.extend_from_slice(parents); self.parents.extend_from_slice(parents);
@@ -235,9 +353,19 @@ impl FighterBoneMatrices {
std::mem::swap(&mut self.matrices, &mut self.previous_frame_matrices); std::mem::swap(&mut self.matrices, &mut self.previous_frame_matrices);
std::mem::swap(&mut self.local_matrices, &mut self.previous_local_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.clear();
self.local_matrices.extend_from_slice(local); 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.resize(local.len(), None);
self.model_matrices.fill(None); self.model_matrices.fill(None);
for index in 0..local.len() { for index in 0..local.len() {
@@ -252,12 +380,57 @@ impl FighterBoneMatrices {
[FGi32::ZERO, FGi32::ZERO, FGi32::ZERO, FGi32::ONE], [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.clear();
self.matrices.extend( self.matrices.extend(
self.model_matrices.iter().map(|matrix| { self.model_matrices.iter().map(|matrix| {
model_scale.mul(matrix.expect("baked bone hierarchy should be valid")) 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()
&& let (Some(current), Some(previous)) = (
raw_root_motion_translation,
self.previous_root_motion_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
})
&& root_motion_loop_frame_count == Some(animation_frame.frame)
{
delta = delta + root_motion_cycle_delta;
}
delta
} else {
FGVec3::ZERO
};
self.previous_root_motion_translation = raw_root_motion_translation;
if advance_history {
self.previous_animation_frame = Some(animation_frame);
}
// A state transition is a discontinuity, not an animation frame to // A state transition is a discontinuity, not an animation frame to
// interpolate from the previous clip. Keep the presentation pose at // interpolate from the previous clip. Keep the presentation pose at
// the new clip's first/current frame immediately. // the new clip's first/current frame immediately.
@@ -344,8 +517,18 @@ fn sample_fighter_bone_matrices(
let manifest = manifests let manifest = manifests
.get(&fighter.manifest) .get(&fighter.manifest)
.expect("fighter manifest should be loaded before the match starts"); .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 { 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, frame.kind,
&baked.bone_names, &baked.bone_names,
&baked.parents, &baked.parents,
@@ -353,7 +536,11 @@ fn sample_fighter_bone_matrices(
manifest.model_scale, manifest.model_scale,
); );
} else { } else {
matrices.resample_pose( matrices.resample_pose_with_frame(
*frame,
&manifest.root_motion_bone,
FGVec3::ZERO,
None,
frame.kind, frame.kind,
&baked.bone_names, &baked.bone_names,
&baked.parents, &baked.parents,
+7
View File
@@ -62,12 +62,19 @@ pub struct FighterManifest {
pub icon: TextureReference, pub icon: TextureReference,
pub attributes: FighterAttributes, pub attributes: FighterAttributes,
pub camera: FighterCameraProfile, 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<AnimKind, String>, pub animations: HashMap<AnimKind, String>,
pub attack_scripts: HashMap<AttackKind, String>, pub attack_scripts: HashMap<AttackKind, String>,
pub hurtboxes: Vec<FighterHurtbox>, pub hurtboxes: Vec<FighterHurtbox>,
pub ecb_bones: Vec<String>, pub ecb_bones: Vec<String>,
} }
fn default_root_motion_bone() -> String {
"root".into()
}
#[derive(Resource)] #[derive(Resource)]
pub struct FighterManifestAssets(pub Vec<Handle<FighterManifest>>); pub struct FighterManifestAssets(pub Vec<Handle<FighterManifest>>);
+11 -1
View File
@@ -4,7 +4,7 @@ use super::{
}; };
use crate::fighter::FighterFacingDirection; use crate::fighter::FighterFacingDirection;
use crate::fighter::animation::AnimKind; use crate::fighter::animation::AnimKind;
use crate::fighter::state::FighterState; use crate::fighter::state::{FighterState, ground_attack};
use crate::input::FighterCommands::{SmashMoveLeft, SmashMoveRight}; use crate::input::FighterCommands::{SmashMoveLeft, SmashMoveRight};
#[derive(Debug, Clone, Hash)] #[derive(Debug, Clone, Hash)]
@@ -23,6 +23,16 @@ impl FighterStateImpl for DashState {
fn check_interrupt(&self, ctx: &mut InterruptContext) -> Option<FighterState> { fn check_interrupt(&self, ctx: &mut InterruptContext) -> Option<FighterState> {
let attributes = ctx.fighter_attributes; 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 // A reverse dash can interrupt before IASA
if let Some(direction) = if let Some(direction) =
check_smash_input_with_dir(ctx).filter(|direction| *direction != self.direction) check_smash_input_with_dir(ctx).filter(|direction| *direction != self.direction)
+83 -11
View File
@@ -1,14 +1,20 @@
use crate::fighter::{ use bevy::{asset::Assets, log::error};
attack::{AttackKind, update_active_attack},
state::{ use crate::{
CollisionContext, EnterContext, ExitContext, FighterState, FighterStateImpl, fighter::{
InterruptContext, UpdateContext, wait::WaitState, attack::{AttackKind, FighterAttackScriptAssets, update_active_attack},
state::{
CollisionContext, EnterContext, ExitContext, FighterState, FighterStateImpl,
InterruptContext, UpdateContext, ground, wait::WaitState,
},
}, },
scripting::FighterAttackScript,
}; };
#[derive(Clone, Debug, Hash)] #[derive(Clone, Debug, Hash)]
pub struct GroundAttackState { pub struct GroundAttackState {
pub attack_kind: AttackKind, pub attack_kind: AttackKind,
pub use_root_motion: bool,
} }
impl FighterStateImpl for GroundAttackState { impl FighterStateImpl for GroundAttackState {
@@ -37,6 +43,18 @@ impl FighterStateImpl for GroundAttackState {
.expect("ground attack must have active hitboxes"), .expect("ground attack must have active hitboxes"),
animation_frame, 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(); let script = state_context.attack_script_assets.scripts[&self.attack_kind].clone();
state_context.hitboxes.0 = Some(crate::fighter::FighterActiveAttack { state_context.hitboxes.0 = Some(crate::fighter::FighterActiveAttack {
kind: self.attack_kind, kind: self.attack_kind,
script, script: script.clone(),
active_hitboxes: Vec::new(), active_hitboxes: Vec::new(),
solved_hitboxes: Vec::new(), solved_hitboxes: Vec::new(),
hit_groups: Vec::new(), hit_groups: Vec::new(),
@@ -62,9 +80,9 @@ impl FighterStateImpl for GroundAttackState {
fn check_collision_interrupt( fn check_collision_interrupt(
&mut self, &mut self,
_state_context: &mut CollisionContext, state_context: &mut CollisionContext,
) -> Option<FighterState> { ) -> Option<FighterState> {
None ground::collision_interrupt(state_context, !self.use_root_motion)
} }
fn is_visually_grounded<'a>(&'a self) -> bool { 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<FighterAttackScript>,
) -> Result<Self, AirAttackStateCreateError> {
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<FighterState> {
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<FighterState> { pub fn check_interrupt(state_context: &InterruptContext) -> Option<FighterState> {
if state_context if state_context
.input .input
@@ -81,9 +144,18 @@ pub fn check_interrupt(state_context: &InterruptContext) -> Option<FighterState>
.input .input
.get_attack_command_type() .get_attack_command_type()
.to_attack_kind_grounded(); .to_attack_kind_grounded();
Some(FighterState::GroundAttack(GroundAttackState { check_interrupt_shared(state_context, attack_kind)
attack_kind, } else {
})) None
}
}
pub fn check_interrupt_dash_attack(state_context: &InterruptContext) -> Option<FighterState> {
if state_context
.input
.has_command(crate::input::FighterCommands::Attack)
{
check_interrupt_shared(state_context, AttackKind::DashAttack)
} else { } else {
None None
} }
+17 -1
View File
@@ -11,7 +11,7 @@ use crate::fighter::attack::{
FighterStaleMoveQueue, HitStop, FighterStaleMoveQueue, HitStop,
}; };
use crate::fighter::attributes::FighterAttributes; 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::ecb::FighterPreviousECB;
use crate::fighter::manifest::FighterManifest; use crate::fighter::manifest::FighterManifest;
use crate::fighter::state::hitstun::HitstunState; use crate::fighter::state::hitstun::HitstunState;
@@ -29,6 +29,7 @@ use crate::match_loading::MatchConfig;
use crate::match_runtime::MatchState; use crate::match_runtime::MatchState;
use crate::math::int::FGi32; use crate::math::int::FGi32;
use crate::math::vec::FGVec2; use crate::math::vec::FGVec2;
use crate::math::vec3::FGVec3;
use crate::player::Player; use crate::player::Player;
use crate::scripting::FighterAttackScript; use crate::scripting::FighterAttackScript;
use crate::stage::line::StageCollision; use crate::stage::line::StageCollision;
@@ -191,6 +192,9 @@ pub struct UpdateContext<'a> {
pub knockback_velocity: &'a mut FighterKnockbackVelocity, pub knockback_velocity: &'a mut FighterKnockbackVelocity,
pub vulnerability: &'a mut FighterInvulnerabilityState, pub vulnerability: &'a mut FighterInvulnerabilityState,
pub game_settings: &'a GameSettings, 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 animation_frame: u32,
pub fighter_attributes: &'a FighterAttributes, pub fighter_attributes: &'a FighterAttributes,
pub attack_scripts: &'a Assets<FighterAttackScript>, pub attack_scripts: &'a Assets<FighterAttackScript>,
@@ -198,6 +202,13 @@ pub struct UpdateContext<'a> {
pub facing_direction: &'a FighterFacingDirection, 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<'_> { impl UpdateContext<'_> {
pub fn get_current_animation_frame(&self) -> u32 { pub fn get_current_animation_frame(&self) -> u32 {
self.animation_frame self.animation_frame
@@ -275,6 +286,7 @@ pub struct FighterFrameQuery {
pub animation_frame: &'static mut FighterAnimationFrame, pub animation_frame: &'static mut FighterAnimationFrame,
pub vulnerability: &'static mut FighterInvulnerabilityState, pub vulnerability: &'static mut FighterInvulnerabilityState,
pub fighter: &'static Fighter, pub fighter: &'static Fighter,
pub bone_matrices: &'static FighterBoneMatrices,
} }
impl<'w, 's> FighterFrameQueryItem<'w, 's> { impl<'w, 's> FighterFrameQueryItem<'w, 's> {
@@ -365,6 +377,10 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> {
surface: &mut self.surface, surface: &mut self.surface,
knockback_velocity: &mut self.knockback_velocity, knockback_velocity: &mut self.knockback_velocity,
game_settings, 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, animation_frame: self.animation_frame.frame,
vulnerability: &mut self.vulnerability, vulnerability: &mut self.vulnerability,
fighter_attributes: attributes, fighter_attributes: attributes,
+4 -1
View File
@@ -41,6 +41,7 @@ pub enum ViewerAttack {
UpSmash, UpSmash,
DownSmash, DownSmash,
ForwardSmash, ForwardSmash,
DashAttack,
} }
impl From<ViewerAttack> for AttackKind { impl From<ViewerAttack> for AttackKind {
@@ -58,6 +59,7 @@ impl From<ViewerAttack> for AttackKind {
ViewerAttack::UpSmash => Self::UpSmash, ViewerAttack::UpSmash => Self::UpSmash,
ViewerAttack::DownSmash => Self::DownSmash, ViewerAttack::DownSmash => Self::DownSmash,
ViewerAttack::ForwardSmash => Self::ForwardSmash, ViewerAttack::ForwardSmash => Self::ForwardSmash,
ViewerAttack::DashAttack => Self::DashAttack,
} }
} }
} }
@@ -100,7 +102,7 @@ struct ViewerState {
manifest_path: String, manifest_path: String,
} }
const VIEWER_ATTACKS: [AttackKind; 12] = [ const VIEWER_ATTACKS: [AttackKind; 13] = [
AttackKind::NeutralAir, AttackKind::NeutralAir,
AttackKind::ForwardAir, AttackKind::ForwardAir,
AttackKind::BackAir, AttackKind::BackAir,
@@ -113,6 +115,7 @@ const VIEWER_ATTACKS: [AttackKind; 12] = [
AttackKind::UpSmash, AttackKind::UpSmash,
AttackKind::DownSmash, AttackKind::DownSmash,
AttackKind::ForwardSmash, AttackKind::ForwardSmash,
AttackKind::DashAttack,
]; ];
pub struct HitboxViewerPlugin; pub struct HitboxViewerPlugin;
+10
View File
@@ -363,6 +363,16 @@ pub fn prepare_match(
); );
return; 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)| { let ecb_bone_indices = baked.bone_names.iter().enumerate().filter_map(|(idx, name)| {
if manifest.ecb_bones.contains(name) { if manifest.ecb_bones.contains(name) {
+3 -3
View File
@@ -17,14 +17,14 @@ pub enum GameplaySet {
Input, Input,
/// Decide whether the current fighter state hands off to another one. /// Decide whether the current fighter state hands off to another one.
Interrupt, Interrupt,
/// Sample the animation pose selected by the interrupt pass.
Animation,
/// Run the fighter state that survived the interrupt pass. /// Run the fighter state that survived the interrupt pass.
StateUpdate, StateUpdate,
/// Integrate velocities into positions. /// Integrate velocities into positions.
Physics, Physics,
/// Resolve the integrated positions against the stage. /// Resolve the integrated positions against the stage.
Collision, Collision,
/// Solve collision boxes
Animation,
/// Resolve attacks /// Resolve attacks
Attacks, Attacks,
/// Advance match rules and detect the end of the match. /// Advance match rules and detect the end of the match.
@@ -42,9 +42,9 @@ impl Plugin for GameplaySchedulePlugin {
( (
GameplaySet::Input, GameplaySet::Input,
GameplaySet::Interrupt, GameplaySet::Interrupt,
GameplaySet::Animation,
GameplaySet::StateUpdate, GameplaySet::StateUpdate,
GameplaySet::Physics, GameplaySet::Physics,
GameplaySet::Animation,
GameplaySet::Collision, GameplaySet::Collision,
GameplaySet::Attacks, GameplaySet::Attacks,
GameplaySet::MatchRules, GameplaySet::MatchRules,
+1
View File
@@ -17,6 +17,7 @@ pub struct FighterAttackScript {
pub iasa_frame: Option<u32>, pub iasa_frame: Option<u32>,
pub wont_autocancel_window: Option<(u32, u32)>, pub wont_autocancel_window: Option<(u32, u32)>,
pub landing_duration: Option<u32>, pub landing_duration: Option<u32>,
pub use_root_motion: bool
} }
impl FighterAttackScript { impl FighterAttackScript {
+18
View File
@@ -17,6 +17,7 @@ struct MoveCompiler {
iasa_frame: Option<u32>, iasa_frame: Option<u32>,
landing_duration: Option<u32>, landing_duration: Option<u32>,
cursor: u32, cursor: u32,
use_root_motion: bool,
} }
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
@@ -114,6 +115,10 @@ impl MoveCompiler {
self.iasa_frame = Some(iasa_frame); self.iasa_frame = Some(iasa_frame);
} }
fn enable_root_motion(&mut self) {
self.use_root_motion = true;
}
fn into_attack_script(mut self) -> Result<FighterAttackScript, String> { fn into_attack_script(mut self) -> Result<FighterAttackScript, String> {
self.remove_all_hitboxes().map_err(|err| err.to_string())?; self.remove_all_hitboxes().map_err(|err| err.to_string())?;
let mut hitboxes = self.hitboxes; let mut hitboxes = self.hitboxes;
@@ -124,6 +129,7 @@ impl MoveCompiler {
iasa_frame: self.iasa_frame, iasa_frame: self.iasa_frame,
wont_autocancel_window: self.wont_autocancel_window, wont_autocancel_window: self.wont_autocancel_window,
landing_duration: self.landing_duration, landing_duration: self.landing_duration,
use_root_motion: self.use_root_motion,
}) })
} }
} }
@@ -232,6 +238,18 @@ pub fn compile_script(text: &str) -> Result<FighterAttackScript, String> {
); );
} }
{
let mc = mc.clone();
engine.register_fn(
"enable_root_motion",
move || -> Result<(), Box<EvalAltResult>> {
let mut b = mc.borrow_mut();
b.enable_root_motion();
Ok(())
},
);
}
{ {
let mc = mc.clone(); let mc = mc.clone();
engine.register_fn( engine.register_fn(