feat(input,ecs): #79 Input.axis_i directional int + #84 world bounds
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 23s
ci / build-and-test (push) Successful in 2m23s
commit-lint / conventional-commits (push) Successful in 5s
docs / build-and-deploy (push) Successful in 28s

#79 — Input.axis_i(neg,pos) -> int returns a -1/0/1 movement intent from the
multi-key device set, so WASD-to-movement needs no bool->int glue and feeds an
int mover directly (dx = Input.axis_i('a','d')).

#84 — a Bounds config entity (rect + policy, from ludic.core) drives the
engine-owned world-bounds system (LateUpdate): clamp / wrap / bounce (clamp +
flip Body velocity) / kill (despawn a body fully outside). Reads the Collider
size so the box stays inside; off by default, spliced only when Bounds is
declared (byte-identical otherwise). Adds World.despawn(e) — the by-id
reflective despawn (runs @OnDespawn + frees) via a new world_despawn intrinsic
whose @fn_world_despawn helper is emitted in emit_program's tail once a use is
seen (the g_uses_* prelude pattern), used by the kill policy and callable from
any system.

Examples input_movement + world_bounds. Full suite 112/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:44:41 +03:00
parent 0497029dae
commit ad3be0c53c
17 changed files with 30435 additions and 29731 deletions

3
changes/input-axis-i.md Normal file
View file

