ludic/runtime/native/input.ludic
Orkuncakilkaya 3eb5447f74
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 22s
ci / build-and-test (push) Successful in 2m24s
commit-lint / conventional-commits (push) Successful in 5s
docs / build-and-deploy (push) Successful in 29s
feat(input): #89 cursor capture — Input.cursor_mode (hide/lock/confine)
A windowed action game can hide the OS cursor and lock/confine the mouse to the
window. Input.cursor_mode(mode): 0 normal, 1 hidden (draw your own reticle),
2 locked (hidden + dissociated — the mouse feeds relative motion via
Input.mouse_dx/dy and Input.mouse_x/y is a clamped virtual cursor, FPS/twin-stick
aim), 3 confined (dissociated but visible; the mouse can't leave the window).
The platform auto-releases (shows + reconnects) while the window is not key
(Cmd-Tab) and on close, so the cursor is never left captured. Headless it is a
no-op (DCE'd).

Native macOS impl in cocoa.ll: [NSCursor hide]/[unhide] (ref-counted, toggled
only on change so the count stays balanced across focus changes),
CGAssociateMouseAndMouseCursorPosition, and CGGetLastMouseDelta for the relative
virtual cursor, behind a new win_cursor_mode intrinsic. Windowed-only behaviour
(not in the headless golden suite); example compiles headless and links
windowed. Full suite 115/0, fixpoint holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 08:06:34 +03:00

548 lines
23 KiB
Text

# ============================================================================
# input.ludic — action maps + deterministic input recording/replay (#7).
#
# The raw platform gives one key per frame (Input.key / rt_poll). This layer
# adds the two ideas the input proposal leads with:
#
# * Action maps — gameplay reads *named actions*, not physical keys, so a key
# is rebindable at runtime and a scheme is data. Bind with Input.bind, read
# with Input.down / Input.pressed, remap with Input.rebind.
# * Deterministic record/replay — because the sim is deterministic in its input
# stream, snapshotting the per-frame key and feeding it back reproduces a run
# exactly (free replays, the seed of lockstep netcode). Input.poll is the one
# call that advances a frame of input; it reads the live key, records it, or
# replays a recorded one depending on the mode — "read input" and "read a
# recorded snapshot" are the same call, as the proposal asks.
#
# All integer and deterministic. The device layer the proposal also sketches —
# multiple simultaneous keys, gamepads, touch, analog axes/vectors — needs a
# platform key-state backend and is tracked separately; this layer stands on the
# single-key poll every target already provides.
# ============================================================================
const INPUT_MAX_ACT: int = 32 # named actions
const INPUT_MAX_KEYS: int = 4 # physical keys bound per action
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
var input_last: int = 0 # the key polled last frame (for edges)
var input_mode: int = 0 # 0 = live, 1 = record, 2 = replay
var input_rec: words = null # recorded key per frame
var input_recn: int = 0 # frames recorded
var input_pos: int = 0 # replay / record cursor
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)
}
}
# slot of the action `name`, or -1. Names compare by byte-string equality.
function input_find(name: pointer) -> int {
input_init()
var i = 0
while i < input_nact {
if input_names[i] == name { return i }
i = i + 1
}
return 0 - 1
}
# get-or-create the slot for `name`.
function input_slot(name: pointer) -> int {
let f = input_find(name)
if f >= 0 { return f }
if input_nact >= INPUT_MAX_ACT { return INPUT_MAX_ACT - 1 } # silently reuse the last slot when full
let s = input_nact
input_names[s] = name
input_nact = input_nact + 1
return s
}
# bind physical `key` to the named action, creating the action if new. A key
# already bound to the action is left as-is (idempotent).
function input_bind(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] == key { return } # already bound
i = i + 1
}
i = 0
while i < INPUT_MAX_KEYS {
if input_keys[base + i] == 0 { input_keys[base + i] = key; return }
i = i + 1
}
}
# #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 {
let s = input_find(name)
if s < 0 { return }
let base = s * INPUT_MAX_KEYS
var i = 0
while i < INPUT_MAX_KEYS {
if input_keys[base + i] == oldkey { input_keys[base + i] = newkey; return }
i = i + 1
}
}
# does key `k` (0 = none) fire the action in slot `s`?
function input_slot_has(s: int, k: int) -> bool {
if s < 0 { return false }
if k == 0 { return false }
let base = s * INPUT_MAX_KEYS
var i = 0
while i < INPUT_MAX_KEYS {
if input_keys[base + i] == k { return true }
i = i + 1
}
return false
}
# #87 — a frame-loop game now has its device layer committed automatically by the
# generated loop (which calls input_drive once per frame). in_have_frame_driver
# records that a loop is driving input, so a *manual* Input.poll in a handler
# becomes a no-op instead of committing a second time in the same frame — a double
# commit copied in_held into in_prev twice, which destroyed the key_pressed /
# key_released edges (in_prev ended up equal to in_held). An entry-driven harness
# has no loop, so the flag stays false and each Input.poll commits a frame as before.
var in_have_frame_driver: bool = false
# The actual per-frame input read: read the live key (or a recorded one), advance
# the record/replay tape, and rebuild the multi-key device layer (held keys, mouse,
# gamepad — #50). Returns the frame's key.
function input_commit() -> int {
input_last = input_frame
if input_mode == 2 { # replay
var k = 0
if input_pos < input_recn { k = input_rec[input_pos]; input_pos = input_pos + 1 }
input_frame = k
input_device_commit(k, 1) # rebuild the device state from the tape
return k
}
let k = rt_poll()
if input_mode == 1 { # record
if input_rec == null { input_rec = words(INPUT_REC_CAP) }
if input_recn < INPUT_REC_CAP { input_rec[input_recn] = k; input_recn = input_recn + 1 }
}
input_frame = k
input_device_commit(k, 0)
return k
}
# Called by the generated frame loop once per frame (#83). Marks that a loop is
# driving input so a later manual Input.poll this frame does not double-commit.
function input_drive() -> int {
in_have_frame_driver = true
return input_commit()
}
# Input.poll — the single per-frame input read a game can call by hand. In a
# frame-loop game the loop already drove input this frame (input_drive), so this is
# a no-op that returns the frame's key; in an entry-driven harness (no loop) it
# commits a frame of input each call, exactly as before.
function input_poll() -> int {
if in_have_frame_driver { return input_frame }
return input_commit()
}
# ============================================================================
# device layer (#50) — multiple simultaneous keys, analog axes/vectors, the
# mouse, gamepads and touch, plus a full-state record/replay snapshot.
#
# The single-key poll above can express one key per frame; a game that reads
# "hold left AND jump" needs a held-key *set*. This layer keeps that set (fed by
# the platform when windowed, by the polled key when headless, and by the
# Input.press / Input.set_* injection on every target — the same idea as Godot's
# action_press, and what a replay or an AI or the network feeds). key_down /
# key_pressed / key_released read it with clean frame edges; the analog helpers
# derive axes and vectors from it; mouse / gamepad / touch state ride alongside.
# Everything is integer and deterministic: the same inputs reproduce the same
# frame on every run and headless, and Input.record / replay snapshot the whole
# thing so a recorded run replays exactly — free replays and lockstep netcode.
# ============================================================================
const IN_WORDS: int = 8 # 256-bit key set (keycodes 0..255)
const IN_PADS: int = 4 # gamepads
const IN_AXES: int = 4 # axes per pad (2 sticks: lx, ly, rx, ry)
const IN_TOUCH: int = 8 # simultaneous touch points
const IN_STRIDE: int = 12 # tape words / frame: 8 held + mx, my, buttons, wheel
var in_ready: bool = false
var in_sim: words = null # simulated held set (Input.press / release) — persists
var in_dev: words = null # platform / polled held set — refreshed each poll
var in_held: words = null # committed effective set this frame (what reads see)
var in_prev: words = null # committed set last frame (for edges)
# mouse
var in_mx: int = 0 # current x/y
var in_my: int = 0
var in_mx0: int = 0 # x/y at the previous frame (for the delta)
var in_my0: int = 0
var in_mdx: int = 0 # delta this frame
var in_mdy: int = 0
var in_mbtn: int = 0 # button bitmask (bit 0 left, 1 right, 2 middle)
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
var in_touch_x: words = null # IN_TOUCH
var in_touch_y: words = null # IN_TOUCH
# full-state tape (held + mouse), recorded / replayed alongside the key tape
var in_tape: words = null # INPUT_REC_CAP * IN_STRIDE
function in_init() -> void {
if in_ready { return }
in_sim = words(IN_WORDS)
in_dev = words(IN_WORDS)
in_held = words(IN_WORDS)
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)
in_touch_y = words(IN_TOUCH)
in_ready = true
}
# ---- key-set bit helpers ---------------------------------------------------
function in_bit_get(set: words, k: int) -> bool {
if (k < 0) or (k >= 256) { return false }
return (set[k >> 5] & (1 << (k & 31))) != 0
}
function in_bit_set(set: words, k: int, on: bool) -> void {
if (k < 0) or (k >= 256) { return }
let w = k >> 5
let m = 1 << (k & 31)
if on { set[w] = set[w] | m } else { set[w] = set[w] & (~m) }
}
function in_set_clear(set: words) -> void { var i = 0; while i < IN_WORDS { set[i] = 0; i = i + 1 } }
function in_set_copy(dst: words, src: words) -> void { var i = 0; while i < IN_WORDS { dst[i] = src[i]; i = i + 1 } }
function in_set_or(dst: words, a: words, b: words) -> void { var i = 0; while i < IN_WORDS { dst[i] = a[i] | b[i]; i = i + 1 } }
# ---- the per-frame device commit (called by input_poll) --------------------
# Snapshot the committed set into prev (for edges), refresh the platform set (or
# rebuild it from the tape on replay), then recombine into the committed set.
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
# a replay is authoritative (as #7's key replay ignores the live device).
let base = (input_pos - 1) * IN_STRIDE
if (in_tape != null) and (base >= 0) {
var i = 0
while i < IN_WORDS { in_dev[i] = in_tape[base + i]; i = i + 1 }
in_mx = in_tape[base + 8]
in_my = in_tape[base + 9]
in_mbtn = in_tape[base + 10]
in_wheel = in_tape[base + 11]
}
in_set_copy(in_held, in_dev)
} else {
# live: fill the platform set from the window (real simultaneous keys) or,
# headless, from the single polled key. Injection (in_sim) is OR-ed on top.
if is_windowed() {
win_held(in_dev)
let mbuf = words(4) # [x, y, button-mask, wheel]
win_mouse(mbuf)
in_mx = mbuf[0]; in_my = mbuf[1]; in_mbtn = mbuf[2]; in_wheel = mbuf[3]
# #51 — feed the platform gamepad + touch state into the same buffers the
# read APIs use. Each is windowed-only glue (win_pad / win_touch are DCE'd
# in a headless build); on hardware they overwrite the injected state.
let pbuf = words(IN_PADS * 6) # [conn, mask, lx, ly, rx, ry]/pad
win_pad(pbuf)
var pi = 0
while pi < IN_PADS {
let pb = pi * 6
input_set_pad(pi, pbuf[pb] != 0, pbuf[pb + 1],
as_fixed(pbuf[pb + 2]), as_fixed(pbuf[pb + 3]),
as_fixed(pbuf[pb + 4]), as_fixed(pbuf[pb + 5]))
pi = pi + 1
}
let tbuf = words(IN_TOUCH * 3) # [active, x, y]/point
win_touch(tbuf)
var ti = 0
while ti < IN_TOUCH {
let tb = ti * 3
input_set_touch(ti, tbuf[tb + 1], tbuf[tb + 2], tbuf[tb] != 0)
ti = ti + 1
}
} else {
in_set_clear(in_dev)
if k > 0 { in_bit_set(in_dev, k, true) }
}
in_set_or(in_held, in_dev, in_sim)
if input_mode == 1 { input_device_record() } # snapshot the frame into the tape
}
# mouse delta vs the previous frame's committed position (in_mx set by the
# platform above when windowed, by Input.set_mouse before this poll otherwise).
in_mdx = in_mx - in_mx0
in_mdy = in_my - in_my0
in_mx0 = in_mx
in_my0 = in_my
}
# write this frame's committed set + mouse into the tape at the record cursor.
function input_device_record() -> void {
if in_tape == null { in_tape = words(INPUT_REC_CAP * IN_STRIDE) }
let f = input_recn - 1
if (f < 0) or (f >= INPUT_REC_CAP) { return }
let base = f * IN_STRIDE
var i = 0
while i < IN_WORDS { in_tape[base + i] = in_held[i]; i = i + 1 }
in_tape[base + 8] = in_mx
in_tape[base + 9] = in_my
in_tape[base + 10] = in_mbtn
in_tape[base + 11] = in_wheel
}
# ---- held keys -------------------------------------------------------------
function input_key_down(k: int) -> bool { in_init(); return in_bit_get(in_held, k) }
function input_key_pressed(k: int) -> bool { in_init(); return in_bit_get(in_held, k) and (not in_bit_get(in_prev, k)) }
function input_key_released(k: int) -> bool { in_init(); return (not in_bit_get(in_held, k)) and in_bit_get(in_prev, k) }
# inject a held key (AI, tutorial, testing, network) — persists until released.
function input_press(k: int) -> void { in_init(); in_bit_set(in_sim, k, true) }
function input_release(k: int) -> void { in_init(); in_bit_set(in_sim, k, false) }
# ---- analog from keys ------------------------------------------------------
# A digital axis: +1.0 if the positive key is held, -1.0 if the negative, else 0.
function input_axis(neg: int, pos: int) -> fixed {
in_init()
var v = fixed(0)
if in_bit_get(in_held, pos) { v = v + fixed(1) }
if in_bit_get(in_held, neg) { v = v - fixed(1) }
return v
}
# #79 — a directional intent as a plain int: +1 if the positive key is held, -1 if
# the negative, 0 if neither or both. Reads the multi-key device set, so it needs
# no bool->int glue (the `dx = ki(key_down('d')) - ki(key_down('a'))` boilerplate)
# and feeds an int mover (TopDown.move) straight: dx = Input.axis_i('a','d').
function input_axis_i(neg: int, pos: int) -> int {
in_init()
var v = 0
if in_bit_get(in_held, pos) { v = v + 1 }
if in_bit_get(in_held, neg) { v = v - 1 }
return v
}
# A 2D vector from four direction keys, normalized so a diagonal is not faster.
function input_vector(left: int, right: int, up: int, down: int) -> Vector {
in_init()
var x = fixed(0)
var y = fixed(0)
if in_bit_get(in_held, right) { x = x + fixed(1) }
if in_bit_get(in_held, left) { x = x - fixed(1) }
if in_bit_get(in_held, down) { y = y + fixed(1) }
if in_bit_get(in_held, up) { y = y - fixed(1) }
if (x != 0) and (y != 0) { # diagonal: scale by 1/sqrt(2)
x = x * 0.7071 # fixed multiply (64-bit intermediate)
y = y * 0.7071
}
return Vector.make(x, y)
}
# 0.0..1.0 strength of a named action (digital for keys: 1 if any bound key held).
function input_strength(name: pointer) -> fixed {
if input_down(name) { return fixed(1) }
return fixed(0)
}
# ---- cursor capture (#89) --------------------------------------------------
# Set the OS cursor mode for a windowed game:
# 0 normal — cursor visible and free (default).
# 1 hidden — cursor hidden while the window is focused (draw your own reticle).
# 2 locked — hidden + dissociated; the mouse feeds relative motion through
# Input.mouse_dx/dy and Input.mouse_x/y is a clamped virtual cursor
# (the FPS / twin-stick capture mode).
# 3 confined — dissociated but visible; the mouse cannot leave the window.
# The platform auto-releases (shows + reconnects) while the window is not key
# (Cmd-Tab) and on close. Headless / non-windowed: a no-op.
function input_cursor_mode(mode: int) -> void {
if is_windowed() { win_cursor_mode(mode) }
}
# ---- mouse -----------------------------------------------------------------
function input_mouse_x() -> int { in_init(); return in_mx }
function input_mouse_y() -> int { in_init(); return in_my }
function input_mouse_dx() -> int { in_init(); return in_mdx }
function input_mouse_dy() -> int { in_init(); return in_mdy }
function input_mouse_down(btn: int) -> bool { in_init(); return (in_mbtn & (1 << btn)) != 0 }
function input_wheel() -> int { in_init(); return in_wheel }
# inject the mouse (headless / AI / testing). buttons is a bitmask; wheel is this
# frame's delta.
function input_set_mouse(x: int, y: int, buttons: int, wheel: int) -> void {
in_init()
in_mx = x; in_my = y; in_mbtn = buttons; in_wheel = wheel
}
# ---- gamepads --------------------------------------------------------------
function input_pad_connected(pad: int) -> bool {
in_init()
if (pad < 0) or (pad >= IN_PADS) { return false }
return in_pad_conn[pad] != 0
}
function input_pad_button(pad: int, btn: int) -> bool {
in_init()
if (pad < 0) or (pad >= IN_PADS) { return false }
return (in_pad_btn[pad] & (1 << btn)) != 0
}
function input_pad_axis(pad: int, axis: int) -> fixed {
in_init()
if (pad < 0) or (pad >= IN_PADS) { return fixed(0) }
if (axis < 0) or (axis >= IN_AXES) { return fixed(0) }
return in_pad_axis[pad * IN_AXES + axis]
}
# inject a gamepad's whole state: connected, button bitmask, and four fixed axes.
function input_set_pad(pad: int, connected: bool, buttons: int, lx: fixed, ly: fixed, rx: fixed, ry: fixed) -> void {
in_init()
if (pad < 0) or (pad >= IN_PADS) { return }
var c = 0
if connected { c = 1 }
in_pad_conn[pad] = c
in_pad_btn[pad] = buttons
let b = pad * IN_AXES
in_pad_axis[b] = lx
in_pad_axis[b + 1] = ly
in_pad_axis[b + 2] = rx
in_pad_axis[b + 3] = ry
}
# ---- touch -----------------------------------------------------------------
function input_touch_count() -> int {
in_init()
var n = 0
var i = 0
while i < IN_TOUCH { if in_touch_on[i] != 0 { n = n + 1 }; i = i + 1 }
return n
}
function input_touch_x(i: int) -> int { in_init(); if (i < 0) or (i >= IN_TOUCH) { return 0 }; return in_touch_x[i] }
function input_touch_y(i: int) -> int { in_init(); if (i < 0) or (i >= IN_TOUCH) { return 0 }; return in_touch_y[i] }
# inject a touch point i: active with a position, or inactive.
function input_set_touch(i: int, x: int, y: int, active: bool) -> void {
in_init()
if (i < 0) or (i >= IN_TOUCH) { return }
var a = 0
if active { a = 1 }
in_touch_on[i] = a
in_touch_x[i] = x
in_touch_y[i] = y
}
# is the named action held on the frame last polled?
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)
return input_slot_has(s, input_frame) and (not input_slot_has(s, input_last))
}
# begin recording polled input from the next frame (resets the tape).
function input_record() -> void {
if input_rec == null { input_rec = words(INPUT_REC_CAP) }
input_recn = 0
input_pos = 0
input_mode = 1
}
# replay the recording from its start; subsequent Input.poll calls read the tape.
function input_replay() -> void {
input_pos = 0
input_mode = 2
}