@zakkster/lite-scratch-fx 1.0.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.d.ts CHANGED
@@ -1,7 +1,19 @@
1
1
  /**
2
- * @zakkster/lite-scratch-fx type declarations.
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;
@@ -28,7 +40,7 @@ export interface SpawnState {
28
40
 
29
41
  /**
30
42
  * 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
43
+ * NOTE: this object is shared and reused across every `spawn` call in a reveal --
32
44
  * read `x`/`y` synchronously and never retain the reference.
33
45
  */
34
46
  export interface SpawnSpot {
@@ -73,20 +85,50 @@ 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;
105
+ /**
106
+ * Device-pixel-ratio for sharp rendering on high-DPI screens. Default 1. Pass
107
+ * window.devicePixelRatio. Recipes still author in CSS pixels.
108
+ */
109
+ dpr?: number;
82
110
  }
83
111
 
84
112
  export interface ScratchController {
85
- /** Run a reveal recipe. Ignored if one is already active or the controller is destroyed. */
113
+ /**
114
+ * Run a reveal recipe. Ignored if one is already active, the controller is destroyed,
115
+ * or a shared engine is busy with another controller's reveal.
116
+ */
86
117
  reveal(recipe: Recipe, onDone?: () => void): void;
118
+ /**
119
+ * Advance one frame in host-driven mode; `dt` is in SECONDS. A no-op unless the
120
+ * controller was created with `{ driven: true }` and a reveal is active.
121
+ */
122
+ tick(dt: number): void;
87
123
  /** Re-seed the RNG. */
88
124
  seed(s: number): void;
89
- /** Stop, clean up the active recipe, and release the engine. */
125
+ /**
126
+ * Abort an in-flight reveal: stop the effect and restore the host canvas's inline
127
+ * style to what it was at reveal start. Does NOT fire `onDone`; the controller is
128
+ * revealable again afterwards. A no-op when idle or destroyed.
129
+ */
130
+ cancel(): void;
131
+ /** Stop, clean up the active recipe, and release the engine (owned engines only). */
90
132
  destroy(): void;
91
133
  }
92
134
 
