@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 +52 -0
- package/README.md +107 -5
- package/index.d.ts +118 -6
- package/index.js +102 -49
- package/llms.txt +55 -21
- package/package.json +7 -4
- package/src/PixelScan.js +74 -0
- package/src/ScratchController.js +83 -74
- package/src/ScratchRecipes.js +679 -130
- package/src/ScratchStage.js +192 -0
- package/src/ScratchRecipes2.js +0 -642
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
|
-
|
|
178
|
-
loop and computes its own delta time
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
147
|
-
export
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
23
|
+
// All 21 recipes now live in one file, grouped by family.
|
|
18
24
|
export {
|
|
19
|
-
BurnRecipe,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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,
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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,
|
|
11
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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.
|
|
5
|
-
"description": "One-shot scratch-card reveal effects. A controller scans the
|
|
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/
|
|
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
|
-
"
|
|
59
|
+
"theme",
|
|
60
|
+
"reveal-animation",
|
|
61
|
+
"scratch-off",
|
|
59
62
|
"zakkster"
|
|
60
63
|
],
|
|
61
64
|
"devDependencies": {
|