Two ECS-native, deterministic namespaces for 2D motion, driven off the fixed
frame clock so replays and lockstep netcode reproduce every frame and every
eased value exactly. Both are pure computed-inline Q16.16 / integer math (no new
runtime, no heap) — the game stores a timer on a component and calls these each
frame, exactly the way Collision.* / Grid.* are used.
Anim.* — spritesheet frame animation:
- Anim.frame(timer,fps,count) -> int looping frame index
- Anim.once(timer,fps,count) -> int one-shot, clamps on the last frame
- Anim.pingpong(timer,fps,count) -> int bounce 0..count-1..0
- Anim.finished(timer,fps,count) -> bool has a one-shot run past its end?
- Anim.duration(fps,count) -> fixed seconds for one cycle
- Anim.cell_x/cell_y(frame,cols,cell) -> int source rect on a grid sheet
Tween.* — value interpolation over a timeline:
- Tween.progress/loop/yoyo(timer,duration) -> fixed normalized amount
- Tween.done(timer,duration) -> bool
- Tween.ease(t, mode) -> fixed shape by a literal curve 0..6,
the same curves as Ease.* (now
factored into a shared ease_eval)
- Tween.number/round/point/tint(from,to,t) blend a fixed / int / Vector / color
The typed blends reuse the existing fixed / Vector / color helpers, and
Tween.ease shares Ease.*'s exact formulas via the new ease_eval(mode,t) — one
source of truth for every easing curve in the engine.
examples/library/anim.ludic asserts 34 cases (frame math, clamping, ping-pong,
cell geometry, timeline clamp/loop/yoyo, rounding, color/vector blends, and
Ease.in == Tween.ease(.,1)); wired into x test (now 62 passed). Docs: Anim +
Tween sections with 16 per-symbol pages, inventory/coverage green. Seed
reseeded; the C-free bootstrap fixpoint holds.
The stateful sugar the proposal sketches (named clips, Anim.play, fluent
Tween.chain/parallel handles, and an auto-injected advance system) is deliberately
left as a follow-up — this lands the deterministic math core both halves stand on.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
87 lines
4.1 KiB
Text
87 lines
4.1 KiB
Text
# anim.ludic — Anim.* spritesheet frame animation and Tween.* value tweening,
|
|
# both pure and deterministic off the fixed frame clock. Each assertion that
|
|
# holds prints its number, so a full run prints:
|
|
# 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
|
|
# A `timer` is elapsed seconds as a fixed; fps / frame counts are ints; tween
|
|
# amounts are a fixed in 0..1. Timers are exact-in-fixed so floor(timer*fps)
|
|
# never lands ambiguously on a frame boundary.
|
|
program Anim {
|
|
property Tag { v: int = 0 }
|
|
model Marker { Tag }
|
|
|
|
handler Boot phase Start {
|
|
let F0 = fixed(0)
|
|
let F1 = fixed(1)
|
|
let HALF = fixed(1) / fixed(2) # 0.5
|
|
let QTR = fixed(1) / fixed(4) # 0.25
|
|
let E18 = fixed(1) / fixed(8) # 0.125
|
|
let E78 = fixed(7) / fixed(8) # 0.875
|
|
let F125 = fixed(5) / fixed(4) # 1.25
|
|
let F15 = fixed(3) / fixed(2) # 1.5
|
|
let TWO = fixed(2)
|
|
let FOUR = fixed(4)
|
|
let WHITE = 16777215 # 0xFFFFFF
|
|
let MID = 8355711 # 0x7F7F7F
|
|
|
|
# --- Anim.frame: looping frame index at 8 fps over a 4-frame clip ---
|
|
if Anim.frame(F0, 8, 4) == 0 { print(1) } # elapsed 0
|
|
if Anim.frame(QTR, 8, 4) == 2 { print(2) } # elapsed 2
|
|
if Anim.frame(HALF, 8, 4) == 0 { print(3) } # elapsed 4 -> 4%4
|
|
if Anim.frame(E78, 8, 4) == 3 { print(4) } # elapsed 7 -> 7%4
|
|
|
|
# --- Anim.once: clamped one-shot (stops on the last frame) ---
|
|
if Anim.once(HALF, 8, 4) == 3 { print(5) } # elapsed 4 -> min(4,3)
|
|
if Anim.once(QTR, 8, 4) == 2 { print(6) } # elapsed 2
|
|
|
|
# --- Anim.pingpong: bounce 0..3..0 ---
|
|
if Anim.pingpong(HALF, 8, 4) == 2 { print(7) } # elapsed 4 -> 6-4
|
|
if Anim.pingpong(E18, 8, 4) == 1 { print(8) } # elapsed 1
|
|
|
|
# --- Anim.finished: has a one-shot run past its last frame? ---
|
|
if Anim.finished(HALF, 8, 4) { print(9) } # elapsed 4 >= 4
|
|
if not Anim.finished(QTR, 8, 4) { print(10) } # elapsed 2 < 4
|
|
|
|
# --- Anim.duration + spritesheet cell geometry ---
|
|
if Anim.duration(8, 4) == HALF { print(11) } # 4/8 = 0.5s
|
|
if Anim.cell_x(5, 4, 16) == 16 { print(12) } # (5%4)*16
|
|
if Anim.cell_y(5, 4, 16) == 16 { print(13) } # (5/4)*16
|
|
if Anim.cell_x(6, 4, 16) == 32 { print(14) } # (6%4)*16
|
|
|
|
# --- Tween.progress: clamped 0..1 over a duration ---
|
|
if Tween.progress(QTR, F1) == QTR { print(15) } # 0.25/1
|
|
if Tween.progress(TWO, F1) == F1 { print(16) } # 2/1 -> clamp 1
|
|
if Tween.progress(F0 - F1, F1) == F0 { print(17) } # -1 -> clamp 0
|
|
|
|
# --- Tween.done ---
|
|
if Tween.done(F1, F1) { print(18) }
|
|
if not Tween.done(HALF, F1) { print(19) }
|
|
|
|
# --- Tween.loop / Tween.yoyo ---
|
|
if Tween.loop(F125, F1) == QTR { print(20) } # frac(1.25)
|
|
if Tween.yoyo(HALF, F1) == HALF { print(21) } # up-leg
|
|
if Tween.yoyo(F15, F1) == HALF { print(22) } # down-leg 2-1.5
|
|
|
|
# --- Tween.number / Tween.round scalar interpolation ---
|
|
if Tween.number(F0, FOUR, HALF) == TWO { print(23) } # lerp fixed
|
|
if Tween.round(0, 10, HALF) == 5 { print(24) } # round(5.0)
|
|
if Tween.round(0, 10, QTR) == 3 { print(25) } # round(2.5) half-up
|
|
|
|
# --- Tween.tint: per-channel color blend ---
|
|
if Tween.tint(0, WHITE, F0) == 0 { print(26) }
|
|
if Tween.tint(0, WHITE, F1) == WHITE { print(27) }
|
|
if Tween.tint(0, WHITE, HALF) == MID { print(28) } # 0x7F7F7F
|
|
|
|
# --- Tween.point: 2D vector interpolation ---
|
|
let p = Tween.point(Vector.make(F0, F0), Vector.make(FOUR, fixed(8)), HALF)
|
|
if Vector.x(p) == TWO { print(29) }
|
|
if Vector.y(p) == FOUR { print(30) }
|
|
|
|
# --- Tween.ease: curve selection, and consistency with Ease.* ---
|
|
if Tween.ease(HALF, 0) == HALF { print(31) } # 0 = linear
|
|
if Tween.ease(HALF, 1) == QTR { print(32) } # 1 = ease-in: 0.5^2
|
|
if Tween.ease(HALF, 1) == Ease.in(HALF) { print(33) } # same curve as Ease.in
|
|
if Tween.ease(F1, 2) == F1 { print(34) } # 2 = ease-out at 1.0
|
|
}
|
|
|
|
handler Run phase Update { quit() }
|
|
}
|