reaction 0.2.0

Universal low-latency input handling for game engines
Documentation
use crate::mapping::action::InputAction;
use crate::mapping::binding::{BindingConfig, InputBinding};
use crate::state::advanced::AdvancedInputState;
use crate::types::GamepadId;
use std::collections::HashMap;

#[derive(Default, Clone)]
pub struct InputMap {
    bindings: HashMap<InputAction, Vec<BindingConfig>>,
}

impl InputMap {
    /// Create a new, empty input map.
    pub fn new() -> Self {
        Self::default()
    }

    pub fn bind(
        &mut self,
        action: InputAction,
        binding: BindingConfig,
    ) -> crate::error::Result<()> {
        // We reject infinite scales because they mess up the summation/normalization later.
        if !binding.scale.is_finite() {
            return Err(crate::error::ReactionError::InvalidConfiguration(
                "Scale must be a finite number".to_string(),
            ));
        }

        // Deadzone needs to be within [0, 1]. Inconsequential values outside this range are
        // technically bugs, so we return an error to catch them early.
        if !(0.0..=1.0).contains(&binding.deadzone) {
            return Err(crate::error::ReactionError::InvalidConfiguration(format!(
                "Invalid deadzone: {}. Must be in [0, 1]",
                binding.deadzone
            )));
        }

        self.bindings.entry(action).or_default().push(binding);
        Ok(())
    }

    /// Resolves an action to a value in the range [-1.0, 1.0].
    ///
    /// ✅ BEHAVIOR: Sums processed values from all active bindings for this action.
    /// This ensures opposing inputs (e.g. Left + Right) correctly cancel each other out.
    pub fn resolve(
        &self,
        action: InputAction,
        input: &AdvancedInputState,
        gamepad_id: Option<GamepadId>,
    ) -> f32 {
        let Some(bindings) = self.bindings.get(&action) else {
            return 0.0;
        };

        let mut sum_val: f32 = 0.0;

        for binding in bindings {
            let raw_val = match binding.input {
                InputBinding::Key(k) => {
                    if input.is_key_down(k) {
                        1.0
                    } else {
                        0.0
                    }
                }
                InputBinding::MouseButton(b) => {
                    if input.is_mouse_button_down(b) {
                        1.0
                    } else {
                        0.0
                    }
                }
                InputBinding::GamepadButton(b) => {
                    if let Some(id) = gamepad_id {
                        if let Some(gp) = input.gamepads.get(id) {
                            if gp.is_button_down(b) {
                                1.0
                            } else {
                                0.0
                            }
                        } else {
                            0.0
                        }
                    } else {
                        0.0
                    }
                }
                InputBinding::GamepadAxis(a) => {
                    if let Some(id) = gamepad_id {
                        if let Some(gp) = input.gamepads.get(id) {
                            gp.get_axis(a)
                        } else {
                            0.0
                        }
                    } else {
                        0.0
                    }
                }
            };

            let mut processed = raw_val;

            // Apply deadzone rescaling. We want a smooth ramp starting from the threshold
            // instead of a hard jump. This is why we rescale the remaining range to [0, 1].
            if processed.abs() < binding.deadzone {
                processed = 0.0;
            } else {
                let sign = processed.signum();
                let mag = processed.abs();
                processed = (mag - binding.deadzone) / (1.0 - binding.deadzone);
                processed *= sign;
            }

            // Apply the user-defined scale (e.g., for inverting an axis).
            processed *= binding.scale;

            // Accumulate. Summing allows opposing bindings (like A and D keys) to cancel out.
            sum_val += processed;
        }

        // Normalize the final output to ensure multiple bindings don't push us out of bounds.
        sum_val.clamp(-1.0, 1.0)
    }

    pub fn is_active(
        &self,
        action: InputAction,
        input: &AdvancedInputState,
        gamepad_id: Option<GamepadId>,
        threshold: f32,
    ) -> bool {
        self.resolve(action, input, gamepad_id).abs() >= threshold
    }
}