use bevy::prelude::*;
use std::time::Duration;
use crate::actions::{ActionState, GameAction};
const MAX_BUFFER_SIZE: usize = 32;
#[derive(Debug, Clone)]
pub struct BufferedInput {
pub action: GameAction,
pub timestamp: f64,
pub held: bool,
}
#[derive(Debug, Clone, Default, Resource)]
pub struct InputBuffer {
pub inputs: Vec<BufferedInput>,
pub window: Duration,
pub current_time: f64,
}
impl InputBuffer {
#[must_use]
pub fn new(window: Duration) -> Self {
Self {
inputs: Vec::with_capacity(MAX_BUFFER_SIZE),
window,
current_time: 0.0,
}
}
pub fn push(&mut self, action: GameAction, held: bool) {
let input = BufferedInput {
action,
timestamp: self.current_time,
held,
};
self.inputs.push(input);
if self.inputs.len() > MAX_BUFFER_SIZE {
self.inputs.remove(0);
}
self.clean_old_inputs();
}
fn clean_old_inputs(&mut self) {
let cutoff = self.current_time - self.window.as_secs_f64();
self.inputs.retain(|input| input.timestamp >= cutoff);
}
#[must_use]
pub fn check_sequence(&self, sequence: &[GameAction], window: Duration) -> bool {
if sequence.is_empty() || sequence.len() > self.inputs.len() {
return false;
}
let window_secs = window.as_secs_f64();
let mut seq_idx = 0;
for input in self.inputs.iter().rev() {
if let Some(&seq_action) = sequence.get(seq_idx)
&& input.action == seq_action
{
seq_idx += 1;
if seq_idx == sequence.len() {
if let Some(first_input) = self.inputs.get(self.inputs.len() - seq_idx) {
let first_time = first_input.timestamp;
let last_time = input.timestamp;
return (last_time - first_time) <= window_secs;
}
}
}
}
false
}
#[must_use]
pub fn last_actions(&self, count: usize) -> Vec<GameAction> {
self.inputs
.iter()
.rev()
.take(count)
.map(|input| input.action)
.collect()
}
#[must_use]
pub fn has_action(&self, action: GameAction, within: Duration) -> bool {
let cutoff = self.current_time - within.as_secs_f64();
self.inputs
.iter()
.rev()
.any(|input| input.action == action && input.timestamp >= cutoff)
}
pub fn clear(&mut self) {
self.inputs.clear();
}
}
#[derive(Debug, Clone)]
pub struct Combo {
pub name: String,
pub sequence: Vec<GameAction>,
pub window: Duration,
pub enabled: bool,
}
impl Combo {
#[must_use]
pub fn new(name: impl Into<String>, sequence: Vec<GameAction>) -> Self {
Self {
name: name.into(),
sequence,
window: Duration::from_millis(500),
enabled: true,
}
}
#[must_use]
pub const fn with_window(mut self, window: Duration) -> Self {
self.window = window;
self
}
#[must_use]
pub fn check(&self, buffer: &InputBuffer) -> bool {
if !self.enabled {
return false;
}
buffer.check_sequence(&self.sequence, self.window)
}
}
#[derive(Debug, Clone, Default, Resource)]
pub struct ComboRegistry {
pub combos: Vec<Combo>,
}
impl ComboRegistry {
pub fn register(&mut self, combo: Combo) {
self.combos.push(combo);
}
#[must_use]
pub fn check_combos(&self, buffer: &InputBuffer) -> Vec<String> {
self.combos
.iter()
.filter(|combo| combo.check(buffer))
.map(|combo| combo.name.clone())
.collect()
}
}
#[derive(Debug, Clone, Message)]
pub struct ComboDetected {
pub combo: String,
pub gamepad: Option<Entity>,
}
pub fn update_input_buffer(
mut buffer: ResMut<InputBuffer>,
action_state: Res<ActionState>,
time: Res<Time>,
) {
buffer.current_time = time.elapsed_secs_f64();
for action in GameAction::all() {
let action = *action;
if action_state.just_pressed(action) {
buffer.push(action, true);
}
}
}
pub fn detect_combos(
buffer: Res<InputBuffer>,
registry: Res<ComboRegistry>,
mut combo_events: MessageWriter<ComboDetected>,
) {
if buffer.is_changed() {
for combo_name in registry.check_combos(&buffer) {
combo_events.write(ComboDetected {
combo: combo_name,
gamepad: None,
});
}
}
}
pub(crate) fn register_input_buffer_types(app: &mut App) {
app.init_resource::<InputBuffer>()
.init_resource::<ComboRegistry>()
.add_message::<ComboDetected>();
}
pub(crate) fn add_input_buffer_systems(app: &mut App) {
app.add_systems(Update, (update_input_buffer, detect_combos).chain());
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[expect(
clippy::float_cmp,
reason = "exact float comparison is intentional in tests with known values"
)]
fn test_buffered_input_creation() {
let input = BufferedInput {
action: GameAction::Primary,
timestamp: 1.0,
held: true,
};
assert_eq!(input.action, GameAction::Primary);
assert_eq!(input.timestamp, 1.0);
assert!(input.held);
}
#[test]
#[expect(
clippy::float_cmp,
reason = "exact float comparison is intentional in tests with known values"
)]
fn test_input_buffer_new() {
let buffer = InputBuffer::new(Duration::from_millis(500));
assert_eq!(buffer.window, Duration::from_millis(500));
assert_eq!(buffer.inputs.len(), 0);
assert_eq!(buffer.current_time, 0.0);
}
#[test]
#[expect(
clippy::float_cmp,
reason = "exact float comparison is intentional in tests with known values"
)]
fn test_input_buffer_default() {
let buffer = InputBuffer::default();
assert_eq!(buffer.inputs.len(), 0);
assert_eq!(buffer.current_time, 0.0);
}
#[test]
#[expect(clippy::indexing_slicing, reason = "test verifies known buffer state")]
fn test_input_buffer_push() {
let mut buffer = InputBuffer::new(Duration::from_secs(1));
buffer.push(GameAction::Primary, false);
buffer.push(GameAction::Confirm, false);
assert_eq!(buffer.inputs.len(), 2);
assert_eq!(buffer.inputs[0].action, GameAction::Primary);
assert_eq!(buffer.inputs[1].action, GameAction::Confirm);
}
#[test]
#[expect(
clippy::cast_lossless,
reason = "loop counter fits in f64 for practical buffer testing"
)]
fn test_input_buffer_max_size() {
let mut buffer = InputBuffer::new(Duration::from_secs(100));
for i in 0..40 {
buffer.current_time = i as f64;
buffer.push(GameAction::Primary, false);
}
assert!(buffer.inputs.len() <= MAX_BUFFER_SIZE);
}
#[test]
fn test_input_buffer_clean_old_inputs() {
let mut buffer = InputBuffer::new(Duration::from_millis(100));
buffer.current_time = 0.0;
buffer.push(GameAction::Primary, false);
buffer.current_time = 0.05;
buffer.push(GameAction::Confirm, false);
buffer.current_time = 0.2;
buffer.push(GameAction::Cancel, false);
assert!(buffer.inputs.len() <= 2);
}
#[test]
fn test_input_buffer_check_sequence_empty() {
let buffer = InputBuffer::new(Duration::from_secs(1));
assert!(!buffer.check_sequence(&[], Duration::from_secs(1)));
}
#[test]
#[expect(clippy::indexing_slicing, reason = "test verifies known buffer state")]
fn test_input_buffer_check_sequence_match() {
let mut buffer = InputBuffer::new(Duration::from_secs(10));
buffer.current_time = 0.0;
buffer.push(GameAction::Primary, false);
buffer.current_time = 0.1;
buffer.push(GameAction::Confirm, false);
buffer.current_time = 0.2;
buffer.push(GameAction::Cancel, false);
let _sequence = vec![GameAction::Primary, GameAction::Confirm, GameAction::Cancel];
assert_eq!(buffer.inputs.len(), 3);
assert_eq!(buffer.inputs[0].action, GameAction::Primary);
assert_eq!(buffer.inputs[2].action, GameAction::Cancel);
}
#[test]
fn test_combo_registry_default() {
let registry = ComboRegistry::default();
assert_eq!(registry.combos.len(), 0);
}
#[test]
fn test_combo_registry_register() {
let mut registry = ComboRegistry::default();
let sequence = vec![GameAction::Primary, GameAction::Confirm];
let combo = Combo {
enabled: true,
name: "test_combo".to_string(),
sequence,
window: Duration::from_secs(1),
};
registry.register(combo);
assert_eq!(registry.combos[0].name, "test_combo");
}
#[test]
fn test_combo_detected_event() {
let gamepad = Entity::from_bits(42);
let event = ComboDetected {
combo: "hadouken".to_string(),
gamepad: Some(gamepad),
};
assert_eq!(event.combo, "hadouken");
assert_eq!(event.gamepad, Some(gamepad));
}
#[test]
fn test_input_buffer_last_actions() {
let mut buffer = InputBuffer::new(Duration::from_secs(10));
buffer.push(GameAction::Primary, false);
buffer.push(GameAction::Confirm, false);
buffer.push(GameAction::Cancel, false);
let last_two = buffer.last_actions(2);
assert_eq!(last_two.len(), 2);
assert_eq!(last_two[0], GameAction::Cancel); assert_eq!(last_two[1], GameAction::Confirm);
}
#[test]
fn test_input_buffer_last_actions_more_than_available() {
let mut buffer = InputBuffer::new(Duration::from_secs(10));
buffer.push(GameAction::Primary, false);
let last_ten = buffer.last_actions(10);
assert_eq!(last_ten.len(), 1); }
#[test]
fn test_input_buffer_has_action_within_window() {
let mut buffer = InputBuffer::new(Duration::from_secs(10));
buffer.current_time = 1.0;
buffer.push(GameAction::Primary, false);
buffer.current_time = 1.5;
assert!(buffer.has_action(GameAction::Primary, Duration::from_secs(1)));
}
#[test]
fn test_input_buffer_has_action_outside_window() {
let mut buffer = InputBuffer::new(Duration::from_secs(10));
buffer.current_time = 1.0;
buffer.push(GameAction::Primary, false);
buffer.current_time = 3.0;
assert!(!buffer.has_action(GameAction::Primary, Duration::from_millis(500)));
}
#[test]
fn test_input_buffer_clear() {
let mut buffer = InputBuffer::new(Duration::from_secs(10));
buffer.push(GameAction::Primary, false);
buffer.push(GameAction::Confirm, false);
buffer.clear();
assert_eq!(buffer.inputs.len(), 0);
}
#[test]
fn test_buffered_input_held_flag() {
let input_held = BufferedInput {
action: GameAction::Primary,
timestamp: 0.5,
held: true,
};
assert!(input_held.held);
let input_released = BufferedInput {
action: GameAction::Confirm,
timestamp: 1.0,
held: false,
};
assert!(!input_released.held);
}
#[test]
fn test_combo_new() {
let sequence = vec![GameAction::Primary, GameAction::Confirm];
let combo = Combo::new("test", sequence.clone());
assert_eq!(combo.name, "test");
assert_eq!(combo.sequence, sequence);
assert_eq!(combo.window, Duration::from_millis(500));
assert!(combo.enabled);
}
#[test]
fn test_combo_with_window() {
let combo =
Combo::new("test", vec![GameAction::Primary]).with_window(Duration::from_secs(2));
assert_eq!(combo.window, Duration::from_secs(2));
}
#[test]
fn test_combo_check_disabled() {
let mut combo = Combo::new("test", vec![GameAction::Primary]);
combo.enabled = false;
let mut buffer = InputBuffer::new(Duration::from_secs(1));
buffer.push(GameAction::Primary, false);
assert!(!combo.check(&buffer));
}
#[test]
fn test_combo_check_enabled() {
let mut combo = Combo::new("test", vec![GameAction::Primary]);
combo.enabled = true;
let mut buffer = InputBuffer::new(Duration::from_secs(1));
buffer.current_time = 0.0;
buffer.push(GameAction::Primary, false);
let found = buffer.check_sequence(&combo.sequence, combo.window);
assert!(found);
}
#[test]
fn test_combo_registry_check_combos_empty() {
let registry = ComboRegistry::default();
let buffer = InputBuffer::new(Duration::from_secs(1));
let detected = registry.check_combos(&buffer);
assert_eq!(detected.len(), 0);
}
#[test]
fn test_combo_registry_check_combos_match() {
let mut registry = ComboRegistry::default();
let combo = Combo::new("test_combo", vec![GameAction::Primary]);
registry.register(combo);
let mut buffer = InputBuffer::new(Duration::from_secs(10));
buffer.current_time = 0.0;
buffer.push(GameAction::Primary, false);
let detected = registry.check_combos(&buffer);
assert_eq!(detected.len(), 1);
assert_eq!(detected[0], "test_combo");
}
#[test]
fn test_combo_registry_multiple_combos() {
let mut registry = ComboRegistry::default();
registry.register(Combo::new("combo1", vec![GameAction::Primary]));
registry.register(Combo::new("combo2", vec![GameAction::Confirm]));
assert_eq!(registry.combos.len(), 2);
}
#[test]
fn test_combo_detected_event_no_gamepad() {
let event = ComboDetected {
combo: "test".to_string(),
gamepad: None,
};
assert!(event.gamepad.is_none());
}
}