feat(anim): animation ergonomics — named clips, Anim.play/Motion.to, frame events, fluent Tween handles (#48)
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 18s
ci / build-and-test (push) Successful in 1m21s
commit-lint / conventional-commits (push) Successful in 5s
docs / build-and-deploy (push) Successful in 20s

The ergonomic layer over the engine-owned SpriteAnim/Motion systems (#43):

- Named clips: Anim.clip("run", frames, fps, mode) registers a clip by name and
  Anim.play(entity, "run") plays it; Anim.play(entity, fps, frames, mode) sets
  the clip directly. A name-keyed registry in systems.ludic.
- Frame events: Anim.on_frame(entity, frame) arms optional SpriteAnim
  event_frame/event_fired fields; the engine flags the tick the clip first lands
  on that frame, and Anim.fired(entity) reads it — the game reacts, so it stays
  inside the no-runtime-dispatch event model.
- Motion.to(entity, from, to, dur, ease) starts a value tween over the Motion
  component in one call (reflection-ABI writes, resetting the timer).
- Fluent Tween handles (runtime/native/tween.ludic): Tween.to / Tween.chain /
  Tween.delay build a sequenced, disposable handle advanced by a new engine-owned
  system (esys_tween, run each Update tick); Tween.value / Tween.done /
  Tween.parallel / Tween.stop read and control it. The 1-arg Tween.done(handle)
  is disambiguated from the 2-arg pure Tween.done(timer, dur).

Splicing: g_uses_anim_rt pulls in systems.ludic; g_uses_tween_rt pulls in
tween.ludic and inserts esys_tween into the Update phase. All integer and
deterministic, so animation and motion reproduce exactly under replay/lockstep.

Worked example + regression: examples/library/anim_sugar.ludic
(4 8 2 1 0 100 100 0 0 1 20 20 30 0 1). Twelve new docs pages (Anim, the new
Motion namespace, Tween handles). Full suite 77 passed, self-host C-free fixpoint
intact, no golden drift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-31 16:53:16 +03:00
parent 382826889f
commit 1f5e3c1c1a
25 changed files with 24149 additions and 21866 deletions

View file

@ -44,11 +44,15 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
}
if (ns == "Anim") {
if is_anim_ns(meth) { return emit_anim_ns(meth, e) }
perr(`unknown builtin Anim.{meth}`)
# else: the stateful Anim.play/clip/on_frame/fired sugar (#48) falls through
# to the bare table below (calls into systems.ludic).
}
if (ns == "Tween") {
if is_tween_ns(meth) { return emit_tween_ns(meth, e) }
perr(`unknown builtin Tween.{meth}`)
# else: the stateful Tween.to/chain/delay/value/stop/parallel handles (#48)
# fall through to the bare table below (calls into tween.ludic). The 1-arg
# Tween.done(handle) is disambiguated from the 2-arg pure form inside
# emit_tween_ns itself, so it stays routed through is_tween_ns above.
}
if (ns == "Collision") {
if is_collide_ns(meth) { return emit_collide_ns(meth, e) }
@ -285,6 +289,34 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
if (meth == "clear_normals") { bare = "light_clear_normals" }
if (meth == "time_of_day") { bare = "light_time_of_day"; push(labels, "t") }
}
# Anim.* / Motion.* ergonomic writes over the engine components (#48), spliced
# from runtime/native/systems.ludic. Anim.play(entity, "run") plays a named clip
# registered with Anim.clip; the four-arg Anim.play sets fps/frames/mode
# directly. on_frame arms a frame event the engine flags on SpriteAnim; fired
# reads that flag. Motion.to starts a value tween over the Motion component.
if (ns == "Anim") {
if (meth == "clip") { bare = "anim_clip"; push(labels, "name"); push(labels, "frames"); push(labels, "fps"); push(labels, "mode") }
if (meth == "on_frame") { bare = "anim_on_frame"; push(labels, "entity"); push(labels, "frame") }
if (meth == "fired") { bare = "anim_fired"; push(labels, "entity") }
if (meth == "play") {
if (len(e.kids) == 2) { bare = "anim_play_named"; push(labels, "entity"); push(labels, "clip") }
else { bare = "anim_play"; push(labels, "entity"); push(labels, "fps"); push(labels, "frames"); push(labels, "mode") }
}
}
if (ns == "Motion") {
if (meth == "to") { bare = "motion_to"; push(labels, "entity"); push(labels, "from"); push(labels, "to"); push(labels, "dur"); push(labels, "ease") }
}
# Tween.* fluent stateful handles (#48), spliced from runtime/native/tween.ludic
# and advanced each Update tick by esys_tween. These stand alongside the pure
# Tween.* interpolators (emit_anim.ludic): the stateful ones take/return a handle.
if (ns == "Tween") {
if (meth == "to") { bare = "tween_to"; push(labels, "from"); push(labels, "to"); push(labels, "dur"); push(labels, "ease") }
if (meth == "chain") { bare = "tween_chain"; push(labels, "handle"); push(labels, "to"); push(labels, "dur"); push(labels, "ease") }
if (meth == "delay") { bare = "tween_delay"; push(labels, "handle"); push(labels, "ticks") }
if (meth == "value") { bare = "tween_value"; push(labels, "handle") }
if (meth == "stop") { bare = "tween_stop"; push(labels, "handle") }
if (meth == "parallel") { bare = "tween_parallel"; push(labels, "a"); push(labels, "b") }
}
# Query.* — ECS spatial queries over the reflection ABI (runtime/native/query.ludic,
# spliced on demand). `prop` is a property id (World.prop_id); the spatial forms
# read two int fields (field ids) as (x, y). nearest/first return an entity (-1 =

View file

@ -63,6 +63,9 @@ function emit_engine_systems_for_phase(phase: pointer) -> void {
if (phase == "Update") {
emit_one_engine_system("SpriteAnim", "esys_spriteanim")
emit_one_engine_system("Motion", "esys_motion")
# Tween.* fluent handles (#48): advanced each Update tick when the game uses
# them (gated on g_uses_tween_rt rather than a declared component).
if g_uses_tween_rt and (find_fn("esys_tween") != null) { emit(" call void @fn_esys_tween()\n") }
}
if (phase == "Render") {
emit_one_engine_system("Light2D", "esys_light2d")

View file

@ -132,8 +132,14 @@ function emit_tween_ns(meth: pointer, e: Node) -> Val {
let c = emit_bind(`icmp sle i32 {u}, 65536`)
return val(emit_bind(`select i1 {c}, i32 {u}, i32 {back}`), "fixed")
}
if (meth == "done") { # timer >= duration -> bool
let timer = emit_expr(e.kids[0]); let dur = emit_expr(e.kids[1])
if (meth == "done") {
# 1-arg Tween.done(handle) -> the fluent stateful handle's completion (#48),
# a call into tween.ludic; the 2-arg Tween.done(timer, dur) is the pure form.
if (len(e.kids) == 1) {
let h = emit_expr(e.kids[0])
return val(emit_bind(`call i32 @fn_tween_done(i32 {h.code})`), "bool")
}
let timer = emit_expr(e.kids[0]); let dur = emit_expr(e.kids[1]) # timer >= duration -> bool
let c = emit_bind(`icmp sge i32 {timer.code}, {dur.code}`)
return val(emit_bind(`zext i1 {c} to i32`), "bool")
}

View file

@ -183,6 +183,15 @@ function p_postfix() -> Node {
# Input.* action-map / record-replay methods (#7) -> splice input.ludic.
# Input.key stays bare (no runtime), so gate on the new methods only.
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") { 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
# the engine auto-advance to do the ticking.
if e.a.kind == E_ID and e.a.s == "Anim" and (e.s == "play" or e.s == "clip" or e.s == "on_frame" or e.s == "fired") { g_uses_anim_rt = true }
if e.a.kind == E_ID and e.a.s == "Motion" and e.s == "to" { g_uses_anim_rt = true }
# Tween.to/chain/delay/value/stop/parallel (#48): the fluent stateful handles
# live in tween.ludic, advanced by an engine-owned system each Update tick.
if e.a.kind == E_ID and e.a.s == "Tween" and (e.s == "to" or e.s == "chain" or e.s == "delay" or e.s == "value" or e.s == "stop" or e.s == "parallel") { g_uses_tween_rt = true }
}
else { if is_op("[") { pi = pi + 1; let lo = expr()
if is_op("..") { pi = pi + 1; let sl = node(E_SLICE); sl.a = e; sl.b = lo; sl.c = expr(); eat_op("]"); e = sl } # s[a..b] substring
@ -412,6 +421,8 @@ var g_uses_value: bool = false # Value.*/Json.*/Reflect.serialize -> splice t
var g_uses_reflect_io: bool = false # Reflect.serialize/apply -> splice the reflection serializer
var g_uses_esys: bool = false # an engine-owned system component (SpriteAnim/Motion/Light2D) is declared -> splice systems.ludic + force the reflection ABI
var g_uses_input: bool = false # a program used Input.bind/down/poll/… (action maps + record/replay) -> splice input.ludic
var g_uses_anim_rt: bool = false # Anim.play/clip/on_frame/fired or Motion.to (#48) -> splice systems.ludic + force the reflection ABI
var g_uses_tween_rt: bool = false # Tween.to/chain/delay/… (#48) -> splice tween.ludic + run esys_tween each Update
function already_loaded(full: pointer) -> bool {
var i = 0
@ -623,6 +634,25 @@ function maybe_splice_runtime() -> void {
do_import("runtime/native/input.ludic")
cur_dir = saved
}
# Tween.* fluent handles (#48): splice the stateful tween runtime; esys_tween is
# inserted into the Update phase (emit_game.ludic) to advance handles each tick.
# It reads the live frame clock via the standard game loop, so pull core in too.
if g_uses_tween_rt {
cur_dir = ""
do_import("runtime/native/core.ludic")
do_import("runtime/native/tween.ludic")
cur_dir = saved
}
# Anim.play/Motion.to sugar (#48): the writes live in systems.ludic and use the
# reflection ABI, so splice it and force the world table even when the game does
# not otherwise trip uses_engine_systems.
if g_uses_anim_rt {
g_uses_esys = true
cur_dir = ""
do_import("runtime/native/core.ludic")
do_import("runtime/native/systems.ludic")
cur_dir = saved
}
if uses_engine_systems() {
g_uses_esys = true
cur_dir = ""

File diff suppressed because it is too large Load diff