Files
Oxide/engine/src/input/axis.rs
T
Homer Simpson f56a1eea3b Import Oxide engine (Stages 0–10) under MIT license
Full project snapshot migrated to new Gitea remote without history:
engine, editor, physics, script, examples, tests, docs, and assets.
Relicensed from GPLv3 to MIT and updated repo URLs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:41:02 +02:00

213 lines
7.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! [`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<Binding>,
/// Bindings that pull the axis toward -1.
pub negative: Vec<Binding>,
}
impl AxisBinding {
/// A new axis with the given direction binding lists.
pub fn new(
positive: impl IntoIterator<Item = Binding>,
negative: impl IntoIterator<Item = Binding>,
) -> 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<Item = Binding>,
left: impl IntoIterator<Item = Binding>,
up: impl IntoIterator<Item = Binding>,
down: impl IntoIterator<Item = Binding>,
) -> 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);
}
}