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:
Homer Simpson
2026-07-05 20:41:02 +02:00
parent 2afb56b329
commit 9eead719b0
157 changed files with 47270 additions and 2 deletions
+403
View File
@@ -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.