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

@ -268,7 +268,7 @@ enough, and a game that declares none is byte-for-byte unchanged.
| Component | Phase | Effect | | Component | Phase | Effect |
|---|---|---| |---|---|---|
| `SpriteAnim { ticks, fps, frames, mode, frame }` | `Update` | advances `frame` — spritesheet frame animation (`mode` 0 loop, 1 once, 2 ping-pong) | | `SpriteAnim { ticks, fps, frames, mode, frame }` | `Update` | advances `frame` — spritesheet frame animation (`mode` 0 loop, 1 once, 2 ping-pong). Optional `event_frame`/`event_fired` fields arm a frame event (`Anim.on_frame` / `Anim.fired`) |
| `Motion { ticks, dur, from, to, ease, value, done }` | `Update` | advances `value` — value tween (`ease` 0 linear, 1 in, 2 out, 3 in-out), latches `done` | | `Motion { ticks, dur, from, to, ease, value, done }` | `Update` | advances `value` — value tween (`ease` 0 linear, 1 in, 2 out, 3 in-out), latches `done` |
| `Light2D { x, y, radius, color, intensity }` | `Render` | additive radial glow; the engine runs the whole 2D light pass and presents. Optional `direction`/`spread` (cone), `falloff`, `softness`, `gel` fields select the render-quality tiers | | `Light2D { x, y, radius, color, intensity }` | `Render` | additive radial glow; the engine runs the whole 2D light pass and presents. Optional `direction`/`spread` (cone), `falloff`, `softness`, `gel` fields select the render-quality tiers |
| `Occluder { x, y, w, h }` | `Render` | a rectangular shadow caster the light pass carves out | | `Occluder { x, y, w, h }` | `Render` | a rectangular shadow caster the light pass carves out |
@ -282,6 +282,15 @@ model Hero { Pos, SpriteAnim }
spawn Hero { Pos { x: 0, y: 0 } SpriteAnim { fps: 10, frames: 6, mode: 0 } } spawn Hero { Pos { x: 0, y: 0 } SpriteAnim { fps: 10, frames: 6, mode: 0 } }
``` ```
An **ergonomic layer** sits over the animation components: register named clips
with `Anim.clip("run", frames, fps, mode)` and (re)start one with
`Anim.play(entity, "run")` (or `Anim.play(entity, fps, frames, mode)`); arm frame
events with `Anim.on_frame` / read them with `Anim.fired`; start a value tween in
one call with `Motion.to(entity, from, to, dur, ease)`. Standalone **fluent tween
handles** — `Tween.to` / `Tween.chain` / `Tween.delay`, read with `Tween.value` /
`Tween.done` / `Tween.parallel` and cancelled with `Tween.stop` — sequence
multi-step motion the engine advances each tick, beyond a single `Motion`.
A `Light2D` / `Occluder` reads its position from a `Position { x, y }` component A `Light2D` / `Occluder` reads its position from a `Position { x, y }` component
on the same entity when the entity carries one, else from its own `x` / `y` on the same entity when the entity carries one, else from its own `x` / `y`
fields — so "Position + Light2D" and a self-positioned light both work. With fields — so "Position + Light2D" and a self-positioned light both work. With

View file