@@ -101,7 +143,73 @@ export function createScratchController(
101
143
 
102
144
  export { createScratchController as ScratchController };
103
145
 
104
- // ── Recipe factories. Each returns a Recipe; all options are optional. ──
146
+ // -- Concurrent reveals over one shared pool (createScratchStage) --
147
+
148
+ export interface ScratchStageOptions {
149
+ /** Total shared pool capacity, split across controllers. Default 2000. */
150
+ maxParticles?: number;
151
+ /** Base seed; each controller derives its own (base + index) unless it passes one. */
152
+ seed?: number;
153
+ /**
154
+ * Device-pixel-ratio for sharp rendering on high-DPI screens. Default 1. Pass
155
+ * window.devicePixelRatio. Recipes still author in CSS pixels.
156
+ */
157
+ dpr?: number;
158
+ }
159
+
160
+ export interface StageControllerOptions {
161
+ /** Slots reserved for this controller's particles out of the shared pool. Default 300. */
162
+ capacity?: number;
163
+ /** RNG seed for this controller. Default: the stage's base seed + controller index. */
164
+ seed?: number;
165
+ /** Horizontal resolution of the spawn-point pixel scan. Default 32. */
166
+ scanPrecision?: number;
167
+ /**
168
+ * Device-pixel-ratio for sharp rendering on high-DPI screens. Default 1 (the stage
169
+ * dpr). Pass window.devicePixelRatio. Recipes still author in CSS pixels.
170
+ */
171
+ dpr?: number;
172
+ }
173
+
174
+ /** A stage-managed controller. The stage drives it, so it has no `tick` of its own. */
175
+ export interface StageController {
176
+ /** Reveal a recipe. Ignored if this controller is already revealing or destroyed. */
177
+ reveal(recipe: Recipe, onDone?: () => void): void;
178
+ /** Re-seed this controller's RNG. */
179
+ seed(s: number): void;
180
+ /**
181
+ * Abort this controller's in-flight reveal: stop the effect and restore the host
182
+ * canvas's inline style. Does NOT fire `onDone`; the controller is revealable again.
183
+ * A no-op when idle or destroyed. Does not touch other controllers' reveals.
184
+ */
185
+ cancel(): void;
186
+ /** Remove this controller from the stage (its sub-range is not reclaimed). */
187
+ destroy(): void;
188
+ }
189
+
190
+ /**
191
+ * A stage owning one shared particle pool. Any number of controllers can reveal at once,
192
+ * all rendering from the one pool, driven by a single `tick(dt)`.
193
+ */
194
+ export interface ScratchStage {
195
+ /** Reserve a sub-range and return a controller bound to this stage. */
196
+ createController(
197
+ sourceCanvas: HTMLCanvasElement,
198
+ effectCanvas: HTMLCanvasElement,
199
+ options?: StageControllerOptions,
200
+ ): StageController;
201
+ /** Advance every active reveal one frame; `dt` is in SECONDS. */
202
+ tick(dt: number): void;
203
+ /** Stop everything and release the shared engine. */
204
+ destroy(): void;
205
+ /** Slots still unreserved in the shared pool. */
206
+ readonly remainingCapacity: number;
207
+ }
208
+
209
+ /** Create a stage for concurrent reveals over one shared particle pool. */
210
+ export function createScratchStage(options?: ScratchStageOptions): ScratchStage;
211
+
212
+ // -- Recipe factories. Each returns a Recipe; all options are optional. --
105
213
 
106
214
  export interface RecipeOptions {
107
215
  count?: number;
@@ -143,8 +251,39 @@ export function CosmicDustRecipe(opts?: ThemeableRecipeOptions): Recipe;
143
251
  /** A factory that builds a recipe from options. */
144
252
  export type RecipeFactory = (opts?: RecipeOptions) => Recipe;
145
253
 
146
- /** Registry of every built-in recipe, keyed by short name. */
147
- export const RECIPES: Readonly<Record<string, RecipeFactory>>;
254
+ /** Resolve a colour ramp from `colors` / `theme` / a fallback (see `src/Palette.js`). */
255
+ export function resolvePalette(
256
+ colors: string[] | undefined,
257
+ theme: Theme | undefined,
258
+ fallback: string[],
259
+ ): string[];
148
260
 
149
- /** Names of every built-in recipe (keys of RECIPES). */
261
+ /**
262
+ * Extensible registry of recipes, keyed by short name. Built-ins are present at load;
263
+ * `registerRecipe` adds more. A null-prototype object.
264
+ */
265
+ export const RECIPES: Record<string, RecipeFactory>;
266
+
267
+ /** Names of every built-in recipe (keys of RECIPES at load time). */
150
268
  export const RECIPE_NAMES: readonly string[];
269
+
270
+ export type RecipeCategory = 'particle' | 'image' | 'beam' | 'css' | (string & {});
271
+
272
+ /** Display + capability metadata for a recipe, for building pickers. */
273
+ export interface RecipeMeta {
274
+ id: string;
275
+ name: string;
276
+ category: RecipeCategory;
277
+ themeable: boolean;
278
+ needsUntaintedCanvas: boolean;
279
+ }
280
+
281
+ /** Live metadata array for every recipe; `registerRecipe` keeps it in sync. */
282
+ export const RECIPE_META: RecipeMeta[];
283
+
284
+ /** Register a custom recipe (or override a built-in); reflected in RECIPES + RECIPE_META. */
285
+ export function registerRecipe(
286
+ id: string,
287
+ factory: RecipeFactory,
288
+ meta?: Partial<Omit<RecipeMeta, 'id'>>,
289
+ ): 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.3.0';
17
+
14
18
  export { createScratchController } from './src/ScratchController.js';
15
19
  export { default as ScratchController } from './src/ScratchController.js';
20
+ export { createScratchStage } from './src/ScratchStage.js';
21
+ export { resolvePalette } from './src/Palette.js';
16
22
 
17
- // Core reveal recipes.
23
+ // All 21 recipes now live in one file, grouped by family.
18
24
  export {
19
- BurnRecipe,
20
- ShatterRecipe,
21
- DissolveRecipe,
22
- ExplodeRecipe,
23
- DragonBreathRecipe,
24
- IceBreathRecipe,
25
- ShineWaveRecipe,
26
- LightningCrawlRecipe,
27
- ShineRecipe,
28
- PeelRecipe,
29
- FadeRecipe,
30
- ImplosionRecipe,
25
+ BurnRecipe, DissolveRecipe, ExplodeRecipe, DragonBreathRecipe, IceBreathRecipe,
26
+ GoldDustRecipe, ConfettiBlastRecipe, CosmicDustRecipe, MatrixDecayRecipe, LiquidMeltRecipe,
27
+ ShatterRecipe, PixelShatterRecipe, GlitchRevealRecipe,
28
+ ShineRecipe, ShineWaveRecipe, LaserScanRecipe, LightningCrawlRecipe, NeonPulseRecipe,
29
+ PeelRecipe, FadeRecipe, ImplosionRecipe,
31
30
  } from './src/ScratchRecipes.js';
32
31
 
33
- // Extended / stylized reveal recipes.
34
- export {
35
- GlitchRevealRecipe,
36
- MatrixDecayRecipe,
37
- GoldDustRecipe,
38
- PixelShatterRecipe,
39
- LaserScanRecipe,
40
- ConfettiBlastRecipe,
41
- LiquidMeltRecipe,
42
- NeonPulseRecipe,
43
- CosmicDustRecipe,
44
- } from './src/ScratchRecipes2.js';
45
-
46
- // A combined registry keyed by short name, for data-driven pickers (demo dropdowns,
47
- // random selection, config files). Keys are the recipe name without the `Recipe` suffix.
48
32
  import {
49
- BurnRecipe, ShatterRecipe, DissolveRecipe, ExplodeRecipe, DragonBreathRecipe,
50
- IceBreathRecipe, ShineWaveRecipe, LightningCrawlRecipe, ShineRecipe, PeelRecipe,
51
- FadeRecipe, ImplosionRecipe,
33
+ BurnRecipe, DissolveRecipe, ExplodeRecipe, DragonBreathRecipe, IceBreathRecipe,
34
+ GoldDustRecipe, ConfettiBlastRecipe, CosmicDustRecipe, MatrixDecayRecipe, LiquidMeltRecipe,
35
+ ShatterRecipe, PixelShatterRecipe, GlitchRevealRecipe,
36
+ ShineRecipe, ShineWaveRecipe, LaserScanRecipe, LightningCrawlRecipe, NeonPulseRecipe,
37
+ PeelRecipe, FadeRecipe, ImplosionRecipe,
52
38
  } from './src/ScratchRecipes.js';
53
- import {
54
- GlitchRevealRecipe, MatrixDecayRecipe, GoldDustRecipe, PixelShatterRecipe,
55
- LaserScanRecipe, ConfettiBlastRecipe, LiquidMeltRecipe, NeonPulseRecipe,
56
- CosmicDustRecipe,
57
- } from './src/ScratchRecipes2.js';
58
39
 
59
- export const RECIPES = Object.freeze({
40
+ // -- Recipe registry (extensible: registerRecipe adds to it) --
41
+ // Keyed by short name, for data-driven pickers (demo dropdowns, random selection,
42
+ // config files). A null-prototype object so keys never collide with Object.prototype.
43
+ export const RECIPES = Object.assign(Object.create(null), {
60
44
  burn: BurnRecipe,
61
- shatter: ShatterRecipe,
62
45
  dissolve: DissolveRecipe,
63
46
  explode: ExplodeRecipe,
64
47
  dragonBreath: DragonBreathRecipe,
65
48
  iceBreath: IceBreathRecipe,
49
+ goldDust: GoldDustRecipe,
50
+ confettiBlast: ConfettiBlastRecipe,
51
+ cosmicDust: CosmicDustRecipe,
52
+ matrixDecay: MatrixDecayRecipe,
53
+ liquidMelt: LiquidMeltRecipe,
54
+ shatter: ShatterRecipe,
55
+ pixelShatter: PixelShatterRecipe,
56
+ glitchReveal: GlitchRevealRecipe,
57
+ shine: ShineRecipe,
66
58
  shineWave: ShineWaveRecipe,
59
+ laserScan: LaserScanRecipe,
67
60
  lightningCrawl: LightningCrawlRecipe,
68
- shine: ShineRecipe,
61
+ neonPulse: NeonPulseRecipe,
69
62
  peel: PeelRecipe,
70
63
  fade: FadeRecipe,
71
64
  implosion: ImplosionRecipe,
72
- glitchReveal: GlitchRevealRecipe,
73
- matrixDecay: MatrixDecayRecipe,
74
- goldDust: GoldDustRecipe,
75
- pixelShatter: PixelShatterRecipe,
76
- laserScan: LaserScanRecipe,
77
- confettiBlast: ConfettiBlastRecipe,
78
- liquidMelt: LiquidMeltRecipe,
79
- neonPulse: NeonPulseRecipe,
80
- cosmicDust: CosmicDustRecipe,
81
65
  });
82
66
 
83
- /** Names of every built-in recipe (the keys of RECIPES). */
67
+ /**
68
+ * Display + capability metadata for every built-in recipe, so a host can build a
69
+ * picker without hardcoding the list. Mirrors lite-ambient's THEME_META. A live
70
+ * array: registerRecipe() updates it, so existing pickers keep working.
71
+ *
72
+ * category 'particle' | 'image' | 'beam' | 'css' | (custom)
73
+ * themeable accepts { colors, theme }
74
+ * needsUntaintedCanvas snapshots the scratch layer via toDataURL() (same-origin only)
75
+ */
76
+ export const RECIPE_META = [
77
+ { id: 'burn', name: 'Burn', category: 'particle', themeable: true, needsUntaintedCanvas: false },
78
+ { id: 'dissolve', name: 'Dissolve', category: 'particle', themeable: true, needsUntaintedCanvas: false },
79
+ { id: 'explode', name: 'Explode', category: 'particle', themeable: true, needsUntaintedCanvas: false },
80
+ { id: 'dragonBreath', name: 'Dragon Breath', category: 'particle', themeable: true, needsUntaintedCanvas: false },
81
+ { id: 'iceBreath', name: 'Ice Breath', category: 'particle', themeable: true, needsUntaintedCanvas: false },
82
+ { id: 'goldDust', name: 'Gold Dust', category: 'particle', themeable: true, needsUntaintedCanvas: false },
83
+ { id: 'confettiBlast', name: 'Confetti Blast', category: 'particle', themeable: true, needsUntaintedCanvas: false },
84
+ { id: 'cosmicDust', name: 'Cosmic Dust', category: 'particle', themeable: true, needsUntaintedCanvas: false },
85
+ { id: 'matrixDecay', name: 'Matrix Decay', category: 'particle', themeable: true, needsUntaintedCanvas: false },
86
+ { id: 'liquidMelt', name: 'Liquid Melt', category: 'particle', themeable: true, needsUntaintedCanvas: false },
87
+ { id: 'shatter', name: 'Shatter', category: 'image', themeable: false, needsUntaintedCanvas: true },
88
+ { id: 'pixelShatter', name: 'Pixel Shatter', category: 'image', themeable: false, needsUntaintedCanvas: true },
89
+ { id: 'glitchReveal', name: 'Glitch Reveal', category: 'image', themeable: false, needsUntaintedCanvas: true },
90
+ { id: 'shine', name: 'Shine', category: 'beam', themeable: false, needsUntaintedCanvas: false },
91
+ { id: 'shineWave', name: 'Shine Wave', category: 'beam', themeable: true, needsUntaintedCanvas: false },
92
+ { id: 'laserScan', name: 'Laser Scan', category: 'beam', themeable: true, needsUntaintedCanvas: false },
93
+ { id: 'lightningCrawl', name: 'Lightning Crawl', category: 'beam', themeable: true, needsUntaintedCanvas: false },
94
+ { id: 'neonPulse', name: 'Neon Pulse', category: 'beam', themeable: true, needsUntaintedCanvas: false },
95
+ { id: 'peel', name: 'Peel', category: 'css', themeable: false, needsUntaintedCanvas: false },
96
+ { id: 'fade', name: 'Fade', category: 'css', themeable: false, needsUntaintedCanvas: false },
97
+ { id: 'implosion', name: 'Implosion', category: 'css', themeable: false, needsUntaintedCanvas: false },
98
+ ];
99
+
100
+ /** Names of every built-in recipe (the keys of RECIPES at load time). */
84
101
  export const RECIPE_NAMES = Object.freeze(Object.keys(RECIPES));
102
+
103
+ /**
104
+ * Register a custom recipe, or override a built-in. Instantly usable via
105
+ * RECIPES[id] and reflected in RECIPE_META so existing pickers keep working.
106
+ * Mirrors lite-ambient's registerTheme().
107
+ *
108
+ * @param {string} id short name (the RECIPES key)
109
+ * @param {Function} factory a recipe factory: (opts) => Recipe
110
+ * @param {{ name?: string, category?: string, themeable?: boolean, needsUntaintedCanvas?: boolean }} [meta]
111
+ * Omitted fields fall back to the existing entry (when overriding), then to a
112
+ * de-camelCased name, category 'custom', and false flags.
113
+ * @returns {Function} the registered factory
114
+ */
115
+ export function registerRecipe(id, factory, meta) {
116
+ if (typeof id !== 'string' || id.length === 0) {
117
+ throw new TypeError('registerRecipe: id must be a non-empty string');
118
+ }
119
+ if (typeof factory !== 'function') {
120
+ throw new TypeError('registerRecipe: factory must be a function');
121
+ }
122
+ RECIPES[id] = factory;
123
+
124
+ const idx = RECIPE_META.findIndex((m) => m.id === id);
125
+ const prev = idx >= 0 ? RECIPE_META[idx] : null;
126
+ const entry = {
127
+ id,
128
+ name: (meta && meta.name) || (prev && prev.name)
129
+ || id.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^[a-z]/, (c) => c.toUpperCase()),
130
+ category: (meta && meta.category) || (prev && prev.category) || 'custom',
131
+ themeable: meta && 'themeable' in meta ? !!meta.themeable : (prev ? prev.themeable : false),
132
+ needsUntaintedCanvas: meta && 'needsUntaintedCanvas' in meta
133
+ ? !!meta.needsUntaintedCanvas : (prev ? prev.needsUntaintedCanvas : false),
134
+ };
135
+ if (idx >= 0) RECIPE_META[idx] = entry; else RECIPE_META.push(entry);
136
+ return factory;
137
+ }
package/llms.txt CHANGED
@@ -7,8 +7,9 @@
7
7
 
8
8
  ## Core model
9
9
 
10
- createScratchController(sourceCanvas, effectCanvas, { maxParticles = 2000, seed = Date.now(), scanPrecision = 32 })
11
- -> { reveal(recipe, onDone?), seed(s), destroy() }
10
+ createScratchController(sourceCanvas, effectCanvas,
11
+ { maxParticles = 2000, seed = Date.now(), scanPrecision = 32, driven = false, engine, dpr = 1 })
12
+ -> { reveal(recipe, onDone?), tick(dt), cancel(), seed(s), destroy() }
12
13
 
13
14
  sourceCanvas = the scratch layer (what the user scratches off).
14
15
  effectCanvas = an overlay canvas the particles render onto.
@@ -65,25 +66,58 @@ locks this because it couples recipes to engine internals (_head).
65
66
 
66
67
  ## Recipes (21)
67
68
 
68
- Core (ScratchRecipes.js): BurnRecipe, ShatterRecipe, DissolveRecipe, ExplodeRecipe,
69
- DragonBreathRecipe, IceBreathRecipe, ShineWaveRecipe, LightningCrawlRecipe, ShineRecipe,
70
- PeelRecipe, FadeRecipe, ImplosionRecipe.
71
- Extended (ScratchRecipes2.js): GlitchRevealRecipe, MatrixDecayRecipe, GoldDustRecipe,
72
- PixelShatterRecipe, LaserScanRecipe, ConfettiBlastRecipe, LiquidMeltRecipe, NeonPulseRecipe,
73
- CosmicDustRecipe.
74
-
75
- RECIPES is a frozen registry keyed by short name (burn, shatter, …); RECIPE_NAMES lists them.
76
- shatter/glitchReveal/pixelShatter snapshot the scratch layer via sourceCanvas.toDataURL()+Image,
77
- so that layer must be same-origin (untainted).
78
-
79
- ## Engine driving (why there is no shared ticker)
80
-
81
- @zakkster/lite-soa-particle-engine owns its own requestAnimationFrame loop: start() schedules
82
- it, stop() cancels it, and _loop computes dt from performance.now() deltas internally. The
83
- controller must NOT also drive _loop from an external ticker -- doing so double-drives the loop
84
- and corrupts dt, which breaks life decay and completion (a reveal runs forever). An earlier
85
- draft accepted a sharedTicker and did exactly that; it was removed. ambient-fx also owns its own
86
- loop and does not accept a shared ticker, so "one RAF for atmosphere + reveal" was never real.
69
+ All 21 live in src/ScratchRecipes.js, grouped by family with section banners:
70
+ particle (spawn from covered pixels, own physics): burn, dissolve, explode, dragonBreath,
71
+ iceBreath, goldDust, confettiBlast, cosmicDust, matrixDecay, liquidMelt
72
+ image (snapshot layer via toDataURL()+Image; same-origin only): shatter, pixelShatter, glitchReveal
73
+ beam/canvas (draw light/lines on the overlay): shine, shineWave, laserScan, lightningCrawl, neonPulse
74
+ css (drive the layer's own transform/opacity): peel, fade, implosion
75
+
76
+ ## Registry, metadata, extension
77
+
78
+ VERSION: the package version string (kept in sync with package.json and CHANGELOG).
79
+ RECIPES: an extensible null-prototype registry keyed by short name (burn, shatter, ...).
80
+ RECIPE_NAMES: the built-in keys at load time.
81
+ RECIPE_META: a live array of { id, name, category, themeable, needsUntaintedCanvas } for every
82
+ recipe -- the source for building pickers without hardcoding. category is
83
+ 'particle'|'image'|'beam'|'css'. needsUntaintedCanvas is true only for shatter/pixelShatter/
84
+ glitchReveal. themeable is true for the 14 recipes that take { colors, theme }.
85
+ registerRecipe(id, factory, meta?): add a recipe or override a built-in; lands in RECIPES and
86
+ RECIPE_META immediately so existing pickers keep working. Mirrors lite-ambient's registerTheme.
87
+ Omitted meta fields fall back to the prior entry, then a de-camelCased name, category 'custom',
88
+ false flags. Throws TypeError on a bad id or non-function factory.
89
+
90
+ ## Engine driving (three modes, dt always correct)
91
+
92
+ The old sharedTicker bug was DOUBLE-driving: the engine self-drove its RAF loop AND an external
93
+ ticker called _loop, corrupting dt so reveals ran forever. That is removed. Three single-drive
94
+ modes exist now:
95
+ DEFAULT: the controller owns a SoaParticleEngine that self-drives via start()/stop().
96
+ driven: true -- the controller never calls start(); the host calls controller.tick(dt) each
97
+ frame with dt in SECONDS (engine.tick(dt) is the engine's primary API; start() is a thin RAF
98
+ wrapper over it). One page clock drives N controllers. tick() is a no-op unless driven AND a
99
+ reveal is active.
100
+ { engine } -- N controllers share one caller-supplied engine (one lane pool for the page).
101
+ The engine has a single onTick slot and one particle pool, so ONE reveal per shared engine at
102
+ a time: a reveal on a busy shared engine is ignored (guarded by a module WeakSet). destroy()
103
+ does NOT destroy a shared engine (the caller owns it); it only unbinds and frees the slot.
104
+ Compose with driven:true and drive the page from one clock.
105
+
106
+ ## Concurrent reveals over one pool: createScratchStage
107
+
108
+ createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr = 1 })
109
+ -> { createController(src, fx, { capacity = 300, seed?, scanPrecision = 32, dpr? }) -> stageController,
110
+ tick(dt), destroy(), get remainingCapacity }
111
+ stageController -> { reveal(recipe, onDone?), cancel(), seed(s), destroy() } // NO tick -- the stage drives.
112
+
113
+ When { engine } (one reveal at a time) is not enough and you need MANY boxes revealing at once
114
+ over one pool: a stage owns one raw engine and gives each controller a fixed sub-range [start,
115
+ start+capacity) of the lanes. reserving past maxParticles throws. Each controller emits directly
116
+ into its slots (data[start+i]=i), renders through subarray views of its range (built once per
117
+ controller, reused every frame -> zero per-frame alloc), draws to its own ctx, keeps its own RNG.
118
+ stage.tick(dt) advances the single engine once; the one onTick fans out to every active reveal.
119
+ Feasible with no engine change because raw tick(dt) is pure dispatch (no physics/culling) and the
120
+ lanes are public typed arrays recipes already write. See decisions/0001-concurrent-shared-reveals.md.
87
121
 
88
122
  ## Fixed in 1.0.0 (was broken pre-package)
89
123
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@zakkster/lite-scratch-fx",
3
3
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
4
- "version": "1.0.0",
5
- "description": "One-shot scratch-card reveal effects. A controller scans the remaining pixels of a scratch layer, spawns particles from them, and delegates physics and rendering to a recipe. 21 ready-made reveal recipes (burn, shatter, dissolve, glitch, gold dust, cosmic dust, and more), themeable palettes, zero GSAP, zero-GC hot path, and deterministic seeded RNG for reproducible reveals.",
4
+ "version": "1.3.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,9 @@
20
20
  "index.d.ts",
21
21
  "src/ScratchController.js",
22
22
  "src/ScratchRecipes.js",
23
- "src/ScratchRecipes2.js",
23
+ "src/ScratchStage.js",
24
+ "src/HostStyle.js",
25
+ "src/PixelScan.js",
24
26
  "src/Palette.js",
25
27
  "README.md",
26
28
  "CHANGELOG.md",
@@ -55,7 +57,9 @@
55
57
  "game",
56
58
  "zero-gc",
57
59
  "deterministic",
58
- "oklch",
60
+ "theme",
61
+ "reveal-animation",
62
+ "scratch-off",
59
63
  "zakkster"
60
64
  ],
61
65
  "devDependencies": {
@@ -0,0 +1,42 @@
1
+ /**
2
+ * HostStyle -- snapshot and restore the host layer's inline style across a reveal.
3
+ *
4
+ * lite-scratch-fx BORROWS the host scratch layer's inline style for the duration of a
5
+ * reveal (recipes write these 5 props during tick) and returns it to exactly what it
6
+ * found on every terminal transition -- completion, cancel, and destroy. The host owns
7
+ * the final revealed state via onDone; the library leaves no fingerprints.
8
+ *
9
+ * The snapshot is one small plain object per reveal. This is a COLD path -- capture at
10
+ * reveal start, restore at reveal end. Never call these per frame.
11
+ *
12
+ * See decisions/0002-host-style-and-cancel.md for the contract.
13
+ *
14
+ * Copyright (c) Zahary Shinikchiev <shinikchiev@yahoo.com>
15
+ * MIT License.
16
+ */
17
+
18
+ // The 5 inline style props the built-in recipes write during tick (particle/beam/css/
19
+ // image families combined). Capturing every element's CURRENT inline value (usually '')
20
+ // means restore returns the host to pristine regardless of what a custom recipe touched
21
+ // among these props.
22
+ export function captureHostStyle(el) {
23
+ if (!el || !el.style) return null; // fail closed: nothing to borrow, nothing to restore
24
+ const s = el.style;
25
+ return {
26
+ opacity: s.opacity,
27
+ transform: s.transform,
28
+ filter: s.filter,
29
+ clipPath: s.clipPath,
30
+ transformOrigin: s.transformOrigin,
31
+ };
32
+ }
33
+
34
+ export function restoreHostStyle(el, snap) {
35
+ if (!el || !el.style || !snap) return; // fail closed
36
+ const s = el.style;
37
+ s.opacity = snap.opacity;
38
+ s.transform = snap.transform;
39
+ s.filter = snap.filter;
40
+ s.clipPath = snap.clipPath;
41
+ s.transformOrigin = snap.transformOrigin;
42
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Pixel scanner -- finds the still-covered pixels of a scratch layer as normalized
3
+ * 0..1 spawn points. Allocated ONCE per scanner and reused across every scan: the
4
+ * offscreen canvas, its context, and the spot arrays are built lazily and grown only
5
+ * when the source canvas is resized, so a scan allocates nothing per pixel (only
6
+ * getImageData's single buffer, which the 2D canvas API mandates).
7
+ *
8
+ * Shared by ScratchController and ScratchStage.
9
+ *
10
+ * Copyright (c) Zahary Shinikchiev <shinikchiev@yahoo.com>
11
+ * MIT License.
12
+ */
13
+
14
+ /**
15
+ * @param {HTMLCanvasElement} sourceCanvas The scratch layer to scan.
16
+ * @returns {{ scan(precision:number): { spotX: Float32Array, spotY: Float32Array, count: number },
17
+ * destroy(): void }}
18
+ */
19
+ export function createPixelScanner(sourceCanvas) {
20
+ let scanCanvas = null;
21
+ let scanCtx = null;
22
+ let scanW = 0, scanH = 0;
23
+ let spotX = new Float32Array(0);
24
+ let spotY = new Float32Array(0);
25
+ const result = { spotX, spotY, count: 0 }; // reused -- no per-scan allocation
26
+
27
+ function ensure(precision) {
28
+ const srcW = sourceCanvas.width || sourceCanvas.offsetWidth || 1;
29
+ const srcH = sourceCanvas.height || sourceCanvas.offsetHeight || 1;
30
+ const h = Math.max(1, Math.floor(srcH * (precision / srcW)));
31
+ if (scanCanvas && scanW === precision && scanH === h) return;
32
+ scanW = precision;
33
+ scanH = h;
34
+ if (!scanCanvas) {
35
+ scanCanvas = document.createElement('canvas');
36
+ scanCtx = scanCanvas.getContext('2d', { willReadFrequently: true });
37
+ }
38
+ scanCanvas.width = scanW;
39
+ scanCanvas.height = scanH;
40
+ const cap = scanW * scanH;
41
+ if (spotX.length < cap) {
42
+ spotX = new Float32Array(cap);
43
+ spotY = new Float32Array(cap);
44
+ }
45
+ }
46
+
47
+ return {
48
+ scan(precision) {
49
+ ensure(precision);
50
+ scanCtx.clearRect(0, 0, scanW, scanH);
51
+ scanCtx.drawImage(sourceCanvas, 0, 0, scanW, scanH);
52
+ const data = scanCtx.getImageData(0, 0, scanW, scanH).data;
53
+ let n = 0;
54
+ for (let i = 3; i < data.length; i += 4) {
55
+ if (data[i] > 128) {
56
+ const p = (i - 3) / 4;
57
+ spotX[n] = (p % scanW) / scanW;
58
+ spotY[n] = ((p / scanW) | 0) / scanH;
59
+ n++;
60
+ }
61
+ }
62
+ result.spotX = spotX;
63
+ result.spotY = spotY;
64
+ result.count = n;
65
+ return result;
66
+ },
67
+ destroy() {
68
+ scanCanvas = null;
69
+ scanCtx = null;
70
+ },
71
+ };
72
+ }
73
+
74
+ export default createPixelScanner;