@ -0,0 +1,3 @@
bump: minor
type: feat
Directional int input (#79) — `Input.axis_i(neg, pos) -> int` returns a -1/0/1 movement intent (`+1` positive key held, `-1` negative, `0` neither or both) read from the multi-key device set, so turning WASD into movement no longer needs the `ki(key_down('d')) - ki(key_down('a'))` bool-to-int glue and feeds an int mover directly: `dx = Input.axis_i('a', 'd')`, `dy = Input.axis_i('w', 's')`. Complements `Input.axis` (fixed) / `Input.vector` (normalized). Example: `examples/library/input_movement.ludic`.

3
changes/world-bounds.md Normal file
View file

@ -0,0 +1,3 @@
bump: minor
type: feat
Optional world boundaries (#84) — declare a single `Bounds` config entity (a rect `x, y, w, h` plus a policy, shipped from **ludic.core**) and the engine-owned world-bounds system keeps every moving `Body` inside the play area each frame, so a game no longer hand-clamps `Position`. Four policies: `0` clamp (walls), `1` wrap (toroidal), `2` bounce (clamp + flip the Body velocity on the axis that hit), `3` kill (despawn a body fully outside). It reads each body's `Collider` size so the whole box stays inside; off by default (no `Bounds` entity = open world). Registered on the engine-system registry for LateUpdate (after movement integrates) and spliced only when a game declares `Bounds`, so a game that never does compiles byte-identically. Also adds `World.despawn(entity)` — the reflective, by-id form of the `despawn` statement (runs `@OnDespawn` + frees the slot), which the kill policy uses and any system can call. Example: `examples/library/world_bounds.ludic`.

View file

@ -0,0 +1,24 @@
---
id: input-axis_i
name: Input.axis_i
category: input
kind: namespace-method
tokens: Input.axis_i
sig: Input.axis_i(neg, pos) -> int
tip: A -1/0/1 directional intent as a plain int, no bool->int glue.
order: 14
ns: Input
member: axis_i
---
Returns a directional intent as a plain <code>int</code>: <code>+1</code> when the positive key is held, <code>-1</code> when the negative one is, <code>0</code> for neither or both. It reads the multi-key device set, so it replaces the <code>ki(key_down('d')) - ki(key_down('a'))</code> boolean-to-int boilerplate and feeds an int mover directly — <code>dx = Input.axis_i('a', 'd')</code>, <code>dy = Input.axis_i('w', 's')</code>. For an analog / normalized value use <a href="input-axis.html"><code>Input.axis</code></a> (fixed) or <a href="input-vector.html"><code>Input.vector</code></a>.
```ludic
program Demo {
entry {
Input.press('d')
Input.poll()
print(Input.axis_i('a', 'd')) # 1
}
}
```

View file

@ -0,0 +1,28 @@
---
id: world-despawn
name: World.despawn
category: world
kind: namespace-method
tokens: World.despawn
sig: World.despawn(entity)
tip: Despawn an entity by id — runs its @OnDespawn hooks and frees the slot.
order: 16
ns: World
member: despawn
---
Despawns an entity by id: it runs the entity's model <code>@OnDespawn</code> hook (with reason <code>Despawned</code>), sweeps any entity-scoped event listeners, and frees the slot back to the pool for reuse — the reflective, by-id form of the <code>despawn</code> statement. Use it from a system that has an entity id in hand (the engine world-bounds kill policy uses it, #84) or wherever you would write <code>despawn e</code> but only have the id.
```ludic
program Demo {
property Mob { hp: int = 0 }
model M { Mob }
entry {
spawn M { Mob { hp: 1 } }
let pm = World.prop_id("Mob")
let e = World.query_next(pm, 0)
World.despawn(e)
print(World.query_next(pm, 0)) # -1 — none left
}
}
```

View file

@ -0,0 +1,25 @@
# input_movement.ludic — Input.axis_i (#79): turn WASD into a -1/0/1 movement
# intent as a plain int, with no bool->int glue (`ki(key_down('d')) - ...`). It
# reads the multi-key device set and feeds an int mover directly. Driven headless
# by injection, the same way the #50 device tests are.
#
# Deterministic; a full run prints: 1 0 -1 -1 0
program InputMovement {
entry {
Input.press('d') # hold right
Input.poll()
print(Input.axis_i('a', 'd')) # 1 — dx = right
print(Input.axis_i('w', 's')) # 0 — dy = none
Input.release('d')
Input.press('a') # hold left
Input.press('w') # and up
Input.poll()
print(Input.axis_i('a', 'd')) # -1 — dx = left
print(Input.axis_i('w', 's')) # -1 — dy = up
Input.press('d') # now both left AND right held
Input.poll()
print(Input.axis_i('a', 'd')) # 0 — opposing keys cancel
}
}

View file

@ -0,0 +1,63 @@
# world_bounds.ludic — the engine world-bounds system (#84): declare one Bounds
# config entity (rect + policy) and the engine keeps every moving Body inside the
# play area each frame — clamp / wrap / bounce / kill — instead of the game
# hand-clamping Position. Bounds/Body/Collider come from ludic.core. Driven by
# tick_fixed (Update integrates, LateUpdate applies the bounds).
#
# Deterministic; a full run prints: 184 -16 184 1 1
program WorldBounds {
import "ludic.core/components.ludic"
function tick_n(n: int) -> void { var i = 0; while i < n { tick_fixed(); i = i + 1 } }
function bi(b: bool) -> int { if b { return 1 }; return 0 }
function clear_all() -> void {
let pb = World.prop_id("Body")
var e = World.query_next(pb, 0)
while e >= 0 { despawn e; e = World.query_next(pb, 0) }
let pn = World.prop_id("Bounds")
var c = World.query_next(pn, 0)
while c >= 0 { despawn c; c = World.query_next(pn, 0) }
}
entry {
let PP = World.prop_id("Position")
let fx = World.field_id(PP, "x")
let PBody = World.prop_id("Body")
let f_vx = World.field_id(PBody, "vx")
# A. clamp (policy 0): a body running right is stopped at the right wall.
spawn B0 { Bounds { x: 0, y: 0, w: 200, h: 200, policy: 0 } }
spawn M0 { Position { x: 180, y: 50 }, Body { vx: fixed(10), policy: 1 }, Collider { w: 16, h: 16 } }
var a = World.query_next(PBody, 0)
tick_n(10)
print(World.get(a, PP, fx)) # 184 = 200 - 16 (box right edge at the wall)
clear_all()
# B. wrap (policy 1): past the right edge, it reappears on the left.
spawn B1 { Bounds { x: 0, y: 0, w: 200, h: 200, policy: 1 } }
spawn M1 { Position { x: 195, y: 50 }, Body { vx: fixed(10), policy: 1 }, Collider { w: 16, h: 16 } }
a = World.query_next(PBody, 0)
tick_n(1)
print(World.get(a, PP, fx)) # -16 = bx - w (wrapped to the far side)
clear_all()
# C. bounce (policy 2): clamp at the wall AND flip the velocity.
spawn B2 { Bounds { x: 0, y: 0, w: 200, h: 200, policy: 2 } }
spawn M2 { Position { x: 190, y: 50 }, Body { vx: fixed(10), policy: 1 }, Collider { w: 16, h: 16 } }
a = World.query_next(PBody, 0)
tick_n(1)
print(World.get(a, PP, fx)) # 184 (clamped at the wall)
print(bi(World.get(a, PBody, f_vx) < 0)) # 1 (vx flipped negative)
clear_all()
# D. kill (policy 3): a body fully outside is despawned.
spawn B3 { Bounds { x: 0, y: 0, w: 200, h: 200, policy: 3 } }
spawn M3 { Position { x: 300, y: 50 }, Body { vx: fixed(10), policy: 1 }, Collider { w: 16, h: 16 } }
tick_n(1)
var live = 0
var q = World.query_next(PBody, 0)
while q >= 0 { live = live + 1; q = World.query_next(PBody, q + 1) }
print(live) # 0 — the out-of-bounds body was killed
}
}

View file

@ -54,3 +54,9 @@ property Sprite { id: int = 0, offx: int = 0, offy: int = 0, scale: int = 1, fli
# read from the Map.* tilemap. tile > 0 sets the tile size in px; wall is the solid
# glyph; oneway is an optional one-way-platform glyph (0 = none).
property Solids { tile: int = 0, wall: int = 0, oneway: int = 0 }
# Optional single config entity defining a world boundary / play area (#84): a rect
# (x, y, w, h) and a policy the engine applies to every moving Body each frame —
# 0 clamp (walls), 1 wrap (toroidal), 2 bounce (flip velocity), 3 kill (despawn when
# fully outside). Off by default (no Bounds entity = open world).
property Bounds { x: int = 0, y: int = 0, w: int = 0, h: int = 0, policy: int = 0 }

View file

@ -21,3 +21,4 @@ provides "Body"
provides "Collider"
provides "Solids"
provides "Sprite"
provides "Bounds"

View file

@ -370,6 +370,17 @@ function input_axis(neg: int, pos: int) -> fixed {
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()

View file

@ -0,0 +1,106 @@
# ============================================================================
# systems_bounds.ludic — the engine-owned world-bounds system (#84).
#
# Games used to hand-roll clamping (clamp Position every frame) to fake walls or
# keep entities inside a play area. Declare a single `Bounds` config entity — a
# rect (x, y, w, h) plus a policy — and the engine keeps every moving `Body`
# inside it each frame, applying the policy at the edge:
#
# policy 0 clamp — stop at the edge (walls)
# policy 1 wrap — reappear on the opposite side (asteroids / toroidal world)
# policy 2 bounce — clamp and flip the Body velocity on the axis that hit
# policy 3 kill — despawn an entity fully outside the bounds (offscreen cleanup)
#
# Off by default (no Bounds entity = open world). Registered on the engine-system
# registry for LateUpdate, so it runs after the movement system has integrated the
# frame; spliced only when a game declares `Bounds`, so a game that never does
# compiles byte-identically. Everything integer + deterministic.
#
# Component contract (ship it from ludic.core):
# property Bounds { x: int, y: int, w: int, h: int, policy: int }
#
# It reads each Body entity's Position and (if present) its Collider size so the
# whole box stays inside; a Body with no Collider is treated as a point.
# ============================================================================
const BND_CLAMP: int = 0
const BND_WRAP: int = 1
const BND_BOUNCE: int = 2
const BND_KILL: int = 3
# read an int field by name off a component, or 0 if absent.
function bnd_get(e: int, p: int, name: pointer) -> int {
let f = World.field_id(p, name)
if f < 0 { return 0 }
return World.get(e, p, f)
}
function esys_bounds() -> void {
let PB = World.prop_id("Bounds")
if PB < 0 { return }
let cfg = World.query_next(PB, 0) # the single bounds config entity
if cfg < 0 { return }
let bx = bnd_get(cfg, PB, "x")
let by = bnd_get(cfg, PB, "y")
let bw = bnd_get(cfg, PB, "w")
let bh = bnd_get(cfg, PB, "h")
let pol = bnd_get(cfg, PB, "policy")
if bw <= 0 { return }
if bh <= 0 { return }
let x1 = bx + bw # right / bottom edges (exclusive of box size)
let y1 = by + bh
let PP = World.prop_id("Position")
if PP < 0 { return }
let fx = World.field_id(PP, "x")
let fy = World.field_id(PP, "y")
let PBody = World.prop_id("Body")
if PBody < 0 { return }
let f_vx = World.field_id(PBody, "vx")
let f_vy = World.field_id(PBody, "vy")
let PC = World.prop_id("Collider")
var e = World.query_next(PBody, 0)
while e >= 0 {
if World.has(e, PP) != 0 {
var x = World.get(e, PP, fx)
var y = World.get(e, PP, fy)
var w = 0
var h = 0
if PC >= 0 {
if World.has(e, PC) != 0 { w = bnd_get(e, PC, "w"); h = bnd_get(e, PC, "h") }
}
var killed = 0
if pol == BND_KILL {
if (x + w < bx) or (x > x1) or (y + h < by) or (y > y1) {
world_despawn(e) # fully outside -> remove it
killed = 1
}
}
if killed == 0 {
if pol == BND_WRAP {
if x + w < bx { x = x1 }
if x > x1 { x = bx - w }
if y + h < by { y = y1 }
if y > y1 { y = by - h }
} else {
# clamp (also the position half of bounce): keep [x, x+w] inside [bx, x1]
var hitx = 0
var hity = 0
if x < bx { x = bx; hitx = 1 }
if x + w > x1 { x = x1 - w; hitx = 1 }
if y < by { y = by; hity = 1 }
if y + h > y1 { y = y1 - h; hity = 1 }
if pol == BND_BOUNCE {
if (hitx == 1) and (f_vx >= 0) { World.set(e, PBody, f_vx, 0 - World.get(e, PBody, f_vx)) }
if (hity == 1) and (f_vy >= 0) { World.set(e, PBody, f_vy, 0 - World.get(e, PBody, f_vy)) }
}
}
World.set(e, PP, fx, x)
World.set(e, PP, fy, y)
}
}
e = World.query_next(PBody, e + 1)
}
}

View file

@ -224,6 +224,7 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
if (meth == "press") { bare = "input_press"; push(labels, "key") }
if (meth == "release") { bare = "input_release"; push(labels, "key") }
if (meth == "axis") { bare = "input_axis"; push(labels, "neg"); push(labels, "pos") }
if (meth == "axis_i") { bare = "input_axis_i"; push(labels, "neg"); push(labels, "pos") } # #79 directional int (-1/0/1)
if (meth == "vector") { bare = "input_vector"; push(labels, "left"); push(labels, "right"); push(labels, "up"); push(labels, "down") }
if (meth == "strength") { bare = "input_strength"; push(labels, "action") }
if (meth == "mouse_x") { bare = "input_mouse_x" }
@ -287,6 +288,7 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
if (meth == "count") { bare = "world_count" }
if (meth == "size") { bare = "world_size" }
if (meth == "spawn") { bare = "world_spawn" }
if (meth == "despawn") { bare = "world_despawn" } # #84 — despawn an entity by id (runs @OnDespawn + frees)
if (meth == "save") { bare = "world_save" }
if (meth == "load") { bare = "world_load" }
if (meth == "prop_id") { bare = "world_prop_id" }

View file

@ -147,6 +147,7 @@ function emit_program() -> void {
g_uses_panic = false
g_uses_result = false
g_uses_option = false
g_uses_world_despawn = false # #84: set when a world_despawn call is emitted (below)
g_cov_lines = new []int
g_cov_active = true
loc_name = new []pointer; loc_reg = new []pointer; loc_ty = new []pointer; loc_mut = new []int
@ -184,6 +185,7 @@ function emit_program() -> void {
i = 0
while i < len(prog) { if prog[i].kind == N_MAIN { emit_main(prog[i]) }; i = i + 1 }
} } }
if g_uses_world_despawn { emit_world_despawn_fn() } # #84: @fn_world_despawn, after all World.despawn / esys_bounds-kill uses are seen
if g_uses_loopback { emit_loopback() } # built-in transport, after all net_send/net_poll uses are seen
if g_uses_str { emit_str_prelude() } # @fn_str_eq / @fn_str_concat, after all uses are seen
if g_uses_intstr { emit_int_str() } # @fn_int_str, for string(int) in interpolation

View file

@ -13,6 +13,7 @@ function is_intrinsic(name: pointer) -> bool {
if (name == "arg_count") or (name == "arg") or (name == "exit") { return true }
if (name == "file_stderr") or (name == "file_stdout") { return true }
if (name == "run") or (name == "getenv") { return true }
if (name == "world_despawn") { return true } # #84 — despawn an entity by id from a system
return false
}
# emit " <r> = <rest>\n" and return r
@ -56,6 +57,15 @@ function emit_intrinsic(name: pointer, e: Node) -> Val {
emit(" call i32 @fclose(ptr "); emit(f); emit(")\n")
return val("0", "void")
}
# #84 — despawn an entity by id (runs its @OnDespawn hooks + frees the slot),
# callable from a system (esys_bounds kill) or as World.despawn(e). The helper
# @fn_world_despawn is emitted with the ECS defs when g_uses_world_despawn.
if (name == "world_despawn") {
g_uses_world_despawn = true # emit @fn_world_despawn in the tail (after all uses seen)
let ent = arg_code(e, 0)
emit(" call void @fn_world_despawn(i32 "); emit(ent); emit(")\n")
return val("0", "void")
}
# print_str / print_int are the polymorphic `print(x)` builtin now (emit_call).
if (name == "arg_count") { return val(emit_bind("load i32, ptr @L_argc"), "int") }
if (name == "arg") {

View file

@ -293,6 +293,37 @@ function emit_despawn_hooks() -> void {
emit_despawn_all_fn()
}
# #84 — @fn_world_despawn(e): the runtime-callable despawn, used by the
# world-bounds kill policy and World.despawn(e). Same per-kind @OnDespawn dispatch
# as the `despawn` statement (emit_despawn) but reading a runtime %e, then the
# entity-scoped-listener sweep and the slot free. Emitted from emit_program's tail
# only when a world_despawn call was emitted (g_uses_world_despawn), after all uses
# are seen — so a program that never despawns by id is byte-identical.
function emit_world_despawn_fn() -> void {
emit("define void @fn_world_despawn(i32 %e) {\nentry:\n")
if len(g_ondespawn) > 0 {
let me = itoa(MAX_ENT)
emit(" %kp = getelementptr inbounds ["); emit(me); emit(" x i32], ptr @L_kind, i32 0, i32 %e\n")
emit(" %k = load i32, ptr %kp\n")
var i = 0
while i < len(g_ondespawn) {
let mname = g_ondespawn[i].s
let si = itoa(i)
emit(" %c"); emit(si); emit(" = icmp eq i32 %k, "); emit(itoa(find_arch_id(mname))); emit("\n")
emit(" br i1 %c"); emit(si); emit(", label %hit"); emit(si); emit(", label %next"); emit(si); emit("\n")
emit("hit"); emit(si); emit(":\n call void @on_despawn_"); emit(mname); emit("(i32 %e, i32 0)\n") # reason = Despawned
let dev = `model_{mname}_despawn`
if (find_event(dev) != null) { emit(" call void @ev_"); emit(dev); emit("(i32 %e, i32 0)\n") }
emit(" br label %next"); emit(si); emit("\n")
emit("next"); emit(si); emit(":\n")
i = i + 1
}
}
if len(g_events) > 0 { emit(" call void @ludic_sweep_entity(i32 %e)\n") } # EV5: drop entity-scoped listeners
emit(" call void @L_free_entity(i32 %e)\n")
emit(" ret void\n}\n\n")
}
# LC1 "no silent deaths": at program shutdown every still-live entity's despawn
# hook fires with reason Quit, so teardown that must run on exit is not skipped.
# @L_despawn_all(reason) walks the live set and dispatches each entity by kind —

View file

@ -192,7 +192,7 @@ function p_postfix() -> Node {
if e.a.kind == E_ID and e.a.s == "Input" and (e.s == "bind" or e.s == "rebind" or e.s == "poll" or e.s == "down" or e.s == "pressed" or e.s == "record" or e.s == "replay" or e.s == "action" or e.s == "bind_pad" or e.s == "active" or e.s == "just_pressed" or e.s == "just_released") { g_uses_input = true }
# #50 device layer — any of the multi-key / analog / mouse / gamepad / touch
# methods also splices input.ludic (Input.key stays bare, no runtime).
if e.a.kind == E_ID and e.a.s == "Input" and (e.s == "key_down" or e.s == "key_pressed" or e.s == "key_released" or e.s == "press" or e.s == "release" or e.s == "axis" or e.s == "vector" or e.s == "strength" or e.s == "mouse_x" or e.s == "mouse_y" or e.s == "mouse_dx" or e.s == "mouse_dy" or e.s == "mouse_down" or e.s == "wheel" or e.s == "set_mouse" or e.s == "pad_connected" or e.s == "pad_button" or e.s == "pad_axis" or e.s == "set_pad" or e.s == "touch_count" or e.s == "touch_x" or e.s == "touch_y" or e.s == "set_touch") { g_uses_input = true }
if e.a.kind == E_ID and e.a.s == "Input" and (e.s == "key_down" or e.s == "key_pressed" or e.s == "key_released" or e.s == "press" or e.s == "release" or e.s == "axis" or e.s == "axis_i" or e.s == "vector" or e.s == "strength" or e.s == "mouse_x" or e.s == "mouse_y" or e.s == "mouse_dx" or e.s == "mouse_dy" or e.s == "mouse_down" or e.s == "wheel" or e.s == "set_mouse" or e.s == "pad_connected" or e.s == "pad_button" or e.s == "pad_axis" or e.s == "set_pad" or e.s == "touch_count" or e.s == "touch_x" or e.s == "touch_y" or e.s == "set_touch") { g_uses_input = true }
# Anim.play/clip/on_frame/fired + Motion.to (#48): the ergonomic writes over
# the SpriteAnim/Motion components live in systems.ludic and use the world
# table, so splice it and force the reflection ABI even if the game leaves
@ -448,6 +448,7 @@ var g_uses_input: bool = false # a program used Input.bind/down/poll/… (act
var g_has_clear_color: bool = false # @ClearColor(N) declared -> the Render phase auto-clears + auto-presents (#86)
var g_clear_color: int = 0 # the declared clear colour (0x00RRGGBB)
var g_warned_draw_sprite: bool = false # emit the bare-draw_sprite deprecation note once (#85)
var g_uses_world_despawn: bool = false # World.despawn / esys_bounds kill -> emit @fn_world_despawn (#84)
# #85 — steer a direct bare draw_sprite / draw_sprite_scaled call to the namespaced
# Screen.sprite (or the engine sprite-render system). Warns once, non-fatally — the
@ -864,6 +865,12 @@ function maybe_splice_runtime() -> void {
do_import("runtime/native/core.ludic")
do_import("runtime/native/systems_sprite.ludic")
}
# Bounds -> the engine world-bounds system (#84). Self-contained (reflection
# ABI only); its kill policy calls world_despawn, so force the @fn_world_despawn
# helper to be emitted.
if find_comp("Bounds") != null {
do_import("runtime/native/systems_bounds.ludic")
}
# Light2D / Occluder -> the lighting render system, which links against the
# 2D light pass (light.ludic). do_import dedupes, so this is a no-op when the
# game also uses Light.* directly (g_uses_light already pulled it in).
@ -903,6 +910,7 @@ function parse_program() -> void {
g_has_clear_color = false
g_clear_color = 0
g_warned_draw_sprite = false
g_uses_world_despawn = false
g_uses_light = false
g_uses_value = false
g_uses_xml = false
@ -920,6 +928,7 @@ function parse_program() -> void {
push(g_esys_comp, "SpriteAnim"); push(g_esys_fn, "esys_spriteanim"); push(g_esys_phase, "Update")
push(g_esys_comp, "Motion"); push(g_esys_fn, "esys_motion"); push(g_esys_phase, "Update")
push(g_esys_comp, "Body"); push(g_esys_fn, "esys_move"); push(g_esys_phase, "Update")
push(g_esys_comp, "Bounds"); push(g_esys_fn, "esys_bounds"); push(g_esys_phase, "LateUpdate")
push(g_esys_comp, "Sprite"); push(g_esys_fn, "esys_sprite"); push(g_esys_phase, "Render")
push(g_esys_comp, "Light2D"); push(g_esys_fn, "esys_light2d"); push(g_esys_phase, "Render")
g_namespaces = new []pointer

File diff suppressed because it is too large Load diff

View file

@ -244,6 +244,7 @@ function cmd_test() -> int {
# builtin gameplay controllers, consumed as in-repo packages (issues #57-#61)
controller_case("library/core_components", "", "110 30 184 1", "core_components.ludic (ludic.core #77: canonical Position/Body/Collider imported from a package, driving esys_move, composed with a game Health component)")
controller_case("library/sprite_render", "q", "1 0", "sprite_render.ludic (ludic.core #85: engine sprite-render system auto-draws a Sprite entity from its Position; respects hidden)")
controller_case("library/world_bounds", "", "184 -16 184 1 0", "world_bounds.ludic (ludic.core #84: engine world-bounds system — clamp/wrap/bounce/kill keep a Body in the play area)")
controller_case("library/gameplay_foundation", "", "2 0 1 5 24 0 2 1 15 85 7 1 1", "gameplay_foundation.ludic (ludic.gameplay #57: Cooldown timer + Stats/modifier stack + Faction table + Combat cancel/mutable pipeline)")
controller_case("games/platformer_demo", "", "1 1 1 1 1 1 1 1", "platformer_demo.ludic (ludic.platformer #58: gravity/land, jump apex, coyote, veto-gated double jump, wall collision + disable-system lever)")
controller_case("games/platformer_scaffolding", "", "1 1 1 1 1 1", "platformer_scaffolding.ludic (ludic.platformer #58 layers 4-5: moving-platform rider carry, pickup->score, spring, hazard+Life i-frames)")
@ -306,6 +307,7 @@ function cmd_test() -> int {
feat_case("library/input_manager", "", "1 1 1 0 1 0 0 1", "input_manager.ludic (#83 Input Manager: default bindings + device-agnostic actions across keyboard/pad + on-press/on-release edges)")
feat_case("library/input_auto", "llq", "2", "input_auto.ludic (#83 the frame loop auto-commits the device layer: Input.active fires with no manual Input.poll)")
feat_case("library/input_edge", "xkkxq", "0 11 1 100 0", "input_edge.ludic (#87 Input.key_pressed/key_released edges fire on the transition frame; no double-commit under the frame loop)")
feat_case("library/input_movement", "", "1 0 -1 -1 0", "input_movement.ludic (#79 Input.axis_i turns WASD into a -1/0/1 movement intent, no bool->int glue)")
feat_case("library/audio", "", "0 0 0 0 1", "audio.ludic (#22 Audio.* load/play/music/volume/pitch/stop/is_playing — headless no-op)")
# #6 Http.* — the client links Foundation (macOS-only), so build it through the
# canonical `ludicc -o` path (which wires the framework) and gate on Darwin. The