@flatkit/compiler 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,428 @@
1
+ # FlatInk DSL — gotchas & best practices (appendix)
2
+
3
+ > **New here?** Start with the [documentation index](README.md) and the
4
+ > [getting-started guide](getting-started.md). This page is the **appendix**: hard-won pitfalls and
5
+ > sharp edges learned on a real production pipeline, then fixed in the engine or written down here.
6
+ > Skim it once you know the basics.
7
+
8
+ ## Mental model
9
+
10
+ - A `.flatink` file splits in two: the **`scene { … }`** block (the VISUAL composition:
11
+ `path`/`circle`/`group`/`image`/`text`) and the **behavior** that follows (`object "Name"
12
+ { … }`, `every frame`, timeline bindings). The two do NOT share the same grammar.
13
+
14
+ ## `object "X"` addresses an ANIMATABLE item — a group, instance, text or image
15
+
16
+ - A **shape is baked material**: `rect 0 0 960 540 as "Eclat"` names it so `text … along "Eclat"` can
17
+ find it, but it carries **no pose**, so it can never be animated. Same for a **layer**. An `object`
18
+ block on either used to be dropped in **total silence** — the program compiled, ran, and the animation
19
+ simply did not exist. It is now a **compile error** naming what you actually hit. To animate a shape,
20
+ wrap it: `group "Eclat" { layer "art" { rect … } }`.
21
+ - Corollary — **opacities multiply**. Setting `opacity 0` on the shape to hide it at rest cancels the
22
+ group's animation even when the group lights up. Drive the *group's* opacity and leave the shape at 1.
23
+
24
+ ## One action / one binding per line
25
+
26
+ - **Comments are `//`, never `#`.** `#` starts a COLOR (`#ffcc00`), so a `# note` inside a program is a
27
+ parse error, not a comment. (Reference listings in these docs annotate with `#`; runnable examples use `//`.)
28
+ - **One single action or assignment per line.** `x = 1 y = 2` raises a clear error
29
+ ("one action per line — unexpected `=`"), with the column pointing at the second `=`.
30
+ A swallowed statement is named now — `score = score + 1 send "ok", 1` reports *"two statements on one
31
+ line — `send …` was swallowed into the expression before it"*, not `unexpected character """`.
32
+ - **`send` footgun (fixed)**: `send "evt", x = 1` (with a comma) used to capture `x = 1` as
33
+ the *payload*. It is now a dedicated error. A `send` carries at most one payload:
34
+ `send "evt"`, `send "evt", <expr>`, `send "evt", text("textId")`, or the record form
35
+ `send "evt", { a = <expr>, b }` — several *named* values in ONE event, not several payloads.
36
+ Inside a record the same rule holds: `{ x = y = 1 }` is the same error.
37
+
38
+ ## Drawing (region / path)
39
+
40
+ - **`stroke` DOES exist on paths** (despite the folklore):
41
+ `path "…" fill #rrggbb stroke #rrggbb <width> [cap butt|round|square] [join …] [miter n] [dash a,b,…]`.
42
+ No need to draw ropes/threads as thin filled shapes.
43
+ - **`stroke` ALSO exists on text** (same grammar): `text "…" color #fff stroke #000 4 join round` outlines
44
+ the glyphs (stroke drawn behind the fill). No need to fake outlines by stacking two text objects.
45
+ - **`opacity` DOES exist on paths**: `path "…" fill #000 opacity 0.5`. (8-digit hex alpha
46
+ works too, but `opacity` reads better.)
47
+ - **Shape primitives** (sugar, normalized to `path` on save):
48
+ - `circle <cx> <cy> <r>`
49
+ - `ellipse <cx> <cy> <rx> <ry>`
50
+ - `rect <x> <y> <w> <h>` · `… <r>` (uniform rounded corners) · `… <rx> <ry>`
51
+ No more hand-computing the k = 0.5523·r Bézier constant.
52
+ - **`filter` on a path**: accepted (`path "…" fill #000 filter glow 6 #fff`). No need to wrap
53
+ in a `group` just to add a shadow/glow. (Also works on group/image/text.)
54
+ - **`linear(angle, …)` gradient**: `0` = → (left to right), `90` = ↓ (top to bottom).
55
+ - **Rings / holes = ONE path with multiple closed subpaths** — fill is **even-odd**, so a nested subpath
56
+ cuts a hole: `path "M-30 -30L30 -30L30 30L-30 30Z M-15 -15L15 -15L15 15L-15 15Z" fill #c33` is a solid
57
+ filled ring (frame + hole). No need to fake it with `nofill stroke`.
58
+ - **Stroke width SCALES with the group**: a child of a `group`/instance at `scale 0.4` (or a `pose scale`)
59
+ draws its stroke at 0.4× too (the stroke is drawn in the scaled space). So a thin ring stays
60
+ proportional when you shrink the group — don't compensate the width by hand.
61
+ - **`clip <x> <y> <w> <h>` on a group/instance**: cuts content to a rectangle (the container's LOCAL
62
+ coords) — e.g. hide the "feet" of an emerging shape: `group "Arc" clip -100 -60 200 60 { … }`. For an
63
+ arbitrary clip shape, use a `mask` layer instead. **Render-only**: hit-testing and the preview/auto-size
64
+ bbox ignore it (clipped-away area stays clickable / counts toward the framing) — it's a visual cut.
65
+
66
+ ## Text
67
+
68
+ - **Word-wrap**: opt-in via `wrap`. `text "long sentence…" … box <W> <H> wrap` breaks at
69
+ spaces within `W`. Without `wrap`, text does NOT wrap on its own (it respects explicit
70
+ `\n`). Opt-in on purpose, to avoid breaking existing layouts.
71
+ - **Dynamic text (read-only)**: `text "Angle: {}°" … bind "round(aDeg)" decimals 1`. The
72
+ expression is evaluated every frame; its formatted value fills the **`{}`** slot in the
73
+ content (or is shown alone when there is no `{}`). `decimals` sets the decimal count.
74
+ No more gauges/needles just to display a measurement (angle, timer, score…).
75
+ - **Centering an `image`**: the origin is the top-left corner → center with `at -W/2,-H/2`.
76
+
77
+ ## Animation (pose / cel / timeline)
78
+
79
+ > Full guide: **[Animating a symbol](animating-symbols.md)**. The sharp edges:
80
+
81
+ - **`pose` rotates/scales in human units**: `pose "G" rotate 90 scale 2` — **degrees** and multipliers,
82
+ **around the group's `pivot`**. No hand-written `matrix(cosθ, sinθ, …)`, no radians. `scaleX`/`scaleY`
83
+ for non-uniform. `matrix(…)` still works as an escape hatch.
84
+ - **A pose only overrides what it states** (patch, not replace): `pose "G" opacity 0.5` keeps G's
85
+ declared position/rotation/scale. Don't re-type `at x,y` in every cel.
86
+ - **Set the `pivot` to the visual center** or a part **orbits** instead of spinning in place. `pivot`
87
+ (local, on the roster item) = the center of rotation/scale; `at x,y` (on the pose) = where the local
88
+ origin lands. `spin cw|ccw` / `turns N` also turn around the pivot.
89
+ - **`expr rotation` is in RADIANS**, like `sin`/`cos`. Stay in degrees with the helpers: `rad(45)`,
90
+ `turns(time)` (one turn per second), `deg(r)`. e.g. `expr rotation "turns(time * 0.5)"`.
91
+ - **`time` WRAPS at `durationFrames`** (the timeline loops), so `sin(time * f)` with an arbitrary `f`
92
+ **jumps** every loop — and a `.flatink` with no `timeline` defaults to **60 frames (2.5 s @24fps)**, so
93
+ the jump is frequent. For free-running ambiance use **`clock`** (monotone, never wraps): `sin(clock * f)`.
94
+ Alternatively drive it by the loop phase `frame / <durationFrames>` (reboucles cleanly) or set a long
95
+ `timeline`. `--check` warns when a channel expression uses `time` under a short looping timeline.
96
+ - **`x`/`y` channels REPLACE the base transform — they are NOT a delta.** `object "G" { x = bump }` sets
97
+ G's local x to `bump`, **overwriting** the group's declared `at X,Y` (x snaps to `bump` ≈ 0 → the element
98
+ jumps to the left edge whenever the expression is small). The channel value is absolute, not added to `at` —
99
+ classic cause of "the animation appears in the wrong place." Two ways to keep it on the anchor:
100
+ - **Preferred — additive offsets `dx`/`dy`** (binding-only): `object "G" { dx = bump }` resolves to
101
+ `pos = at + (dx, dy)`, so `dx = 58*sin(clock)` oscillates **around** `at X,Y` with no base to re-type.
102
+ This is the natural "offset from the anchor" idiom (mirror of CSS `translate` / pivot). `dx`/`dy` compose
103
+ with an absolute `x`/`y` if both are bound (`pos = x + dx`); they have no keyframe/`spring`/`smooth` form.
104
+ - **Or re-inject the base** with the absolute channel: `x = $(X) + bump` (same for `y`/`rotation`/`scaleX`/…).
105
+ - **Channel `scaleX`/`scaleY`/`rotation` turn around the group's `pivot`** (like cel poses), so a panel
106
+ with `pivot <center>` driven by `object "P" { scaleX = s }` grows/spins **in place** — it no longer
107
+ shrinks toward the top-left corner. With no `pivot` (default `{0,0}`) it scales/rotates around the origin
108
+ as before. When a `pivot` is set, the `x`/`y` channels position **the pivot** (the object's anchor).
109
+ - **A layer WITH cels draws ONLY the cel's `matter` + the containers that cel poses.** A bare shape
110
+ written straight into such a layer is **silently never drawn** (it stays in the roster, which holds
111
+ bodies, not drawings). The drawing of a frame goes inside the cel: `cel 0 { matter { circle 0 0 30
112
+ fill #e33 } }` — that IS how you author **frame-by-frame** (a new `matter` per cel; it holds until the
113
+ next one, `morph` tweens the shape). A static element belongs on a **cel-less layer**. `--check` warns
114
+ on all three silent drops: a bare shape in a cel layer, a `pose "X"` matching no roster item, and a
115
+ roster item no cel poses. See [Animating a symbol](animating-symbols.md).
116
+ - **Render order**: in an animated layer the **matter draws BEHIND the posed containers**, and
117
+ declaration order between the matter and an animated `group` is NOT preserved. To put a static
118
+ shape in front of an animation, give it its own **layer above** (or wrap it in a group).
119
+ - **Gating a subtree by opacity is FREE**: a group whose resolved `opacity` is `<= 0.01` (e.g. the
120
+ off-phase branch of `opacity = phase == X ? 1 : 0`, even when smoothed toward ~0) is **pruned** — its
121
+ whole subtree is skipped for both draw AND expression eval, exactly like the hit-test already treats it
122
+ as click-through. So `opacity = phase==X ? 1 : 0` on the phase groups is the idiomatic, performant way to
123
+ show/hide whole acts; you don't need `visible`/`hidden` (which aren't animatable anyway).
124
+ - **Preview without clipping**: `flatc --preview` defaults to `--bbox all` (union over every frame, so
125
+ drifting/rotating/growing motion fits). `--bbox frame0` is the old frame-0 measure; `--pad N` adds a
126
+ margin.
127
+ - **Preview origin is STABLE across frames**: the canvas is auto-sized once (the union), independent of
128
+ `--frame`. So rendering several frames of an oscillating/translating symbol gives the SAME frame each
129
+ time — the object moves *inside* a fixed canvas, it does not "jump". No need for an invisible anchor.
130
+ - **Exposed interface** (`params {}` / `states {}`): a symbol can publish typed params (color/number/bool,
131
+ `fill <param>`) and named states (`states door { closed at 0 open at 24 }`, driven by `Name.param = open`).
132
+ See [Animating a symbol](animating-symbols.md). Restyle/tune without touching internals.
133
+ - **A container shows only on cels that pose it** — a cel is a full snapshot, so a container omitted from a
134
+ cel disappears there (that's how a symbol *exits*). For a container that stays put: declare it on its own
135
+ **cel-less layer** (always shown, declared once), or use **`cel N hold { … }`** to carry the previous
136
+ cel's poses forward (compile-time sugar — you only write what changes). It's per-*keyframe*, not per-frame.
137
+ - **`--preview --set` bakes the values into the flatpack** (onto the wrapped instance's `params`), so a
138
+ pre-themed `.flatpack` plays correctly in the browser too — provided the player bundle is current (a
139
+ stale bundle won't resolve `fill <param>` → black; rebuild after a `@flatkit/*` bump).
140
+
141
+ ## Drag & drop
142
+
143
+ - **Drop semantics**: by default the **object's center** (its x/y channels) is tested against
144
+ the zone. Two levers to match human expectations:
145
+ - `when dropped on Zone at pointer { … }`: tests the **POINTER position** (not the center).
146
+ - `group "Zone" … hitbox <W> <H> { … }`: an **explicit drop rectangle** (centered on the
147
+ origin, ±W/2 × ±H/2) instead of the content bbox. Replaces invisible `#ffffff01` paths.
148
+ - **Locking a placed object**: `drag x, y { enabled <expr> }`. The drag is active only while
149
+ the expression is ≠ 0. No more `x = (p==1) ? Zone.x : xv` + `if p==0` guard patterns.
150
+ - **Event order on release**: `when released` fires **BEFORE** the drop test (useful to lower a
151
+ "hold" flag before the drop fires). A `link` interactor writes its outputs (`<target>` index,
152
+ end position) **before** `released` too, so a `when released` handler can read `<target>`
153
+ directly (consistent with `drag`, which writes its vars before `dragged`).
154
+ - **Several `when dropped on` per object**: evaluated in **declaration order**, without
155
+ short-circuit (the basis of the right-zone / wrong-zones pattern).
156
+
157
+ ## Gestures beyond drag (interactors)
158
+
159
+ Inside an `object "Name" { … }`, besides `drag x, y` / `dragX` / `dragY`:
160
+
161
+ - **`turn <angle> around <x>,<y> [{ snap <deg> · enabled <expr> }]`**: pointer-driven
162
+ rotation. Writes into `<angle>` the pivot→cursor direction **in radians** (like the `rotation` channel
163
+ and `gesture.angle`), so `rotation = <angle>` wires directly. To work in **degrees**, use the twin
164
+ **`turnDeg`** (writes degrees) paired with the **`rotationDeg = <angle>`** channel (sugar for
165
+ `rotation = rad(<angle>)`). `snap <deg>` is authored in degrees on both. Great for clock hands, dials, knobs.
166
+ - **`trace <progress> along <TraceGroup> [{ tolerance <px> · enabled <expr> }]`**: follow a
167
+ path with the finger. While the pointer stays within `tolerance` of the trace (the regions
168
+ of the named group), `<progress>` rises from 0 to 1 (monotone, never goes back down).
169
+ Great for tracing a letter, a border, a constellation. (`tolerance` defaults to 24 px.)
170
+ - **`reveal <progress> [{ brush <px> · enabled <expr> }]`**: scratch / wipe. The grabbed
171
+ object IS the area to reveal; rubbing it ticks the cells of an internal grid (cell side =
172
+ `brush`) and `<progress>` rises from 0 to 1 (monotone, and **cumulative across separate grabs**
173
+ — a child rubbing in several short strokes keeps adding coverage, it does not reset). Drive a
174
+ cover's opacity with `opacity = 1 - <progress>`. Great for scratch cards, fogged glass, digging.
175
+ (`brush` defaults to 24 px; coverage model, no pixel mask.)
176
+ - **`link <endX>, <endY>, <target> to <TargetsGroup> [{ enabled <expr> }]`**: pull an elastic
177
+ thread toward a target. During the drag, `<endX>`/`<endY>` = pointer position (DRAW the
178
+ thread yourself with expressions, e.g. a region connecting the object to `endX,endY`). On
179
+ release, `<target>` = the **1..n** index of the named child of the group that was hit
180
+ (0 if none), and the thread end snaps to the linked target's center; off-target, the
181
+ author handles the "return" via `<target> == 0`. Several links coexist: one `link`
182
+ interactor per source object. Great for word↔image, prey↔predator, capital↔country.
183
+ **WORLD coords** (place sources and targets at the scene root so the thread lines up).
184
+ - Reminder: `{ enabled <expr> }` (dynamic lock) works on `turn`/`trace`/`reveal`/`link` too.
185
+ - **Output into an ARRAY element**: every gesture output accepts `name[<idx>]` (not just a
186
+ bare identifier) — `drag hx[i], hy[i]`, `turn ang[k] around …`, `reveal seen[2]`,
187
+ `link ex[i], ey[i], rel[i] to …`. This is the natural form **under `each`**:
188
+ `each "Handle" as i { drag hx[i], hy[i] }` attaches one drag per instance, each writing its
189
+ OWN slot. (The index is substituted by `each`; the array must exist: `var hx = fill(n, 0)`.)
190
+
191
+ ## Feedback (reactions in one line)
192
+
193
+ An object's channel expressions can read **its own interaction state**: `self.hovered`,
194
+ `self.grabbed`, `self.pressed` (each `0`/`1`). So a hover-lift or a grab-squash is just an
195
+ expression — no mirror variable, no handler:
196
+
197
+ ```
198
+ object "Button" {
199
+ scaleX = self.hovered ? 1.06 : 1
200
+ opacity = self.hovered ? 0.85 : 1
201
+ scaleY = self.grabbed ? 0.94 : 1
202
+ }
203
+ ```
204
+
205
+ `self.hovered` tracks the pointer **handler-independently** (you do not need a `when enter/leave`),
206
+ and composes with `self.x`/`self.y` etc. (same `self`).
207
+
208
+ - **`feedback <tokens>` sugar** — the one-liner. Inside an `object` block,
209
+ `feedback lift tilt dim shake(<expr>)` unfolds into the channel bindings above (auto-injecting
210
+ `use "feedback"`), **composed per channel** so it never clashes with your `x`/`y` position
211
+ bindings. Tokens: `lift` (hover grow), `tilt` (grab squash), `dim` (hover opacity),
212
+ `shake(<expr>)` (refusal wobble — `<expr> ≠ 0` shakes the `rotation`). One line per element
213
+ instead of six — the biggest size/token saver across an activity.
214
+ ```
215
+ object "Tile" {
216
+ x = tx y = ty // your position bindings, untouched
217
+ feedback lift dim shake(wrongZone)
218
+ }
219
+ ```
220
+ - **`use "feedback"` functions** — the same reactions as plain helpers if you want to wire them by
221
+ hand: `lift(h)` · `dim(h)` · `tilt(g)` · `sink(g)` · `shake(bad, t)`. (Settle-bounce is not here
222
+ yet: it needs a release timestamp, so it is not stateless.)
223
+ - ⚠️ **Capture an instant with `clock`, never `time`.** `pulse`/`shake` ride the monotone `clock`, so
224
+ an instant captured on the looping `time` is compared against an axis it never shares: `clock - since`
225
+ grows without bound and **the ramp never fires**. Nothing jumps, nothing blinks — the failure is
226
+ entirely silent, which makes it far more expensive than a visible one. It is the classic leftover of
227
+ a pre-0.23 codebase, where `pulse` rode `time` too.
228
+ ```
229
+ when wrong { shown = clock } // ✓ monotone — pulse can subtract it
230
+ when wrong { shown = time } // ✗ wraps every durationFrames — the ramp never starts
231
+ opacity = pulse(shown, 4)
232
+ ```
233
+ `flatc --check` now names it: *"shown" is captured on `time` but read as an INSTANT by `pulse()`…*
234
+
235
+ ## Factoring
236
+
237
+ - **Scene-level `repeat`**: generates N items.
238
+ ```
239
+ scene {
240
+ layer "Stars" {
241
+ repeat i from 0 to 9 {
242
+ circle $(60 + i*40) 80 6 fill #ffd98a
243
+ }
244
+ }
245
+ }
246
+ ```
247
+ The index is used inside numbers via the **`$(expr)`** interpolation (compile-time
248
+ arithmetic: `$(i*40)`, `$( (i+1)*20 )`…). Nested loops are fine (grids).
249
+ ⚠️ Not to be confused with `repeat … times` / `repeat i from … to …` inside `object`
250
+ scripts (a RUNTIME loop, executed every frame).
251
+ - **`def <name> = <expr>`**: a named **compile-time** constant (columns, margins, counts…).
252
+ Declared anywhere (the line is removed at compile time), usable in any scene coordinate via
253
+ `$()`, including `repeat` bounds. A `def` may reference earlier ones.
254
+ ```
255
+ def colL = 120
256
+ def gap = 40
257
+ def n = 2
258
+ scene { layer "L" {
259
+ repeat i from 0 to n { circle $(colL + i*gap) 80 6 fill #ffd98a }
260
+ } }
261
+ ```
262
+ `def`s resolve **everywhere**: scene coordinates AND behavior expressions (`object`/`each`,
263
+ e.g. `x = 20 + t * $(vmax)`). ⚠️ Compile-time only: `def`s are NOT runtime variables (no
264
+ `var`), they vanish from the model (like `repeat`); for a value that changes at runtime,
265
+ use `var`. (Avoid naming a `def` like a symbol parameter — collision.)
266
+ - **`at center` anchor**: positions an item at the canvas center. `at center` (both axes),
267
+ `at center,540` (x centered, y = 540), `at 120,center` (x = 120, y centered). Sugar
268
+ resolved at parse from `size` (re-serialized as coords, like `def`). Composes with `$()`.
269
+ - **`align <point> of "Name" [offset dx,dy]` anchor**: puts an item's **origin** on a point
270
+ of **another item's bbox** (center inside a frame, hang a counter under a pit, snap a sign
271
+ to an edge). 9 points: `center`, `top`, `bottom`, `left`, `right`, `topleft`, `topright`,
272
+ `bottomleft`, `bottomright` (an edge point = centered on the cross axis). Optional
273
+ `offset dx,dy`.
274
+ ```
275
+ group "Counter" align bottom of "Pit" offset 0,18 { … }
276
+ group "Tag" align top of "Bin" { … }
277
+ ```
278
+ ⚠️ **STATIC bbox** (scene transforms, WITHOUT expression channels — same as drop zones).
279
+ Anchoring only (NO adjacency/flow: deliberate — stacking is done with `repeat`+`$()`,
280
+ e.g. `at $(120 + i*84),540`). Positions in **root** space (place source and target at the
281
+ same level). Missing target = error. Sugar resolved at parse (re-serialized as coords).
282
+ - **Parameterized symbols**: `symbol "Name"(p, q = default) { … }` + `instance "Name"(args)
283
+ [as "X"] at x,y`. Reuse a VISUAL while varying values (label, tint, size). `$(param)`
284
+ substitution in the body — params can be **numbers AND text/color** (`text "$(label)"`,
285
+ `fill $(tint)`).
286
+ ```
287
+ symbol "Card"(label, tint = "#ffffff") {
288
+ layer "c" { rect -40 -40 80 80 fill $(tint)
289
+ text "$(label)" as "lbl$(label)" font "sans-serif" size 20 align center line 1.2 color #000 box 80 80 }
290
+ }
291
+ scene { layer "L" {
292
+ repeat i from 0 to 4 { instance "Card"($(i+1)) as "C$(i)" at $(80 + i*90),200 }
293
+ } }
294
+ ```
295
+ - **Defaults**: a param can gain a default without breaking existing calls (the signature
296
+ doubles as documentation).
297
+ - **Per-instance ids**: `as "lbl$(label)"` in the body → each card exposes its own
298
+ `text("lblFire")` for `send` payloads.
299
+ - Each `instance(...)` becomes a concrete **`group`** (substitution at PARSE time, **zero
300
+ runtime cost**).
301
+ - ⚠️ A param is **frozen** (≠ `var`: it is not a runtime variable). The body sees ONLY its
302
+ params (not the `i` of an outer `repeat` — pass it as an argument). Wrong arity / unknown
303
+ param = error.
304
+ - ⚠️ Compile-time sugar (re-serialized as groups, like `def`/`repeat`). For shared
305
+ BEHAVIOR, see `each` below.
306
+ - ⚠️ **`.flatink`-only**: a parameterized `symbol "X"(…)` lives in the **program** (`.flatink`), NOT in a
307
+ `.flat` library. `.flat` libs hold **non-parameterized** symbols, instanced **without** parens
308
+ (`instance "Hero" as "H"`); a parameterized one is instanced **with** args (`instance "Card"("A")`).
309
+ Putting a `(…)` symbol in a `.flat` — or letting `flatc` auto-discover such a `.flat` in the folder —
310
+ surfaces as a misleading `"{" expected, "("` (the `.flat` parser doesn't take parameters). Rule of
311
+ thumb: parens ⇔ parameterized ⇔ inline in the `.flatink`.
312
+ - **`each "Symbol" as i { … }`**: applies BEHAVIOR to every instance of a symbol, with
313
+ index `i`.
314
+ - **Channel bindings** on real instances (`each "Brick" as i { opacity = bricks[i] }`) →
315
+ resolved at runtime (`vals[i]`, variable arrays).
316
+ - **Handlers** on the instances of a **parameterized symbol** (`each "Key" as i { when
317
+ clicked { … } }`) → unrolled at parse into one `object` per generated instance (index `i`
318
+ substituted). The BEHAVIOR counterpart of parameterized symbols (the VISUAL): together
319
+ they make a full keypad in ~10 lines.
320
+ ⚠️ Give instances distinct names (`as "K$(i)"`) so each handler targets ITS tile.
321
+ - **Indexed assignment**: `arr[<expr>] = <value>` (the `set` keyword is optional). The index
322
+ can be any expression, **including nested**: `occ[sl[i + 1]] = 0` works (balanced
323
+ brackets). An unclosed bracket is a **hard error** (no more silent truncation).
324
+ - **`else if`**: supported (`} else if cond {`); no need to nest `else { if … }`.
325
+ - **`match` — declarative pairing** (factors the drag+drop boilerplate). Unrolled into
326
+ `object` blocks.
327
+ ```
328
+ match Word1, Word2, Word3 onto NounBin, VerbBin, AdjBin {
329
+ correct Word1 -> NounBin, Word2 -> VerbBin, Word3 -> AdjBin
330
+ lock on wrong // optional; absent = RETRYABLE (default)
331
+ on correct as it { send "found", text(it) } // optional GENERIC action hooks
332
+ on wrong as it { send "miss" } // `it` = the current item's name
333
+ on done { send "done" } // fires when all correct placements are in
334
+ }
335
+ ```
336
+ Generates, per item: `drag <Item>_x, <Item>_y { enabled <Item>_placed == 0 }` + one
337
+ `dropped on` per zone that sets the **exposed state**: `<Item>_placed` (0/1), `<Item>_ok`
338
+ (0/1), `<Item>_zone` (index). **No event is imposed** (full host decoupling) — you decide
339
+ what to send via the hooks. The **visual stays yours**: declare `var <Item>_x`/`var
340
+ <Item>_y` (start positions) and write your expressions.
341
+
342
+ ## Tooling (the generation loop)
343
+
344
+ - **`flatc --check <file>`**: semantic lint only. Exits ≠ 0 on ERROR; **warnings** print
345
+ without blocking. Also covers **layout** (approximate, no rendering): text (without `wrap`)
346
+ overflowing the canvas edge, image/text clipped at an edge, missing drop zone,
347
+ **overlapping hitboxes**, never-used global variable.
348
+ - **`flatc --watch <file>`**: recompiles on every change in the folder.
349
+ - **`flatc --render <file> -o out.png [--frame N] [--at k=v[,k2=v2]] [--steps N] [--scale S]`**:
350
+ renders a headless **PNG image** (skia backend, faithful to the browser: SVG, gradients,
351
+ glow/shadow filters). This is how you **see what you draw** before playing. `--at` forces
352
+ variables → capture a precise state (e.g. `--at step=2` for an escape-room stage); `--frame N`
353
+ targets a frame. **`--steps N`** runs N fixed simulation steps (`every frame`, 60 Hz) *before*
354
+ the capture, so a stateful act unfolds on its own — no need to force every derived ramp variable
355
+ by hand in `--at`. (Bounded to 10 000 steps.) Requires the optional `skia-canvas` dependency
356
+ (see the error message for the install steps).
357
+ - **`flatc --assets inline|external <file>`**: how media is baked. `inline` (default) embeds each
358
+ asset as a base64 `data:` URI inside the `.flatpack` — one portable file. `external` keeps
359
+ `asset.data` as a relative key and copies the files into a sidecar `<out>.assets/` folder; serve
360
+ that folder and play with `sameOriginAssetResolver(<flatpackUrl>)`. Use `external` for big media
361
+ (video, large audio) you do not want inflating the JSON.
362
+ - **`flatc --play <file.flatink|.flatpack> --script <gestures.json>`**: plays **headless**
363
+ (no canvas), replays a gesture script and prints `{ sends, vars }` as JSON. Great in CI.
364
+ Script format: an array of gestures. **Prefer SEMANTIC gestures** (by object NAME) —
365
+ robust (coordinate-independent), readable, and the engine resolves the position:
366
+ ```json
367
+ [
368
+ { "type": "drag", "source": "Card1", "target": "ZoneA" },
369
+ { "type": "wait", "frames": 30 },
370
+ { "type": "tap", "target": "Button" },
371
+ { "type": "scratch", "target": "Cover1" },
372
+ { "type": "connect", "source": "Word", "target": "Picture" }
373
+ ]
374
+ ```
375
+ `drag source→target` = the engine grabs `source` (at its RESOLVED position, expressions
376
+ included) and releases it at the center of `target`; `tap target` = a click at the object's
377
+ center; **`scratch target`** = the engine sweeps the `reveal` target's whole bbox for you
378
+ (boustrophedon at the brush spacing) so its coverage reaches ~1 — no more dozens of hand-typed
379
+ `move`s; **`connect source→target`** = pulls a `link` wire from `source` and releases over
380
+ `target`, resolving the target index. Unknown object → **hard error** (not a silent miss). The
381
+ generator describes the INTENT; the engine guarantees the interaction.
382
+ LOW-LEVEL gestures (scene coords) remain available for special cases:
383
+ ```json
384
+ [
385
+ { "type": "down", "x": 50, "y": 50 }, { "type": "move", "x": 60, "y": 60 }, { "type": "up", "x": 60, "y": 60 },
386
+ { "type": "set", "name": "unlocked", "value": 1 }, { "type": "wait", "frames": 60 }
387
+ ]
388
+ ```
389
+ (`set` drives a variable from the "host"; `wait` lets the simulation run N fixed 60 Hz
390
+ steps — required to "wait out" `every frame` physics, time does not advance on its own in
391
+ headless mode.)
392
+ - **`flatc --play … --trace`**: instead of the final JSON, a **human-readable log per
393
+ gesture** — the emitted `send`s + the **variable diff** at each step. Step-by-step
394
+ inspection to understand/debug a script (a headless debug-player):
395
+ ```
396
+ drag Word1→Bin1 sends:[found] vars{Word1_placed:undefined→1 Word1_ok:undefined→1}
397
+ wait 5
398
+ drag Word2→Bin2 sends:[found, win] vars{Word2_placed:undefined→1 Word2_ok:undefined→1}
399
+ ```
400
+ - **`expect` — self-verification in CI**: a gesture `{ "type": "expect", "sends": ["done"],
401
+ "vars": { "score": 3 } }` in the script compares and makes **`flatc --play` exit ≠ 0** on
402
+ mismatch (each mismatch listed on stderr). `sends` = the **sequence of names** emitted
403
+ SINCE the last `expect`; `vars` = current state. No more eyeballing: the script becomes a
404
+ test. Works with or without `--trace`.
405
+ - **Gesture recording**: `player.startRecording()` / `stopRecording(): Gesture[]` capture
406
+ gestures played BY HAND (down/up/cancel + the `move`s during a drag, with `wait`s for
407
+ elapsed time) → a script **directly replayable by `--play`**. No more hand-written coords.
408
+ - **Canonical file name**: `.flatpack` (JSON inside; `.flatpack.json` is a legacy alias that
409
+ `--play` tolerates).
410
+
411
+ ## `filter` performance
412
+
413
+ - A `filter glow`/`shadow` = **one offscreen canvas recomposited** per group carrying the
414
+ filter. The engine automatically **caches** *static* filtered subtrees (decor with no
415
+ expression or animation): they are only re-rendered on zoom/pan or asset load. So a filter
416
+ on **static decor** is nearly free in steady state.
417
+ - A filter on an **animated** element (channel expression, `bind`, timeline) is recomposited
418
+ every frame. Keep filters on animated elements **small**.
419
+ - Cheap alternative for decor shadows: a **"baked" shadow** = the same path offset by a few
420
+ px in `#00000028` UNDER the cutout (no offscreen canvas). Ideal for large planes.
421
+ - Drop-shadow cost ∝ area × blur: a large blurred plane is expensive when not cached.
422
+
423
+ ## Audio
424
+
425
+ - `sound "assetId" at <frame> [gain <g>] [loop]` on the timeline, and the `sound "assetId"`
426
+ action in a handler, ARE wired (WebAudio). Declare the asset like images:
427
+ `asset "ding" "ding.mp3" sound`. Media (mp3/wav/ogg/m4a) are embedded as data URIs by
428
+ `flatc`. (No sample sounds ship for now — bring your own.)
@@ -0,0 +1,95 @@
1
+ # Embedding the player: loading embedded fonts
2
+
3
+ A `.flatink` can **embed its fonts** so a deck is self-contained:
4
+
5
+ ```
6
+ asset "Archivo Black" "ArchivoBlack.woff2" font
7
+ scene { layer "L" { text "Déçu où ? ÇÀ" at 20,40 font "Archivo Black" size 48 } }
8
+ ```
9
+
10
+ `flatc` inlines the face into the compiled doc as a base64 data-URI:
11
+
12
+ ```jsonc
13
+ // doc.assets
14
+ [{ "id": "Archivo Black", "kind": "font", "mime": "font/woff2",
15
+ "data": "data:font/woff2;base64,…", "family": "Archivo Black" }]
16
+ ```
17
+
18
+ ## The host owns font loading
19
+
20
+ `FlatPlayer` draws text with `ctx.font = "<family>"` on the canvas it was given. It does **not** register
21
+ the doc's `kind:'font'` assets itself — loading a face is environment-specific (a browser `FontFace`, a
22
+ skia `FontLibrary`, …) and asynchronous, while the player's render is synchronous. So **the host that embeds
23
+ the player is responsible for registering the embedded fonts before rendering.** Skip this and text silently
24
+ falls back to a system font.
25
+
26
+ > The `family` to register is **`asset.family || asset.id`** — it's exactly what the text targets via
27
+ > `font "<…>"`. Wrap loading in `try/catch`: a corrupt face should degrade to a fallback, never crash.
28
+
29
+ ### Browser (`<canvas>`)
30
+
31
+ `@flatkit/player` exports the helper — `import` it, no need to reimplement:
32
+
33
+ ```js
34
+ import { FlatPlayer, loadEmbeddedFonts } from '@flatkit/player'
35
+
36
+ await loadEmbeddedFonts(doc) // ← BEFORE new FlatPlayer
37
+ const player = new FlatPlayer(canvas, doc)
38
+
39
+ // Optional safety net (FOIT): redraw once any late face settles.
40
+ document.fonts?.ready.then(() => player.render())
41
+ ```
42
+
43
+ `loadEmbeddedFonts(doc)` registers each `asset kind:'font'` (base64 data-URI) as a `FontFace` under
44
+ `asset.family || asset.id`, **no-ops outside a DOM** (SSR / Node), and **skips a corrupt face** (graceful
45
+ fallback, never throws). It's a tiny tree-shakeable export — it pulls in no extra dependency (uses the
46
+ browser's own `FontFace` / `document.fonts`). Calling it again (e.g. a remount) is harmless: a family
47
+ already on `document.fonts` is not re-registered.
48
+
49
+ > **Security.** Only embedded `data:` URIs are honored, and the bytes are decoded and handed to `FontFace`
50
+ > directly — `asset.data` is never spliced into a CSS `src` string. So an untrusted doc can neither point a
51
+ > face at a remote origin (no network fetch) nor inject extra CSS `src` descriptors via `url()`/`local()`.
52
+ > This is the same "no arbitrary fetch" contract the player's image/audio paths enforce.
53
+
54
+ ### Node / headless (`skia-canvas`)
55
+
56
+ `FontLibrary` reads **file** paths, so materialize each base64 data-URI to a temp file, then register it.
57
+
58
+ ```js
59
+ import { FlatPlayer } from '@flatkit/player'
60
+ import { FontLibrary } from 'skia-canvas'
61
+ import { mkdtempSync, writeFileSync } from 'node:fs'
62
+ import { tmpdir } from 'node:os'
63
+ import { join } from 'node:path'
64
+
65
+ function useEmbeddedFonts(doc) {
66
+ for (const a of doc.assets ?? []) {
67
+ if (a.kind !== 'font' || !/^data:[^;]*;base64,/.test(a.data ?? '')) continue
68
+ const base64 = a.data.slice(a.data.indexOf(',') + 1)
69
+ const ext = (a.mime || '').includes('woff2') ? 'woff2' : (a.mime || '').includes('woff') ? 'woff' : 'ttf'
70
+ const file = join(mkdtempSync(join(tmpdir(), 'fk-fonts-')), `${(a.family || a.id).replace(/[^a-z0-9]+/gi, '_')}.${ext}`)
71
+ writeFileSync(file, Buffer.from(base64, 'base64'))
72
+ try {
73
+ FontLibrary.use(a.family || a.id, [file]) // 2-arg form forces the family alias
74
+ } catch {
75
+ /* invalid font → fallback */
76
+ }
77
+ }
78
+ }
79
+
80
+ useEmbeddedFonts(doc) // ← BEFORE the first render
81
+ const player = new FlatPlayer(canvasEl, doc, { input: false, audio: false })
82
+ ```
83
+
84
+ `FontLibrary` is a **global, idempotent** registry for the process — registering the same family twice is
85
+ harmless (you may memoize by family to avoid re-writing temp files).
86
+
87
+ ## What's already handled for you
88
+
89
+ - **`flatc --render`** (the CLI, used for preview/OG images) registers embedded fonts **internally** before
90
+ drawing — CLI users and render pipelines get correct text for free. The host glue above is only needed when
91
+ you drive `@flatkit/player` **directly**.
92
+
93
+ - The **browser** path ships as `loadEmbeddedFonts` (exported above). The **skia/Node** path stays a snippet:
94
+ it would drag the optional `skia-canvas` dependency into the package, so it's left to the host (and the
95
+ `flatc --render` CLI already covers headless rendering).
@@ -0,0 +1,107 @@
1
+ # Expressions & stdlib
2
+
3
+ Expressions appear in channel bindings (`rotation = …`), conditions (`if …`, `enabled …`), `send`
4
+ payloads, `bind` text, text-on-path `start`/`spacing` (`text … along "…" start "time*0.1"`), and `$()`
5
+ interpolation. They are **pure and numeric** — no statements, no loops, no side effects (so they can't
6
+ hang and only touch the values the runtime provides).
7
+
8
+ ## Operators
9
+
10
+ From lowest to highest precedence:
11
+
12
+ ```
13
+ ?: ternary a > 0 ? 1 : -1
14
+ || && logical
15
+ == != < > <= >= comparison (a value ≠ 0 is "true")
16
+ + - * / % arithmetic
17
+ - ! unary
18
+ . [] member / index mouse.x · slots[i]
19
+ fn(…) call
20
+ ```
21
+
22
+ There are no booleans — comparisons and logic yield `1` / `0`.
23
+
24
+ ## Built-in functions
25
+
26
+ ```
27
+ sin cos tan asin acos atan atan2
28
+ abs sqrt pow exp log floor ceil round sign
29
+ min max hypot clamp(x, lo, hi) lerp(a, b, t) mod(a, b) between(x, lo, hi)
30
+ rad(deg) deg(rad) turns(n)
31
+ ```
32
+
33
+ Constants: `PI`, `TAU` (2π), `E`.
34
+
35
+ > **`lerp` is the exponential smoother.** `lerp(v, target, k)` = `v + (target - v) * k` — so
36
+ > `niv = lerp(niv, target, 0.1)` in `every frame` eases `niv` toward `target` (no new helper needed; the
37
+ > target and rate stay explicit). For a timed 1→0 feedback ramp, see `pulse(since, dur)` in `use "feedback"`.
38
+
39
+ **Angles are RADIANS** (the `rotation` channel, `sin`/`cos`/`atan2`). Author in degrees with the helpers:
40
+ `rad(45)` (degrees → radians), `turns(n)` (n full turns → radians, e.g. `rotation = turns(time)` spins once
41
+ per second), `deg(r)` (the inverse, for readouts). Or bind the **`rotationDeg`** channel (sugar for
42
+ `rotation = rad(…)`) and use the **`turnDeg`** interactor — both work in degrees, for when that reads better.
43
+
44
+ ## Reserved names
45
+
46
+ | Name | Meaning |
47
+ |---|---|
48
+ | `time` | seconds elapsed — **resets to 0 every `durationFrames`** (the timeline loops). Fine for motion tuned to the loop; for free-running ambiance use `clock`. |
49
+ | `clock` | seconds elapsed, **monotone** (never wraps). Use for ambient motion in a looping/interactive scene so `sin(clock * f)` doesn't jump on each loop. |
50
+ | `frame` | current frame (0-based; also wraps at `durationFrames`) |
51
+ | `value` | the channel's current value (in a channel binding) |
52
+ | `mouse.x` `mouse.y` | pointer position (scene units) |
53
+ | `keys.<Key>` | `1` while a key is held, `0` otherwise — `<Key>` is the browser `KeyboardEvent.key` value (`keys.ArrowRight`, `keys.a`, `keys.Escape`), plus the alias `keys.Space` for the space bar. Naming a key here also makes the player **consume** it (no page scroll) — see [host integration](host-integration.md#keyboard) |
54
+ | `self.x` `self.y` `self.scaleX` … | the object's own current pose (in its channel bindings) |
55
+ | `self.hovered` `self.grabbed` `self.pressed` | the object's own interaction state (`0`/`1`) — see [feedback](behavior-and-interactions.md#feedback) |
56
+ | `<Name>.x` `<Name>.y` … | a named object's live channels (e.g. `Target.x`) |
57
+
58
+ ## Arrays
59
+
60
+ ```
61
+ var slots = [0, 0, 0]
62
+ object "P" { x = slots[i] } // computed index
63
+ slots[i + 1] = 1 // indexed assignment (in actions)
64
+ ```
65
+
66
+ ## Functions (`fn`)
67
+
68
+ Define reusable helpers — a **value** function (an expression) or a **procedure** (actions):
69
+
70
+ ```
71
+ fn dist(ax, ay, bx, by) = hypot(ax - bx, ay - by) # value
72
+ fn reset() { score = 0 go to frame 0 } # procedure
73
+ ```
74
+
75
+ ## Stdlib packages
76
+
77
+ Import bundled helpers with `use "<name>"`. They're embedded (no network, no files), referenced in the
78
+ `.flatpack`, and resolved by the player. Functions are available **bare** and **qualified**
79
+ (`boxHit(…)` or `collision.boxHit(…)` — the qualified form disambiguates collisions).
80
+
81
+ > **The `use` line is optional**: calling a package function imports its package automatically. A package
82
+ > is a vocabulary, not a module to wire up — `pulse(…)` works because you wrote it. Write `use` when you
83
+ > want the dependency stated, or to pick a side when two packages share a bare name. Your own `fn` of the
84
+ > same name always wins.
85
+
86
+ ```
87
+ use "collision" # boxHit(ax,ay,bx,by,hw,hh) · dist(ax,ay,bx,by) · near(ax,ay,bx,by,r)
88
+ use "easing" # easeIn(t) · easeOut(t) · easeInOut(t) · smooth(t) (t in 0..1)
89
+ use "gesture" # snap(v,step) · snapTo(v,target,r) · railT/railX/railY(px,py,ax,ay,bx,by) · angle(cx,cy,px,py) · inZone(px,py,x,y,w,h)
90
+ use "feedback" # lift(h) · dim(h) · tilt(g) · sink(g) · shake(bad,t) · pulse(since,dur) (channel reactions)
91
+ # shake/pulse ride the MONOTONE `clock` → capture instants with `clock`, never `time`
92
+ ```
93
+
94
+ Example:
95
+
96
+ ```
97
+ use "collision"
98
+ object "Ball" {
99
+ when dropped on Goal { won = near(self.x, self.y, Goal.x, Goal.y, 30) ? 1 : 0 }
100
+ }
101
+ ```
102
+
103
+ ## See also
104
+
105
+ - Where expressions are used (channels, interactors, feedback) → **[Behavior & interactions](behavior-and-interactions.md)**
106
+ - The `feedback` sugar that writes channel expressions for you → **[Feedback](behavior-and-interactions.md#feedback)**
107
+ - Sending computed values out to the embedding app → **[Host integration](host-integration.md)**