@zakkster/lite-scratch-fx 1.0.0 → 1.3.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.
- package/CHANGELOG.md +86 -5
- package/README.md +374 -113
- package/index.d.ts +148 -9
- package/index.js +102 -49
- package/llms.txt +55 -21
- package/package.json +8 -4
- package/src/HostStyle.js +42 -0
- package/src/PixelScan.js +74 -0
- package/src/ScratchController.js +146 -96
- package/src/ScratchRecipes.js +680 -131
- package/src/ScratchStage.js +235 -0
- package/src/ScratchRecipes2.js +0 -642
package/README.md
CHANGED
|
@@ -1,32 +1,38 @@
|
|
|
1
1
|
# @zakkster/lite-scratch-fx
|
|
2
2
|
|
|
3
|
+
> One-shot scratch-card reveal effects on canvas. A controller scans the remaining pixels of a scratch layer, spawns particles from them, and delegates physics + rendering to a recipe. 21 ready-made reveal recipes, themeable palettes. Zero GSAP, zero-GC hot path, deterministic seeded RNG for reproducible reveals.
|
|
4
|
+
|
|
3
5
|
[](https://www.npmjs.com/package/@zakkster/lite-scratch-fx)
|
|
4
6
|
[](https://github.com/sponsors/PeshoVurtoleta)
|
|
7
|
+

|
|
5
8
|
[](https://bundlephobia.com/result?p=@zakkster/lite-scratch-fx)
|
|
6
9
|
[](https://www.npmjs.com/package/@zakkster/lite-scratch-fx)
|
|
7
10
|
[](https://www.npmjs.com/package/@zakkster/lite-scratch-fx)
|
|
8
11
|

|
|
12
|
+

|
|
9
13
|
[](https://opensource.org/licenses/MIT)
|
|
10
14
|
|
|
11
|
-
|
|
12
|
-
pixels of a scratch layer, spawns particles from those points, and delegates physics
|
|
13
|
-
and rendering to a **recipe**. Ships **21 ready-made reveal recipes**; bring your own by
|
|
14
|
-
matching the recipe interface.
|
|
15
|
+
## The scratch-card reveal layer the ecosystem was missing
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
Scratch cards have two halves that nobody ships together. The **interaction** -- a
|
|
18
|
+
pointer erasing a foil layer -- is a five-line canvas `destination-out` loop you already
|
|
19
|
+
know how to write. The **reveal** -- the moment the card decides "won", clears the rest,
|
|
20
|
+
and celebrates -- is where every project reinvents an ad-hoc particle burst, hardcodes a
|
|
21
|
+
palette, and (because it runs a fresh burst every frame) drops frames on the GC. This
|
|
22
|
+
library is that second half: a controller that reads the *still-covered* pixels of your
|
|
23
|
+
scratch layer as spawn points, and 21 ready-made **recipes** that turn them into a burn,
|
|
24
|
+
a shatter, a confetti blast, or your own. Deterministic from a seed, so an instant-win
|
|
25
|
+
reveal is auditable and replayable. Zero GSAP.
|
|
18
26
|
|
|
19
|
-
```
|
|
27
|
+
```bash
|
|
20
28
|
npm install @zakkster/lite-scratch-fx
|
|
21
29
|
```
|
|
22
30
|
|
|
23
|
-
## Quick start
|
|
24
|
-
|
|
25
31
|
```js
|
|
26
32
|
import { createScratchController, BurnRecipe } from '@zakkster/lite-scratch-fx';
|
|
27
33
|
|
|
28
|
-
// scratchCanvas: the layer the user scratches off.
|
|
29
|
-
// fxCanvas: an overlay canvas the
|
|
34
|
+
// scratchCanvas: the foil layer the user scratches off.
|
|
35
|
+
// fxCanvas: an overlay canvas the reveal renders particles onto.
|
|
30
36
|
const fx = createScratchController(scratchCanvas, fxCanvas, { seed: 42 });
|
|
31
37
|
|
|
32
38
|
revealButton.onclick = () => {
|
|
@@ -36,75 +42,89 @@ revealButton.onclick = () => {
|
|
|
36
42
|
};
|
|
37
43
|
```
|
|
38
44
|
|
|
39
|
-
`reveal(recipe, onDone)` samples spawn points from the still-covered pixels of the
|
|
40
|
-
scratch layer, runs the recipe to completion,
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
45
|
+
`reveal(recipe, onDone?)` samples spawn points from the still-covered pixels of the
|
|
46
|
+
scratch layer, runs the recipe to completion, clears the overlay, then calls `onDone`. A
|
|
47
|
+
second `reveal` while one is active is ignored. Same seed in, same reveal out -- every
|
|
48
|
+
time.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Table of contents
|
|
53
|
+
|
|
54
|
+
- [Why this exists](#why-this-exists)
|
|
55
|
+
- [What you get](#what-you-get)
|
|
56
|
+
- [The controller and the recipe interface](#the-controller-and-the-recipe-interface)
|
|
57
|
+
- [API reference](#api-reference)
|
|
58
|
+
- [createScratchController](#createscratchcontroller)
|
|
59
|
+
- [createScratchStage](#createscratchstage)
|
|
60
|
+
- [Registry and palette helpers](#registry-and-palette-helpers)
|
|
61
|
+
- [Constants](#constants)
|
|
62
|
+
- [Composability](#composability)
|
|
63
|
+
- [Zero-GC design notes](#zero-gc-design-notes)
|
|
64
|
+
- [Design decisions worth knowing](#design-decisions-worth-knowing)
|
|
65
|
+
- [Testing](#testing)
|
|
66
|
+
- [What this is not](#what-this-is-not)
|
|
67
|
+
- [Ecosystem](#ecosystem)
|
|
68
|
+
- [License](#license)
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Why this exists
|
|
73
|
+
|
|
74
|
+
The gap is **interaction vs reveal**. Erasing a foil layer with a pointer is trivial and
|
|
75
|
+
every stack solves it inline. The reveal -- the payoff animation once the card is decided
|
|
76
|
+
-- is what gets copy-pasted, hardcoded, and quietly leaked. Three things make it worth a
|
|
77
|
+
library instead of a snippet:
|
|
78
|
+
|
|
79
|
+
1. **The spawn points come from the layer itself.** A reveal should erupt from the part
|
|
80
|
+
still covered, not from a fixed grid. The controller downsamples the scratch layer,
|
|
81
|
+
collects the pixels whose alpha is still above threshold, and hands those to the recipe
|
|
82
|
+
as normalized `0..1` spawn spots -- so a half-scratched card reveals only what is left.
|
|
83
|
+
|
|
84
|
+
2. **Instant-win reveals must be auditable.** A prize-bearing reveal that cannot be
|
|
85
|
+
reproduced is a support ticket waiting to happen. The RNG is seeded and deterministic:
|
|
86
|
+
the same seed replays the exact same reveal, so "what did the player see" is a
|
|
87
|
+
reproducible question, not a guess. `seed(s)` re-seeds between reveals.
|
|
88
|
+
|
|
89
|
+
3. **The reveal runs every frame -- so it cannot allocate.** A burst that mints a fresh
|
|
90
|
+
object per surviving pixel, or a new particle-view per frame, turns the celebration
|
|
91
|
+
into GC jitter. The reveal-start scan and the per-frame tick are both zero-allocation
|
|
92
|
+
by construction (see [Zero-GC design notes](#zero-gc-design-notes)).
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## What you get
|
|
97
|
+
|
|
98
|
+
- **`createScratchController(scratch, fx, options?)`** -- one controller over a scratch
|
|
99
|
+
layer and an effect overlay. `reveal(recipe, onDone?)` scans, spawns, runs to
|
|
100
|
+
completion, clears, and calls back. The API every caller reaches for.
|
|
101
|
+
- **21 reveal recipes across 4 families** -- 10 **particle** (`burn`, `dissolve`,
|
|
102
|
+
`explode`, `dragonBreath`, `iceBreath`, `goldDust`, `confettiBlast`, `cosmicDust`,
|
|
103
|
+
`matrixDecay`, `liquidMelt`), 3 **image** (`shatter`, `pixelShatter`, `glitchReveal`),
|
|
104
|
+
5 **beam** (`shine`, `shineWave`, `laserScan`, `lightningCrawl`, `neonPulse`), and 3
|
|
105
|
+
**css** (`peel`, `fade`, `implosion`). Bring your own by matching the recipe interface.
|
|
106
|
+
- **14 themeable recipes.** Every recipe with a visible palette takes the same
|
|
107
|
+
`{ colors, theme }` pair, so a themed host threads one `{ light, mid, dark }` triple
|
|
108
|
+
through every effect. The 7 image / neutral recipes have no palette and ignore both.
|
|
109
|
+
- **Three driving modes, none able to double-drive the loop** -- the default self-driven
|
|
110
|
+
engine (one card), `{ driven: true }` (one page clock drives N controllers), and
|
|
111
|
+
`createScratchStage` (many boxes revealing at once over one shared pool).
|
|
112
|
+
- **An extensible registry.** `RECIPES` (short name -> factory), `RECIPE_NAMES`, and a
|
|
113
|
+
live `RECIPE_META` array so a picker groups and filters without hardcoding the list.
|
|
114
|
+
`registerRecipe` adds or overrides a recipe and every existing picker sees it.
|
|
115
|
+
- **Full TypeScript declarations** ship in [`index.d.ts`](./index.d.ts) -- the recipe
|
|
116
|
+
interface, controller and stage options, `Theme`, and `ThemeableRecipeOptions`.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## The controller and the recipe interface
|
|
121
|
+
|
|
122
|
+
<details>
|
|
123
|
+
<summary>How the controller drives a recipe, and the two contracts that keep it zero-GC.</summary>
|
|
124
|
+
|
|
125
|
+
A recipe is a plain object with five members. The controller calls `init` once, `spawn`
|
|
126
|
+
for each particle, then `tick` every frame until it returns `true`, and finally
|
|
127
|
+
`destroy`:
|
|
108
128
|
|
|
109
129
|
```js
|
|
110
130
|
function MyRecipe({ count = 120, duration = 1000 } = {}) {
|
|
@@ -122,7 +142,7 @@ function MyRecipe({ count = 120, duration = 1000 } = {}) {
|
|
|
122
142
|
let alive = 0;
|
|
123
143
|
for (let i = 0; i < p.max; i++) {
|
|
124
144
|
if (p.life[i] <= 0) continue;
|
|
125
|
-
const slot = p.data[i]; // maps back to the
|
|
145
|
+
const slot = p.data[i]; // maps back to the spawn index (see below)
|
|
126
146
|
p.x[i] += p.vx[i] * dt * 60;
|
|
127
147
|
p.y[i] += p.vy[i] * dt * 60;
|
|
128
148
|
p.life[i] -= dt;
|
|
@@ -140,50 +160,291 @@ function MyRecipe({ count = 120, duration = 1000 } = {}) {
|
|
|
140
160
|
}
|
|
141
161
|
```
|
|
142
162
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
163
|
+
**`spot` is shared and mutable -- never retain it.** The `spot` handed to every `spawn`
|
|
164
|
+
call is one reused object. Read `spot.x` / `spot.y` synchronously; the next spawn
|
|
165
|
+
overwrites it in place. This is what keeps reveal-start allocation-free: there is no
|
|
166
|
+
`{ x, y }` literal per surviving pixel.
|
|
167
|
+
|
|
168
|
+
**`p.data[i]` maps a live particle back to its slot.** Recipes keep their own parallel
|
|
169
|
+
typed arrays (`size` above), keyed by spawn index. The controller passes each particle's
|
|
170
|
+
spawn index as the engine's data flag, so inside `tick` you read `p.data[i]` to recover
|
|
171
|
+
the index your `spawn` wrote to. A test locks this mapping so it stays correct across
|
|
147
172
|
engine upgrades.
|
|
148
173
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
`{
|
|
153
|
-
|
|
174
|
+
`dt` is in **seconds**; `elapsedMs` is milliseconds since the reveal began. The
|
|
175
|
+
particle-view `p` (raw SoA lanes `x, y, vx, vy, life, invLife, data, max`) is reused
|
|
176
|
+
across frames, so `tick` allocates nothing. To make a recipe themeable, take
|
|
177
|
+
`{ colors, theme }` in your factory and resolve a ramp once with `resolvePalette`.
|
|
178
|
+
|
|
179
|
+
</details>
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## API reference
|
|
184
|
+
|
|
185
|
+
### createScratchController
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
createScratchController(sourceCanvas, effectCanvas, options?): ScratchController
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
| Option | Type | Default | Meaning |
|
|
192
|
+
| -------------- | ----------------- | ------------- | --------------------------------------------------- |
|
|
193
|
+
| `maxParticles` | number | `2000` | Particle pool capacity (ignored when `engine` given) |
|
|
194
|
+
| `seed` | number | `Date.now()` | Deterministic RNG seed |
|
|
195
|
+
| `scanPrecision`| number | `32` | Horizontal resolution of the spawn-point pixel scan |
|
|
196
|
+
| `driven` | boolean | `false` | Host-driven mode: you call `tick(dt)` each frame |
|
|
197
|
+
| `engine` | SoaParticleEngine | (own) | Share one caller-supplied engine (one lane pool) |
|
|
198
|
+
|
|
199
|
+
Returns a controller:
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
controller.reveal(recipe, onDone?): void // run a reveal; ignored if one is active
|
|
203
|
+
controller.tick(dt): void // advance a frame in { driven: true } mode; dt in SECONDS
|
|
204
|
+
controller.seed(s): void // re-seed the RNG for reproducible reveals
|
|
205
|
+
controller.destroy(): void // stop, clean up the active recipe, release an owned engine
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
- **default** -- the controller owns a `@zakkster/lite-soa-particle-engine` that
|
|
209
|
+
self-drives its own `requestAnimationFrame` loop. Fine for a single card.
|
|
210
|
+
- **`{ driven: true }`** -- the controller never starts a RAF loop; the host calls
|
|
211
|
+
`tick(dt)` every frame. `tick` is a no-op unless a reveal is active, so calling it on
|
|
212
|
+
every box is cheap. One clock, N controllers.
|
|
213
|
+
- **`{ engine }`** -- share one engine (one lane pool for the whole page). A shared engine
|
|
214
|
+
has a single render slot, so **one reveal per shared engine at a time**; a `reveal` on a
|
|
215
|
+
busy shared engine is ignored, and `destroy()` never tears a shared engine down.
|
|
216
|
+
|
|
217
|
+
### createScratchStage
|
|
154
218
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
219
|
+
For **many boxes revealing simultaneously** over a single pool, a stage owns one engine,
|
|
220
|
+
hands each controller a fixed sub-range of the lanes, and drives every active reveal
|
|
221
|
+
through one clock.
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
createScratchStage(options?): ScratchStage
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
| Option | Type | Default | Meaning |
|
|
228
|
+
| -------------- | ------ | ------------ | ---------------------------------------------- |
|
|
229
|
+
| `maxParticles` | number | `2000` | Total shared pool capacity, split across cards |
|
|
230
|
+
| `seed` | number | `Date.now()` | Base seed; each controller derives `base + index` |
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
stage.createController(sourceCanvas, effectCanvas, options?): StageController
|
|
234
|
+
stage.tick(dt): void // advance every active reveal one frame; dt in SECONDS
|
|
235
|
+
stage.destroy(): void // stop everything and release the shared engine
|
|
236
|
+
stage.remainingCapacity // slots still unreserved in the shared pool (read-only)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
`createController` options: `capacity` (slots reserved out of the pool, default `300` --
|
|
240
|
+
reserving past `maxParticles` throws), `seed` (default: the stage base seed plus the
|
|
241
|
+
controller index), and `scanPrecision` (default `32`). A stage-managed controller exposes
|
|
242
|
+
`reveal(recipe, onDone?)`, `seed(s)`, and `destroy()` -- but **no `tick` of its own**, the
|
|
243
|
+
stage drives it.
|
|
244
|
+
|
|
245
|
+
### Registry and palette helpers
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
registerRecipe(id, factory, meta?): RecipeFactory
|
|
249
|
+
resolvePalette(colors, theme, fallback): string[]
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
- **`registerRecipe(id, factory, meta?)`** -- add a recipe or override a built-in. It
|
|
253
|
+
lands in `RECIPES` and `RECIPE_META` immediately, so existing pickers pick it up with no
|
|
254
|
+
code change. Omitted `meta` fields fall back to the prior entry, then a de-camelCased
|
|
255
|
+
name, `category: 'custom'`, and `false` flags. Throws `TypeError` on a bad `id` or a
|
|
256
|
+
non-function `factory`.
|
|
257
|
+
- **`resolvePalette(colors, theme, fallback)`** -- resolve a colour ramp for a themeable
|
|
258
|
+
recipe: `colors` wins, then `theme`, then the recipe's own `fallback`.
|
|
259
|
+
|
|
260
|
+
### Constants
|
|
261
|
+
|
|
262
|
+
| Constant | Value | Meaning |
|
|
263
|
+
| ------------------------- | ----------------------------------------------------------- | ---------------------------------------------------- |
|
|
264
|
+
| `VERSION` | `'1.3.0'` | Package version string (synced to package.json). |
|
|
265
|
+
| `maxParticles` (default) | `2000` | Controller / stage pool capacity. |
|
|
266
|
+
| `scanPrecision` (default) | `32` | Horizontal resolution of the spawn-point scan. |
|
|
267
|
+
| stage `capacity` (default)| `300` | Slots a stage controller reserves from the pool. |
|
|
268
|
+
| `RECIPE_NAMES.length` | `21` | Count of built-in recipes at load time. |
|
|
269
|
+
| `RECIPE_META[n]` | `{ id, name, category, themeable, needsUntaintedCanvas }` | Live per-recipe metadata for building pickers. |
|
|
270
|
+
| categories | `'particle'` | `'image'` | `'beam'` | `'css'` | The four built-in recipe families. |
|
|
271
|
+
|
|
272
|
+
`RECIPES` is an extensible null-prototype registry keyed by short name;
|
|
273
|
+
`RECIPE_NAMES` is its keys at load time; `RECIPE_META` is a live array
|
|
274
|
+
(`registerRecipe` keeps it in sync). `needsUntaintedCanvas` is `true` only for the 3
|
|
275
|
+
image recipes, which snapshot the layer via `toDataURL()` and therefore need a same-origin
|
|
276
|
+
canvas.
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
## Composability
|
|
281
|
+
|
|
282
|
+
A full grid of scratch cards: the manual erase you own, one shared pool via
|
|
283
|
+
`createScratchStage`, a per-card controller, a `RECIPE_META`-driven picker, and a single
|
|
284
|
+
`requestAnimationFrame` calling `stage.tick(dt)`.
|
|
285
|
+
|
|
286
|
+
```js
|
|
287
|
+
import { createScratchStage, RECIPES, RECIPE_META } from '@zakkster/lite-scratch-fx';
|
|
288
|
+
|
|
289
|
+
// One pool, one clock, a whole grid of cards revealing at once.
|
|
290
|
+
const stage = createScratchStage({ maxParticles: 1500, seed: 1 });
|
|
291
|
+
|
|
292
|
+
// A picker driven by RECIPE_META -- registering a recipe needs no change here.
|
|
293
|
+
const particle = RECIPE_META.filter((m) => m.category === 'particle');
|
|
294
|
+
const pick = () => particle[(Math.random() * particle.length) | 0].id;
|
|
295
|
+
|
|
296
|
+
for (const card of document.querySelectorAll('.card')) {
|
|
297
|
+
const scratch = card.querySelector('.scratch'); // the foil the user erases
|
|
298
|
+
const fx = card.querySelector('.fx'); // the particle overlay
|
|
299
|
+
const ctrl = stage.createController(scratch, fx, { capacity: 200 });
|
|
300
|
+
|
|
301
|
+
// Manual erase: pointer strokes punch holes in the foil layer (you own this).
|
|
302
|
+
const sctx = scratch.getContext('2d');
|
|
303
|
+
sctx.globalCompositeOperation = 'destination-out';
|
|
304
|
+
scratch.addEventListener('pointermove', (e) => {
|
|
305
|
+
if (e.buttons !== 1) return;
|
|
306
|
+
sctx.beginPath();
|
|
307
|
+
sctx.arc(e.offsetX, e.offsetY, 18, 0, Math.PI * 2);
|
|
308
|
+
sctx.fill();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// "Reveal the rest" runs a recipe over the pixels still covered.
|
|
312
|
+
card.querySelector('.reveal').addEventListener('click', () => {
|
|
313
|
+
ctrl.reveal(RECIPES[pick()]());
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// One rAF drives every active reveal in the grid; dt is in SECONDS.
|
|
318
|
+
let last = performance.now();
|
|
319
|
+
function frame(now) {
|
|
320
|
+
const dt = (now - last) / 1000; last = now;
|
|
321
|
+
stage.tick(dt); // advances every card mid-reveal, allocation-free
|
|
322
|
+
requestAnimationFrame(frame);
|
|
323
|
+
}
|
|
324
|
+
requestAnimationFrame(frame);
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
The erase is plain canvas `destination-out` -- this library never touches your input. The
|
|
328
|
+
stage holds **one** lane pool for the whole grid, hands each card a fixed 200-slot
|
|
329
|
+
sub-range, and the single `stage.tick(dt)` fans out to every card that is mid-reveal. Add
|
|
330
|
+
a recipe with `registerRecipe` and the `RECIPE_META` picker above serves it with no edit.
|
|
331
|
+
|
|
332
|
+
---
|
|
333
|
+
|
|
334
|
+
## Zero-GC design notes
|
|
335
|
+
|
|
336
|
+
<details>
|
|
337
|
+
<summary>What the reveal path allocates (nothing, after warm-up) and how it stays that way.</summary>
|
|
338
|
+
|
|
339
|
+
Two hot paths matter: the **reveal-start scan** (runs once per reveal, over every covered
|
|
340
|
+
pixel) and the **per-frame tick** (runs every frame, over every live particle). Both are
|
|
341
|
+
allocation-free by construction.
|
|
342
|
+
|
|
343
|
+
| Operation | Steady-state allocations |
|
|
344
|
+
| ------------------------------- | ------------------------------------------------- |
|
|
345
|
+
| reveal-start scan canvas | **1 ever** per controller (built once, reused) |
|
|
346
|
+
| per-pixel spawn objects | **0** -- one reused, mutable `spot` object |
|
|
347
|
+
| per-frame `tick` | **0** -- one reused particle-view (`p`) |
|
|
348
|
+
| stage subarray views | once per controller, reused every frame |
|
|
349
|
+
|
|
350
|
+
The scan canvas is minted a single time at construction, not per reveal, and the surviving
|
|
351
|
+
pixels fill preallocated `Float32Array`s read back through one reused `spot` -- no
|
|
352
|
+
`{ x, y }` literal per pixel. The per-frame `tick` runs physics over the engine's
|
|
353
|
+
typed-array lanes through a particle-view object that is reused across frames. A stage
|
|
354
|
+
controller builds `subarray` views of its lane sub-range once and reuses them every frame,
|
|
355
|
+
so even a grid of simultaneous reveals grows the heap by nothing.
|
|
356
|
+
|
|
357
|
+
The torture gate (`@zakkster/lite-gc-profiler` + `@zakkster/lite-leak`, under
|
|
358
|
+
`--expose-gc`) proves this across tiers **T0-T5 + T9**: every one of the 21 recipes
|
|
359
|
+
completes (T0), an empty scratch layer still completes via the centre fallback (T1), a
|
|
360
|
+
seed replays byte-identical spawn work (T2), a controller mints exactly one scan canvas
|
|
361
|
+
across 25 reveals (T3), the per-frame tick stays under the bytes/op ceiling (T4), a
|
|
362
|
+
concurrent two-card stage tick stays under it too (T5), and the T9 controls prove each
|
|
363
|
+
gate rejects its deliberately-broken variant.
|
|
364
|
+
|
|
365
|
+
</details>
|
|
366
|
+
|
|
367
|
+
---
|
|
368
|
+
|
|
369
|
+
## Design decisions worth knowing
|
|
370
|
+
|
|
371
|
+
- **Spawn points come from the layer, not a grid.** The controller downsamples the scratch
|
|
372
|
+
layer to `scanPrecision` px wide and collects pixels whose alpha is still above
|
|
373
|
+
threshold as normalized `0..1` spawn spots. A half-scratched card reveals only the part
|
|
374
|
+
still covered. An empty layer falls back to the centre, so a reveal never hangs.
|
|
375
|
+
- **`count: 0` is a real value.** A pure-canvas reveal (`shine`, `implosion`, the css
|
|
376
|
+
recipes) declares `count: 0` and spawns no particles. The controller uses
|
|
377
|
+
`count ?? maxParticles`, so an explicit `0` is honoured -- an earlier `count || max` bug
|
|
378
|
+
turned `0` into a full 2000-particle pool.
|
|
379
|
+
- **One drive, never two.** The engine self-drives its own RAF and computes its own `dt`.
|
|
380
|
+
The three modes (default, `{ driven: true }`, `createScratchStage`) are each a *single*
|
|
381
|
+
drive. The removed `sharedTicker` option double-drove the loop, corrupting `dt` so life
|
|
382
|
+
never decayed and reveals ran forever; driven mode replaces it correctly.
|
|
383
|
+
- **`spot` and `p` are shared and reused.** The spawn spot and the particle-view are one
|
|
384
|
+
reused object each -- read them synchronously, never retain. This is the whole zero-GC
|
|
385
|
+
story, and it is a contract a recipe must honour.
|
|
386
|
+
- **Theming is one shape for every effect.** The 14 palette recipes all take
|
|
387
|
+
`{ colors, theme }`; `colors` wins, `theme` maps a `{ light, mid, dark }` triple onto the
|
|
388
|
+
ramp, and omitting both keeps the recipe's own default. A skin threads one triple through
|
|
389
|
+
the whole grid.
|
|
390
|
+
- **Image recipes need an untainted canvas.** `shatter`, `pixelShatter`, and
|
|
391
|
+
`glitchReveal` snapshot the layer via `toDataURL()` and an `Image`, so the scratch layer
|
|
392
|
+
must be same-origin. `RECIPE_META[n].needsUntaintedCanvas` flags them for a picker.
|
|
393
|
+
|
|
394
|
+
---
|
|
395
|
+
|
|
396
|
+
## Testing
|
|
397
|
+
|
|
398
|
+
**62 deterministic `node:test` cases, all pass**, plus a torture gate that proves both
|
|
399
|
+
leak-freedom and the zero-allocation reveal path.
|
|
400
|
+
|
|
401
|
+
```bash
|
|
402
|
+
npm test # 62 node:test cases: controller lifecycle, data[] indexing, per-recipe
|
|
403
|
+
# reveal-to-completion, theming, the registry, driven mode, and the stage
|
|
404
|
+
npm run torture # node --expose-gc test/torture.mjs -> prints "ok" (T0-T5 + T9)
|
|
405
|
+
```
|
|
158
406
|
|
|
159
|
-
|
|
407
|
+
The suites cover the controller lifecycle, the load-bearing `data[i]` spawn-index mapping,
|
|
408
|
+
a reveal-to-completion smoke test for every one of the 21 recipes, theming (every themeable
|
|
409
|
+
recipe drives its palette from a theme; short-ramp safety; `colors` overrides `theme`), the
|
|
410
|
+
registry and `RECIPE_META` bijection, host-driven mode, the concurrent stage, and a
|
|
411
|
+
`VERSION`-matches-`package.json` pin. The torture harness gates completion, degenerate
|
|
412
|
+
layers, RNG determinism, the one-scan-canvas-ever assertion, and the per-frame /
|
|
413
|
+
concurrent-stage heap-growth ceilings, with controls proving each gate can fail. No gate
|
|
414
|
+
output is a FAIL.
|
|
160
415
|
|
|
161
|
-
|
|
416
|
+
---
|
|
162
417
|
|
|
163
|
-
|
|
164
|
-
|---|---|---|---|
|
|
165
|
-
| `maxParticles` | number | `2000` | Particle pool capacity |
|
|
166
|
-
| `seed` | number | `Date.now()` | Deterministic RNG seed |
|
|
167
|
-
| `scanPrecision` | number | `32` | Horizontal resolution of the spawn-point pixel scan |
|
|
418
|
+
## What this is not
|
|
168
419
|
|
|
169
|
-
|
|
420
|
+
- **Not a scratch-gesture library.** It reads the covered pixels of your scratch layer;
|
|
421
|
+
it does not draw the foil or handle the erase input. The pointer `destination-out` loop
|
|
422
|
+
is five lines and yours -- see [Composability](#composability).
|
|
423
|
+
- **Not GSAP or a general animation timeline.** No tweens, no easing DSL, no timeline
|
|
424
|
+
scrubbing. It is a one-shot reveal driver: `reveal` runs a recipe to completion and
|
|
425
|
+
stops.
|
|
426
|
+
- **Not a physics engine.** Each recipe owns its own physics over the engine's typed-array
|
|
427
|
+
lanes. There is no collision, no constraint solver, no shared force field.
|
|
428
|
+
- **Not a UI framework.** No components, no DOM layout, no canvas creation for your cards.
|
|
429
|
+
Bring your own markup and wire two canvases to a controller.
|
|
430
|
+
- **Not for retaining `spot` or `p`.** Both are reused, mutable objects. Retaining either
|
|
431
|
+
reads garbage on the next frame -- copy the numbers out if you need them later.
|
|
170
432
|
|
|
171
|
-
|
|
172
|
-
- **`seed(s)`** — re-seed the RNG for reproducible reveals.
|
|
173
|
-
- **`destroy()`** — stop, clean up the active recipe, release the engine.
|
|
433
|
+
---
|
|
174
434
|
|
|
175
|
-
##
|
|
435
|
+
## Ecosystem
|
|
176
436
|
|
|
177
|
-
|
|
178
|
-
loop and computes its own delta time; the controller does not drive it from an external
|
|
179
|
-
ticker. An earlier draft accepted a `sharedTicker` and drove the engine loop manually —
|
|
180
|
-
that double-drove the loop and corrupted its `dt`, breaking life decay and completion.
|
|
181
|
-
It has been removed.
|
|
437
|
+
Part of the **@zakkster** zero-GC stack. This package builds on three of them:
|
|
182
438
|
|
|
183
|
-
|
|
439
|
+
- [`lite-soa-particle-engine`](https://www.npmjs.com/package/@zakkster/lite-soa-particle-engine) -- the struct-of-arrays particle pool the reveal renders through
|
|
440
|
+
- [`lite-random`](https://www.npmjs.com/package/@zakkster/lite-random) -- the deterministic seeded RNG behind reproducible reveals
|
|
441
|
+
- [`lite-lerp`](https://www.npmjs.com/package/@zakkster/lite-lerp) -- the interpolation helpers recipes use for motion and colour
|
|
442
|
+
- [`lite-ambient-fx`](https://www.npmjs.com/package/@zakkster/lite-ambient-fx) -- ambient canvas backgrounds; its `registerTheme` is the model for this package's `registerRecipe`
|
|
443
|
+
- [`lite-signal`](https://www.npmjs.com/package/@zakkster/lite-signal) -- zero-GC reactive graph for hot paths
|
|
444
|
+
- **`lite-scratch-fx`** -- this package
|
|
184
445
|
|
|
185
|
-
|
|
446
|
+
---
|
|
186
447
|
|
|
187
448
|
## License
|
|
188
449
|
|
|
189
|
-
MIT
|
|
450
|
+
MIT (c) Zahary Shinikchiev <shinikchiev@yahoo.com>
|