@zakkster/lite-scratch-fx 1.2.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 +42 -13
- package/README.md +348 -189
- package/index.d.ts +31 -4
- package/index.js +2 -2
- package/llms.txt +6 -6
- package/package.json +2 -1
- package/src/HostStyle.js +42 -0
- package/src/ScratchController.js +67 -26
- package/src/ScratchRecipes.js +13 -13
- package/src/ScratchStage.js +60 -17
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,100 +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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
| `liquidMelt` | The layer melts and runs |
|
|
123
|
-
| `neonPulse` | A neon pulse ring |
|
|
124
|
-
| `cosmicDust` | Slow drifting star dust |
|
|
125
|
-
|
|
126
|
-
Several recipes (`shatter`, `glitchReveal`, `pixelShatter`) snapshot the scratch layer
|
|
127
|
-
with `sourceCanvas.toDataURL()` and an `Image`, so the layer must be same-origin
|
|
128
|
-
(untainted) for those effects.
|
|
129
|
-
|
|
130
|
-
## Writing a recipe
|
|
131
|
-
|
|
132
|
-
A recipe is a plain object. The controller drives it:
|
|
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`:
|
|
133
128
|
|
|
134
129
|
```js
|
|
135
130
|
function MyRecipe({ count = 120, duration = 1000 } = {}) {
|
|
@@ -147,7 +142,7 @@ function MyRecipe({ count = 120, duration = 1000 } = {}) {
|
|
|
147
142
|
let alive = 0;
|
|
148
143
|
for (let i = 0; i < p.max; i++) {
|
|
149
144
|
if (p.life[i] <= 0) continue;
|
|
150
|
-
const slot = p.data[i]; // maps back to the
|
|
145
|
+
const slot = p.data[i]; // maps back to the spawn index (see below)
|
|
151
146
|
p.x[i] += p.vx[i] * dt * 60;
|
|
152
147
|
p.y[i] += p.vy[i] * dt * 60;
|
|
153
148
|
p.life[i] -= dt;
|
|
@@ -165,127 +160,291 @@ function MyRecipe({ count = 120, duration = 1000 } = {}) {
|
|
|
165
160
|
}
|
|
166
161
|
```
|
|
167
162
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
172
172
|
engine upgrades.
|
|
173
173
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
`{
|
|
178
|
-
in your factory and resolve a ramp once with `resolvePalette` from `./src/Palette.js`.
|
|
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`.
|
|
179
178
|
|
|
180
|
-
|
|
181
|
-
across frames, and physics runs over the engine's typed-array lanes. `npm run torture`
|
|
182
|
-
gates both the reveal-start scan and the per-frame tick against heap growth.
|
|
179
|
+
</details>
|
|
183
180
|
|
|
184
|
-
|
|
181
|
+
---
|
|
185
182
|
|
|
186
|
-
|
|
183
|
+
## API reference
|
|
187
184
|
|
|
188
|
-
|
|
189
|
-
|---|---|---|---|
|
|
190
|
-
| `maxParticles` | number | `2000` | Particle pool capacity |
|
|
191
|
-
| `seed` | number | `Date.now()` | Deterministic RNG seed |
|
|
192
|
-
| `scanPrecision` | number | `32` | Horizontal resolution of the spawn-point pixel scan |
|
|
185
|
+
### createScratchController
|
|
193
186
|
|
|
194
|
-
|
|
187
|
+
```ts
|
|
188
|
+
createScratchController(sourceCanvas, effectCanvas, options?): ScratchController
|
|
189
|
+
```
|
|
195
190
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
+
```
|
|
199
207
|
|
|
200
|
-
|
|
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.
|
|
201
216
|
|
|
202
|
-
|
|
203
|
-
`requestAnimationFrame` loop — fine for one card. For a page full of scratch boxes,
|
|
204
|
-
two options let one clock and one lane pool serve the whole grid:
|
|
217
|
+
### createScratchStage
|
|
205
218
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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.
|
|
209
222
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
223
|
+
```ts
|
|
224
|
+
createScratchStage(options?): ScratchStage
|
|
225
|
+
```
|
|
213
226
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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[]
|
|
222
250
|
```
|
|
223
251
|
|
|
224
|
-
- **`
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
232
261
|
|
|
233
|
-
|
|
234
|
-
|
|
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. |
|
|
235
271
|
|
|
236
|
-
|
|
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.
|
|
237
277
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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)`.
|
|
242
285
|
|
|
243
286
|
```js
|
|
244
|
-
import { createScratchStage } from '@zakkster/lite-scratch-fx';
|
|
287
|
+
import { createScratchStage, RECIPES, RECIPE_META } from '@zakkster/lite-scratch-fx';
|
|
245
288
|
|
|
289
|
+
// One pool, one clock, a whole grid of cards revealing at once.
|
|
246
290
|
const stage = createScratchStage({ maxParticles: 1500, seed: 1 });
|
|
247
|
-
const boxes = cards.map((c) =>
|
|
248
|
-
stage.createController(c.scratch, c.fx, { capacity: 200 })); // reserves 200 slots each
|
|
249
291
|
|
|
250
|
-
//
|
|
251
|
-
|
|
252
|
-
|
|
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
|
+
}
|
|
253
316
|
|
|
317
|
+
// One rAF drives every active reveal in the grid; dt is in SECONDS.
|
|
254
318
|
let last = performance.now();
|
|
255
319
|
function frame(now) {
|
|
256
320
|
const dt = (now - last) / 1000; last = now;
|
|
257
|
-
stage.tick(dt); // advances every
|
|
321
|
+
stage.tick(dt); // advances every card mid-reveal, allocation-free
|
|
258
322
|
requestAnimationFrame(frame);
|
|
259
323
|
}
|
|
260
324
|
requestAnimationFrame(frame);
|
|
261
325
|
```
|
|
262
326
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
+
```
|
|
406
|
+
|
|
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.
|
|
415
|
+
|
|
416
|
+
---
|
|
417
|
+
|
|
418
|
+
## What this is not
|
|
269
419
|
|
|
270
|
-
|
|
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.
|
|
271
432
|
|
|
272
|
-
|
|
273
|
-
animation-frame loop and computes its own delta time. There are exactly three single-drive
|
|
274
|
-
modes, none of which can double-drive the loop:
|
|
433
|
+
---
|
|
275
434
|
|
|
276
|
-
|
|
277
|
-
- **`{ driven: true }`** — the host drives via `controller.tick(dt)` *instead of* the engine's
|
|
278
|
-
RAF (see [Scaling to a grid](#scaling-to-a-grid-of-scratch-boxes)).
|
|
279
|
-
- **`createScratchStage`** — the stage drives all its controllers via `stage.tick(dt)`.
|
|
435
|
+
## Ecosystem
|
|
280
436
|
|
|
281
|
-
|
|
282
|
-
to the engine's own RAF, double-driving it and corrupting `dt` (breaking life decay and
|
|
283
|
-
completion). Driven mode replaces it correctly — one clock, one drive.
|
|
437
|
+
Part of the **@zakkster** zero-GC stack. This package builds on three of them:
|
|
284
438
|
|
|
285
|
-
|
|
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
|
|
286
445
|
|
|
287
|
-
|
|
446
|
+
---
|
|
288
447
|
|
|
289
448
|
## License
|
|
290
449
|
|
|
291
|
-
MIT
|
|
450
|
+
MIT (c) Zahary Shinikchiev <shinikchiev@yahoo.com>
|