feat(input): #83 Input Manager + auto-commit the device layer in the frame loop
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 22s
ci / build-and-test (push) Successful in 2m18s
commit-lint / conventional-commits (push) Successful in 5s
docs / build-and-deploy (push) Successful in 28s

Fixes the papercut: the generated frame loop called rt_poll() (feeding only
Input.key) but never input_poll(), so Input.active/key_down/mouse/pad read
empty unless the game called Input.poll() by hand. The loop now calls
input_poll() when the game uses any Input runtime method — committing the
held-key/mouse/gamepad state, and record/replay — and stores its return as the
frame key so Input.key still works. A game using no Input runtime keeps the
plain rt_poll path, byte-identical.

Adds the Input-Manager API: Input.action(name,key) ships a default binding
(kept if already bound, so a rebind/loaded map isn't clobbered),
Input.bind_pad(name,button) makes an action device-agnostic (keyboard OR pad),
and Input.active/just_pressed/just_released read the multi-key device layer
with clean on-press/on-release edges (deterministic, dispatch-free — a handler
polls the edge; a replay fires identically).

Examples input_manager + input_auto, 5 docs pages. Full suite 107/0, goldens
byte-identical, fixpoint holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-02 07:04:10 +03:00
parent 6a83c28e05
commit bccd26fb29
14 changed files with 31773 additions and 31127 deletions

View file

@ -26,6 +26,7 @@ const INPUT_REC_CAP: int = 8192 # recordable frames
var input_names: pointers = null # action name per slot (0..input_nact)
var input_keys: words = null # INPUT_MAX_ACT * INPUT_MAX_KEYS key codes (0 = empty)
var input_pads: words = null # INPUT_MAX_ACT * INPUT_MAX_KEYS pad buttons, stored +1 (0 = empty) — #83
var input_nact: int = 0
var input_frame: int = 0 # the key polled this frame
@ -40,6 +41,7 @@ function input_init() -> void {
if input_names == null {
input_names = bytes(INPUT_MAX_ACT * 8) # a pointer (8 bytes) per action slot
input_keys = words(INPUT_MAX_ACT * INPUT_MAX_KEYS)
input_pads = words(INPUT_MAX_ACT * INPUT_MAX_KEYS) # #83 pad buttons per action (+1 encoded)
}
}
@ -82,6 +84,38 @@ function input_bind(name: pointer, key: int) -> void {
}
}
# #83 — a *default* binding: bind `key` only if the action has no key bound yet.
# A game ships its defaults with Input.action in Boot; a player's later Input.rebind
# (or a loaded key-map) is not clobbered, and re-running the defaults is idempotent.
function input_default(name: pointer, key: int) -> void {
let s = input_slot(name)
let base = s * INPUT_MAX_KEYS
var i = 0
while i < INPUT_MAX_KEYS {
if input_keys[base + i] != 0 { return } # already has a binding — keep it
i = i + 1
}
input_keys[base] = key
}
# #83 — device-agnostic actions: also fire the named action from a gamepad button.
# Buttons are stored +1 so 0 stays the empty marker. The same action can carry both
# keyboard keys (input_bind / input_default) and pad buttons; a read fires on either.
function input_bind_pad(name: pointer, button: int) -> void {
let s = input_slot(name)
let base = s * INPUT_MAX_KEYS
var i = 0
while i < INPUT_MAX_KEYS {
if input_pads[base + i] == (button + 1) { return } # already bound
i = i + 1
}
i = 0
while i < INPUT_MAX_KEYS {
if input_pads[base + i] == 0 { input_pads[base + i] = button + 1; return }
i = i + 1
}
}
# runtime rebinding: replace `oldkey` with `newkey` on the named action. A no-op
# if the action or the old key is not found.
function input_rebind(name: pointer, oldkey: int, newkey: int) -> void {
@ -173,6 +207,7 @@ var in_wheel: int = 0 # wheel delta this frame
# gamepads: connected flag, button bitmask, and IN_AXES fixed axes each
var in_pad_conn: words = null # IN_PADS
var in_pad_btn: words = null # IN_PADS
var in_pad_btn0: words = null # IN_PADS — pad button mask last frame (edges) — #83
var in_pad_axis: words = null # IN_PADS * IN_AXES (fixed)
# touch points: active flag, x, y each
var in_touch_on: words = null # IN_TOUCH
@ -189,6 +224,7 @@ function in_init() -> void {
in_prev = words(IN_WORDS)
in_pad_conn = words(IN_PADS)
in_pad_btn = words(IN_PADS)
in_pad_btn0 = words(IN_PADS)
in_pad_axis = words(IN_PADS * IN_AXES)
in_touch_on = words(IN_TOUCH)
in_touch_x = words(IN_TOUCH)
@ -217,6 +253,11 @@ function in_set_or(dst: words, a: words, b: words) -> void { var i = 0; while i
function input_device_commit(k: int, replaying: int) -> void {
in_init()
in_set_copy(in_prev, in_held) # last frame's committed set
# #83: snapshot last frame's pad-button masks for the just_pressed/released edges.
# Taken before the platform refresh (win_pad, below, runs after this), so it holds
# the previous frame's committed value against which this frame's edge is measured.
var pj = 0
while pj < IN_PADS { in_pad_btn0[pj] = in_pad_btn[pj]; pj = pj + 1 }
if replaying == 1 {
# rebuild the platform set + mouse from the tape; sim/injection is ignored so
@ -401,6 +442,45 @@ function input_down(name: pointer) -> bool {
return input_slot_has(input_find(name), input_frame)
}
# #83 — the Input-Manager reads: an action is *active* when any of its bound
# keyboard keys is in the multi-key device held-set OR any of its bound pad buttons
# is down on pad 0. Unlike input_down (which reads the single per-frame key), these
# see the whole device layer (hold left AND jump), and are device-agnostic. The
# frame loop now commits the device layer automatically (input_poll), so these read
# live without the game calling Input.poll by hand.
function input_active_in(name: pointer, held: words, padmask: int) -> bool {
let s = input_find(name)
if s < 0 { return false }
let base = s * INPUT_MAX_KEYS
var i = 0
while i < INPUT_MAX_KEYS {
let k = input_keys[base + i]
if (k != 0) and in_bit_get(held, k) { return true }
let pb = input_pads[base + i]
if (pb != 0) and ((padmask & (1 << (pb - 1))) != 0) { return true }
i = i + 1
}
return false
}
function input_active(name: pointer) -> bool {
in_init()
return input_active_in(name, in_held, in_pad_btn[0])
}
# went active this frame (active now, not last frame) — the deterministic on-press.
function input_just_pressed(name: pointer) -> bool {
in_init()
let now = input_active_in(name, in_held, in_pad_btn[0])
let was = input_active_in(name, in_prev, in_pad_btn0[0])
return now and (not was)
}
# went inactive this frame (not active now, was last frame) — the on-release.
function input_just_released(name: pointer) -> bool {
in_init()
let now = input_active_in(name, in_held, in_pad_btn[0])
let was = input_active_in(name, in_prev, in_pad_btn0[0])
return (not now) and was
}
# did the named action go down this frame (down now, not down last frame)?
function input_pressed(name: pointer) -> bool {
let s = input_find(name)