Initial work on ledge & improved baking of root motion. #90
@@ -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::*;
|
||||
|
||||
@@ -87,6 +87,8 @@ pub enum AnimKind {
|
||||
CrouchEnter,
|
||||
CrouchIdle,
|
||||
CrouchExit,
|
||||
LedgeWait,
|
||||
LedgeJump,
|
||||
|
||||
PlatformPass,
|
||||
|
||||
|
||||
@@ -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
|
||||
let local = nodes
|
||||
.iter()
|
||||
.map(|node| local_transform(node.clone(), &channels, time))
|
||||
.collect::<io::Result<Vec<_>>>()?,
|
||||
);
|
||||
.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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
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);
|
||||
matrices.cached_root_motion_kind = Some(frame.kind);
|
||||
matrices.cached_root_motion_cycle_delta = delta;
|
||||
delta
|
||||
};
|
||||
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],
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -134,6 +134,7 @@ mod tests {
|
||||
(FGVec2::lit("30", "0"), StagePolyLineSegmentType::Floor),
|
||||
],
|
||||
)],
|
||||
..StageCollision::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,3 +3,4 @@ pub mod segment;
|
||||
pub mod vec;
|
||||
pub mod fixed_transform;
|
||||
pub mod vec3;
|
||||
pub mod rect;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user