Implement animation root motion deltas (#84)
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:
@@ -100,4 +100,5 @@ pub enum AnimKind {
|
|||||||
AttackDownTilt,
|
AttackDownTilt,
|
||||||
AttackNeutralAir,
|
AttackNeutralAir,
|
||||||
AttackForwardAir,
|
AttackForwardAir,
|
||||||
|
AttackDash,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>>);
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user