diff --git a/src/fighter/collision.rs b/src/fighter/collision.rs index 52b22ae..1dea83d 100644 --- a/src/fighter/collision.rs +++ b/src/fighter/collision.rs @@ -48,6 +48,14 @@ pub struct AirCollisionWithStageResult { 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)] fn air_collide_with_stage( prev_translation: &FighterPreviousTranslation, @@ -56,7 +64,7 @@ fn air_collide_with_stage( ecb: &FighterECB, stage_collision: &StageCollision, ignored_polys: &[usize], - can_ignore_soft_platforms: bool, + options: AirCollisionOptions, ) -> Option { let mut translation = *translation; let mut velocity = FighterVelocity::default(); @@ -73,7 +81,7 @@ fn air_collide_with_stage( &FighterDesiredECB::from(*ecb), stage_collision, ignored_polys, - can_ignore_soft_platforms, + options, ) } @@ -101,7 +109,7 @@ pub fn air_collide_with_stage_substepped( desired: &FighterDesiredECB, stage_collision: &StageCollision, ignored_polys: &[usize], - can_ignore_soft_platforms: bool, + options: AirCollisionOptions, ) -> Option { let requested_translation = translation.0; let movement = requested_translation - prev_translation.0; @@ -151,7 +159,7 @@ pub fn air_collide_with_stage_substepped( &lower_edges, StagePolyLineSegmentType::Floor, ignored_polys, - can_ignore_soft_platforms, + options.can_pass_through_soft_platforms, ) { *ecb = current_ecb; 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); } + 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 = [ ( previous_ecb.bottom, @@ -245,6 +273,59 @@ struct PointSweepResult { correction: FGVec2, } +fn sweep_bottom_point_through_platforms( + stage_collision: &StageCollision, + previous_bottom: FGVec2, + current_bottom: FGVec2, + ignored_polys: &[usize], +) -> Option { + 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( stage_collision: &StageCollision, previous_position: FGVec2, @@ -442,7 +523,7 @@ mod tests { &desired, &stage_collision, &[], - false, + Default::default(), ) .expect("the floor should be hit"); @@ -482,7 +563,7 @@ mod tests { &desired, &stage_collision, &[], - false, + Default::default(), ) .is_none()); assert_eq!(translation.0, FGVec2::lit("3", "0")); @@ -518,7 +599,7 @@ mod tests { &desired, &stage_collision, &[], - false, + Default::default(), ) .is_none()); assert_eq!(translation.0.x, FGi32::lit("3")); @@ -554,7 +635,7 @@ mod tests { &desired, &stage_collision, &[], - false, + Default::default(), ) .is_none()); assert_eq!(translation.0, FGVec2::lit("0", "1")); @@ -589,7 +670,7 @@ mod tests { &ecb, &stage_collision, &[1], - false, + Default::default(), ) .expect("the non-ignored platform should still be collidable"); @@ -626,7 +707,10 @@ mod tests { &ecb, &stage_collision, &[], - true, + AirCollisionOptions { + can_pass_through_soft_platforms: true, + ..Default::default() + }, ) .is_none() ); @@ -639,7 +723,7 @@ mod tests { &ecb, &stage_collision, &[], - false, + Default::default(), ) .expect("air dodges still collide with soft platforms") .hit_position, @@ -675,11 +759,131 @@ mod tests { &ecb, &stage_collision, &[], - true, + AirCollisionOptions { + can_pass_through_soft_platforms: true, + ..Default::default() + }, ) .expect("solid floors remain collidable while passing down"); assert_eq!(collision.line_id.polygon, 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() + ); + } } diff --git a/src/fighter/mod.rs b/src/fighter/mod.rs index 0005d31..84f4566 100644 --- a/src/fighter/mod.rs +++ b/src/fighter/mod.rs @@ -245,7 +245,6 @@ impl Plugin for FighterPlugin { GgrsSchedule, ( state::state_update_system, - decay_hitstop, decay_invulnerability, ) .chain() @@ -265,6 +264,8 @@ impl Plugin for FighterPlugin { ecb::decrement_ecb_lockout, ecb::update_ecb, state::state_collision_interrupt_system, + // Every gameplay phase must observe the same hitstop value. + decay_hitstop, decay_ledge_grab_lockout, ) .chain(), diff --git a/src/fighter/state/air.rs b/src/fighter/state/air.rs index ed9c8ed..6caf4b5 100644 --- a/src/fighter/state/air.rs +++ b/src/fighter/state/air.rs @@ -125,7 +125,7 @@ pub fn air_interrupts_common( pub fn collision_interrupt( ctx: &mut CollisionContext, landing_type: LandingType, - can_ignore_soft_platforms: bool, + options: crate::fighter::collision::AirCollisionOptions, ) -> Option { if ctx.velocity.y <= FGi32::ZERO { 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; let bottom_offset = ctx.ecb.rebase_onto_bottom(); ctx.translation.0 += bottom_offset; diff --git a/src/fighter/state/air_attack.rs b/src/fighter/state/air_attack.rs index 6252a7a..34a6ea4 100644 --- a/src/fighter/state/air_attack.rs +++ b/src/fighter/state/air_attack.rs @@ -93,7 +93,7 @@ impl FighterStateImpl for AirAttackState { let coll_interrupt = air::collision_interrupt( state_context, crate::fighter::state::land::LandingType::Normal, - false, + Default::default(), ); coll_interrupt.map(|state| { diff --git a/src/fighter/state/air_dodge.rs b/src/fighter/state/air_dodge.rs index 9d5f2f9..2c9f71d 100644 --- a/src/fighter/state/air_dodge.rs +++ b/src/fighter/state/air_dodge.rs @@ -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) { diff --git a/src/fighter/state/fall.rs b/src/fighter/state/fall.rs index 97945b7..776dfea 100644 --- a/src/fighter/state/fall.rs +++ b/src/fighter/state/fall.rs @@ -39,7 +39,10 @@ impl FighterStateImpl for FallState { air::collision_interrupt( state_context, crate::fighter::state::land::LandingType::Normal, - true, + crate::fighter::collision::AirCollisionOptions { + can_pass_through_soft_platforms: true, + ..Default::default() + }, ) } diff --git a/src/fighter/state/hitstun.rs b/src/fighter/state/hitstun.rs index cb7d9e8..c97d741 100644 --- a/src/fighter/state/hitstun.rs +++ b/src/fighter/state/hitstun.rs @@ -1,8 +1,6 @@ use crate::{ fighter::{ - animation::AnimKind, - attack::{self, FighterHitReaction}, - state::{ + FighterVelocity, animation::AnimKind, attack::{self, FighterHitReaction}, state::{ CollisionContext, EnterContext, ExitContext, FighterState, FighterStateImpl, InterruptContext, UpdateContext, air::{self, AirborneStateCommon}, @@ -40,6 +38,30 @@ impl HitstunState { impl FighterStateImpl for HitstunState { const NAME: &'static str = "Hitstun"; + fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option { + 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 { let hitstop_kind = match self.hitstop_kind { HitstopStateKind::Grounded => "Grounded", @@ -54,19 +76,6 @@ impl FighterStateImpl for HitstunState { format!("Hitstun ({hitstop_kind}, {hit_reaction_type})") } - fn check_interrupt(&self, state_context: &mut InterruptContext) -> Option { - 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) { if !self.knockback_applied { let launch_direction = attack::directional_influence::apply_directional_influence( @@ -83,6 +92,8 @@ impl FighterStateImpl for HitstunState { HitstopStateKind::Grounded => {} HitstopStateKind::Airborne => { 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); 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( &mut self, state_context: &mut CollisionContext, @@ -114,7 +112,7 @@ impl FighterStateImpl for HitstunState { match &mut self.hitstop_kind { HitstopStateKind::Grounded => collision_interrupt(state_context, true), HitstopStateKind::Airborne => { - air::collision_interrupt(state_context, LandingType::Normal, false) + air::collision_interrupt(state_context, LandingType::Normal, Default::default()) } } } diff --git a/src/fighter/state/jump.rs b/src/fighter/state/jump.rs index a6b2efc..cac5e0b 100644 --- a/src/fighter/state/jump.rs +++ b/src/fighter/state/jump.rs @@ -99,6 +99,8 @@ impl FighterStateImpl for JumpSquatState { fn on_exit(&mut self, state_context: &mut crate::fighter::state::ExitContext) { 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 { @@ -174,7 +176,10 @@ impl FighterStateImpl for JumpState { air::collision_interrupt( state_context, crate::fighter::state::land::LandingType::Normal, - true, + crate::fighter::collision::AirCollisionOptions { + can_pass_through_soft_platforms: true, + ..Default::default() + }, ) } diff --git a/src/fighter/state/mod.rs b/src/fighter/state/mod.rs index 156a471..3be0e3b 100644 --- a/src/fighter/state/mod.rs +++ b/src/fighter/state/mod.rs @@ -273,10 +273,10 @@ impl CollisionContext<'_> { pub fn air_collide_with_stage( &mut self, - can_ignore_soft_platforms: bool, + mut options: crate::fighter::collision::AirCollisionOptions, ) -> Option { 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; crate::fighter::collision::air_collide_with_stage_substepped( self.prev_translation, @@ -286,7 +286,7 @@ impl CollisionContext<'_> { self.desired_ecb, self.stage_collision, &ignored_polys, - ignore_soft_platforms, + options, ) } pub fn air_collide_with_ledges( diff --git a/src/fighter/state/platform_pass.rs b/src/fighter/state/platform_pass.rs index 81105ad..598a196 100644 --- a/src/fighter/state/platform_pass.rs +++ b/src/fighter/state/platform_pass.rs @@ -46,7 +46,10 @@ impl FighterStateImpl for PlatformPassState { air::collision_interrupt( state_context, crate::fighter::state::land::LandingType::Normal, - true, + crate::fighter::collision::AirCollisionOptions { + can_pass_through_soft_platforms: true, + ..Default::default() + }, ) }