← input · API reference · version 0.2.6
3 file(s), 716 lines. This is the source the published version ships, and it is loft — the library is written in the same language you call it from.
Public items
A worked example is a call site the author chose as the thing to read first. Call sites are every line naming the item, found mechanically over the source with comments excluded — so an item with no worked example is not an item nobody uses.
| Item | Defined | Worked example | Call sites |
|---|---|---|---|
MB_LEFT const | 53 | — | 101 104 106 112 113 115 … (15 total) |
MB_RIGHT const | 54 | — | 105 112 114 116 123 169 … (13 total) |
MB_MIDDLE const | 55 | — | none in this package |
ActionBinding struct | 60 | — | 78 34 35 130 159 53 … (9 total) |
ab_name const | 61 | — | 203 218 232 34 35 130 … (12 total) |
ab_keys const | 62 | — | 156 204 219 233 34 35 … (12 total) |
AxisBinding struct | 68 | — | 79 38 57 |
ax_name const | 69 | — | 247 38 57 |
ax_neg const | 70 | — | 163 164 248 38 57 139 |
ax_pos const | 71 | — | 166 167 249 38 57 137 |
Bindings struct | 77 | — | 91 106 130 32 128 157 … (9 total) |
bnd_actions const | 78 | — | 155 202 217 231 33 129 … (11 total) |
bnd_axes const | 79 | — | 162 246 37 132 161 56 … (8 total) |
InputState struct | 90 | — | 106 113 130 137 153 182 … (19 total) |
input_new fn | 106 | — | 41 134 60 |
input_set_bindings fn | 130 | INP-004 | 163 210 235 |
input_tick fn | 153 | INP-001 | none in this package |
input_tick_from_state fn | 181 | INP-001 | 48 54 60 66 78 82 … (33 total) |
is_action_pressed fn | 201 | INP-002 | 49 55 61 70 137 141 … (18 total) |
is_action_just_pressed fn | 216 | INP-001 | 50 56 62 71 138 145 … (14 total) |
is_action_just_released fn | 230 | INP-001 | 51 57 63 67 91 |
get_axis fn | 245 | INP-002 | 79 83 87 91 94 129 … (10 total) |
mouse_x fn | 259 | — | 102 171 |
mouse_y fn | 261 | — | 103 171 |
mouse_button_down fn | 266 | INP-003 | 104 105 113 114 121 168 … (9 total) |
mouse_button_just_pressed fn | 273 | INP-003 | 106 115 116 170 180 184 |
mouse_button_just_released fn | 281 | — | 122 123 189 191 |
mouse_wheel fn | 289 | — | 107 117 104 106 |
src/input.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// input — abstract input state + key bindings for loft games.
//
// (This library was parked from 2026-06-01 to 2026-06-04 on two loft
// compiler bugs — a cross-package constructor landing in CONST_STORE
// (loft#248) and a nested `&self` method call whose writes did not
// persist on the interpreter (loft#266). Both are fixed and the test
// gate is gone; the suite runs on the interpreter and `--native`.)
//
// Wraps the polled input primitives exposed by the `graphics` library
// (`gl_key_pressed`, `gl_mouse_x`/`gl_mouse_y`, `gl_mouse_button`,
// `gl_mouse_wheel`) into:
//
// - **Action bindings**: map a high-level action name (e.g. "jump")
// to one or more key codes — game code queries `is_action_pressed`
// instead of hardcoding `gl_key_pressed(KEY_SPACE)`.
// - **Edge detection**: `is_action_just_pressed` returns true on the
// exact tick a binding went from up to down (single-tick edge);
// same for `mouse_button_just_pressed`. Built by keeping
// last-frame state alongside this-frame state.
// - **Axis abstraction**: pair of keys → -1.0 / 0.0 / +1.0 (e.g.
// "horizontal" binds Left=-1 / Right=+1; query with `get_axis`).
//
// Per-tick contract:
//
// state.input_tick(); // poll graphics primitives
// if state.is_action_just_pressed("jump") { ... }
// vx = state.get_axis("horizontal");
//
// Headless / replay / test usage: `input_tick_from_state` advances
// the state from a caller-supplied snapshot instead of polling
// graphics — the same edge-detection logic runs on either input
// source, so tests don't need a real GL context.
//
// `input_tick` is the only function CI cannot exercise — it polls a live
// GL context. Everything else runs headless through
// `input_tick_from_state`, which takes the same snapshot a replay or a
// network rollback would feed it.
use graphics::*;
// Maximum key code the library tracks. Graphics's highest constant
// today is `KEY_F12 = 146`; 256 leaves room for future additions and
// matches the byte-indexable shape of a typical keyboard scan-code
// table. Bindings referencing a key code >= this are silently
// ignored at query time (a `pressed` query for an out-of-range
// binding returns false).
KEY_MAX = 256;
// Mouse-button bitmask values (mirrors `graphics::gl_mouse_button`).
pub const MB_LEFT = 1;
pub const MB_RIGHT = 2;
pub const MB_MIDDLE = 4;
// One named action: "jump" → [KEY_SPACE, KEY_UP] (any of the listed
// keys triggers the action). An empty `ab_keys` is degenerate but
// valid — the action will simply never fire.
pub struct ActionBinding {
pub const ab_name: text,
pub const ab_keys: vector<integer>
}
// One named axis: pair of (negative key, positive key) → -1.0 / 0.0
// / +1.0. If both are down simultaneously the axis reads 0.0 (they
// cancel) — same convention as Unity / Godot's built-in axis pairs.
pub struct AxisBinding {
pub const ax_name: text,
pub const ax_neg: integer,
pub const ax_pos: integer
}
// Full binding set — game code constructs this once and passes it
// into `input_new`. Re-binding at runtime: mutate this struct in
// place (or build a new one) and call `input_set_bindings`.
pub struct Bindings {
pub const bnd_actions: vector<ActionBinding>,
pub const bnd_axes: vector<AxisBinding>
}
// Abstract input state. Fields are documented but considered
// opaque — query via the `is_action_*` / `get_axis` / `mouse_*`
// functions below.
//
// `is_keys_now[i]` is true if key code `i` is currently down;
// `is_keys_prev[i]` holds the same flag from the previous tick so
// edge detection compares the two. Mouse buttons are a bitmask
// (`MB_LEFT | MB_RIGHT | …`).
pub struct InputState {
pub is_bindings: Bindings,
const is_keys_now: vector<boolean>,
const is_keys_prev: vector<boolean>,
is_mouse_x: float,
is_mouse_y: float,
is_mouse_buttons_now: integer,
is_mouse_buttons_prev: integer,
is_mouse_wheel: integer
}
// Construct an `InputState` from a `Bindings` set. Both key tables
// start fully released; the first call to `input_tick` populates
// `is_keys_now` (and `is_keys_prev` stays all-false, so any key down
// on the first tick fires its `just_pressed` edge — the documented
// "first frame counts as a transition" semantics).
pub fn input_new(b: Bindings) -> InputState {
keys_now: vector<boolean> = [];
keys_prev: vector<boolean> = [];
for _ in 0..KEY_MAX {
keys_now += [false];
keys_prev += [false];
}
InputState{
is_bindings: b,
is_keys_now: keys_now,
is_keys_prev: keys_prev,
is_mouse_x: 0.0,
is_mouse_y: 0.0,
is_mouse_buttons_now: 0,
is_mouse_buttons_prev: 0,
is_mouse_wheel: 0
}
}
// Replace the binding set on a live state (e.g. after the player
// remaps keys in a settings menu). Does NOT clear the key tables —
// in-flight key presses survive the rebind so a remap doesn't
// strand an already-held key.
// Example: @INP-004
pub fn input_set_bindings(self: &InputState, b: Bindings) {
self.is_bindings = b;
}
// Internal: shift `is_keys_now → is_keys_prev` and zero `is_keys_now`
// in preparation for the next poll. Same for mouse buttons. Public
// surface uses `input_tick` (or `input_tick_from_state` for tests).
fn advance_tick(self: &InputState) {
for i in 0..KEY_MAX {
self.is_keys_prev[i] = self.is_keys_now[i];
self.is_keys_now[i] = false;
}
self.is_mouse_buttons_prev = self.is_mouse_buttons_now;
self.is_mouse_buttons_now = 0;
self.is_mouse_wheel = 0;
}
// Poll the graphics input primitives and refresh state for one tick.
// Reads `gl_key_pressed` for every key code referenced by the
// current bindings (avoids a 256-call sweep — only ask about keys
// the game actually cares about), plus `gl_mouse_x`/`gl_mouse_y`/
// `gl_mouse_button`/`gl_mouse_wheel` unconditionally.
// Example: @INP-001
pub fn input_tick(self: &InputState) {
self.advance_tick();
for ab in self.is_bindings.bnd_actions {
for k in ab.ab_keys {
if k >= 0 && k < KEY_MAX {
self.is_keys_now[k] = graphics::gl_key_pressed(k);
}
}
}
for ax in self.is_bindings.bnd_axes {
if ax.ax_neg >= 0 && ax.ax_neg < KEY_MAX {
self.is_keys_now[ax.ax_neg] = graphics::gl_key_pressed(ax.ax_neg);
}
if ax.ax_pos >= 0 && ax.ax_pos < KEY_MAX {
self.is_keys_now[ax.ax_pos] = graphics::gl_key_pressed(ax.ax_pos);
}
}
self.is_mouse_x = graphics::gl_mouse_x();
self.is_mouse_y = graphics::gl_mouse_y();
self.is_mouse_buttons_now = graphics::gl_mouse_button();
self.is_mouse_wheel = graphics::gl_mouse_wheel();
}
// Advance state from a caller-supplied snapshot instead of polling
// `graphics`. Used by tests (no GL context) and by network replay
// / record-playback systems. `keys` length is clamped to KEY_MAX;
// out-of-range entries are ignored.
// Example: @INP-001
pub fn input_tick_from_state(
self: &InputState,
keys: const vector<boolean>,
mx: float, my: float,
mbtn: integer, wheel: integer
) {
self.advance_tick();
n = len(keys);
if n > KEY_MAX { n = KEY_MAX; }
for i in 0..n {
self.is_keys_now[i] = keys[i];
}
self.is_mouse_x = mx;
self.is_mouse_y = my;
self.is_mouse_buttons_now = mbtn;
self.is_mouse_wheel = wheel;
}
// True if ANY of the action's bound keys is currently down.
// Example: @INP-002
pub fn is_action_pressed(self: const InputState, action: text) -> boolean {
for ab in self.is_bindings.bnd_actions {
if ab.ab_name != action { continue; }
for k in ab.ab_keys {
if k >= 0 && k < KEY_MAX && self.is_keys_now[k] { return true; }
}
return false;
}
false
}
// True for exactly one tick: the moment any of the action's bound
// keys transitions from up to down. If multiple bound keys all
// transition on the same tick it still fires once.
// Example: @INP-001
pub fn is_action_just_pressed(self: const InputState, action: text) -> boolean {
for ab in self.is_bindings.bnd_actions {
if ab.ab_name != action { continue; }
for k in ab.ab_keys {
if k >= 0 && k < KEY_MAX && self.is_keys_now[k] && !self.is_keys_prev[k] { return true; }
}
return false;
}
false
}
// True for exactly one tick: the moment any of the action's bound
// keys transitions from down to up.
// Example: @INP-001
pub fn is_action_just_released(self: const InputState, action: text) -> boolean {
for ab in self.is_bindings.bnd_actions {
if ab.ab_name != action { continue; }
for k in ab.ab_keys {
if k >= 0 && k < KEY_MAX && !self.is_keys_now[k] && self.is_keys_prev[k] { return true; }
}
return false;
}
false
}
// Read an axis pair: -1.0 if only `ax_neg` is down, +1.0 if only
// `ax_pos` is down, 0.0 if neither or both. Unknown axis name
// returns 0.0.
// Example: @INP-002
pub fn get_axis(self: const InputState, axis: text) -> float {
for ax in self.is_bindings.bnd_axes {
if ax.ax_name != axis { continue; }
neg = ax.ax_neg >= 0 && ax.ax_neg < KEY_MAX && self.is_keys_now[ax.ax_neg];
pos = ax.ax_pos >= 0 && ax.ax_pos < KEY_MAX && self.is_keys_now[ax.ax_pos];
if neg && !pos { return -1.0; }
if pos && !neg { return 1.0; }
return 0.0;
}
0.0
}
// Mouse position — pixels from the top-left of the window
// (matches `graphics::gl_mouse_x` / `gl_mouse_y`).
pub fn mouse_x(self: const InputState) -> float { self.is_mouse_x }
// The pointer's y this frame, the companion of `mouse_x`.
pub fn mouse_y(self: const InputState) -> float { self.is_mouse_y }
// True if the given mouse button (`MB_LEFT` / `MB_RIGHT` / `MB_MIDDLE`)
// is currently down. Pass the constant, not a bitmask of multiple.
// Example: @INP-003
pub fn mouse_button_down(self: const InputState, button: integer) -> boolean {
(self.is_mouse_buttons_now & button) != 0
}
// True for exactly one tick — the moment the button transitions
// from up to down.
// Example: @INP-003
pub fn mouse_button_just_pressed(self: const InputState, button: integer) -> boolean {
now_down = (self.is_mouse_buttons_now & button) != 0;
prev_down = (self.is_mouse_buttons_prev & button) != 0;
now_down && !prev_down
}
// True for exactly one tick — the moment the button transitions
// from down to up.
pub fn mouse_button_just_released(self: const InputState, button: integer) -> boolean {
now_down = (self.is_mouse_buttons_now & button) != 0;
prev_down = (self.is_mouse_buttons_prev & button) != 0;
prev_down && !now_down
}
// Accumulated scroll-wheel delta since the last `input_tick` —
// positive = scroll up. Mirrors `graphics::gl_mouse_wheel`.
pub fn mouse_wheel(self: const InputState) -> integer { self.is_mouse_wheel }
tests/01-basics.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Smoke test for the input library. Drives state via
// `input_tick_from_state` (no GL context needed); validates:
// - construction from bindings
// - pressed / just_pressed / just_released for actions
// - axis pair direction + cancellation
// - mouse button bitmask + edge detection
// - unknown action / axis names return safe defaults
//
// Action coverage: every public symbol of the library is exercised
// at least once here.
use input::*;
KEY_SPACE = 32;
KEY_LEFT = 130;
KEY_RIGHT = 131;
KEY_A = 97;
// Build a snapshot of length KEY_MAX with `down` set on the given
// keys. Mirrors the shape `input_tick_from_state` expects.
fn snap(keys: vector<integer>) -> vector<boolean> {
v: vector<boolean> = [];
for _ in 0..256 { v += [false]; }
for k in keys { if k >= 0 && k < 256 { v[k] = true; } }
v
}
fn make_state() -> input::InputState {
b = input::Bindings{
bnd_actions: [
input::ActionBinding{ab_name: "jump", ab_keys: [KEY_SPACE]},
input::ActionBinding{ab_name: "use", ab_keys: [KEY_A]}
],
bnd_axes: [
input::AxisBinding{ax_name: "horizontal", ax_neg: KEY_LEFT, ax_pos: KEY_RIGHT}
]
};
input::input_new(b)
}
fn test_pressed_and_edges() {
s = make_state();
// Tick 1: SPACE down — pressed AND just_pressed (rises from idle).
s.input_tick_from_state(snap([KEY_SPACE]), 0.0, 0.0, 0, 0);
assert(s.is_action_pressed("jump"), "tick1: jump pressed");
assert(s.is_action_just_pressed("jump"), "tick1: jump just pressed");
assert(!s.is_action_just_released("jump"), "tick1: jump NOT just released");
// Tick 2: SPACE still down — pressed but no edge.
s.input_tick_from_state(snap([KEY_SPACE]), 0.0, 0.0, 0, 0);
assert(s.is_action_pressed("jump"), "tick2: jump still pressed");
assert(!s.is_action_just_pressed("jump"), "tick2: jump NOT just pressed");
assert(!s.is_action_just_released("jump"), "tick2: jump NOT just released");
// Tick 3: SPACE released — just_released fires once.
s.input_tick_from_state(snap([]), 0.0, 0.0, 0, 0);
assert(!s.is_action_pressed("jump"), "tick3: jump released");
assert(!s.is_action_just_pressed("jump"), "tick3: jump NOT just pressed");
assert(s.is_action_just_released("jump"), "tick3: jump just released");
// Tick 4: idle — no edges.
s.input_tick_from_state(snap([]), 0.0, 0.0, 0, 0);
assert(!s.is_action_just_released("jump"), "tick4: edge cleared");
// Unknown action name — all queries return safe defaults.
assert(!s.is_action_pressed("nope"), "unknown action !pressed");
assert(!s.is_action_just_pressed("nope"), "unknown action !just_pressed");
}
fn test_axis_pair() {
s = make_state();
// Neutral: zero.
s.input_tick_from_state(snap([]), 0.0, 0.0, 0, 0);
assert(s.get_axis("horizontal") == 0.0, "axis neutral=0");
// Right only: +1.
s.input_tick_from_state(snap([KEY_RIGHT]), 0.0, 0.0, 0, 0);
assert(s.get_axis("horizontal") == 1.0, "axis right=+1");
// Left only: -1.
s.input_tick_from_state(snap([KEY_LEFT]), 0.0, 0.0, 0, 0);
assert(s.get_axis("horizontal") == -1.0, "axis left=-1");
// Both: cancel to 0.
s.input_tick_from_state(snap([KEY_LEFT, KEY_RIGHT]), 0.0, 0.0, 0, 0);
assert(s.get_axis("horizontal") == 0.0, "axis both=cancel");
// Unknown axis name: 0.0.
assert(s.get_axis("vertical") == 0.0, "unknown axis=0");
}
fn test_mouse() {
s = make_state();
// Tick 1: left button down, mouse at (10, 20).
s.input_tick_from_state(snap([]), 10.0, 20.0, input::MB_LEFT, 3);
assert(s.mouse_x() == 10.0, "mouse_x");
assert(s.mouse_y() == 20.0, "mouse_y");
assert(s.mouse_button_down(input::MB_LEFT), "left button down");
assert(!s.mouse_button_down(input::MB_RIGHT), "right NOT down");
assert(s.mouse_button_just_pressed(input::MB_LEFT), "left just pressed");
assert(s.mouse_wheel() == 3, "wheel=+3");
// Tick 2: left still down + right also down — left edge cleared,
// right edge fires.
s.input_tick_from_state(snap([]), 10.0, 20.0,
input::MB_LEFT | input::MB_RIGHT, 0);
assert(s.mouse_button_down(input::MB_LEFT), "left still down");
assert(s.mouse_button_down(input::MB_RIGHT), "right now down");
assert(!s.mouse_button_just_pressed(input::MB_LEFT), "left edge cleared");
assert(s.mouse_button_just_pressed(input::MB_RIGHT), "right just pressed");
assert(s.mouse_wheel() == 0, "wheel resets per tick");
// Tick 3: all released — just_released fires for both.
s.input_tick_from_state(snap([]), 10.0, 20.0, 0, 0);
assert(!s.mouse_button_down(input::MB_LEFT), "left released");
assert(s.mouse_button_just_released(input::MB_LEFT), "left just released");
assert(s.mouse_button_just_released(input::MB_RIGHT), "right just released");
}
fn test_multi_key_action() {
// Action bound to MULTIPLE keys — pressing either fires it.
b = input::Bindings{
bnd_actions: [
input::ActionBinding{ab_name: "fire", ab_keys: [KEY_SPACE, KEY_A]}
],
bnd_axes: []
};
s = input::input_new(b);
s.input_tick_from_state(snap([KEY_A]), 0.0, 0.0, 0, 0);
assert(s.is_action_pressed("fire"), "fire via A");
assert(s.is_action_just_pressed("fire"), "fire edge via A");
s.input_tick_from_state(snap([KEY_SPACE]), 0.0, 0.0, 0, 0);
assert(s.is_action_pressed("fire"), "fire via SPACE");
// A went up, SPACE went down — both transitions in one tick. The
// SPACE-down transition fires `just_pressed` even though the
// action was held last tick (different key, different transition).
assert(s.is_action_just_pressed("fire"), "fire edge via SPACE");
}
fn test_rebind() {
s = make_state();
// SPACE down → jump fires.
s.input_tick_from_state(snap([KEY_SPACE]), 0.0, 0.0, 0, 0);
assert(s.is_action_pressed("jump"), "pre-rebind jump pressed");
// Rebind: "jump" now needs A instead. SPACE is still held but no
// longer counts.
new_b = input::Bindings{
bnd_actions: [
input::ActionBinding{ab_name: "jump", ab_keys: [KEY_A]}
],
bnd_axes: []
};
s.input_set_bindings(new_b);
// Re-tick at the SAME hardware state — SPACE no longer triggers.
s.input_tick_from_state(snap([KEY_SPACE]), 0.0, 0.0, 0, 0);
assert(!s.is_action_pressed("jump"), "post-rebind: SPACE no longer triggers");
s.input_tick_from_state(snap([KEY_A]), 0.0, 0.0, 0, 0);
assert(s.is_action_pressed("jump"), "post-rebind: A triggers");
}
fn main() {
test_pressed_and_edges();
test_axis_pair();
test_mouse();
test_multi_key_action();
test_rebind();
println("input lib: all checks passed");
}
tests/02-worked-examples.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// One test per non-obvious contract, and each one is that contract's
// worked EXAMPLE.
//
// ⚠⚠ **The pair is the deliverable.** A snippet in a doc comment rots
// silently — nothing compiles it, so it goes stale at the first
// signature change and the reader finds out. A test nobody links to is
// correct and invisible. So every function below carries an
// `// Example: @INP-0NN` line pointing here, and every tag here is
// reachable from the functions it demonstrates.
//
// ⚠ These are examples FIRST and assertions second: each is written the
// way a game's frame loop would actually write it. `01-basics` already
// pins that each query answers correctly; what these pin is the shape of
// a CORRECT CALL.
//
// Why this library owes them: **an edge is a difference between two
// ticks, and a name is a bare `text`.** Nothing in a signature says how
// often to tick, that a misspelt action is silently never pressed, that
// a combined button mask means "either", or that a rebind inherits the
// keys already held. Every one of those is a well-typed call answering
// the same `false` / `0.0` a correct one answers.
//
// Every example drives the state through `input_tick_from_state`, so
// they run headless — no GL context, and the same edge-detection code
// path a polled game takes.
use input::*;
// Key codes as `graphics` numbers them (`KEY_A` = 97, `KEY_SPACE` = 32,
// `KEY_LEFT` = 130, `KEY_RIGHT` = 131). A game reads them from
// `graphics`; the tests restate them so they need no GL build.
KEY_SPACE = 32;
KEY_LEFT = 130;
KEY_RIGHT = 131;
KEY_A = 97;
KEY_B = 98;
// One frame's worth of keyboard, as `input_tick_from_state` wants it:
// a flag per key code, index = code.
fn keys_down(kd_codes: vector<integer>) -> vector<boolean> {
kd_v: vector<boolean> = [];
for _ in 0..256 { kd_v += [false]; }
for kd_k in kd_codes { if kd_k >= 0 && kd_k < 256 { kd_v[kd_k] = true; } }
kd_v
}
// A small platformer's bindings: two actions and one axis.
fn platformer_state() -> input::InputState {
ps_b = input::Bindings{
bnd_actions: [
input::ActionBinding{ab_name: "jump", ab_keys: [KEY_SPACE]},
input::ActionBinding{ab_name: "use", ab_keys: [KEY_A]}
],
bnd_axes: [
input::AxisBinding{ax_name: "horizontal", ax_neg: KEY_LEFT, ax_pos: KEY_RIGHT}
]
};
input::input_new(ps_b)
}
// The frame loop, and the one rule it has to obey.
//
// ⚠⚠ **Tick exactly once per frame.** An edge is the difference between
// the tick's table and the previous tick's, so the TICK is what consumes
// it — not the query. Ask as many times as you like; tick twice in one
// frame and the press is gone, with nothing anywhere reporting it.
// @INP-001 — the edge lives between two ticks, so tick once per frame
fn test_a_frame_loop_ticks_once() {
fl_s = platformer_state();
// Frame 1 — the player is holding jump as the level starts. Both
// tables begin all-false, so the first tick counts as a transition:
// an already-held key fires its edge on frame one, by design.
fl_s.input_tick_from_state(keys_down([KEY_SPACE]), 0.0, 0.0, 0, 0);
assert(fl_s.is_action_just_pressed("jump"), "frame 1: the edge fires");
// ⚠ Queries are free and repeatable — two systems can both ask.
assert(fl_s.is_action_just_pressed("jump"), "a second reader lost the edge");
assert(fl_s.is_action_pressed("jump"), "held, as well as risen");
// Frame 2 — still held: pressed, but no edge. This is what makes
// `just_pressed` the right query for "fire once per press".
fl_s.input_tick_from_state(keys_down([KEY_SPACE]), 0.0, 0.0, 0, 0);
assert(fl_s.is_action_pressed("jump"), "frame 2: still held");
assert(!fl_s.is_action_just_pressed("jump"), "frame 2: the edge is spent");
// Frame 3 — released: the falling edge, once.
fl_s.input_tick_from_state(keys_down([]), 0.0, 0.0, 0, 0);
assert(fl_s.is_action_just_released("jump"), "frame 3: the release fires");
assert(!fl_s.is_action_pressed("jump"), "frame 3: no longer held");
// ⚠ The failure this door exists to prevent: a second tick inside one
// frame — a menu that polls, or a fixed-step loop that ticks input
// alongside every simulation step — hands the press to nobody.
fl_lost = platformer_state();
fl_lost.input_tick_from_state(keys_down([KEY_SPACE]), 0.0, 0.0, 0, 0);
fl_lost.input_tick_from_state(keys_down([KEY_SPACE]), 0.0, 0.0, 0, 0);
assert(!fl_lost.is_action_just_pressed("jump"),
"the second tick in one frame swallowed the press");
// The wheel is a per-tick delta and is zeroed the same way.
fl_lost.input_tick_from_state(keys_down([]), 0.0, 0.0, 0, 4);
assert(fl_lost.mouse_wheel() == 4, "wheel this tick");
fl_lost.input_tick_from_state(keys_down([]), 0.0, 0.0, 0, 0);
assert(fl_lost.mouse_wheel() == 0, "the wheel does not accumulate across ticks");
}
// Querying an action and an axis — and what a typo does.
//
// ⚠⚠ **A name is a bare `text` that is never declared anywhere.** An
// unknown action answers `false`, which is exactly what an action that
// is simply not pressed answers; an unknown axis answers `0.0`, which is
// exactly what a centred one answers. So a misspelt binding is not an
// error, it is a control that never works, for the life of the build.
// @INP-002 — every failure answers what success answers
fn test_a_misspelt_name_is_silent() {
sn_s = platformer_state();
sn_s.input_tick_from_state(keys_down([KEY_SPACE]), 0.0, 0.0, 0, 0);
assert(sn_s.is_action_pressed("jump"), "the bound name works");
// ⚠ These two lines are indistinguishable at the call site:
assert(!sn_s.is_action_pressed("jmup"), "a typo answers false");
assert(!sn_s.is_action_pressed("use"), "an unpressed action answers false");
assert(!sn_s.is_action_just_pressed("jmup"), "and so does its edge");
// ⚠ Same for an axis, where `0.0` carries three different meanings:
assert(sn_s.get_axis("horizonal") == 0.0, "unknown axis reads centred");
assert(sn_s.get_axis("horizontal") == 0.0, "neither key down reads centred");
sn_s.input_tick_from_state(keys_down([KEY_LEFT, KEY_RIGHT]), 0.0, 0.0, 0, 0);
assert(sn_s.get_axis("horizontal") == 0.0, "both keys down CANCEL to centred");
// ...so `get_axis(a) == 0.0` can never be read as "the player is not
// steering". The values that do mean something are the poles:
sn_s.input_tick_from_state(keys_down([KEY_RIGHT]), 0.0, 0.0, 0, 0);
assert(sn_s.get_axis("horizontal") == 1.0, "ax_pos is +1");
sn_s.input_tick_from_state(keys_down([KEY_LEFT]), 0.0, 0.0, 0, 0);
assert(sn_s.get_axis("horizontal") == -1.0, "ax_neg is -1");
// The check that does hold, if a typo must be caught: ask the bindings.
assert(has_action(sn_s, "jump"), "jump is bound");
assert(!has_action(sn_s, "jmup"), "jmup is not");
}
// The lookup the example above recommends when a name comes from config or a
// remap screen: the binding list is the only place a name exists.
fn has_action(ha_s: const input::InputState, ha_name: text) -> boolean {
for ha_ab in ha_s.is_bindings.bnd_actions {
if ha_ab.ab_name == ha_name { return true; }
}
false
}
// Reading mouse buttons.
//
// ⚠⚠ **A button argument is a MASK and the test is `& != 0`, so passing
// two bits asks "either", not "both".** `MB_LEFT | MB_RIGHT` is a
// well-typed integer that reads true while only the left is down — and
// its `just_pressed` then MISSES the right button entirely, because the
// mask was already non-zero. Pass one constant per call.
// @INP-003 — a combined button mask means "either", and hides an edge
fn test_one_button_constant_per_call() {
mb_s = platformer_state();
// Frame 1 — the left button goes down.
mb_s.input_tick_from_state(keys_down([]), 12.0, 34.0, input::MB_LEFT, 0);
assert(mb_s.mouse_button_down(input::MB_LEFT), "left is down");
assert(!mb_s.mouse_button_down(input::MB_RIGHT), "right is not");
assert(mb_s.mouse_button_just_pressed(input::MB_LEFT), "left's edge");
assert(mb_s.mouse_x() == 12.0 && mb_s.mouse_y() == 34.0, "pointer, top-left origin");
// ⚠ Only the left is down, and the combined mask says yes:
assert(mb_s.mouse_button_down(input::MB_LEFT | input::MB_RIGHT),
"a combined mask answers `either`, not `both`");
// Frame 2 — the right joins while the left stays down.
mb_s.input_tick_from_state(keys_down([]), 12.0, 34.0,
input::MB_LEFT | input::MB_RIGHT, 0);
assert(mb_s.mouse_button_just_pressed(input::MB_RIGHT),
"asked per button, the new press is seen");
// ⚠ ...and asked as a mask, it is not: the mask was already non-zero
// last tick, so `now && !prev` is false and the click is dropped.
assert(!mb_s.mouse_button_just_pressed(input::MB_LEFT | input::MB_RIGHT),
"the combined mask hid the right-button press");
// Frame 3 — the left lifts, the right stays.
mb_s.input_tick_from_state(keys_down([]), 12.0, 34.0, input::MB_RIGHT, 0);
assert(mb_s.mouse_button_just_released(input::MB_LEFT), "left's release");
assert(mb_s.mouse_button_down(input::MB_RIGHT), "right still held");
assert(!mb_s.mouse_button_just_released(input::MB_RIGHT), "right did not release");
}
// The settings screen: the player remaps a control mid-game.
//
// ⚠⚠ **`input_set_bindings` swaps the names, never the key tables.**
// That is what keeps a key held across a remap from being stranded —
// and it means the new binding INHERITS both tables, so an action bound
// onto an already-held key reads pressed at once, and fires a
// `just_pressed` edge the player never made for it.
// @INP-004 — a rebind inherits the keys already held, edges included
fn test_a_remap_inherits_the_held_keys() {
rb_s = platformer_state();
// The player is holding A, which is "use".
rb_s.input_tick_from_state(keys_down([KEY_A]), 0.0, 0.0, 0, 0);
assert(rb_s.is_action_just_pressed("use"), "A rose for `use`");
// They remap "jump" onto A while still holding it.
rb_s.input_set_bindings(input::Bindings{
bnd_actions: [input::ActionBinding{ab_name: "jump", ab_keys: [KEY_A]}],
bnd_axes: []
});
// ⚠ `jump` is held immediately — the intended half: a key down across
// the remap is not stranded until the player lets go and presses again.
assert(rb_s.is_action_pressed("jump"), "the held key carries into the new binding");
// ⚠ ...and the unintended half: `jump` also fires its rising edge on
// this tick, off a press aimed at `use`. Apply a remap on a frame
// where the key is up, or absorb one frame of edges after applying it.
assert(rb_s.is_action_just_pressed("jump"), "the new binding inherited the edge");
// The next tick with A still down settles it: held, no edge.
rb_s.input_tick_from_state(keys_down([KEY_A]), 0.0, 0.0, 0, 0);
assert(rb_s.is_action_pressed("jump") && !rb_s.is_action_just_pressed("jump"),
"one frame later the inherited edge is spent");
// ⚠ How complete the inherited table is depends on WHICH tick door ran.
// `input_tick_from_state` copies the whole snapshot, so even a key no
// binding mentions is recorded and a rebind onto it reads pressed at
// once. `input_tick` polls only the keys the bindings name, so the
// same rebind reads false there until the next tick refreshes it.
rb_u = platformer_state();
rb_u.input_tick_from_state(keys_down([KEY_B]), 0.0, 0.0, 0, 0); // B binds to nothing
rb_u.input_set_bindings(input::Bindings{
bnd_actions: [input::ActionBinding{ab_name: "crouch", ab_keys: [KEY_B]}],
bnd_axes: []
});
assert(rb_u.is_action_pressed("crouch"), "the snapshot door recorded the unbound key");
}
fn main() {
test_a_frame_loop_ticks_once();
test_a_misspelt_name_is_silent();
test_one_button_constant_per_call();
test_a_remap_inherits_the_held_keys();
println("input worked examples passed");
}