Add authoritative fighter surface state (#60)
CI / build-and-test (push) Successful in 1m28s
CI / production-build (push) Successful in 11m59s

## Summary
- add a rollback-tracked `FighterSurfaceState` as the single source of truth for stage contact
- move active stage line ownership out of individual grounded fighter states
- update landing, airborne motion, ledge departure, hit reactions, and debug save states to maintain surface contact
- add tests for contact lifecycle, connected-segment traversal, and ledge departure

Reviewed-on: #60
Co-authored-by: Álex Román Núñez <eirexe@eirteam.moe>
This commit was merged in pull request #60.
This commit is contained in:
2026-09-25 10:45:27 +00:00
committed by EIREXE
parent 3daf8f2e21
commit d521461625
18 changed files with 411 additions and 301 deletions
+34 -32
View File
@@ -7,7 +7,8 @@ use bevy::prelude::*;
use crate::fighter::animation::{AnimKind, FighterAnimationFrame};
use crate::fighter::attack::{
FighterAttackScriptAssets, FighterKnockbackVelocity, FighterPendingHitReaction, FighterStaleMoveQueue, HitStop,
FighterAttackScriptAssets, FighterKnockbackVelocity, FighterPendingHitReaction,
FighterStaleMoveQueue, HitStop,
};
use crate::fighter::attributes::FighterAttributes;
use crate::fighter::baked_animation::BakedFighterAnimations;
@@ -16,7 +17,9 @@ use crate::fighter::manifest::FighterManifest;
use crate::fighter::state::hitstun::HitstunState;
use crate::fighter::visual::FighterAnimations;
use crate::fighter::{
Fighter, FighterECB, FighterFacingDirection, FighterHitboxes, FighterHitstop, FighterHitstopShake, FighterHitstopShakeKind, FighterHitstopShakeRole, FighterPreviousTranslation, FighterTranslation, FighterVelocity,
Fighter, FighterECB, FighterFacingDirection, FighterHitboxes, FighterHitstop,
FighterHitstopShake, FighterHitstopShakeKind, FighterHitstopShakeRole,
FighterPreviousTranslation, FighterSurfaceState, FighterTranslation, FighterVelocity,
};
use crate::game_settings::GameSettings;
use crate::input::FighterInput;
@@ -95,9 +98,7 @@ pub trait FighterStateImpl: Sized {
None
}
fn is_visually_grounded<'a>(
&'a self,
) -> bool;
fn is_visually_grounded<'a>(&'a self) -> bool;
}
pub struct AnimationStatus {
@@ -151,6 +152,7 @@ pub struct UpdateContext<'a> {
pub input: &'a mut FighterInput,
pub hitboxes: &'a mut FighterHitboxes,
pub velocity: &'a mut FighterVelocity,
pub surface: &'a mut FighterSurfaceState,
pub knockback_velocity: &'a mut FighterKnockbackVelocity,
pub game_settings: &'a GameSettings,
pub animation_frame: u32,
@@ -170,6 +172,7 @@ pub struct CollisionContext<'a> {
pub translation: &'a mut FighterTranslation,
pub prev_translation: &'a FighterPreviousTranslation,
pub velocity: &'a mut FighterVelocity,
pub surface: &'a mut FighterSurfaceState,
pub ecb: &'a FighterECB,
pub prev_ecb: &'a FighterPreviousECB,
pub stage_collision: &'a StageCollision,
@@ -212,6 +215,7 @@ pub struct FighterFrameQuery {
pub input: &'static mut FighterInput,
pub hitboxes: &'static mut FighterHitboxes,
pub velocity: &'static mut FighterVelocity,
pub surface: &'static mut FighterSurfaceState,
pub knockback_velocity: &'static mut FighterKnockbackVelocity,
pub stale_move_queue: &'static mut FighterStaleMoveQueue,
pub ecb: &'static FighterECB,
@@ -292,13 +296,14 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> {
input: &mut self.input,
hitboxes: &mut self.hitboxes,
velocity: &mut self.velocity,
surface: &mut self.surface,
knockback_velocity: &mut self.knockback_velocity,
game_settings,
animation_frame: self.animation_frame.frame,
fighter_attributes: attributes,
attack_scripts,
attack_script_assets,
facing_direction: &self.facing_direction
facing_direction: &self.facing_direction,
}
}
@@ -310,6 +315,7 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> {
translation: &mut self.translation,
prev_translation: &self.prev_translation,
velocity: &mut self.velocity,
surface: &mut self.surface,
ecb: &self.ecb,
prev_ecb: &self.prev_ecb,
stage_collision,
@@ -400,7 +406,9 @@ pub fn state_interrupt_system(
let new_state = if let Some(reaction) = pending_hit_reaction.take() {
hitstop.remaining_frames = *reaction.hitstop_frames;
let shake_frames = reaction.hitstop_frames.calculate_shake_frames(&game_settings);
let shake_frames = reaction
.hitstop_frames
.calculate_shake_frames(&game_settings);
hitstop_shake.role = FighterHitstopShakeRole::Receiver;
hitstop_shake.total_frames = shake_frames;
hitstop_shake.remaining_frames = shake_frames;
@@ -409,36 +417,26 @@ pub fn state_interrupt_system(
} else {
FighterHitstopShakeKind::Vertical
};
let mut hitstop_kind = match &*state {
FighterState::Wait(value) => {
hitstun::HitstopStateKind::Grounded(value.grounded_common.clone())
}
FighterState::Walk(value) => {
hitstun::HitstopStateKind::Grounded(value.grounded_common.clone())
}
FighterState::Dash(value) => {
hitstun::HitstopStateKind::Grounded(value.ground_common.clone())
}
FighterState::Run(value) => {
hitstun::HitstopStateKind::Grounded(value.grounded_common.clone())
}
FighterState::Turn(value) => {
hitstun::HitstopStateKind::Grounded(value.grounded_common.clone())
}
FighterState::GroundAttack(value) => {
hitstun::HitstopStateKind::Grounded(value.grounded_common.clone())
}
_ => hitstun::HitstopStateKind::Airborne,
let mut hitstop_kind = if frame.surface.is_grounded() {
hitstun::HitstopStateKind::Grounded
} else {
hitstun::HitstopStateKind::Airborne
};
if let hitstun::HitstopStateKind::Grounded(_) = hitstop_kind {
if let hitstun::HitstopStateKind::Grounded = hitstop_kind {
let straight_up_angle = FGi32::lit("90.0");
if reaction.hitstop_frames >= HitStop::new(game_settings.fighter_common.tumble_bounce_hitstop_threshold) {
if reaction.hitstop_frames
>= HitStop::new(game_settings.fighter_common.tumble_bounce_hitstop_threshold)
{
hitstop_kind = hitstun::HitstopStateKind::Airborne;
// TODO: Use floor normal here
} else if reaction.launch_angle.abs_diff(straight_up_angle) < game_settings.fighter_common.attack_ground_to_airborne_transition_vertical_angle_threshold {
hitstop_kind = hitstun::HitstopStateKind::Airborne;
} else if reaction.launch_angle.abs_diff(straight_up_angle)
< game_settings
.fighter_common
.attack_ground_to_airborne_transition_vertical_angle_threshold
{
hitstop_kind = hitstun::HitstopStateKind::Airborne;
} else {
// straight down is 270
let straight_down_angle = FGi32::lit("270.0");
@@ -450,6 +448,10 @@ pub fn state_interrupt_system(
}
}
if matches!(hitstop_kind, hitstun::HitstopStateKind::Airborne) {
frame.surface.leave_ground();
}
info!("Entering hitstop kind: {:?}", hitstop_kind);
Some(FighterState::Hitstun(HitstunState::new(
reaction,