Implement land from below, fix attacks not lifting fighters off the ground #96

Merged
EIREXE merged 1 commits from land_from_below into master 2026-10-14 18:33:18 +00:00
10 changed files with 274 additions and 53 deletions
+216 -12
View File
@@ -48,6 +48,14 @@ pub struct AirCollisionWithStageResult {
pub hit_normal: FGVec2, pub hit_normal: FGVec2,
} }
#[derive(Clone, Copy, Debug, Default)]
pub struct AirCollisionOptions {
/// Allow downward input to disable collision with pass-through platforms.
pub can_pass_through_soft_platforms: bool,
/// Treat an upward crossing of a pass-through platform by the ECB bottom as a landing.
pub land_on_soft_platforms_from_below: bool,
}
#[cfg(test)] #[cfg(test)]
fn air_collide_with_stage( fn air_collide_with_stage(
prev_translation: &FighterPreviousTranslation, prev_translation: &FighterPreviousTranslation,
@@ -56,7 +64,7 @@ fn air_collide_with_stage(
ecb: &FighterECB, ecb: &FighterECB,
stage_collision: &StageCollision, stage_collision: &StageCollision,
ignored_polys: &[usize], ignored_polys: &[usize],
can_ignore_soft_platforms: bool, options: AirCollisionOptions,
) -> Option<AirCollisionWithStageResult> { ) -> Option<AirCollisionWithStageResult> {
let mut translation = *translation; let mut translation = *translation;
let mut velocity = FighterVelocity::default(); let mut velocity = FighterVelocity::default();
@@ -73,7 +81,7 @@ fn air_collide_with_stage(
&FighterDesiredECB::from(*ecb), &FighterDesiredECB::from(*ecb),
stage_collision, stage_collision,
ignored_polys, ignored_polys,
can_ignore_soft_platforms, options,
) )
} }
@@ -101,7 +109,7 @@ pub fn air_collide_with_stage_substepped(
desired: &FighterDesiredECB, desired: &FighterDesiredECB,
stage_collision: &StageCollision, stage_collision: &StageCollision,
ignored_polys: &[usize], ignored_polys: &[usize],
can_ignore_soft_platforms: bool, options: AirCollisionOptions,
) -> Option<AirCollisionWithStageResult> { ) -> Option<AirCollisionWithStageResult> {
let requested_translation = translation.0; let requested_translation = translation.0;
let movement = requested_translation - prev_translation.0; let movement = requested_translation - prev_translation.0;
@@ -151,7 +159,7 @@ pub fn air_collide_with_stage_substepped(
&lower_edges, &lower_edges,
StagePolyLineSegmentType::Floor, StagePolyLineSegmentType::Floor,
ignored_polys, ignored_polys,
can_ignore_soft_platforms, options.can_pass_through_soft_platforms,
) { ) {
*ecb = current_ecb; *ecb = current_ecb;
translation.0 = current_position + result.correction; translation.0 = current_position + result.correction;
@@ -193,6 +201,26 @@ pub fn air_collide_with_stage_substepped(
velocity.0 -= velocity.0.project_onto_normalized(contact.hit_normal); velocity.0 -= velocity.0.project_onto_normalized(contact.hit_normal);
} }
if options.land_on_soft_platforms_from_below
&& let Some(result) = sweep_bottom_point_through_platforms(
stage_collision,
previous_position + previous_ecb.bottom,
current_position + current_ecb.bottom,
ignored_polys,
)
{
*ecb = current_ecb;
translation.0 = current_position + result.correction;
velocity.0 -= velocity.0.project_onto_normalized(result.hit_normal);
let bottom_contact = translation.0 + current_ecb.bottom;
return Some(AirCollisionWithStageResult {
line_id: result.line_id,
hit_position: bottom_contact,
resolved_translation: translation.0,
hit_normal: result.hit_normal,
});
}
let side_edges = [ let side_edges = [
( (
previous_ecb.bottom, previous_ecb.bottom,
@@ -245,6 +273,59 @@ struct PointSweepResult {
correction: FGVec2, correction: FGVec2,
} }
fn sweep_bottom_point_through_platforms(
stage_collision: &StageCollision,
previous_bottom: FGVec2,
current_bottom: FGVec2,
ignored_polys: &[usize],
) -> Option<PointSweepResult> {
let bottom_sweep = FGSegment2d::new(previous_bottom, current_bottom);
stage_collision
.stage_polys
.iter()
.enumerate()
.filter(|(poly_idx, poly)| {
!ignored_polys.contains(poly_idx) && matches!(poly.poly_type, StagePolyType::Platform)
})
.flat_map(|(poly_idx, poly)| {
poly.segments
.iter()
.enumerate()
.filter(|(_, segment)| {
matches!(segment.segment_type, StagePolyLineSegmentType::Floor)
})
.filter_map(move |(segment_idx, segment)| {
let previous_distance = segment
.normal
.dot(previous_bottom - segment.segment.point1());
let current_distance = segment
.normal
.dot(current_bottom - segment.segment.point1());
if previous_distance >= crate::math::int::FGWide::ZERO
|| current_distance < crate::math::int::FGWide::ZERO
{
return None;
}
let hit_position = segment.segment.segment_intersection(&bottom_sweep)?;
Some((
PointSweepResult {
line_id: StageLineID {
polygon: poly_idx,
segment: segment_idx,
},
hit_normal: segment.normal,
correction: hit_position - current_bottom,
},
hit_position.distance_squared(previous_bottom),
))
})
})
.min_by_key(|(_, distance)| *distance)
.map(|(result, _)| result)
}
fn sweep_edges( fn sweep_edges(
stage_collision: &StageCollision, stage_collision: &StageCollision,
previous_position: FGVec2, previous_position: FGVec2,
@@ -442,7 +523,7 @@ mod tests {
&desired, &desired,
&stage_collision, &stage_collision,
&[], &[],
false, Default::default(),
) )
.expect("the floor should be hit"); .expect("the floor should be hit");
@@ -482,7 +563,7 @@ mod tests {
&desired, &desired,
&stage_collision, &stage_collision,
&[], &[],
false, Default::default(),
) )
.is_none()); .is_none());
assert_eq!(translation.0, FGVec2::lit("3", "0")); assert_eq!(translation.0, FGVec2::lit("3", "0"));
@@ -518,7 +599,7 @@ mod tests {
&desired, &desired,
&stage_collision, &stage_collision,
&[], &[],
false, Default::default(),
) )
.is_none()); .is_none());
assert_eq!(translation.0.x, FGi32::lit("3")); assert_eq!(translation.0.x, FGi32::lit("3"));
@@ -554,7 +635,7 @@ mod tests {
&desired, &desired,
&stage_collision, &stage_collision,
&[], &[],
false, Default::default(),
) )
.is_none()); .is_none());
assert_eq!(translation.0, FGVec2::lit("0", "1")); assert_eq!(translation.0, FGVec2::lit("0", "1"));
@@ -589,7 +670,7 @@ mod tests {
&ecb, &ecb,
&stage_collision, &stage_collision,
&[1], &[1],
false, Default::default(),
) )
.expect("the non-ignored platform should still be collidable"); .expect("the non-ignored platform should still be collidable");
@@ -626,7 +707,10 @@ mod tests {
&ecb, &ecb,
&stage_collision, &stage_collision,
&[], &[],
true, AirCollisionOptions {
can_pass_through_soft_platforms: true,
..Default::default()
},
) )
.is_none() .is_none()
); );
@@ -639,7 +723,7 @@ mod tests {
&ecb, &ecb,
&stage_collision, &stage_collision,
&[], &[],
false, Default::default(),
) )
.expect("air dodges still collide with soft platforms") .expect("air dodges still collide with soft platforms")
.hit_position, .hit_position,
@@ -675,11 +759,131 @@ mod tests {
&ecb, &ecb,
&stage_collision, &stage_collision,
&[], &[],
true, AirCollisionOptions {
can_pass_through_soft_platforms: true,
..Default::default()
},
) )
.expect("solid floors remain collidable while passing down"); .expect("solid floors remain collidable while passing down");
assert_eq!(collision.line_id.polygon, 0); assert_eq!(collision.line_id.polygon, 0);
assert_eq!(collision.hit_position, FGVec2::lit("0", "0")); assert_eq!(collision.hit_position, FGVec2::lit("0", "0"));
} }
#[test]
fn upward_bottom_crossing_can_land_on_soft_platform() {
let stage_collision = StageCollision {
stage_polys: vec![horizontal_platform("0")],
..Default::default()
};
let previous_translation = FighterPreviousTranslation(FGVec2::lit("0", "-1"));
let mut translation = FighterTranslation(FGVec2::lit("0", "1"));
let mut velocity = FighterVelocity(FGVec2::lit("0", "2"));
let mut ecb = FighterECB::default();
let desired = FighterDesiredECB::from(ecb);
let collision = air_collide_with_stage_substepped(
&previous_translation,
&mut translation,
&mut velocity,
&mut ecb,
&desired,
&stage_collision,
&[],
AirCollisionOptions {
land_on_soft_platforms_from_below: true,
..Default::default()
},
)
.expect("the upward bottom-point crossing should land on the platform");
assert_eq!(collision.hit_position, FGVec2::ZERO);
assert_eq!(collision.line_id, StageLineID { polygon: 0, segment: 0 });
assert_eq!(translation.0, FGVec2::ZERO);
assert_eq!(velocity.y, FGi32::ZERO);
}
#[test]
fn upward_platform_landing_requires_crossing_and_opt_in() {
let stage_collision = StageCollision {
stage_polys: vec![horizontal_platform("0")],
..Default::default()
};
let previous_ecb = FighterPreviousECB::default();
let ecb = FighterECB::default();
let options = AirCollisionOptions {
land_on_soft_platforms_from_below: true,
..Default::default()
};
assert!(
air_collide_with_stage(
&FighterPreviousTranslation(FGVec2::lit("0", "-2")),
&FighterTranslation(FGVec2::lit("0", "-1")),
&previous_ecb,
&ecb,
&stage_collision,
&[],
options,
)
.is_none()
);
assert!(
air_collide_with_stage(
&FighterPreviousTranslation(FGVec2::lit("0", "-1")),
&FighterTranslation(FGVec2::lit("0", "1")),
&previous_ecb,
&ecb,
&stage_collision,
&[],
Default::default(),
)
.is_none()
);
}
#[test]
fn upward_crossing_does_not_land_on_solid_or_ignored_platforms() {
let previous_translation = FighterPreviousTranslation(FGVec2::lit("0", "-1"));
let translation = FighterTranslation(FGVec2::lit("0", "1"));
let previous_ecb = FighterPreviousECB::default();
let ecb = FighterECB::default();
let options = AirCollisionOptions {
land_on_soft_platforms_from_below: true,
..Default::default()
};
let solid_stage = StageCollision {
stage_polys: vec![solid_floor()],
..Default::default()
};
let platform_stage = StageCollision {
stage_polys: vec![horizontal_platform("0")],
..Default::default()
};
assert!(
air_collide_with_stage(
&previous_translation,
&translation,
&previous_ecb,
&ecb,
&solid_stage,
&[],
options,
)
.is_none()
);
assert!(
air_collide_with_stage(
&previous_translation,
&translation,
&previous_ecb,
&ecb,
&platform_stage,
&[0],
options,
)
.is_none()
);
}
} }
+2 -1
View File
@@ -245,7 +245,6 @@ impl Plugin for FighterPlugin {
GgrsSchedule, GgrsSchedule,
( (
state::state_update_system, state::state_update_system,
decay_hitstop,
decay_invulnerability, decay_invulnerability,
) )
.chain() .chain()
@@ -265,6 +264,8 @@ impl Plugin for FighterPlugin {
ecb::decrement_ecb_lockout, ecb::decrement_ecb_lockout,
ecb::update_ecb, ecb::update_ecb,
state::state_collision_interrupt_system, state::state_collision_interrupt_system,
// Every gameplay phase must observe the same hitstop value.
decay_hitstop,
decay_ledge_grab_lockout, decay_ledge_grab_lockout,
) )
.chain(), .chain(),
+2 -2
View File
@@ -125,7 +125,7 @@ pub fn air_interrupts_common(
pub fn collision_interrupt( pub fn collision_interrupt(
ctx: &mut CollisionContext, ctx: &mut CollisionContext,
landing_type: LandingType, landing_type: LandingType,
can_ignore_soft_platforms: bool, options: crate::fighter::collision::AirCollisionOptions,
) -> Option<FighterState> { ) -> Option<FighterState> {
if ctx.velocity.y <= FGi32::ZERO { if ctx.velocity.y <= FGi32::ZERO {
if let Some(ledge_result) = ctx.air_collide_with_ledges() { if let Some(ledge_result) = ctx.air_collide_with_ledges() {
@@ -134,7 +134,7 @@ pub fn collision_interrupt(
} }
} }
let res = ctx.air_collide_with_stage(can_ignore_soft_platforms)?; let res = ctx.air_collide_with_stage(options)?;
ctx.translation.0 = res.resolved_translation; ctx.translation.0 = res.resolved_translation;
let bottom_offset = ctx.ecb.rebase_onto_bottom(); let bottom_offset = ctx.ecb.rebase_onto_bottom();
ctx.translation.0 += bottom_offset; ctx.translation.0 += bottom_offset;
+1 -1
View File
@@ -93,7 +93,7 @@ impl FighterStateImpl for AirAttackState {
let coll_interrupt = air::collision_interrupt( let coll_interrupt = air::collision_interrupt(
state_context, state_context,
crate::fighter::state::land::LandingType::Normal, crate::fighter::state::land::LandingType::Normal,
false, Default::default(),
); );
coll_interrupt.map(|state| { coll_interrupt.map(|state| {
+8 -1
View File
@@ -65,7 +65,14 @@ impl FighterStateImpl for AirDodgeState {
} }
} }
air::collision_interrupt(state_context, LandingType::Special, false) air::collision_interrupt(
state_context,
LandingType::Special,
crate::fighter::collision::AirCollisionOptions {
land_on_soft_platforms_from_below: true,
..Default::default()
},
)
} }
fn on_enter(&mut self, state_context: &mut EnterContext) { fn on_enter(&mut self, state_context: &mut EnterContext) {
+4 -1
View File
@@ -39,7 +39,10 @@ impl FighterStateImpl for FallState {
air::collision_interrupt( air::collision_interrupt(
state_context, state_context,
crate::fighter::state::land::LandingType::Normal, crate::fighter::state::land::LandingType::Normal,
true, crate::fighter::collision::AirCollisionOptions {
can_pass_through_soft_platforms: true,
..Default::default()
},
) )
} }
+28 -30
View File
@@ -1,8 +1,6 @@
use crate::{ use crate::{
fighter::{ fighter::{
animation::AnimKind, FighterVelocity, animation::AnimKind, attack::{self, FighterHitReaction}, state::{
attack::{self, FighterHitReaction},
state::{
CollisionContext, EnterContext, ExitContext, FighterState, FighterStateImpl, CollisionContext, EnterContext, ExitContext, FighterState, FighterStateImpl,
InterruptContext, UpdateContext, InterruptContext, UpdateContext,
air::{self, AirborneStateCommon}, air::{self, AirborneStateCommon},
@@ -40,6 +38,30 @@ impl HitstunState {
impl FighterStateImpl for HitstunState { impl FighterStateImpl for HitstunState {
const NAME: &'static str = "Hitstun"; const NAME: &'static str = "Hitstun";
fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option<FighterState> {
if state_context.get_current_animation_frame() > self.hit_reaction.hitstun_frames {
match &self.hitstop_kind {
HitstopStateKind::Grounded => Some(FighterState::Wait(WaitState)),
HitstopStateKind::Airborne => Some(FighterState::Fall(FallState::new(
AirborneStateCommon::default(),
))),
}
} else {
None
}
}
fn on_enter(&mut self, state_context: &mut EnterContext) {
let animation = match self.hitstop_kind {
HitstopStateKind::Grounded => AnimKind::FlinchGround,
HitstopStateKind::Airborne => AnimKind::FlinchAir,
};
state_context.play_animation(animation, false);
state_context.velocity.0 = FGVec2::ZERO;
}
fn on_exit(&mut self, _state_context: &mut ExitContext) {}
fn name(&self) -> String { fn name(&self) -> String {
let hitstop_kind = match self.hitstop_kind { let hitstop_kind = match self.hitstop_kind {
HitstopStateKind::Grounded => "Grounded", HitstopStateKind::Grounded => "Grounded",
@@ -54,19 +76,6 @@ impl FighterStateImpl for HitstunState {
format!("Hitstun ({hitstop_kind}, {hit_reaction_type})") format!("Hitstun ({hitstop_kind}, {hit_reaction_type})")
} }
fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option<FighterState> {
if state_context.get_current_animation_frame() > self.hit_reaction.hitstun_frames {
match &self.hitstop_kind {
HitstopStateKind::Grounded => Some(FighterState::Wait(WaitState)),
HitstopStateKind::Airborne => Some(FighterState::Fall(FallState::new(
AirborneStateCommon::default(),
))),
}
} else {
None
}
}
fn update(&mut self, ctx: &mut UpdateContext) { fn update(&mut self, ctx: &mut UpdateContext) {
if !self.knockback_applied { if !self.knockback_applied {
let launch_direction = attack::directional_influence::apply_directional_influence( let launch_direction = attack::directional_influence::apply_directional_influence(
@@ -83,6 +92,8 @@ impl FighterStateImpl for HitstunState {
HitstopStateKind::Grounded => {} HitstopStateKind::Grounded => {}
HitstopStateKind::Airborne => { HitstopStateKind::Airborne => {
air::integrate_gravity(ctx.velocity, ctx.fighter_attributes, false); air::integrate_gravity(ctx.velocity, ctx.fighter_attributes, false);
let vel_with_kb = FighterVelocity(ctx.velocity.0 + ctx.knockback_velocity.0);
air::apply_air_motion(&vel_with_kb, ctx.translation, ctx.prev_translation);
} }
} }
@@ -92,21 +103,8 @@ impl FighterStateImpl for HitstunState {
.max(FGi32::ZERO); .max(FGi32::ZERO);
ctx.knockback_velocity.0 = ctx.knockback_velocity.normalize_or_zero() * next_speed; ctx.knockback_velocity.0 = ctx.knockback_velocity.normalize_or_zero() * next_speed;
ctx.translation.0 += ctx.velocity.0 + ctx.knockback_velocity.0;
} }
fn on_enter(&mut self, state_context: &mut EnterContext) {
let animation = match self.hitstop_kind {
HitstopStateKind::Grounded => AnimKind::FlinchGround,
HitstopStateKind::Airborne => AnimKind::FlinchAir,
};
state_context.play_animation(animation, false);
state_context.velocity.0 = FGVec2::ZERO;
}
fn on_exit(&mut self, _state_context: &mut ExitContext) {}
fn check_collision_interrupt( fn check_collision_interrupt(
&mut self, &mut self,
state_context: &mut CollisionContext, state_context: &mut CollisionContext,
@@ -114,7 +112,7 @@ impl FighterStateImpl for HitstunState {
match &mut self.hitstop_kind { match &mut self.hitstop_kind {
HitstopStateKind::Grounded => collision_interrupt(state_context, true), HitstopStateKind::Grounded => collision_interrupt(state_context, true),
HitstopStateKind::Airborne => { HitstopStateKind::Airborne => {
air::collision_interrupt(state_context, LandingType::Normal, false) air::collision_interrupt(state_context, LandingType::Normal, Default::default())
} }
} }
} }
+6 -1
View File
@@ -99,6 +99,8 @@ impl FighterStateImpl for JumpSquatState {
fn on_exit(&mut self, state_context: &mut crate::fighter::state::ExitContext) { fn on_exit(&mut self, state_context: &mut crate::fighter::state::ExitContext) {
state_context.input.disable_hold_all(); state_context.input.disable_hold_all();
// We clear tap jump here, it may have weird interactions with air dodging up
state_context.input.clear_command(FighterCommands::TapJump);
} }
fn is_visually_grounded<'a>(&'a self) -> bool { fn is_visually_grounded<'a>(&'a self) -> bool {
@@ -174,7 +176,10 @@ impl FighterStateImpl for JumpState {
air::collision_interrupt( air::collision_interrupt(
state_context, state_context,
crate::fighter::state::land::LandingType::Normal, crate::fighter::state::land::LandingType::Normal,
true, crate::fighter::collision::AirCollisionOptions {
can_pass_through_soft_platforms: true,
..Default::default()
},
) )
} }
+3 -3
View File
@@ -273,10 +273,10 @@ impl CollisionContext<'_> {
pub fn air_collide_with_stage( pub fn air_collide_with_stage(
&mut self, &mut self,
can_ignore_soft_platforms: bool, mut options: crate::fighter::collision::AirCollisionOptions,
) -> Option<crate::fighter::collision::AirCollisionWithStageResult> { ) -> Option<crate::fighter::collision::AirCollisionWithStageResult> {
let ignored_polys = self.surface.ignored_polygon_ids(); let ignored_polys = self.surface.ignored_polygon_ids();
let ignore_soft_platforms = can_ignore_soft_platforms options.can_pass_through_soft_platforms = options.can_pass_through_soft_platforms
&& self.input.get_last_frame().movement.y <= -self.downward_stick_threshold; && self.input.get_last_frame().movement.y <= -self.downward_stick_threshold;
crate::fighter::collision::air_collide_with_stage_substepped( crate::fighter::collision::air_collide_with_stage_substepped(
self.prev_translation, self.prev_translation,
@@ -286,7 +286,7 @@ impl CollisionContext<'_> {
self.desired_ecb, self.desired_ecb,
self.stage_collision, self.stage_collision,
&ignored_polys, &ignored_polys,
ignore_soft_platforms, options,
) )
} }
pub fn air_collide_with_ledges( pub fn air_collide_with_ledges(
+4 -1
View File
@@ -46,7 +46,10 @@ impl FighterStateImpl for PlatformPassState {
air::collision_interrupt( air::collision_interrupt(
state_context, state_context,
crate::fighter::state::land::LandingType::Normal, crate::fighter::state::land::LandingType::Normal,
true, crate::fighter::collision::AirCollisionOptions {
can_pass_through_soft_platforms: true,
..Default::default()
},
) )
} }