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>
15 KiB
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 fromBindingdirection sets (e.g.WASD→Vec2 "Move"), stored alongside button actions in the sameActionMapand persisted through the sameActionOverridespayload.
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:
- Pumps every incoming
WindowEventinto it viaInputState::handle_eventbefore any callback sees the event, soctx.input()inWindowApp::eventalready reflects the event being delivered. - Calls
WindowApp::update— game logic readsctx.input()to query the accumulated state for the frame. - After
updatereturns, callsInputState::end_frameto 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)— ifkwas not already held, bothheldandpressedadd it. If it was already held (OS auto-repeat),pressedis unchanged. The one-shot press edge fires exactly once per real keypress.release_key(k)—heldremovesk;releasedaddsk. 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()— clearspressedandreleased(and the per-frame mouse delta + scroll).heldand 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 ofdefaults; 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
Bindingdirection sets, sharingActionMapstorage and the sameActionOverridespersistence payload. - Editor pieces (planned). Flythrough camera using the action map, bindings page in Preferences contributed via the Stage-6 extension API, transform gizmos.