Implement up smash, dacus and further improve input system. (#88)
CI / build-and-test (push) Successful in 2m42s
CI / production-build (push) Successful in 13m1s

Reviewed-on: #88
Co-authored-by: Álex Román Núñez <eirexe@eirteam.moe>
This commit was merged in pull request #88.
This commit is contained in:
2026-10-09 09:15:18 +00:00
committed by EIREXE
parent a1595d841a
commit 97f2fe7065
14 changed files with 429 additions and 88 deletions
+350 -73
View File
@@ -9,6 +9,8 @@ use crate::fighter::FighterFacingDirection;
use crate::fighter::attack::AttackKind;
use crate::game_settings::GameSettings;
use crate::input::{DigitalAction, FighterInputFrame};
use crate::math::int::FGi32;
use crate::math::vec::FGVec2;
use crate::netcode::GGRSCfg;
use crate::player::Player;
@@ -64,6 +66,34 @@ pub enum FighterAttackCommandType {
TiltAttackRight,
}
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
pub enum FlickDirection {
Up,
Down,
Left,
Right,
}
impl FlickDirection {
const fn smash_move_command(self) -> FighterCommands {
match self {
Self::Up => FighterCommands::SmashMoveUp,
Self::Down => FighterCommands::SmashMoveDown,
Self::Left => FighterCommands::SmashMoveLeft,
Self::Right => FighterCommands::SmashMoveRight,
}
}
const fn smash_attack_command(self) -> FighterAttackCommandType {
match self {
Self::Up => FighterAttackCommandType::SmashAttackUp,
Self::Down => FighterAttackCommandType::SmashAttackDown,
Self::Left => FighterAttackCommandType::SmashAttackLeft,
Self::Right => FighterAttackCommandType::SmashAttackRight,
}
}
}
impl FighterAttackCommandType {
pub fn to_attack_kind_grounded(&self) -> AttackKind {
match self {
@@ -193,6 +223,149 @@ impl ButtonHistory {
}
}
#[derive(Clone, Copy, Debug, Hash)]
struct FlickAxisState {
age: u8,
eligible: bool,
consumed: bool,
command_emitted: bool,
}
impl Default for FlickAxisState {
fn default() -> Self {
Self {
age: u8::MAX,
eligible: false,
consumed: false,
command_emitted: false,
}
}
}
impl FlickAxisState {
fn update(
&mut self,
current: FGi32,
previous: FGi32,
reset_threshold: FGi32,
axis_threshold: FGi32,
frame_threshold: u8,
) {
if current.abs() < reset_threshold {
*self = Self::default();
return;
}
if previous.abs() < reset_threshold || previous.signum() != current.signum() {
self.age = 1;
self.consumed = false;
self.command_emitted = false;
} else {
self.age = self.age.saturating_add(1);
}
self.eligible =
!self.consumed && current.abs() >= axis_threshold && self.age <= frame_threshold;
}
}
#[derive(Clone, Copy, Debug, Default, Hash)]
struct StickFlickState {
x: FlickAxisState,
y: FlickAxisState,
}
impl StickFlickState {
fn update(
&mut self,
current: FGVec2,
previous: FGVec2,
reset_threshold: FGi32,
axis_threshold: FGi32,
frame_threshold: u8,
) {
self.x.update(
current.x,
previous.x,
reset_threshold,
axis_threshold,
frame_threshold,
);
self.y.update(
current.y,
previous.y,
reset_threshold,
axis_threshold,
frame_threshold,
);
}
fn peek(&self, value: FGVec2) -> Option<FlickDirection> {
self.resolve(value, self.x.eligible, self.y.eligible)
}
/// Returns each axis that has not produced a passive command during this
/// gesture. This does not consume the flick, so an attack can still use
/// the remainder of its smash-input window.
fn next_commands(&mut self, value: FGVec2) -> [Option<FlickDirection>; 2] {
let use_x = self.x.eligible && !self.x.command_emitted;
let use_y = self.y.eligible && !self.y.command_emitted;
if use_x {
self.x.command_emitted = true;
}
if use_y {
self.y.command_emitted = true;
}
[
use_x.then_some(if value.x.is_positive() {
FlickDirection::Right
} else {
FlickDirection::Left
}),
use_y.then_some(if value.y.is_positive() {
FlickDirection::Up
} else {
FlickDirection::Down
}),
]
}
fn consume(&mut self, value: FGVec2) -> Option<FlickDirection> {
let direction = self.peek(value)?;
// Simultaneous eligible axes are one diagonal gesture. Consuming only
// the selected axis would expose the other axis on the next frame.
if self.x.eligible {
self.x.consumed = true;
self.x.eligible = false;
}
if self.y.eligible {
self.y.consumed = true;
self.y.eligible = false;
}
Some(direction)
}
fn resolve(&self, value: FGVec2, use_x: bool, use_y: bool) -> Option<FlickDirection> {
if use_x {
Some(if value.x.is_positive() {
FlickDirection::Right
} else {
FlickDirection::Left
})
} else if use_y {
Some(if value.y.is_positive() {
FlickDirection::Up
} else {
FlickDirection::Down
})
} else {
None
}
}
}
/// Per-fighter input state. Every field is deterministic rollback state.
#[derive(Component, Clone, Copy, Hash)]
#[require(Rollback)]
@@ -203,8 +376,8 @@ pub struct FighterInput {
button_history: [ButtonHistory; 3],
prev_frame: FighterInputFrame,
current_frame: FighterInputFrame,
frames_in_x_flick_deadzone: u8,
frames_in_y_flick_deadzone: u8,
movement_flick: StickFlickState,
directional_attack_flick: StickFlickState,
attack_command: FighterAttackCommandType,
}
@@ -217,8 +390,8 @@ impl Default for FighterInput {
button_history: [ButtonHistory::default(); 3],
prev_frame: FighterInputFrame::default(),
current_frame: FighterInputFrame::default(),
frames_in_x_flick_deadzone: 0xFE,
frames_in_y_flick_deadzone: 0xFE,
movement_flick: StickFlickState::default(),
directional_attack_flick: StickFlickState::default(),
attack_command: FighterAttackCommandType::Neutral,
}
}
@@ -368,8 +541,26 @@ impl FighterInput {
self.current_frame.bindings_for(action).count()
}
pub fn get_frames_in_smash_move_deadzone(&self) -> u8 {
self.frames_in_x_flick_deadzone
pub fn peek_movement_flick(&self) -> Option<FlickDirection> {
self.movement_flick.peek(self.current_frame.movement)
}
pub fn peek_movement_flick_dbg(&self) -> u8 {
self.movement_flick.y.age
}
pub fn consume_movement_flick(&mut self) -> Option<FlickDirection> {
self.movement_flick.consume(self.current_frame.movement)
}
pub fn peek_directional_attack_flick(&self) -> Option<FlickDirection> {
self.directional_attack_flick
.peek(self.current_frame.directional_attack)
}
pub fn consume_directional_attack_flick(&mut self) -> Option<FlickDirection> {
self.directional_attack_flick
.consume(self.current_frame.directional_attack)
}
pub fn set_attack_command_type(&mut self, command: FighterAttackCommandType) {
self.attack_command = command;
@@ -441,31 +632,30 @@ pub fn postprocess_input(
);
}
let x_abs = input.current_frame.movement.x.abs();
let y_abs = input.current_frame.movement.y.abs();
let movement = input.current_frame.movement;
let previous_movement = input.prev_frame.movement;
let directional_attack = input.current_frame.directional_attack;
let previous_directional_attack = input.prev_frame.directional_attack;
let x_abs = movement.x.abs();
let y_abs = movement.y.abs();
let reset_deadzone = game_settings.input_common.smash_input_reset_axis_threshold;
let axis_threshold = game_settings.input_common.smash_input_axis_threshold;
let frame_threshold = game_settings.input_common.smash_input_frame_threshold;
if x_abs < reset_deadzone {
input.frames_in_x_flick_deadzone = 0xFE;
} else if input.prev_frame.movement.x.abs() < reset_deadzone
|| input.prev_frame.movement.x.signum() != input.current_frame.movement.x.signum()
{
input.frames_in_x_flick_deadzone = 1;
} else {
input.frames_in_x_flick_deadzone = input.frames_in_x_flick_deadzone.saturating_add(1);
}
if y_abs < reset_deadzone {
input.frames_in_y_flick_deadzone = 0xFE;
} else if input.prev_frame.movement.y.abs() < reset_deadzone
|| input.prev_frame.movement.y.signum() != input.current_frame.movement.y.signum()
{
input.frames_in_y_flick_deadzone = 1;
} else {
input.frames_in_y_flick_deadzone = input.frames_in_y_flick_deadzone.saturating_add(1);
}
input.movement_flick.update(
movement,
previous_movement,
reset_deadzone,
axis_threshold,
frame_threshold,
);
input.directional_attack_flick.update(
directional_attack,
previous_directional_attack,
reset_deadzone,
axis_threshold,
frame_threshold,
);
let tap = game_settings.input_common.input_buffer_size;
let jump_pressed = input.is_pressed(DigitalAction::Jump);
@@ -485,62 +675,40 @@ pub fn postprocess_input(
input.set_lifetime(FighterCommands::GuardHold, tap);
}
let mut y_flick_detected =
y_abs >= axis_threshold && input.frames_in_y_flick_deadzone <= frame_threshold;
let mut x_flick_detected =
x_abs >= axis_threshold && input.frames_in_x_flick_deadzone <= frame_threshold;
if y_flick_detected {
input.clear_command(FighterCommands::SmashMoveUp);
input.clear_command(FighterCommands::SmashMoveDown);
input.frames_in_y_flick_deadzone = 0xFF;
let command = if input.current_frame.movement.y > 0.0 {
FighterCommands::SmashMoveUp
} else {
FighterCommands::SmashMoveDown
};
for flick in input
.movement_flick
.next_commands(movement)
.into_iter()
.flatten()
{
let command = flick.smash_move_command();
match flick {
FlickDirection::Up | FlickDirection::Down => {
input.clear_command(FighterCommands::SmashMoveUp);
input.clear_command(FighterCommands::SmashMoveDown);
}
FlickDirection::Left | FlickDirection::Right => {
input.clear_command(FighterCommands::SmashMoveLeft);
input.clear_command(FighterCommands::SmashMoveRight);
}
}
input.set_lifetime(command, tap);
}
if x_flick_detected {
input.clear_command(FighterCommands::SmashMoveLeft);
input.clear_command(FighterCommands::SmashMoveRight);
input.frames_in_x_flick_deadzone = 0xFF;
let command = if input.current_frame.movement.x > 0.0 {
FighterCommands::SmashMoveRight
} else {
FighterCommands::SmashMoveLeft
};
input.set_lifetime(command, tap);
}
if x_flick_detected && y_flick_detected {
x_flick_detected = x_abs > y_abs;
y_flick_detected = !x_flick_detected;
}
if y_flick_detected && input.current_frame.movement.y > 0.0 {
input.set_lifetime(FighterCommands::TapJump, tap);
if flick == FlickDirection::Up {
input.set_lifetime(FighterCommands::TapJump, tap);
}
}
if attack_pressed {
let attack_type = if y_flick_detected {
if input.current_frame.movement.y > 0.0 {
FighterAttackCommandType::SmashAttackUp
} else {
FighterAttackCommandType::SmashAttackDown
}
} else if x_flick_detected {
if input.current_frame.movement.x > 0.0 {
FighterAttackCommandType::SmashAttackRight
} else {
FighterAttackCommandType::SmashAttackLeft
}
let attack_type = if let Some(flick) = input.consume_movement_flick() {
flick.smash_attack_command()
} else if x_abs > y_abs {
if input.current_frame.movement.x.is_positive() {
if movement.x.is_positive() {
FighterAttackCommandType::TiltAttackRight
} else {
FighterAttackCommandType::TiltAttackLeft
}
} else if y_abs > x_abs {
if input.current_frame.movement.y.is_positive() {
if movement.y.is_positive() {
FighterAttackCommandType::TiltAttackUp
} else {
FighterAttackCommandType::TiltAttackDown
@@ -548,10 +716,17 @@ pub fn postprocess_input(
} else {
FighterAttackCommandType::Neutral
};
dbg!(input.movement_flick.y, attack_type);
input.set_lifetime(FighterCommands::Attack, tap);
input.set_attack_command_type(attack_type);
}
if let Some(flick) = input.consume_directional_attack_flick() {
input.set_lifetime(FighterCommands::Attack, tap);
input.set_attack_command_type(flick.smash_attack_command());
}
for command in FighterCommands::iter() {
input.update_hold(command);
}
@@ -563,6 +738,20 @@ mod tests {
use super::*;
use crate::input::ActionBindingMask;
const RESET_THRESHOLD: FGi32 = FGi32::lit("0.2");
const AXIS_THRESHOLD: FGi32 = FGi32::lit("0.8");
const FRAME_THRESHOLD: u8 = 3;
fn update_flick(state: &mut StickFlickState, current: FGVec2, previous: FGVec2) {
state.update(
current,
previous,
RESET_THRESHOLD,
AXIS_THRESHOLD,
FRAME_THRESHOLD,
);
}
fn advance_command(input: &mut FighterInput, command: FighterCommands) {
input.reduce_command_lifetime(command);
input.update_hold(command);
@@ -583,6 +772,94 @@ mod tests {
assert!(second_binding_pressed(current, previous));
}
#[test]
fn flick_remains_available_for_the_configured_window() {
let mut flick = StickFlickState::default();
let right = FGVec2::lit("1", "0");
update_flick(&mut flick, right, FGVec2::ZERO);
assert_eq!(flick.peek(right), Some(FlickDirection::Right));
for _ in 2..=FRAME_THRESHOLD {
update_flick(&mut flick, right, right);
assert_eq!(flick.peek(right), Some(FlickDirection::Right));
}
update_flick(&mut flick, right, right);
assert_eq!(flick.peek(right), None);
}
#[test]
fn passive_command_does_not_consume_flick() {
let mut flick = StickFlickState::default();
let right = FGVec2::lit("1", "0");
update_flick(&mut flick, right, FGVec2::ZERO);
assert_eq!(
flick.next_commands(right),
[Some(FlickDirection::Right), None]
);
assert_eq!(flick.next_commands(right), [None, None]);
assert_eq!(flick.peek(right), Some(FlickDirection::Right));
update_flick(&mut flick, right, right);
assert_eq!(flick.consume(right), Some(FlickDirection::Right));
assert_eq!(flick.peek(right), None);
}
#[test]
fn consumed_flick_rearms_after_returning_below_reset_threshold() {
let mut flick = StickFlickState::default();
let right = FGVec2::lit("1", "0");
update_flick(&mut flick, right, FGVec2::ZERO);
assert_eq!(flick.consume(right), Some(FlickDirection::Right));
update_flick(&mut flick, right, right);
assert_eq!(flick.peek(right), None);
update_flick(&mut flick, FGVec2::ZERO, right);
update_flick(&mut flick, right, FGVec2::ZERO);
assert_eq!(flick.peek(right), Some(FlickDirection::Right));
}
#[test]
fn flick_can_reach_activation_threshold_after_entering_reset_deadzone() {
let mut flick = StickFlickState::default();
let partial = FGVec2::lit("0.4", "0");
let right = FGVec2::lit("1", "0");
update_flick(&mut flick, partial, FGVec2::ZERO);
assert_eq!(flick.peek(partial), None);
update_flick(&mut flick, right, partial);
assert_eq!(flick.peek(right), Some(FlickDirection::Right));
}
#[test]
fn diagonal_flick_prioritizes_horizontal_and_consumes_the_gesture() {
let mut flick = StickFlickState::default();
let diagonal = FGVec2::lit("0.9", "1");
update_flick(&mut flick, diagonal, FGVec2::ZERO);
assert_eq!(
flick.next_commands(diagonal),
[Some(FlickDirection::Right), Some(FlickDirection::Up)]
);
assert_eq!(flick.consume(diagonal), Some(FlickDirection::Right));
assert_eq!(flick.peek(diagonal), None);
}
#[test]
fn direction_reversal_starts_a_new_flick() {
let mut flick = StickFlickState::default();
let right = FGVec2::lit("1", "0");
let left = FGVec2::lit("-1", "0");
update_flick(&mut flick, right, FGVec2::ZERO);
assert_eq!(flick.consume(right), Some(FlickDirection::Right));
update_flick(&mut flick, left, right);
assert_eq!(flick.peek(left), Some(FlickDirection::Left));
}
#[test]
fn command_can_be_consumed_without_erasing_raw_input() {
let mut input = FighterInput::default();