@ -0,0 +1,12 @@
bump: minor
type: feat
**Animation ergonomics (#48).** An ergonomic layer over the engine-owned
SpriteAnim/Motion systems: `Anim.clip` registers named spritesheet clips and
`Anim.play(entity, "run")` plays one (or `Anim.play(entity, fps, frames, mode)`
directly); `Anim.on_frame` / `Anim.fired` arm and read frame events (the engine
flags the tick a clip lands on a frame, gameplay reacts); `Motion.to` starts a
value tween over the Motion component in one call. New fluent, engine-advanced
`Tween` handles — `Tween.to` / `Tween.chain` / `Tween.delay` build a sequence,
`Tween.value` / `Tween.done` / `Tween.parallel` / `Tween.stop` read and control
it — advanced each Update tick by an engine-owned system. All integer and
deterministic, so animation and motion reproduce exactly under replay/lockstep.

View file

@ -4,4 +4,4 @@ title: Anim
order: 28 order: 28
--- ---
Spritesheet frame animation off the fixed frame clock. Store an elapsed <code>timer</code> (seconds, a <code>fixed</code>) on a component and each frame ask <a href="anim-frame"><code>Anim.frame</code></a> / <a href="anim-once"><code>Anim.once</code></a> / <a href="anim-pingpong"><code>Anim.pingpong</code></a> which cell to draw; <a href="anim-cell_x"><code>Anim.cell_x</code></a>/<a href="anim-cell_y"><code>Anim.cell_y</code></a> turn a frame index into a source rectangle on the sheet. Everything is integer/fixed and deterministic — the same timer reproduces the same frame every run, so replays and lockstep netcode match exactly. Spritesheet frame animation off the fixed frame clock. Store an elapsed <code>timer</code> (seconds, a <code>fixed</code>) on a component and each frame ask <a href="anim-frame"><code>Anim.frame</code></a> / <a href="anim-once"><code>Anim.once</code></a> / <a href="anim-pingpong"><code>Anim.pingpong</code></a> which cell to draw; <a href="anim-cell_x"><code>Anim.cell_x</code></a>/<a href="anim-cell_y"><code>Anim.cell_y</code></a> turn a frame index into a source rectangle on the sheet. Everything is integer/fixed and deterministic — the same timer reproduces the same frame every run, so replays and lockstep netcode match exactly. For the ECS engine-owned <code>SpriteAnim</code> component there is also an ergonomic layer: register named clips with <a href="anim-clip"><code>Anim.clip</code></a> and play them by name with <a href="anim-play"><code>Anim.play</code></a>, and arm frame events with <a href="anim-on_frame"><code>Anim.on_frame</code></a> / <a href="anim-fired"><code>Anim.fired</code></a>.

View file

@ -0,0 +1,35 @@
---
id: anim-clip
name: Anim.clip
category: anim
kind: namespace-method
tokens: Anim.clip
sig: Anim.clip(name, frames, fps, mode)
tip: Register a named spritesheet clip so Anim.play can play it by name.
order: 31
ns: Anim
member: clip
---
Registers (or updates) a spritesheet clip under a <code>name</code>, so gameplay refers to a motion by name — <code>Anim.play(entity, "run")</code> — instead of repeating its frame count and rate everywhere. The clip stores <code>frames</code> (cell count), <code>fps</code> (rate) and <code>mode</code> (0 loop, 1 once, 2 ping-pong). Names compare by identity (a string literal interns to one pointer), and the registry holds up to 32 clips. Registering the same name again overwrites it.
Parameters:
- `name` — the clip name (a string)
- `frames` — number of cells in the clip
- `fps` — playback rate in frames per second
- `mode` — 0 loop, 1 once (clamp on last), 2 ping-pong
```ludic
program Demo {
property SpriteAnim { ticks: int = 0, fps: int = 0, frames: int = 0, mode: int = 0, frame: int = 0 }
model Hero { SpriteAnim }
entry {
Anim.clip("idle", 2, 4, 0) # a slow 2-frame idle loop
Anim.clip("run", 6, 12, 0) # a 6-frame run
Anim.clip("hit", 3, 15, 1) # a one-shot hit reaction
spawn Hero { SpriteAnim { } }
Anim.play(World.query_next(World.prop_id("SpriteAnim"), 0), "run")
quit()
}
}
```

View file

@ -0,0 +1,28 @@
---
id: anim-fired
name: Anim.fired
category: anim
kind: namespace-method
tokens: Anim.fired
sig: Anim.fired(entity) -> bool
tip: Did the entity's clip land on its armed frame event this tick?
order: 33
ns: Anim
member: fired
---
Returns whether an entity's <code>SpriteAnim</code> clip landed on the frame armed by <a href="anim-on_frame"><code>Anim.on_frame</code></a> this tick — the read side of a frame event. It is true only on the single tick the clip first reaches that cell, so a game can poll it each frame and turn it into a gameplay action (emit an event, spawn a hitbox, play a footstep). Returns <code>false</code> if the entity has no <code>SpriteAnim</code> or no <code>event_fired</code> field.
Parameters:
- `entity` — the entity carrying `SpriteAnim`
```ludic
program Demo {
property SpriteAnim { ticks: int = 0, fps: int = 0, frames: int = 0, mode: int = 0, frame: int = 0, event_frame: int = 0, event_fired: int = 0 }
model Hero { SpriteAnim }
handler Step phase Update {
let e = World.query_next(World.prop_id("SpriteAnim"), 0)
if Anim.fired(e) { print(42) } # react: footstep, hitbox, event…
}
}
```

View file

@ -0,0 +1,33 @@
---
id: anim-on_frame
name: Anim.on_frame
category: anim
kind: namespace-method
tokens: Anim.on_frame
sig: Anim.on_frame(entity, frame)
tip: Arm a frame event — the engine flags the tick a clip lands on this frame.
order: 32
ns: Anim
member: on_frame
---
Arms a <strong>frame event</strong> on an entity's <code>SpriteAnim</code>: the engine sets the component's <code>event_fired</code> flag on the tick the clip <em>first lands</em> on <code>frame</code> — a footstep on the contact cell, a hitbox going live mid-swing. Gameplay reads the flag with <a href="anim-fired"><code>Anim.fired</code></a> in its own handler and reacts (emit its own event, spawn, play a sound). The engine detects the boundary; the game owns the reaction, so it stays inside the deterministic, no-runtime-dispatch event model. Requires the component to carry <code>event_frame</code> and <code>event_fired</code> fields.
Parameters:
- `entity` — the entity carrying `SpriteAnim`
- `frame` — the cell index that should fire the event
```ludic
program Demo {
property SpriteAnim { ticks: int = 0, fps: int = 0, frames: int = 0, mode: int = 0, frame: int = 0, event_frame: int = 0, event_fired: int = 0 }
model Hero { SpriteAnim }
entry {
spawn Hero { SpriteAnim { fps: 12, frames: 6, mode: 0 } }
let e = World.query_next(World.prop_id("SpriteAnim"), 0)
Anim.on_frame(e, 3) # fire when the run clip hits its contact frame
tick_fixed()
if Anim.fired(e) { print(1) }
quit()
}
}
```

View file

@ -0,0 +1,34 @@
---
id: anim-play
name: Anim.play
category: anim
kind: namespace-method
tokens: Anim.play
sig: Anim.play(entity, clip) | Anim.play(entity, fps, frames, mode)
tip: Start (or restart) a spritesheet clip on an entity in one call.
order: 30
ns: Anim
member: play
---
Starts a spritesheet clip on an entity's <code>SpriteAnim</code> component and rewinds it to its first cell, so gameplay swaps or replays an animation with one call instead of setting five fields by hand. Two forms: <code>Anim.play(entity, "run")</code> plays a <a href="anim-clip"><code>named clip</code></a> registered with <code>Anim.clip</code>; <code>Anim.play(entity, fps, frames, mode)</code> sets the clip directly (<code>mode</code> 0 loop, 1 once, 2 ping-pong). A no-op if the entity has no <code>SpriteAnim</code>. The engine advances the clip from there each tick.
Parameters:
- `entity` — the entity carrying `SpriteAnim`
- `clip` — a registered clip name (2-argument form), **or**
- `fps`, `frames`, `mode` — the clip rate, cell count and play mode (4-argument form)
```ludic
program Demo {
property SpriteAnim { ticks: int = 0, fps: int = 0, frames: int = 0, mode: int = 0, frame: int = 0 }
model Hero { SpriteAnim }
entry {
Anim.clip("run", 6, 12, 0)
spawn Hero { SpriteAnim { } }
let e = World.query_next(World.prop_id("SpriteAnim"), 0)
Anim.play(e, "run") # by name
Anim.play(e, 8, 4, 1) # or directly: 4 frames @ 8fps, once
quit()
}
}
```

View file

@ -0,0 +1,7 @@
---
id: motion
title: Motion
order: 29
---
Ergonomic control of the engine-owned <code>Motion</code> component — the value tween the engine advances each tick (see the ECS engine-systems). <a href="motion-to"><code>Motion.to</code></a> starts a tween on an entity from one value to another over a number of ticks with an easing curve, rewinding it so it plays from the start, instead of setting the component's <code>from</code>/<code>to</code>/<code>dur</code>/<code>ease</code> fields by hand. The tween is integer and deterministic, so motion reproduces exactly under replay and lockstep. For a standalone, sequenced tween not tied to a component, see the fluent <a href="tween"><code>Tween</code></a> handles. Related: <a href="anim"><code>Anim</code></a>, <a href="ease"><code>Ease</code></a>, <a href="tween"><code>Tween</code></a>.

View file

@ -0,0 +1,33 @@
---
id: motion-to
name: Motion.to
category: motion
kind: namespace-method
tokens: Motion.to
sig: Motion.to(entity, from, to, dur, ease)
tip: Start a value tween on an entity's Motion component in one call.
order: 1
ns: Motion
member: to
---
Starts a value tween on an entity's <code>Motion</code> component: it sets <code>from</code>, <code>to</code>, <code>dur</code> and <code>ease</code>, resets the timer, and seeds <code>value</code> at <code>from</code>, so the engine interpolates <code>value</code> from <code>from</code> to <code>to</code> over <code>dur</code> ticks and latches <code>done</code> at the end. One call replaces setting five fields by hand — a health bar sliding, a door opening, an alpha fade. A no-op if the entity has no <code>Motion</code>. Integer and deterministic.
Parameters:
- `entity` — the entity carrying `Motion`
- `from`, `to` — the start and end values (integer game units)
- `dur` — the duration in engine ticks
- `ease` — 0 linear, 1 in, 2 out, 3 in-out
```ludic
program Demo {
property Motion { ticks: int = 0, dur: int = 0, from: int = 0, to: int = 0, ease: int = 0, value: int = 0, done: int = 0 }
model Door { Motion }
entry {
spawn Door { Motion { } }
let e = World.query_next(World.prop_id("Motion"), 0)
Motion.to(e, 0, 64, 30, 2) # slide 0 -> 64 over 30 ticks, ease-out
quit()
}
}
```

View file

@ -4,4 +4,4 @@ title: Tween
order: 29 order: 29
--- ---
Value interpolation over a timeline, off the fixed frame clock. The timeline helpers <a href="tween-progress"><code>Tween.progress</code></a>/<a href="tween-loop"><code>Tween.loop</code></a>/<a href="tween-yoyo"><code>Tween.yoyo</code></a> turn an elapsed <code>timer</code> and a <code>duration</code> into a normalized amount; <a href="tween-ease"><code>Tween.ease</code></a> shapes that amount through an easing curve (shared with <a href="ease"><code>Ease</code></a>); and the typed blends <a href="tween-number"><code>Tween.number</code></a>/<a href="tween-round"><code>Tween.round</code></a>/<a href="tween-point"><code>Tween.point</code></a>/<a href="tween-tint"><code>Tween.tint</code></a> interpolate a <code>fixed</code>, <code>int</code>, <code>Vector</code>, or color. All deterministic fixed-point, so a replay reproduces every eased value exactly. Value interpolation over a timeline, off the fixed frame clock. The timeline helpers <a href="tween-progress"><code>Tween.progress</code></a>/<a href="tween-loop"><code>Tween.loop</code></a>/<a href="tween-yoyo"><code>Tween.yoyo</code></a> turn an elapsed <code>timer</code> and a <code>duration</code> into a normalized amount; <a href="tween-ease"><code>Tween.ease</code></a> shapes that amount through an easing curve (shared with <a href="ease"><code>Ease</code></a>); and the typed blends <a href="tween-number"><code>Tween.number</code></a>/<a href="tween-round"><code>Tween.round</code></a>/<a href="tween-point"><code>Tween.point</code></a>/<a href="tween-tint"><code>Tween.tint</code></a> interpolate a <code>fixed</code>, <code>int</code>, <code>Vector</code>, or color. All deterministic fixed-point, so a replay reproduces every eased value exactly. Beyond these pure interpolators there are <strong>fluent handles</strong> the engine advances for you: <a href="tween-to"><code>Tween.to</code></a> starts one and returns a handle, <a href="tween-chain"><code>Tween.chain</code></a> and <a href="tween-delay"><code>Tween.delay</code></a> sequence more segments, and <a href="tween-value"><code>Tween.value</code></a> / <a href="tween-done"><code>Tween.done</code></a> / <a href="tween-parallel"><code>Tween.parallel</code></a> / <a href="tween-stop"><code>Tween.stop</code></a> read and control them.

View file

@ -0,0 +1,32 @@
---
id: tween-chain
name: Tween.chain
category: tween
kind: namespace-method
tokens: Tween.chain
sig: Tween.chain(handle, to, dur, ease) -> handle
tip: Append a tween segment that runs after the handle's current queue.
order: 31
ns: Tween
member: chain
---
Appends a tween segment to a <a href="tween-to"><code>handle</code></a> that runs <em>after</em> its current segments finish, continuing from where the previous one ended — so the game only names the new target. Returns the same handle, so calls chain fluently. A handle holds up to eight segments; combine with <a href="tween-delay"><code>Tween.delay</code></a> for pauses. The engine advances the whole sequence one segment at a time.
Parameters:
- `handle` — the tween handle to extend
- `to` — the target value for this segment
- `dur` — the segment length in engine ticks
- `ease` — 0 linear, 1 in, 2 out, 3 in-out
```ludic
program Demo {
property Marker { n: int = 0 }
model M { Marker }
entry {
var h = Tween.to(0, 100, 20, 2) # rise
h = Tween.chain(h, 0, 20, 1) # then fall back
quit()
}
}
```

View file

@ -0,0 +1,31 @@
---
id: tween-delay
name: Tween.delay
category: tween
kind: namespace-method
tokens: Tween.delay
sig: Tween.delay(handle, ticks) -> handle
tip: Append a pause to a tween handle's sequence.
order: 32
ns: Tween
member: delay
---
Appends a <strong>pause</strong> of <code>ticks</code> ticks to a <a href="tween-to"><code>handle</code></a>'s sequence: the value holds where the previous segment left it, then the next <a href="tween-chain"><code>chained</code></a> segment begins. Returns the handle for fluent chaining. Use it to stagger a multi-step motion — rise, hold, fall — or to offset one handle from another running in parallel.
Parameters:
- `handle` — the tween handle to extend
- `ticks` — how many engine ticks to hold
```ludic
program Demo {
property Marker { n: int = 0 }
model M { Marker }
entry {
var h = Tween.to(0, 50, 15, 0) # move
h = Tween.delay(h, 10) # wait
h = Tween.chain(h, 100, 15, 0) # move again
quit()
}
}
```

View file

@ -0,0 +1,30 @@
---
id: tween-parallel
name: Tween.parallel
category: tween
kind: namespace-method
tokens: Tween.parallel
sig: Tween.parallel(a, b) -> bool
tip: Are two tween handles both finished? — a parallel completion query.
order: 35
ns: Tween
member: parallel
---
Returns whether two <a href="tween-to"><code>handles</code></a> are <em>both</em> finished. Independent handles are advanced together every frame, so running several at once already runs them in parallel; this is the "wait for all" query over a pair — start a fade and a slide together, then act when both complete. For longer sequences on a single value, chain segments with <a href="tween-chain"><code>Tween.chain</code></a> instead.
Parameters:
- `a`, `b` — the two tween handles to test
```ludic
program Demo {
property Marker { n: int = 0 }
model M { Marker }
entry {
let fade = Tween.to(255, 0, 20, 0)
let slide = Tween.to(0, 80, 30, 2)
if Tween.parallel(fade, slide) { print(1) } # both done?
quit()
}
}
```

View file

@ -0,0 +1,29 @@
---
id: tween-stop
name: Tween.stop
category: tween
kind: namespace-method
tokens: Tween.stop
sig: Tween.stop(handle)
tip: Stop and dispose a tween handle, freezing its value.
order: 34
ns: Tween
member: stop
---
Stops a <a href="tween-to"><code>tween handle</code></a> immediately and frees its slot back to the pool. Its <a href="tween-value"><code>value</code></a> is frozen where it was, and <a href="tween-done"><code>Tween.done</code></a> then reports it finished. Use it to cancel a motion early — an interrupted animation, an entity that despawned mid-tween — so long-lived games recycle handles instead of exhausting the pool.
Parameters:
- `handle` — the tween handle to stop
```ludic
program Demo {
property Marker { n: int = 0 }
model M { Marker }
entry {
let h = Tween.to(0, 100, 60, 0)
Tween.stop(h) # cancel it now
quit()
}
}
```

View file

@ -0,0 +1,34 @@
---
id: tween-to
name: Tween.to
category: tween
kind: namespace-method
tokens: Tween.to
sig: Tween.to(from, to, dur, ease) -> handle
tip: Start a fluent, engine-advanced tween and return a handle.
order: 30
ns: Tween
member: to
---
Starts a <strong>stateful tween handle</strong> — from <code>from</code> to <code>to</code> over <code>dur</code> ticks with easing <code>ease</code> — and returns an integer handle. Unlike the pure <a href="tween-progress"><code>Tween.progress</code></a> interpolators (which the game drives from its own timer), a handle is <em>advanced by the engine</em> one tick per frame: fire it once, then read <a href="tween-value"><code>Tween.value</code></a> each frame and <a href="tween-done"><code>Tween.done</code></a> to know when it finishes. <a href="tween-chain"><code>Tween.chain</code></a> and <a href="tween-delay"><code>Tween.delay</code></a> append segments for a sequence. Integer and deterministic, so a sequence plays identically under replay. The handle pool is fixed-size and reuses finished slots.
Parameters:
- `from`, `to` — the start and end values (integer game units)
- `dur` — the segment length in engine ticks
- `ease` — 0 linear, 1 in, 2 out, 3 in-out
```ludic
program Demo {
property SpriteAnim { ticks: int = 0, fps: int = 0, frames: int = 0, mode: int = 0, frame: int = 0 }
property Pop { h: int = 0 }
model Coin { SpriteAnim, Pop }
entry {
spawn Coin { Pop { h: 0 } }
let e = World.query_next(World.prop_id("Pop"), 0)
let f = World.field_id(World.prop_id("Pop"), "h")
World.set(e, World.prop_id("Pop"), f, Tween.to(0, 40, 20, 2)) # pop upward
quit()
}
}
```

View file

@ -0,0 +1,32 @@
---
id: tween-value
name: Tween.value
category: tween
kind: namespace-method
tokens: Tween.value
sig: Tween.value(handle) -> int
tip: The current value of a fluent tween handle this frame.
order: 33
ns: Tween
member: value
---
Returns the current interpolated value of a <a href="tween-to"><code>tween handle</code></a> this frame — the number the engine advanced it to. Read it each frame to drive a position, an alpha, a scale. Once every segment has finished the value rests on the final target; <a href="tween-done"><code>Tween.done</code></a> reports when that has happened. Returns <code>0</code> for an invalid handle.
Parameters:
- `handle` — the tween handle to read
```ludic
program Demo {
property Pos { x: int = 0 }
property Tw { h: int = 0 }
model Mover { Pos, Tw }
handler Move phase Update {
let p = World.prop_id("Pos")
let t = World.prop_id("Tw")
let e = World.query_next(t, 0)
let h = World.get(e, t, World.field_id(t, "h"))
World.set(e, p, World.field_id(p, "x"), Tween.value(h))
}
}
```

View file

@ -0,0 +1,84 @@
# anim_sugar.ludic — the animation ergonomics from #48, layered over the
# engine-owned SpriteAnim / Motion systems (#43) and the fixed frame clock:
#
# * Anim.clip / Anim.play — named spritesheet clips ("run") and one-call
# (re)start, direct or by name
# * Anim.on_frame / fired — a frame event the engine flags, gameplay reacts to
# * Motion.to — start a value tween over the Motion component
# * Tween.to/chain/delay — a fluent, engine-advanced tween handle
# * Tween.parallel/done — completion queries over handles
#
# Everything is integer + deterministic (the clock ticks 60/s), so a full run
# prints: 4 8 2 1 0 100 100 0 0 1 20 20 30 0 1
program AnimSugar {
property SpriteAnim { ticks: int = 0, fps: int = 0, frames: int = 0, mode: int = 0, frame: int = 0, event_frame: int = 0, event_fired: int = 0 }
property Motion { ticks: int = 0, dur: int = 0, from: int = 0, to: int = 0, ease: int = 0, value: int = 0, done: int = 0 }
model Sprite { SpriteAnim }
model Mover { Motion }
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 }
entry {
Anim.clip("run", 4, 12, 0) # register a 4-frame, 12fps loop
let sa = World.prop_id("SpriteAnim")
let saf = World.field_id(sa, "frame")
let sfr = World.field_id(sa, "frames")
spawn Sprite { SpriteAnim { } }
let sprite = World.query_next(sa, 0)
Anim.play(sprite, "run") # named clip -> frames = 4
print(World.get(sprite, sa, sfr)) # 4
Anim.play(sprite, 6, 8, 1) # direct: fps 6, frames 8, once
print(World.get(sprite, sa, sfr)) # 8
# frame event: arm frame 2 on the looping "run" clip, then tick onto it
Anim.play(sprite, "run")
Anim.on_frame(sprite, 2)
tick_n(10) # frame = (10*12/60) % 4 = 2
print(World.get(sprite, sa, saf)) # 2
print(bi(Anim.fired(sprite))) # 1 — landed on the armed frame
tick_n(1)
print(bi(Anim.fired(sprite))) # 0 — no new landing
# Motion.to: one call starts a tween over the Motion component
spawn Mover { Motion { } }
let mo = World.prop_id("Motion")
let mov = World.field_id(mo, "value")
let mover = World.query_next(mo, 0)
Motion.to(mover, 0, 100, 10, 0) # linear 0..100 over 10 ticks
tick_n(10)
print(World.get(mover, mo, mov)) # 100
# Tween fluent handle: 0->100, then 100->0
var h = Tween.to(0, 100, 10, 0)
h = Tween.chain(h, 0, 10, 0)
tick_n(10)
print(Tween.value(h)) # 100 — end of first segment
print(bi(Tween.done(h))) # 0 — chained segment pending
tick_n(10)
print(Tween.value(h)) # 0 — end of chained segment
print(bi(Tween.done(h))) # 1
# Tween with a delay in the middle, and a parallel completion query
var g = Tween.to(10, 20, 10, 0)
g = Tween.delay(g, 5)
g = Tween.chain(g, 30, 10, 0)
tick_n(10)
print(Tween.value(g)) # 20 — first segment done
tick_n(5)
print(Tween.value(g)) # 20 — held through the delay
tick_n(10)
print(Tween.value(g)) # 30 — chained segment done
let p = Tween.to(0, 5, 4, 0)
let q = Tween.to(0, 9, 8, 0)
tick_n(4)
print(bi(Tween.parallel(p, q))) # 0 — q still running
tick_n(4)
print(bi(Tween.parallel(p, q))) # 1 — both done
quit()
}
}

