Rework input buffer to match HDR closer #79

Merged
EIREXE merged 3 commits from feature/ultimate-style-input-buffer into master 2026-10-03 09:20:56 +00:00
2 changed files with 175 additions and 70 deletions
Showing only changes of commit 021f466ac3 - Show all commits
-4
View File
@@ -107,15 +107,11 @@ impl FighterStateImpl for JumpSquatState {
fn on_enter(&mut self, state_context: &mut EnterContext) { fn on_enter(&mut self, state_context: &mut EnterContext) {
state_context.play_animation(AnimKind::JumpSquat, false); 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.enable_hold_all(10);
state_context.input.clear_command(FighterCommands::Jump); state_context.input.clear_command(FighterCommands::Jump);
} }
fn on_exit(&mut self, state_context: &mut crate::fighter::state::ExitContext) { 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(); state_context.input.disable_hold_all();
} }
+175 -66
View File
@@ -1,9 +1,4 @@
//! Rollback-safe input history and command buffering. //! 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::prelude::*;
use bevy_ggrs::{PlayerInputs, Rollback}; use bevy_ggrs::{PlayerInputs, Rollback};
@@ -101,8 +96,7 @@ impl FighterAttackCommandType {
struct CommandSlot { struct CommandSlot {
lifetime: u8, lifetime: u8,
age: u16, age: u16,
hold_remaining: u16, hold_remaining: HoldLimit,
hold_latched: bool,
consumed: bool, consumed: bool,
} }
@@ -111,24 +105,45 @@ impl Default for CommandSlot {
Self { Self {
lifetime: 0, lifetime: 0,
age: 0, age: 0,
hold_remaining: 0, hold_remaining: HoldLimit::Disabled,
hold_latched: false,
consumed: false, consumed: false,
} }
} }
} }
#[derive(Clone, Copy, Debug, Hash)] #[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq)]
struct HoldLimit { enum HoldLimit {
enabled: bool, #[default]
max_frames: u16, Disabled,
Frames(u16),
Infinite,
} }
impl Default for HoldLimit { #[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq)]
fn default() -> Self { enum HoldOverride {
Self { #[default]
enabled: false, Inherit,
max_frames: 0, 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)
} }
} }
} }
@@ -181,7 +196,7 @@ impl ButtonHistory {
#[require(Rollback)] #[require(Rollback)]
pub struct FighterInput { pub struct FighterInput {
command_slots: [CommandSlot; FighterCommands::COUNT], command_slots: [CommandSlot; FighterCommands::COUNT],
hold_limits: [HoldLimit; FighterCommands::COUNT], hold_limits: [HoldOverride; FighterCommands::COUNT],
hold_all: HoldLimit, hold_all: HoldLimit,
button_history: [ButtonHistory; 3], button_history: [ButtonHistory; 3],
prev_frame: FighterInputFrame, prev_frame: FighterInputFrame,
@@ -195,7 +210,7 @@ impl Default for FighterInput {
fn default() -> Self { fn default() -> Self {
Self { Self {
command_slots: [CommandSlot::default(); FighterCommands::COUNT], command_slots: [CommandSlot::default(); FighterCommands::COUNT],
hold_limits: [HoldLimit::default(); FighterCommands::COUNT], hold_limits: [HoldOverride::default(); FighterCommands::COUNT],
hold_all: HoldLimit::default(), hold_all: HoldLimit::default(),
button_history: [ButtonHistory::default(); 3], button_history: [ButtonHistory::default(); 3],
prev_frame: FighterInputFrame::default(), prev_frame: FighterInputFrame::default(),
@@ -224,8 +239,7 @@ impl FighterInput {
pub fn clear_command(&mut self, command: FighterCommands) { pub fn clear_command(&mut self, command: FighterCommands) {
let slot = &mut self.command_slots[command.index()]; let slot = &mut self.command_slots[command.index()];
slot.lifetime = 0; slot.lifetime = 0;
slot.hold_remaining = 0; slot.hold_remaining = HoldLimit::Disabled;
slot.hold_latched = false;
slot.consumed = true; slot.consumed = true;
} }
@@ -242,8 +256,7 @@ impl FighterInput {
let slot = &mut self.command_slots[command.index()]; let slot = &mut self.command_slots[command.index()];
slot.lifetime = lifetime; slot.lifetime = lifetime;
slot.age = 0; slot.age = 0;
slot.hold_remaining = 0; slot.hold_remaining = HoldLimit::Disabled;
slot.hold_latched = false;
slot.consumed = false; slot.consumed = false;
self.latch_if_hold_enabled(command); self.latch_if_hold_enabled(command);
} }
@@ -258,40 +271,64 @@ impl FighterInput {
.saturating_sub(1); .saturating_sub(1);
} }
/// Holds all inputs for n frames
pub fn enable_hold_all(&mut self, max_frames: u16) { pub fn enable_hold_all(&mut self, max_frames: u16) {
self.hold_all = HoldLimit { self.hold_all = HoldLimit::from_max_frames(max_frames);
enabled: true,
max_frames: if max_frames == 0 { u16::MAX } else { max_frames },
};
self.latch_active_commands(); self.latch_active_commands();
} }
pub fn disable_hold_all(&mut self) { pub fn disable_hold_all(&mut self) {
self.hold_all = HoldLimit::default(); 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) { pub fn enable_hold(&mut self, command: FighterCommands, max_frames: u16) {
self.hold_limits[command.index()] = HoldLimit { self.hold_limits[command.index()] = HoldOverride::from_max_frames(max_frames);
enabled: true,
max_frames: if max_frames == 0 { u16::MAX } else { max_frames },
};
if self.has_command(command) { if self.has_command(command) {
self.latch_if_hold_enabled(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) { pub fn disable_hold(&mut self, command: FighterCommands) {
self.hold_limits[command.index()] = HoldLimit::default(); 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) { fn latch_if_hold_enabled(&mut self, command: FighterCommands) {
let limit = self.hold_limit(command); let limit = self.hold_limit(command);
if !limit.enabled {
return;
}
let slot = &mut self.command_slots[command.index()]; let slot = &mut self.command_slots[command.index()];
slot.hold_latched = true; slot.hold_remaining = match limit {
slot.hold_remaining = limit.max_frames; 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) { fn latch_active_commands(&mut self) {
@@ -341,10 +378,11 @@ impl FighterInput {
fn hold_limit(&self, command: FighterCommands) -> HoldLimit { fn hold_limit(&self, command: FighterCommands) -> HoldLimit {
let specific = self.hold_limits[command.index()]; let specific = self.hold_limits[command.index()];
if specific.enabled { match specific {
specific HoldOverride::Inherit => self.hold_all,
} else { HoldOverride::Disabled => HoldLimit::Disabled,
self.hold_all HoldOverride::Frames(frames) => HoldLimit::Frames(frames),
HoldOverride::Infinite => HoldLimit::Infinite,
} }
} }
@@ -352,15 +390,27 @@ impl FighterInput {
let index = command.index(); let index = command.index();
let slot = &mut self.command_slots[index]; let slot = &mut self.command_slots[index];
slot.age = slot.age.saturating_add(1); slot.age = slot.age.saturating_add(1);
if slot.hold_latched && slot.hold_remaining != 0 { match slot.hold_remaining {
if slot.hold_remaining != u16::MAX { HoldLimit::Disabled => {}
slot.hold_remaining = slot.hold_remaining.saturating_sub(1); HoldLimit::Infinite => {
if slot.lifetime == 0 {
slot.lifetime = 1;
}
} }
if slot.lifetime == 0 { HoldLimit::Frames(remaining) => {
slot.lifetime = 1; // 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;
}
} }
} else if slot.hold_latched {
slot.hold_latched = false;
} }
} }
} }
@@ -496,16 +546,9 @@ pub fn postprocess_input(
input.set_attack_command_type(attack_type); input.set_attack_command_type(attack_type);
} }
// Extend only commands whose source is still held. Recognition above for command in FighterCommands::iter() {
// has already reset age and cleared the consumed marker for new edges. input.update_hold(command);
input.update_hold(FighterCommands::Jump); }
input.update_hold(FighterCommands::Shield);
input.update_hold(FighterCommands::GuardHold);
input.update_hold(FighterCommands::Attack);
input.update_hold(FighterCommands::SmashMoveUp);
input.update_hold(FighterCommands::SmashMoveDown);
input.update_hold(FighterCommands::SmashMoveLeft);
input.update_hold(FighterCommands::SmashMoveRight);
} }
} }
@@ -514,6 +557,11 @@ mod tests {
use super::*; use super::*;
use crate::input::ActionBindingMask; use crate::input::ActionBindingMask;
fn advance_command(input: &mut FighterInput, command: FighterCommands) {
input.reduce_command_lifetime(command);
input.update_hold(command);
}
#[test] #[test]
fn second_binding_is_an_edge_not_a_level() { fn second_binding_is_an_edge_not_a_level() {
let first = ActionBindingMask(0b0001); let first = ActionBindingMask(0b0001);
@@ -556,19 +604,80 @@ mod tests {
fn hold_buffer_keeps_released_attack_through_jump_squat() { fn hold_buffer_keeps_released_attack_through_jump_squat() {
let mut input = FighterInput::default(); let mut input = FighterInput::default();
input.set_lifetime(FighterCommands::Attack, 3); input.set_lifetime(FighterCommands::Attack, 3);
// Recognition happened before jump squat enabled persistence.
input.update_hold(FighterCommands::Attack);
input.enable_hold_all(10); input.enable_hold_all(10);
// The command remains latched even though its physical source is no // Five simulation ticks cover the test fighter's jump squat. The
// longer held. Five simulation ticks cover the test fighter's squat. // physical attack binding is released, but persistence belongs to the
// recognized command rather than the binding's held state.
for _ in 0..5 { for _ in 0..5 {
for command in FighterCommands::iter() { advance_command(&mut input, FighterCommands::Attack);
input.reduce_command_lifetime(command);
}
input.update_hold(FighterCommands::Attack);
} }
assert!(input.has_command(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(); 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)); 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));
}
} }