Implement grounded fighter pushing
This commit is contained in:
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,10 @@ impl Plugin for FighterPlugin {
|
|||||||
.chain()
|
.chain()
|
||||||
.in_set(GameplaySet::Collision),
|
.in_set(GameplaySet::Collision),
|
||||||
)
|
)
|
||||||
|
.add_systems(
|
||||||
|
GgrsSchedule,
|
||||||
|
push::apply_grounded_pushes.in_set(GameplaySet::Physics),
|
||||||
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
PostUpdate,
|
PostUpdate,
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -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<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>,
|
||||||
|
fighters: Query<(
|
||||||
|
&Fighter,
|
||||||
|
&FighterTranslation,
|
||||||
|
&FighterFacingDirection,
|
||||||
|
&FighterSurfaceState,
|
||||||
|
)>,
|
||||||
|
mut translations: Query<&mut FighterTranslation>,
|
||||||
|
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) = 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")]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user