View file

@ -28,6 +28,127 @@ function esys_div(a: int, b: int) -> int {
return a / b return a / b
} }
# ---- named clip registry (#48) ---------------------------------------------
# A game registers spritesheet clips by name — Anim.clip("run", 6, 12, 0) — and
# plays one with Anim.play(entity, "run"), so gameplay names a motion instead of
# hand-writing fps/frames/mode into a component. A small name-keyed table, the
# same shape the input action map uses; string names compare by byte-pointer
# identity (a string literal interns to one pointer per program).
const ANIM_MAX_CLIPS: int = 32
var anim_clip_names: pointers = null # clip name per slot
var anim_clip_fps: words = null
var anim_clip_frames: words = null
var anim_clip_mode: words = null
var anim_nclips: int = 0
function anim_clip_init() -> void {
if anim_clip_names == null {
anim_clip_names = bytes(ANIM_MAX_CLIPS * 8) # a pointer (8 bytes) per slot
anim_clip_fps = words(ANIM_MAX_CLIPS)
anim_clip_frames = words(ANIM_MAX_CLIPS)
anim_clip_mode = words(ANIM_MAX_CLIPS)
}
}
# register (or update) a named clip. mode is the SpriteAnim mode: 0 loop, 1 once,
# 2 ping-pong.
function anim_clip(name: pointer, frames: int, fps: int, mode: int) -> void {
anim_clip_init()
var i = 0
while i < anim_nclips {
if anim_clip_names[i] == name {
anim_clip_frames[i] = frames; anim_clip_fps[i] = fps; anim_clip_mode[i] = mode
return
}
i = i + 1
}
if anim_nclips >= ANIM_MAX_CLIPS { return } # silently ignore past capacity
let s = anim_nclips
anim_clip_names[s] = name
anim_clip_frames[s] = frames
anim_clip_fps[s] = fps
anim_clip_mode[s] = mode
anim_nclips = anim_nclips + 1
}
function anim_clip_find(name: pointer) -> int {
anim_clip_init()
var i = 0
while i < anim_nclips {
if anim_clip_names[i] == name { return i }
i = i + 1
}
return 0 - 1
}
# ---- Anim.play / Anim.on_frame / Anim.fired (#48) --------------------------
# Ergonomic writes over the SpriteAnim component through the reflection ABI, so a
# game restarts or swaps a clip with one call instead of setting five fields by
# hand. All no-op cleanly if the entity has no SpriteAnim (or a field is absent).
# write one SpriteAnim int field on entity e (by name), if present.
function anim_set(e: int, field: pointer, v: int) -> void {
let p = World.prop_id("SpriteAnim")
if p < 0 { return }
let f = World.field_id(p, field)
if f >= 0 { World.set(e, p, f, v) }
}
# start / restart a clip on entity e: set fps/frames/mode and rewind ticks to 0
# so the clip plays from its first cell this tick.
function anim_play(e: int, fps: int, frames: int, mode: int) -> void {
anim_set(e, "fps", fps)
anim_set(e, "frames", frames)
anim_set(e, "mode", mode)
anim_set(e, "ticks", 0)
anim_set(e, "event_fired", 0)
}
# start a registered clip by name (a no-op if the name is unknown).
function anim_play_named(e: int, name: pointer) -> void {
let c = anim_clip_find(name)
if c < 0 { return }
anim_play(e, anim_clip_fps[c], anim_clip_frames[c], anim_clip_mode[c])
}
# arm a frame event: the engine flags SpriteAnim.event_fired = 1 on the tick the
# clip first lands on `frame` (a footstep, a hitbox going live). The game reads
# the flag in its own handler and reacts (emit its own event, spawn, …) — the
# engine detects the boundary, gameplay owns the reaction, so it stays within the
# no-runtime-dispatch event model.
function anim_on_frame(e: int, frame: int) -> void {
anim_set(e, "event_frame", frame)
}
# did entity e's clip land on its armed event frame this tick?
function anim_fired(e: int) -> bool {
let p = World.prop_id("SpriteAnim")
if p < 0 { return false }
let f = World.field_id(p, "event_fired")
if f < 0 { return false }
return World.get(e, p, f) != 0
}
# ---- Motion.to (#48) -------------------------------------------------------
# Start a value tween on entity e over the Motion component: from -> to over `dur`
# ticks with easing `ease`, rewinding ticks so it plays from the start. A no-op
# if the entity has no Motion.
function motion_to(e: int, from: int, to: int, dur: int, ease: int) -> void {
let p = World.prop_id("Motion")
if p < 0 { return }
motion_set(e, p, "from", from)
motion_set(e, p, "to", to)
motion_set(e, p, "dur", dur)
motion_set(e, p, "ease", ease)
motion_set(e, p, "ticks", 0)
motion_set(e, p, "done", 0)
motion_set(e, p, "value", from)
}
function motion_set(e: int, p: int, field: pointer, v: int) -> void {
let f = World.field_id(p, field)
if f >= 0 { World.set(e, p, f, v) }
}
# ---- SpriteAnim: spritesheet frame advance (#43) --------------------------- # ---- SpriteAnim: spritesheet frame advance (#43) ---------------------------
# Component contract — `property SpriteAnim { ticks: int, fps: int, frames: int, # Component contract — `property SpriteAnim { ticks: int, fps: int, frames: int,
# mode: int, frame: int }`: # mode: int, frame: int }`:
@ -46,10 +167,13 @@ function esys_spriteanim() -> void {
let f_frames = World.field_id(p, "frames") let f_frames = World.field_id(p, "frames")
let f_mode = World.field_id(p, "mode") let f_mode = World.field_id(p, "mode")
let f_frame = World.field_id(p, "frame") let f_frame = World.field_id(p, "frame")
let f_evfr = World.field_id(p, "event_frame") # optional: the frame to flag
let f_evfd = World.field_id(p, "event_fired") # optional: OUTPUT, 1 on the landing tick
if f_ticks < 0 { return } if f_ticks < 0 { return }
if f_frame < 0 { return } if f_frame < 0 { return }
var e = World.query_next(p, 0) var e = World.query_next(p, 0)
while e >= 0 { while e >= 0 {
let prev = World.get(e, p, f_frame) # last tick's cell (for the frame-event edge)
let ticks = World.get(e, p, f_ticks) + 1 let ticks = World.get(e, p, f_ticks) + 1
World.set(e, p, f_ticks, ticks) World.set(e, p, f_ticks, ticks)
let fps = World.get(e, p, f_fps) let fps = World.get(e, p, f_fps)
@ -71,6 +195,13 @@ function esys_spriteanim() -> void {
} }
} }
World.set(e, p, f_frame, fr) World.set(e, p, f_frame, fr)
# frame event: flag the tick the clip first lands on its armed frame (edge).
if (f_evfr >= 0) and (f_evfd >= 0) {
let evfr = World.get(e, p, f_evfr)
var fired = 0
if (fr == evfr) and (prev != evfr) { fired = 1 }
World.set(e, p, f_evfd, fired)
}
e = World.query_next(p, e + 1) e = World.query_next(p, e + 1)
} }
} }

