Implement grounded fighter pushing #85

Merged
EIREXE merged 4 commits from feature/grounded-fighter-pushing into master 2026-10-07 07:29:02 +00:00
10 changed files with 235 additions and 9 deletions
+7
View File
@@ -9,6 +9,10 @@ use crate::{input::FighterInputFrame, math::int::FGi32};
#[derive(Asset, Clone, Copy, Reflect, Serialize, Deserialize)] #[derive(Asset, Clone, Copy, Reflect, Serialize, Deserialize)]
#[reflect(opaque)] #[reflect(opaque)]
pub struct FighterAttributes { 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 base_walk_accel: FGi32,
pub stick_walk_accel: FGi32, pub stick_walk_accel: FGi32,
pub max_walk_vel: FGi32, pub max_walk_vel: FGi32,
@@ -54,6 +58,9 @@ impl FighterAttributes {
if self.weight <= FGi32::ZERO { if self.weight <= FGi32::ZERO {
return Err("weight must be positive".into()); return Err("weight must be positive".into());
} }
if self.pushbox_radius <= FGi32::ZERO {
return Err("pushbox radius must be positive".into());
}
Ok(()) Ok(())
} }
+7
View File
@@ -17,6 +17,7 @@ pub mod errors;
pub mod hurtbox; pub mod hurtbox;
pub mod manifest; pub mod manifest;
pub mod motion; pub mod motion;
pub mod push;
pub mod state; pub mod state;
pub mod surface; pub mod surface;
pub mod visual; pub mod visual;
@@ -198,6 +199,12 @@ impl Plugin for FighterPlugin {
.chain() .chain()
.in_set(GameplaySet::Collision), .in_set(GameplaySet::Collision),
) )
.add_systems(
GgrsSchedule,
push::apply_grounded_pushes
.before(state::state_update_system)
.in_set(GameplaySet::StateUpdate),
)
.add_systems( .add_systems(
PostUpdate, PostUpdate,
( (
+213
View File
@@ -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<Pushbox>],
stage: &StageCollision,
nudge: FGi32,
) -> Vec<FGi32> {
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<FighterRoster>,
mut fighters: Query<(
&Fighter,
&mut FighterTranslation,
&FighterFacingDirection,
&FighterSurfaceState,
)>,
manifests: Res<Assets<FighterManifest>>,
settings: Res<GameSettings>,
stage: Res<StageCollision>,
) {
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::<Vec<_>>();
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();
}
}
+2
View File
@@ -148,6 +148,8 @@ mod tests {
fn test_attributes() -> FighterAttributes { fn test_attributes() -> FighterAttributes {
FighterAttributes { FighterAttributes {
pushbox_offset: FGi32::ZERO,
pushbox_radius: FGi32::ONE,
base_walk_accel: FGi32::ZERO, base_walk_accel: FGi32::ZERO,
stick_walk_accel: FGi32::ZERO, stick_walk_accel: FGi32::ZERO,
max_walk_vel: FGi32::ZERO, max_walk_vel: FGi32::ZERO,
-1
View File
@@ -99,7 +99,6 @@ impl FighterStateImpl for DashState {
ground::apply_grounded_motion( ground::apply_grounded_motion(
state_context.velocity, state_context.velocity,
state_context.translation, state_context.translation,
state_context.prev_translation,
); );
} }
+3 -5
View File
@@ -6,7 +6,7 @@ use crate::fighter::state::{
CollisionContext, FighterState, InterruptContext, air, crouch, dash, fall, ground_attack, turn, wait, walk, CollisionContext, FighterState, InterruptContext, air, crouch, dash, fall, ground_attack, turn, wait, walk,
}; };
use crate::fighter::{ use crate::fighter::{
FighterAttributes, FighterPreviousTranslation, FighterTranslation, FighterVelocity, FighterAttributes, FighterTranslation, FighterVelocity,
}; };
use crate::game_settings::GameSettings; use crate::game_settings::GameSettings;
use crate::math::int::{FGWide, FGi32}; use crate::math::int::{FGWide, FGi32};
@@ -149,10 +149,8 @@ fn resolve_grounded_position(
pub fn apply_grounded_motion( pub fn apply_grounded_motion(
velocity: &FighterVelocity, velocity: &FighterVelocity,
translation: &mut FighterTranslation, translation: &mut FighterTranslation,
prev_translation: &mut FighterPreviousTranslation,
) { ) {
// Vertical velocity should be 0 on the ground // Vertical velocity should be 0 on the ground
prev_translation.0 = translation.0;
translation.0 += FGVec2::new(velocity.0.x, FGi32::ZERO); 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; let ground_velocity = ctx.velocity.x;
ctx.velocity.x += apply_grounded_friction(friction, ground_velocity); 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( pub fn collide_with_stage_grounded(
@@ -246,7 +244,7 @@ pub fn collision_interrupt(ctx: &mut CollisionContext, can_walk_off: bool) -> Op
mod tests { mod tests {
use super::*; use super::*;
use crate::fighter::ecb::FighterPreviousECB; use crate::fighter::ecb::FighterPreviousECB;
use crate::fighter::{FighterECB, FighterSurfaceState}; use crate::fighter::{FighterECB, FighterPreviousTranslation, FighterSurfaceState};
use crate::stage::line::StagePolyType; use crate::stage::line::StagePolyType;
use crate::input::FighterInput; use crate::input::FighterInput;
-1
View File
@@ -50,7 +50,6 @@ impl FighterStateImpl for GroundAttackState {
ground::apply_grounded_motion( ground::apply_grounded_motion(
state_context.velocity, state_context.velocity,
state_context.translation, state_context.translation,
state_context.prev_translation,
); );
} else { } else {
ground::update_friction_motion(state_context); ground::update_friction_motion(state_context);
-1
View File
@@ -48,7 +48,6 @@ impl FighterStateImpl for RunState {
ground::apply_grounded_motion( ground::apply_grounded_motion(
state_context.velocity, state_context.velocity,
state_context.translation, state_context.translation,
state_context.prev_translation,
); );
} }
-1
View File
@@ -40,7 +40,6 @@ impl FighterStateImpl for WalkState {
ground::apply_grounded_motion( ground::apply_grounded_motion(
state_context.velocity, state_context.velocity,
state_context.translation, state_context.translation,
state_context.prev_translation,
); );
} }
+3
View File
@@ -10,6 +10,9 @@ use crate::{math::int::FGi32, menus::style::FGUiStyle};
pub struct FighterSettingsCommon { pub struct FighterSettingsCommon {
pub walk_speed_ease: FGi32, pub walk_speed_ease: FGi32,
/// Horizontal position nudge applied to each overlapping grounded fighter.
pub grounded_push_nudge: FGi32,
/// Maximum allowed horizontal velocity /// Maximum allowed horizontal velocity
pub ground_max_horizontal_velocity: FGi32, pub ground_max_horizontal_velocity: FGi32,