@zakkster/lite-scratch-fx 1.0.0 → 1.2.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 CHANGED
@@ -1,5 +1,57 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.2.0] - 2026-08-22
4
+
5
+ Concurrent scratch-card reveals over one shared particle pool. Purely additive --
6
+ `createScratchController` and its options are unchanged.
7
+
8
+ ### Added
9
+ - **`createScratchStage({ maxParticles, seed })`** — a stage that owns one particle
10
+ engine and lets any number of controllers reveal **at the same time** over that one
11
+ pool (where `{ engine }` allowed only one reveal at a time). Each
12
+ `stage.createController(src, fx, { capacity })` reserves a fixed sub-range of the lanes,
13
+ renders only its own particles through reused subarray views (zero per-frame allocation),
14
+ and keeps its own deterministic RNG. `stage.tick(dt)` (seconds) drives every active reveal
15
+ from one clock; reserving past `maxParticles` throws. Stage-managed controllers have no
16
+ `tick` of their own. See `decisions/0001-concurrent-shared-reveals.md`.
17
+ - `src/PixelScan.js` — the reveal-scan logic extracted into a shared `createPixelScanner`,
18
+ now used by both the controller and the stage (no behaviour change).
19
+
20
+ ### Notes
21
+ - Feasible with no change to `@zakkster/lite-soa-particle-engine`: raw-mode `tick(dt)` is
22
+ pure dispatch and the lanes are public typed arrays recipes already write, so a stage can
23
+ partition one pool into per-controller sub-ranges entirely in this package.
24
+
25
+ ## [1.1.0] - 2026-08-22
26
+
27
+ Recipe organization and an extensible registry, modeled on `@zakkster/lite-ambient-fx`.
28
+ No behaviour change to any recipe — all 21 bodies are byte-identical to 1.0.0.
29
+
30
+ ### Added
31
+ - **`RECIPE_META`** — a live array of `{ id, name, category, themeable, needsUntaintedCanvas }`
32
+ for every recipe, so a host can build a picker (group by `category`, filter by `themeable`)
33
+ without hardcoding the list. Mirrors lite-ambient's `THEME_META`.
34
+ - **`registerRecipe(id, factory, meta?)`** — register a custom recipe or override a built-in;
35
+ it lands in `RECIPES` and `RECIPE_META` immediately so existing pickers keep working.
36
+ Mirrors lite-ambient's `registerTheme()`. Omitted meta fields fall back to the prior entry,
37
+ then a de-camelCased name, `category: 'custom'`, and `false` flags.
38
+ - `resolvePalette` is now exported (and typed) for authors writing themeable recipes.
39
+ - **Host-driven mode** — `createScratchController(src, fx, { driven: true })` skips the
40
+ engine's RAF loop; the host calls `controller.tick(dt)` (dt in seconds) so one page clock
41
+ drives N controllers. `tick()` is a no-op unless driven and a reveal is active.
42
+ - **Shared engine** — `createScratchController(src, fx, { engine })` reuses a caller-supplied
43
+ particle engine, so a grid of scratch boxes holds one lane pool instead of one per box.
44
+ A shared engine serves one reveal at a time (a reveal on a busy shared engine is ignored);
45
+ `destroy()` never tears down a caller-supplied engine. The two options compose.
46
+
47
+ ### Changed
48
+ - **The two recipe files are merged into one `src/ScratchRecipes.js`, grouped by family**
49
+ (particle / image / beam / css) with section banners — replacing the arbitrary
50
+ `ScratchRecipes.js` (12) + `ScratchRecipes2.js` (9) split. `ScratchRecipes2.js` is removed
51
+ from the package. All imports resolve through `index.js`, so the public API is unchanged.
52
+ - `RECIPES` is now an extensible null-prototype registry (was a frozen object) so
53
+ `registerRecipe` can add to it. Reads are unaffected.
54
+
3
55
  ## [1.0.0] - 2026-08-22
4
56
 
5
57
  First published release. The effects and controller existed as loose source files; this
package/README.md CHANGED
@@ -74,6 +74,31 @@ fx.reveal(recipe);
74
74
  // RECIPE_NAMES is every key: 'burn', 'shatter', 'dissolve', …