208
runtime/native/tween.ludic Normal file
View file

@ -0,0 +1,208 @@
# ============================================================================
# tween.ludic — fluent, stateful tween handles the engine advances (#48).
#
# The pure Tween.* namespace (emit_anim.ludic) interpolates a single value from a
# game-managed timer. This adds the *sequenced* layer the follow-up asked for: a
# disposable handle that chains several segments — tween, delay, tween — and that
# the engine advances one tick per frame, so a game fires a multi-step motion once
# and just reads the current value:
#
# var h = Tween.to(0, 100, 30, 2) # ease-out 0 -> 100 over 30 ticks
# h = Tween.delay(h, 15) # hold 15 ticks
# h = Tween.chain(h, 0, 30, 1) # then ease-in 100 -> 0 over 30 ticks
# # ...each frame the engine advances it...
# sprite.x = Tween.value(h)
# if Tween.done(h) { ... }
#
# Everything is integer + Q16.16-free (the ease curves run in 0..1024 fixed-point,
# like Motion), so a sequence plays identically on every run and headless — free
# replays and lockstep. The handle pool is fixed-size; Tween.to reuses a finished
# slot, so a game that starts and finishes tweens forever never runs out.
# ============================================================================
const TW_MAX: int = 64 # concurrent handles
const TW_SEGS: int = 8 # segments per handle (chain depth)
# per-handle state
var tw_used: words = null # slot in use (1) or free (0)
var tw_seg: words = null # index of the segment currently playing
var tw_nseg: words = null # number of segments queued
var tw_tick: words = null # ticks elapsed inside the current segment
var tw_value: words = null # OUTPUT: the value this frame
var tw_done: words = null # OUTPUT: 1 once every segment has finished
# per-segment state (flat: handle * TW_SEGS + seg)
var tw_kind: words = null # 0 = tween (from->to), 1 = delay (hold)
var tw_from: words = null
var tw_to: words = null
var tw_dur: words = null # segment length in ticks
var tw_ease: words = null # 0 linear, 1 in, 2 out, 3 in-out (Motion's curves)
function tw_init() -> void {
if tw_used == null {
tw_used = words(TW_MAX)
tw_seg = words(TW_MAX)
tw_nseg = words(TW_MAX)
tw_tick = words(TW_MAX)
tw_value = words(TW_MAX)
tw_done = words(TW_MAX)
tw_kind = words(TW_MAX * TW_SEGS)
tw_from = words(TW_MAX * TW_SEGS)
tw_to = words(TW_MAX * TW_SEGS)
tw_dur = words(TW_MAX * TW_SEGS)
tw_ease = words(TW_MAX * TW_SEGS)
}
}
# claim a free handle (a finished or never-used slot). -1 if the pool is full.
function tw_alloc() -> int {
tw_init()
var i = 0
while i < TW_MAX {
if tw_used[i] == 0 { return i }
i = i + 1
}
# none free: reuse the first finished handle so long-lived games don't leak.
i = 0
while i < TW_MAX {
if tw_done[i] != 0 { return i }
i = i + 1
}
return 0 - 1
}
# append one segment to a handle (internal). Silently ignored past TW_SEGS.
function tw_push(h: int, kind: int, from: int, to: int, dur: int, ease: int) -> void {
if (h < 0) or (h >= TW_MAX) { return }
let n = tw_nseg[h]
if n >= TW_SEGS { return }
let s = h * TW_SEGS + n
tw_kind[s] = kind
tw_from[s] = from
tw_to[s] = to
tw_dur[s] = dur
tw_ease[s] = ease
tw_nseg[h] = n + 1
}
# start a new tween handle: from -> to over `dur` ticks with easing `ease`.
function tween_to(from: int, to: int, dur: int, ease: int) -> int {
let h = tw_alloc()
if h < 0 { return 0 - 1 }
tw_used[h] = 1
tw_seg[h] = 0
tw_nseg[h] = 0
tw_tick[h] = 0
tw_value[h] = from
tw_done[h] = 0
tw_push(h, 0, from, to, dur, ease)
return h
}
# chain a tween segment after the handle's current queue: continues from where the
# previous segment ends, so the game only names the new target. Returns the handle
# for fluent chaining.
function tween_chain(h: int, to: int, dur: int, ease: int) -> int {
if (h < 0) or (h >= TW_MAX) { return h }
var from = tw_value[h]
let n = tw_nseg[h]
if n > 0 {
let last = h * TW_SEGS + (n - 1)
if tw_kind[last] == 0 { from = tw_to[last] } # continue from the previous tween's end
}
tw_push(h, 0, from, from + (to - from), dur, ease) # `to` is the absolute target
return h
}
# chain a pause of `ticks` ticks (the value holds). Returns the handle.
function tween_delay(h: int, ticks: int) -> int {
if (h < 0) or (h >= TW_MAX) { return h }
tw_push(h, 1, 0, 0, ticks, 0)
return h
}
# the value of a handle this frame.
function tween_value(h: int) -> int {
if (h < 0) or (h >= TW_MAX) { return 0 }
return tw_value[h]
}
# has every segment of the handle finished?
function tween_done(h: int) -> bool {
if (h < 0) or (h >= TW_MAX) { return true }
return tw_done[h] != 0
}
# are two handles both finished? — the completion of a parallel pair. Independent
# handles advance together each frame, so running several at once *is* parallel;
# this is the "all done" query over a pair.
function tween_parallel(a: int, b: int) -> bool {
return tween_done(a) and tween_done(b)
}
# free a handle immediately (stop and dispose). Its value is frozen where it was.
function tween_stop(h: int) -> void {
if (h < 0) or (h >= TW_MAX) { return }
tw_used[h] = 0
tw_done[h] = 1
}
# floor of a/b for non-negative a (the tick counter only ever rises).
function tw_div(a: int, b: int) -> int {
if b <= 0 { return 0 }
return a / b
}
# the same integer easing curves Motion uses (progress carried in 0..1024).
function tween_ease(t: int, ease: int) -> int {
if ease == 1 { return t * t / 1024 }
if ease == 2 { let u = 1024 - t; return 1024 - (u * u / 1024) }
if ease == 3 {
if t < 512 { return (t * t / 1024) * 2 }
let u = 1024 - t
return 1024 - (u * u / 1024) * 2
}
return t
}
# advance one active handle by a tick: interpolate within the current segment,
# roll over to the next when it ends, latch done past the last.
function tw_advance_one(h: int) -> void {
if tw_done[h] != 0 { return }
let n = tw_nseg[h]
if n == 0 { tw_done[h] = 1; return }
var seg = tw_seg[h]
if seg >= n { tw_done[h] = 1; return }
var tick = tw_tick[h] + 1
let s = h * TW_SEGS + seg
let dur = tw_dur[s]
let kind = tw_kind[s]
if kind == 0 { # a tween segment
var t = 1024
if dur > 0 { t = tw_div(tick * 1024, dur) }
if t > 1024 { t = 1024 }
let te = tween_ease(t, tw_ease[s])
tw_value[h] = tw_from[s] + (tw_to[s] - tw_from[s]) * te / 1024
}
# (a delay segment holds tw_value unchanged.)
if tick >= dur { # this segment finished: advance
if kind == 0 { tw_value[h] = tw_to[s] } # rest exactly on the target
seg = seg + 1
tw_seg[h] = seg
tw_tick[h] = 0
if seg >= n { tw_done[h] = 1 }
} else {
tw_tick[h] = tick
}
}
# the engine-owned system: advance every active tween handle one tick. Inserted
# into the Update phase when a game uses Tween.to (emit_game.ludic).
function esys_tween() -> void {
if tw_used == null { return }
var i = 0
while i < TW_MAX {
if (tw_used[i] != 0) and (tw_done[i] == 0) { tw_advance_one(i) }
i = i + 1
}
}

