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>
This commit is contained in:
+403
@@ -0,0 +1,403 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user