//! 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::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, } 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 = 8; 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, } } } #[derive(Default, Clone, Hash, Copy, Debug)] pub enum FighterAttackCommandType { #[default] Neutral, SmashAttackUp, SmashAttackDown, SmashAttackLeft, SmashAttackRight, TiltAttackUp, TiltAttackDown, TiltAttackLeft, TiltAttackRight, } 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; } } } /// 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, frames_in_x_flick_deadzone: u8, frames_in_y_flick_deadzone: u8, 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(), frames_in_x_flick_deadzone: 0xFE, frames_in_y_flick_deadzone: 0xFE, 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 get_frames_in_smash_move_deadzone(&self) -> u8 { self.frames_in_x_flick_deadzone } 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>, game_settings: Res, ) { 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 x_abs = input.current_frame.movement.x.abs(); let y_abs = input.current_frame.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); } 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); } 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 }; 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 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 } } else if x_abs > y_abs { if input.current_frame.movement.x.is_positive() { FighterAttackCommandType::TiltAttackRight } else { FighterAttackCommandType::TiltAttackLeft } } else if y_abs > x_abs { if input.current_frame.movement.y.is_positive() { FighterAttackCommandType::TiltAttackUp } else { FighterAttackCommandType::TiltAttackDown } } else { FighterAttackCommandType::Neutral }; input.set_lifetime(FighterCommands::Attack, tap); input.set_attack_command_type(attack_type); } for command in FighterCommands::iter() { input.update_hold(command); } } } #[cfg(test)] mod tests { use super::*; use crate::input::ActionBindingMask; 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 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)); } }