View file

@ -44,11 +44,15 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
} }
if (ns == "Anim") { if (ns == "Anim") {
if is_anim_ns(meth) { return emit_anim_ns(meth, e) } 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 (ns == "Tween") {
if is_tween_ns(meth) { return emit_tween_ns(meth, e) } 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 (ns == "Collision") {
if is_collide_ns(meth) { return emit_collide_ns(meth, e) } 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 == "clear_normals") { bare = "light_clear_normals" }
if (meth == "time_of_day") { bare = "light_time_of_day"; push(labels, "t") } 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, # 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 # 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 = # 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") { if (phase == "Update") {
emit_one_engine_system("SpriteAnim", "esys_spriteanim") emit_one_engine_system("SpriteAnim", "esys_spriteanim")
emit_one_engine_system("Motion", "esys_motion") 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") { if (phase == "Render") {
emit_one_engine_system("Light2D", "esys_light2d") 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`) let c = emit_bind(`icmp sle i32 {u}, 65536`)
return val(emit_bind(`select i1 {c}, i32 {u}, i32 {back}`), "fixed") return val(emit_bind(`select i1 {c}, i32 {u}, i32 {back}`), "fixed")
} }
if (meth == "done") { # timer >= duration -> bool if (meth == "done") {
let timer = emit_expr(e.kids[0]); let dur = emit_expr(e.kids[1]) # 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}`) let c = emit_bind(`icmp sge i32 {timer.code}, {dur.code}`)
return val(emit_bind(`zext i1 {c} to i32`), "bool") 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.* action-map / record-replay methods (#7) -> splice input.ludic.
# Input.key stays bare (no runtime), so gate on the new methods only. # 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 } 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() 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 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_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_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_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 { function already_loaded(full: pointer) -> bool {
var i = 0 var i = 0
@ -623,6 +634,25 @@ function maybe_splice_runtime() -> void {
do_import("runtime/native/input.ludic") do_import("runtime/native/input.ludic")
cur_dir = saved 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() { if uses_engine_systems() {
g_uses_esys = true g_uses_esys = true
cur_dir = "" cur_dir = ""

File diff suppressed because it is too large Load diff

View file

@ -196,6 +196,7 @@ function cmd_test() -> int {
feat_case("library/regex", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "regex.ludic (Regex match/find/groups/classes/quantifiers/replace + linear-time safety)") feat_case("library/regex", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "regex.ludic (Regex match/find/groups/classes/quantifiers/replace + linear-time safety)")
feat_case("library/grid", "", "1 2 3 4 5 6 7 8 9 10 11 12 13", "grid.ludic (Grid line/flood/line_of_sight + A* pathfinding over the tilemap)") feat_case("library/grid", "", "1 2 3 4 5 6 7 8 9 10 11 12 13", "grid.ludic (Grid line/flood/line_of_sight + A* pathfinding over the tilemap)")
feat_case("library/anim", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34", "anim.ludic (Anim frame/once/pingpong/cell + Tween progress/loop/yoyo/ease/number/round/point/tint)") feat_case("library/anim", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34", "anim.ludic (Anim frame/once/pingpong/cell + Tween progress/loop/yoyo/ease/number/round/point/tint)")
feat_case("library/anim_sugar", "", "4 8 2 1 0 100 100 0 0 1 20 20 30 0 1", "anim_sugar.ludic (Anim.clip/play/on_frame/fired + Motion.to + fluent Tween.to/chain/delay/parallel handles; issue #48)")
feat_case("library/query", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "query.ludic (Query count/first/nearest/within — ECS spatial queries over the reflection ABI)") feat_case("library/query", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "query.ludic (Query count/first/nearest/within — ECS spatial queries over the reflection ABI)")
feat_case("library/reflect", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20", "reflect.ludic (Reflect prop/field enumeration + type + get/set/has/kind — runtime reflection over the world schema)") feat_case("library/reflect", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20", "reflect.ludic (Reflect prop/field enumeration + type + get/set/has/kind — runtime reflection over the world schema)")
feat_case("library/serialize", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16", "serialize.ludic (Value tree + Json encode/parse + Reflect.serialize/apply — bit-exact save/load; issue #44)") feat_case("library/serialize", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16", "serialize.ludic (Value tree + Json encode/parse + Reflect.serialize/apply — bit-exact save/load; issue #44)")