Files
Oxide/docs/input.md
T
Homer Simpson 9eead719b0 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

404 lines
15 KiB
Markdown
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.
# Input
Stage 7 reference for `oxide_engine::input` — the engine's input
abstraction. Three layers live here today:
- **Piece 1: [`InputState`](#raw-input-state)** — the raw per-frame
snapshot (keyboard / mouse / cursor / scroll, with edge detection).
- **Piece 2: [`Binding`] + [`ActionMap`](#named-actions-and-remapping)** —
named actions (e.g. `"Jump"`) bound to one or more physical inputs, with
defaults, runtime remapping, and RON-persistable user overrides.
- **Piece 3: [`AxisBinding`] + [`Axis2DBinding`](#directional-axes)** —
directional inputs composed from `Binding` direction sets (e.g. `WASD`
`Vec2 "Move"`), stored alongside button actions in the same `ActionMap`
and persisted through the same `ActionOverrides` payload.
The editor pieces (flythrough camera, bindings preferences page, transform
gizmos) layer on top of these.
For the raw [`WindowEvent`](windowing.md) vocabulary the runner pumps from,
see [windowing.md](windowing.md).
## Raw input state
### Why a separate layer
Game code wants three distinct things from a physical key:
- **The press edge** — fires *once* on the frame a key first goes down. A
jump fires here.
- **The release edge** — fires *once* on the frame a key comes back up. A
charged shot fires here.
- **The held state** — true every frame between press and release. A sprint
modifier reads this.
Reading these straight off `WindowEvent::KeyboardInput` is doable but error-
prone: OS key auto-repeat re-sends `Pressed` on every repeat, focus loss can
leave keys "held" with no matching release, and a `CursorMoved` carries no
delta unless the consumer remembers the previous position. `InputState`
solves all of that in one place, and its semantics are unit-tested.
## How the runner uses it
The windowing [`run`](windowing.md) loop owns one `InputState` and:
1. Pumps every incoming [`WindowEvent`](windowing.md) into it via
`InputState::handle_event` **before** any callback sees the event, so
`ctx.input()` in `WindowApp::event` already reflects the event being
delivered.
2. Calls `WindowApp::update` — game logic reads `ctx.input()` to query the
accumulated state for the frame.
3. After `update` returns, calls `InputState::end_frame` to roll edges and
per-frame deltas off. Held state and the cursor anchor persist.
The result: in `update`, edges describe what happened "since the previous
frame" and held state is "right now".
## Reading input from a `WindowApp`
```rust
use oxide_engine::prelude::*;
use oxide_engine::winit::event::MouseButton;
use oxide_engine::winit::keyboard::KeyCode;
#[derive(Default)]
struct MyApp;
impl WindowApp for MyApp {
fn update(&mut self, ctx: &mut AppCtx<'_>) {
let input = ctx.input();
if input.pressed(KeyCode::Space) {
// Fires once, on the frame Space went down.
}
if input.held(KeyCode::ShiftLeft) {
// True every frame Shift is down.
}
if input.released(KeyCode::Escape) {
ctx.request_exit();
}
// Right-drag pans by the mouse delta accumulated this frame.
if input.mouse_held(MouseButton::Right) {
let _delta = input.mouse_delta(); // physical pixels
}
// Scroll is in line-equivalent units (touchpad pixels are normalized
// so wheels and trackpads report on the same scale).
let _zoom_amount = input.scroll().y;
}
}
```
## Edge semantics, in detail
`InputState` keeps three sets per device (held / pressed / released) and
applies these rules:
- `press_key(k)` — if `k` was **not** already held, both `held` and
`pressed` add it. If it was already held (OS auto-repeat), `pressed` is
unchanged. The one-shot press edge fires exactly once per real keypress.
- `release_key(k)``held` removes `k`; `released` adds `k`. The release
edge fires whether or not the key was previously tracked as held, so the
occasional "release without matching press" the OS delivers (focus
changes, alt-tab) still produces a usable signal.
- `end_frame()` — clears `pressed` and `released` (and the per-frame mouse
delta + scroll). `held` and the cursor anchor are untouched.
- `WindowEvent::Focused(false)` — every currently-held key and mouse button
is force-released (released-edge fires for each), so a key held when the
user alt-tabbed away cannot remain stuck after the window comes back.
Mouse buttons mirror the keyboard rules exactly. Cursor + delta and scroll
use the same end-of-frame reset.
## Cursor and mouse delta
Cursor position is stored as physical pixels relative to the window. The
**delta** is the sum of the segment vectors between `set_cursor` calls
*within the frame*, not the gross displacement from the first event. The
first `set_cursor` after construction (or after `forget_cursor` /
`WindowEvent::CursorLeft`) seeds the anchor without contributing to the
delta — so the first frame the cursor enters the window never produces a
phantom jump.
```text
Frame 1: cursor enters at (100, 100) → delta = (0, 0)
Frame 2: moves (100,100)→(105,98)→(108,95) → delta = (8, -5)
Frame 3: no movement → delta = (0, 0)
(cursor still at (108, 95))
```
`add_mouse_delta(dx, dy)` exists for relative-motion sources that don't
go through `CursorMoved` (a future `DeviceEvent::MouseMotion` pump, a
pointer-lock toggle, or a synthesized test). It layers on top of the
cursor-based delta.
## Scroll
Scroll is reported in **line-equivalent units**: wheel notches arrive as
`LineDelta` and pass through unchanged; trackpad pixel deltas are divided
by a fixed pixels-per-line constant (40) so a touchpad gesture and a wheel
notch produce comparable numbers.
## Testing inputs directly
The mutator API (`press_key`, `release_mouse`, `set_cursor`,
`add_mouse_delta`, `add_scroll`, `forget_cursor`, `release_all_held`) is
the same path `handle_event` uses, and is intentionally public. Tests
should call it directly rather than try to fabricate `WindowEvent`s —
winit 0.30's `DeviceId` cannot be constructed outside a real event loop,
so most input variants are unreachable from synthesized events. The
mutators are unit-tested and exercised end-to-end by `stage7` integration
tests in the [`tests`](../tests) crate.
```rust
use oxide_engine::prelude::*;
use oxide_engine::winit::keyboard::KeyCode;
let mut input = InputState::new();
input.press_key(KeyCode::Space);
assert!(input.pressed(KeyCode::Space));
assert!(input.held(KeyCode::Space));
input.end_frame();
assert!(!input.pressed(KeyCode::Space));
assert!(input.held(KeyCode::Space));
```
## Named actions and remapping
`InputState` answers "is `KeyCode::Space` down?". Game code shouldn't ask
that question: physical keys are user-settings territory, and querying
them directly couples gameplay to a fixed keyboard layout. `ActionMap`
adds the indirection — game code asks "is `\"Jump\"` engaged?" and the
map resolves it to whatever the user (or the program's default) has
bound.
### The data model
An action carries two binding lists:
- **`defaults`** — the bindings registered from code at startup. They
never change at runtime.
- **`current`** — the bindings actually queried each frame. Initially a
clone of `defaults`; remapped by the settings screen; restored by the
"Restore defaults" button.
Persistence saves only `current`. On reload, the program first registers
actions from code (defaults reappear from source), then applies the saved
overrides on top. Actions that vanished from code never break an old
settings file — they're silently skipped.
### Setting up actions
```rust
use oxide_engine::prelude::*;
use oxide_engine::winit::event::MouseButton;
use oxide_engine::winit::keyboard::KeyCode;
let mut actions = ActionMap::new();
actions
.register("Jump", [Binding::Key(KeyCode::Space)])
.register(
"Sprint",
[
Binding::Key(KeyCode::ShiftLeft),
Binding::Key(KeyCode::ShiftRight),
],
)
.register("Fire", [Binding::Mouse(MouseButton::Left)]);
```
Multi-bind on either axis is supported: an action can list several
bindings (the `Sprint` example), and one physical key can drive several
actions (e.g. `Space` → both `"Jump"` and `"Confirm"`).
### Querying actions
```rust
# use oxide_engine::prelude::*;
# use oxide_engine::winit::keyboard::KeyCode;
# let mut actions = ActionMap::new();
# actions.register("Jump", [Binding::Key(KeyCode::Space)]);
# let input = InputState::new();
if actions.action_pressed("Jump", &input) {
// Fires once, on the frame Jump becomes engaged.
}
if actions.action_held("Jump", &input) {
// True every frame Jump is engaged (at least one binding held).
}
if actions.action_released("Jump", &input) {
// Fires once, when the last engaged binding releases.
}
```
Action edges have **hysteresis at the action level**, not the binding
level: pressing a second binding while the action is already engaged does
not retrigger `action_pressed`, and releasing one binding while another
is still held does not fire `action_released`. The edge fires only on
the action's transition between engaged and disengaged. (See the
[`action_pressed`](../engine/src/input/action.rs) rustdoc for the
precise definition.)
Querying an unregistered action returns `false` everywhere — never a
panic — so typo'd action names are graceful.
### Runtime remap
```rust
# use oxide_engine::prelude::*;
# use oxide_engine::winit::keyboard::KeyCode;
# let mut actions = ActionMap::new();
# actions.register("Jump", [Binding::Key(KeyCode::Space)]);
// A bindings preferences page calls these — game code is untouched.
actions.set_bindings("Jump", vec![Binding::Key(KeyCode::KeyW)]);
actions.add_binding("Jump", Binding::Key(KeyCode::Space)); // restore as alt
actions.remove_binding("Jump", Binding::Key(KeyCode::KeyW));
actions.clear_bindings("Jump"); // make Jump temporarily unbindable
actions.restore_defaults("Jump"); // ↩ user's defaults
actions.restore_all_defaults(); // ↩ everything
```
### Persistence via the Stage-6 settings framework
`ActionOverrides` is the serializable projection of an `ActionMap`'s
current bindings, and it derives `Default + Serialize + Deserialize` so
it plugs straight into `Settings::register::<ActionOverrides>(name)`
no framework code changes needed. The whole cycle:
```rust
use oxide_engine::prelude::*;
use oxide_engine::winit::keyboard::KeyCode;
// One-time setup at startup.
let mut actions = ActionMap::new();
actions.register("Jump", [Binding::Key(KeyCode::Space)]);
let mut settings = Settings::new();
settings.register::<ActionOverrides>("input.bindings");
// User remap → write into Settings → export to disk.
actions.set_bindings("Jump", vec![Binding::Key(KeyCode::KeyW)]);
*settings.get_mut::<ActionOverrides>("input.bindings").unwrap() =
actions.overrides();
let on_disk = settings.export(); // RON map, persist however you like
// On the next launch, after re-registering defaults from code:
settings.import(&on_disk);
actions.apply_overrides(settings.get::<ActionOverrides>("input.bindings").unwrap());
// Jump is now bound to W again.
```
The `apply_overrides` step is order-independent with respect to which
actions the file knows about: unknown names are skipped, and registered
actions absent from the file keep their defaults.
## Directional axes
Buttons answer "is this engaged?". Movement and camera control want a
**direction with magnitude**. `AxisBinding` (1D, returns `f32`) and
`Axis2DBinding` (2D, returns `Vec2`) compose direction sets of
`Binding`s into those values. They live in the same [`ActionMap`] as
button actions but in **separate name spaces**, so `"Move"` can be a 2D
axis and `"MoveSlower"` a button without conflict — and a `"Move"`
button can coexist with a `"Move"` axis if a project wants it to.
### 1D axes
An `AxisBinding` is a pair of binding sets (one for the +1 direction,
one for 1). Any binding held on a side contributes a full unit; if
both sides are held simultaneously they cancel to 0 — a "soft brake"
the player gets for free.
```rust
use oxide_engine::prelude::*;
use oxide_engine::winit::keyboard::KeyCode;
let mut actions = ActionMap::new();
actions.register_axis(
"MoveX",
AxisBinding::new(
[Binding::Key(KeyCode::KeyD)],
[Binding::Key(KeyCode::KeyA)],
),
);
let mut input = InputState::new();
input.press_key(KeyCode::KeyD);
assert_eq!(actions.axis("MoveX", &input), 1.0);
```
Multiple bindings on the same direction do **not** stack (`D` and `→`
both held still reads as `1.0`, not `2.0`) — the axis reports
direction, not accumulated input.
### 2D axes
`Axis2DBinding::new(right, left, up, down)` composes four direction
sets into a `Vec2`. Diagonals are intentionally **not normalized** — a
game that wants unit-length movement normalizes at the call site; a
game that wants diagonal-faster gets it for free. The cancel-on-both
rule applies independently on each axis.
```rust
# use oxide_engine::prelude::*;
# use oxide_engine::winit::keyboard::KeyCode;
let mut actions = ActionMap::new();
actions.register_axis_2d(
"Move",
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::KeyW);
input.press_key(KeyCode::KeyA);
let v = actions.axis_2d("Move", &input);
assert_eq!(v, oxide_engine::math::Vec2::new(-1.0, 1.0));
// If you want unit-length: `if v != Vec2::ZERO { v.normalize() } else { v }`
```
### Remap, restore, and persistence
`set_axis_bindings` / `set_axis_2d_bindings` swap the current bindings
without renaming the action. `restore_axis_defaults` /
`restore_axis_2d_defaults` revert to the code-defined bindings.
`restore_all_defaults` covers every action across all three kinds in
one call.
[`ActionOverrides`] carries axis overrides alongside button overrides
in three sub-maps. The settings round-trip is identical to the button
case — `ActionOverrides` is the same settings-section type:
```rust
# use oxide_engine::prelude::*;
# let mut actions = ActionMap::new();
# let mut settings = Settings::new();
settings.register::<ActionOverrides>("input.bindings");
*settings.get_mut::<ActionOverrides>("input.bindings").unwrap() =
actions.overrides();
let on_disk = settings.export(); // axes, axes_2d, and buttons all persist
```
Older settings files written before axes existed (i.e. with no `axes` or
`axes_2d` field in the RON) load cleanly — the missing sub-maps
deserialize as empty, and registered axes keep their code-defined
defaults.
## Status and what's next
- **Piece 1 (✅).** Raw `InputState` + runner integration.
- **Piece 2 (✅).** Named button action mapping with defaults, multi-bind
in either direction, runtime remap, and RON persistence through the
Stage-6 settings framework.
- **Piece 3 (✅ — this section).** 1D and 2D directional axes composed
from `Binding` direction sets, sharing `ActionMap` storage and the
same `ActionOverrides` persistence payload.
- **Editor pieces (planned).** Flythrough camera using the action map,
bindings page in Preferences contributed via the Stage-6 extension API,
transform gizmos.