967 lines
31 KiB
Rust
967 lines
31 KiB
Rust
//! Rollback-safe input history and command buffering.
|
|
|
|
use bevy::prelude::*;
|
|
use bevy_ggrs::{PlayerInputs, Rollback};
|
|
use strum::IntoEnumIterator;
|
|
use strum_macros::EnumIter;
|
|
|
|
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;
|
|
|
|
#[derive(Clone, Copy, EnumIter, Debug, PartialEq, Eq, Hash)]
|
|
pub enum FighterCommands {
|
|
Jump,
|
|
Shield,
|
|
GuardHold,
|
|
Attack,
|
|
SmashMoveUp,
|
|
SmashMoveDown,
|
|
SmashMoveLeft,
|
|
SmashMoveRight,
|
|
TapJump,
|
|
}
|
|
|
|
pub(crate) fn second_binding_pressed(
|
|
current: crate::input::ActionBindingMask,
|
|
previous: crate::input::ActionBindingMask,
|
|
) -> bool {
|
|
current.count() >= 2 && current.pressed_since(previous).0 != 0
|
|
}
|
|
|
|
impl FighterCommands {
|
|
pub const COUNT: usize = 9;
|
|
|
|
pub const fn index(self) -> usize {
|
|
match self {
|
|
Self::Jump => 0,
|
|
Self::Shield => 1,
|
|
Self::GuardHold => 2,
|
|
Self::Attack => 3,
|
|
Self::SmashMoveUp => 4,
|
|
Self::SmashMoveDown => 5,
|
|
Self::SmashMoveLeft => 6,
|
|
Self::SmashMoveRight => 7,
|
|
Self::TapJump => 8,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Default, Clone, Hash, Copy, Debug)]
|
|
pub enum FighterAttackCommandType {
|
|
#[default]
|
|
Neutral,
|
|
SmashAttackUp,
|
|
SmashAttackDown,
|
|
SmashAttackLeft,
|
|
SmashAttackRight,
|
|
TiltAttackUp,
|
|
TiltAttackDown,
|
|
TiltAttackLeft,
|
|
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 {
|
|
Self::Neutral => AttackKind::Jab,
|
|
Self::SmashAttackUp => AttackKind::UpSmash,
|
|
Self::SmashAttackDown => AttackKind::DownSmash,
|
|
Self::SmashAttackLeft | Self::SmashAttackRight => AttackKind::ForwardSmash,
|
|
Self::TiltAttackUp => AttackKind::UpTilt,
|
|
Self::TiltAttackDown => AttackKind::DownTilt,
|
|
Self::TiltAttackLeft | Self::TiltAttackRight => AttackKind::ForwardTilt,
|
|
}
|
|
}
|
|
|
|
pub fn to_attack_kind_airborne(&self, facing_direction: &FighterFacingDirection) -> AttackKind {
|
|
match self {
|
|
Self::Neutral => AttackKind::NeutralAir,
|
|
Self::SmashAttackUp | Self::TiltAttackUp => AttackKind::UpAir,
|
|
Self::SmashAttackDown | Self::TiltAttackDown => AttackKind::DownAir,
|
|
Self::SmashAttackLeft | Self::TiltAttackLeft => match facing_direction {
|
|
FighterFacingDirection::Left => AttackKind::ForwardAir,
|
|
FighterFacingDirection::Right => AttackKind::BackAir,
|
|
},
|
|
Self::SmashAttackRight | Self::TiltAttackRight => match facing_direction {
|
|
FighterFacingDirection::Left => AttackKind::BackAir,
|
|
FighterFacingDirection::Right => AttackKind::ForwardAir,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Hash)]
|
|
struct CommandSlot {
|
|
lifetime: u8,
|
|
age: u16,
|
|
hold_remaining: HoldLimit,
|
|
consumed: bool,
|
|
}
|
|
|
|
impl Default for CommandSlot {
|
|
fn default() -> Self {
|
|
Self {
|
|
lifetime: 0,
|
|
age: 0,
|
|
hold_remaining: HoldLimit::Disabled,
|
|
consumed: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq)]
|
|
enum HoldLimit {
|
|
#[default]
|
|
Disabled,
|
|
Frames(u16),
|
|
Infinite,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq)]
|
|
enum HoldOverride {
|
|
#[default]
|
|
Inherit,
|
|
Disabled,
|
|
Frames(u16),
|
|
Infinite,
|
|
}
|
|
|
|
impl HoldOverride {
|
|
fn from_max_frames(max_frames: u16) -> Self {
|
|
if max_frames == 0 {
|
|
Self::Infinite
|
|
} else {
|
|
Self::Frames(max_frames)
|
|
}
|
|
}
|
|
}
|
|
|
|
impl HoldLimit {
|
|
fn from_max_frames(max_frames: u16) -> Self {
|
|
if max_frames == 0 {
|
|
Self::Infinite
|
|
} else {
|
|
Self::Frames(max_frames)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Hash)]
|
|
struct ButtonHistory {
|
|
trigger_age: u16,
|
|
release_age: u16,
|
|
held_frames: u16,
|
|
secondary_trigger_age: u16,
|
|
}
|
|
|
|
impl Default for ButtonHistory {
|
|
fn default() -> Self {
|
|
Self {
|
|
trigger_age: u16::MAX,
|
|
release_age: u16::MAX,
|
|
held_frames: 0,
|
|
secondary_trigger_age: u16::MAX,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ButtonHistory {
|
|
fn update(&mut self, current: u16, previous: u16) {
|
|
let pressed = current & !previous;
|
|
let released = previous & !current;
|
|
self.trigger_age = self.trigger_age.saturating_add(1);
|
|
self.release_age = self.release_age.saturating_add(1);
|
|
self.secondary_trigger_age = self.secondary_trigger_age.saturating_add(1);
|
|
if pressed != 0 {
|
|
self.trigger_age = 0;
|
|
}
|
|
if released != 0 {
|
|
self.release_age = 0;
|
|
}
|
|
if current != 0 {
|
|
self.held_frames = self.held_frames.saturating_add(1);
|
|
} else {
|
|
self.held_frames = 0;
|
|
}
|
|
if current.count_ones() >= 2 && (pressed != 0 || previous.count_ones() < 2) {
|
|
self.secondary_trigger_age = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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)]
|
|
pub struct FighterInput {
|
|
command_slots: [CommandSlot; FighterCommands::COUNT],
|
|
hold_limits: [HoldOverride; FighterCommands::COUNT],
|
|
hold_all: HoldLimit,
|
|
button_history: [ButtonHistory; 3],
|
|
prev_frame: FighterInputFrame,
|
|
current_frame: FighterInputFrame,
|
|
movement_flick: StickFlickState,
|
|
directional_attack_flick: StickFlickState,
|
|
attack_command: FighterAttackCommandType,
|
|
}
|
|
|
|
impl Default for FighterInput {
|
|
fn default() -> Self {
|
|
Self {
|
|
command_slots: [CommandSlot::default(); FighterCommands::COUNT],
|
|
hold_limits: [HoldOverride::default(); FighterCommands::COUNT],
|
|
hold_all: HoldLimit::default(),
|
|
button_history: [ButtonHistory::default(); 3],
|
|
prev_frame: FighterInputFrame::default(),
|
|
current_frame: FighterInputFrame::default(),
|
|
movement_flick: StickFlickState::default(),
|
|
directional_attack_flick: StickFlickState::default(),
|
|
attack_command: FighterAttackCommandType::Neutral,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl FighterInput {
|
|
pub fn get_last_frame(&self) -> FighterInputFrame {
|
|
self.current_frame
|
|
}
|
|
|
|
pub fn push_input_frame(&mut self, frame: FighterInputFrame) {
|
|
self.prev_frame = self.current_frame;
|
|
self.current_frame = frame;
|
|
}
|
|
|
|
pub fn clear_buffer(&mut self) {
|
|
self.command_slots = [CommandSlot::default(); FighterCommands::COUNT];
|
|
}
|
|
|
|
pub fn clear_command(&mut self, command: FighterCommands) {
|
|
let slot = &mut self.command_slots[command.index()];
|
|
slot.lifetime = 0;
|
|
slot.hold_remaining = HoldLimit::Disabled;
|
|
slot.consumed = true;
|
|
}
|
|
|
|
pub fn consume_command(&mut self, command: FighterCommands) -> bool {
|
|
if self.has_command(command) {
|
|
self.clear_command(command);
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
pub fn set_lifetime(&mut self, command: FighterCommands, lifetime: u8) {
|
|
let slot = &mut self.command_slots[command.index()];
|
|
slot.lifetime = lifetime;
|
|
slot.age = 0;
|
|
slot.hold_remaining = HoldLimit::Disabled;
|
|
slot.consumed = false;
|
|
self.latch_if_hold_enabled(command);
|
|
}
|
|
|
|
pub fn has_command(&self, command: FighterCommands) -> bool {
|
|
self.command_slots[command.index()].lifetime > 0
|
|
}
|
|
|
|
pub fn reduce_command_lifetime(&mut self, command: FighterCommands) {
|
|
self.command_slots[command.index()].lifetime = self.command_slots[command.index()]
|
|
.lifetime
|
|
.saturating_sub(1);
|
|
}
|
|
|
|
/// Holds all inputs for n frames
|
|
pub fn enable_hold_all(&mut self, max_frames: u16) {
|
|
self.hold_all = HoldLimit::from_max_frames(max_frames);
|
|
self.latch_active_commands();
|
|
}
|
|
|
|
pub fn disable_hold_all(&mut self) {
|
|
self.hold_all = HoldLimit::Disabled;
|
|
}
|
|
|
|
/// Stops global enrollment and immediately cancels every latched command.
|
|
pub fn cancel_hold_all(&mut self) {
|
|
self.disable_hold_all();
|
|
for slot in &mut self.command_slots {
|
|
slot.hold_remaining = HoldLimit::Disabled;
|
|
}
|
|
}
|
|
|
|
pub fn enable_hold(&mut self, command: FighterCommands, max_frames: u16) {
|
|
self.hold_limits[command.index()] = HoldOverride::from_max_frames(max_frames);
|
|
if self.has_command(command) {
|
|
self.latch_if_hold_enabled(command);
|
|
}
|
|
}
|
|
|
|
/// Explicitly prevents this command from using either command-specific or
|
|
/// global hold buffering, while preserving an existing latch.
|
|
pub fn disable_hold(&mut self, command: FighterCommands) {
|
|
self.hold_limits[command.index()] = HoldOverride::Disabled;
|
|
}
|
|
|
|
/// Removes the command-specific policy so this command inherits the
|
|
/// global policy again.
|
|
pub fn reset_hold_override(&mut self, command: FighterCommands) {
|
|
self.hold_limits[command.index()] = HoldOverride::Inherit;
|
|
}
|
|
|
|
/// Stops enrollment and immediately cancels this command's hold lifetime.
|
|
pub fn cancel_hold(&mut self, command: FighterCommands) {
|
|
self.disable_hold(command);
|
|
self.command_slots[command.index()].hold_remaining = HoldLimit::Disabled;
|
|
}
|
|
|
|
fn latch_if_hold_enabled(&mut self, command: FighterCommands) {
|
|
let limit = self.hold_limit(command);
|
|
let slot = &mut self.command_slots[command.index()];
|
|
slot.hold_remaining = match limit {
|
|
HoldLimit::Disabled => return,
|
|
HoldLimit::Infinite => HoldLimit::Infinite,
|
|
// The limit includes tap-buffer frames already elapsed.
|
|
HoldLimit::Frames(max_frames) => {
|
|
let remaining = max_frames.saturating_sub(slot.age);
|
|
if remaining == 0 {
|
|
return;
|
|
}
|
|
HoldLimit::Frames(remaining)
|
|
}
|
|
};
|
|
}
|
|
|
|
fn latch_active_commands(&mut self) {
|
|
for command in FighterCommands::iter() {
|
|
if self.has_command(command) {
|
|
self.latch_if_hold_enabled(command);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn is_pressed(&self, action: DigitalAction) -> bool {
|
|
self.current_frame
|
|
.bindings_for(action)
|
|
.pressed_since(self.prev_frame.bindings_for(action))
|
|
.0
|
|
!= 0
|
|
}
|
|
|
|
pub fn is_released(&self, action: DigitalAction) -> bool {
|
|
self.current_frame
|
|
.bindings_for(action)
|
|
.released_since(self.prev_frame.bindings_for(action))
|
|
.0
|
|
!= 0
|
|
}
|
|
|
|
pub fn pressed_bindings(&self, action: DigitalAction) -> u16 {
|
|
self.current_frame
|
|
.bindings_for(action)
|
|
.pressed_since(self.prev_frame.bindings_for(action))
|
|
.0
|
|
}
|
|
|
|
pub fn binding_count(&self, action: DigitalAction) -> u32 {
|
|
self.current_frame.bindings_for(action).count()
|
|
}
|
|
|
|
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;
|
|
}
|
|
pub fn get_attack_command_type(&self) -> FighterAttackCommandType {
|
|
self.attack_command
|
|
}
|
|
|
|
fn hold_limit(&self, command: FighterCommands) -> HoldLimit {
|
|
let specific = self.hold_limits[command.index()];
|
|
match specific {
|
|
HoldOverride::Inherit => self.hold_all,
|
|
HoldOverride::Disabled => HoldLimit::Disabled,
|
|
HoldOverride::Frames(frames) => HoldLimit::Frames(frames),
|
|
HoldOverride::Infinite => HoldLimit::Infinite,
|
|
}
|
|
}
|
|
|
|
fn update_hold(&mut self, command: FighterCommands) {
|
|
let index = command.index();
|
|
let slot = &mut self.command_slots[index];
|
|
slot.age = slot.age.saturating_add(1);
|
|
match slot.hold_remaining {
|
|
HoldLimit::Disabled => {}
|
|
HoldLimit::Infinite => {
|
|
if slot.lifetime == 0 {
|
|
slot.lifetime = 1;
|
|
}
|
|
}
|
|
HoldLimit::Frames(remaining) => {
|
|
// This tick consumes one of the total persistent frames, but
|
|
// the command remains active for the tick that consumes the
|
|
// final frame.
|
|
let remaining = remaining.saturating_sub(1);
|
|
slot.hold_remaining = if remaining == 0 {
|
|
HoldLimit::Disabled
|
|
} else {
|
|
HoldLimit::Frames(remaining)
|
|
};
|
|
if slot.lifetime == 0 {
|
|
slot.lifetime = 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn postprocess_input(
|
|
query: Query<(&mut FighterInput, &Player)>,
|
|
inputs: Res<PlayerInputs<GGRSCfg>>,
|
|
game_settings: Res<GameSettings>,
|
|
) {
|
|
for (mut input, player) in query {
|
|
for command in FighterCommands::iter() {
|
|
input.reduce_command_lifetime(command);
|
|
}
|
|
|
|
input.push_input_frame(inputs[player.handle].0);
|
|
let current_bindings = input.current_frame.bindings;
|
|
let previous_bindings = input.prev_frame.bindings;
|
|
for (history, action) in input.button_history.iter_mut().zip([
|
|
DigitalAction::Jump,
|
|
DigitalAction::Shield,
|
|
DigitalAction::Attack,
|
|
]) {
|
|
history.update(
|
|
current_bindings.get(action).0,
|
|
previous_bindings.get(action).0,
|
|
);
|
|
}
|
|
|
|
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;
|
|
|
|
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);
|
|
let shield_pressed = input.is_pressed(DigitalAction::Shield);
|
|
let attack_pressed = input.is_pressed(DigitalAction::Attack);
|
|
if jump_pressed {
|
|
input.set_lifetime(FighterCommands::Jump, tap);
|
|
}
|
|
if shield_pressed {
|
|
input.set_lifetime(FighterCommands::Shield, tap);
|
|
}
|
|
|
|
let shield_now = input.current_frame.bindings_for(DigitalAction::Shield);
|
|
let shield_previous = input.prev_frame.bindings_for(DigitalAction::Shield);
|
|
let second_shield_pressed = second_binding_pressed(shield_now, shield_previous);
|
|
if second_shield_pressed {
|
|
input.set_lifetime(FighterCommands::GuardHold, tap);
|
|
}
|
|
|
|
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 flick == FlickDirection::Up {
|
|
input.set_lifetime(FighterCommands::TapJump, tap);
|
|
}
|
|
}
|
|
|
|
if attack_pressed {
|
|
let attack_type = if let Some(flick) = input.consume_movement_flick() {
|
|
flick.smash_attack_command()
|
|
} else if x_abs > y_abs {
|
|
if movement.x.is_positive() {
|
|
FighterAttackCommandType::TiltAttackRight
|
|
} else {
|
|
FighterAttackCommandType::TiltAttackLeft
|
|
}
|
|
} else if y_abs > x_abs {
|
|
if movement.y.is_positive() {
|
|
FighterAttackCommandType::TiltAttackUp
|
|
} else {
|
|
FighterAttackCommandType::TiltAttackDown
|
|
}
|
|
} 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
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);
|
|
}
|
|
|
|
#[test]
|
|
fn second_binding_is_an_edge_not_a_level() {
|
|
let first = ActionBindingMask(0b0001);
|
|
let both = ActionBindingMask(0b0011);
|
|
assert!(second_binding_pressed(both, first));
|
|
assert!(!second_binding_pressed(both, both));
|
|
}
|
|
|
|
#[test]
|
|
fn replacing_a_binding_still_triggers_the_combination() {
|
|
let previous = ActionBindingMask(0b0011);
|
|
let current = ActionBindingMask(0b0101);
|
|
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();
|
|
input.set_lifetime(FighterCommands::GuardHold, 3);
|
|
assert!(input.consume_command(FighterCommands::GuardHold));
|
|
assert!(!input.has_command(FighterCommands::GuardHold));
|
|
assert_eq!(input.get_last_frame(), FighterInputFrame::default());
|
|
}
|
|
|
|
#[test]
|
|
fn hold_extension_does_not_resurrect_consumed_commands() {
|
|
let mut input = FighterInput::default();
|
|
input.enable_hold(FighterCommands::Shield, 10);
|
|
input.set_lifetime(FighterCommands::Shield, 1);
|
|
input.update_hold(FighterCommands::Shield);
|
|
input.reduce_command_lifetime(FighterCommands::Shield);
|
|
input.update_hold(FighterCommands::Shield);
|
|
assert!(input.has_command(FighterCommands::Shield));
|
|
input.consume_command(FighterCommands::Shield);
|
|
input.update_hold(FighterCommands::Shield);
|
|
assert!(!input.has_command(FighterCommands::Shield));
|
|
}
|
|
|
|
#[test]
|
|
fn hold_buffer_keeps_released_attack_through_jump_squat() {
|
|
let mut input = FighterInput::default();
|
|
input.set_lifetime(FighterCommands::Attack, 3);
|
|
// Recognition happened before jump squat enabled persistence.
|
|
input.update_hold(FighterCommands::Attack);
|
|
input.enable_hold_all(10);
|
|
|
|
// Five simulation ticks cover the test fighter's jump squat. The
|
|
// physical attack binding is released, but persistence belongs to the
|
|
// recognized command rather than the binding's held state.
|
|
for _ in 0..5 {
|
|
advance_command(&mut input, FighterCommands::Attack);
|
|
}
|
|
|
|
assert!(input.has_command(FighterCommands::Attack));
|
|
// Jump-squat exit stops enrollment. The latch crosses into Jump and
|
|
// remains visible on its first airborne interrupt frame.
|
|
input.disable_hold_all();
|
|
advance_command(&mut input, FighterCommands::Attack);
|
|
assert!(input.has_command(FighterCommands::Attack));
|
|
assert!(input.consume_command(FighterCommands::Attack));
|
|
assert!(!input.has_command(FighterCommands::Attack));
|
|
}
|
|
|
|
#[test]
|
|
fn finite_hold_limit_includes_elapsed_tap_frames() {
|
|
let mut input = FighterInput::default();
|
|
input.set_lifetime(FighterCommands::Attack, 3);
|
|
// The recognition frame has already elapsed when jump squat starts.
|
|
input.update_hold(FighterCommands::Attack);
|
|
input.enable_hold_all(10);
|
|
|
|
for _ in 0..9 {
|
|
advance_command(&mut input, FighterCommands::Attack);
|
|
assert!(input.has_command(FighterCommands::Attack));
|
|
}
|
|
advance_command(&mut input, FighterCommands::Attack);
|
|
assert!(!input.has_command(FighterCommands::Attack));
|
|
}
|
|
|
|
#[test]
|
|
fn stopping_enrollment_and_cancelling_latches_are_distinct() {
|
|
let mut input = FighterInput::default();
|
|
input.enable_hold_all(10);
|
|
input.set_lifetime(FighterCommands::Shield, 1);
|
|
input.disable_hold_all();
|
|
advance_command(&mut input, FighterCommands::Shield);
|
|
assert!(input.has_command(FighterCommands::Shield));
|
|
|
|
input.cancel_hold_all();
|
|
advance_command(&mut input, FighterCommands::Shield);
|
|
assert!(!input.has_command(FighterCommands::Shield));
|
|
}
|
|
|
|
#[test]
|
|
fn command_override_can_disable_global_hold() {
|
|
let mut input = FighterInput::default();
|
|
input.enable_hold_all(10);
|
|
input.disable_hold(FighterCommands::Attack);
|
|
input.set_lifetime(FighterCommands::Attack, 1);
|
|
advance_command(&mut input, FighterCommands::Attack);
|
|
assert!(!input.has_command(FighterCommands::Attack));
|
|
|
|
input.reset_hold_override(FighterCommands::Attack);
|
|
input.set_lifetime(FighterCommands::Attack, 1);
|
|
advance_command(&mut input, FighterCommands::Attack);
|
|
assert!(input.has_command(FighterCommands::Attack));
|
|
}
|
|
|
|
#[test]
|
|
fn infinite_hold_uses_an_explicit_state() {
|
|
let mut input = FighterInput::default();
|
|
input.enable_hold(FighterCommands::Shield, 0);
|
|
input.set_lifetime(FighterCommands::Shield, 1);
|
|
for _ in 0..u16::MAX as usize + 1 {
|
|
advance_command(&mut input, FighterCommands::Shield);
|
|
}
|
|
assert!(input.has_command(FighterCommands::Shield));
|
|
}
|
|
}
|