From 472a0b6a19d08d92f01182668255eee3891eeccb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lex=20Rom=C3=A1n=20N=C3=BA=C3=B1ez?= Date: Tue, 13 Oct 2026 19:20:29 +0000 Subject: [PATCH] Improve ECB behaviour, add ECB interpolation, wall/ceiling support etc (#94) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed-on: https://git.eirexe.moe/EIREXE/shinespark/pulls/94 Co-authored-by: Álex Román Núñez --- src/debug_tools/save_state.rs | 24 +- src/fighter/collision.rs | 515 +++++++++++++++++++++++++++++++--- src/fighter/ecb.rs | 231 +++++++++++++-- src/fighter/mod.rs | 60 ++-- src/fighter/state/air.rs | 10 +- src/fighter/state/ground.rs | 129 +++++---- src/fighter/state/mod.rs | 68 +++-- 7 files changed, 874 insertions(+), 163 deletions(-) diff --git a/src/debug_tools/save_state.rs b/src/debug_tools/save_state.rs index 874f461..65eb93e 100644 --- a/src/debug_tools/save_state.rs +++ b/src/debug_tools/save_state.rs @@ -1,12 +1,25 @@ use bevy::prelude::*; use crate::{ - camera::MatchCamera, fighter::{ - FighterECB, FighterFacingDirection, FighterHitboxes, FighterHitstop, FighterHitstopShake, FighterInvulnerabilityState, FighterLedgeInformation, FighterPendingRespawn, FighterPreviousFacingDirection, FighterPreviousTranslation, FighterSurfaceState, FighterTranslation, FighterVelocity, animation::FighterAnimationFrame, attack::{ + camera::MatchCamera, + fighter::{ + FighterECB, FighterFacingDirection, FighterHitboxes, FighterHitstop, FighterHitstopShake, + FighterInvulnerabilityState, FighterLedgeInformation, FighterPendingRespawn, + FighterPreviousFacingDirection, FighterPreviousTranslation, FighterSurfaceState, + FighterTranslation, FighterVelocity, + animation::FighterAnimationFrame, + attack::{ FighterDamage, FighterKnockbackVelocity, FighterPendingHitReaction, FighterPendingHits, FighterSolvedHurtboxes, FighterStaleMoveQueue, - }, baked_animation::FighterBoneMatrices, state::FighterState, - }, input::FighterInput, match_runtime::MatchState, player::Player, stage::line::StageCollision, + }, + baked_animation::FighterBoneMatrices, + ecb::FighterDesiredECB, + state::FighterState, + }, + input::FighterInput, + match_runtime::MatchState, + player::Player, + stage::line::StageCollision, }; #[derive(Resource, Default)] @@ -27,6 +40,7 @@ struct FighterCheckpoint { entity: Entity, handle: usize, ecb: FighterECB, + desired_ecb: FighterDesiredECB, previous_ecb: crate::fighter::ecb::FighterPreviousECB, velocity: FighterVelocity, translation: FighterTranslation, @@ -131,6 +145,7 @@ pub(super) fn save_or_restore(world: &mut World) { let mut entity = world.entity_mut(saved.entity); entity.insert(saved.ecb); + entity.insert(saved.desired_ecb); entity.insert(saved.previous_ecb); entity.insert(saved.velocity); entity.insert(saved.translation); @@ -169,6 +184,7 @@ fn capture_fighter(world: &World, entity: Entity) -> Option { entity, handle: fighter.get::()?.handle, ecb: *fighter.get::()?, + desired_ecb: *fighter.get::()?, previous_ecb: *fighter.get::()?, velocity: *fighter.get::()?, translation: *fighter.get::()?, diff --git a/src/fighter/collision.rs b/src/fighter/collision.rs index 592778f..52b22ae 100644 --- a/src/fighter/collision.rs +++ b/src/fighter/collision.rs @@ -1,11 +1,17 @@ //! Resolving an integrated fighter position against the stage. -use crate::fighter::ecb::FighterPreviousECB; -use crate::fighter::{FighterECB, FighterPreviousTranslation, FighterTranslation}; +use crate::fighter::ecb::{self as ecb_module, FighterDesiredECB}; +use crate::fighter::{FighterPreviousTranslation, FighterTranslation}; +use crate::fighter::FighterVelocity; +#[cfg(test)] +use crate::fighter::{FighterECB, ecb::FighterPreviousECB}; +use crate::math::int::FGi32; use crate::math::rect::FGRect; use crate::math::segment::FGSegment2d; use crate::math::vec::FGVec2; -use crate::stage::line::{StageCollision, StageLineID, StagePolyType, StageVertexID}; +use crate::stage::line::{ + StageCollision, StageLineID, StagePolyLineSegmentType, StagePolyType, StageVertexID, +}; /* pub fn collide_fighter_with_scene( fighters: Query<(&FighterECB, &mut FighterVelocity, &mut FighterTranslation)>, @@ -36,10 +42,14 @@ pub fn collide_fighter_with_scene( pub struct AirCollisionWithStageResult { pub line_id: StageLineID, pub hit_position: FGVec2, + /// Fighter origin at the collision substep. The ECB remains local to this + /// origin; callers must not rebase the ECB by the frame-sized movement. + pub resolved_translation: FGVec2, pub hit_normal: FGVec2, } -pub fn air_collide_with_stage( +#[cfg(test)] +fn air_collide_with_stage( prev_translation: &FighterPreviousTranslation, translation: &FighterTranslation, prev_ecb: &FighterPreviousECB, @@ -48,42 +58,298 @@ pub fn air_collide_with_stage( ignored_polys: &[usize], can_ignore_soft_platforms: bool, ) -> Option { - let ray_segment = FGSegment2d::new( - prev_translation.0 + prev_ecb.get_bottom_point(), - translation.0 + ecb.get_bottom_point(), - ); + let mut translation = *translation; + let mut velocity = FighterVelocity::default(); + air_collide_with_stage_substepped( + prev_translation, + &mut translation, + &mut velocity, + &mut ecb_module::FighterECB { + top: prev_ecb.top, + bottom: prev_ecb.bottom, + left: prev_ecb.left, + right: prev_ecb.right, + }, + &FighterDesiredECB::from(*ecb), + stage_collision, + ignored_polys, + can_ignore_soft_platforms, + ) +} +/// Sweeps movement and ECB shape changes together. +pub(crate) fn collision_step_count(max_change: FGi32) -> u32 { + let six = FGi32::lit("6.0"); + if max_change <= six { + return 1; + } + + let mut quotient = 0; + let mut remainder = max_change; + while remainder >= six { + quotient += 1; + remainder -= six; + } + quotient + 1 +} + +pub fn air_collide_with_stage_substepped( + prev_translation: &FighterPreviousTranslation, + translation: &mut FighterTranslation, + velocity: &mut FighterVelocity, + ecb: &mut crate::fighter::FighterECB, + desired: &FighterDesiredECB, + stage_collision: &StageCollision, + ignored_polys: &[usize], + can_ignore_soft_platforms: bool, +) -> Option { + let requested_translation = translation.0; + let movement = requested_translation - prev_translation.0; + let mut largest_change = movement.x.abs().max(movement.y.abs()); + largest_change = largest_change.max(ecb_module::ecb_max_delta(ecb, desired)); + let steps = collision_step_count(largest_change); + + let step_count = FGi32::from_num(steps); + let movement_per_step = movement / step_count; + let mut current_position = prev_translation.0; + let mut current_ecb = *ecb; + let mut collision_offset = FGVec2::ZERO; + + for step in 0..steps { + let previous_position = current_position; + let previous_ecb = current_ecb; + let remaining_steps = FGi32::from_num(steps - step); + ecb_module::interpolate_ecb(&mut current_ecb, *desired, FGi32::ONE / remaining_steps); + current_position = if step + 1 == steps { + // Fixed-point division may truncate. Always make the final + // substep land exactly on the requested frame endpoint. + requested_translation + collision_offset + } else { + prev_translation.0 + + movement_per_step * FGi32::from_num(step + 1) + + collision_offset + }; + + let lower_edges = [ + ( + previous_ecb.bottom, + previous_ecb.left, + current_ecb.bottom, + current_ecb.left, + ), + ( + previous_ecb.bottom, + previous_ecb.right, + current_ecb.bottom, + current_ecb.right, + ), + ]; + if let Some(result) = sweep_edges( + stage_collision, + previous_position, + current_position, + &lower_edges, + StagePolyLineSegmentType::Floor, + ignored_polys, + can_ignore_soft_platforms, + ) { + *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 upper_edges = [ + ( + previous_ecb.top, + previous_ecb.left, + current_ecb.top, + current_ecb.left, + ), + ( + previous_ecb.top, + previous_ecb.right, + current_ecb.top, + current_ecb.right, + ), + ]; + if let Some(contact) = sweep_edges( + stage_collision, + previous_position, + current_position, + &upper_edges, + StagePolyLineSegmentType::Ceiling, + ignored_polys, + true, + ) { + current_position += contact.correction; + collision_offset += contact.correction; + velocity.0 -= velocity.0.project_onto_normalized(contact.hit_normal); + } + + let side_edges = [ + ( + previous_ecb.bottom, + previous_ecb.right, + current_ecb.bottom, + current_ecb.right, + ), + ( + previous_ecb.right, + previous_ecb.top, + current_ecb.right, + current_ecb.top, + ), + ( + previous_ecb.top, + previous_ecb.left, + current_ecb.top, + current_ecb.left, + ), + ( + previous_ecb.left, + previous_ecb.bottom, + current_ecb.left, + current_ecb.bottom, + ), + ]; + if let Some(contact) = sweep_edges( + stage_collision, + previous_position, + current_position, + &side_edges, + StagePolyLineSegmentType::Wall, + ignored_polys, + true, + ) { + current_position += contact.correction; + collision_offset += contact.correction; + velocity.0 -= velocity.0.project_onto_normalized(contact.hit_normal); + } + } + + *ecb = current_ecb; + translation.0 = current_position; + None +} + +struct PointSweepResult { + line_id: StageLineID, + hit_normal: FGVec2, + correction: FGVec2, +} + +fn sweep_edges( + stage_collision: &StageCollision, + previous_position: FGVec2, + current_position: FGVec2, + edges: &[(FGVec2, FGVec2, FGVec2, FGVec2)], + segment_type: StagePolyLineSegmentType, + ignored_polys: &[usize], + ignore_platforms: bool, +) -> Option { stage_collision .stage_polys .iter() .enumerate() .filter(|(poly_idx, poly)| { !ignored_polys.contains(poly_idx) - && !(can_ignore_soft_platforms && matches!(poly.poly_type, StagePolyType::Platform)) + && !(ignore_platforms && matches!(poly.poly_type, StagePolyType::Platform)) }) - .filter_map(|(poly_idx, poly)| { - poly.intersect_ray(ray_segment).map(|intersect_result| { - ( - AirCollisionWithStageResult { - line_id: { - StageLineID { - polygon: poly_idx, - segment: intersect_result.segment_idx, + .flat_map(|(poly_idx, poly)| { + poly.segments + .iter() + .enumerate() + .filter(move |(_, segment)| segment.segment_type == segment_type) + .filter_map(move |(segment_idx, segment)| { + edges + .iter() + .filter_map(|(previous_a, previous_b, current_a, current_b)| { + let previous_a = previous_position + *previous_a; + let previous_b = previous_position + *previous_b; + let current_a = current_position + *current_a; + let current_b = current_position + *current_b; + let previous_min = segment + .normal + .dot(previous_a - segment.segment.point1()) + .min(segment.normal.dot(previous_b - segment.segment.point1())); + let current_a_distance = + segment.normal.dot(current_a - segment.segment.point1()); + let current_b_distance = + segment.normal.dot(current_b - segment.segment.point1()); + let current_min = current_a_distance.min(current_b_distance); + if previous_min < crate::math::int::FGWide::ZERO + || current_min > crate::math::int::FGWide::ZERO + { + return None; } - }, - hit_position: intersect_result.position, - hit_normal: intersect_result.normal, - }, - intersect_result - .position - .distance_squared(ray_segment.point2()), - ) - }) + + let boundary = [ + FGSegment2d::new(previous_a, previous_b), + FGSegment2d::new(previous_a, current_a), + FGSegment2d::new(previous_b, current_b), + FGSegment2d::new(current_a, current_b), + ]; + let quad = [previous_a, previous_b, current_b, current_a]; + let position = boundary + .iter() + .filter_map(|edge| segment.segment.segment_intersection(edge)) + .max_by_key(|position| position.distance_squared(current_a)) + .or_else(|| { + [segment.segment.point1(), segment.segment.point2()] + .into_iter() + .find(|point| point_in_convex_quad(*point, quad)) + })?; + let penetration = FGi32::from_num(-current_min); + Some((position, segment.normal * penetration)) + }) + .max_by_key(|(position, _)| { + position.distance_squared(segment.segment.point1()) + }) + .map(|(position, correction)| { + ( + PointSweepResult { + line_id: StageLineID { + polygon: poly_idx, + segment: segment_idx, + }, + hit_normal: segment.normal, + correction, + }, + position.distance_squared(segment.segment.point2()), + ) + }) + }) }) .max_by(|(_, a), (_, b)| a.cmp(b)) .map(|(result, _)| result) } +fn point_in_convex_quad(point: FGVec2, quad: [FGVec2; 4]) -> bool { + let mut has_positive = false; + let mut has_negative = false; + for (a, b) in quad.into_iter().zip(quad.into_iter().cycle().skip(1)).take(4) { + let edge = b - a; + let offset = point - a; + let cross = crate::math::int::FGWide::from_num(edge.x) + * crate::math::int::FGWide::from_num(offset.y) + - crate::math::int::FGWide::from_num(edge.y) + * crate::math::int::FGWide::from_num(offset.x); + has_positive |= cross > crate::math::int::FGWide::ZERO; + has_negative |= cross < crate::math::int::FGWide::ZERO; + if has_positive && has_negative { + return false; + } + } + true +} + #[derive(Clone)] pub struct AirCollisionWithLedgeResult { pub ledge: StageVertexID, @@ -93,9 +359,11 @@ pub fn air_collide_with_ledges( ledge_box: &FGRect, stage_collision: &StageCollision, ) -> Option { - stage_collision.intersect_ledges(ledge_box).map(|result| { - AirCollisionWithLedgeResult { ledge: result.vertex_id } - }) + stage_collision + .intersect_ledges(ledge_box) + .map(|result| AirCollisionWithLedgeResult { + ledge: result.vertex_id, + }) } #[cfg(test)] @@ -126,6 +394,173 @@ mod tests { ) } + fn single_segment( + start: FGVec2, + end: FGVec2, + segment_type: StagePolyLineSegmentType, + ) -> StagePoly { + let segment = FGSegment2d::new(start, end); + StagePoly { + poly_type: StagePolyType::Closed, + segments: vec![crate::stage::line::StagePolyLineSegment { + segment, + normal: segment.left_normal(), + segment_type, + }], + } + } + + #[test] + fn melee_step_count_adds_one_step_after_each_six_unit_block() { + assert_eq!(collision_step_count(FGi32::lit("6")), 1); + assert_eq!(collision_step_count(FGi32::lit("6.01")), 2); + assert_eq!(collision_step_count(FGi32::lit("12")), 3); + assert_eq!(collision_step_count(FGi32::lit("18")), 4); + } + + #[test] + fn collision_result_keeps_ecb_local_to_resolved_origin() { + let stage_collision = StageCollision { + stage_polys: vec![horizontal_platform("0")], + ..StageCollision::default() + }; + let previous_translation = FighterPreviousTranslation(FGVec2::lit("0", "2")); + let mut translation = FighterTranslation(FGVec2::lit("0", "-1")); + let mut velocity = FighterVelocity(FGVec2::lit("0", "-3")); + let mut ecb = FighterECB { + top: FGVec2::lit("0", "2"), + bottom: FGVec2::lit("0", "-1"), + left: FGVec2::lit("-1", "0.5"), + right: FGVec2::lit("1", "0.5"), + }; + let desired = FighterDesiredECB::from(ecb); + let collision = air_collide_with_stage_substepped( + &previous_translation, + &mut translation, + &mut velocity, + &mut ecb, + &desired, + &stage_collision, + &[], + false, + ) + .expect("the floor should be hit"); + + assert_eq!( + collision.resolved_translation + ecb.bottom, + collision.hit_position + ); + assert_eq!(velocity.0.y, FGi32::ZERO); + } + + #[test] + fn airborne_side_point_resolves_wall_and_projects_velocity() { + let stage_collision = StageCollision { + stage_polys: vec![single_segment( + FGVec2::lit("2", "2"), + FGVec2::lit("2", "-2"), + StagePolyLineSegmentType::Wall, + )], + ..StageCollision::default() + }; + let previous = FighterPreviousTranslation(FGVec2::lit("4", "0")); + let mut translation = FighterTranslation(FGVec2::lit("0", "0")); + let mut velocity = FighterVelocity(FGVec2::lit("-4", "0")); + let mut ecb = FighterECB { + top: FGVec2::lit("0", "1"), + bottom: FGVec2::lit("0", "-1"), + left: FGVec2::lit("-1", "0"), + right: FGVec2::lit("1", "0"), + }; + let desired = FighterDesiredECB::from(ecb); + + assert!(air_collide_with_stage_substepped( + &previous, + &mut translation, + &mut velocity, + &mut ecb, + &desired, + &stage_collision, + &[], + false, + ) + .is_none()); + assert_eq!(translation.0, FGVec2::lit("3", "0")); + assert_eq!(velocity.0.x, FGi32::ZERO); + } + + #[test] + fn stage_corner_cannot_pass_through_middle_of_diamond_edge() { + let stage_collision = StageCollision { + stage_polys: vec![single_segment( + FGVec2::lit("2", "0.6"), + FGVec2::lit("2", "0.4"), + StagePolyLineSegmentType::Wall, + )], + ..StageCollision::default() + }; + let previous = FighterPreviousTranslation(FGVec2::lit("4", "0")); + let mut translation = FighterTranslation(FGVec2::ZERO); + let mut velocity = FighterVelocity(FGVec2::lit("-4", "0")); + let mut ecb = FighterECB { + top: FGVec2::lit("0", "1"), + bottom: FGVec2::lit("0", "-1"), + left: FGVec2::lit("-1", "0"), + right: FGVec2::lit("1", "0"), + }; + let desired = FighterDesiredECB::from(ecb); + + assert!(air_collide_with_stage_substepped( + &previous, + &mut translation, + &mut velocity, + &mut ecb, + &desired, + &stage_collision, + &[], + false, + ) + .is_none()); + assert_eq!(translation.0.x, FGi32::lit("3")); + assert_eq!(velocity.0.x, FGi32::ZERO); + } + + #[test] + fn airborne_top_point_resolves_ceiling_and_projects_velocity() { + let stage_collision = StageCollision { + stage_polys: vec![single_segment( + FGVec2::lit("2", "2"), + FGVec2::lit("-2", "2"), + StagePolyLineSegmentType::Ceiling, + )], + ..StageCollision::default() + }; + let previous = FighterPreviousTranslation(FGVec2::ZERO); + let mut translation = FighterTranslation(FGVec2::lit("0", "4")); + let mut velocity = FighterVelocity(FGVec2::lit("0", "4")); + let mut ecb = FighterECB { + top: FGVec2::lit("0", "1"), + bottom: FGVec2::lit("0", "-1"), + left: FGVec2::lit("-1", "0"), + right: FGVec2::lit("1", "0"), + }; + let desired = FighterDesiredECB::from(ecb); + + assert!(air_collide_with_stage_substepped( + &previous, + &mut translation, + &mut velocity, + &mut ecb, + &desired, + &stage_collision, + &[], + false, + ) + .is_none()); + assert_eq!(translation.0, FGVec2::lit("0", "1")); + assert_eq!(velocity.0.y, FGi32::ZERO); + } + #[test] fn airborne_collision_skips_ignored_polygons() { let stage_collision = StageCollision { @@ -183,16 +618,18 @@ mod tests { right: FGVec2::ZERO, }; - assert!(air_collide_with_stage( - &previous_translation, - &translation, - &previous_ecb, - &ecb, - &stage_collision, - &[], - true, - ) - .is_none()); + assert!( + air_collide_with_stage( + &previous_translation, + &translation, + &previous_ecb, + &ecb, + &stage_collision, + &[], + true, + ) + .is_none() + ); assert_eq!( air_collide_with_stage( diff --git a/src/fighter/ecb.rs b/src/fighter/ecb.rs index ea60692..ff54f05 100644 --- a/src/fighter/ecb.rs +++ b/src/fighter/ecb.rs @@ -4,14 +4,12 @@ use crate::{ fighter::{ FighterFacingDirection, FighterSurfaceState, FighterTranslation, baked_animation::FighterBoneMatrices, - }, math::{int::FGi32, vec::FGVec2}, + }, + math::{int::FGi32, vec::FGVec2}, }; -/// Environment collision box: the diamond used to resolve a fighter against -/// the stage. Purely geometry — the collision response lives in -/// [`super::collision`]. #[derive(Component, Debug, Clone, Copy, Hash, Default)] -#[require(FighterPreviousECB)] +#[require(FighterPreviousECB, FighterDesiredECB)] pub struct FighterECB { pub top: FGVec2, pub bottom: FGVec2, @@ -19,6 +17,14 @@ pub struct FighterECB { pub right: FGVec2, } +#[derive(Component, Debug, Clone, Copy, Hash, Default)] +pub struct FighterDesiredECB { + pub top: FGVec2, + pub bottom: FGVec2, + pub left: FGVec2, + pub right: FGVec2, +} + #[derive(Component, Debug, Clone)] pub struct FighterECBBoneIndices { pub indices: Vec, @@ -43,12 +49,96 @@ impl From for FighterPreviousECB { } } +impl From for FighterDesiredECB { + fn from(ecb: FighterECB) -> Self { + Self { + top: ecb.top, + bottom: ecb.bottom, + left: ecb.left, + right: ecb.right, + } + } +} + +impl FighterDesiredECB { + pub fn as_ecb(self) -> FighterECB { + FighterECB { + top: self.top, + bottom: self.bottom, + left: self.left, + right: self.right, + } + } + + pub fn validate(&mut self) { + let minimum = FGi32::lit("1.0"); + if (self.top.y - self.bottom.y).abs() < minimum { + self.top.y += minimum; + let midpoint = (self.top.y + self.bottom.y) / FGi32::lit("2.0"); + self.left.y = midpoint; + self.right.y = midpoint; + } + if self.top.y < minimum { + self.top.y = minimum; + } + if self.left.x > -minimum { + self.left.x = -minimum; + } + if self.right.x < minimum { + self.right.x = minimum; + } + if self.top.y < self.bottom.y { + self.top.y = self.bottom.y + minimum; + } + + let midpoint = (self.top.y + self.bottom.y) / FGi32::lit("2.0"); + if self.left.y < self.bottom.y || self.left.y > self.top.y { + self.left.y = midpoint; + } + if self.right.y < self.bottom.y || self.right.y > self.top.y { + self.left.y = midpoint; + self.right.y = midpoint; + } + let epsilon = FGi32::from_bits(66); + if self.top.y - self.right.y < epsilon + || self.right.y - self.bottom.y < epsilon + { + self.right.y = midpoint; + } + if self.top.y - self.left.y < epsilon + || self.left.y - self.bottom.y < epsilon + { + self.left.y = midpoint; + } + } +} + pub fn snapshot_fighter_ecb(fighters: Query<(&FighterECB, &mut FighterPreviousECB)>) { for (ecb, mut previous_ecb) in fighters { *previous_ecb = (*ecb).into(); } } +pub fn interpolate_ecb(ecb: &mut FighterECB, desired: FighterDesiredECB, time: FGi32) { + let lerp = |a: FGVec2, b: FGVec2| a + (b - a) * time; + ecb.top = lerp(ecb.top, desired.top); + ecb.bottom = lerp(ecb.bottom, desired.bottom); + ecb.left = lerp(ecb.left, desired.left); + ecb.right = lerp(ecb.right, desired.right); +} + +pub fn ecb_max_delta(ecb: &FighterECB, desired: &FighterDesiredECB) -> FGi32 { + [ + (desired.left.x - ecb.left.x).abs(), + (desired.right.x - ecb.right.x).abs(), + (desired.top.y - ecb.top.y).abs(), + (desired.right.y - ecb.right.y).abs(), + ] + .into_iter() + .max() + .unwrap_or(FGi32::ZERO) +} + impl FighterECB { pub fn to_3d_lineloop(&self, trf: Transform) -> [Vec3; 4] { [self.top, self.right, self.bottom, self.left] @@ -58,6 +148,17 @@ impl FighterECB { pub fn get_bottom_point(&self) -> FGVec2 { self.bottom } + + /// Change the local origin to the bottom point without moving the ECB in + /// world space. Grounded fighters use the floor contact as their origin. + pub fn rebase_onto_bottom(&mut self) -> FGVec2 { + let bottom = self.bottom; + self.top -= bottom; + self.left -= bottom; + self.right -= bottom; + self.bottom = FGVec2::ZERO; + bottom + } } impl FighterPreviousECB { @@ -73,7 +174,7 @@ impl FighterPreviousECB { pub fn update_ecb( query: Query<( - &mut FighterECB, + &mut FighterDesiredECB, &FighterSurfaceState, &FighterECBBoneIndices, &FighterBoneMatrices, @@ -81,23 +182,44 @@ pub fn update_ecb( &FighterFacingDirection, )>, ) { - for (mut ecb, surface_state, bone_indices, bone_matrices, translation, facing_dir) in query { + for (mut desired, surface_state, bone_indices, bone_matrices, translation, facing_dir) in query + { let trf = translation.get_3d_transform(facing_dir); - let transformed_ecb_bones = bone_indices.indices.iter().filter_map(|idx| { - let Some((_, bone_matrix)) = bone_matrices.get_by_idx(*idx) else { - return None; - }; + let transformed_ecb_bones = bone_indices + .indices + .iter() + .filter_map(|idx| { + let Some((_, bone_matrix)) = bone_matrices.get_by_idx(*idx) else { + return None; + }; - let pt = trf.transform_point(bone_matrix.get_translation()); + let pt = trf.transform_point(bone_matrix.get_translation()); - Some(FGVec2::new(pt.x, pt.y) - translation.0) - }).collect::>(); + Some(FGVec2::new(pt.x, pt.y) - translation.0) + }) + .collect::>(); - let max_x = transformed_ecb_bones.iter().max_by(|a, b| a.x.cmp(&b.x)).map(|final_val| final_val.x).unwrap_or(FGi32::lit("0.5")); - let min_x = transformed_ecb_bones.iter().min_by(|a, b| a.x.cmp(&b.x)).map(|final_val| final_val.x).unwrap_or(FGi32::lit("-0.5")); - let max_y = transformed_ecb_bones.iter().max_by(|a, b| a.y.cmp(&b.y)).map(|final_val| final_val.y).unwrap_or(FGi32::lit("0.5")); - let min_y = transformed_ecb_bones.iter().min_by(|a, b| a.y.cmp(&b.y)).map(|final_val| final_val.y).unwrap_or(FGi32::lit("-0.5")); + let max_x = transformed_ecb_bones + .iter() + .max_by(|a, b| a.x.cmp(&b.x)) + .map(|final_val| final_val.x) + .unwrap_or(FGi32::lit("0.5")); + let min_x = transformed_ecb_bones + .iter() + .min_by(|a, b| a.x.cmp(&b.x)) + .map(|final_val| final_val.x) + .unwrap_or(FGi32::lit("-0.5")); + let max_y = transformed_ecb_bones + .iter() + .max_by(|a, b| a.y.cmp(&b.y)) + .map(|final_val| final_val.y) + .unwrap_or(FGi32::lit("0.5")); + let min_y = transformed_ecb_bones + .iter() + .min_by(|a, b| a.y.cmp(&b.y)) + .map(|final_val| final_val.y) + .unwrap_or(FGi32::lit("-0.5")); let midpoint_height = (max_y + min_y) / FGi32::lit("2.0"); let top = FGVec2::new(FGi32::ZERO, max_y); @@ -105,24 +227,29 @@ pub fn update_ecb( let left = FGVec2::new(min_x, midpoint_height); let right = FGVec2::new(max_x, midpoint_height); - let ecb_lock_frames_remaining = if let FighterSurfaceState::Airborne { ecb_lock_frames_remaining, .. } = surface_state { + let ecb_lock_frames_remaining = if let FighterSurfaceState::Airborne { + ecb_lock_frames_remaining, + .. + } = surface_state + { *ecb_lock_frames_remaining } else { 0 }; - ecb.top = top; + desired.top = top; if ecb_lock_frames_remaining == 0 { - ecb.bottom = bottom; + desired.bottom = bottom; } - ecb.left = left; - ecb.right = right; + desired.left = left; + desired.right = right; if surface_state.is_grounded() { - ecb.bottom = FGVec2::ZERO; + desired.bottom = FGVec2::ZERO; } + desired.validate(); } } @@ -152,4 +279,60 @@ mod tests { assert_eq!(previous.left, current.left); assert_eq!(previous.right, current.right); } + + #[test] + fn interpolation_reaches_target_with_full_step() { + let mut current = FighterECB { + top: FGVec2::lit("0", "1"), + bottom: FGVec2::lit("0", "0"), + left: FGVec2::lit("-1", "0.5"), + right: FGVec2::lit("1", "0.5"), + }; + let desired = FighterDesiredECB { + top: FGVec2::lit("0", "3"), + bottom: FGVec2::lit("0", "-1"), + left: FGVec2::lit("-2", "1"), + right: FGVec2::lit("2", "1"), + }; + interpolate_ecb(&mut current, desired, FGi32::ONE); + assert_eq!(current.top, desired.top); + assert_eq!(current.bottom, desired.bottom); + assert_eq!(current.left, desired.left); + assert_eq!(current.right, desired.right); + } + + #[test] + fn validation_repairs_tiny_and_out_of_range_diamond() { + let mut desired = FighterDesiredECB { + top: FGVec2::lit("0", "0.25"), + bottom: FGVec2::lit("0", "0"), + left: FGVec2::lit("0", "4"), + right: FGVec2::lit("0", "-4"), + }; + desired.validate(); + + assert_eq!(desired.top.y, FGi32::lit("1.25")); + assert_eq!(desired.left.x, FGi32::lit("-1")); + assert_eq!(desired.right.x, FGi32::lit("1")); + assert_eq!(desired.left.y, FGi32::lit("0.625")); + assert_eq!(desired.right.y, FGi32::lit("0.625")); + } + + #[test] + fn rebasing_onto_bottom_preserves_world_points() { + let origin = FGVec2::lit("4", "8"); + let mut ecb = FighterECB { + top: FGVec2::lit("0", "3"), + bottom: FGVec2::lit("0", "-1"), + left: FGVec2::lit("-2", "1"), + right: FGVec2::lit("2", "1"), + }; + let old_world_points = [ecb.top, ecb.bottom, ecb.left, ecb.right].map(|p| origin + p); + let new_origin = origin + ecb.bottom; + ecb.rebase_onto_bottom(); + let new_world_points = [ecb.top, ecb.bottom, ecb.left, ecb.right].map(|p| new_origin + p); + + assert_eq!(old_world_points, new_world_points); + assert_eq!(ecb.bottom, FGVec2::ZERO); + } } diff --git a/src/fighter/mod.rs b/src/fighter/mod.rs index 8ae863e..0005d31 100644 --- a/src/fighter/mod.rs +++ b/src/fighter/mod.rs @@ -10,6 +10,7 @@ pub mod animation; pub mod attack; pub mod attributes; pub mod baked_animation; +pub mod blast_zones; pub mod collision; pub mod debug; pub mod ecb; @@ -21,7 +22,6 @@ pub mod push; pub mod state; pub mod surface; pub mod visual; -pub mod blast_zones; // The fighter components are re-exported so the rest of the crate can say // `fighter::FighterVelocity` without caring which file it lives in. Named @@ -37,13 +37,25 @@ pub use motion::{ pub use surface::FighterSurfaceState; use crate::{ - AppState, fighter::{ + AppState, + fighter::{ attack::{ AttackKind, FighterAttackPlugin, FighterAttackScriptAssets, FighterDamage, FighterKnockbackVelocity, FighterPendingHitReaction, FighterSolvedHurtboxes, FighterStaleMoveQueue, - }, baked_animation::FighterBoneIndexMap, ecb::FighterECBBoneIndices, manifest::FighterManifest, state::{FighterState, air::AirborneStateCommon, fall::FallState}, visual::{FighterAnimations, FighterInvulnerabilityVisualState}, - }, game_settings::GameSettings, input::FighterInput, math::{int::FGi32, vec::FGVec2}, player::Player, schedule::GameplaySet, scripting::FighterAttackScript, + }, + baked_animation::FighterBoneIndexMap, + ecb::{FighterDesiredECB, FighterECBBoneIndices}, + manifest::FighterManifest, + state::{FighterState, air::AirborneStateCommon, fall::FallState}, + visual::{FighterAnimations, FighterInvulnerabilityVisualState}, + }, + game_settings::GameSettings, + input::FighterInput, + math::{int::FGi32, vec::FGVec2}, + player::Player, + schedule::GameplaySet, + scripting::FighterAttackScript, }; use state::state_interrupt_system; @@ -102,7 +114,8 @@ impl FighterLedgeInformation { } fn tick(&mut self) { - self.regrab_lockout_remaining_frames = self.regrab_lockout_remaining_frames.saturating_sub(1); + self.regrab_lockout_remaining_frames = + self.regrab_lockout_remaining_frames.saturating_sub(1); } } @@ -135,8 +148,12 @@ mod ledge_grab_lockout_tests { pub enum FighterInvulnerabilityState { #[default] Vulnerable, - Invulnerable { frames_remaining: Option }, - Intangible { frames_remaining: Option } + Invulnerable { + frames_remaining: Option, + }, + Intangible { + frames_remaining: Option, + }, } impl FighterFacingDirection { @@ -197,7 +214,7 @@ pub struct FighterPendingRespawn(bool); FighterKnockbackVelocity, FighterTranslation, FighterInvulnerabilityState, - FighterInvulnerabilityVisualState, + FighterInvulnerabilityVisualState )] pub struct Fighter { pub manifest: Handle, @@ -226,7 +243,11 @@ impl Plugin for FighterPlugin { ) .add_systems( GgrsSchedule, - (state::state_update_system, decay_hitstop, decay_invulnerability) + ( + state::state_update_system, + decay_hitstop, + decay_invulnerability, + ) .chain() .in_set(GameplaySet::StateUpdate), ) @@ -241,11 +262,12 @@ impl Plugin for FighterPlugin { ( ( blast_zones::apply_blast_zones, - ecb::update_ecb, ecb::decrement_ecb_lockout, + ecb::update_ecb, state::state_collision_interrupt_system, decay_ledge_grab_lockout, - ).chain(), + ) + .chain(), baked_animation::resample_fighter_bone_matrices, ) .chain() @@ -272,6 +294,8 @@ impl Plugin for FighterPlugin { // is a desync waiting to happen. .rollback_component_with_copy::() .checksum_component_with_hash::() + .rollback_component_with_copy::() + .checksum_component_with_hash::() .rollback_component_with_clone::() .checksum_component_with_hash::() .rollback_component_with_copy::() @@ -335,8 +359,9 @@ impl Plugin for FighterPlugin { .run_if(crate::debug_tools::attack_hitboxes_enabled) .run_if(in_state(AppState::InMatch)), ) - .add_systems(FixedPostUpdate, - visual::update_status_colors.run_if(resource_exists::) + .add_systems( + FixedPostUpdate, + visual::update_status_colors.run_if(resource_exists::), ) .add_systems( FixedPostUpdate, @@ -396,10 +421,11 @@ pub fn spawn_fighter( Transform::from_scale(Vec3::splat(model_scale.to_num())), ( FighterECB::default(), + FighterDesiredECB::default(), FighterHitboxes::default(), FighterECBBoneIndices { - indices: ecb_bone_indices - } + indices: ecb_bone_indices, + }, ), ( FighterVelocity::default(), @@ -449,7 +475,7 @@ pub fn decay_invulnerability(query: Query<&mut FighterInvulnerabilityState>) { } else { None } - }, + } FighterInvulnerabilityState::Intangible { frames_remaining } => { if let Some(frames_remaining) = frames_remaining { *frames_remaining = frames_remaining.saturating_sub(1); @@ -457,7 +483,7 @@ pub fn decay_invulnerability(query: Query<&mut FighterInvulnerabilityState>) { } else { None } - }, + } }; if let Some(frames_remaining) = frames_remaining { diff --git a/src/fighter/state/air.rs b/src/fighter/state/air.rs index b3d8ef2..e8d3415 100644 --- a/src/fighter/state/air.rs +++ b/src/fighter/state/air.rs @@ -134,12 +134,10 @@ pub fn collision_interrupt( } let res = ctx.air_collide_with_stage(can_ignore_soft_platforms)?; - let diff = res.hit_position - ctx.translation.0; - ctx.ecb.bottom -= diff; - ctx.ecb.top -= diff; - ctx.ecb.left -= diff; - ctx.ecb.right -= diff; - ctx.translation.0 = res.hit_position; + ctx.translation.0 = res.resolved_translation; + let bottom_offset = ctx.ecb.rebase_onto_bottom(); + ctx.translation.0 += bottom_offset; + *ctx.desired_ecb = crate::fighter::ecb::FighterDesiredECB::from(*ctx.ecb); ctx.presentation_snap.0 = true; ctx.surface.land(res.line_id, ctx.ledge_info); Some(FighterState::Land(LandingState::create(landing_type))) diff --git a/src/fighter/state/ground.rs b/src/fighter/state/ground.rs index 9703f19..039ef75 100644 --- a/src/fighter/state/ground.rs +++ b/src/fighter/state/ground.rs @@ -4,16 +4,18 @@ 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::{ - FighterAttributes, FighterTranslation, FighterVelocity, + 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::{StageLineID, StagePoly, StagePolyLineSegment, StagePolyLineSegmentType}; +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() { @@ -148,10 +150,7 @@ fn resolve_grounded_position( GroundedPositionResult::InAir } -pub fn apply_grounded_motion( - velocity: &FighterVelocity, - translation: &mut FighterTranslation, -) { +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); } @@ -176,61 +175,80 @@ 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 { + 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; - let stage_poly = stage_collision.stage_polys.get(current_line_id.polygon); + 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) + }; - if let Some(stage_poly) = stage_poly { - let ecb_bottom_pos = translation.0 + ecb.get_bottom_point(); + 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, - current_line_id.segment, - ecb_bottom_pos, + line_id.segment, + current_position + current_ecb.bottom, 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(); + 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(current_segment.normal) + - state_context.velocity.0.project_onto_normalized(segment.normal) }; - state_context.surface.land(StageLineID { - polygon: current_line_id.polygon, - segment: segment_idx, - }, state_context.ledge_info); - GroundedMotionResult::InGround } 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, ); - GroundedMotionResult::InAir + state_context.ecb_advanced = true; + return GroundedMotionResult::InAir; } } - } else { - state_context.surface.apply_airborne_policy( - crate::fighter::surface::EcbLockPolicy::StartOnDetach, - state_context.leave_ground_ecb_lock_duration, - ); - 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 { @@ -245,11 +263,13 @@ pub fn collision_interrupt(ctx: &mut CollisionContext, can_walk_off: bool) -> Op #[cfg(test)] mod tests { use super::*; - use crate::fighter::ecb::FighterPreviousECB; - use crate::fighter::{FighterECB, FighterLedgeInformation, FighterPreviousTranslation, FighterSurfaceState}; - use crate::math::rect::FGRect; -use crate::stage::line::StagePolyType; + 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( @@ -379,6 +399,7 @@ use crate::stage::line::StagePolyType; 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, @@ -387,6 +408,7 @@ use crate::stage::line::StagePolyType; 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, @@ -397,6 +419,7 @@ use crate::stage::line::StagePolyType; origin: FGVec2::ZERO, end: FGVec2::ZERO, }, + ecb_advanced: false, }; assert!(matches!( @@ -440,6 +463,7 @@ use crate::stage::line::StagePolyType; 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 { @@ -449,6 +473,7 @@ use crate::stage::line::StagePolyType; 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, @@ -459,6 +484,7 @@ use crate::stage::line::StagePolyType; origin: FGVec2::ZERO, end: FGVec2::ZERO, }, + ecb_advanced: false, }; assert!(matches!( @@ -498,15 +524,10 @@ pub fn grounded_movement_standstill_common_interrupts( 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 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.y = -game_settings.input_common.stick_deadzone - FGi32::lit("-0.01"); wavedash_dir.x = movement.x.signum(); } @@ -517,4 +538,4 @@ pub fn perform_wavedash(input: &FighterInput, game_settings: &GameSettings) -> F FighterState::AirDodge(AirDodgeState { direction: wavedash_dir.normalize_or_zero(), }) -} \ No newline at end of file +} diff --git a/src/fighter/state/mod.rs b/src/fighter/state/mod.rs index 2b55cbe..a465453 100644 --- a/src/fighter/state/mod.rs +++ b/src/fighter/state/mod.rs @@ -12,7 +12,7 @@ use crate::fighter::attack::{ }; use crate::fighter::attributes::FighterAttributes; use crate::fighter::baked_animation::{BakedFighterAnimations, FighterBoneMatrices}; -use crate::fighter::ecb::FighterPreviousECB; +use crate::fighter::ecb::{self, FighterDesiredECB, FighterPreviousECB}; use crate::fighter::manifest::FighterManifest; use crate::fighter::state::hitstun::HitstunState; use crate::fighter::surface::EcbLockPolicy; @@ -49,14 +49,14 @@ pub mod ground_attack; pub mod hitstun; pub mod jump; pub mod land; +pub mod ledge_action; +pub mod ledge_grab; +pub mod ledge_wait; pub mod platform_pass; pub mod run; pub mod turn; pub mod wait; pub mod walk; -pub mod ledge_wait; -pub mod ledge_action; -pub mod ledge_grab; #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] pub enum SurfacePolicy { @@ -192,10 +192,7 @@ impl EnterContext<'_> { let position = self .baked_animations .initial_root_position(self.animation_frame.kind, self.model_scale)?; - Some( - self.translation.0 - + animation_position_to_gameplay(position, self.facing_direction), - ) + Some(self.translation.0 + animation_position_to_gameplay(position, self.facing_direction)) } pub fn get_animation_duration(&self, kind: AnimKind) -> Option { @@ -255,6 +252,7 @@ pub struct CollisionContext<'a> { pub velocity: &'a mut FighterVelocity, pub surface: &'a mut FighterSurfaceState, pub ecb: &'a mut FighterECB, + pub desired_ecb: &'a mut FighterDesiredECB, pub prev_ecb: &'a FighterPreviousECB, pub stage_collision: &'a StageCollision, pub leave_ground_ecb_lock_duration: u32, @@ -262,25 +260,42 @@ pub struct CollisionContext<'a> { pub ledge_info: &'a mut FighterLedgeInformation, pub fighter_ledge_collision_box: FGRect, pub downward_stick_threshold: FGi32, + pub(crate) ecb_advanced: bool, } impl CollisionContext<'_> { + /// Commit the target pose before resolving this frame. The collision + /// substep resolver can later replace this with fractional steps. + pub fn apply_desired_ecb(&mut self) { + ecb::interpolate_ecb(self.ecb, *self.desired_ecb, FGi32::ONE); + self.ecb_advanced = true; + } + pub fn air_collide_with_stage( - &self, + &mut self, can_ignore_soft_platforms: bool, ) -> Option { let ignored_polys = self.surface.ignored_polygon_ids(); let ignore_soft_platforms = can_ignore_soft_platforms && self.input.get_last_frame().movement.y <= -self.downward_stick_threshold; - crate::fighter::collision::air_collide_with_stage( + let result = crate::fighter::collision::air_collide_with_stage_substepped( self.prev_translation, self.translation, - self.prev_ecb, + self.velocity, self.ecb, + self.desired_ecb, self.stage_collision, &ignored_polys, ignore_soft_platforms, - ) + ); + self.ecb_advanced = true; + result + } + + fn finish_ecb_frame(&mut self) { + if !self.ecb_advanced { + self.apply_desired_ecb(); + } } pub fn air_collide_with_ledges( &self, @@ -330,6 +345,7 @@ pub struct FighterFrameQuery { pub knockback_velocity: &'static mut FighterKnockbackVelocity, pub stale_move_queue: &'static mut FighterStaleMoveQueue, pub ecb: &'static mut FighterECB, + pub desired_ecb: &'static mut FighterDesiredECB, pub prev_ecb: &'static FighterPreviousECB, pub facing_direction: &'static mut FighterFacingDirection, pub animation_frame: &'static mut FighterAnimationFrame, @@ -477,6 +493,7 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> { velocity: &mut self.velocity, surface: &mut self.surface, ecb: &mut self.ecb, + desired_ecb: &mut self.desired_ecb, ledge_info: &mut self.ledge_information, prev_ecb: &self.prev_ecb, stage_collision, @@ -485,6 +502,7 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> { .leave_ground_ecb_frame_lock_duration, downward_stick_threshold: game_settings.input_common.run_stick_threshold, fighter_ledge_collision_box, + ecb_advanced: false, } } } @@ -500,7 +518,6 @@ pub struct StatefulFighterQuery { pub player: &'static Player, pub pending_hit_reaction: &'static mut FighterPendingHitReaction, pub hitstop: &'static mut FighterHitstop, - pub ledge_info: &'static mut FighterLedgeInformation, pub hitstop_shake: &'static mut FighterHitstopShake, pub pending_respawn: &'static mut FighterPendingRespawn, pub damage: &'static mut FighterDamage, @@ -710,7 +727,6 @@ pub fn state_collision_interrupt_system( mut pending_respawn, player, mut damage, - mut ledge_info, .. } = fighter; @@ -761,7 +777,7 @@ pub fn state_collision_interrupt_system( *damage = FighterDamage::default(); // reset ledge - *ledge_info = FighterLedgeInformation::default(); + *frame.ledge_information = FighterLedgeInformation::default(); transition_state( &mut state, @@ -781,9 +797,13 @@ pub fn state_collision_interrupt_system( continue; } - let new_state = state.check_collision_interrupt( - &mut frame.collision_context(&stage_collision, manifest, &game_settings), - ); + let new_state = { + let mut collision_context = + frame.collision_context(&stage_collision, manifest, &game_settings); + let new_state = state.check_collision_interrupt(&mut collision_context); + collision_context.finish_ecb_frame(); + new_state + }; if let Some(new_state) = new_state { transition_state( &mut state, @@ -840,19 +860,26 @@ mod tests { let mut velocity = FighterVelocity::default(); let mut surface = FighterSurfaceState::default(); let mut ecb = FighterECB::default(); + let mut desired_ecb = FighterDesiredECB { + top: FGVec2::lit("0", "2"), + bottom: FGVec2::ZERO, + left: FGVec2::lit("-1", "1"), + right: FGVec2::lit("1", "1"), + }; let mut ledge_info = FighterLedgeInformation { regrab_lockout_remaining_frames: 5, ..default() }; let previous_ecb = FighterPreviousECB::from(ecb); let mut presentation_snap = FighterPresentationSnap::default(); - let context = CollisionContext { + let mut context = CollisionContext { translation: &mut translation, prev_translation: &previous_translation, input: &input, 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, @@ -863,6 +890,7 @@ mod tests { end: FGVec2::lit("1", "-1"), }, downward_stick_threshold: FGi32::ONE, + ecb_advanced: false, }; assert!(context.air_collide_with_ledges().is_none()); @@ -871,5 +899,7 @@ mod tests { context.air_collide_with_ledges().map(|result| result.ledge), Some(ledge_id) ); + context.finish_ecb_frame(); + assert_eq!(context.ecb.top, FGVec2::lit("0", "2")); } }