75
75
  ```
76
76
 
77
+ ### Building a picker with `RECIPE_META`
78
+
79
+ `RECIPE_META` is a live array of `{ id, name, category, themeable, needsUntaintedCanvas }`
80
+ for every recipe, so a UI can group and filter without hardcoding the list:
81
+
82
+ ```js
83
+ import { RECIPE_META } from '@zakkster/lite-scratch-fx';
84
+
85
+ // Only the recipes a themed skin can drive:
86
+ const themeable = RECIPE_META.filter((m) => m.themeable);
87
+ // Group a dropdown by category: 'particle' | 'image' | 'beam' | 'css'
88
+ ```
89
+
90
+ ### Registering your own recipe
91
+
92
+ `registerRecipe(id, factory, meta?)` adds a recipe (or overrides a built-in). It lands in
93
+ `RECIPES` and `RECIPE_META` immediately, so existing pickers pick it up with no code change:
94
+
95
+ ```js
96
+ import { registerRecipe, createScratchController } from '@zakkster/lite-scratch-fx';
97
+
98
+ registerRecipe('sparkle', SparkleRecipe, { category: 'particle', themeable: true });
99
+ fx.reveal(RECIPES.sparkle({ theme: mySkin }));
100
+ ```
101
+
77
102
  | Recipe | Effect |
78
103
  |---|---|
79
104
  | `burn` | Embers rise and fade while the layer burns away |
@@ -172,13 +197,90 @@ Returns `{ reveal(recipe, onDone?), seed(s), destroy() }`.
172
197
  - **`seed(s)`** — re-seed the RNG for reproducible reveals.
173
198
  - **`destroy()`** — stop, clean up the active recipe, release the engine.
174
199
 
200
+ ## Scaling to a grid of scratch boxes
201
+
202
+ By default each controller owns a particle engine that self-drives its own
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:
205
+
206
+ ```js
207
+ import { createScratchController } from '@zakkster/lite-scratch-fx';
208
+ import { SoaParticleEngine } from '@zakkster/lite-soa-particle-engine';
209
+
210
+ // One shared engine (one lane pool for the whole page) + host-driven controllers.
211
+ const engine = new SoaParticleEngine(1500);
212
+ const boxes = cards.map((c) => createScratchController(c.scratch, c.fx, { engine, driven: true }));
213
+
214
+ // One page loop drives them all; dt is in SECONDS.
215
+ let last = performance.now();
216
+ function frame(now) {
217
+ const dt = (now - last) / 1000; last = now;
218
+ for (const b of boxes) b.tick(dt); // only the box mid-reveal does work
219
+ requestAnimationFrame(frame);
220
+ }
221
+ requestAnimationFrame(frame);
222
+ ```
223
+
224
+ - **`{ driven: true }`** — the controller never starts its own RAF; you call `tick(dt)`
225
+ each frame. `tick` is a no-op unless a reveal is active, so calling it on every box is
226
+ cheap. One clock, N controllers.
227
+ - **`{ engine }`** — share one caller-supplied engine, so the page holds *one* lane pool
228
+ instead of one per box. A shared engine has a single render slot and one particle pool,
229
+ so **one reveal per shared engine at a time**: a `reveal` on a busy shared engine is
230
+ ignored (a scratch-card grid reveals one card at a time anyway). `destroy()` never tears
231
+ down a shared engine — its lifecycle is yours.
232
+
233
+ The two compose (shared + driven, above) or stand alone. The default (own engine,
234
+ self-driven) is unchanged.
235
+
236
+ ### Concurrent reveals over one pool: `createScratchStage`
237
+
238
+ `{ engine }` shares a pool but runs **one reveal at a time**. When you need **many boxes
239
+ revealing simultaneously** while still holding a single pool, use a stage: it owns one
240
+ engine, hands each controller a fixed sub-range of the lanes, and dispatches every active
241
+ reveal through one clock.
242
+
243
+ ```js
244
+ import { createScratchStage } from '@zakkster/lite-scratch-fx';
245
+
246
+ 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
+
250
+ // Any number of boxes can reveal at once; one pool, one loop:
251
+ boxes[3].reveal(RECIPES.burn({ theme: skin }));
252
+ boxes[7].reveal(RECIPES.confettiBlast());
253
+
254
+ let last = performance.now();
255
+ function frame(now) {
256
+ const dt = (now - last) / 1000; last = now;
257
+ stage.tick(dt); // advances every active reveal
258
+ requestAnimationFrame(frame);
259
+ }
260
+ requestAnimationFrame(frame);
261
+ ```
262
+
263
+ Each controller reserves `capacity` slots from `maxParticles` (reserving past the pool
264
+ throws), renders only its own particles, and keeps its own deterministic RNG. Stage-managed
265
+ controllers have no `tick` of their own — the stage drives them. The per-frame `stage.tick`
266
+ is allocation-free even with many reveals in flight (torture-gated). Use `createScratchStage`
267
+ for a live grid of simultaneous reveals; use `{ engine }` when reveals are one-at-a-time and
268
+ you just want the memory saving; use the default per-controller engine for a single card.
269
+
175
270
  ## Notes on the engine
176
271
 
177
- The particle engine (`@zakkster/lite-soa-particle-engine`) owns its own animation-frame
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.
272
+ By default the particle engine (`@zakkster/lite-soa-particle-engine`) self-drives its own
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:
275
+
276
+ - **default** the engine self-drives (`start()`/`stop()`).
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)`.
280
+
281
+ The removed `sharedTicker` option was a different thing: it drove the engine loop *in addition*
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.
182
284
 
