@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 +63 -0
- package/LICENSE +21 -0
- package/README.md +189 -0
- package/index.d.ts +150 -0
- package/index.js +84 -0
- package/llms.txt +97 -0
- package/package.json +69 -0
- package/src/Palette.js +37 -0
- package/src/ScratchController.js +211 -0
- package/src/ScratchRecipes.js +719 -0
- package/src/ScratchRecipes2.js +642 -0
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
|
+
[](https://www.npmjs.com/package/@zakkster/lite-scratch-fx)
|
|
4
|
+
[](https://github.com/sponsors/PeshoVurtoleta)
|
|
5
|
+
[](https://bundlephobia.com/result?p=@zakkster/lite-scratch-fx)
|
|
6
|
+
[](https://www.npmjs.com/package/@zakkster/lite-scratch-fx)
|
|
7
|
+
[](https://www.npmjs.com/package/@zakkster/lite-scratch-fx)
|
|
8
|
+

|
|
9
|
+
[](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
|
+
}
|