@zakkster/lite-scratch-fx 1.0.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 ADDED
@@ -0,0 +1,63 @@
1
+ # Changelog
2
+
3
+ ## [1.0.0] - 2026-08-22
4
+
5
+ First published release. The effects and controller existed as loose source files; this
6
+ release consolidates them into a real package, makes every recipe themeable, holds the
7
+ zero-GC line on the reveal path, and stands up the torture gate.
8
+
9
+ ### Added
10
+ - `createScratchController(sourceCanvas, effectCanvas, { maxParticles, seed, scanPrecision })`
11
+ — scans the still-covered pixels of a scratch layer for spawn points, runs a reveal
12
+ recipe to completion, clears the overlay, and calls the completion callback.
13
+ - 21 reveal recipes: `burn`, `shatter`, `dissolve`, `explode`, `dragonBreath`,
14
+ `iceBreath`, `shineWave`, `lightningCrawl`, `shine`, `peel`, `fade`, `implosion`,
15
+ `glitchReveal`, `matrixDecay`, `goldDust`, `pixelShatter`, `laserScan`,
16
+ `confettiBlast`, `liquidMelt`, `neonPulse`, `cosmicDust`.
17
+ - **Themeable palettes.** The 14 recipes with a visible palette take a unified
18
+ `{ colors, theme }` pair — pass a `{ light, mid, dark }` `theme` shorthand or an
19
+ explicit `colors` ramp; `colors` wins, and both default to the recipe's own palette.
20
+ Replaces three inconsistent conventions (`color`, `colors`, hardcoded). New
21
+ `resolvePalette` helper in `src/Palette.js`.
22
+ - `RECIPES` registry (short-name → factory) and `RECIPE_NAMES` for data-driven pickers.
23
+ - Full TypeScript declarations (`index.d.ts`), including `Theme` and `ThemeableRecipeOptions`.
24
+ - Test suite under `node:test` (46 checks): controller lifecycle, the `data[]` dataFlag
25
+ indexing invariant, a reveal-to-completion smoke test for every recipe, and theming
26
+ (every themeable recipe drives its palette from a theme; short-ramp safety; `colors`
27
+ overrides `theme`).
28
+ - Torture gate `npm run torture` (`node --expose-gc test/torture.mjs`, lite-gc-profiler +
29
+ lite-leak): completion, degenerate scratch layers, RNG-spawn determinism, the
30
+ one-scan-canvas-ever assertion, the per-frame tick heap-growth gate, and controls that
31
+ prove each gate can fail.
32
+
33
+ ### Changed
34
+ - **Zero-GC reveal path.** `scanPixels` no longer mints an offscreen canvas or an
35
+ object per surviving pixel on every reveal — the scan canvas is built once and the
36
+ spawn points fill preallocated `Float32Array`s read through one reused `spot` object.
37
+ The per-frame `tick` no longer allocates a fresh particle-view object; it reuses one.
38
+ - Test runner is `node:test` (was vitest); `vitest` and `typescript` devDependencies
39
+ dropped in favour of `@zakkster/lite-gc-profiler` + `@zakkster/lite-leak`.
40
+ - Dropped the unused `@zakkster/lite-ticker` runtime dependency (it backed the removed
41
+ shared-ticker path and was imported nowhere).
42
+
43
+ ### Fixed
44
+ - **Reveals never completed.** The controller attached a ticker that called
45
+ `engine._loop` every frame, but the SoA engine already owns its own
46
+ `requestAnimationFrame` loop and computes its own `dt`. Driving it a second time
47
+ corrupted `dt`, so particle life never decayed and the completion condition
48
+ (`alive === 0 && elapsed >= duration`) was never met — a reveal would run forever. The
49
+ external ticker is removed; the engine self-drives via `start()` / `stop()`.
50
+ - **`count: 0` recipes spawned a full pool.** `recipe.count || maxParticles` turned an
51
+ explicit `0` (pure-canvas reveals like `shine`, `implosion`, `laserScan`) into
52
+ `maxParticles` (2000). Now uses `?? maxParticles`, so `0` is honoured.
53
+ - Import specifier typo: `ScratchController` imported `from 'lite-ticker'` (unscoped);
54
+ the ticker dependency has since been removed entirely.
55
+ - Removed a duplicate `ImplosionRecipe` that was defined in two source files (and a
56
+ dangling reference to it in a re-export barrel).
57
+
58
+ ### Removed
59
+ - The `sharedTicker` controller option. It implied the reveal loop could share one RAF
60
+ with `@zakkster/lite-ambient-fx`, but ambient-fx owns its own loop and the SoA engine
61
+ self-drives, so the option did nothing except enable the double-drive bug above.
62
+ - The older standalone effect module (`ScratchFXRecipes.js`), superseded by the
63
+ recipe-interface versions.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zahary Shinikchiev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,189 @@
1
+ # @zakkster/lite-scratch-fx
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@zakkster/lite-scratch-fx.svg?style=for-the-badge&color=latest)](https://www.npmjs.com/package/@zakkster/lite-scratch-fx)
4
+ [![sponsor](https://img.shields.io/badge/sponsor-PeshoVurtoleta-ea4aaa.svg?logo=github)](https://github.com/sponsors/PeshoVurtoleta)
5
+ [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@zakkster/lite-scratch-fx?style=for-the-badge)](https://bundlephobia.com/result?p=@zakkster/lite-scratch-fx)
6
+ [![npm downloads](https://img.shields.io/npm/dm/@zakkster/lite-scratch-fx?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-scratch-fx)
7
+ [![npm total downloads](https://img.shields.io/npm/dt/@zakkster/lite-scratch-fx?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-scratch-fx)
8
+ ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational)
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT)
10
+
11
+ One-shot **scratch-card reveal effects** on canvas. A controller scans the remaining
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
+
16
+ Zero GSAP. Deterministic RNG. The particle engine owns its own `requestAnimationFrame`
17
+ loop and auto-cleans up on completion.
18
+
19
+ ```
20
+ npm install @zakkster/lite-scratch-fx
21
+ ```
22
+
23
+ ## Quick start
24
+
25
+ ```js
26
+ import { createScratchController, BurnRecipe } from '@zakkster/lite-scratch-fx';
27
+
28
+ // scratchCanvas: the layer the user scratches off.
29
+ // fxCanvas: an overlay canvas the effect renders particles onto.
30
+ const fx = createScratchController(scratchCanvas, fxCanvas, { seed: 42 });
31
+
32
+ revealButton.onclick = () => {
33
+ fx.reveal(BurnRecipe({ duration: 1500 }), () => {
34
+ console.log('prize revealed');
35
+ });
36
+ };
37
+ ```
38
+
39
+ `reveal(recipe, onDone)` samples spawn points from the still-covered pixels of the
40
+ scratch layer, runs the recipe to completion, then clears the overlay and calls
41
+ `onDone`. A second `reveal` while one is active is ignored.
42
+
43
+ ## Theming
44
+
45
+ Every recipe with a visible palette (14 of the 21) takes the same two options, so a
46
+ themed host can drive them all through one shape. Pass a `{ light, mid, dark }`
47
+ `theme` for the common case, or an explicit `colors` ramp to override it. Either
48
+ defaults to the recipe's own palette when omitted, so untyped calls look exactly as
49
+ before.
50
+
51
+ ```js
52
+ // A skin threads one triple through every effect:
53
+ const vikings = { light: '#e8d5a0', mid: '#b8860b', dark: '#3a2a10' };
54
+ fx.reveal(RECIPES.burn({ theme: vikings }));
55
+ fx.reveal(RECIPES.dissolve({ theme: vikings })); // single-colour recipes use theme.light
56
+
57
+ // Or hand a recipe an exact ramp:
58
+ fx.reveal(RECIPES.confettiBlast({ colors: ['#ff0055', '#00ffcc', '#ffcc00'] }));
59
+ ```
60
+
61
+ `colors` wins when both are given. The seven image-based (`shatter`, `pixelShatter`,
62
+ `glitchReveal`) and neutral (`shine`, `peel`, `fade`, `implosion`) recipes have no
63
+ palette to theme and safely ignore both options.
64
+
65
+ ## The 21 recipes
66
+
67
+ Import them by name, or pick from the `RECIPES` registry:
68
+
69
+ ```js
70
+ import { RECIPES, RECIPE_NAMES, createScratchController } from '@zakkster/lite-scratch-fx';
71
+
72
+ const recipe = RECIPES.cosmicDust({ count: 300 });
73
+ fx.reveal(recipe);
74
+ // RECIPE_NAMES is every key: 'burn', 'shatter', 'dissolve', …
75
+ ```
76
+
77
+ | Recipe | Effect |
78
+ |---|---|
79
+ | `burn` | Embers rise and fade while the layer burns away |
80
+ | `shatter` | The layer breaks into gravity-driven glass pieces |
81
+ | `dissolve` | Pixels detach and drift off |
82
+ | `explode` | A hard outward blast of debris |
83
+ | `dragonBreath` | A sweep of fire particles |
84
+ | `iceBreath` | A sweep of frost particles |
85
+ | `shineWave` | A moving light beam with sparkle particles |
86
+ | `lightningCrawl` | Procedural lightning bolts crawl across |
87
+ | `shine` | A single pass of light (no particles) |
88
+ | `peel` | The layer curls and peels away |
89
+ | `fade` | A plain opacity fade |
90
+ | `implosion` | The layer collapses to the center with speed lines |
91
+ | `glitchReveal` | Digital slice-and-shift glitch |
92
+ | `matrixDecay` | Falling code-rain decay |
93
+ | `goldDust` | Drifting gold sparkle |
94
+ | `pixelShatter` | The layer shatters into pixel blocks |
95
+ | `laserScan` | A scanning laser line |
96
+ | `confettiBlast` | A confetti burst |
97
+ | `liquidMelt` | The layer melts and runs |
98
+ | `neonPulse` | A neon pulse ring |
99
+ | `cosmicDust` | Slow drifting star dust |
100
+
101
+ Several recipes (`shatter`, `glitchReveal`, `pixelShatter`) snapshot the scratch layer
102
+ with `sourceCanvas.toDataURL()` and an `Image`, so the layer must be same-origin
103
+ (untainted) for those effects.
104
+
105
+ ## Writing a recipe
106
+
107
+ A recipe is a plain object. The controller drives it:
108
+
109
+ ```js
110
+ function MyRecipe({ count = 120, duration = 1000 } = {}) {
111
+ let size;
112
+ return {
113
+ count, // 0 = pure-canvas reveal, no particles
114
+ init(ctx, capacity, w, h) { // allocate parallel per-particle arrays
115
+ size = new Float32Array(capacity);
116
+ },
117
+ spawn(idx, rng, w, h, spot) { // one particle; spot is 0..1 on the layer
118
+ size[idx] = rng.range(2, 5);
119
+ return { x: spot.x * w, y: spot.y * h, vx: rng.range(-1, 1), vy: rng.range(-4, -1), life: 1 };
120
+ },
121
+ tick(dt, elapsedMs, p, ctx, src, w, h) { // every frame; return true when done
122
+ let alive = 0;
123
+ for (let i = 0; i < p.max; i++) {
124
+ if (p.life[i] <= 0) continue;
125
+ const slot = p.data[i]; // maps back to the array index (see below)
126
+ p.x[i] += p.vx[i] * dt * 60;
127
+ p.y[i] += p.vy[i] * dt * 60;
128
+ p.life[i] -= dt;
129
+ if (p.life[i] <= 0) continue;
130
+ alive++;
131
+ ctx.globalAlpha = p.life[i];
132
+ ctx.beginPath();
133
+ ctx.arc(p.x[i], p.y[i], size[slot], 0, Math.PI * 2);
134
+ ctx.fill();
135
+ }
136
+ return alive === 0 && elapsedMs >= duration;
137
+ },
138
+ destroy() { size = null; },
139
+ };
140
+ }
141
+ ```
142
+
143
+ **Per-particle state and `p.data[i]`.** Recipes keep their own parallel arrays (`size`
144
+ above), keyed by the particle's slot. The controller passes each particle's spawn ring
145
+ index as the engine's data flag, so inside `tick` you read `p.data[i]` to get the index
146
+ your `spawn` wrote to. This mapping is covered by a test so it stays correct across
147
+ engine upgrades.
148
+
149
+ **`spot` is shared and mutable.** The `spot` handed to every `spawn` call is one reused
150
+ object — read `spot.x` / `spot.y` synchronously and never retain the reference; the next
151
+ spawn overwrites it. This is what keeps the reveal-start frame allocation-free (no
152
+ `{x, y}` literal per surviving pixel). To make a recipe themeable, take `{ colors, theme }`
153
+ in your factory and resolve a ramp once with `resolvePalette` from `./src/Palette.js`.
154
+
155
+ The per-frame `tick` runs allocation-free by design: the particle-view object is reused
156
+ across frames, and physics runs over the engine's typed-array lanes. `npm run torture`
157
+ gates both the reveal-start scan and the per-frame tick against heap growth.
158
+
159
+ ## API
160
+
161
+ ### `createScratchController(sourceCanvas, effectCanvas, options?)`
162
+
163
+ | Option | Type | Default | |
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 |
168
+
169
+ Returns `{ reveal(recipe, onDone?), seed(s), destroy() }`.
170
+
171
+ - **`reveal(recipe, onDone?)`** — run a recipe; ignored if one is active.
172
+ - **`seed(s)`** — re-seed the RNG for reproducible reveals.
173
+ - **`destroy()`** — stop, clean up the active recipe, release the engine.
174
+
175
+ ## Notes on the engine
176
+
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.
182
+
183
+ ## Dependencies
184
+
185
+ `@zakkster/lite-soa-particle-engine`, `@zakkster/lite-random`, `@zakkster/lite-lerp`.
186
+
187
+ ## License
188
+
189
+ MIT © Zahary Shinikchiev
package/index.d.ts ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * @zakkster/lite-scratch-fx — type declarations.
3
+ */
4
+
5
+ /** The raw SoA particle arrays a recipe's `tick` receives. */
6
+ export interface ParticleView {
7
+ x: Float32Array;
8
+ y: Float32Array;
9
+ vx: Float32Array;
10
+ vy: Float32Array;
11
+ life: Float32Array;
12
+ invLife: Float32Array;
13
+ /** Per-particle data flag; for these recipes it carries the spawn ring index. */
14
+ data: Float32Array | Uint32Array;
15
+ /** Capacity of the parallel arrays (loop `for i < max`). */
16
+ max: number;
17
+ }
18
+
19
+ /** The initial state a recipe returns from `spawn`. */
20
+ export interface SpawnState {
21
+ x: number;
22
+ y: number;
23
+ vx: number;
24
+ vy: number;
25
+ /** Lifetime in seconds; must be > 0 for the particle to be emitted. */
26
+ life: number;
27
+ }
28
+
29
+ /**
30
+ * A normalized spawn point (0..1 in each axis) taken from the scratch layer.
31
+ * NOTE: this object is shared and reused across every `spawn` call in a reveal —
32
+ * read `x`/`y` synchronously and never retain the reference.
33
+ */
34
+ export interface SpawnSpot {
35
+ x: number;
36
+ y: number;
37
+ }
38
+
39
+ /** A palette shorthand: a themed host threads one of these through every effect. */
40
+ export interface Theme {
41
+ light?: string;
42
+ mid?: string;
43
+ dark?: string;
44
+ }
45
+
46
+ /** Deterministic RNG passed to `spawn` (from @zakkster/lite-random). */
47
+ export interface RecipeRng {
48
+ next(): number;
49
+ int(min: number, max: number): number;
50
+ range(min: number, max: number): number;
51
+ }
52
+
53
+ /**
54
+ * A reveal recipe. The controller calls `init` once, `spawn` for each particle, then
55
+ * `tick` every frame until it returns `true`; `destroy` releases any arrays.
56
+ */
57
+ export interface Recipe {
58
+ /** Particles to spawn. `0` means a pure-canvas reveal with no particles. */
59
+ count: number;
60
+ init(ctx: CanvasRenderingContext2D, capacity: number, w: number, h: number): void;
61
+ spawn(idx: number, rng: RecipeRng, w: number, h: number, spot: SpawnSpot): SpawnState;
62
+ /** Return `true` when the effect is complete. `elapsedMs` is milliseconds since reveal. */
63
+ tick(
64
+ dt: number,
65
+ elapsedMs: number,
66
+ engine: ParticleView,
67
+ ctx: CanvasRenderingContext2D,
68
+ sourceCanvas: HTMLCanvasElement,
69
+ w: number,
70
+ h: number,
71
+ ): boolean;
72
+ destroy(): void;
73
+ }
74
+
75
+ export interface ScratchControllerOptions {
76
+ /** Particle pool capacity. Default 2000. */
77
+ maxParticles?: number;
78
+ /** Seed for the deterministic RNG. Default `Date.now()`. */
79
+ seed?: number;
80
+ /** Horizontal resolution of the spawn-point pixel scan. Default 32. */
81
+ scanPrecision?: number;
82
+ }
83
+
84
+ export interface ScratchController {
85
+ /** Run a reveal recipe. Ignored if one is already active or the controller is destroyed. */
86
+ reveal(recipe: Recipe, onDone?: () => void): void;
87
+ /** Re-seed the RNG. */
88
+ seed(s: number): void;
89
+ /** Stop, clean up the active recipe, and release the engine. */
90
+ destroy(): void;
91
+ }
92
+
93
+ /**
94
+ * Create a scratch-card reveal controller over a scratch layer and an effect overlay.
95
+ */
96
+ export function createScratchController(
97
+ sourceCanvas: HTMLCanvasElement,
98
+ effectCanvas: HTMLCanvasElement,
99
+ options?: ScratchControllerOptions,
100
+ ): ScratchController;
101
+
102
+ export { createScratchController as ScratchController };
103
+
104
+ // ── Recipe factories. Each returns a Recipe; all options are optional. ──
105
+
106
+ export interface RecipeOptions {
107
+ count?: number;
108
+ duration?: number;
109
+ [key: string]: unknown;
110
+ }
111
+
112
+ /** Options for the 14 recipes that carry a themeable palette. */
113
+ export interface ThemeableRecipeOptions extends RecipeOptions {
114
+ /** An explicit colour ramp; wins over `theme`. */
115
+ colors?: string[];
116
+ /** A `{ light, mid, dark }` shorthand mapped onto the recipe's ramp. */
117
+ theme?: Theme;
118
+ }
119
+
120
+ export function BurnRecipe(opts?: ThemeableRecipeOptions): Recipe;
121
+ export function ShatterRecipe(opts?: RecipeOptions & { gravity?: number }): Recipe;
122
+ export function DissolveRecipe(opts?: ThemeableRecipeOptions): Recipe;
123
+ export function ExplodeRecipe(opts?: ThemeableRecipeOptions & { force?: number }): Recipe;
124
+ export function DragonBreathRecipe(opts?: ThemeableRecipeOptions): Recipe;
125
+ export function IceBreathRecipe(opts?: ThemeableRecipeOptions): Recipe;
126
+ export function ShineWaveRecipe(opts?: ThemeableRecipeOptions & { beamWidth?: number; particleCount?: number }): Recipe;
127
+ export function LightningCrawlRecipe(opts?: ThemeableRecipeOptions & { boltCount?: number }): Recipe;
128
+ export function ShineRecipe(opts?: RecipeOptions & { width?: number }): Recipe;
129
+ export function PeelRecipe(opts?: RecipeOptions): Recipe;
130
+ export function FadeRecipe(opts?: RecipeOptions): Recipe;
131
+ export function ImplosionRecipe(opts?: RecipeOptions): Recipe;
132
+
133
+ export function GlitchRevealRecipe(opts?: RecipeOptions): Recipe;
134
+ export function MatrixDecayRecipe(opts?: ThemeableRecipeOptions): Recipe;
135
+ export function GoldDustRecipe(opts?: ThemeableRecipeOptions): Recipe;
136
+ export function PixelShatterRecipe(opts?: RecipeOptions): Recipe;
137
+ export function LaserScanRecipe(opts?: ThemeableRecipeOptions): Recipe;
138
+ export function ConfettiBlastRecipe(opts?: ThemeableRecipeOptions): Recipe;
139
+ export function LiquidMeltRecipe(opts?: ThemeableRecipeOptions): Recipe;
140
+ export function NeonPulseRecipe(opts?: ThemeableRecipeOptions): Recipe;
141
+ export function CosmicDustRecipe(opts?: ThemeableRecipeOptions): Recipe;
142
+
143
+ /** A factory that builds a recipe from options. */
144
+ export type RecipeFactory = (opts?: RecipeOptions) => Recipe;
145
+
146
+ /** Registry of every built-in recipe, keyed by short name. */
147
+ export const RECIPES: Readonly<Record<string, RecipeFactory>>;
148
+
149
+ /** Names of every built-in recipe (keys of RECIPES). */
150
+ export const RECIPE_NAMES: readonly string[];
package/index.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @zakkster/lite-scratch-fx
3
+ *
4
+ * One-shot scratch-card reveal effects. A controller scans the remaining pixels of
5
+ * a scratch layer, spawns particles from those points, and delegates physics and
6
+ * rendering to a recipe. 21 ready-made reveal recipes; bring your own by matching
7
+ * the recipe interface. Themeable palettes, a zero-GC reveal path, and a
8
+ * deterministic seeded RNG for reproducible reveals.
9
+ *
10
+ * Copyright (c) Zahary Shinikchiev <shinikchiev@yahoo.com>
11
+ * MIT License.
12
+ */
13
+
14
+ export { createScratchController } from './src/ScratchController.js';
15
+ export { default as ScratchController } from './src/ScratchController.js';
16
+
17
+ // Core reveal recipes.
18
+ 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,
31
+ } from './src/ScratchRecipes.js';
32
+
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
+ import {
49
+ BurnRecipe, ShatterRecipe, DissolveRecipe, ExplodeRecipe, DragonBreathRecipe,
50
+ IceBreathRecipe, ShineWaveRecipe, LightningCrawlRecipe, ShineRecipe, PeelRecipe,
51
+ FadeRecipe, ImplosionRecipe,
52
+ } from './src/ScratchRecipes.js';
53
+ import {
54
+ GlitchRevealRecipe, MatrixDecayRecipe, GoldDustRecipe, PixelShatterRecipe,
55
+ LaserScanRecipe, ConfettiBlastRecipe, LiquidMeltRecipe, NeonPulseRecipe,
56
+ CosmicDustRecipe,
57
+ } from './src/ScratchRecipes2.js';
58
+
59
+ export const RECIPES = Object.freeze({
60
+ burn: BurnRecipe,
61
+ shatter: ShatterRecipe,
62
+ dissolve: DissolveRecipe,
63
+ explode: ExplodeRecipe,
64
+ dragonBreath: DragonBreathRecipe,
65
+ iceBreath: IceBreathRecipe,
66
+ shineWave: ShineWaveRecipe,
67
+ lightningCrawl: LightningCrawlRecipe,
68
+ shine: ShineRecipe,
69
+ peel: PeelRecipe,
70
+ fade: FadeRecipe,
71
+ 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
+ });
82
+
83
+ /** Names of every built-in recipe (the keys of RECIPES). */
84
+ export const RECIPE_NAMES = Object.freeze(Object.keys(RECIPES));
package/llms.txt ADDED
@@ -0,0 +1,97 @@
1
+ # @zakkster/lite-scratch-fx
2
+
3
+ > One-shot scratch-card reveal effects on canvas. A controller scans the remaining pixels
4
+ > of a scratch layer, spawns particles from them, and delegates physics + rendering to a
5
+ > recipe. 21 ready-made reveal recipes, themeable palettes. Zero GSAP, zero-GC hot path,
6
+ > deterministic seeded RNG for reproducible reveals.
7
+
8
+ ## Core model
9
+
10
+ createScratchController(sourceCanvas, effectCanvas, { maxParticles = 2000, seed = Date.now(), scanPrecision = 32 })
11
+ -> { reveal(recipe, onDone?), seed(s), destroy() }
12
+
13
+ sourceCanvas = the scratch layer (what the user scratches off).
14
+ effectCanvas = an overlay canvas the particles render onto.
15
+
16
+ reveal(recipe, onDone?):
17
+ 1. sizes effectCanvas to the scratch layer,
18
+ 2. scanPixels(): downsamples the scratch layer to scanPrecision px wide and collects the
19
+ still-covered pixels (alpha > 128) into preallocated Float32Arrays as normalized 0..1
20
+ spawn points. The scan canvas is built ONCE at construction and reused; no per-pixel
21
+ object and no per-reveal canvas are allocated (zero-GC reveal start),
22
+ 3. recipe.init(ctx, maxParticles, w, h),
23
+ 4. for each of min(recipe.count ?? maxParticles, maxParticles) particles: recipe.spawn(...)
24
+ -> {x,y,vx,vy,life}, emitted into the engine with the spawn ring index as the data flag,
25
+ 5. engine.start() (the engine owns its RAF loop),
26
+ 6. each frame the engine calls recipe.tick(...); when it returns true the controller stops
27
+ the engine, calls recipe.destroy(), clears the overlay, and calls onDone.
28
+ A second reveal while one is active is ignored.
29
+
30
+ ## Recipe interface
31
+
32
+ {
33
+ count, // particles to spawn; 0 = pure-canvas reveal
34
+ init(ctx, capacity, w, h), // allocate parallel per-particle arrays
35
+ spawn(idx, rng, w, h, spot), // -> { x, y, vx, vy, life }; spot is {x,y} in 0..1
36
+ tick(dt, elapsedMs, p, ctx, src, w, h), // -> true when complete
37
+ destroy(),
38
+ }
39
+
40
+ spot is a SHARED, reused object: read spot.x/spot.y synchronously in spawn, never retain it
41
+ (the next spawn overwrites it in place). p (the engine view) has raw SoA arrays: x, y, vx, vy,
42
+ life, invLife, data, max -- and is itself reused across frames, so tick allocates nothing.
43
+ dt is in SECONDS. elapsedMs is milliseconds since reveal.
44
+
45
+ ## Theming
46
+
47
+ 14 of the 21 recipes carry a visible palette and take a unified { colors, theme } pair:
48
+ colors: string[] an explicit ramp; highest precedence
49
+ theme: {light, mid, dark} shorthand mapped onto the recipe's ramp
50
+ (omit both) the recipe's own default palette
51
+ Multi-colour recipes index the ramp per particle; single-colour recipes use ramp[0]. A host
52
+ threads one {light, mid, dark} triple through every effect for a consistent skin. The 7
53
+ image-based (shatter, pixelShatter, glitchReveal) and neutral (shine, peel, fade, implosion)
54
+ recipes have no palette and ignore both options. Helper: resolvePalette(colors, theme, fallback)
55
+ in src/Palette.js.
56
+
57
+ ## data[] indexing (load-bearing)
58
+
59
+ Recipes keep per-particle state in their own parallel typed arrays keyed by slot. To map a
60
+ live particle back to its slot inside tick, the controller passes engine._head (the spawn
61
+ ring index) as emit()'s dataFlag; the engine stores it at data[i]; recipes read data[i] back
62
+ as the index into their arrays. Verified: emit writes this.data[i]=dataFlag at i=_head then
63
+ advances _head, so data[i]===idx for that particle, and it's stable across frames. A test
64
+ locks this because it couples recipes to engine internals (_head).
65
+
66
+ ## Recipes (21)
67
+
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.
87
+
88
+ ## Fixed in 1.0.0 (was broken pre-package)
89
+
90
+ - Reveals never completed (double-driven engine loop -> corrupted dt). Fixed: engine self-drives.
91
+ - count:0 recipes spawned 2000 particles (`count || max`); fixed to `count ?? max`.
92
+ - `from 'lite-ticker'` (unscoped) import typo; ticker dependency removed entirely.
93
+ - Duplicate ImplosionRecipe across two files + a dangling barrel reference; de-duplicated.
94
+
95
+ ## Dependencies
96
+
97
+ @zakkster/lite-soa-particle-engine, @zakkster/lite-random, @zakkster/lite-lerp.
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@zakkster/lite-scratch-fx",
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.",
6
+ "type": "module",
7
+ "main": "./index.js",
8
+ "module": "./index.js",
9
+ "types": "./index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./index.d.ts",
13
+ "node": "./index.js",
14
+ "import": "./index.js",
15
+ "default": "./index.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "index.js",
20
+ "index.d.ts",
21
+ "src/ScratchController.js",
22
+ "src/ScratchRecipes.js",
23
+ "src/ScratchRecipes2.js",
24
+ "src/Palette.js",
25
+ "README.md",
26
+ "CHANGELOG.md",
27
+ "llms.txt",
28
+ "LICENSE"
29
+ ],
30
+ "license": "MIT",
31
+ "homepage": "https://github.com/PeshoVurtoleta/lite-scratch-fx#readme",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/PeshoVurtoleta/lite-scratch-fx.git"
35
+ },
36
+ "bugs": {
37
+ "url": "https://github.com/PeshoVurtoleta/lite-scratch-fx/issues"
38
+ },
39
+ "funding": {
40
+ "type": "github",
41
+ "url": "https://github.com/sponsors/PeshoVurtoleta"
42
+ },
43
+ "dependencies": {
44
+ "@zakkster/lite-lerp": "^1.1.0",
45
+ "@zakkster/lite-random": "^1.1.0",
46
+ "@zakkster/lite-soa-particle-engine": "^1.2.0"
47
+ },
48
+ "keywords": [
49
+ "scratch-card",
50
+ "reveal",
51
+ "particles",
52
+ "canvas",
53
+ "vfx",
54
+ "effects",
55
+ "game",
56
+ "zero-gc",
57
+ "deterministic",
58
+ "oklch",
59
+ "zakkster"
60
+ ],
61
+ "devDependencies": {
62
+ "@zakkster/lite-gc-profiler": "^1.15.0",
63
+ "@zakkster/lite-leak": "^1.8.0"
64
+ },
65
+ "scripts": {
66
+ "test": "node --test test/*.test.mjs",
67
+ "torture": "node --expose-gc test/torture.mjs"
68
+ }
69
+ }