Make dashback configurable, fix movement bugs, add double jump forward and back.

This commit is contained in:
2026-09-13 01:00:15 +02:00
parent ad105418be
commit 811578af2f
18 changed files with 774 additions and 154 deletions
+3 -1
View File
@@ -73,8 +73,10 @@ pub enum AnimKind {
JumpSquat,
JumpForward,
JumpBack,
DoubleJump,
DoubleJumpForward,
DoubleJumpBack,
Landing,
LandingSpecial,
AirDodge,
Fall,
Turn,
+1
View File
@@ -32,6 +32,7 @@ pub struct FighterAttributes {
pub air_acceleration_base: FGi32,
pub air_acceleration_stick: FGi32,
pub max_air_horizontal_velocity: FGi32,
pub air_drift_max: FGi32,
pub ground_friction: FGi32,
pub terminal_velocity: FGi32,
+82 -24
View File
@@ -19,31 +19,23 @@ pub fn apply_air_drift(
}
} else {
let target_velocity =
input.get_last_frame().movement.x.signum() * attribs.max_air_horizontal_velocity;
let acceleration =
input.get_last_frame().movement.x.signum() * attribs.air_acceleration_base;
input.get_last_frame().movement.x * attribs.air_drift_max;
let mut acceleration =
acceleration + input.get_last_frame().movement.x * attribs.air_acceleration_stick;
let velocity_x = velocity.x;
if !(velocity_x * acceleration < FGi32::ZERO) {
if acceleration > FGi32::ZERO {
if velocity_x + acceleration > target_velocity {
acceleration = -attribs.air_friction;
if velocity_x + acceleration < target_velocity {
acceleration = target_velocity - velocity_x;
}
if velocity_x + acceleration > attribs.max_air_horizontal_velocity {
acceleration = attribs.max_air_horizontal_velocity - velocity_x;
}
}
} else if velocity_x + acceleration < target_velocity {
acceleration = attribs.air_friction;
if velocity_x + acceleration > target_velocity {
acceleration = target_velocity - velocity_x;
}
if velocity_x + acceleration < -attribs.max_air_horizontal_velocity {
acceleration = -attribs.max_air_horizontal_velocity - velocity_x;
}
input.get_last_frame().movement.x.signum() * attribs.air_acceleration_base + input.get_last_frame().movement.x * attribs.air_acceleration_stick;
// Acceleration opposes velocity
if acceleration * velocity.x < 0 {
// do nothing
} else {
let predicted_vel = velocity.x + acceleration;
let will_go_past = predicted_vel.abs() > target_velocity.abs();
if velocity.x.abs() < target_velocity.abs() && will_go_past {
// Prevent overshoot
acceleration = target_velocity - velocity.x
} else if velocity.x.abs() > target_velocity.abs() {
let diff = (velocity.x - target_velocity).abs();
acceleration = velocity.x.signum() * (-(attribs.air_friction).min(diff));
}
}
@@ -60,3 +52,69 @@ pub fn apply_air_motion(
prev_translation.0 = translation.0;
translation.0 += velocity.0;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
input::{FighterInput, FighterInputFrame},
math::vec::FGVec2,
};
fn test_attributes() -> FighterAttributes {
FighterAttributes {
base_walk_accel: FGi32::ZERO,
stick_walk_accel: FGi32::ZERO,
max_walk_vel: FGi32::ZERO,
dash_acceleration_duration: 0,
dash_duration: 0,
dash_initial_velocity: FGi32::ZERO,
base_dash_accel: FGi32::ZERO,
stick_dash_accel: FGi32::ZERO,
max_dash_vel: FGi32::ZERO,
jumpsquat_duration: 0,
short_hop_vertical_velocity: FGi32::ZERO,
full_jump_vertical_velocity: FGi32::ZERO,
jump_horizontal_velocity: FGi32::ZERO,
air_jump_multiplier: FGi32::ZERO,
air_jump_horizontal_velocity: FGi32::ZERO,
landing_iasa: 0,
landing_duration: 0,
air_acceleration_base: FGi32::ZERO,
air_acceleration_stick: FGi32::ZERO,
max_air_horizontal_velocity: FGi32::lit("1.2"),
air_drift_max: FGi32::lit("1.2"),
ground_friction: FGi32::ZERO,
terminal_velocity: FGi32::ZERO,
gravity: FGi32::ZERO,
weight: FGi32::lit("1"),
air_friction: FGi32::lit("0.001"),
air_dodge_duration: 0,
air_dodge_velocity: FGi32::ZERO,
air_dodge_decay: FGi32::ZERO,
}
}
#[test]
fn zero_air_acceleration_is_symmetric() {
let attributes = test_attributes();
let mut right_input = FighterInput::default();
right_input.push_input_frame(FighterInputFrame {
movement: FGVec2::new(FGi32::ONE, FGi32::ZERO),
..default()
});
let mut left_input = FighterInput::default();
left_input.push_input_frame(FighterInputFrame {
movement: FGVec2::new(-FGi32::ONE, FGi32::ZERO),
..default()
});
let mut right_velocity = FighterVelocity(FGVec2::ZERO);
let mut left_velocity = FighterVelocity(FGVec2::ZERO);
apply_air_drift(&mut right_velocity, &attributes, &right_input);
apply_air_drift(&mut left_velocity, &attributes, &left_input);
assert_eq!(right_velocity.x, FGi32::ZERO);
assert_eq!(left_velocity.x, FGi32::ZERO);
}
}
+4 -9
View File
@@ -1,14 +1,9 @@
use crate::{
fighter::{
animation::AnimKind,
collision,
state::{
FighterState, FighterStateContext, FighterStateImpl, air, fall::FallState,
ground::GroundedStateCommon, land::LandingState,
animation::AnimKind, collision, state::{
FighterState, FighterStateContext, FighterStateImpl, air, fall::FallState, ground::GroundedStateCommon, land::{LandingState, LandingType},
},
},
input,
math::vec::FGVec2,
}, input, math::vec::FGVec2,
};
#[derive(Debug, Clone, Hash)]
@@ -47,7 +42,7 @@ impl FighterStateImpl for AirDodgeState {
let grounded_common = GroundedStateCommon {
current_line_id: res.line_id,
};
Some(FighterState::Land(LandingState { grounded_common }))
Some(FighterState::Land(LandingState { grounded_common, landing_type: LandingType::Special }))
} else {
None
}
+2 -2
View File
@@ -2,7 +2,7 @@ use super::{FighterStateContext, FighterStateImpl};
use crate::fighter::animation::AnimKind;
use crate::fighter::collision;
use crate::fighter::state::ground::GroundedStateCommon;
use crate::fighter::state::land::LandingState;
use crate::fighter::state::land::{LandingState, LandingType};
use crate::fighter::state::{FighterState, air, jump};
#[derive(Debug, Clone, Copy, Hash)]
@@ -45,7 +45,7 @@ impl FighterStateImpl for FallState {
let grounded_common = GroundedStateCommon {
current_line_id: res.line_id,
};
Some(FighterState::Land(LandingState { grounded_common }))
Some(FighterState::Land(LandingState::create(grounded_common, LandingType::Normal)))
} else {
None
}
+218 -39
View File
@@ -9,9 +9,9 @@ use crate::fighter::{
FighterAttributes, FighterPreviousTranslation, FighterTranslation, FighterVelocity,
};
use crate::game_settings::GameSettings;
use crate::math::int::FGi32;
use crate::math::int::{FGWide, FGi32};
use crate::math::vec::FGVec2;
use crate::stage::line::{StageLineID, StagePolyLineSegmentType};
use crate::stage::line::{StageLineID, StagePoly, StagePolyLineSegment, StagePolyLineSegmentType};
use bevy::prelude::*;
@@ -74,6 +74,85 @@ pub enum GroundedMotionResult {
InAir,
}
enum GroundedPositionResult {
InGround {
point: FGVec2,
segment_idx: usize,
blocked: bool,
},
InAir,
}
fn is_walkable_floor(segment: &StagePolyLineSegment) -> bool {
segment.segment_type == StagePolyLineSegmentType::Floor && segment.normal.y > FGi32::ZERO
}
fn resolve_grounded_position(
stage_poly: &StagePoly,
starting_segment_idx: usize,
point: FGVec2,
can_walk_off: bool,
) -> GroundedPositionResult {
let mut segment_idx = starting_segment_idx;
// A single frame can cross several short connected segments. The bound also
// prevents malformed closed polygons from cycling forever.
for _ in 0..stage_poly.segments.len() {
let Some(segment) = stage_poly.get_segment(segment_idx) else {
return GroundedPositionResult::InAir;
};
if !is_walkable_floor(segment) {
return GroundedPositionResult::InAir;
}
let segment_vector = segment.segment.point2() - segment.segment.point1();
let projection = segment_vector.dot(point - segment.segment.point1());
let length_squared = segment_vector.length_squared();
let (next_segment_idx, edge_point) = if projection < FGWide::ZERO {
(
stage_poly.get_prev_segment_index(segment_idx),
segment.segment.point1(),
)
} else if projection > length_squared {
(
stage_poly.get_next_segment_index(segment_idx),
segment.segment.point2(),
)
} else {
let projected_point = segment.segment.point1()
+ (point - segment.segment.point1()).project_onto(segment.segment.direction());
return GroundedPositionResult::InGround {
point: projected_point,
segment_idx,
blocked: false,
};
};
if let Some(next_segment_idx) = next_segment_idx
&& next_segment_idx != segment_idx
&& stage_poly
.get_segment(next_segment_idx)
.is_some_and(is_walkable_floor)
{
segment_idx = next_segment_idx;
continue;
}
return if can_walk_off {
GroundedPositionResult::InAir
} else {
GroundedPositionResult::InGround {
point: edge_point,
segment_idx,
blocked: true,
}
};
}
GroundedPositionResult::InAir
}
pub fn apply_grounded_motion(
velocity: &FighterVelocity,
translation: &mut FighterTranslation,
@@ -87,7 +166,7 @@ pub fn apply_grounded_motion(
pub fn collide_with_stage_grounded(
state_context: &mut FighterStateContext,
ground_common: &mut GroundedStateCommon,
_can_walk_off: bool,
can_walk_off: bool,
) -> GroundedMotionResult {
let translation = &mut state_context.translation;
let ecb = &state_context.ecb;
@@ -99,49 +178,149 @@ pub fn collide_with_stage_grounded(
if let Some(stage_poly) = stage_poly {
let ecb_bottom_pos = translation.0 + ecb.get_bottom_point();
let current_segment_idx = ground_common.current_line_id.segment;
// First, we check the current segment, if that fails, we check the surrounding ones
let grounded_ecb_bottom_pos = [
Some(current_segment_idx),
stage_poly.get_prev_segment_index(current_segment_idx),
stage_poly.get_next_segment_index(current_segment_idx),
]
.into_iter()
.flatten()
.find_map(|segment_idx| {
let segment = stage_poly.get_segment(segment_idx)?;
if segment.segment_type != StagePolyLineSegmentType::Floor {
return None;
match resolve_grounded_position(
stage_poly,
ground_common.current_line_id.segment,
ecb_bottom_pos,
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();
state_context.velocity.0 = if blocked {
FGVec2::ZERO
} else {
state_context.velocity.0
- state_context
.velocity
.0
.project_onto_normalized(current_segment.normal)
};
ground_common.current_line_id.segment = segment_idx;
GroundedMotionResult::InGround
}
let line_dir = segment.segment.direction();
let projected_point = segment.segment.point1()
+ (ecb_bottom_pos - segment.segment.point1()).project_onto(line_dir);
let closest_point = segment.segment.closest_point(ecb_bottom_pos);
let distance = closest_point.distance(projected_point);
if distance < 0.01 {
return Some((projected_point, segment_idx, segment.normal));
}
None
});
if let Some((bp, new_segment_idx, normal)) = grounded_ecb_bottom_pos {
translation.0 = bp - ecb.get_bottom_point();
state_context.velocity.0 =
state_context.velocity.0 - state_context.velocity.0.project_onto_normalized(normal);
ground_common.current_line_id.segment = new_segment_idx;
GroundedMotionResult::InGround
} else {
GroundedMotionResult::InAir
GroundedPositionResult::InAir => GroundedMotionResult::InAir,
}
} else {
GroundedMotionResult::InAir
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::stage::line::StagePolyType;
fn two_segment_floor() -> StagePoly {
StagePoly::build(
StagePolyType::Platform,
&[
(FGVec2::lit("0", "0"), StagePolyLineSegmentType::Floor),
(FGVec2::lit("10", "0"), StagePolyLineSegmentType::Floor),
(FGVec2::lit("20", "0"), StagePolyLineSegmentType::Floor),
],
)
}
#[test]
fn tiny_endpoint_crossing_moves_to_adjacent_floor() {
let stage = two_segment_floor();
let point = FGVec2::new(FGi32::from_bits(10 * 65_536 + 1), FGi32::ZERO);
let GroundedPositionResult::InGround {
point: resolved,
segment_idx,
blocked,
} = resolve_grounded_position(&stage, 0, point, false)
else {
panic!("connected floor should remain grounded");
};
assert_eq!(segment_idx, 1);
assert_eq!(resolved, point);
assert!(!blocked);
}
#[test]
fn blocked_edge_keeps_floor_and_can_move_inward() {
let stage = StagePoly::build(
StagePolyType::Platform,
&[
(FGVec2::lit("0", "0"), StagePolyLineSegmentType::Floor),
(FGVec2::lit("10", "0"), StagePolyLineSegmentType::Wall),
(FGVec2::lit("10", "-2"), StagePolyLineSegmentType::Ceiling),
],
);
let just_past_edge = FGVec2::new(FGi32::from_bits(10 * 65_536 + 1), FGi32::ZERO);
let GroundedPositionResult::InGround {
point,
segment_idx,
blocked,
} = resolve_grounded_position(&stage, 0, just_past_edge, false)
else {
panic!("non-walk-off movement should stop at the edge");
};
assert_eq!(point, FGVec2::lit("10", "0"));
assert_eq!(segment_idx, 0);
assert!(blocked);
let GroundedPositionResult::InGround {
point,
segment_idx,
blocked,
} = resolve_grounded_position(
&stage,
segment_idx,
FGVec2::new(FGi32::from_bits(10 * 65_536 - 1), FGi32::ZERO),
false,
)
else {
panic!("fighter should be able to move back from the edge");
};
assert!(point.x < FGi32::lit("10"));
assert_eq!(segment_idx, 0);
assert!(!blocked);
}
#[test]
fn walk_off_edge_enters_air() {
let stage = StagePoly::build(
StagePolyType::Platform,
&[
(FGVec2::lit("0", "0"), StagePolyLineSegmentType::Floor),
(FGVec2::lit("10", "0"), StagePolyLineSegmentType::Floor),
],
);
let just_past_edge = FGVec2::new(FGi32::from_bits(10 * 65_536 + 1), FGi32::ZERO);
assert!(matches!(
resolve_grounded_position(&stage, 0, just_past_edge, true),
GroundedPositionResult::InAir
));
}
#[test]
fn vertical_segment_cannot_hold_grounded_fighter() {
let stage = StagePoly::build(
StagePolyType::Platform,
&[
(FGVec2::lit("10", "0"), StagePolyLineSegmentType::Floor),
(FGVec2::lit("10", "-2"), StagePolyLineSegmentType::Floor),
],
);
assert!(matches!(
resolve_grounded_position(&stage, 0, FGVec2::lit("10", "0"), false),
GroundedPositionResult::InAir
));
}
}
pub fn grounded_movement_common_interrupts(
state_context: &FighterStateContext,
ground_common: &GroundedStateCommon,
+11 -13
View File
@@ -2,18 +2,10 @@ use bevy::prelude::*;
use crate::{
fighter::{
animation::AnimKind,
collision,
state::{
FighterState, FighterStateContext, FighterStateImpl, air,
air_dodge::{self, AirDodgeState},
fall::FallState,
ground::{self, GroundedMotionResult, GroundedStateCommon},
land::LandingState,
animation::AnimKind, collision, state::{
FighterState, FighterStateContext, FighterStateImpl, air, air_dodge::{self, AirDodgeState}, fall::FallState, ground::{self, GroundedMotionResult, GroundedStateCommon}, land::{LandingState, LandingType},
},
},
input::FighterCommands,
math::{int::FGi32, vec::FGVec2},
}, input::FighterCommands, math::{int::FGi32, vec::FGVec2},
};
#[derive(Debug, Hash, Clone, Copy)]
pub enum JumpType {
@@ -174,7 +166,13 @@ impl FighterStateImpl for JumpState {
}
}
JumpType::DoubleJump => {
state_context.play_animation(AnimKind::DoubleJump, false);
if movement_stick_x.is_zero()
|| movement_stick_x.signum() == state_context.facing_direction.to_sign()
{
state_context.play_animation(AnimKind::DoubleJumpForward, false);
} else {
state_context.play_animation(AnimKind::DoubleJumpBack, false);
}
}
}
@@ -233,7 +231,7 @@ impl FighterStateImpl for JumpState {
let grounded_common = GroundedStateCommon {
current_line_id: res.line_id,
};
Some(FighterState::Land(LandingState { grounded_common }))
Some(FighterState::Land(LandingState::create(grounded_common, LandingType::Normal)))
} else {
None
}
+19 -5
View File
@@ -11,14 +11,21 @@ use crate::fighter::{
},
};
#[derive(Debug, Clone, Hash)]
pub enum LandingType {
Normal,
Special
}
#[derive(Debug, Clone, Hash)]
pub struct LandingState {
pub grounded_common: GroundedStateCommon,
pub landing_type: LandingType,
}
impl LandingState {
pub fn create(grounded_common: GroundedStateCommon) -> Self {
Self { grounded_common }
pub fn create(grounded_common: GroundedStateCommon, landing_type: LandingType) -> Self {
Self { grounded_common, landing_type }
}
}
@@ -29,7 +36,7 @@ impl FighterStateImpl for LandingState {
if state_context.is_current_animation_finished() {
grounded_movement_standstill_common_interrupts(state_context, &self.grounded_common)
} else if state_context.get_current_animation_frame()
>= state_context.fighter_manifest.attributes.landing_iasa
>= state_context.fighter_manifest.attributes.landing_iasa && let LandingType::Normal = self.landing_type
{
dash::check_input(state_context, &self.grounded_common).or_else(|| {
ground::grounded_movement_common_interrupts(state_context, &self.grounded_common)
@@ -69,7 +76,7 @@ impl FighterStateImpl for LandingState {
state_context: &mut FighterStateContext,
) -> Option<FighterState> {
if let GroundedMotionResult::InAir =
ground::collide_with_stage_grounded(state_context, &mut self.grounded_common, false)
ground::collide_with_stage_grounded(state_context, &mut self.grounded_common, true)
{
Some(FighterState::Fall(FallState))
} else {
@@ -78,6 +85,13 @@ impl FighterStateImpl for LandingState {
}
fn on_enter(&mut self, state_context: &mut FighterStateContext) {
state_context.play_animation(AnimKind::Landing, false);
match self.landing_type {
LandingType::Normal => {
state_context.play_animation(AnimKind::Landing, false);
},
LandingType::Special => {
state_context.play_animation(AnimKind::LandingSpecial, false);
},
};
}
}
+12 -5
View File
@@ -3,6 +3,7 @@ use crate::fighter::animation::AnimKind;
use crate::fighter::state::fall::FallState;
use crate::fighter::state::ground::{self, GroundedMotionResult, GroundedStateCommon};
use crate::fighter::state::{FighterState, dash, jump, wait, walk};
use crate::input::FighterCommands;
#[derive(Debug, Clone, Hash)]
pub struct TurnState {
@@ -13,7 +14,13 @@ impl FighterStateImpl for TurnState {
const NAME: &'static str = "Turn";
fn check_interrupt(&self, state_context: &mut FighterStateContext) -> Option<FighterState> {
// State chain that results in the turn completing
if state_context.get_current_animation_frame() <= state_context.game_settings.input_common.turn_into_dashback_window as u32 {
if let Some(state) = dash::check_input(state_context, &self.grounded_common) {
return Some(state);
}
}
if let Some(state) = jump::check_input(state_context, &self.grounded_common) {
*state_context.facing_direction = state_context.facing_direction.reverse();
Some(state)
} else if state_context.is_current_animation_finished() {
if let Some(state) = dash::check_input(state_context, &self.grounded_common) {
@@ -78,14 +85,14 @@ pub fn check_input(
grounded_common: &GroundedStateCommon,
) -> Option<FighterState> {
let input_frame = state_context.input.get_last_frame();
let smash_input_frame_threshold = state_context
.game_settings
.input_common
.smash_input_frame_threshold;
let has_smash_input = state_context.input.has_command(FighterCommands::SmashMoveLeft) || state_context.input.has_command(FighterCommands::SmashMoveRight);
if !input_frame.movement.x.is_zero()
&& input_frame.movement.x.signum() != state_context.facing_direction.to_sign()
&& state_context.input.get_frames_in_smash_move_deadzone() > smash_input_frame_threshold
&& !has_smash_input
{
dbg!(input_frame.movement.x);
return Some(FighterState::Turn(TurnState {
grounded_common: grounded_common.clone(),
}));
+1 -3
View File
@@ -13,9 +13,7 @@ pub struct WalkState {
impl FighterStateImpl for WalkState {
const NAME: &'static str = "Walk";
fn check_interrupt(&self, state_context: &mut FighterStateContext) -> Option<FighterState> {
if let Some(state) = turn::check_input(state_context, &self.grounded_common) {
return Some(state);
} else if let Some(state) =
if let Some(state) =
ground::grounded_movement_common_interrupts(state_context, &self.grounded_common)
{
return Some(state);
+1
View File
@@ -22,6 +22,7 @@ pub struct InputSettingsCommon {
pub run_stick_threshold: FGi32,
pub smash_input_frame_threshold: u8,
pub input_buffer_size: u8,
pub turn_into_dashback_window: u8,
}
#[derive(Reflect, Serialize, Deserialize, Resource, Asset, Clone, RonAsset)]
-13
View File
@@ -216,19 +216,6 @@ pub fn postprocess_input(
);
}
if x_abs >= axis_threshold && input.frames_in_x_flick_deadzone <= frame_threshold {
input.clear_command(FighterCommands::SmashMoveLeft);
input.clear_command(FighterCommands::SmashMoveRight);
let command_to_set = if input.current_frame.movement.x > 0.0 {
FighterCommands::SmashMoveRight
} else {
FighterCommands::SmashMoveLeft
};
input.set_lifetime(command_to_set, game_settings.input_common.input_buffer_size);
}
let mut y_flick_detected =
y_abs >= axis_threshold && input.frames_in_y_flick_deadzone <= frame_threshold;
let mut x_flick_detected =
+1 -4
View File
@@ -272,10 +272,7 @@ fn read_local_inputs(
Some((_, LocalInputSource::Gamepad(_))) => gamepad
.map(|pad| gamepad::sample_gamepad(pad, &input_map))
.unwrap_or_default(),
None => match gamepad {
Some(pad) => gamepad::sample_gamepad(pad, &input_map),
None => keyboard::sample_keyboard(&key, &input_map),
},
None => FighterInputFrame::default()
};
// preprocess frame
+9
View File
@@ -141,6 +141,15 @@ impl FGSegment2d {
None
}
}
/// Returns a value representing the normalized position along the segment.
pub fn project_point_normalized(&self, point: FGVec2) -> FGi32 {
let segment_vector = self.point2 - self.point1;
let offset = point - self.point1;
// The signed projection of `offset` onto `segment_vector`, scaled by the length of the segment.
let projection_scaled = segment_vector.dot(offset);
FGi32::from_num(FGWide::from_num(projection_scaled) / segment_vector.length_squared())
}
}
#[cfg(test)]