Rework input buffer to match HDR closer #79
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+173
-64
@@ -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 {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
let slot = &mut self.command_slots[command.index()];
|
HoldLimit::Frames(remaining)
|
||||||
slot.hold_latched = true;
|
}
|
||||||
slot.hold_remaining = limit.max_frames;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
if slot.lifetime == 0 {
|
||||||
slot.lifetime = 1;
|
slot.lifetime = 1;
|
||||||
}
|
}
|
||||||
} else if slot.hold_latched {
|
}
|
||||||
slot.hold_latched = false;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user