diff --git a/src/fighter/state/air_dodge.rs b/src/fighter/state/air_dodge.rs index f05c53b..d5eb01a 100644 --- a/src/fighter/state/air_dodge.rs +++ b/src/fighter/state/air_dodge.rs @@ -1,13 +1,17 @@ use crate::{ fighter::{ - FighterInvulnerabilityState, animation::AnimKind, state::{ + FighterInvulnerabilityState, + animation::AnimKind, + state::{ CollisionContext, EnterContext, FighterState, FighterStateImpl, InterruptContext, UpdateContext, air::{self, AirborneStateCommon}, fall::FallState, land::LandingType, }, - }, input, math::vec::FGVec2, + }, + input, + math::vec::FGVec2, }; #[derive(Debug, Clone, Hash)] @@ -29,8 +33,13 @@ impl FighterStateImpl for AirDodgeState { } fn update(&mut self, state_context: &mut UpdateContext) { - let airdodge_intangible_end = state_context.fighter_attributes.air_dodge_intangible_start + state_context.fighter_attributes.air_dodge_intangible_duration; - if state_context.animation_frame == state_context.fighter_attributes.air_dodge_intangible_start { + let airdodge_intangible_end = state_context.fighter_attributes.air_dodge_intangible_start + + state_context + .fighter_attributes + .air_dodge_intangible_duration; + if state_context.animation_frame + == state_context.fighter_attributes.air_dodge_intangible_start + { *state_context.vulnerability = FighterInvulnerabilityState::Intangible; } else if state_context.animation_frame >= airdodge_intangible_end { *state_context.vulnerability = FighterInvulnerabilityState::Vulnerable; @@ -54,6 +63,13 @@ impl FighterStateImpl for AirDodgeState { fn on_enter(&mut self, state_context: &mut EnterContext) { state_context.play_animation(AnimKind::AirDodge, false); + state_context + .input + .clear_command(input::FighterCommands::Shield); + state_context + .input + .clear_command(input::FighterCommands::GuardHold); + state_context.velocity.0 = self.direction * state_context.fighter_attributes.air_dodge_velocity; } @@ -65,11 +81,11 @@ impl FighterStateImpl for AirDodgeState { fn surface_policy(&self) -> super::SurfacePolicy { super::SurfacePolicy::Airborne(crate::fighter::surface::EcbLockPolicy::StartOnDetach) } - + fn on_exit(&mut self, state_context: &mut super::ExitContext) { *state_context.vulnerability = FighterInvulnerabilityState::Vulnerable; } - + fn name(&self) -> String { Self::NAME.to_owned() } @@ -79,7 +95,14 @@ pub fn check_input(state_context: &InterruptContext) -> Option { state_context .input .has_command(input::FighterCommands::Shield) - .then(|| { + .then_some(()) + .or_else(|| { + state_context + .input + .has_command(input::FighterCommands::GuardHold) + .then_some(()) + }) + .map(|_| { FighterState::AirDodge(AirDodgeState { direction: state_context .input diff --git a/src/fighter/state/jump.rs b/src/fighter/state/jump.rs index 7de00a2..1e66480 100644 --- a/src/fighter/state/jump.rs +++ b/src/fighter/state/jump.rs @@ -11,7 +11,7 @@ use crate::{ ground, }, }, - input::FighterCommands, + input::{DigitalAction, FighterCommands}, math::{int::FGi32, vec::FGVec2}, }; #[derive(Debug, Hash, Clone, Copy)] @@ -68,8 +68,18 @@ impl FighterStateImpl for JumpSquatState { direction: wavedash_dir.normalize_or_zero(), })) } else if self.duration_counter >= state_context.fighter_attributes.jumpsquat_duration { - info!("Jump {}", state_context.input.get_last_frame().jump); - let jump_type = if state_context.input.get_last_frame().jump { + info!( + "Jump {}", + state_context + .input + .get_last_frame() + .is_held(DigitalAction::Jump) + ); + let jump_type = if state_context + .input + .get_last_frame() + .is_held(DigitalAction::Jump) + { JumpType::LongJump } else { JumpType::ShortHop @@ -97,9 +107,18 @@ impl FighterStateImpl for JumpSquatState { fn on_enter(&mut self, state_context: &mut EnterContext) { state_context.play_animation(AnimKind::JumpSquat, false); + // Preserve buffered inputs while jump squat makes the transition + // temporarily unavailable, matching Ultimate/HDR-style hold input. + state_context.input.enable_hold_all(10); state_context.input.clear_command(FighterCommands::Jump); } + fn on_exit(&mut self, state_context: &mut crate::fighter::state::ExitContext) { + // The hold policy is state-scoped and must never leak into the next + // state. Command lifetime itself remains independent of this policy. + state_context.input.disable_hold_all(); + } + fn is_visually_grounded<'a>(&'a self) -> bool { true } diff --git a/src/fighter/state/mod.rs b/src/fighter/state/mod.rs index 6738c0f..9a56bd0 100644 --- a/src/fighter/state/mod.rs +++ b/src/fighter/state/mod.rs @@ -18,7 +18,10 @@ use crate::fighter::state::hitstun::HitstunState; use crate::fighter::surface::EcbLockPolicy; use crate::fighter::visual::FighterAnimations; use crate::fighter::{ - Fighter, FighterDamage, FighterECB, FighterFacingDirection, FighterHitboxes, FighterHitstop, FighterHitstopShake, FighterHitstopShakeKind, FighterHitstopShakeRole, FighterInvulnerabilityState, FighterPendingRespawn, FighterPresentationSnap, FighterPreviousTranslation, FighterSurfaceState, FighterTranslation, FighterVelocity, + Fighter, FighterDamage, FighterECB, FighterFacingDirection, FighterHitboxes, FighterHitstop, + FighterHitstopShake, FighterHitstopShakeKind, FighterHitstopShakeRole, + FighterInvulnerabilityState, FighterPendingRespawn, FighterPresentationSnap, + FighterPreviousTranslation, FighterSurfaceState, FighterTranslation, FighterVelocity, }; use crate::game_settings::GameSettings; use crate::input::FighterInput; @@ -32,6 +35,7 @@ use crate::stage::line::StageCollision; use crate::stage::manifest::{StageManifest, StageManifestRegistry}; pub mod air; +pub mod air_attack; pub mod air_dodge; pub mod angel; pub mod crouch; @@ -47,7 +51,6 @@ pub mod run; pub mod turn; pub mod wait; pub mod walk; -pub mod air_attack; #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] pub enum SurfacePolicy { @@ -170,6 +173,7 @@ impl EnterContext<'_> { } pub struct ExitContext<'a> { + pub input: &'a mut FighterInput, pub hitboxes: &'a mut FighterHitboxes, pub surface: &'a mut FighterSurfaceState, pub vulnerability: &'a mut FighterInvulnerabilityState, @@ -306,7 +310,7 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> { animation, surface: &self.surface, stage_collision, - attack_script_assets: attack_script_assets + attack_script_assets: attack_script_assets, } } @@ -333,6 +337,7 @@ impl<'w, 's> FighterFrameQueryItem<'w, 's> { fn exit_context(&mut self) -> ExitContext<'_> { ExitContext { + input: &mut self.input, hitboxes: &mut self.hitboxes, surface: &mut self.surface, vulnerability: &mut self.vulnerability, @@ -540,7 +545,7 @@ pub fn state_interrupt_system( &baked_animations, &manifest.attributes, &stage_collision, - attack_script_assets + attack_script_assets, )) }; diff --git a/src/input/buffer.rs b/src/input/buffer.rs index 33f111a..8c79ac4 100644 --- a/src/input/buffer.rs +++ b/src/input/buffer.rs @@ -1,8 +1,9 @@ -//! Per-fighter input state: the current and previous frame, plus the buffered -//! commands (smash inputs and friends) that states query when deciding whether -//! to interrupt. - -use std::ops::{Index, IndexMut}; +//! Rollback-safe input history and command buffering. +//! +//! Raw mapped bindings, button edges, and command lifetimes are deliberately +//! separate. This preserves combinations such as pressing a second shield +//! binding while the first one is held, while still allowing a command to +//! survive after its source button is released. use bevy::prelude::*; use bevy_ggrs::{PlayerInputs, Rollback}; @@ -12,14 +13,15 @@ use strum_macros::EnumIter; use crate::fighter::FighterFacingDirection; use crate::fighter::attack::AttackKind; use crate::game_settings::GameSettings; -use crate::input::FighterInputFrame; +use crate::input::{DigitalAction, FighterInputFrame}; use crate::netcode::GGRSCfg; use crate::player::Player; -#[derive(Clone, Copy, EnumIter, Debug)] +#[derive(Clone, Copy, EnumIter, Debug, PartialEq, Eq, Hash)] pub enum FighterCommands { Jump, Shield, + GuardHold, Attack, SmashMoveUp, SmashMoveDown, @@ -27,7 +29,31 @@ pub enum FighterCommands { SmashMoveRight, } -#[derive(Default, Clone, Hash, Copy)] +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, @@ -44,86 +70,116 @@ pub enum FighterAttackCommandType { impl FighterAttackCommandType { pub fn to_attack_kind_grounded(&self) -> AttackKind { match self { - FighterAttackCommandType::Neutral => AttackKind::Jab, - FighterAttackCommandType::SmashAttackUp => AttackKind::UpSmash, - FighterAttackCommandType::SmashAttackDown => AttackKind::DownSmash, - FighterAttackCommandType::SmashAttackLeft => AttackKind::ForwardSmash, - FighterAttackCommandType::SmashAttackRight => AttackKind::ForwardSmash, - FighterAttackCommandType::TiltAttackUp => AttackKind::UpTilt, - FighterAttackCommandType::TiltAttackDown => AttackKind::DownTilt, - FighterAttackCommandType::TiltAttackLeft => AttackKind::ForwardTilt, - FighterAttackCommandType::TiltAttackRight => AttackKind::ForwardTilt, + 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 { - FighterAttackCommandType::Neutral => AttackKind::NeutralAir, - FighterAttackCommandType::SmashAttackUp | FighterAttackCommandType::TiltAttackUp => AttackKind::UpAir, - FighterAttackCommandType::SmashAttackDown | FighterAttackCommandType::TiltAttackDown => AttackKind::DownAir, - - FighterAttackCommandType::SmashAttackLeft | FighterAttackCommandType::TiltAttackLeft => { - match facing_direction { - FighterFacingDirection::Left => AttackKind::ForwardAir, - FighterFacingDirection::Right => AttackKind::BackAir, - } + 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, }, - FighterAttackCommandType::SmashAttackRight | FighterAttackCommandType::TiltAttackRight => { - match facing_direction { - FighterFacingDirection::Left => AttackKind::BackAir, - FighterFacingDirection::Right => AttackKind::ForwardAir, - } + Self::SmashAttackRight | Self::TiltAttackRight => match facing_direction { + FighterFacingDirection::Left => AttackKind::BackAir, + FighterFacingDirection::Right => AttackKind::ForwardAir, }, } } } -/// Frames of life remaining for each buffered command. Indexed by -/// [`FighterCommands`] so adding a command is a two-line change here. -#[derive(Default, Clone, Copy, Hash)] -pub struct FighterCommandLifetimes { - smash_move_left: u8, - smash_move_right: u8, - smash_move_down: u8, - smash_move_up: u8, - jump: u8, - shield: u8, - attack: u8, +#[derive(Clone, Copy, Debug, Hash)] +struct CommandSlot { + lifetime: u8, + age: u16, + consumed: bool, } -impl Index for FighterCommandLifetimes { - type Output = u8; - - fn index(&self, command: FighterCommands) -> &Self::Output { - match command { - FighterCommands::SmashMoveLeft => &self.smash_move_left, - FighterCommands::SmashMoveRight => &self.smash_move_right, - FighterCommands::SmashMoveDown => &self.smash_move_down, - FighterCommands::SmashMoveUp => &self.smash_move_up, - FighterCommands::Jump => &self.jump, - FighterCommands::Shield => &self.shield, - FighterCommands::Attack => &self.attack, +impl Default for CommandSlot { + fn default() -> Self { + Self { + lifetime: 0, + age: 0, + consumed: false, } } } -impl IndexMut for FighterCommandLifetimes { - fn index_mut(&mut self, command: FighterCommands) -> &mut Self::Output { - match command { - FighterCommands::SmashMoveLeft => &mut self.smash_move_left, - FighterCommands::SmashMoveRight => &mut self.smash_move_right, - FighterCommands::SmashMoveDown => &mut self.smash_move_down, - FighterCommands::SmashMoveUp => &mut self.smash_move_up, - FighterCommands::Jump => &mut self.jump, - FighterCommands::Shield => &mut self.shield, - FighterCommands::Attack => &mut self.attack, +#[derive(Clone, Copy, Debug, Hash)] +struct HoldLimit { + enabled: bool, + max_frames: u16, +} + +impl Default for HoldLimit { + fn default() -> Self { + Self { + enabled: false, + max_frames: 0, } } } -#[derive(Component, Default, Clone, Copy, Hash)] +#[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_lifetimes: FighterCommandLifetimes, + command_slots: [CommandSlot; FighterCommands::COUNT], + hold_limits: [HoldLimit; FighterCommands::COUNT], + hold_all: HoldLimit, + button_history: [ButtonHistory; 3], prev_frame: FighterInputFrame, current_frame: FighterInputFrame, frames_in_x_flick_deadzone: u8, @@ -131,6 +187,22 @@ pub struct FighterInput { attack_command: FighterAttackCommandType, } +impl Default for FighterInput { + fn default() -> Self { + Self { + command_slots: [CommandSlot::default(); FighterCommands::COUNT], + hold_limits: [HoldLimit::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 @@ -142,40 +214,122 @@ impl FighterInput { } pub fn clear_buffer(&mut self) { - self.command_lifetimes = FighterCommandLifetimes::default() + self.command_slots = [CommandSlot::default(); FighterCommands::COUNT]; } pub fn clear_command(&mut self, command: FighterCommands) { - self.command_lifetimes[command] = 0; + let slot = &mut self.command_slots[command.index()]; + slot.lifetime = 0; + 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) { - self.command_lifetimes[command] = lifetime; + let slot = &mut self.command_slots[command.index()]; + slot.lifetime = lifetime; + slot.age = 0; + slot.consumed = false; } pub fn has_command(&self, command: FighterCommands) -> bool { - self.command_lifetimes[command] > 0 + self.command_slots[command.index()].lifetime > 0 } pub fn reduce_command_lifetime(&mut self, command: FighterCommands) { - self.command_lifetimes[command] = self.command_lifetimes[command].saturating_sub(1); + self.command_slots[command.index()].lifetime = self.command_slots[command.index()] + .lifetime + .saturating_sub(1); + } + + pub fn enable_hold_all(&mut self, max_frames: u16) { + self.hold_all = HoldLimit { + enabled: true, + max_frames, + }; + } + + pub fn disable_hold_all(&mut self) { + self.hold_all = HoldLimit::default(); + } + + pub fn enable_hold(&mut self, command: FighterCommands, max_frames: u16) { + self.hold_limits[command.index()] = HoldLimit { + enabled: true, + max_frames, + }; + } + + pub fn disable_hold(&mut self, command: FighterCommands) { + self.hold_limits[command.index()] = HoldLimit::default(); + } + + 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()]; + if specific.enabled { + specific + } else { + self.hold_all + } + } + + fn update_hold(&mut self, command: FighterCommands, source_held: bool) { + let index = command.index(); + let limit = self.hold_limit(command); + let slot = &mut self.command_slots[index]; + slot.age = slot.age.saturating_add(1); + if slot.lifetime == 0 && source_held && !slot.consumed { + if limit.enabled && (limit.max_frames == 0 || slot.age < limit.max_frames) { + slot.lifetime = 1; + } + } + } } -/// Turns this frame's rolled-back inputs into per-fighter [`FighterInput`] -/// state. Runs in `GameplaySet::Input`, before any state looks at it. pub fn postprocess_input( query: Query<(&mut FighterInput, &Player)>, inputs: Res>, @@ -187,95 +341,95 @@ pub fn postprocess_input( } 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, + ); + } - // Directional smash detection 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 { - let prev_x_abs = input.prev_frame.movement.x.abs(); - if prev_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); - } + 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 { - let prev_y_abs = input.prev_frame.movement.y.abs(); - if prev_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.frames_in_y_flick_deadzone = input.frames_in_y_flick_deadzone.saturating_add(1); } - if !input.prev_frame.jump && input.current_frame.jump { - input.set_lifetime( - FighterCommands::Jump, - game_settings.input_common.input_buffer_size, - ); + 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); } - if !input.prev_frame.shield && input.current_frame.shield { - input.set_lifetime( - FighterCommands::Shield, - game_settings.input_common.input_buffer_size, - ); - } - - if !input.prev_frame.attack && input.current_frame.attack { - input.set_lifetime( - FighterCommands::Attack, - game_settings.input_common.input_buffer_size, - ); + 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_to_set = if input.current_frame.movement.y > 0.0 { + let command = if input.current_frame.movement.y > 0.0 { FighterCommands::SmashMoveUp } else { FighterCommands::SmashMoveDown }; - - input.set_lifetime(command_to_set, game_settings.input_common.input_buffer_size); + 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 both flicks are detected, only one wins - if x_flick_detected && y_flick_detected { x_flick_detected = x_abs > y_abs; y_flick_detected = !x_flick_detected; } - let attack_detected = input.current_frame.attack && !input.prev_frame.attack; - - // Tilt/smash up/down - if attack_detected { + if attack_pressed { let attack_type = if y_flick_detected { if input.current_frame.movement.y > 0.0 { FighterAttackCommandType::SmashAttackUp @@ -303,25 +457,66 @@ pub fn postprocess_input( } else { FighterAttackCommandType::Neutral }; - - input.set_lifetime( - FighterCommands::Attack, - game_settings.input_common.input_buffer_size, - ); + input.set_lifetime(FighterCommands::Attack, tap); input.set_attack_command_type(attack_type); } - if x_flick_detected { - 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); - } + // Extend only commands whose source is still held. Recognition above + // has already reset age and cleared the consumed marker for new edges. + let jump_held = input.current_frame.is_held(DigitalAction::Jump); + let shield_held = input.current_frame.is_held(DigitalAction::Shield); + let attack_held = input.current_frame.is_held(DigitalAction::Attack); + input.update_hold(FighterCommands::Jump, jump_held); + input.update_hold(FighterCommands::Shield, shield_held); + input.update_hold(FighterCommands::GuardHold, shield_now.count() >= 2); + input.update_hold(FighterCommands::Attack, attack_held); + input.update_hold(FighterCommands::SmashMoveUp, false); + input.update_hold(FighterCommands::SmashMoveDown, false); + input.update_hold(FighterCommands::SmashMoveLeft, false); + input.update_hold(FighterCommands::SmashMoveRight, false); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::input::ActionBindingMask; + + #[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, true); + input.reduce_command_lifetime(FighterCommands::Shield); + input.update_hold(FighterCommands::Shield, true); + assert!(input.has_command(FighterCommands::Shield)); + input.consume_command(FighterCommands::Shield); + input.update_hold(FighterCommands::Shield, true); + assert!(!input.has_command(FighterCommands::Shield)); } } diff --git a/src/input/frame.rs b/src/input/frame.rs index 3422f67..f558749 100644 --- a/src/input/frame.rs +++ b/src/input/frame.rs @@ -2,13 +2,119 @@ use serde::{Deserialize, Serialize}; use crate::math::vec::FGVec2; +/// A fixed-width set of distinct bindings assigned to one logical action. +/// Keeping binding identity in the rollback frame allows combinations such as +/// pressing a second shield binding while the first one is still held. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +pub struct ActionBindingMask(pub u16); + +impl ActionBindingMask { + pub const fn empty() -> Self { + Self(0) + } + + pub const fn from_slot(slot: u8) -> Self { + Self(if slot < 16 { 1u16 << slot } else { 0 }) + } + + pub const fn contains(self, other: Self) -> bool { + self.0 & other.0 == other.0 + } + + pub const fn intersects(self, other: Self) -> bool { + self.0 & other.0 != 0 + } + + pub const fn count(self) -> u32 { + self.0.count_ones() + } + + pub const fn pressed_since(self, previous: Self) -> Self { + Self(self.0 & !previous.0) + } + + pub const fn released_since(self, previous: Self) -> Self { + Self(previous.0 & !self.0) + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +pub struct DigitalActionBindings { + pub jump: ActionBindingMask, + pub shield: ActionBindingMask, + pub attack: ActionBindingMask, +} + +impl DigitalActionBindings { + pub fn set(&mut self, action: DigitalAction, slot: u8, pressed: bool) { + let mask = match action { + DigitalAction::Jump => &mut self.jump, + DigitalAction::Shield => &mut self.shield, + DigitalAction::Attack => &mut self.attack, + }; + let bit = ActionBindingMask::from_slot(slot).0; + if pressed { + mask.0 |= bit; + } else { + mask.0 &= !bit; + } + } + + pub const fn get(self, action: DigitalAction) -> ActionBindingMask { + match action { + DigitalAction::Jump => self.jump, + DigitalAction::Shield => self.shield, + DigitalAction::Attack => self.attack, + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub enum DigitalAction { + Jump, + Shield, + Attack, +} + /// One frame of a player's raw input. This is the type GGRS serializes and /// sends over the wire, so keep it small and keep it `PartialEq`. #[derive(Copy, Clone, Debug, PartialEq, Default, Serialize, Deserialize, Hash)] pub struct FighterInputFrame { pub movement: FGVec2, pub directional_attack: FGVec2, - pub jump: bool, - pub shield: bool, - pub attack: bool, + pub bindings: DigitalActionBindings, +} + +impl FighterInputFrame { + pub const fn bindings_for(self, action: DigitalAction) -> ActionBindingMask { + self.bindings.get(action) + } + + pub const fn is_held(self, action: DigitalAction) -> bool { + self.bindings_for(action).0 != 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn binding_masks_preserve_distinct_same_action_bindings() { + let mut bindings = DigitalActionBindings::default(); + bindings.set(DigitalAction::Shield, 0, true); + bindings.set(DigitalAction::Shield, 1, true); + assert_eq!(bindings.shield.0, 0b11); + assert_eq!(bindings.shield.count(), 2); + assert_eq!(bindings.shield.pressed_since(ActionBindingMask(1)).0, 0b10); + } + + #[test] + fn clearing_one_binding_does_not_clear_the_other() { + let mut bindings = DigitalActionBindings::default(); + bindings.set(DigitalAction::Shield, 0, true); + bindings.set(DigitalAction::Shield, 1, true); + bindings.set(DigitalAction::Shield, 0, false); + assert_eq!(bindings.shield.0, 0b10); + } } diff --git a/src/input/gamepad.rs b/src/input/gamepad.rs index 890d666..6113f77 100644 --- a/src/input/gamepad.rs +++ b/src/input/gamepad.rs @@ -14,10 +14,13 @@ pub fn sample_gamepad(pad: &Gamepad, input_map: &BaseInputMap) -> FighterInputFr match &gamepad_element.binding { GamepadBinding::Button(button) => { let pressed = pad.pressed(*button); + if let Some(action) = gamepad_element.action.digital_action() { + input_frame + .bindings + .set(action, gamepad_element.binding_slot, pressed); + } match gamepad_element.action { - InputMapAction::Jump => input_frame.jump |= pressed, - InputMapAction::Shield => input_frame.shield |= pressed, - InputMapAction::Attack => input_frame.attack |= pressed, + InputMapAction::Jump | InputMapAction::Shield | InputMapAction::Attack => {} // Digital buttons driving movement isn't used by the // default bindings (the stick covers that), but stay // consistent with the keyboard backend if one is added. diff --git a/src/input/keyboard.rs b/src/input/keyboard.rs index dffa7b6..21ebce4 100644 --- a/src/input/keyboard.rs +++ b/src/input/keyboard.rs @@ -30,9 +30,15 @@ pub fn sample_keyboard(key: &ButtonInput, input_map: &BaseInputMap) -> InputMapAction::MovementYDir(sign) => { input_frame.movement.y += (sign) * action_strength } - InputMapAction::Jump => input_frame.jump = action_strength != FGi32::ZERO, - InputMapAction::Shield => input_frame.shield = action_strength != FGi32::ZERO, - InputMapAction::Attack => input_frame.attack = action_strength != FGi32::ZERO, + InputMapAction::Jump | InputMapAction::Shield | InputMapAction::Attack => { + if let Some(action) = keyboard_elment.action.digital_action() { + input_frame.bindings.set( + action, + keyboard_elment.binding_slot, + action_strength != FGi32::ZERO, + ); + } + } } } diff --git a/src/input/map.rs b/src/input/map.rs index 81fe3cf..afecc89 100644 --- a/src/input/map.rs +++ b/src/input/map.rs @@ -19,6 +19,17 @@ pub enum InputMapAction { Attack, } +impl InputMapAction { + pub const fn digital_action(&self) -> Option { + match self { + Self::Jump => Some(crate::input::DigitalAction::Jump), + Self::Shield => Some(crate::input::DigitalAction::Shield), + Self::Attack => Some(crate::input::DigitalAction::Attack), + Self::MovementXDir(_) | Self::MovementYDir(_) => None, + } + } +} + #[derive(Debug)] pub enum MenuInputMapAction { MovementXDir(i32), @@ -32,6 +43,7 @@ pub enum MenuInputMapAction { pub struct KeyboardInputMapElement { pub key: KeyCode, pub action: InputMapAction, + pub binding_slot: u8, } #[derive(Debug)] @@ -44,6 +56,7 @@ pub enum GamepadBinding { pub struct GamepadInputMapElement { pub binding: GamepadBinding, pub action: InputMapAction, + pub binding_slot: u8, } #[derive(Debug)] diff --git a/src/input/mod.rs b/src/input/mod.rs index 8cf93bd..1878bd8 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -15,7 +15,7 @@ pub mod keyboard; pub mod map; pub use buffer::{FighterCommands, FighterInput}; -pub use frame::FighterInputFrame; +pub use frame::{ActionBindingMask, DigitalAction, DigitalActionBindings, FighterInputFrame}; pub use map::{ BaseInputMap, GamepadBinding, GamepadInputMapElement, InputActionState, InputMapAction, KeyboardInputMapElement, MenuGamepadInputMapElement, MenuInputMap, MenuInputMapAction, @@ -78,36 +78,43 @@ impl Plugin for FighterInputPlugin { KeyboardInputMapElement { key: KeyCode::KeyD, action: InputMapAction::MovementXDir(1), + binding_slot: 0, }, // left KeyboardInputMapElement { key: KeyCode::KeyA, action: InputMapAction::MovementXDir(-1), + binding_slot: 0, }, // up KeyboardInputMapElement { key: KeyCode::KeyW, action: InputMapAction::MovementYDir(1), + binding_slot: 0, }, // down KeyboardInputMapElement { key: KeyCode::KeyS, action: InputMapAction::MovementYDir(-1), + binding_slot: 0, }, // jump KeyboardInputMapElement { key: KeyCode::Space, action: InputMapAction::Jump, + binding_slot: 0, }, // shield/airdodge KeyboardInputMapElement { key: KeyCode::ShiftLeft, action: InputMapAction::Shield, + binding_slot: 0, }, // attack KeyboardInputMapElement { key: KeyCode::KeyJ, action: InputMapAction::Attack, + binding_slot: 0, }, // Menu ], @@ -116,28 +123,34 @@ impl Plugin for FighterInputPlugin { GamepadInputMapElement { binding: GamepadBinding::Axis(GamepadAxis::LeftStickX, 1), action: InputMapAction::MovementXDir(1), + binding_slot: 0, }, GamepadInputMapElement { binding: GamepadBinding::Axis(GamepadAxis::LeftStickY, 1), action: InputMapAction::MovementYDir(1), + binding_slot: 0, }, // jump GamepadInputMapElement { binding: GamepadBinding::Button(GamepadButton::North), action: InputMapAction::Jump, + binding_slot: 0, }, GamepadInputMapElement { binding: GamepadBinding::Button(GamepadButton::LeftTrigger2), action: InputMapAction::Shield, + binding_slot: 0, }, GamepadInputMapElement { binding: GamepadBinding::Button(GamepadButton::RightTrigger2), action: InputMapAction::Shield, + binding_slot: 1, }, // attack GamepadInputMapElement { binding: GamepadBinding::Button(GamepadButton::East), action: InputMapAction::Attack, + binding_slot: 0, }, ], })