Initial work on ledge & improved baking of root motion. (#90)
CI / build-and-test (push) Successful in 2m15s
CI / production-build (push) Successful in 12m24s

Reviewed-on: #90
Co-authored-by: Álex Román Núñez <eirexe@eirteam.moe>
This commit was merged in pull request #90.
This commit is contained in:
2026-10-12 20:58:27 +00:00
committed by EIREXE
parent 0642a9234b
commit 9b974d0dd6
22 changed files with 1094 additions and 141 deletions
+13
View File
@@ -116,6 +116,7 @@ enum DebugFlag {
AttackHitboxes,
Hurtboxes,
Camera,
LedgeGrab,
DrawThroughGeometry,
FrameStepping,
}
@@ -164,6 +165,10 @@ const FIGHTER_MENU: &[DebugMenuEntry] = &[
label: "Attack hitboxes",
flag: DebugFlag::AttackHitboxes,
},
DebugMenuEntry::Toggle {
label: "Ledge grab",
flag: DebugFlag::LedgeGrab,
},
DebugMenuEntry::Toggle {
label: "Hurtboxes",
flag: DebugFlag::Hurtboxes,
@@ -260,6 +265,7 @@ pub struct DebugSettings {
pub attack_hitboxes: bool,
pub hurtboxes: bool,
pub camera: bool,
pub ledge_grab: bool,
pub draw_through_geometry: bool,
pub motion_sampling: PresentationMotionMode,
pub game_speed: GameSpeedDebug,
@@ -283,6 +289,7 @@ impl Default for DebugSettings {
attack_hitboxes: false,
hurtboxes: false,
camera: false,
ledge_grab: false,
draw_through_geometry: false,
motion_sampling: PresentationMotionMode::default(),
game_speed: GameSpeedDebug::Full,
@@ -350,6 +357,7 @@ impl DebugSettings {
DebugFlag::Hurtboxes => self.hurtboxes,
DebugFlag::DrawThroughGeometry => self.draw_through_geometry,
DebugFlag::Camera => self.camera,
DebugFlag::LedgeGrab => self.ledge_grab,
DebugFlag::FrameStepping => self.frame_stepping,
}
}
@@ -368,6 +376,7 @@ impl DebugSettings {
self.draw_through_geometry = !self.draw_through_geometry
}
DebugFlag::Camera => self.camera = !self.camera,
DebugFlag::LedgeGrab => self.ledge_grab = !self.ledge_grab,
DebugFlag::FrameStepping => self.frame_stepping = !self.frame_stepping,
}
}
@@ -926,6 +935,10 @@ pub fn camera_debug_enabled(settings: Res<DebugSettings>) -> bool {
settings.camera
}
pub fn ledge_grab_enabled(settings: Res<DebugSettings>) -> bool {
settings.ledge_grab
}
#[cfg(test)]
mod tests {
use super::*;
+2
View File
@@ -87,6 +87,8 @@ pub enum AnimKind {
CrouchEnter,
CrouchIdle,
CrouchExit,
LedgeWait,
LedgeJump,
PlatformPass,
+39 -9
View File
@@ -27,6 +27,7 @@ struct FighterBakeSource {
model_bytes: Vec<u8>,
buffers: Vec<Vec<u8>>,
animations: HashMap<AnimKind, String>,
root_motion_bone: String,
}
#[derive(Default, TypePath)]
struct FighterBakeSourceLoader;
@@ -82,6 +83,7 @@ impl AssetLoader for FighterBakeSourceLoader {
model_bytes,
buffers,
animations: manifest.animations,
root_motion_bone: manifest.root_motion_bone,
})
}
fn extensions(&self) -> &[&str] {
@@ -123,7 +125,12 @@ impl AssetTransformer for FighterBakeTransformer {
source: TransformedAsset<Self::AssetInput>,
_: &'a Self::Settings,
) -> Result<TransformedAsset<Self::AssetOutput>, Self::Error> {
let baked = bake_gltf(&source.model_bytes, &source.buffers, &source.animations)?;
let baked = bake_gltf(
&source.model_bytes,
&source.buffers,
&source.animations,
&source.root_motion_bone,
)?;
Ok(source.replace_asset(baked))
}
}
@@ -201,6 +208,7 @@ fn bake_gltf(
bytes: &[u8],
buffer_bytes: &[Vec<u8>],
animations: &HashMap<AnimKind, String>,
root_motion_bone: &str,
) -> io::Result<BakedFighterAnimations> {
let gltf = gltf::Gltf::from_slice(bytes)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
@@ -228,13 +236,14 @@ fn bake_gltf(
"glTF buffer data is incomplete",
));
}
bake_gltf_document(gltf.document, buffers, animations)
bake_gltf_document(gltf.document, buffers, animations, root_motion_bone)
}
pub(super) fn bake_gltf_document(
document: gltf::Document,
buffers: Vec<gltf::buffer::Data>,
animations: &HashMap<AnimKind, String>,
root_motion_bone: &str,
) -> io::Result<BakedFighterAnimations> {
let nodes: Vec<_> = document.nodes().collect();
if nodes.is_empty() {
@@ -260,6 +269,15 @@ pub(super) fn bake_gltf_document(
})
})
.collect::<io::Result<Vec<_>>>()?;
let root_bone_index = names
.iter()
.position(|name| name == root_motion_bone)
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("fighter model has no root-motion bone named {root_motion_bone}"),
)
})?;
let mut pending = HashMap::<&str, Vec<AnimKind>>::new();
for (kind, name) in animations {
pending.entry(name).or_default().push(*kind);
@@ -286,17 +304,28 @@ pub(super) fn bake_gltf_document(
.fold(0.0_f32, f32::max);
let frame_count = baked_frame_count(duration, BAKED_ANIMATION_FPS);
let mut frames = Vec::with_capacity(frame_count);
let mut root_positions = Vec::with_capacity(frame_count);
for frame in 0..frame_count {
let time = (frame as f32 / BAKED_ANIMATION_FPS as f32).min(duration);
frames.push(
nodes
.iter()
.map(|node| local_transform(node.clone(), &channels, time))
.collect::<io::Result<Vec<_>>>()?,
);
let local = nodes
.iter()
.map(|node| local_transform(node.clone(), &channels, time))
.collect::<io::Result<Vec<_>>>()?;
let mut model = vec![None; local.len()];
let root = super::runtime::resolve_model_transform(
root_bone_index,
&parents,
&local,
&mut model,
)?;
root_positions.push(root.get_translation());
frames.push(local);
}
let index = clips.len();
clips.push(BakedAnimationClip { frames });
clips.push(BakedAnimationClip {
frames,
root_positions,
});
for kind in kinds {
clip_indices.insert(kind, index);
}
@@ -322,6 +351,7 @@ pub(super) fn bake_gltf_document(
frame_rate: BAKED_ANIMATION_FPS,
bone_names: names,
parents,
root_bone_index,
clips,
clip_indices,
})
+98 -6
View File
@@ -36,7 +36,13 @@ mod tests {
))
.unwrap();
let (document, buffers) = test_gltf(&manifest.animations);
let baked = bake_gltf_document(document, buffers, &manifest.animations).unwrap();
let baked = bake_gltf_document(
document,
buffers,
&manifest.animations,
&manifest.root_motion_bone,
)
.unwrap();
assert_eq!(baked.frame_rate, BAKED_ANIMATION_FPS);
assert_eq!(baked.frame_count(AnimKind::Wait), Some(121));
@@ -51,6 +57,15 @@ mod tests {
baked.bone_names.len()
);
assert_eq!(baked.parents.len(), baked.bone_names.len());
assert_eq!(baked.root_bone_index, 0);
assert_eq!(
baked.initial_root_position(AnimKind::Wait, FGi32::lit("2")),
Some(FGVec3::ZERO)
);
assert_eq!(
baked.root_motion_cycle_delta(AnimKind::Wait, FGi32::lit("2")),
Some(FGVec3::lit("2", "0", "0"))
);
}
/// Builds the smallest glTF document that still exercises the complete
@@ -162,8 +177,14 @@ mod tests {
frame_rate: BAKED_ANIMATION_FPS,
bone_names: vec!["root".into()],
parents: vec![None],
root_bone_index: 0,
clips: vec![BakedAnimationClip {
frames: vec![vec![first], vec![second], vec![terminal]],
root_positions: vec![
FGVec3::ZERO,
FGVec3::lit("2", "0", "0"),
FGVec3::lit("4", "0", "0"),
],
}],
clip_indices: HashMap::from([(AnimKind::Wait, 0)]),
};
@@ -339,7 +360,8 @@ mod tests {
matrices.set_pose_with_frame(
frame,
"root",
Some(0),
Some(FGVec3::lit("2", "0", "0")),
FGVec3::ZERO,
None,
AnimKind::Wait,
@@ -349,10 +371,15 @@ mod tests {
FGi32::lit("2"),
);
assert_eq!(matrices.root_motion_delta(), FGVec3::ZERO);
assert_eq!(
matrices.root_motion_position(),
Some(FGVec3::lit("2", "0", "0"))
);
matrices.set_pose_with_frame(
crate::fighter::animation::FighterAnimationFrame { frame: 1, ..frame },
"root",
Some(0),
Some(FGVec3::lit("6", "0", "0")),
FGVec3::ZERO,
None,
AnimKind::Wait,
@@ -377,7 +404,8 @@ mod tests {
matrices.set_pose_with_frame(
frame,
"root",
Some(0),
Some(FGVec3::lit("3", "4", "5")),
FGVec3::ZERO,
None,
AnimKind::Wait,
@@ -396,6 +424,68 @@ mod tests {
);
}
#[test]
fn every_animation_extracts_root_translation_and_retains_its_position() {
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::LedgeWait,
true,
);
matrices.set_pose_with_frame(
frame,
Some(0),
Some(FGVec3::lit("3", "4", "5")),
FGVec3::ZERO,
None,
AnimKind::LedgeWait,
&names,
&parents,
&[root, hand],
FGi32::ONE,
);
assert_eq!(matrices.root_motion_delta(), FGVec3::ZERO);
assert_eq!(
matrices.root_motion_position(),
Some(FGVec3::lit("3", "4", "5"))
);
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")
);
root.translate(FGVec3::lit("6", "4", "5"));
matrices.set_pose_with_frame(
crate::fighter::animation::FighterAnimationFrame { frame: 1, ..frame },
Some(0),
Some(FGVec3::lit("6", "4", "5")),
FGVec3::ZERO,
None,
AnimKind::LedgeWait,
&names,
&parents,
&[root, hand],
FGi32::ONE,
);
assert_eq!(matrices.root_motion_delta(), FGVec3::lit("3", "0", "0"));
assert_eq!(
matrices.get_current("root").unwrap().get_translation(),
FGVec3::ZERO
);
}
#[test]
fn repeating_root_motion_preserves_cycle_displacement_at_wrap() {
let names = ["root".into()];
@@ -411,7 +501,8 @@ mod tests {
for (frame_number, x) in [(0, "0"), (1, "1")] {
matrices.set_pose_with_frame(
frame(frame_number),
"root",
Some(0),
Some(FGVec3::lit(x, "0", "0")),
FGVec3::lit("2", "0", "0"),
Some(2),
AnimKind::Wait,
@@ -423,7 +514,8 @@ mod tests {
}
matrices.set_pose_with_frame(
frame(2),
"root",
Some(0),
Some(FGVec3::ZERO),
FGVec3::lit("2", "0", "0"),
Some(2),
AnimKind::Wait,
+66 -79
View File
@@ -21,6 +21,9 @@ use crate::{
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BakedAnimationClip {
pub frames: Vec<Vec<FixedMat4>>,
/// Unscaled model-space position of the configured root-motion bone at
/// each baked frame.
pub root_positions: Vec<FGVec3>,
}
#[derive(Asset, bevy::reflect::TypePath, Clone, Debug, Serialize, Deserialize)]
@@ -29,11 +32,22 @@ pub struct BakedFighterAnimations {
pub frame_rate: u32,
pub bone_names: Vec<String>,
pub parents: Vec<Option<usize>>,
pub(super) root_bone_index: usize,
pub(super) clips: Vec<BakedAnimationClip>,
pub(super) clip_indices: HashMap<AnimKind, usize>,
}
impl BakedFighterAnimations {
fn sample_index(&self, frame: &FighterAnimationFrame) -> Option<usize> {
let frames = &self.clip(frame.kind)?.frames;
let frame_index = if frame.repeat {
let loop_frame_count = frames.len().saturating_sub(1).max(1) as u32;
frame.frame % loop_frame_count
} else {
frame.frame
};
Some((frame_index as usize).min(frames.len().saturating_sub(1)))
}
pub fn contains_bone(&self, bone: &str) -> bool {
self.bone_names.iter().any(|name| name == bone)
}
@@ -42,17 +56,20 @@ impl BakedFighterAnimations {
self.bone_names.iter().all(|name| names.insert(name))
}
pub fn sample_pose(&self, frame: &FighterAnimationFrame) -> Option<&[FixedMat4]> {
let frames = &self.clip(frame.kind)?.frames;
let frame_index = if frame.repeat {
let loop_frame_count = frames.len().saturating_sub(1).max(1) as u32;
frame.frame % loop_frame_count
} else {
frame.frame
};
frames
.get(frame_index as usize)
.or_else(|| frames.last())
.map(Vec::as_slice)
let clip = self.clip(frame.kind)?;
clip.frames.get(self.sample_index(frame)?).map(Vec::as_slice)
}
pub fn sample_root_position(
&self,
frame: &FighterAnimationFrame,
model_scale: FGi32,
) -> Option<FGVec3> {
let clip = self.clip(frame.kind)?;
let position = *clip.root_positions.get(self.sample_index(frame)?)?;
Some(position * model_scale)
}
pub fn initial_root_position(&self, kind: AnimKind, model_scale: FGi32) -> Option<FGVec3> {
self.sample_root_position(&FighterAnimationFrame::new(kind, false), model_scale)
}
pub fn frame_count(&self, kind: AnimKind) -> Option<u32> {
self.clip(kind).map(|clip| clip.frames.len() as u32)
@@ -60,27 +77,10 @@ impl BakedFighterAnimations {
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(),
)
let clip = self.clip(kind)?;
Some((*clip.root_positions.last()? - *clip.root_positions.first()?) * model_scale)
}
/// Samples one bone in model space without depending on the runtime's
/// previous-frame buffers. Tooling uses this when the user scrubs between
@@ -124,13 +124,12 @@ pub struct FighterBoneMatrices {
model_matrices: Vec<Option<FixedMat4>>,
presentation_kind: Option<AnimKind>,
previous_animation_frame: Option<FighterAnimationFrame>,
root_motion_position: Option<FGVec3>,
/// 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,
cached_root_motion_kind: Option<AnimKind>,
cached_root_motion_cycle_delta: FGVec3,
}
#[derive(Component, Clone, Debug, Default)]
@@ -149,7 +148,8 @@ impl FighterBoneMatrices {
};
self.set_pose_with_frame(
*frame,
"root",
Some(baked.root_bone_index),
baked.sample_root_position(frame, model_scale),
FGVec3::ZERO,
None,
frame.kind,
@@ -203,6 +203,11 @@ impl FighterBoneMatrices {
pub fn root_motion_delta(&self) -> FGVec3 {
self.root_motion_delta
}
/// Current raw, scaled model-space position of the configured root-motion
/// bone. The published skeleton has this translation removed.
pub fn root_motion_position(&self) -> Option<FGVec3> {
self.root_motion_position
}
pub fn iter(&self) -> impl Iterator<Item = (&str, FixedMat4)> {
self.bone_names
.iter()
@@ -236,7 +241,8 @@ impl FighterBoneMatrices {
) {
self.set_pose_with_frame(
FighterAnimationFrame::new(kind, false),
"",
None,
None,
FGVec3::ZERO,
None,
kind,
@@ -249,7 +255,8 @@ impl FighterBoneMatrices {
pub(super) fn set_pose_with_frame(
&mut self,
animation_frame: FighterAnimationFrame,
root_motion_bone: &str,
root_bone_index: Option<usize>,
root_motion_position: Option<FGVec3>,
root_motion_cycle_delta: FGVec3,
root_motion_loop_frame_count: Option<u32>,
kind: AnimKind,
@@ -260,7 +267,8 @@ impl FighterBoneMatrices {
) {
self.sample_pose(
animation_frame,
root_motion_bone,
root_bone_index,
root_motion_position,
root_motion_cycle_delta,
root_motion_loop_frame_count,
kind,
@@ -282,7 +290,8 @@ impl FighterBoneMatrices {
) {
self.resample_pose_with_frame(
FighterAnimationFrame::new(kind, false),
"",
None,
None,
FGVec3::ZERO,
None,
kind,
@@ -295,7 +304,8 @@ impl FighterBoneMatrices {
pub(super) fn resample_pose_with_frame(
&mut self,
animation_frame: FighterAnimationFrame,
root_motion_bone: &str,
root_bone_index: Option<usize>,
root_motion_position: Option<FGVec3>,
_root_motion_cycle_delta: FGVec3,
_root_motion_loop_frame_count: Option<u32>,
kind: AnimKind,
@@ -306,7 +316,8 @@ impl FighterBoneMatrices {
) {
self.sample_pose(
animation_frame,
root_motion_bone,
root_bone_index,
root_motion_position,
FGVec3::ZERO,
None,
kind,
@@ -320,7 +331,8 @@ impl FighterBoneMatrices {
fn sample_pose(
&mut self,
animation_frame: FighterAnimationFrame,
root_motion_bone: &str,
root_index: Option<usize>,
raw_root_motion_translation: Option<FGVec3>,
root_motion_cycle_delta: FGVec3,
root_motion_loop_frame_count: Option<u32>,
kind: AnimKind,
@@ -360,16 +372,6 @@ 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"),
)
};
let model_scale = FixedMat4 {
cols: [
[model_scale, FGi32::ZERO, FGi32::ZERO, FGi32::ZERO],
@@ -378,21 +380,8 @@ impl FighterBoneMatrices {
[FGi32::ZERO, FGi32::ZERO, FGi32::ZERO, FGi32::ONE],
],
};
// Root motion only needs the configured bone's model transform. Resolve
// that ancestry first, then do the normal full-pose pass once below.
// Previously the complete hierarchy was resolved once before removing
// root translation and once afterwards.
let raw_root_motion_translation = root_index.map(|root_index| {
self.model_matrices.resize(local.len(), None);
self.model_matrices.fill(None);
let root = resolve_model_transform(root_index, parents, local, &mut self.model_matrices)
.expect("baked bone hierarchy should be valid");
model_scale.mul(root).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.
// Gameplay always owns root displacement. Keep rotation and scale in
// the pose while removing the configured root bone's translation.
if let Some(root_index) = root_index {
self.local_matrices[root_index].translate(FGVec3::ZERO);
}
@@ -419,7 +408,8 @@ impl FighterBoneMatrices {
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
previous.kind == animation_frame.kind
&& previous.frame.saturating_add(1) == animation_frame.frame
})
&& root_motion_loop_frame_count == Some(animation_frame.frame)
{
@@ -429,6 +419,7 @@ impl FighterBoneMatrices {
} else {
FGVec3::ZERO
};
self.root_motion_position = raw_root_motion_translation;
self.previous_root_motion_translation = raw_root_motion_translation;
if advance_history {
self.previous_animation_frame = Some(animation_frame);
@@ -519,23 +510,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 = if matrices.cached_root_motion_kind == Some(frame.kind) {
matrices.cached_root_motion_cycle_delta
} else {
let delta = baked
.root_motion_cycle_delta(frame.kind, &manifest.root_motion_bone, manifest.model_scale)
.unwrap_or(FGVec3::ZERO);
matrices.cached_root_motion_kind = Some(frame.kind);
matrices.cached_root_motion_cycle_delta = delta;
delta
};
let root_motion_position = baked.sample_root_position(frame, manifest.model_scale);
let root_motion_cycle_delta = baked
.root_motion_cycle_delta(frame.kind, 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_with_frame(
*frame,
&manifest.root_motion_bone,
Some(baked.root_bone_index),
root_motion_position,
root_motion_cycle_delta,
root_motion_loop_frame_count,
frame.kind,
@@ -547,7 +533,8 @@ fn sample_fighter_bone_matrices(
} else {
matrices.resample_pose_with_frame(
*frame,
&manifest.root_motion_bone,
Some(baked.root_bone_index),
root_motion_position,
FGVec3::ZERO,
None,
frame.kind,
@@ -568,7 +555,7 @@ fn sample_fighter_bone_matrices(
}
}
fn resolve_model_transform(
pub(super) fn resolve_model_transform(
index: usize,
parents: &[Option<usize>],
local: &[FixedMat4],
+19 -1
View File
@@ -2,9 +2,10 @@
use crate::fighter::ecb::FighterPreviousECB;
use crate::fighter::{FighterECB, FighterPreviousTranslation, FighterTranslation};
use crate::math::rect::FGRect;
use crate::math::segment::FGSegment2d;
use crate::math::vec::FGVec2;
use crate::stage::line::{StageCollision, StageLineID, StagePolyType};
use crate::stage::line::{StageCollision, StageLineID, StagePolyType, StageVertexID};
/*
pub fn collide_fighter_with_scene(
fighters: Query<(&FighterECB, &mut FighterVelocity, &mut FighterTranslation)>,
@@ -83,6 +84,20 @@ pub fn air_collide_with_stage(
.map(|(result, _)| result)
}
#[derive(Clone)]
pub struct AirCollisionWithLedgeResult {
pub ledge: StageVertexID,
}
pub fn air_collide_with_ledges(
ledge_box: &FGRect,
stage_collision: &StageCollision,
) -> Option<AirCollisionWithLedgeResult> {
stage_collision.intersect_ledges(ledge_box).map(|result| {
AirCollisionWithLedgeResult { ledge: result.vertex_id }
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -115,6 +130,7 @@ mod tests {
fn airborne_collision_skips_ignored_polygons() {
let stage_collision = StageCollision {
stage_polys: vec![horizontal_platform("0"), horizontal_platform("1")],
..StageCollision::default()
};
let previous_translation = FighterPreviousTranslation(FGVec2::lit("0", "2"));
let translation = FighterTranslation(FGVec2::lit("0", "-1"));
@@ -150,6 +166,7 @@ mod tests {
fn airborne_collision_skips_soft_platforms_while_passing_down() {
let stage_collision = StageCollision {
stage_polys: vec![horizontal_platform("0")],
..StageCollision::default()
};
let previous_translation = FighterPreviousTranslation(FGVec2::lit("0", "2"));
let translation = FighterTranslation(FGVec2::lit("0", "-1"));
@@ -197,6 +214,7 @@ mod tests {
fn airborne_collision_still_hits_solid_floors_while_passing_down() {
let stage_collision = StageCollision {
stage_polys: vec![solid_floor()],
..StageCollision::default()
};
let previous_translation = FighterPreviousTranslation(FGVec2::lit("0", "2"));
let translation = FighterTranslation(FGVec2::lit("0", "-1"));
+20
View File
@@ -216,6 +216,26 @@ pub fn hurtbox_debug(
}
}
pub fn ledge_grab_debug(query: Query<(&FighterTranslation, &Fighter)>, fighters: Res<Assets<FighterManifest>>, mut gizmos: Gizmos) {
for (translation, fighter) in query {
let Some(manifest) = fighters.get(&fighter.manifest) else {
continue;
};
let start_2d = translation.0 + manifest.ledge_grab_box_start;
let start_2d = Vec2::new(start_2d.x.to_num(), start_2d.y.to_num());
let end_2d = translation.0 + manifest.ledge_grab_box_end;
let end_2d = Vec2::new(end_2d.x.to_num(), end_2d.y.to_num());
let center = (start_2d + end_2d) * 0.5;
let size = (start_2d - end_2d).abs();
gizmos.rect(Vec3::new(center.x, center.y, 0.0), size, bevy::color::palettes::css::BLUE);
}
}
/*fn draw_affine_capsule(
gizmos: &mut Gizmos,
capsule: FixedAffineCapsule,
+3 -5
View File
@@ -8,11 +8,7 @@ use strum::IntoEnumIterator;
use strum_macros::EnumIter;
use crate::{
AppState,
fighter::{FighterAttributes, FighterCameraProfile, animation::AnimKind, attack::AttackKind},
game_settings::GameSettings,
math::{int::FGi32, vec3::FGVec3},
texture_reference::TextureReference,
AppState, fighter::{FighterAttributes, FighterCameraProfile, animation::AnimKind, attack::AttackKind}, game_settings::GameSettings, math::{int::FGi32, vec::FGVec2, vec3::FGVec3}, texture_reference::TextureReference,
};
#[derive(
@@ -69,6 +65,8 @@ pub struct FighterManifest {
pub attack_scripts: HashMap<AttackKind, String>,
pub hurtboxes: Vec<FighterHurtbox>,
pub ecb_bones: Vec<String>,
pub ledge_grab_box_start: FGVec2,
pub ledge_grab_box_end: FGVec2,
}
fn default_root_motion_bone() -> String {
+64 -1
View File
@@ -86,6 +86,50 @@ pub struct FighterHitstop {
pub remaining_frames: u32,
}
#[derive(Component, Default, Copy, Clone, Hash, Debug, PartialEq, Eq)]
pub struct FighterLedgeGrabLockout {
pub remaining_frames: u32,
}
impl FighterLedgeGrabLockout {
pub fn start(&mut self, duration_frames: u32) {
self.remaining_frames = duration_frames;
}
pub fn is_active(&self) -> bool {
self.remaining_frames > 0
}
fn tick(&mut self) {
self.remaining_frames = self.remaining_frames.saturating_sub(1);
}
}
#[cfg(test)]
mod ledge_grab_lockout_tests {
use super::FighterLedgeGrabLockout;
#[test]
fn lockout_expires_after_the_requested_number_of_ticks() {
let mut lockout = FighterLedgeGrabLockout::default();
lockout.start(2);
assert!(lockout.is_active());
lockout.tick();
assert!(lockout.is_active());
lockout.tick();
assert!(!lockout.is_active());
}
#[test]
fn zero_duration_does_not_enable_lockout() {
let mut lockout = FighterLedgeGrabLockout::default();
lockout.start(0);
assert!(!lockout.is_active());
}
}
#[derive(Component, Default, Copy, Clone, Hash)]
pub enum FighterInvulnerabilityState {
#[default]
@@ -148,6 +192,7 @@ pub struct FighterPendingRespawn(bool);
FighterPendingHitReaction,
FighterPendingRespawn,
FighterHitstop,
FighterLedgeGrabLockout,
FighterKnockbackVelocity,
FighterTranslation,
FighterInvulnerabilityState,
@@ -193,7 +238,13 @@ impl Plugin for FighterPlugin {
.add_systems(
GgrsSchedule,
(
(blast_zones::apply_blast_zones, ecb::update_ecb, ecb::decrement_ecb_lockout, state::state_collision_interrupt_system).chain(),
(
blast_zones::apply_blast_zones,
ecb::update_ecb,
ecb::decrement_ecb_lockout,
state::state_collision_interrupt_system,
decay_ledge_grab_lockout,
).chain(),
baked_animation::resample_fighter_bone_matrices,
)
.chain()
@@ -247,6 +298,8 @@ impl Plugin for FighterPlugin {
.checksum_component_with_hash::<FighterPendingRespawn>()
.rollback_component_with_copy::<FighterHitstop>()
.checksum_component_with_hash::<FighterHitstop>()
.rollback_component_with_copy::<FighterLedgeGrabLockout>()
.checksum_component_with_hash::<FighterLedgeGrabLockout>()
.rollback_component_with_copy::<FighterInvulnerabilityState>()
.checksum_component_with_hash::<FighterInvulnerabilityState>()
.rollback_component_with_copy::<FighterKnockbackVelocity>()
@@ -258,6 +311,10 @@ impl Plugin for FighterPlugin {
FixedPostUpdate,
debug::debug_draw_ecb.run_if(crate::debug_tools::ecb_enabled),
)
.add_systems(
FixedPostUpdate,
debug::ledge_grab_debug.run_if(crate::debug_tools::ledge_grab_enabled),
)
.add_systems(
EguiPrimaryContextPass,
debug::fighter_debug.run_if(crate::debug_tools::fighter_info_enabled),
@@ -373,6 +430,12 @@ pub fn decay_hitstop(query: Query<(&mut FighterHitstop, &mut FighterHitstopShake
}
}
pub fn decay_ledge_grab_lockout(query: Query<&mut FighterLedgeGrabLockout>) {
for mut lockout in query {
lockout.tick();
}
}
pub fn decay_invulnerability(query: Query<&mut FighterInvulnerabilityState>) {
for invuln_state in query {
let invuln_state = invuln_state.into_inner();
+1
View File
@@ -134,6 +134,7 @@ mod tests {
(FGVec2::lit("30", "0"), StagePolyLineSegmentType::Floor),
],
)],
..StageCollision::default()
}
}
+10
View File
@@ -1,5 +1,6 @@
use crate::fighter::state::jump::{self};
use crate::fighter::state::land::{LandingState, LandingType};
use crate::fighter::state::ledge_wait::LedgeWaitState;
use crate::fighter::state::{
CollisionContext, FighterState, InterruptContext, air_attack, air_dodge,
};
@@ -126,6 +127,15 @@ pub fn collision_interrupt(
landing_type: LandingType,
can_ignore_soft_platforms: bool,
) -> Option<FighterState> {
if ctx.velocity.y <= FGi32::ZERO {
if let Some(ledge_result) = ctx.air_collide_with_ledges() {
return Some(FighterState::LedgeWait(LedgeWaitState {
airborne_state_common: AirborneStateCommon::default(),
ledge_id: ledge_result.ledge,
}));
}
}
let res = ctx.air_collide_with_stage(can_ignore_soft_platforms)?;
let diff = res.hit_position - ctx.translation.0;
ctx.ecb.bottom -= diff;
+40 -1
View File
@@ -2,6 +2,7 @@
//! ECS types so it stays cheap to reason about and to unit-test — it has to be
//! bit-for-bit deterministic for rollback.
use crate::fighter::state::air_dodge::AirDodgeState;
use crate::fighter::state::{
CollisionContext, FighterState, InterruptContext, air, crouch, dash, fall, ground_attack, turn, wait, walk,
};
@@ -9,6 +10,7 @@ use crate::fighter::{
FighterAttributes, FighterTranslation, FighterVelocity,
};
use crate::game_settings::GameSettings;
use crate::input::FighterInput;
use crate::math::int::{FGWide, FGi32};
use crate::math::vec::FGVec2;
use crate::stage::line::{StageLineID, StagePoly, StagePolyLineSegment, StagePolyLineSegmentType};
@@ -245,7 +247,8 @@ mod tests {
use super::*;
use crate::fighter::ecb::FighterPreviousECB;
use crate::fighter::{FighterECB, FighterPreviousTranslation, FighterSurfaceState};
use crate::stage::line::StagePolyType;
use crate::math::rect::FGRect;
use crate::stage::line::StagePolyType;
use crate::input::FighterInput;
fn two_segment_floor() -> StagePoly {
@@ -357,6 +360,7 @@ mod tests {
fn grounded_collision_updates_surface_segment() {
let stage_collision = crate::stage::line::StageCollision {
stage_polys: vec![two_segment_floor()],
..crate::stage::line::StageCollision::default()
};
let mut surface = FighterSurfaceState::Grounded {
line_id: StageLineID {
@@ -387,6 +391,11 @@ mod tests {
leave_ground_ecb_lock_duration: 10,
presentation_snap: &mut presentation_snap,
downward_stick_threshold: FGi32::ONE,
fighter_ledge_collision_box: FGRect {
origin: FGVec2::ZERO,
end: FGVec2::ZERO,
},
ledge_grab_lockout_active: false,
};
assert!(matches!(
@@ -412,6 +421,7 @@ mod tests {
(FGVec2::lit("10", "0"), StagePolyLineSegmentType::Floor),
],
)],
..crate::stage::line::StageCollision::default()
};
let mut surface = FighterSurfaceState::Grounded {
line_id: StageLineID {
@@ -442,6 +452,11 @@ mod tests {
leave_ground_ecb_lock_duration: 10,
presentation_snap: &mut presentation_snap,
downward_stick_threshold: FGi32::ONE,
fighter_ledge_collision_box: FGRect {
origin: FGVec2::ZERO,
end: FGVec2::ZERO,
},
ledge_grab_lockout_active: false,
};
assert!(matches!(
@@ -477,3 +492,27 @@ pub fn grounded_movement_standstill_common_interrupts(
None
}
}
pub fn perform_wavedash(input: &FighterInput, game_settings: &GameSettings) -> FighterState {
// If aiming sideways and in the deadzone, do a perfect wavedash
// TODO: Improve this so we don't have to go through the airborne state first
let mut wavedash_dir =
input
.get_last_frame()
.movement
.normalize_or_zero();
let movement = input.get_last_frame().movement;
if movement.y.is_zero() && !movement.x.is_zero() {
wavedash_dir.y =
-game_settings.input_common.stick_deadzone - FGi32::lit("-0.01");
wavedash_dir.x = movement.x.signum();
}
if wavedash_dir == FGVec2::ZERO {
wavedash_dir = FGVec2::lit("0.0", "-1.0");
}
FighterState::AirDodge(AirDodgeState {
direction: wavedash_dir.normalize_or_zero(),
})
}
+3 -23
View File
@@ -2,9 +2,9 @@
use crate::{
fighter::{
animation::AnimKind, state::{
CollisionContext, EnterContext, FighterState, FighterStateImpl, InterruptContext, UpdateContext, air::{self, AirborneStateCommon}, air_dodge::AirDodgeState, ground, ground_attack,
CollisionContext, EnterContext, FighterState, FighterStateImpl, InterruptContext, UpdateContext, air::{self, AirborneStateCommon}, ground::{self}, ground_attack,
},
}, input::{DigitalAction, FighterCommands}, math::{int::FGi32, vec::FGVec2},
}, input::{DigitalAction, FighterCommands},
};
#[derive(Debug, Hash, Clone, Copy)]
pub enum JumpType {
@@ -50,27 +50,7 @@ impl FighterStateImpl for JumpSquatState {
}
// Special handling for wavedash from jump squat
if state_context.input.has_command(FighterCommands::Shield) {
// If aiming sideways and in the deadzone, do a perfect wavedash
// TODO: Improve this so we don't have to go through the airborne state first
let mut wavedash_dir = state_context
.input
.get_last_frame()
.movement
.normalize_or_zero();
let movement = state_context.input.get_last_frame().movement;
if movement.y.is_zero() && !movement.x.is_zero() {
wavedash_dir.y =
-state_context.game_settings.input_common.stick_deadzone - FGi32::lit("-0.01");
wavedash_dir.x = movement.x.signum();
}
if wavedash_dir == FGVec2::ZERO {
wavedash_dir = FGVec2::lit("0.0", "-1.0");
}
Some(FighterState::AirDodge(AirDodgeState {
direction: wavedash_dir.normalize_or_zero(),
}))
Some(ground::perform_wavedash(state_context.input, state_context.game_settings))
} else if self.duration_counter >= state_context.fighter_attributes.jumpsquat_duration {
let tap_jump_held = matches!(self.initiator, JumpStateInitiator::Tap)
&& state_context.input.get_last_frame().movement.y
+139
View File
@@ -0,0 +1,139 @@
use super::{CollisionContext, EnterContext, FighterStateImpl, InterruptContext, UpdateContext};
use crate::fighter::animation::AnimKind;
use crate::fighter::state::air::AirborneStateCommon;
use crate::fighter::state::jump::{JumpState, JumpStateInitiator, JumpType};
use crate::fighter::state::{FighterState, ground};
use crate::fighter::FighterFacingDirection;
use crate::input::FighterCommands;
use crate::math::int::FGi32;
use crate::math::vec::FGVec2;
use crate::stage::line::{StageLedgeSide, StageLineID, StageVertexID};
#[derive(Debug, Clone, Hash)]
pub enum LedgeAction {
Jump(JumpStateInitiator)
}
#[derive(Debug, Clone, Hash)]
pub struct LedgeActionState {
pub ledge_id: StageVertexID,
pub ledge_action: LedgeAction,
pub is_in_grounded_phase: bool,
}
impl LedgeActionState {
pub fn create_jump(ledge_id: StageVertexID, initiator: JumpStateInitiator) -> Self {
LedgeActionState {
ledge_id,
ledge_action: LedgeAction::Jump(initiator),
is_in_grounded_phase: false,
}
}
}
impl FighterStateImpl for LedgeActionState {
const NAME: &'static str = "LedgeAction";
fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option<FighterState> {
match &self.ledge_action {
LedgeAction::Jump(initiator) => {
if state_context.is_current_animation_finished() {
if state_context.input.has_command(FighterCommands::Shield) {
Some(ground::perform_wavedash(state_context.input, state_context.game_settings))
} else {
Some(FighterState::Jump(JumpState {
jump_type: JumpType::DoubleJump,
airborne_state_common: AirborneStateCommon::default(),
initiator: initiator.clone()
}))
}
} else {
None
}
},
}
}
fn on_enter(&mut self, state_context: &mut EnterContext) {
match self.ledge_action {
LedgeAction::Jump(_) => {
state_context.play_animation(AnimKind::LedgeJump, false);
state_context.input.enable_hold_all(10);
state_context.input.clear_command(FighterCommands::Jump);
state_context.input.clear_command(FighterCommands::TapJump);
},
}
state_context.velocity.0 = FGVec2::ZERO;
state_context.knockback_velocity.0 = FGVec2::ZERO;
let Some(ledge) = state_context.stage_collision.find_ledge_by_vertex(self.ledge_id) else {
return;
};
state_context.translation.0 = ledge.position;
*state_context.facing_direction = match ledge.hang_side {
StageLedgeSide::Left => FighterFacingDirection::Right,
StageLedgeSide::Right => FighterFacingDirection::Left,
};
if let Some(root_position) = state_context.initial_animation_root_position() {
state_context.translation.0 = root_position;
}
state_context.presentation_snap.0 = true;
}
fn update(&mut self, state_context: &mut UpdateContext) {
if !self.is_in_grounded_phase {
let root_pos = state_context.animation_root_position.unwrap_or_default();
let Some(ledge) = state_context.stage_collision.find_ledge_by_vertex(self.ledge_id) else {
return;
};
let ledge_pos = ledge.position;
let ledge_modifier = match ledge.hang_side {
StageLedgeSide::Left => FGi32::ONE,
StageLedgeSide::Right => FGi32::NEG_ONE,
};
let normalized_root_position = root_pos - ledge_pos;
let normalized_root_position = FGVec2::new(normalized_root_position.x * ledge_modifier, normalized_root_position.y);
if normalized_root_position.x.is_positive() && normalized_root_position.y.is_positive() {
self.is_in_grounded_phase = true;
let segment_id = StageLineID {
polygon: self.ledge_id.polygon,
segment: self.ledge_id.vertex
};
let Some(poly) = state_context.stage_collision.get_segment(segment_id) else {
return;
};
state_context.translation.0 = poly.segment.point1();
state_context.surface.land(segment_id);
}
}
if self.is_in_grounded_phase {
state_context.velocity.0.x = state_context.animation_root_motion_delta.x;
ground::apply_grounded_motion(
state_context.velocity,
state_context.translation,
);
} else {
if let Some(root_position) = state_context.animation_root_position {
state_context.translation.0 = root_position;
}
}
}
fn check_collision_interrupt(
&mut self,
_state_context: &mut CollisionContext,
) -> Option<FighterState> {
None
}
fn is_visually_grounded<'a>(&'a self) -> bool {
self.is_in_grounded_phase
}
}
+130
View File
@@ -0,0 +1,130 @@
use super::{CollisionContext, EnterContext, FighterStateImpl, InterruptContext, UpdateContext};
use crate::fighter::animation::AnimKind;
use crate::fighter::state::air::AirborneStateCommon;
use crate::fighter::state::fall::FallState;
use crate::fighter::state::FighterState;
use crate::fighter::FighterFacingDirection;
use crate::fighter::state::jump::JumpStateInitiator;
use crate::fighter::state::ledge_action::{LedgeActionState};
use crate::input::FighterCommands;
use crate::math::vec::FGVec2;
use crate::stage::line::{StageLedgeSide, StageVertexID};
#[derive(Debug, Clone, Hash)]
pub struct LedgeWaitState {
pub airborne_state_common: AirborneStateCommon,
pub ledge_id: StageVertexID,
}
impl FighterStateImpl for LedgeWaitState {
const NAME: &'static str = "LedgeWait";
fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option<FighterState> {
if state_context.input.has_command(FighterCommands::Jump) {
return Some(FighterState::LedgeAction(LedgeActionState::create_jump(self.ledge_id, JumpStateInitiator::Button)));
} else if state_context.input.has_command(FighterCommands::TapJump) {
return Some(FighterState::LedgeAction(LedgeActionState::create_jump(self.ledge_id, JumpStateInitiator::Tap)));
}
if is_ledge_drop_input(state_context.input.get_last_frame().movement.x,
state_context.game_settings.input_common.run_stick_threshold,
state_context.facing_direction) {
state_context.ledge_grab_lockout.start(
state_context
.game_settings
.fighter_common
.ledge_drop_regrab_lockout_duration_frames,
);
return Some(FighterState::Fall(FallState::new(
AirborneStateCommon { is_fast_falling: false },
)));
}
if is_ledge_drop_fastfall_input(
state_context.input.get_last_frame().movement.y,
state_context.game_settings.input_common.run_stick_threshold,
) {
state_context.ledge_grab_lockout.start(
state_context
.game_settings
.fighter_common
.ledge_drop_regrab_lockout_duration_frames,
);
return Some(FighterState::Fall(FallState::new(
AirborneStateCommon { is_fast_falling: true },
)));
}
None
}
fn on_enter(&mut self, state_context: &mut EnterContext) {
state_context.velocity.0 = FGVec2::ZERO;
state_context.knockback_velocity.0 = FGVec2::ZERO;
let Some(ledge) = state_context.stage_collision.find_ledge_by_vertex(self.ledge_id) else {
return;
};
state_context.translation.0 = ledge.position;
*state_context.facing_direction = match ledge.hang_side {
StageLedgeSide::Left => FighterFacingDirection::Right,
StageLedgeSide::Right => FighterFacingDirection::Left,
};
state_context.play_animation(AnimKind::LedgeWait, true);
if let Some(root_position) = state_context.initial_animation_root_position() {
state_context.translation.0 = root_position;
}
state_context.presentation_snap.0 = true;
}
fn update(&mut self, state_context: &mut UpdateContext) {
if let Some(root_position) = state_context.animation_root_position {
state_context.translation.0 = root_position;
}
}
fn check_collision_interrupt(
&mut self,
_state_context: &mut CollisionContext,
) -> Option<FighterState> {
None
}
fn is_visually_grounded<'a>(&'a self) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::math::int::FGi32;
#[test]
fn ledge_drop_uses_the_run_stick_threshold() {
let threshold = FGi32::lit("0.625");
assert!(is_ledge_drop_fastfall_input(FGi32::lit("-0.625"), threshold));
assert!(is_ledge_drop_fastfall_input(FGi32::lit("-1.0"), threshold));
assert!(!is_ledge_drop_fastfall_input(FGi32::lit("-0.624"), threshold));
}
}
fn is_ledge_drop_fastfall_input(
vertical_input: crate::math::int::FGi32,
threshold: crate::math::int::FGi32,
) -> bool {
vertical_input <= -threshold
}
fn is_ledge_drop_input(
horizontal_input: crate::math::int::FGi32,
threshold: crate::math::int::FGi32,
facing_dir: &FighterFacingDirection,
) -> bool {
let dir = facing_dir.to_sign();
if horizontal_input.abs() > threshold {
if dir != horizontal_input.signum() {
return true;
}
}
false
}
+176 -11
View File
@@ -20,14 +20,16 @@ use crate::fighter::visual::FighterAnimations;
use crate::fighter::{
Fighter, FighterDamage, FighterECB, FighterFacingDirection, FighterHitboxes, FighterHitstop,
FighterHitstopShake, FighterHitstopShakeKind, FighterHitstopShakeRole,
FighterInvulnerabilityState, FighterPendingRespawn, FighterPresentationSnap,
FighterPreviousTranslation, FighterSurfaceState, FighterTranslation, FighterVelocity,
FighterInvulnerabilityState, FighterLedgeGrabLockout, FighterPendingRespawn,
FighterPresentationSnap, FighterPreviousTranslation, FighterSurfaceState, FighterTranslation,
FighterVelocity,
};
use crate::game_settings::GameSettings;
use crate::input::FighterInput;
use crate::match_loading::MatchConfig;
use crate::match_runtime::MatchState;
use crate::math::int::FGi32;
use crate::math::rect::FGRect;
use crate::math::vec::FGVec2;
use crate::math::vec3::FGVec3;
use crate::player::Player;
@@ -52,6 +54,8 @@ pub mod run;
pub mod turn;
pub mod wait;
pub mod walk;
pub mod ledge_wait;
pub mod ledge_action;
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
pub enum SurfacePolicy {
@@ -135,6 +139,7 @@ pub struct AnimationStatus {
pub struct InterruptContext<'a> {
pub input: &'a FighterInput,
pub facing_direction: &'a mut FighterFacingDirection,
pub ledge_grab_lockout: &'a mut FighterLedgeGrabLockout,
pub game_settings: &'a GameSettings,
pub fighter_attributes: &'a FighterAttributes,
pub surface: &'a FighterSurfaceState,
@@ -158,21 +163,38 @@ pub struct EnterContext<'a> {
pub input: &'a mut FighterInput,
pub hitboxes: &'a mut FighterHitboxes,
pub velocity: &'a mut FighterVelocity,
pub translation: &'a mut FighterTranslation,
pub knockback_velocity: &'a mut FighterKnockbackVelocity,
pub stale_move_queue: &'a mut FighterStaleMoveQueue,
pub facing_direction: &'a mut FighterFacingDirection,
pub animation_frame: &'a mut FighterAnimationFrame,
pub presentation_snap: &'a mut FighterPresentationSnap,
pub surface: &'a mut FighterSurfaceState,
pub vulnerability: &'a mut FighterInvulnerabilityState,
pub fighter_attributes: &'a FighterAttributes,
pub attack_script_assets: &'a FighterAttackScriptAssets,
pub game_settings: &'a GameSettings,
pub stage_collision: &'a StageCollision,
baked_animations: &'a BakedFighterAnimations,
model_scale: FGi32,
}
impl EnterContext<'_> {
pub fn play_animation(&mut self, kind: AnimKind, repeat: bool) {
self.animation_frame.reset(kind, repeat);
}
/// World-space root position authored for frame zero of the animation most
/// recently selected with [`Self::play_animation`].
pub fn initial_animation_root_position(&self) -> Option<FGVec2> {
let position = self
.baked_animations
.initial_root_position(self.animation_frame.kind, self.model_scale)?;
Some(
self.translation.0
+ animation_position_to_gameplay(position, self.facing_direction),
)
}
}
pub struct ExitContext<'a> {
@@ -195,18 +217,22 @@ pub struct UpdateContext<'a> {
/// 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,
/// Current world-space root position implied by this animation frame.
/// States opt into root motion by assigning this to [`FighterTranslation`].
pub animation_root_position: Option<FGVec2>,
pub animation_frame: u32,
pub fighter_attributes: &'a FighterAttributes,
pub attack_scripts: &'a Assets<FighterAttackScript>,
pub attack_script_assets: &'a FighterAttackScriptAssets,
pub facing_direction: &'a FighterFacingDirection,
pub stage_collision: &'a StageCollision,
}
fn animation_root_motion_to_gameplay(
delta: FGVec3,
fn animation_position_to_gameplay(
position: FGVec3,
facing_direction: &FighterFacingDirection,
) -> FGVec2 {
FGVec2::new(delta.z * facing_direction.to_sign(), delta.y)
FGVec2::new(position.z * facing_direction.to_sign(), position.y)
}
impl UpdateContext<'_> {
@@ -226,6 +252,8 @@ pub struct CollisionContext<'a> {
pub stage_collision: &'a StageCollision,
pub leave_ground_ecb_lock_duration: u32,
pub presentation_snap: &'a mut FighterPresentationSnap,
pub fighter_ledge_collision_box: FGRect,
pub ledge_grab_lockout_active: bool,
pub downward_stick_threshold: FGi32,
}
@@ -247,6 +275,17 @@ impl CollisionContext<'_> {
ignore_soft_platforms,
)
}
pub fn air_collide_with_ledges(
&self,
) -> Option<crate::fighter::collision::AirCollisionWithLedgeResult> {
if self.ledge_grab_lockout_active {
return None;
}
crate::fighter::collision::air_collide_with_ledges(
&self.fighter_ledge_collision_box,
self.stage_collision,
)
}
}
fighter_states! {
@@ -260,6 +299,8 @@ fighter_states! {
JumpSquat => jump::JumpSquatState,
Jump => jump::JumpState,
Land => land::LandingState,
LedgeWait => ledge_wait::LedgeWaitState,
LedgeAction => ledge_action::LedgeActionState,
AirDodge => air_dodge::AirDodgeState,
Turn => turn::TurnState,
GroundAttack => ground_attack::GroundAttackState,
@@ -285,6 +326,7 @@ pub struct FighterFrameQuery {
pub facing_direction: &'static mut FighterFacingDirection,
pub animation_frame: &'static mut FighterAnimationFrame,
pub vulnerability: &'static mut FighterInvulnerabilityState,
pub ledge_grab_lockout: &'static mut FighterLedgeGrabLockout,
pub fighter: &'static Fighter,
pub bone_matrices: &'static FighterBoneMatrices,
}
@@ -320,6 +362,7 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> {
InterruptContext {
input: &self.input,
facing_direction: &mut self.facing_direction,
ledge_grab_lockout: &mut self.ledge_grab_lockout,
game_settings,
fighter_attributes: attributes,
animation,
@@ -335,20 +378,28 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> {
attributes: &'a FighterAttributes,
attack_script_assets: &'a FighterAttackScriptAssets,
game_settings: &'a GameSettings,
stage_collision: &'a StageCollision,
baked_animations: &'a BakedFighterAnimations,
model_scale: FGi32,
) -> EnterContext<'a> {
EnterContext {
input: &mut self.input,
hitboxes: &mut self.hitboxes,
velocity: &mut self.velocity,
knockback_velocity: &mut self.knockback_velocity,
translation: &mut self.translation,
stale_move_queue: &mut self.stale_move_queue,
facing_direction: &mut self.facing_direction,
animation_frame: &mut self.animation_frame,
surface: &mut self.surface,
vulnerability: &mut self.vulnerability,
presentation_snap: &mut self.presentation_snap,
fighter_attributes: attributes,
attack_script_assets,
game_settings,
stage_collision,
baked_animations,
model_scale,
}
}
@@ -367,7 +418,16 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> {
attributes: &'a FighterAttributes,
attack_scripts: &'a Assets<FighterAttackScript>,
attack_script_assets: &'a FighterAttackScriptAssets,
stage_collision: &'a StageCollision,
) -> UpdateContext<'a> {
let animation_root_motion_delta = animation_position_to_gameplay(
self.bone_matrices.root_motion_delta(),
&self.facing_direction,
);
let animation_root_position = self
.bone_matrices
.root_motion_position()
.map(|_| self.translation.0 + animation_root_motion_delta);
UpdateContext {
translation: &mut self.translation,
prev_translation: &mut self.prev_translation,
@@ -377,24 +437,28 @@ 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_root_motion_delta,
animation_root_position,
animation_frame: self.animation_frame.frame,
vulnerability: &mut self.vulnerability,
fighter_attributes: attributes,
attack_scripts,
attack_script_assets,
facing_direction: &self.facing_direction,
stage_collision: &stage_collision,
}
}
fn collision_context<'a>(
&'a mut self,
stage_collision: &'a StageCollision,
fighter_manifest: &'a FighterManifest,
game_settings: &'a GameSettings,
) -> CollisionContext<'a> {
let fighter_ledge_collision_box = FGRect {
origin: self.translation.0 + fighter_manifest.ledge_grab_box_start,
end: self.translation.0 + fighter_manifest.ledge_grab_box_end,
};
CollisionContext {
translation: &mut self.translation,
prev_translation: &self.prev_translation,
@@ -409,6 +473,8 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> {
.fighter_common
.leave_ground_ecb_frame_lock_duration,
downward_stick_threshold: game_settings.input_common.run_stick_threshold,
fighter_ledge_collision_box,
ledge_grab_lockout_active: self.ledge_grab_lockout.is_active(),
}
}
}
@@ -432,6 +498,7 @@ pub struct StatefulFighterQuery {
pub fn state_update_system(
mut query: Query<StatefulFighterQuery>,
game_settings: Res<GameSettings>,
stage_collision: Res<StageCollision>,
attack_scripts: Res<Assets<FighterAttackScript>>,
manifests: Res<Assets<FighterManifest>>,
) {
@@ -455,6 +522,7 @@ pub fn state_update_system(
&manifest.attributes,
&attack_scripts,
&fighter.attack_script_assets,
&stage_collision,
);
state.update(&mut update_ctx);
}
@@ -466,7 +534,10 @@ fn transition_state(
frame: &mut FighterFrameQueryItem,
attributes: &FighterAttributes,
attack_script_assets: &FighterAttackScriptAssets,
stage_collision: &StageCollision,
game_settings: &GameSettings,
baked_animations: &BakedFighterAnimations,
model_scale: FGi32,
) {
let old_name = current.name();
let new_name = next.name();
@@ -480,7 +551,14 @@ fn transition_state(
.leave_ground_ecb_frame_lock_duration,
);
}
next.on_enter(&mut frame.enter_context(attributes, attack_script_assets, game_settings));
next.on_enter(&mut frame.enter_context(
attributes,
attack_script_assets,
game_settings,
stage_collision,
baked_animations,
model_scale,
));
*current = next;
info!("{} -> {}", old_name, new_name);
}
@@ -508,6 +586,9 @@ pub fn state_interrupt_system(
let manifest = fighter_manifests
.get(&fighter.manifest)
.expect("Fighter manifest should be valid");
let baked = baked_animations
.get(&animations.baked)
.expect("Animation should exist");
let new_state = if let Some(reaction) = pending_hit_reaction.take() {
hitstop.remaining_frames = *reaction.victim_hitstop_frames;
@@ -579,7 +660,10 @@ pub fn state_interrupt_system(
&mut frame,
&manifest.attributes,
attack_script_assets,
&stage_collision,
&game_settings,
baked,
manifest.model_scale,
);
};
}
@@ -594,6 +678,7 @@ pub fn state_collision_interrupt_system(
mut match_state: ResMut<MatchState>,
stage_registry: Res<StageManifestRegistry>,
stage_manifests: Res<Assets<StageManifest>>,
baked_animations: Res<Assets<BakedFighterAnimations>>,
) {
let Some(stage_manifest_handle) = stage_registry.get(match_config.stage) else {
return;
@@ -608,6 +693,7 @@ pub fn state_collision_interrupt_system(
mut state,
mut frame,
fighter,
animations,
attack_script_assets,
mut hitstop,
mut pending_respawn,
@@ -619,6 +705,9 @@ pub fn state_collision_interrupt_system(
let manifest = fighter_manifests
.get(&fighter.manifest)
.expect("Fighter manifest should be valid");
let baked = baked_animations
.get(&animations.baked)
.expect("Animation should exist");
if pending_respawn.0 {
**pending_respawn = false;
@@ -665,7 +754,10 @@ pub fn state_collision_interrupt_system(
&mut frame,
&manifest.attributes,
attack_script_assets,
&stage_collision,
&game_settings,
baked,
manifest.model_scale,
);
continue;
}
@@ -675,7 +767,7 @@ pub fn state_collision_interrupt_system(
}
let new_state = state.check_collision_interrupt(
&mut frame.collision_context(&stage_collision, &game_settings),
&mut frame.collision_context(&stage_collision, manifest, &game_settings),
);
if let Some(new_state) = new_state {
transition_state(
@@ -684,8 +776,81 @@ pub fn state_collision_interrupt_system(
&mut frame,
&manifest.attributes,
&attack_script_assets,
&stage_collision,
&game_settings,
baked,
manifest.model_scale,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fighter::ecb::FighterPreviousECB;
use crate::stage::line::{StageLedge, StageLedgeSide, StageVertexID};
#[test]
fn animation_positions_map_forward_to_facing_and_preserve_height() {
let position = FGVec3::lit("99", "3", "5");
assert_eq!(
animation_position_to_gameplay(position, &FighterFacingDirection::Right),
FGVec2::lit("5", "3")
);
assert_eq!(
animation_position_to_gameplay(position, &FighterFacingDirection::Left),
FGVec2::lit("-5", "3")
);
}
#[test]
fn ledge_lockout_suppresses_ledge_collision() {
let ledge_id = StageVertexID {
polygon: 0,
vertex: 0,
};
let stage_collision = StageCollision {
ledges: vec![StageLedge {
id: ledge_id,
position: FGVec2::ZERO,
hang_side: StageLedgeSide::Left,
}],
..default()
};
let previous_translation = FighterPreviousTranslation::default();
let input = FighterInput::default();
let mut translation = FighterTranslation::default();
let mut velocity = FighterVelocity::default();
let mut surface = FighterSurfaceState::default();
let mut ecb = FighterECB::default();
let previous_ecb = FighterPreviousECB::from(ecb);
let mut presentation_snap = FighterPresentationSnap::default();
let mut context = CollisionContext {
translation: &mut translation,
prev_translation: &previous_translation,
input: &input,
velocity: &mut velocity,
surface: &mut surface,
ecb: &mut ecb,
prev_ecb: &previous_ecb,
stage_collision: &stage_collision,
leave_ground_ecb_lock_duration: 0,
presentation_snap: &mut presentation_snap,
fighter_ledge_collision_box: FGRect {
origin: FGVec2::lit("-1", "1"),
end: FGVec2::lit("1", "-1"),
},
ledge_grab_lockout_active: true,
downward_stick_threshold: FGi32::ONE,
};
assert!(context.air_collide_with_ledges().is_none());
context.ledge_grab_lockout_active = false;
assert_eq!(
context.air_collide_with_ledges().map(|result| result.ledge),
Some(ledge_id)
);
}
}
+2
View File
@@ -45,6 +45,8 @@ pub struct FighterSettingsCommon {
pub leave_ground_ecb_frame_lock_duration: u32,
pub platform_pass_platform_ignore_duration_frames: u32,
pub platform_pass_initial_downwards_velocity: FGi32,
/// Frames during which a fighter cannot grab any ledge after dropping from one.
pub ledge_drop_regrab_lockout_duration_frames: u32,
pub intangible_color: Color,
pub intangible_pulse_duration: u32,
+1
View File
@@ -3,3 +3,4 @@ pub mod segment;
pub mod vec;
pub mod fixed_transform;
pub mod vec3;
pub mod rect;
+12
View File
@@ -0,0 +1,12 @@
use crate::math::vec::FGVec2;
pub struct FGRect {
pub origin: FGVec2,
pub end: FGVec2,
}
impl FGRect {
pub fn contains_point(&self, point: FGVec2) -> bool {
point.x >= self.origin.x && point.y <= self.origin.y && point.x <= self.end.x && point.y >= self.end.y
}
}
+16 -2
View File
@@ -2,14 +2,14 @@ use bevy::{color::palettes::css::RED, prelude::*};
use crate::{
match_loading::MatchConfig, math::{int::FGi32, vec3::FGVec3}, stage::{
line::StageCollision,
line::{StageCollision, StageLedgeSide},
manifest::{StageManifest, StageManifestRegistry},
},
};
pub fn debug_draw_scene(mut gizmos: Gizmos, stage_collision: Res<StageCollision>) {
for poly in stage_collision.stage_polys.iter() {
for segment in poly.segments.iter() {
for segment in &poly.segments {
let from = segment.segment.point1().to_vec2();
let to = segment.segment.point2().to_vec2();
let from_3d = Vec3::new(from.x, from.y, 0.0);
@@ -20,6 +20,20 @@ pub fn debug_draw_scene(mut gizmos: Gizmos, stage_collision: Res<StageCollision>
gizmos.arrow(midpoint, midpoint + normal_3d, RED);
}
}
for ledge in &stage_collision.ledges {
let position = ledge.position.to_vec2().extend(0.0);
let direction = match ledge.hang_side {
StageLedgeSide::Left => Vec3::NEG_X,
StageLedgeSide::Right => Vec3::X,
};
gizmos.circle(position, 1.5, bevy::color::palettes::css::LIME);
gizmos.arrow(
position,
position + direction * 4.0,
bevy::color::palettes::css::LIME,
);
}
}
pub fn debug_draw_blast_zones(
+51 -1
View File
@@ -3,7 +3,7 @@
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
use crate::math::{segment::FGSegment2d, vec::FGVec2};
use crate::math::{rect::FGRect, segment::FGSegment2d, vec::FGVec2};
#[derive(Reflect, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum StagePolyLineSegmentType {
@@ -32,6 +32,27 @@ pub struct StageLineID {
pub segment: usize,
}
/// Identifies a corner in the stage collision geometry.
#[derive(Reflect, Hash, Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct StageVertexID {
pub polygon: usize,
pub vertex: usize,
}
/// The side of a ledge on which a grabbing fighter hangs.
#[derive(Reflect, Serialize, Deserialize, Hash, Debug, Clone, Copy, PartialEq, Eq)]
pub enum StageLedgeSide {
Left,
Right,
}
#[derive(Reflect, Hash, Debug, Clone, Copy, PartialEq)]
pub struct StageLedge {
pub id: StageVertexID,
pub position: FGVec2,
pub hang_side: StageLedgeSide,
}
#[derive(Reflect, Clone, Default)]
pub struct StagePoly {
pub poly_type: StagePolyType,
@@ -44,10 +65,39 @@ pub struct StagePlaneIntersectResult {
pub normal: FGVec2,
}
pub struct StageLedgeIntersectResult {
pub vertex_id: StageVertexID,
pub ledge_idx: usize
}
#[derive(Resource, Reflect, Clone, Default)]
#[reflect(Resource)]
pub struct StageCollision {
pub stage_polys: Vec<StagePoly>,
pub ledges: Vec<StageLedge>,
}
impl StageCollision {
pub fn intersect_ledges(&self, ledge_grab_box: &FGRect) -> Option<StageLedgeIntersectResult> {
for (ledge_idx, ledge) in self.ledges.iter().enumerate() {
if ledge_grab_box.contains_point(ledge.position) {
return Some(StageLedgeIntersectResult {
vertex_id: ledge.id,
ledge_idx
});
}
}
None
}
pub fn find_ledge_by_vertex(&self, vertex: StageVertexID) -> Option<&StageLedge> {
self.ledges.iter().find(|ledge| ledge.id == vertex)
}
pub fn get_segment(&self, segment: StageLineID) -> Option<&StagePolyLineSegment> {
self.stage_polys.get(segment.polygon).map(|poly| poly.segments.get(segment.segment))?
}
}
impl StagePoly {
+189 -2
View File
@@ -9,7 +9,10 @@ use strum_macros::EnumIter;
use crate::{
AppState, game_settings::GameSettings, math::{vec::FGVec2}, stage::{
StagePoly,
line::{StageCollision, StagePolyLineSegmentType, StagePolyType},
line::{
StageCollision, StageLedge, StageLedgeSide, StagePolyLineSegment,
StagePolyLineSegmentType, StagePolyType, StageVertexID,
},
}, texture_reference::TextureReference,
};
use bevy::prelude::*;
@@ -71,6 +74,16 @@ pub enum StageId {
pub struct StagePolyDefinition {
poly_type: StagePolyType,
lines: Vec<(FGVec2, StagePolyLineSegmentType)>,
#[serde(default)]
ledges: Vec<StageLedgeDefinition>,
}
#[derive(Serialize, Deserialize, Clone, Copy, Reflect)]
pub struct StageLedgeDefinition {
/// Index into the polygon's `lines`, identifying the corner position.
vertex: usize,
/// Side of the corner on which a grabbing fighter will hang.
hang_side: StageLedgeSide,
}
#[derive(Deserialize, Clone, Asset, Reflect, RonAsset)]
@@ -176,6 +189,13 @@ pub fn prepare_stage_manifests(
);
return;
}
if let Err(error) = manifest.validate_ledges() {
fail(
&mut next_state,
&format!("{:?} has invalid ledges: {error}", manifest.id),
);
return;
}
if registry.insert(manifest.id, handle.clone()).is_some() {
fail(
&mut next_state,
@@ -267,6 +287,14 @@ impl StageManifest {
Ok(())
}
fn validate_ledges(&self) -> Result<(), String> {
for (polygon_index, definition) in self.polygons.iter().enumerate() {
definition.validate_ledges(polygon_index)?;
}
Ok(())
}
pub fn to_collision(&self) -> StageCollision {
let stage_polys: Vec<StagePoly> = self
.polygons
@@ -274,7 +302,84 @@ impl StageManifest {
.map(|def| StagePoly::build(def.poly_type, &def.lines))
.collect();
StageCollision { stage_polys }
let ledges = self
.polygons
.iter()
.enumerate()
.flat_map(|(polygon, definition)| definition.resolved_ledges(polygon))
.collect();
StageCollision {
stage_polys,
ledges,
}
}
}
impl StagePolyDefinition {
fn validate_ledges(&self, polygon_index: usize) -> Result<(), String> {
let polygon = StagePoly::build(self.poly_type, &self.lines);
let mut vertices = HashSet::with_capacity(self.ledges.len());
for ledge in &self.ledges {
if ledge.vertex >= self.lines.len() {
return Err(format!(
"polygon {polygon_index} references missing vertex {}",
ledge.vertex
));
}
if !vertices.insert(ledge.vertex) {
return Err(format!(
"polygon {polygon_index} defines vertex {} as a ledge more than once",
ledge.vertex
));
}
let outgoing = polygon.get_segment(ledge.vertex);
let incoming_index = if ledge.vertex > 0 {
Some(ledge.vertex - 1)
} else if matches!(self.poly_type, StagePolyType::Closed) {
polygon.segments.len().checked_sub(1)
} else {
None
};
let incoming = incoming_index.and_then(|index| polygon.get_segment(index));
let is_walkable_floor = |segment: Option<&StagePolyLineSegment>| {
segment.is_some_and(|segment| {
segment.segment_type == StagePolyLineSegmentType::Floor
&& segment.normal.y > crate::math::int::FGi32::ZERO
})
};
let (interior, exterior) = match ledge.hang_side {
StageLedgeSide::Left => (outgoing, incoming),
StageLedgeSide::Right => (incoming, outgoing),
};
if !is_walkable_floor(interior) {
return Err(format!(
"polygon {polygon_index} vertex {} has no upward-facing floor opposite its {:?} hang side",
ledge.vertex, ledge.hang_side
));
}
if is_walkable_floor(exterior) {
return Err(format!(
"polygon {polygon_index} vertex {} is between two walkable floors rather than at an exposed corner",
ledge.vertex
));
}
}
Ok(())
}
fn resolved_ledges(&self, polygon: usize) -> impl Iterator<Item = StageLedge> + '_ {
self.ledges.iter().map(move |ledge| StageLedge {
id: StageVertexID {
polygon,
vertex: ledge.vertex,
},
position: self.lines[ledge.vertex].0,
hang_side: ledge.hang_side,
})
}
}
@@ -319,3 +424,85 @@ impl StageCameraProfile {
< 90.0
}
}
#[cfg(test)]
mod tests {
use super::*;
fn platform(ledges: Vec<StageLedgeDefinition>) -> StagePolyDefinition {
StagePolyDefinition {
poly_type: StagePolyType::Platform,
lines: vec![
(FGVec2::lit("-5", "0"), StagePolyLineSegmentType::Floor),
(FGVec2::lit("5", "0"), StagePolyLineSegmentType::Floor),
],
ledges,
}
}
#[test]
fn platform_endpoints_can_be_defined_as_ledges() {
let definition = platform(vec![
StageLedgeDefinition {
vertex: 0,
hang_side: StageLedgeSide::Left,
},
StageLedgeDefinition {
vertex: 1,
hang_side: StageLedgeSide::Right,
},
]);
assert_eq!(definition.validate_ledges(3), Ok(()));
let ledges = definition.resolved_ledges(3).collect::<Vec<_>>();
assert_eq!(ledges[0].id, StageVertexID { polygon: 3, vertex: 0 });
assert_eq!(ledges[0].position, FGVec2::lit("-5", "0"));
assert_eq!(ledges[1].id, StageVertexID { polygon: 3, vertex: 1 });
assert_eq!(ledges[1].position, FGVec2::lit("5", "0"));
}
#[test]
fn invalid_and_duplicate_ledge_vertices_are_rejected() {
let missing = platform(vec![StageLedgeDefinition {
vertex: 2,
hang_side: StageLedgeSide::Right,
}]);
assert!(missing.validate_ledges(0).is_err());
let duplicate = platform(vec![
StageLedgeDefinition {
vertex: 0,
hang_side: StageLedgeSide::Left,
},
StageLedgeDefinition {
vertex: 0,
hang_side: StageLedgeSide::Right,
},
]);
assert!(duplicate.validate_ledges(0).is_err());
}
#[test]
fn ledge_must_border_an_upward_facing_floor() {
let definition = StagePolyDefinition {
poly_type: StagePolyType::Platform,
lines: vec![
(FGVec2::lit("0", "0"), StagePolyLineSegmentType::Wall),
(FGVec2::lit("0", "5"), StagePolyLineSegmentType::Wall),
],
ledges: vec![StageLedgeDefinition {
vertex: 0,
hang_side: StageLedgeSide::Left,
}],
};
assert!(definition.validate_ledges(0).is_err());
let wrong_side = platform(vec![StageLedgeDefinition {
vertex: 0,
hang_side: StageLedgeSide::Right,
}]);
assert!(wrong_side.validate_ledges(0).is_err());
}
}