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

Reviewed-on: #90
Co-authored-by: Álex Román Núñez <eirexe@eirteam.moe>
This commit was merged in pull request #90.
This commit is contained in:
2026-10-12 20:58:27 +00:00
committed by EIREXE
parent 0642a9234b
commit 9b974d0dd6
22 changed files with 1094 additions and 141 deletions
+176 -11
View File
@@ -20,14 +20,16 @@ use crate::fighter::visual::FighterAnimations;
use crate::fighter::{
Fighter, FighterDamage, FighterECB, FighterFacingDirection, FighterHitboxes, FighterHitstop,
FighterHitstopShake, FighterHitstopShakeKind, FighterHitstopShakeRole,
FighterInvulnerabilityState, FighterPendingRespawn, FighterPresentationSnap,
FighterPreviousTranslation, FighterSurfaceState, FighterTranslation, FighterVelocity,
FighterInvulnerabilityState, FighterLedgeGrabLockout, FighterPendingRespawn,
FighterPresentationSnap, FighterPreviousTranslation, FighterSurfaceState, FighterTranslation,
FighterVelocity,
};
use crate::game_settings::GameSettings;
use crate::input::FighterInput;
use crate::match_loading::MatchConfig;
use crate::match_runtime::MatchState;
use crate::math::int::FGi32;
use crate::math::rect::FGRect;
use crate::math::vec::FGVec2;
use crate::math::vec3::FGVec3;
use crate::player::Player;
@@ -52,6 +54,8 @@ pub mod run;
pub mod turn;
pub mod wait;
pub mod walk;
pub mod ledge_wait;
pub mod ledge_action;
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
pub enum SurfacePolicy {
@@ -135,6 +139,7 @@ pub struct AnimationStatus {
pub struct InterruptContext<'a> {
pub input: &'a FighterInput,
pub facing_direction: &'a mut FighterFacingDirection,
pub ledge_grab_lockout: &'a mut FighterLedgeGrabLockout,
pub game_settings: &'a GameSettings,
pub fighter_attributes: &'a FighterAttributes,
pub surface: &'a FighterSurfaceState,
@@ -158,21 +163,38 @@ pub struct EnterContext<'a> {
pub input: &'a mut FighterInput,
pub hitboxes: &'a mut FighterHitboxes,
pub velocity: &'a mut FighterVelocity,
pub translation: &'a mut FighterTranslation,
pub knockback_velocity: &'a mut FighterKnockbackVelocity,
pub stale_move_queue: &'a mut FighterStaleMoveQueue,
pub facing_direction: &'a mut FighterFacingDirection,
pub animation_frame: &'a mut FighterAnimationFrame,
pub presentation_snap: &'a mut FighterPresentationSnap,
pub surface: &'a mut FighterSurfaceState,
pub vulnerability: &'a mut FighterInvulnerabilityState,
pub fighter_attributes: &'a FighterAttributes,
pub attack_script_assets: &'a FighterAttackScriptAssets,
pub game_settings: &'a GameSettings,
pub stage_collision: &'a StageCollision,
baked_animations: &'a BakedFighterAnimations,
model_scale: FGi32,
}
impl EnterContext<'_> {
pub fn play_animation(&mut self, kind: AnimKind, repeat: bool) {
self.animation_frame.reset(kind, repeat);
}
/// World-space root position authored for frame zero of the animation most
/// recently selected with [`Self::play_animation`].
pub fn initial_animation_root_position(&self) -> Option<FGVec2> {
let position = self
.baked_animations
.initial_root_position(self.animation_frame.kind, self.model_scale)?;
Some(
self.translation.0
+ animation_position_to_gameplay(position, self.facing_direction),
)
}
}
pub struct ExitContext<'a> {
@@ -195,18 +217,22 @@ pub struct UpdateContext<'a> {
/// Root motion in the fighter's gameplay plane: X is world-right and Y is
/// world-up. Animation +Z is forward and is mirrored by facing direction.
pub animation_root_motion_delta: FGVec2,
/// Current world-space root position implied by this animation frame.
/// States opt into root motion by assigning this to [`FighterTranslation`].
pub animation_root_position: Option<FGVec2>,
pub animation_frame: u32,
pub fighter_attributes: &'a FighterAttributes,
pub attack_scripts: &'a Assets<FighterAttackScript>,
pub attack_script_assets: &'a FighterAttackScriptAssets,
pub facing_direction: &'a FighterFacingDirection,
pub stage_collision: &'a StageCollision,
}
fn animation_root_motion_to_gameplay(
delta: FGVec3,
fn animation_position_to_gameplay(
position: FGVec3,
facing_direction: &FighterFacingDirection,
) -> FGVec2 {
FGVec2::new(delta.z * facing_direction.to_sign(), delta.y)
FGVec2::new(position.z * facing_direction.to_sign(), position.y)
}
impl UpdateContext<'_> {
@@ -226,6 +252,8 @@ pub struct CollisionContext<'a> {
pub stage_collision: &'a StageCollision,
pub leave_ground_ecb_lock_duration: u32,
pub presentation_snap: &'a mut FighterPresentationSnap,
pub fighter_ledge_collision_box: FGRect,
pub ledge_grab_lockout_active: bool,
pub downward_stick_threshold: FGi32,
}
@@ -247,6 +275,17 @@ impl CollisionContext<'_> {
ignore_soft_platforms,
)
}
pub fn air_collide_with_ledges(
&self,
) -> Option<crate::fighter::collision::AirCollisionWithLedgeResult> {
if self.ledge_grab_lockout_active {
return None;
}
crate::fighter::collision::air_collide_with_ledges(
&self.fighter_ledge_collision_box,
self.stage_collision,
)
}
}
fighter_states! {
@@ -260,6 +299,8 @@ fighter_states! {
JumpSquat => jump::JumpSquatState,
Jump => jump::JumpState,
Land => land::LandingState,
LedgeWait => ledge_wait::LedgeWaitState,
LedgeAction => ledge_action::LedgeActionState,
AirDodge => air_dodge::AirDodgeState,
Turn => turn::TurnState,
GroundAttack => ground_attack::GroundAttackState,
@@ -285,6 +326,7 @@ pub struct FighterFrameQuery {
pub facing_direction: &'static mut FighterFacingDirection,
pub animation_frame: &'static mut FighterAnimationFrame,
pub vulnerability: &'static mut FighterInvulnerabilityState,
pub ledge_grab_lockout: &'static mut FighterLedgeGrabLockout,
pub fighter: &'static Fighter,
pub bone_matrices: &'static FighterBoneMatrices,
}
@@ -320,6 +362,7 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> {
InterruptContext {
input: &self.input,
facing_direction: &mut self.facing_direction,
ledge_grab_lockout: &mut self.ledge_grab_lockout,
game_settings,
fighter_attributes: attributes,
animation,
@@ -335,20 +378,28 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> {
attributes: &'a FighterAttributes,
attack_script_assets: &'a FighterAttackScriptAssets,
game_settings: &'a GameSettings,
stage_collision: &'a StageCollision,
baked_animations: &'a BakedFighterAnimations,
model_scale: FGi32,
) -> EnterContext<'a> {
EnterContext {
input: &mut self.input,
hitboxes: &mut self.hitboxes,
velocity: &mut self.velocity,
knockback_velocity: &mut self.knockback_velocity,
translation: &mut self.translation,
stale_move_queue: &mut self.stale_move_queue,
facing_direction: &mut self.facing_direction,
animation_frame: &mut self.animation_frame,
surface: &mut self.surface,
vulnerability: &mut self.vulnerability,
presentation_snap: &mut self.presentation_snap,
fighter_attributes: attributes,
attack_script_assets,
game_settings,
stage_collision,
baked_animations,
model_scale,
}
}
@@ -367,7 +418,16 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> {
attributes: &'a FighterAttributes,
attack_scripts: &'a Assets<FighterAttackScript>,
attack_script_assets: &'a FighterAttackScriptAssets,
stage_collision: &'a StageCollision,
) -> UpdateContext<'a> {
let animation_root_motion_delta = animation_position_to_gameplay(
self.bone_matrices.root_motion_delta(),
&self.facing_direction,
);
let animation_root_position = self
.bone_matrices
.root_motion_position()
.map(|_| self.translation.0 + animation_root_motion_delta);
UpdateContext {
translation: &mut self.translation,
prev_translation: &mut self.prev_translation,
@@ -377,24 +437,28 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> {
surface: &mut self.surface,
knockback_velocity: &mut self.knockback_velocity,
game_settings,
animation_root_motion_delta: animation_root_motion_to_gameplay(
self.bone_matrices.root_motion_delta(),
&self.facing_direction,
),
animation_root_motion_delta,
animation_root_position,
animation_frame: self.animation_frame.frame,
vulnerability: &mut self.vulnerability,
fighter_attributes: attributes,
attack_scripts,
attack_script_assets,
facing_direction: &self.facing_direction,
stage_collision: &stage_collision,
}
}
fn collision_context<'a>(
&'a mut self,
stage_collision: &'a StageCollision,
fighter_manifest: &'a FighterManifest,
game_settings: &'a GameSettings,
) -> CollisionContext<'a> {
let fighter_ledge_collision_box = FGRect {
origin: self.translation.0 + fighter_manifest.ledge_grab_box_start,
end: self.translation.0 + fighter_manifest.ledge_grab_box_end,
};
CollisionContext {
translation: &mut self.translation,
prev_translation: &self.prev_translation,
@@ -409,6 +473,8 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> {
.fighter_common
.leave_ground_ecb_frame_lock_duration,
downward_stick_threshold: game_settings.input_common.run_stick_threshold,
fighter_ledge_collision_box,
ledge_grab_lockout_active: self.ledge_grab_lockout.is_active(),
}
}
}
@@ -432,6 +498,7 @@ pub struct StatefulFighterQuery {
pub fn state_update_system(
mut query: Query<StatefulFighterQuery>,
game_settings: Res<GameSettings>,
stage_collision: Res<StageCollision>,
attack_scripts: Res<Assets<FighterAttackScript>>,
manifests: Res<Assets<FighterManifest>>,
) {
@@ -455,6 +522,7 @@ pub fn state_update_system(
&manifest.attributes,
&attack_scripts,
&fighter.attack_script_assets,
&stage_collision,
);
state.update(&mut update_ctx);
}
@@ -466,7 +534,10 @@ fn transition_state(
frame: &mut FighterFrameQueryItem,
attributes: &FighterAttributes,
attack_script_assets: &FighterAttackScriptAssets,
stage_collision: &StageCollision,
game_settings: &GameSettings,
baked_animations: &BakedFighterAnimations,
model_scale: FGi32,
) {
let old_name = current.name();
let new_name = next.name();
@@ -480,7 +551,14 @@ fn transition_state(
.leave_ground_ecb_frame_lock_duration,
);
}
next.on_enter(&mut frame.enter_context(attributes, attack_script_assets, game_settings));
next.on_enter(&mut frame.enter_context(
attributes,
attack_script_assets,
game_settings,
stage_collision,
baked_animations,
model_scale,
));
*current = next;
info!("{} -> {}", old_name, new_name);
}
@@ -508,6 +586,9 @@ pub fn state_interrupt_system(
let manifest = fighter_manifests
.get(&fighter.manifest)
.expect("Fighter manifest should be valid");
let baked = baked_animations
.get(&animations.baked)
.expect("Animation should exist");
let new_state = if let Some(reaction) = pending_hit_reaction.take() {
hitstop.remaining_frames = *reaction.victim_hitstop_frames;
@@ -579,7 +660,10 @@ pub fn state_interrupt_system(
&mut frame,
&manifest.attributes,
attack_script_assets,
&stage_collision,
&game_settings,
baked,
manifest.model_scale,
);
};
}
@@ -594,6 +678,7 @@ pub fn state_collision_interrupt_system(
mut match_state: ResMut<MatchState>,
stage_registry: Res<StageManifestRegistry>,
stage_manifests: Res<Assets<StageManifest>>,
baked_animations: Res<Assets<BakedFighterAnimations>>,
) {
let Some(stage_manifest_handle) = stage_registry.get(match_config.stage) else {
return;
@@ -608,6 +693,7 @@ pub fn state_collision_interrupt_system(
mut state,
mut frame,
fighter,
animations,
attack_script_assets,
mut hitstop,
mut pending_respawn,
@@ -619,6 +705,9 @@ pub fn state_collision_interrupt_system(
let manifest = fighter_manifests
.get(&fighter.manifest)
.expect("Fighter manifest should be valid");
let baked = baked_animations
.get(&animations.baked)
.expect("Animation should exist");
if pending_respawn.0 {
**pending_respawn = false;
@@ -665,7 +754,10 @@ pub fn state_collision_interrupt_system(
&mut frame,
&manifest.attributes,
attack_script_assets,
&stage_collision,
&game_settings,
baked,
manifest.model_scale,
);
continue;
}
@@ -675,7 +767,7 @@ pub fn state_collision_interrupt_system(
}
let new_state = state.check_collision_interrupt(
&mut frame.collision_context(&stage_collision, &game_settings),
&mut frame.collision_context(&stage_collision, manifest, &game_settings),
);
if let Some(new_state) = new_state {
transition_state(
@@ -684,8 +776,81 @@ pub fn state_collision_interrupt_system(
&mut frame,
&manifest.attributes,
&attack_script_assets,
&stage_collision,
&game_settings,
baked,
manifest.model_scale,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fighter::ecb::FighterPreviousECB;
use crate::stage::line::{StageLedge, StageLedgeSide, StageVertexID};
#[test]
fn animation_positions_map_forward_to_facing_and_preserve_height() {
let position = FGVec3::lit("99", "3", "5");
assert_eq!(
animation_position_to_gameplay(position, &FighterFacingDirection::Right),
FGVec2::lit("5", "3")
);
assert_eq!(
animation_position_to_gameplay(position, &FighterFacingDirection::Left),
FGVec2::lit("-5", "3")
);
}
#[test]
fn ledge_lockout_suppresses_ledge_collision() {
let ledge_id = StageVertexID {
polygon: 0,
vertex: 0,
};
let stage_collision = StageCollision {
ledges: vec![StageLedge {
id: ledge_id,
position: FGVec2::ZERO,
hang_side: StageLedgeSide::Left,
}],
..default()
};
let previous_translation = FighterPreviousTranslation::default();
let input = FighterInput::default();
let mut translation = FighterTranslation::default();
let mut velocity = FighterVelocity::default();
let mut surface = FighterSurfaceState::default();
let mut ecb = FighterECB::default();
let previous_ecb = FighterPreviousECB::from(ecb);
let mut presentation_snap = FighterPresentationSnap::default();
let mut context = CollisionContext {
translation: &mut translation,
prev_translation: &previous_translation,
input: &input,
velocity: &mut velocity,
surface: &mut surface,
ecb: &mut ecb,
prev_ecb: &previous_ecb,
stage_collision: &stage_collision,
leave_ground_ecb_lock_duration: 0,
presentation_snap: &mut presentation_snap,
fighter_ledge_collision_box: FGRect {
origin: FGVec2::lit("-1", "1"),
end: FGVec2::lit("1", "-1"),
},
ledge_grab_lockout_active: true,
downward_stick_threshold: FGi32::ONE,
};
assert!(context.air_collide_with_ledges().is_none());
context.ledge_grab_lockout_active = false;
assert_eq!(
context.air_collide_with_ledges().map(|result| result.ledge),
Some(ledge_id)
);
}
}