## Summary - Mark landing position corrections as presentation discontinuities. - Render and frame fighters from the current position for the snap frame. - Keep previous translation history unchanged for gameplay collision and attack logic. Reviewed-on: #67 Co-authored-by: Álex Román Núñez <eirexe@eirteam.moe>
482 lines
16 KiB
Rust
482 lines
16 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::{
|
|
CollisionContext, FighterState, InterruptContext, air, crouch, dash, fall, ground_attack, turn, wait, walk,
|
|
};
|
|
use crate::fighter::{
|
|
FighterAttributes, FighterPreviousTranslation, FighterTranslation, FighterVelocity,
|
|
};
|
|
use crate::game_settings::GameSettings;
|
|
use crate::math::int::{FGWide, FGi32};
|
|
use crate::math::vec::FGVec2;
|
|
use crate::stage::line::{StageLineID, StagePoly, StagePolyLineSegment, StagePolyLineSegmentType};
|
|
|
|
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,
|
|
prev_translation: &mut FighterPreviousTranslation,
|
|
) {
|
|
// Vertical velocity should be 0 on the ground
|
|
prev_translation.0 = translation.0;
|
|
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, ctx.prev_translation);
|
|
}
|
|
|
|
pub fn collide_with_stage_grounded(
|
|
state_context: &mut CollisionContext,
|
|
can_walk_off: bool,
|
|
) -> GroundedMotionResult {
|
|
let translation = &mut state_context.translation;
|
|
let ecb = &state_context.ecb;
|
|
let stage_collision = state_context.stage_collision;
|
|
|
|
let Some(current_line_id) = state_context.surface.line_id() else {
|
|
return GroundedMotionResult::InAir;
|
|
};
|
|
|
|
let stage_poly = stage_collision.stage_polys.get(current_line_id.polygon);
|
|
|
|
if let Some(stage_poly) = stage_poly {
|
|
let ecb_bottom_pos = translation.0 + ecb.get_bottom_point();
|
|
match resolve_grounded_position(
|
|
stage_poly,
|
|
current_line_id.segment,
|
|
ecb_bottom_pos,
|
|
can_walk_off,
|
|
) {
|
|
GroundedPositionResult::InGround {
|
|
point,
|
|
segment_idx,
|
|
blocked,
|
|
} => {
|
|
let current_segment = stage_poly.get_segment(segment_idx).unwrap();
|
|
translation.0 = point - ecb.get_bottom_point();
|
|
state_context.velocity.0 = if blocked {
|
|
FGVec2::ZERO
|
|
} else {
|
|
state_context.velocity.0
|
|
- state_context
|
|
.velocity
|
|
.0
|
|
.project_onto_normalized(current_segment.normal)
|
|
};
|
|
state_context.surface.land(StageLineID {
|
|
polygon: current_line_id.polygon,
|
|
segment: segment_idx,
|
|
});
|
|
GroundedMotionResult::InGround
|
|
}
|
|
GroundedPositionResult::InAir => {
|
|
state_context.surface.apply_airborne_policy(
|
|
crate::fighter::surface::EcbLockPolicy::StartOnDetach,
|
|
state_context.leave_ground_ecb_lock_duration,
|
|
);
|
|
GroundedMotionResult::InAir
|
|
}
|
|
}
|
|
} else {
|
|
state_context.surface.apply_airborne_policy(
|
|
crate::fighter::surface::EcbLockPolicy::StartOnDetach,
|
|
state_context.leave_ground_ecb_lock_duration,
|
|
);
|
|
GroundedMotionResult::InAir
|
|
}
|
|
}
|
|
|
|
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::FighterPreviousECB;
|
|
use crate::fighter::{FighterECB, FighterSurfaceState};
|
|
use crate::stage::line::StagePolyType;
|
|
use crate::input::FighterInput;
|
|
|
|
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()],
|
|
};
|
|
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 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,
|
|
prev_ecb: &previous_ecb,
|
|
stage_collision: &stage_collision,
|
|
leave_ground_ecb_lock_duration: 10,
|
|
presentation_snap: &mut presentation_snap,
|
|
downward_stick_threshold: FGi32::ONE,
|
|
};
|
|
|
|
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),
|
|
],
|
|
)],
|
|
};
|
|
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 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,
|
|
prev_ecb: &previous_ecb,
|
|
stage_collision: &stage_collision,
|
|
leave_ground_ecb_lock_duration: 10,
|
|
presentation_snap: &mut presentation_snap,
|
|
downward_stick_threshold: FGi32::ONE,
|
|
};
|
|
|
|
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
|
|
}
|
|
}
|