ludic/examples/library/anim.ludic
Orkuncakilkaya e4d1e95dcb
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 16s
ci / build-and-test (push) Successful in 1m9s
commit-lint / conventional-commits (push) Successful in 3s
docs / build-and-deploy (push) Successful in 18s
feat(stdlib): add Anim.* + Tween.* — deterministic 2D animation & tweening (#5)
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>
2026-08-31 13:05:06 +03:00

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() }
}