feat(anim): animation ergonomics — named clips, Anim.play/Motion.to, frame events, fluent Tween handles (#48)
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:
parent
382826889f
commit
1f5e3c1c1a
25 changed files with 24149 additions and 21866 deletions
11
LANGUAGE.md
11
LANGUAGE.md
|
|
@ -268,7 +268,7 @@ enough, and a game that declares none is byte-for-byte unchanged.
|
|||
|
||||
| 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` |
|
||||
| `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 |
|
||||
|
|
@ -282,6 +282,15 @@ model Hero { Pos, SpriteAnim }
|
|||
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
|
||||
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
|
||||
|
|
|
|||
12
changes/anim-ergonomics.md
Normal file
12
changes/anim-ergonomics.md
Normal 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.
|
||||
|
|
@ -4,4 +4,4 @@ title: Anim
|
|||
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>.
|
||||
|
|
|
|||
35
docs/language/anim/anim-clip.md
Normal file
35
docs/language/anim/anim-clip.md
Normal 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()
|
||||
}
|
||||
}
|
||||
```
|
||||
28
docs/language/anim/anim-fired.md
Normal file
28
docs/language/anim/anim-fired.md
Normal 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…
|
||||
}
|
||||
}
|
||||
```
|
||||
33
docs/language/anim/anim-on_frame.md
Normal file
33
docs/language/anim/anim-on_frame.md
Normal 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()
|
||||
}
|
||||
}
|
||||
```
|
||||
34
docs/language/anim/anim-play.md
Normal file
34
docs/language/anim/anim-play.md
Normal 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()
|
||||
}
|
||||
}
|
||||
```
|
||||
7
docs/language/motion/_section.md
Normal file
7
docs/language/motion/_section.md
Normal 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>.
|
||||
33
docs/language/motion/motion-to.md
Normal file
33
docs/language/motion/motion-to.md
Normal 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()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -4,4 +4,4 @@ title: Tween
|
|||
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.
|
||||
|
|
|
|||
32
docs/language/tween/tween-chain.md
Normal file
32
docs/language/tween/tween-chain.md
Normal 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()
|
||||
}
|
||||
}
|
||||
```
|
||||
31
docs/language/tween/tween-delay.md
Normal file
31
docs/language/tween/tween-delay.md
Normal 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()
|
||||
}
|
||||
}
|
||||
```
|
||||
30
docs/language/tween/tween-parallel.md
Normal file
30
docs/language/tween/tween-parallel.md
Normal 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()
|
||||
}
|
||||
}
|
||||
```
|
||||
29
docs/language/tween/tween-stop.md
Normal file
29
docs/language/tween/tween-stop.md
Normal 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()
|
||||
}
|
||||
}
|
||||
```
|
||||
34
docs/language/tween/tween-to.md
Normal file
34
docs/language/tween/tween-to.md
Normal 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()
|
||||
}
|
||||
}
|
||||
```
|
||||
32
docs/language/tween/tween-value.md
Normal file
32
docs/language/tween/tween-value.md
Normal 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))
|
||||
}
|
||||
}
|
||||
```
|
||||
84
examples/library/anim_sugar.ludic
Normal file
84
examples/library/anim_sugar.ludic
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,127 @@ function esys_div(a: int, b: int) -> int {
|
|||
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) ---------------------------
|
||||
# Component contract — `property SpriteAnim { ticks: int, fps: int, frames: int,
|
||||
# mode: int, frame: int }`:
|
||||
|
|
@ -46,10 +167,13 @@ function esys_spriteanim() -> void {
|
|||
let f_frames = World.field_id(p, "frames")
|
||||
let f_mode = World.field_id(p, "mode")
|
||||
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_frame < 0 { return }
|
||||
var e = World.query_next(p, 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
|
||||
World.set(e, p, f_ticks, ticks)
|
||||
let fps = World.get(e, p, f_fps)
|
||||
|
|
@ -71,6 +195,13 @@ function esys_spriteanim() -> void {
|
|||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
208
runtime/native/tween.ludic
Normal file
208
runtime/native/tween.ludic
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
45127
selfhost/ludicc.seed.ll
45127
selfhost/ludicc.seed.ll
File diff suppressed because it is too large
Load diff
|
|
@ -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/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_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/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)")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue