From 422cdaac1781c67e3a1379ac88223ca4df50c3be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lex=20Rom=C3=A1n=20N=C3=BA=C3=B1ez?= Date: Tue, 6 Oct 2026 19:33:13 +0200 Subject: [PATCH 1/4] Implement grounded fighter pushing --- src/fighter/attributes.rs | 7 ++ src/fighter/mod.rs | 5 + src/fighter/push.rs | 207 ++++++++++++++++++++++++++++++++++++++ src/fighter/state/air.rs | 2 + src/game_settings.rs | 3 + 5 files changed, 224 insertions(+) create mode 100644 src/fighter/push.rs diff --git a/src/fighter/attributes.rs b/src/fighter/attributes.rs index 229992a..93dff36 100644 --- a/src/fighter/attributes.rs +++ b/src/fighter/attributes.rs @@ -9,6 +9,10 @@ use crate::{input::FighterInputFrame, math::int::FGi32}; #[derive(Asset, Clone, Copy, Reflect, Serialize, Deserialize)] #[reflect(opaque)] pub struct FighterAttributes { + /// Fixed horizontal offset of the fighter's grounded pushbox center. + pub pushbox_offset: FGi32, + /// Fixed horizontal radius used for grounded fighter-to-fighter pushing. + pub pushbox_radius: FGi32, pub base_walk_accel: FGi32, pub stick_walk_accel: FGi32, pub max_walk_vel: FGi32, @@ -54,6 +58,9 @@ impl FighterAttributes { if self.weight <= FGi32::ZERO { return Err("weight must be positive".into()); } + if self.pushbox_radius <= FGi32::ZERO { + return Err("pushbox radius must be positive".into()); + } Ok(()) } diff --git a/src/fighter/mod.rs b/src/fighter/mod.rs index 5ba5dc4..b1b4ec8 100644 --- a/src/fighter/mod.rs +++ b/src/fighter/mod.rs @@ -17,6 +17,7 @@ pub mod errors; pub mod hurtbox; pub mod manifest; pub mod motion; +pub mod push; pub mod state; pub mod surface; pub mod visual; @@ -198,6 +199,10 @@ impl Plugin for FighterPlugin { .chain() .in_set(GameplaySet::Collision), ) + .add_systems( + GgrsSchedule, + push::apply_grounded_pushes.in_set(GameplaySet::Physics), + ) .add_systems( PostUpdate, ( diff --git a/src/fighter/push.rs b/src/fighter/push.rs new file mode 100644 index 0000000..934594c --- /dev/null +++ b/src/fighter/push.rs @@ -0,0 +1,207 @@ +//! Deterministic grounded fighter-to-fighter pushing. + +use bevy::prelude::*; + +use crate::{ + fighter::{ + Fighter, FighterFacingDirection, FighterRoster, FighterSurfaceState, FighterTranslation, + manifest::FighterManifest, + }, + game_settings::GameSettings, + math::int::FGi32, + stage::line::{StageCollision, StageLineID, StagePoly}, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Pushbox { + center: FGi32, + radius: FGi32, + line_id: StageLineID, +} + +fn can_interact(stage: &StageCollision, first: StageLineID, second: StageLineID) -> bool { + if first.polygon != second.polygon { + return false; + } + if first.segment == second.segment { + return true; + } + + let Some(poly) = stage.stage_polys.get(first.polygon) else { + return false; + }; + adjacent_segment(poly, first.segment, second.segment) +} + +fn adjacent_segment(poly: &StagePoly, first: usize, second: usize) -> bool { + poly.get_next_segment_index(first) == Some(second) + || poly.get_prev_segment_index(first) == Some(second) +} + +fn push_delta(first: Pushbox, second: Pushbox, nudge: FGi32) -> FGi32 { + if (first.center - second.center).abs() >= first.radius + second.radius { + return FGi32::ZERO; + } + (first.center - second.center).signum() * nudge +} + +/// Applies all pair contributions from a position snapshot simultaneously. +/// The pair loop intentionally visits every direction: each fighter gets its +/// own nudge, just as in Melee. +fn calculate_push_deltas( + pushboxes: &[Option], + stage: &StageCollision, + nudge: FGi32, +) -> Vec { + let mut deltas = vec![FGi32::ZERO; pushboxes.len()]; + for first in 0..pushboxes.len() { + let Some(first_pushbox) = pushboxes[first] else { + continue; + }; + for second in 0..pushboxes.len() { + if first == second { + continue; + } + let Some(second_pushbox) = pushboxes[second] else { + continue; + }; + if can_interact(stage, first_pushbox.line_id, second_pushbox.line_id) { + deltas[first] += push_delta(first_pushbox, second_pushbox, nudge); + } + } + } + deltas +} + +pub fn apply_grounded_pushes( + roster: Res, + fighters: Query<( + &Fighter, + &FighterTranslation, + &FighterFacingDirection, + &FighterSurfaceState, + )>, + mut translations: Query<&mut FighterTranslation>, + manifests: Res>, + settings: Res, + stage: Res, +) { + let pushboxes = roster + .0 + .iter() + .map(|entity| { + let Ok((fighter, translation, facing, surface)) = fighters.get(*entity) else { + return None; + }; + let Some(line_id) = surface.line_id() else { + return None; + }; + let manifest = manifests.get(&fighter.manifest)?; + let attributes = &manifest.attributes; + Some(Pushbox { + center: translation.x + facing.to_sign() * attributes.pushbox_offset, + radius: attributes.pushbox_radius, + line_id, + }) + }) + .collect::>(); + let deltas = calculate_push_deltas( + &pushboxes, + &stage, + settings.fighter_common.grounded_push_nudge, + ); + + for (entity, delta) in roster.0.iter().zip(deltas) { + if delta == FGi32::ZERO { + continue; + } + if let Ok(mut translation) = translations.get_mut(*entity) { + translation.x += delta; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::math::vec::FGVec2; + use crate::stage::line::{StagePolyLineSegmentType, StagePolyType}; + + fn stage() -> StageCollision { + StageCollision { + stage_polys: vec![StagePoly::build( + StagePolyType::Platform, + &[ + (FGVec2::lit("0", "0"), StagePolyLineSegmentType::Floor), + (FGVec2::lit("10", "0"), StagePolyLineSegmentType::Floor), + (FGVec2::lit("20", "0"), StagePolyLineSegmentType::Floor), + ], + )], + } + } + + fn box_at(x: &str, segment: usize) -> Pushbox { + Pushbox { + center: FGi32::lit(x), + radius: FGi32::lit("1"), + line_id: StageLineID { + polygon: 0, + segment, + }, + } + } + + #[test] + fn overlapping_pair_gets_opposite_fixed_nudges() { + let deltas = calculate_push_deltas( + &[Some(box_at("0", 0)), Some(box_at("1", 0))], + &stage(), + FGi32::lit("0.3"), + ); + assert_eq!(deltas, vec![FGi32::lit("-0.3"), FGi32::lit("0.3")]); + } + + #[test] + fn touching_pushboxes_do_not_nudge() { + let deltas = calculate_push_deltas( + &[Some(box_at("0", 0)), Some(box_at("2", 0))], + &stage(), + FGi32::lit("0.3"), + ); + assert_eq!(deltas, vec![FGi32::ZERO, FGi32::ZERO]); + } + + #[test] + fn adjacent_segments_can_push_and_unrelated_segments_cannot() { + let adjacent = calculate_push_deltas( + &[Some(box_at("9.5", 0)), Some(box_at("10", 1))], + &stage(), + FGi32::lit("0.3"), + ); + assert_eq!(adjacent, vec![FGi32::lit("-0.3"), FGi32::lit("0.3")]); + + let unrelated = calculate_push_deltas( + &[Some(box_at("1", 0)), Some(box_at("2", 2))], + &stage(), + FGi32::lit("0.3"), + ); + assert_eq!(unrelated, vec![FGi32::ZERO, FGi32::ZERO]); + } + + #[test] + fn multiple_overlaps_accumulate() { + let deltas = calculate_push_deltas( + &[ + Some(box_at("0", 0)), + Some(box_at("0.5", 0)), + Some(box_at("1", 0)), + ], + &stage(), + FGi32::lit("0.3"), + ); + assert_eq!( + deltas, + vec![FGi32::lit("-0.6"), FGi32::ZERO, FGi32::lit("0.6")] + ); + } +} diff --git a/src/fighter/state/air.rs b/src/fighter/state/air.rs index ba7d6dc..bf853cd 100644 --- a/src/fighter/state/air.rs +++ b/src/fighter/state/air.rs @@ -148,6 +148,8 @@ mod tests { fn test_attributes() -> FighterAttributes { FighterAttributes { + pushbox_offset: FGi32::ZERO, + pushbox_radius: FGi32::ONE, base_walk_accel: FGi32::ZERO, stick_walk_accel: FGi32::ZERO, max_walk_vel: FGi32::ZERO, diff --git a/src/game_settings.rs b/src/game_settings.rs index 6c16cc7..628296f 100644 --- a/src/game_settings.rs +++ b/src/game_settings.rs @@ -10,6 +10,9 @@ use crate::{math::int::FGi32, menus::style::FGUiStyle}; pub struct FighterSettingsCommon { pub walk_speed_ease: FGi32, + /// Horizontal position nudge applied to each overlapping grounded fighter. + pub grounded_push_nudge: FGi32, + /// Maximum allowed horizontal velocity pub ground_max_horizontal_velocity: FGi32, -- 2.54.0 From da8ae800dece4c46393e3ebbeab6d1a219cdee57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lex=20Rom=C3=A1n=20N=C3=BA=C3=B1ez?= Date: Tue, 6 Oct 2026 19:54:00 +0200 Subject: [PATCH 2/4] Fix grounded push system query conflict --- src/fighter/mod.rs | 4 +++- src/fighter/push.rs | 17 +++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/fighter/mod.rs b/src/fighter/mod.rs index b1b4ec8..922e0bc 100644 --- a/src/fighter/mod.rs +++ b/src/fighter/mod.rs @@ -201,7 +201,9 @@ impl Plugin for FighterPlugin { ) .add_systems( GgrsSchedule, - push::apply_grounded_pushes.in_set(GameplaySet::Physics), + push::apply_grounded_pushes + .before(state::state_update_system) + .in_set(GameplaySet::StateUpdate), ) .add_systems( PostUpdate, diff --git a/src/fighter/push.rs b/src/fighter/push.rs index 934594c..9c10800 100644 --- a/src/fighter/push.rs +++ b/src/fighter/push.rs @@ -75,13 +75,12 @@ fn calculate_push_deltas( pub fn apply_grounded_pushes( roster: Res, - fighters: Query<( + mut fighters: Query<( &Fighter, - &FighterTranslation, + &mut FighterTranslation, &FighterFacingDirection, &FighterSurfaceState, )>, - mut translations: Query<&mut FighterTranslation>, manifests: Res>, settings: Res, stage: Res, @@ -115,7 +114,7 @@ pub fn apply_grounded_pushes( if delta == FGi32::ZERO { continue; } - if let Ok(mut translation) = translations.get_mut(*entity) { + if let Ok((_, mut translation, _, _)) = fighters.get_mut(*entity) { translation.x += delta; } } @@ -135,6 +134,7 @@ mod tests { (FGVec2::lit("0", "0"), StagePolyLineSegmentType::Floor), (FGVec2::lit("10", "0"), StagePolyLineSegmentType::Floor), (FGVec2::lit("20", "0"), StagePolyLineSegmentType::Floor), + (FGVec2::lit("30", "0"), StagePolyLineSegmentType::Floor), ], )], } @@ -204,4 +204,13 @@ mod tests { vec![FGi32::lit("-0.6"), FGi32::ZERO, FGi32::lit("0.6")] ); } + + #[test] + fn system_parameters_initialize_without_query_conflicts() { + let mut world = World::new(); + let mut schedule = Schedule::default(); + schedule.add_systems(apply_grounded_pushes); + + schedule.initialize(&mut world).unwrap(); + } } -- 2.54.0 From a3fe5123f1402db2c5067d4f3efc9f9eb61db182 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lex=20Rom=C3=A1n=20N=C3=BA=C3=B1ez?= Date: Tue, 6 Oct 2026 20:00:24 +0200 Subject: [PATCH 3/4] Preserve grounded motion presentation snapshot --- src/fighter/state/dash.rs | 1 - src/fighter/state/ground.rs | 8 +++----- src/fighter/state/ground_attack.rs | 1 - src/fighter/state/run.rs | 1 - src/fighter/state/walk.rs | 1 - 5 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/fighter/state/dash.rs b/src/fighter/state/dash.rs index 61c04cf..523fae0 100644 --- a/src/fighter/state/dash.rs +++ b/src/fighter/state/dash.rs @@ -99,7 +99,6 @@ impl FighterStateImpl for DashState { ground::apply_grounded_motion( state_context.velocity, state_context.translation, - state_context.prev_translation, ); } diff --git a/src/fighter/state/ground.rs b/src/fighter/state/ground.rs index 024cbfe..219d13b 100644 --- a/src/fighter/state/ground.rs +++ b/src/fighter/state/ground.rs @@ -6,7 +6,7 @@ use crate::fighter::state::{ CollisionContext, FighterState, InterruptContext, air, crouch, dash, fall, ground_attack, turn, wait, walk, }; use crate::fighter::{ - FighterAttributes, FighterPreviousTranslation, FighterTranslation, FighterVelocity, + FighterAttributes, FighterTranslation, FighterVelocity, }; use crate::game_settings::GameSettings; use crate::math::int::{FGWide, FGi32}; @@ -149,10 +149,8 @@ fn resolve_grounded_position( 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); } @@ -169,7 +167,7 @@ pub fn update_friction_motion(ctx: &mut crate::fighter::state::UpdateContext) { 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); + apply_grounded_motion(ctx.velocity, ctx.translation); } pub fn collide_with_stage_grounded( @@ -246,7 +244,7 @@ pub fn collision_interrupt(ctx: &mut CollisionContext, can_walk_off: bool) -> Op mod tests { use super::*; use crate::fighter::ecb::FighterPreviousECB; - use crate::fighter::{FighterECB, FighterSurfaceState}; + use crate::fighter::{FighterECB, FighterPreviousTranslation, FighterSurfaceState}; use crate::stage::line::StagePolyType; use crate::input::FighterInput; diff --git a/src/fighter/state/ground_attack.rs b/src/fighter/state/ground_attack.rs index 80f941b..854679c 100644 --- a/src/fighter/state/ground_attack.rs +++ b/src/fighter/state/ground_attack.rs @@ -50,7 +50,6 @@ impl FighterStateImpl for GroundAttackState { ground::apply_grounded_motion( state_context.velocity, state_context.translation, - state_context.prev_translation, ); } else { ground::update_friction_motion(state_context); diff --git a/src/fighter/state/run.rs b/src/fighter/state/run.rs index 38414ac..5aa4ce0 100644 --- a/src/fighter/state/run.rs +++ b/src/fighter/state/run.rs @@ -48,7 +48,6 @@ impl FighterStateImpl for RunState { ground::apply_grounded_motion( state_context.velocity, state_context.translation, - state_context.prev_translation, ); } diff --git a/src/fighter/state/walk.rs b/src/fighter/state/walk.rs index 383f47f..a94940c 100644 --- a/src/fighter/state/walk.rs +++ b/src/fighter/state/walk.rs @@ -40,7 +40,6 @@ impl FighterStateImpl for WalkState { ground::apply_grounded_motion( state_context.velocity, state_context.translation, - state_context.prev_translation, ); } -- 2.54.0 From d3bb1de0f0a9c56741faedbf7104c816bb902025 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lex=20Rom=C3=A1n=20N=C3=BA=C3=B1ez?= Date: Tue, 6 Oct 2026 20:06:24 +0200 Subject: [PATCH 4/4] Mend some stuff --- src/fighter/push.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/fighter/push.rs b/src/fighter/push.rs index 9c10800..6681a61 100644 --- a/src/fighter/push.rs +++ b/src/fighter/push.rs @@ -1,4 +1,4 @@ -//! Deterministic grounded fighter-to-fighter pushing. +//! fighter-to-fighter pushing use bevy::prelude::*; @@ -45,9 +45,6 @@ fn push_delta(first: Pushbox, second: Pushbox, nudge: FGi32) -> FGi32 { (first.center - second.center).signum() * nudge } -/// Applies all pair contributions from a position snapshot simultaneously. -/// The pair loop intentionally visits every direction: each fighter gets its -/// own nudge, just as in Melee. fn calculate_push_deltas( pushboxes: &[Option], stage: &StageCollision, -- 2.54.0