From 259dada54f440bc8a2ad36c688e985888d838a81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lex=20Rom=C3=A1n=20N=C3=BA=C3=B1ez?= Date: Wed, 7 Oct 2026 07:29:02 +0000 Subject: [PATCH] Implement grounded fighter pushing (#85) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed-on: https://git.eirexe.moe/EIREXE/shinespark/pulls/85 Co-authored-by: Álex Román Núñez --- src/fighter/attributes.rs | 7 + src/fighter/mod.rs | 7 + src/fighter/push.rs | 213 +++++++++++++++++++++++++++++ src/fighter/state/air.rs | 2 + 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 - src/game_settings.rs | 3 + 10 files changed, 235 insertions(+), 9 deletions(-) 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..922e0bc 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,12 @@ impl Plugin for FighterPlugin { .chain() .in_set(GameplaySet::Collision), ) + .add_systems( + GgrsSchedule, + 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 new file mode 100644 index 0000000..6681a61 --- /dev/null +++ b/src/fighter/push.rs @@ -0,0 +1,213 @@ +//! 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 +} + +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, + mut fighters: Query<( + &Fighter, + &mut FighterTranslation, + &FighterFacingDirection, + &FighterSurfaceState, + )>, + 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, _, _)) = fighters.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), + (FGVec2::lit("30", "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")] + ); + } + + #[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(); + } +} 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/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, ); } 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,