Files
shinespark/src/fighter/state/ground.rs
T
EIREXE 4294544944
CI / build-and-test (pull_request) Successful in 1m43s
CI / production-build (pull_request) Successful in 11m2s
Fix unit tests and remove advanced_ecb
2026-10-13 23:16:08 +02:00

539 lines
19 KiB
Rust

//! 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,
);
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,
);
return GroundedMotionResult::InAir;
}
}
}
*state_context.ecb = current_ecb;
state_context.translation.0 = current_position;
state_context.surface.land(line_id, state_context.ledge_info);
GroundedMotionResult::InGround
}
pub fn collision_interrupt(ctx: &mut CollisionContext, can_walk_off: bool) -> Option<FighterState> {
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,
},
waveland_snap_distance: FGi32::ZERO,
};
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,
},
waveland_snap_distance: FGi32::ZERO,
};
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<FighterState> {
super::jump::check_input(state_context)
}
pub fn grounded_movement_standstill_common_interrupts(
state_context: &InterruptContext,
) -> Option<FighterState> {
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(),
})
}