diff --git a/src/debug_tools.rs b/src/debug_tools.rs index 6bbfc8b..ad3bce5 100644 --- a/src/debug_tools.rs +++ b/src/debug_tools.rs @@ -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) -> bool { settings.camera } +pub fn ledge_grab_enabled(settings: Res) -> bool { + settings.ledge_grab +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/fighter/animation.rs b/src/fighter/animation.rs index 6020a95..6d78db4 100644 --- a/src/fighter/animation.rs +++ b/src/fighter/animation.rs @@ -87,6 +87,8 @@ pub enum AnimKind { CrouchEnter, CrouchIdle, CrouchExit, + LedgeWait, + LedgeJump, PlatformPass, diff --git a/src/fighter/baked_animation/asset_baking.rs b/src/fighter/baked_animation/asset_baking.rs index 6a639c6..6f958a9 100644 --- a/src/fighter/baked_animation/asset_baking.rs +++ b/src/fighter/baked_animation/asset_baking.rs @@ -27,6 +27,7 @@ struct FighterBakeSource { model_bytes: Vec, buffers: Vec>, animations: HashMap, + 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, _: &'a Self::Settings, ) -> Result, 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], animations: &HashMap, + root_motion_bone: &str, ) -> io::Result { 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, animations: &HashMap, + root_motion_bone: &str, ) -> io::Result { let nodes: Vec<_> = document.nodes().collect(); if nodes.is_empty() { @@ -260,6 +269,15 @@ pub(super) fn bake_gltf_document( }) }) .collect::>>()?; + 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>::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::>>()?, - ); + let local = nodes + .iter() + .map(|node| local_transform(node.clone(), &channels, time)) + .collect::>>()?; + 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, }) diff --git a/src/fighter/baked_animation/mod.rs b/src/fighter/baked_animation/mod.rs index 644c991..5ab11cd 100644 --- a/src/fighter/baked_animation/mod.rs +++ b/src/fighter/baked_animation/mod.rs @@ -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, diff --git a/src/fighter/baked_animation/runtime.rs b/src/fighter/baked_animation/runtime.rs index d0fa735..65b58f4 100644 --- a/src/fighter/baked_animation/runtime.rs +++ b/src/fighter/baked_animation/runtime.rs @@ -21,6 +21,9 @@ use crate::{ #[derive(Clone, Debug, Serialize, Deserialize)] pub struct BakedAnimationClip { pub frames: Vec>, + /// Unscaled model-space position of the configured root-motion bone at + /// each baked frame. + pub root_positions: Vec, } #[derive(Asset, bevy::reflect::TypePath, Clone, Debug, Serialize, Deserialize)] @@ -29,11 +32,22 @@ pub struct BakedFighterAnimations { pub frame_rate: u32, pub bone_names: Vec, pub parents: Vec>, + pub(super) root_bone_index: usize, pub(super) clips: Vec, pub(super) clip_indices: HashMap, } impl BakedFighterAnimations { + fn sample_index(&self, frame: &FighterAnimationFrame) -> Option { + 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 { + 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 { + self.sample_root_position(&FighterAnimationFrame::new(kind, false), model_scale) } pub fn frame_count(&self, kind: AnimKind) -> Option { 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 { - 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>, presentation_kind: Option, previous_animation_frame: Option, + root_motion_position: Option, /// The raw, scaled model-space root translation used as the baseline for /// root-motion extraction. This is kept separately because the published /// pose has the root translation removed. previous_root_motion_translation: Option, root_motion_delta: crate::math::vec3::FGVec3, - cached_root_motion_kind: Option, - 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 { + self.root_motion_position + } pub fn iter(&self) -> impl Iterator { 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, + root_motion_position: Option, root_motion_cycle_delta: FGVec3, root_motion_loop_frame_count: Option, 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, + root_motion_position: Option, _root_motion_cycle_delta: FGVec3, _root_motion_loop_frame_count: Option, 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, + raw_root_motion_translation: Option, root_motion_cycle_delta: FGVec3, root_motion_loop_frame_count: Option, 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], local: &[FixedMat4], diff --git a/src/fighter/collision.rs b/src/fighter/collision.rs index f9eae3b..592778f 100644 --- a/src/fighter/collision.rs +++ b/src/fighter/collision.rs @@ -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 { + 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")); diff --git a/src/fighter/debug.rs b/src/fighter/debug.rs index 6381bf6..9e255d9 100644 --- a/src/fighter/debug.rs +++ b/src/fighter/debug.rs @@ -216,6 +216,26 @@ pub fn hurtbox_debug( } } +pub fn ledge_grab_debug(query: Query<(&FighterTranslation, &Fighter)>, fighters: Res>, 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, diff --git a/src/fighter/manifest.rs b/src/fighter/manifest.rs index 2242531..d216154 100644 --- a/src/fighter/manifest.rs +++ b/src/fighter/manifest.rs @@ -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, pub hurtboxes: Vec, pub ecb_bones: Vec, + pub ledge_grab_box_start: FGVec2, + pub ledge_grab_box_end: FGVec2, } fn default_root_motion_bone() -> String { diff --git a/src/fighter/mod.rs b/src/fighter/mod.rs index e014070..eda26b1 100644 --- a/src/fighter/mod.rs +++ b/src/fighter/mod.rs @@ -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::() .rollback_component_with_copy::() .checksum_component_with_hash::() + .rollback_component_with_copy::() + .checksum_component_with_hash::() .rollback_component_with_copy::() .checksum_component_with_hash::() .rollback_component_with_copy::() @@ -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(); diff --git a/src/fighter/push.rs b/src/fighter/push.rs index 6681a61..dae9555 100644 --- a/src/fighter/push.rs +++ b/src/fighter/push.rs @@ -134,6 +134,7 @@ mod tests { (FGVec2::lit("30", "0"), StagePolyLineSegmentType::Floor), ], )], + ..StageCollision::default() } } diff --git a/src/fighter/state/air.rs b/src/fighter/state/air.rs index 0695dab..0a835ff 100644 --- a/src/fighter/state/air.rs +++ b/src/fighter/state/air.rs @@ -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 { + 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; diff --git a/src/fighter/state/ground.rs b/src/fighter/state/ground.rs index 219d13b..4b1798b 100644 --- a/src/fighter/state/ground.rs +++ b/src/fighter/state/ground.rs @@ -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(), + }) +} \ No newline at end of file diff --git a/src/fighter/state/jump.rs b/src/fighter/state/jump.rs index 10a9eef..a6b2efc 100644 --- a/src/fighter/state/jump.rs +++ b/src/fighter/state/jump.rs @@ -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 diff --git a/src/fighter/state/ledge_action.rs b/src/fighter/state/ledge_action.rs new file mode 100644 index 0000000..d18dc45 --- /dev/null +++ b/src/fighter/state/ledge_action.rs @@ -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 { + 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 { + None + } + + fn is_visually_grounded<'a>(&'a self) -> bool { + self.is_in_grounded_phase + } +} + diff --git a/src/fighter/state/ledge_wait.rs b/src/fighter/state/ledge_wait.rs new file mode 100644 index 0000000..e638e61 --- /dev/null +++ b/src/fighter/state/ledge_wait.rs @@ -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 { + 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 { + 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 +} + diff --git a/src/fighter/state/mod.rs b/src/fighter/state/mod.rs index c045966..ff9941d 100644 --- a/src/fighter/state/mod.rs +++ b/src/fighter/state/mod.rs @@ -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 { + 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, pub animation_frame: u32, pub fighter_attributes: &'a FighterAttributes, pub attack_scripts: &'a Assets, 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 { + 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, 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, game_settings: Res, + stage_collision: Res, attack_scripts: Res>, manifests: Res>, ) { @@ -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, stage_registry: Res, stage_manifests: Res>, + baked_animations: Res>, ) { 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) + ); + } +} diff --git a/src/game_settings.rs b/src/game_settings.rs index 628296f..1c0b053 100644 --- a/src/game_settings.rs +++ b/src/game_settings.rs @@ -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, diff --git a/src/math/mod.rs b/src/math/mod.rs index 6f49641..33fadf1 100644 --- a/src/math/mod.rs +++ b/src/math/mod.rs @@ -3,3 +3,4 @@ pub mod segment; pub mod vec; pub mod fixed_transform; pub mod vec3; +pub mod rect; diff --git a/src/math/rect.rs b/src/math/rect.rs new file mode 100644 index 0000000..816a4a3 --- /dev/null +++ b/src/math/rect.rs @@ -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 + } +} diff --git a/src/stage/debug.rs b/src/stage/debug.rs index 651556f..4a3bb12 100644 --- a/src/stage/debug.rs +++ b/src/stage/debug.rs @@ -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) { 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 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( diff --git a/src/stage/line.rs b/src/stage/line.rs index 7756285..1b0f1e2 100644 --- a/src/stage/line.rs +++ b/src/stage/line.rs @@ -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, + pub ledges: Vec, +} + +impl StageCollision { + pub fn intersect_ledges(&self, ledge_grab_box: &FGRect) -> Option { + 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 { diff --git a/src/stage/manifest.rs b/src/stage/manifest.rs index b765ede..a76dbb8 100644 --- a/src/stage/manifest.rs +++ b/src/stage/manifest.rs @@ -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, +} + +#[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 = 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 + '_ { + 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) -> 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::>(); + 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()); + } + +}