//! Ground movement math shared by every grounded state. Deliberately free of //! 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, }; use crate::fighter::{collision, ecb}; 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::{StagePoly, StagePolyLineSegment, StagePolyLineSegmentType}; #[cfg(test)] use crate::stage::line::StageLineID; pub fn apply_grounded_friction(friction: FGi32, ground_vel: FGi32) -> FGi32 { if friction.abs() > ground_vel.abs() { -ground_vel } else { (-ground_vel.signum()) * friction.abs() } } pub fn move_accelerate() {} pub fn compute_ground_accel( accel: FGi32, target_vel: FGi32, gr_vel: FGi32, attribs: &FighterAttributes, game_settings: &GameSettings, ) -> FGi32 { if target_vel == FGi32::ZERO { apply_grounded_friction(attribs.ground_friction, gr_vel) } else { let mut accel = accel; let ground_max_horizontal_velocity = game_settings.fighter_common.ground_max_horizontal_velocity; if !(gr_vel * accel < FGi32::ZERO) { // accelerating, not reversing if accel > FGi32::ZERO { if gr_vel + accel > target_vel { accel = -attribs.ground_friction; if gr_vel + accel < target_vel { accel = target_vel - gr_vel; } if gr_vel + accel > ground_max_horizontal_velocity { accel = ground_max_horizontal_velocity - gr_vel; } } } else if gr_vel + accel < target_vel { accel = attribs.ground_friction; if gr_vel + accel > target_vel { accel = target_vel - gr_vel; } if gr_vel + accel < -ground_max_horizontal_velocity { accel = -ground_max_horizontal_velocity - gr_vel; } } } accel } } pub enum GroundedMotionResult { InGround, InAir, } enum GroundedPositionResult { InGround { point: FGVec2, segment_idx: usize, blocked: bool, }, InAir, } fn is_walkable_floor(segment: &StagePolyLineSegment) -> bool { segment.segment_type == StagePolyLineSegmentType::Floor && segment.normal.y > FGi32::ZERO } fn resolve_grounded_position( stage_poly: &StagePoly, starting_segment_idx: usize, point: FGVec2, can_walk_off: bool, ) -> GroundedPositionResult { let mut segment_idx = starting_segment_idx; // A single frame can cross several short connected segments. The bound also // prevents malformed closed polygons from cycling forever. for _ in 0..stage_poly.segments.len() { let Some(segment) = stage_poly.get_segment(segment_idx) else { return GroundedPositionResult::InAir; }; if !is_walkable_floor(segment) { return GroundedPositionResult::InAir; } let segment_vector = segment.segment.point2() - segment.segment.point1(); let projection = segment_vector.dot(point - segment.segment.point1()); let length_squared = segment_vector.length_squared(); let (next_segment_idx, edge_point) = if projection < FGWide::ZERO { ( stage_poly.get_prev_segment_index(segment_idx), segment.segment.point1(), ) } else if projection > length_squared { ( stage_poly.get_next_segment_index(segment_idx), segment.segment.point2(), ) } else { let projected_point = segment.segment.point1() + (point - segment.segment.point1()).project_onto(segment.segment.direction()); return GroundedPositionResult::InGround { point: projected_point, segment_idx, blocked: false, }; }; if let Some(next_segment_idx) = next_segment_idx && next_segment_idx != segment_idx && stage_poly .get_segment(next_segment_idx) .is_some_and(is_walkable_floor) { segment_idx = next_segment_idx; continue; } return if can_walk_off { GroundedPositionResult::InAir } else { GroundedPositionResult::InGround { point: edge_point, segment_idx, blocked: true, } }; } GroundedPositionResult::InAir } pub fn apply_grounded_motion(velocity: &FighterVelocity, translation: &mut FighterTranslation) { // Vertical velocity should be 0 on the ground translation.0 += FGVec2::new(velocity.0.x, FGi32::ZERO); } pub fn update_friction_motion(ctx: &mut crate::fighter::state::UpdateContext) { let friction = if ctx.velocity.x.abs() > ctx.fighter_attributes.max_walk_vel { ctx.fighter_attributes.ground_friction * ctx .game_settings .fighter_common .ground_friction_over_walk_speed_multiplier } else { ctx.fighter_attributes.ground_friction }; let ground_velocity = ctx.velocity.x; ctx.velocity.x += apply_grounded_friction(friction, ground_velocity); apply_grounded_motion(ctx.velocity, ctx.translation); } pub fn collide_with_stage_grounded( state_context: &mut CollisionContext, can_walk_off: bool, ) -> GroundedMotionResult { let Some(mut line_id) = state_context.surface.line_id() else { state_context.apply_desired_ecb(); return GroundedMotionResult::InAir; }; let requested_translation = state_context.translation.0; let movement = requested_translation - state_context.prev_translation.0; let largest_change = movement .x .abs() .max(movement.y.abs()) .max(ecb::ecb_max_delta(state_context.ecb, state_context.desired_ecb)); let steps = collision::collision_step_count(largest_change); let movement_per_step = movement / FGi32::from_num(steps); let mut current_position = state_context.prev_translation.0; let mut current_ecb = *state_context.ecb; for step in 0..steps { ecb::interpolate_ecb( &mut current_ecb, *state_context.desired_ecb, FGi32::ONE / FGi32::from_num(steps - step), ); current_position = if step + 1 == steps { requested_translation } else { state_context.prev_translation.0 + movement_per_step * FGi32::from_num(step + 1) }; let Some(stage_poly) = state_context.stage_collision.stage_polys.get(line_id.polygon) else { *state_context.ecb = current_ecb; state_context.translation.0 = current_position; state_context.surface.apply_airborne_policy( crate::fighter::surface::EcbLockPolicy::StartOnDetach, state_context.leave_ground_ecb_lock_duration, ); state_context.ecb_advanced = true; return GroundedMotionResult::InAir; }; match resolve_grounded_position( stage_poly, line_id.segment, current_position + current_ecb.bottom, can_walk_off, ) { GroundedPositionResult::InGround { point, segment_idx, blocked } => { line_id.segment = segment_idx; current_position = point - current_ecb.bottom; let segment = stage_poly.get_segment(segment_idx).unwrap(); state_context.velocity.0 = if blocked { FGVec2::ZERO } else { state_context.velocity.0 - state_context.velocity.0.project_onto_normalized(segment.normal) }; } GroundedPositionResult::InAir => { *state_context.ecb = current_ecb; state_context.translation.0 = current_position; state_context.surface.apply_airborne_policy( crate::fighter::surface::EcbLockPolicy::StartOnDetach, state_context.leave_ground_ecb_lock_duration, ); state_context.ecb_advanced = true; return GroundedMotionResult::InAir; } } } *state_context.ecb = current_ecb; state_context.translation.0 = current_position; state_context.surface.land(line_id, state_context.ledge_info); state_context.ecb_advanced = true; GroundedMotionResult::InGround } pub fn collision_interrupt(ctx: &mut CollisionContext, can_walk_off: bool) -> Option { match collide_with_stage_grounded(ctx, can_walk_off) { GroundedMotionResult::InGround => None, GroundedMotionResult::InAir => Some(FighterState::Fall(fall::FallState::new( air::AirborneStateCommon::default(), ))), } } #[cfg(test)] mod tests { use super::*; use crate::fighter::ecb::{FighterDesiredECB, FighterPreviousECB}; use crate::fighter::{ FighterECB, FighterLedgeInformation, FighterPreviousTranslation, FighterSurfaceState, }; use crate::input::FighterInput; use crate::math::rect::FGRect; use crate::stage::line::StagePolyType; fn two_segment_floor() -> StagePoly { StagePoly::build( StagePolyType::Platform, &[ (FGVec2::lit("0", "0"), StagePolyLineSegmentType::Floor), (FGVec2::lit("10", "0"), StagePolyLineSegmentType::Floor), (FGVec2::lit("20", "0"), StagePolyLineSegmentType::Floor), ], ) } #[test] fn tiny_endpoint_crossing_moves_to_adjacent_floor() { let stage = two_segment_floor(); let point = FGVec2::new(FGi32::from_bits(10 * 65_536 + 1), FGi32::ZERO); let GroundedPositionResult::InGround { point: resolved, segment_idx, blocked, } = resolve_grounded_position(&stage, 0, point, false) else { panic!("connected floor should remain grounded"); }; assert_eq!(segment_idx, 1); assert_eq!(resolved, point); assert!(!blocked); } #[test] fn blocked_edge_keeps_floor_and_can_move_inward() { let stage = StagePoly::build( StagePolyType::Platform, &[ (FGVec2::lit("0", "0"), StagePolyLineSegmentType::Floor), (FGVec2::lit("10", "0"), StagePolyLineSegmentType::Wall), (FGVec2::lit("10", "-2"), StagePolyLineSegmentType::Ceiling), ], ); let just_past_edge = FGVec2::new(FGi32::from_bits(10 * 65_536 + 1), FGi32::ZERO); let GroundedPositionResult::InGround { point, segment_idx, blocked, } = resolve_grounded_position(&stage, 0, just_past_edge, false) else { panic!("non-walk-off movement should stop at the edge"); }; assert_eq!(point, FGVec2::lit("10", "0")); assert_eq!(segment_idx, 0); assert!(blocked); let GroundedPositionResult::InGround { point, segment_idx, blocked, } = resolve_grounded_position( &stage, segment_idx, FGVec2::new(FGi32::from_bits(10 * 65_536 - 1), FGi32::ZERO), false, ) else { panic!("fighter should be able to move back from the edge"); }; assert!(point.x < FGi32::lit("10")); assert_eq!(segment_idx, 0); assert!(!blocked); } #[test] fn walk_off_edge_enters_air() { let stage = StagePoly::build( StagePolyType::Platform, &[ (FGVec2::lit("0", "0"), StagePolyLineSegmentType::Floor), (FGVec2::lit("10", "0"), StagePolyLineSegmentType::Floor), ], ); let just_past_edge = FGVec2::new(FGi32::from_bits(10 * 65_536 + 1), FGi32::ZERO); assert!(matches!( resolve_grounded_position(&stage, 0, just_past_edge, true), GroundedPositionResult::InAir )); } #[test] fn vertical_segment_cannot_hold_grounded_fighter() { let stage = StagePoly::build( StagePolyType::Platform, &[ (FGVec2::lit("10", "0"), StagePolyLineSegmentType::Floor), (FGVec2::lit("10", "-2"), StagePolyLineSegmentType::Floor), ], ); assert!(matches!( resolve_grounded_position(&stage, 0, FGVec2::lit("10", "0"), false), GroundedPositionResult::InAir )); } #[test] 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 { polygon: 0, segment: 0, }, }; let mut translation = FighterTranslation(FGVec2::lit("11", "0")); let previous_translation = FighterPreviousTranslation(translation.0); let mut velocity = FighterVelocity::default(); let mut ledge_info = FighterLedgeInformation::default(); let mut ecb = FighterECB { bottom: FGVec2::lit("0.0", "-0.5"), top: FGVec2::lit("0.0", "0.5"), left: FGVec2::lit("-0.5", "0.0"), right: FGVec2::lit("0.5", "0.0"), }; let previous_ecb = FighterPreviousECB::from(ecb); let mut desired_ecb = FighterDesiredECB::from(ecb); let mut presentation_snap = crate::fighter::FighterPresentationSnap::default(); let mut context = CollisionContext { translation: &mut translation, prev_translation: &previous_translation, input: &FighterInput::default(), velocity: &mut velocity, surface: &mut surface, ecb: &mut ecb, desired_ecb: &mut desired_ecb, ledge_info: &mut ledge_info, prev_ecb: &previous_ecb, stage_collision: &stage_collision, 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, }, ecb_advanced: false, }; assert!(matches!( collide_with_stage_grounded(&mut context, true), GroundedMotionResult::InGround )); assert_eq!( surface.line_id(), Some(StageLineID { polygon: 0, segment: 1, }) ); } #[test] fn walking_off_edge_clears_surface_contact() { let stage_collision = crate::stage::line::StageCollision { stage_polys: vec![StagePoly::build( StagePolyType::Platform, &[ (FGVec2::lit("0", "0"), StagePolyLineSegmentType::Floor), (FGVec2::lit("10", "0"), StagePolyLineSegmentType::Floor), ], )], ..crate::stage::line::StageCollision::default() }; let mut surface = FighterSurfaceState::Grounded { line_id: StageLineID { polygon: 0, segment: 0, }, }; let mut translation = FighterTranslation(FGVec2::lit("11", "0")); let previous_translation = FighterPreviousTranslation(translation.0); let mut velocity = FighterVelocity::default(); let mut ecb = FighterECB { bottom: FGVec2::lit("0.0", "-0.5"), top: FGVec2::lit("0.0", "0.5"), left: FGVec2::lit("-0.5", "0.0"), right: FGVec2::lit("0.5", "0.0"), }; let previous_ecb = FighterPreviousECB::from(ecb); let mut desired_ecb = FighterDesiredECB::from(ecb); let mut presentation_snap = crate::fighter::FighterPresentationSnap::default(); let mut ledge_info = FighterLedgeInformation::default(); let mut context = CollisionContext { translation: &mut translation, prev_translation: &previous_translation, input: &FighterInput::default(), velocity: &mut velocity, surface: &mut surface, ecb: &mut ecb, desired_ecb: &mut desired_ecb, ledge_info: &mut ledge_info, prev_ecb: &previous_ecb, stage_collision: &stage_collision, 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, }, ecb_advanced: false, }; assert!(matches!( collide_with_stage_grounded(&mut context, true), GroundedMotionResult::InAir )); assert!(matches!(surface, FighterSurfaceState::Airborne { .. })); } } pub fn grounded_movement_common_interrupts( state_context: &InterruptContext, ) -> Option { super::jump::check_input(state_context) } pub fn grounded_movement_standstill_common_interrupts( state_context: &InterruptContext, ) -> Option { if let Some(state) = ground_attack::check_interrupt(state_context) { Some(state) } else if let Some(state) = dash::check_input(state_context) { Some(state) } else if let Some(state) = turn::check_input(state_context) { Some(state) } else if let Some(state) = crouch::check_input(state_context) { Some(state) } else if let Some(state) = walk::check_input(state_context) { Some(state) } else if let Some(state) = wait::check_input(state_context) { Some(state) } else { 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(), }) }