183
285
  ## Dependencies
184
286
 
package/index.d.ts CHANGED
@@ -2,6 +2,18 @@
2
2
  * @zakkster/lite-scratch-fx — type declarations.
3
3
  */
4
4
 
5
+ /** The package version. Kept in sync with package.json and CHANGELOG. */
6
+ export const VERSION: string;
7
+
8
+ /** A @zakkster/lite-soa-particle-engine instance. Opaque here; share one across controllers. */
9
+ export interface SoaParticleEngine {
10
+ tick(dt: number): boolean;
11
+ start(): void;
12
+ stop(): void;
13
+ clear(): void;
14
+ destroy(): void;
15
+ }
16
+
5
17
  /** The raw SoA particle arrays a recipe's `tick` receives. */
6
18
  export interface ParticleView {
7
19
  x: Float32Array;
@@ -73,20 +85,39 @@ export interface Recipe {
73
85
  }
74
86
 
75
87
  export interface ScratchControllerOptions {
76
- /** Particle pool capacity. Default 2000. */
88
+ /** Particle pool capacity. Default 2000. Ignored when `engine` is supplied. */
77
89
  maxParticles?: number;
78
90
  /** Seed for the deterministic RNG. Default `Date.now()`. */
79
91
  seed?: number;
80
92
  /** Horizontal resolution of the spawn-point pixel scan. Default 32. */
81
93
  scanPrecision?: number;
94
+ /**
95
+ * Host-driven mode. When true the controller never starts a RAF loop; the host calls
96
+ * `tick(dt)` every frame (dt in seconds). One page clock can drive N controllers.
97
+ */
98
+ driven?: boolean;
99
+ /**
100
+ * Share a caller-supplied particle engine (one lane pool for the whole page). The
101
+ * engine has a single render slot and one pool, so only one reveal per shared engine
102
+ * at a time; `destroy()` will not tear a shared engine down.
103
+ */
104
+ engine?: SoaParticleEngine;
82
105
  }
83
106
 
84
107
  export interface ScratchController {
85
- /** Run a reveal recipe. Ignored if one is already active or the controller is destroyed. */
108
+ /**
109
+ * Run a reveal recipe. Ignored if one is already active, the controller is destroyed,
110
+ * or a shared engine is busy with another controller's reveal.
111
+ */
86
112
  reveal(recipe: Recipe, onDone?: () => void): void;
113
+ /**
114
+ * Advance one frame in host-driven mode; `dt` is in SECONDS. A no-op unless the
115
+ * controller was created with `{ driven: true }` and a reveal is active.
116
+ */
117
+ tick(dt: number): void;
87
118
  /** Re-seed the RNG. */
88
119
  seed(s: number): void;
89
- /** Stop, clean up the active recipe, and release the engine. */
120
+ /** Stop, clean up the active recipe, and release the engine (owned engines only). */
90
121
  destroy(): void;
91
122
  }
92
123
 
@@ -101,6 +132,56 @@ export function createScratchController(
101
132
 
102
133
  export { createScratchController as ScratchController };
103
134
 
135
+ // ── Concurrent reveals over one shared pool (createScratchStage) ──
136
+
137
+ export interface ScratchStageOptions {
138
+ /** Total shared pool capacity, split across controllers. Default 2000. */
139
+ maxParticles?: number;
140
+ /** Base seed; each controller derives its own (base + index) unless it passes one. */
141
+ seed?: number;
142
+ }
143
+
144
+ export interface StageControllerOptions {
145
+ /** Slots reserved for this controller's particles out of the shared pool. Default 300. */
146
+ capacity?: number;
147
+ /** RNG seed for this controller. Default: the stage's base seed + controller index. */
148
+ seed?: number;
149
+ /** Horizontal resolution of the spawn-point pixel scan. Default 32. */
150
+ scanPrecision?: number;
151
+ }
152
+
153
+ /** A stage-managed controller. The stage drives it, so it has no `tick` of its own. */
154
+ export interface StageController {
155
+ /** Reveal a recipe. Ignored if this controller is already revealing or destroyed. */
156
+ reveal(recipe: Recipe, onDone?: () => void): void;
157
+ /** Re-seed this controller's RNG. */
158
+ seed(s: number): void;
159
+ /** Remove this controller from the stage (its sub-range is not reclaimed). */
160
+ destroy(): void;
161
+ }
162
+
163
+ /**
164
+ * A stage owning one shared particle pool. Any number of controllers can reveal at once,
165
+ * all rendering from the one pool, driven by a single `tick(dt)`.
166
+ */
167
+ export interface ScratchStage {
168
+ /** Reserve a sub-range and return a controller bound to this stage. */
169
+ createController(
170
+ sourceCanvas: HTMLCanvasElement,
171
+ effectCanvas: HTMLCanvasElement,
172
+ options?: StageControllerOptions,
173
+ ): StageController;
174
+ /** Advance every active reveal one frame; `dt` is in SECONDS. */
175
+ tick(dt: number): void;
176
+ /** Stop everything and release the shared engine. */
177
+ destroy(): void;
178
+ /** Slots still unreserved in the shared pool. */
179
+ readonly remainingCapacity: number;
180
+ }
181
+
182
+ /** Create a stage for concurrent reveals over one shared particle pool. */
183
+ export function createScratchStage(options?: ScratchStageOptions): ScratchStage;
184
+
104
185
  // ── Recipe factories. Each returns a Recipe; all options are optional. ──
105
186
 
106
187
  export interface RecipeOptions {
@@ -143,8 +224,39 @@ export function CosmicDustRecipe(opts?: ThemeableRecipeOptions): Recipe;
143
224
  /** A factory that builds a recipe from options. */
144
225
  export type RecipeFactory = (opts?: RecipeOptions) => Recipe;
145
226
 
146
- /** Registry of every built-in recipe, keyed by short name. */
147
- export const RECIPES: Readonly<Record<string, RecipeFactory>>;
227
+ /** Resolve a colour ramp from `colors` / `theme` / a fallback (see `src/Palette.js`). */
228
+ export function resolvePalette(
229
+ colors: string[] | undefined,
230
+ theme: Theme | undefined,
231
+ fallback: string[],
232
+ ): string[];
148
233
 
149
- /** Names of every built-in recipe (keys of RECIPES). */
234
+ /**
235
+ * Extensible registry of recipes, keyed by short name. Built-ins are present at load;
236
+ * `registerRecipe` adds more. A null-prototype object.
237
+ */
238
+ export const RECIPES: Record<string, RecipeFactory>;
239
+
240
+ /** Names of every built-in recipe (keys of RECIPES at load time). */
150
241
  export const RECIPE_NAMES: readonly string[];
242
+
243
+ export type RecipeCategory = 'particle' | 'image' | 'beam' | 'css' | (string & {});
244
+
245
+ /** Display + capability metadata for a recipe, for building pickers. */
246
+ export interface RecipeMeta {
247
+ id: string;
248
+ name: string;
249
+ category: RecipeCategory;
250
+ themeable: boolean;
251
+ needsUntaintedCanvas: boolean;
252
+ }
253
+
254
+ /** Live metadata array for every recipe; `registerRecipe` keeps it in sync. */
255
+ export const RECIPE_META: RecipeMeta[];
256
+
257
+ /** Register a custom recipe (or override a built-in); reflected in RECIPES + RECIPE_META. */
258
+ export function registerRecipe(
259
+ id: string,
260
+ factory: RecipeFactory,
261
+ meta?: Partial<Omit<RecipeMeta, 'id'>>,
262
+ ): RecipeFactory;
package/index.js CHANGED
@@ -11,74 +11,127 @@
11
11
  * MIT License.
12
12
  */
13
13
 
14
+ // Three-place version sync: this constant, package.json "version", and the top
15
+ // CHANGELOG.md heading must always match. /release keeps them locked.
16
+ export const VERSION = '1.2.0';
17
+
14
18
  export { createScratchController } from './src/ScratchController.js';
15
19
  export { default as ScratchController } from './src/ScratchController.js';
20
+ export { createScratchStage } from './src/ScratchStage.js';
21
+ export { resolvePalette } from './src/Palette.js';
16
22
 
17
- // Core reveal recipes.
23
+ // All 21 recipes now live in one file, grouped by family.
18
24
  export {
19
- BurnRecipe,
20
- ShatterRecipe,
21
- DissolveRecipe,
22
- ExplodeRecipe,
23
- DragonBreathRecipe,
24
- IceBreathRecipe,
25
- ShineWaveRecipe,
26
- LightningCrawlRecipe,
27
- ShineRecipe,
28
- PeelRecipe,
29
- FadeRecipe,
30
- ImplosionRecipe,
25
+ BurnRecipe, DissolveRecipe, ExplodeRecipe, DragonBreathRecipe, IceBreathRecipe,
26
+ GoldDustRecipe, ConfettiBlastRecipe, CosmicDustRecipe, MatrixDecayRecipe, LiquidMeltRecipe,
27
+ ShatterRecipe, PixelShatterRecipe, GlitchRevealRecipe,
28
+ ShineRecipe, ShineWaveRecipe, LaserScanRecipe, LightningCrawlRecipe, NeonPulseRecipe,
29
+ PeelRecipe, FadeRecipe, ImplosionRecipe,
31
30
  } from './src/ScratchRecipes.js';
32
31
 
33
- // Extended / stylized reveal recipes.
34
- export {
35
- GlitchRevealRecipe,
36
- MatrixDecayRecipe,
37
- GoldDustRecipe,
38
- PixelShatterRecipe,
39
- LaserScanRecipe,
40
- ConfettiBlastRecipe,
41
- LiquidMeltRecipe,
42
- NeonPulseRecipe,
43
- CosmicDustRecipe,
44
- } from './src/ScratchRecipes2.js';
45
-
46
- // A combined registry keyed by short name, for data-driven pickers (demo dropdowns,
47
- // random selection, config files). Keys are the recipe name without the `Recipe` suffix.
48
32
  import {
49
- BurnRecipe, ShatterRecipe, DissolveRecipe, ExplodeRecipe, DragonBreathRecipe,
50
- IceBreathRecipe, ShineWaveRecipe, LightningCrawlRecipe, ShineRecipe, PeelRecipe,
51
- FadeRecipe, ImplosionRecipe,
33
+ BurnRecipe, DissolveRecipe, ExplodeRecipe, DragonBreathRecipe, IceBreathRecipe,
34
+ GoldDustRecipe, ConfettiBlastRecipe, CosmicDustRecipe, MatrixDecayRecipe, LiquidMeltRecipe,
35
+ ShatterRecipe, PixelShatterRecipe, GlitchRevealRecipe,
36
+ ShineRecipe, ShineWaveRecipe, LaserScanRecipe, LightningCrawlRecipe, NeonPulseRecipe,
37
+ PeelRecipe, FadeRecipe, ImplosionRecipe,
52
38
  } from './src/ScratchRecipes.js';
53
- import {
54
- GlitchRevealRecipe, MatrixDecayRecipe, GoldDustRecipe, PixelShatterRecipe,
55
- LaserScanRecipe, ConfettiBlastRecipe, LiquidMeltRecipe, NeonPulseRecipe,
56
- CosmicDustRecipe,
57
- } from './src/ScratchRecipes2.js';
58
39
 
59
- export const RECIPES = Object.freeze({
40
+ // ── Recipe registry (extensible: registerRecipe adds to it) ──
41
+ // Keyed by short name, for data-driven pickers (demo dropdowns, random selection,
42
+ // config files). A null-prototype object so keys never collide with Object.prototype.
43
+ export const RECIPES = Object.assign(Object.create(null), {
60
44
  burn: BurnRecipe,
61
- shatter: ShatterRecipe,
62
45
  dissolve: DissolveRecipe,
63
46
  explode: ExplodeRecipe,
64
47
  dragonBreath: DragonBreathRecipe,
65
48
  iceBreath: IceBreathRecipe,
49
+ goldDust: GoldDustRecipe,
50
+ confettiBlast: ConfettiBlastRecipe,
51
+ cosmicDust: CosmicDustRecipe,
52
+ matrixDecay: MatrixDecayRecipe,
53
+ liquidMelt: LiquidMeltRecipe,
54
+ shatter: ShatterRecipe,
55
+ pixelShatter: PixelShatterRecipe,
56
+ glitchReveal: GlitchRevealRecipe,
57
+ shine: ShineRecipe,
66
58
  shineWave: ShineWaveRecipe,
59
+ laserScan: LaserScanRecipe,
67
60
  lightningCrawl: LightningCrawlRecipe,
68
- shine: ShineRecipe,
61
+ neonPulse: NeonPulseRecipe,
69
62
  peel: PeelRecipe,
70
63
  fade: FadeRecipe,
71
64
  implosion: ImplosionRecipe,
72
- glitchReveal: GlitchRevealRecipe,
73
- matrixDecay: MatrixDecayRecipe,
74
- goldDust: GoldDustRecipe,
75
- pixelShatter: PixelShatterRecipe,
76
- laserScan: LaserScanRecipe,
77
- confettiBlast: ConfettiBlastRecipe,
78
- liquidMelt: LiquidMeltRecipe,
79
- neonPulse: NeonPulseRecipe,
80
- cosmicDust: CosmicDustRecipe,
81
65
  });
82
66
 
83
- /** Names of every built-in recipe (the keys of RECIPES). */
67
+ /**
68
+ * Display + capability metadata for every built-in recipe, so a host can build a
69
+ * picker without hardcoding the list. Mirrors lite-ambient's THEME_META. A live
70
+ * array: registerRecipe() updates it, so existing pickers keep working.
71
+ *
72
+ * category 'particle' | 'image' | 'beam' | 'css' | (custom)
73
+ * themeable accepts { colors, theme }
74
+ * needsUntaintedCanvas snapshots the scratch layer via toDataURL() (same-origin only)
75
+ */
76
+ export const RECIPE_META = [
77
+ { id: 'burn', name: 'Burn', category: 'particle', themeable: true, needsUntaintedCanvas: false },
78
+ { id: 'dissolve', name: 'Dissolve', category: 'particle', themeable: true, needsUntaintedCanvas: false },
79
+ { id: 'explode', name: 'Explode', category: 'particle', themeable: true, needsUntaintedCanvas: false },
80
+ { id: 'dragonBreath', name: 'Dragon Breath', category: 'particle', themeable: true, needsUntaintedCanvas: false },
81
+ { id: 'iceBreath', name: 'Ice Breath', category: 'particle', themeable: true, needsUntaintedCanvas: false },
82
+ { id: 'goldDust', name: 'Gold Dust', category: 'particle', themeable: true, needsUntaintedCanvas: false },
83
+ { id: 'confettiBlast', name: 'Confetti Blast', category: 'particle', themeable: true, needsUntaintedCanvas: false },
84
+ { id: 'cosmicDust', name: 'Cosmic Dust', category: 'particle', themeable: true, needsUntaintedCanvas: false },
85
+ { id: 'matrixDecay', name: 'Matrix Decay', category: 'particle', themeable: true, needsUntaintedCanvas: false },
86
+ { id: 'liquidMelt', name: 'Liquid Melt', category: 'particle', themeable: true, needsUntaintedCanvas: false },
87
+ { id: 'shatter', name: 'Shatter', category: 'image', themeable: false, needsUntaintedCanvas: true },
88
+ { id: 'pixelShatter', name: 'Pixel Shatter', category: 'image', themeable: false, needsUntaintedCanvas: true },
89
+ { id: 'glitchReveal', name: 'Glitch Reveal', category: 'image', themeable: false, needsUntaintedCanvas: true },
90
+ { id: 'shine', name: 'Shine', category: 'beam', themeable: false, needsUntaintedCanvas: false },
91
+ { id: 'shineWave', name: 'Shine Wave', category: 'beam', themeable: true, needsUntaintedCanvas: false },
92
+ { id: 'laserScan', name: 'Laser Scan', category: 'beam', themeable: true, needsUntaintedCanvas: false },
93
+ { id: 'lightningCrawl', name: 'Lightning Crawl', category: 'beam', themeable: true, needsUntaintedCanvas: false },
94
+ { id: 'neonPulse', name: 'Neon Pulse', category: 'beam', themeable: true, needsUntaintedCanvas: false },
95
+ { id: 'peel', name: 'Peel', category: 'css', themeable: false, needsUntaintedCanvas: false },
96
+ { id: 'fade', name: 'Fade', category: 'css', themeable: false, needsUntaintedCanvas: false },
97
+ { id: 'implosion', name: 'Implosion', category: 'css', themeable: false, needsUntaintedCanvas: false },
98
+ ];
99
+
100
+ /** Names of every built-in recipe (the keys of RECIPES at load time). */
84
101
  export const RECIPE_NAMES = Object.freeze(Object.keys(RECIPES));
102
+
103
+ /**
104
+ * Register a custom recipe, or override a built-in. Instantly usable via
105
+ * RECIPES[id] and reflected in RECIPE_META so existing pickers keep working.
106
+ * Mirrors lite-ambient's registerTheme().
107
+ *
108
+ * @param {string} id short name (the RECIPES key)
109
+ * @param {Function} factory a recipe factory: (opts) => Recipe
110
+ * @param {{ name?: string, category?: string, themeable?: boolean, needsUntaintedCanvas?: boolean }} [meta]
111
+ * Omitted fields fall back to the existing entry (when overriding), then to a
112
+ * de-camelCased name, category 'custom', and false flags.
113
+ * @returns {Function} the registered factory
114
+ */
115
+ export function registerRecipe(id, factory, meta) {
116
+ if (typeof id !== 'string' || id.length === 0) {
117
+ throw new TypeError('registerRecipe: id must be a non-empty string');
118
+ }
119
+ if (typeof factory !== 'function') {
120
+ throw new TypeError('registerRecipe: factory must be a function');
121
+ }
122
+ RECIPES[id] = factory;
123
+
124
+ const idx = RECIPE_META.findIndex((m) => m.id === id);
125
+ const prev = idx >= 0 ? RECIPE_META[idx] : null;
126
+ const entry = {
127
+ id,
128
+ name: (meta && meta.name) || (prev && prev.name)
129
+ || id.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^[a-z]/, (c) => c.toUpperCase()),
130
+ category: (meta && meta.category) || (prev && prev.category) || 'custom',
131
+ themeable: meta && 'themeable' in meta ? !!meta.themeable : (prev ? prev.themeable : false),
132
+ needsUntaintedCanvas: meta && 'needsUntaintedCanvas' in meta
133
+ ? !!meta.needsUntaintedCanvas : (prev ? prev.needsUntaintedCanvas : false),
134
+ };
135
+ if (idx >= 0) RECIPE_META[idx] = entry; else RECIPE_META.push(entry);
136
+ return factory;
137
+ }
package/llms.txt CHANGED
@@ -7,8 +7,9 @@
7
7
 
8
8
  ## Core model
9
9
 
10
- createScratchController(sourceCanvas, effectCanvas, { maxParticles = 2000, seed = Date.now(), scanPrecision = 32 })
11
- -> { reveal(recipe, onDone?), seed(s), destroy() }
10
+ createScratchController(sourceCanvas, effectCanvas,
11
+ { maxParticles = 2000, seed = Date.now(), scanPrecision = 32, driven = false, engine })
12
+ -> { reveal(recipe, onDone?), tick(dt), seed(s), destroy() }
12
13
 
13
14
  sourceCanvas = the scratch layer (what the user scratches off).
14
15
  effectCanvas = an overlay canvas the particles render onto.
@@ -65,25 +66,58 @@ locks this because it couples recipes to engine internals (_head).
65
66
 
66
67
  ## Recipes (21)
67
68
 
68
- Core (ScratchRecipes.js): BurnRecipe, ShatterRecipe, DissolveRecipe, ExplodeRecipe,
69
- DragonBreathRecipe, IceBreathRecipe, ShineWaveRecipe, LightningCrawlRecipe, ShineRecipe,
70
- PeelRecipe, FadeRecipe, ImplosionRecipe.
71
- Extended (ScratchRecipes2.js): GlitchRevealRecipe, MatrixDecayRecipe, GoldDustRecipe,
72
- PixelShatterRecipe, LaserScanRecipe, ConfettiBlastRecipe, LiquidMeltRecipe, NeonPulseRecipe,
73
- CosmicDustRecipe.
74
-
75
- RECIPES is a frozen registry keyed by short name (burn, shatter, …); RECIPE_NAMES lists them.
76
- shatter/glitchReveal/pixelShatter snapshot the scratch layer via sourceCanvas.toDataURL()+Image,
77
- so that layer must be same-origin (untainted).
78
-
79
- ## Engine driving (why there is no shared ticker)
80
-
81
- @zakkster/lite-soa-particle-engine owns its own requestAnimationFrame loop: start() schedules
82
- it, stop() cancels it, and _loop computes dt from performance.now() deltas internally. The
83
- controller must NOT also drive _loop from an external ticker -- doing so double-drives the loop
84
- and corrupts dt, which breaks life decay and completion (a reveal runs forever). An earlier
85
- draft accepted a sharedTicker and did exactly that; it was removed. ambient-fx also owns its own
86
- loop and does not accept a shared ticker, so "one RAF for atmosphere + reveal" was never real.
69
+ All 21 live in src/ScratchRecipes.js, grouped by family with section banners:
70
+ particle (spawn from covered pixels, own physics): burn, dissolve, explode, dragonBreath,
71
+ iceBreath, goldDust, confettiBlast, cosmicDust, matrixDecay, liquidMelt
72
+ image (snapshot layer via toDataURL()+Image; same-origin only): shatter, pixelShatter, glitchReveal
73
+ beam/canvas (draw light/lines on the overlay): shine, shineWave, laserScan, lightningCrawl, neonPulse
74
+ css (drive the layer's own transform/opacity): peel, fade, implosion
75
+
76
+ ## Registry, metadata, extension
77
+
78
+ VERSION: the package version string (kept in sync with package.json and CHANGELOG).
79
+ RECIPES: an extensible null-prototype registry keyed by short name (burn, shatter, …).
80
+ RECIPE_NAMES: the built-in keys at load time.
81
+ RECIPE_META: a live array of { id, name, category, themeable, needsUntaintedCanvas } for every
82
+ recipe -- the source for building pickers without hardcoding. category is
83
+ 'particle'|'image'|'beam'|'css'. needsUntaintedCanvas is true only for shatter/pixelShatter/
84
+ glitchReveal. themeable is true for the 14 recipes that take { colors, theme }.
85
+ registerRecipe(id, factory, meta?): add a recipe or override a built-in; lands in RECIPES and
86
+ RECIPE_META immediately so existing pickers keep working. Mirrors lite-ambient's registerTheme.
87
+ Omitted meta fields fall back to the prior entry, then a de-camelCased name, category 'custom',
88
+ false flags. Throws TypeError on a bad id or non-function factory.
89
+
90
+ ## Engine driving (three modes, dt always correct)
91
+
92
+ The old sharedTicker bug was DOUBLE-driving: the engine self-drove its RAF loop AND an external
93
+ ticker called _loop, corrupting dt so reveals ran forever. That is removed. Three single-drive
94
+ modes exist now:
95
+ DEFAULT: the controller owns a SoaParticleEngine that self-drives via start()/stop().
96
+ driven: true -- the controller never calls start(); the host calls controller.tick(dt) each
97
+ frame with dt in SECONDS (engine.tick(dt) is the engine's primary API; start() is a thin RAF
98
+ wrapper over it). One page clock drives N controllers. tick() is a no-op unless driven AND a
99
+ reveal is active.
100
+ { engine } -- N controllers share one caller-supplied engine (one lane pool for the page).
101
+ The engine has a single onTick slot and one particle pool, so ONE reveal per shared engine at
102
+ a time: a reveal on a busy shared engine is ignored (guarded by a module WeakSet). destroy()
103
+ does NOT destroy a shared engine (the caller owns it); it only unbinds and frees the slot.
104
+ Compose with driven:true and drive the page from one clock.
105
+
106
+ ## Concurrent reveals over one pool: createScratchStage
107
+
108
+ createScratchStage({ maxParticles = 2000, seed = Date.now() })
109
+ -> { createController(src, fx, { capacity = 300, seed?, scanPrecision = 32 }) -> stageController,
110
+ tick(dt), destroy(), get remainingCapacity }
111
+ stageController -> { reveal(recipe, onDone?), seed(s), destroy() } // NO tick -- the stage drives.
112
+
113
+ When { engine } (one reveal at a time) is not enough and you need MANY boxes revealing at once
114
+ over one pool: a stage owns one raw engine and gives each controller a fixed sub-range [start,
115
+ start+capacity) of the lanes. reserving past maxParticles throws. Each controller emits directly
116
+ into its slots (data[start+i]=i), renders through subarray views of its range (built once per
117
+ controller, reused every frame -> zero per-frame alloc), draws to its own ctx, keeps its own RNG.
118
+ stage.tick(dt) advances the single engine once; the one onTick fans out to every active reveal.
119
+ Feasible with no engine change because raw tick(dt) is pure dispatch (no physics/culling) and the
120
+ lanes are public typed arrays recipes already write. See decisions/0001-concurrent-shared-reveals.md.
87
121
 
88
122
  ## Fixed in 1.0.0 (was broken pre-package)
89
123
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@zakkster/lite-scratch-fx",
3
3
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
4
- "version": "1.0.0",
5
- "description": "One-shot scratch-card reveal effects. A controller scans the remaining pixels of a scratch layer, spawns particles from them, and delegates physics and rendering to a recipe. 21 ready-made reveal recipes (burn, shatter, dissolve, glitch, gold dust, cosmic dust, and more), themeable palettes, zero GSAP, zero-GC hot path, and deterministic seeded RNG for reproducible reveals.",
4
+ "version": "1.2.0",
5
+ "description": "One-shot scratch-card reveal effects on canvas. A controller scans the still-covered pixels of a scratch layer, spawns particles from them, and delegates physics and rendering to a recipe. 21 themeable recipes (burn, shatter, dissolve, glitch, gold dust, cosmic dust, and more), a registry with a register hook, zero GSAP, a zero-GC hot path, deterministic seeded RNG, and a stage that runs a grid of simultaneous reveals over one shared pool.",
6
6
  "type": "module",
7
7
  "main": "./index.js",
8
8
  "module": "./index.js",
@@ -20,7 +20,8 @@
20
20
  "index.d.ts",
21
21
  "src/ScratchController.js",
22
22
  "src/ScratchRecipes.js",
23
- "src/ScratchRecipes2.js",
23
+ "src/ScratchStage.js",
24
+ "src/PixelScan.js",
24
25
  "src/Palette.js",
25
26
  "README.md",
26
27
  "CHANGELOG.md",
@@ -55,7 +56,9 @@
55
56
  "game",
56
57
  "zero-gc",
57
58
  "deterministic",
58
- "oklch",
59
+ "theme",
60
+ "reveal-animation",
61
+ "scratch-off",
59
62
  "zakkster"
60
63
  ],
61
64
  "devDependencies": {