//! [`AxisBinding`] and [`Axis2DBinding`] — directional inputs composed from //! [`Binding`]s into floats and [`Vec2`]s. //! //! A 1D axis pairs a "positive" binding set with a "negative" binding set; //! each direction held contributes ±1. If both directions are held the //! contributions cancel and the axis reads 0 — a "soft brake" any third- //! person camera or twin-stick character controller needs out of the box. //! Each direction supports several bindings (a WASD axis can also accept //! arrow keys), and the same physical key can appear in many axes' direction //! sets. //! //! A 2D axis is just a pair of 1D axes (X then Y). Diagonals are //! intentionally **not** normalized at this layer — some games want //! Quake-style diagonal speedup, others want unit-length input. Whichever //! convention a game wants, applying it once at the call site is clearer //! than having to undo a default at every site that disagrees. use serde::{Deserialize, Serialize}; use crate::math::Vec2; use super::{Binding, InputState}; /// One direction of an axis — typically positive (right / forward / up) or /// negative (left / back / down) — bound to one or more physical inputs. /// Any binding held contributes a full unit; multiple held bindings on the /// same direction do not stack. #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AxisBinding { /// Bindings that pull the axis toward +1. pub positive: Vec, /// Bindings that pull the axis toward -1. pub negative: Vec, } impl AxisBinding { /// A new axis with the given direction binding lists. pub fn new( positive: impl IntoIterator, negative: impl IntoIterator, ) -> Self { Self { positive: positive.into_iter().collect(), negative: negative.into_iter().collect(), } } /// Evaluates the axis against `input`. Returns -1, 0, or +1 (the /// directions OR'd together — multiple held bindings on the same side /// don't stack). pub fn value(&self, input: &InputState) -> f32 { let pos = self.positive.iter().any(|b| b.held(input)); let neg = self.negative.iter().any(|b| b.held(input)); match (pos, neg) { (true, false) => 1.0, (false, true) => -1.0, // Both held → mutual cancel; neither → idle. Same result. _ => 0.0, } } } /// A 2D axis composed of two [`AxisBinding`]s (X and Y). /// /// Output is the unmodified vector `(x.value, y.value)` — diagonals are /// `(±1, ±1)`, magnitude √2. Normalize at the call site if your game wants /// unit-length movement. #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Axis2DBinding { /// The X (right − left) axis. pub x: AxisBinding, /// The Y (up − down) axis. pub y: AxisBinding, } impl Axis2DBinding { /// A 2D axis from four direction binding lists in the usual order /// (`right`, `left`, `up`, `down`). pub fn new( right: impl IntoIterator, left: impl IntoIterator, up: impl IntoIterator, down: impl IntoIterator, ) -> Self { Self { x: AxisBinding::new(right, left), y: AxisBinding::new(up, down), } } /// Evaluates the axis against `input`, returning the raw `(x, y)` value /// without normalization. pub fn value(&self, input: &InputState) -> Vec2 { Vec2::new(self.x.value(input), self.y.value(input)) } } #[cfg(test)] mod tests { use super::*; use winit::keyboard::KeyCode; fn ad_axis() -> AxisBinding { AxisBinding::new([Binding::Key(KeyCode::KeyD)], [Binding::Key(KeyCode::KeyA)]) } #[test] fn idle_axis_is_zero() { let input = InputState::new(); assert_eq!(ad_axis().value(&input), 0.0); } #[test] fn positive_direction_returns_plus_one() { let mut input = InputState::new(); input.press_key(KeyCode::KeyD); assert_eq!(ad_axis().value(&input), 1.0); } #[test] fn negative_direction_returns_minus_one() { let mut input = InputState::new(); input.press_key(KeyCode::KeyA); assert_eq!(ad_axis().value(&input), -1.0); } #[test] fn both_directions_held_cancel_to_zero() { let mut input = InputState::new(); input.press_key(KeyCode::KeyA); input.press_key(KeyCode::KeyD); assert_eq!( ad_axis().value(&input), 0.0, "left+right held simultaneously must read as idle" ); } #[test] fn multi_bindings_on_same_direction_do_not_stack() { // WASD + arrow keys both contribute, but holding two positives is // still +1 (not +2). The axis is a directional indicator, not an // accumulator. let axis = AxisBinding::new( [ Binding::Key(KeyCode::KeyD), Binding::Key(KeyCode::ArrowRight), ], [ Binding::Key(KeyCode::KeyA), Binding::Key(KeyCode::ArrowLeft), ], ); let mut input = InputState::new(); input.press_key(KeyCode::KeyD); input.press_key(KeyCode::ArrowRight); assert_eq!(axis.value(&input), 1.0); } #[test] fn axis_2d_returns_vector_components_independently() { let axis = Axis2DBinding::new( [Binding::Key(KeyCode::KeyD)], [Binding::Key(KeyCode::KeyA)], [Binding::Key(KeyCode::KeyW)], [Binding::Key(KeyCode::KeyS)], ); let mut input = InputState::new(); input.press_key(KeyCode::KeyD); input.press_key(KeyCode::KeyW); assert_eq!(axis.value(&input), Vec2::new(1.0, 1.0)); input.release_key(KeyCode::KeyD); input.press_key(KeyCode::KeyA); // Now A + W held. assert_eq!(axis.value(&input), Vec2::new(-1.0, 1.0)); } #[test] fn axis_2d_diagonal_is_unnormalized() { // Diagonals are (±1, ±1) — caller normalizes if it cares. let axis = Axis2DBinding::new( [Binding::Key(KeyCode::KeyD)], [Binding::Key(KeyCode::KeyA)], [Binding::Key(KeyCode::KeyW)], [Binding::Key(KeyCode::KeyS)], ); let mut input = InputState::new(); input.press_key(KeyCode::KeyD); input.press_key(KeyCode::KeyW); let v = axis.value(&input); assert!( (v.length() - 2_f32.sqrt()).abs() < 1e-6, "diagonal must be sqrt(2), got {}", v.length() ); } #[test] fn axis_ron_round_trip() { let axis = Axis2DBinding::new( [Binding::Key(KeyCode::KeyD)], [Binding::Key(KeyCode::KeyA)], [Binding::Key(KeyCode::KeyW)], [Binding::Key(KeyCode::KeyS)], ); let s = ron::to_string(&axis).unwrap(); let parsed: Axis2DBinding = ron::from_str(&s).unwrap(); assert_eq!(parsed, axis); } }