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

15 KiB
Raw Blame History

Input

Stage 7 reference for oxide_engine::input — the engine's input abstraction. Three layers live here today:

  • Piece 1: InputState — the raw per-frame snapshot (keyboard / mouse / cursor / scroll, with edge detection).
  • Piece 2: [Binding] + ActionMap — 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 inputs composed from Binding direction sets (e.g. WASDVec2 "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 vocabulary the runner pumps from, see 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 loop owns one InputState and:

  1. Pumps every incoming WindowEvent 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

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.

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 WindowEvents — 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 crate.

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

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

# 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 rustdoc for the precise definition.)

Querying an unregistered action returns false everywhere — never a panic — so typo'd action names are graceful.

Runtime remap

# 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:

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 Bindings 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.

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.

# 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:

# 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.