@zakkster/lite-scratch-fx 1.3.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,81 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.5.0] - 2026-08-23
4
+
5
+ Cover-fade fidelity, live image reveals, and additive recipe/stage knobs. Backward
6
+ compatible: every new recipe option and the new stage method is additive, and calling
7
+ with no options reproduces prior output byte-identically.
8
+
9
+ ### Added
10
+ - **`stage.remainingCapacityFor(capacity)` (B-19).** Returns the exact particle-slots
11
+ allocatable for a request of that capacity: `(maxParticles - nextStart) + n * capacity`,
12
+ where `n` is the count of reclaimed ranges of that exact capacity. A size-`C`
13
+ `createController` succeeds iff `remainingCapacityFor(C) >= C`. Zero-allocation; throws
14
+ `TypeError` on a non-integer or negative argument. `remainingCapacity` is unchanged (total
15
+ free slots); its JSDoc now states reclaimed ranges are reusable only on an exact-capacity
16
+ match, so `remainingCapacityFor` is the number to consult for an allocation decision.
17
+ - **Additive recipe factory options.** `dissolve` gains `fadeSpeed` (cover-fade window as a
18
+ fraction of `duration`, default `0.3`); `dragonBreath` and `iceBreath` gain `spread`,
19
+ `speedMin`, `speedMax` (spawn cone half-angle in radians and speed range; defaults `0.3`/
20
+ `12`/`25` and `0.225`/`15`/`28`); `peel` and `shineWave` gain `ease` (a `t -> t` easing
21
+ function, default `easeIn`/`easeInOut`). A non-function `ease` falls back to the default.
22
+ Every default equals the previous literal, so existing callers are unaffected, and each
23
+ numeric knob is a single `rng` draw in the same position (determinism preserved).
24
+
25
+ ### Changed
26
+ - **Image recipes draw the scratch layer live (R5).** `shatter`, `pixelShatter`, and
27
+ `glitchReveal` now draw the source canvas each frame with `drawImage(src, ...)` instead of
28
+ snapshotting it once via `toDataURL()` + `Image`. This removes the synchronous PNG encode,
29
+ the async decode, and the blank first frames: they render on frame 1 and work on any
30
+ canvas, including cross-origin/tainted layers. `RECIPE_META[*].needsUntaintedCanvas` is now
31
+ `false` for every built-in; the field is retained so a third-party recipe that reads the
32
+ canvas back (`getImageData`/`toDataURL`) can still advertise a same-origin requirement.
33
+ - **`iceBreath` and `dragonBreath` cover fades match the consuming renderer.** `iceBreath`
34
+ fades with `power2.in` (cubic, `1 - easeIn`) over 0.5 s after a 0.1 s delay; `dragonBreath`
35
+ fades with `power1.in` (quadratic) over 0.4 s after a 0.1 s delay. Both were previously
36
+ linear with no delay.
37
+
38
+ ### Fixed
39
+ - **Burn cover fade restored to cubic (B-18).** 1.4.0 shipped a quadratic fade (`1 - t*t`).
40
+ The consuming renderer tweens the burn cover with `power2.in`, which is cubic in GSAP's
41
+ naming (`Power2 = Cubic`; verified `parseEase('power2.in')(0.5) === 0.125`), so the correct
42
+ curve is `1 - easeIn(progress)` (`easeIn = t*t*t`). Reverted to cubic: at `progress` 0.5 the
43
+ cover opacity is `0.875`, not `0.75`.
44
+
45
+ ### Removed
46
+ - The `toDataURL()` snapshot and `Image` decode from `shatter`, `pixelShatter`, and
47
+ `glitchReveal` (replaced by live `drawImage(src, ...)`).
48
+
49
+ ## [1.4.0] - 2026-08-23
50
+
51
+ Determinism, recipe tuning, and stage lane reclaim. One backward-compatible interface
52
+ addition -- a 5th `rng` argument to `recipe.init`; recipes that ignore it are unaffected.
53
+
54
+ ### Added
55
+ - **Seeded RNG passed to `recipe.init` (B-14).** `recipe.init(ctx, capacity, w, h, rng)` now
56
+ receives the controller's or stage's seeded `Random` as an additive 5th argument.
57
+ `dragonBreath`, `shineWave`, and `lightningCrawl` drew from `Math.random()` in `init`/`tick`,
58
+ breaking reproducibility; they now draw from `rng`, so two reveals at the same seed produce
59
+ byte-identical lanes. Recipes with a shorter `init` signature ignore the argument.
60
+ - **Stage sub-range reclaim (B-8).** `createScratchStage` keeps a free list of released lane
61
+ ranges; `controller.destroy()` returns its `[start, capacity)` range and `createController`
62
+ reuses an exact-capacity match before extending the pool, so a grid rebuilt after destroying
63
+ all controllers no longer throws `pool exhausted`. `remainingCapacity` counts the free list.
64
+ `cancel()` and completion do not reclaim -- the range stays reserved and the controller is
65
+ revealable. The per-frame tick path is byte-identical (the free list is touched only in
66
+ `createController`/`destroy`).
67
+
68
+ ### Changed
69
+ - **`burn` retuned to a 0.2 s flash (B-15).** Defaults `count` 150 -> 100, `duration` 1500 -> 200
70
+ ms; spawn `size` 2-5 -> 3-8, `decay` 0.01-0.03 -> 0.02-0.05, upward `vy` -1..-4 -> -7..-2. The
71
+ additive gravity term `vy += 0.1 * ds` is removed and the cover fade is quadratic (`1 - t*t`)
72
+ instead of the cubic `easeIn`. (The quadratic fade was a regression -- see 1.5.0 B-18.)
73
+ - **`iceBreath` default `count` 300 -> 450 (B-16).**
74
+
75
+ ### Removed
76
+ - `Math.random()` from `dragonBreath`, `shineWave`, and `lightningCrawl` (replaced by the
77
+ seeded `rng`).
78
+
3
79
  ## [1.3.2] - 2026-08-23
4
80
 
5
81
  Recipe-fidelity pass against the consuming renderer. No API or signature change; the
package/README.md CHANGED
@@ -36,7 +36,7 @@ import { createScratchController, BurnRecipe } from '@zakkster/lite-scratch-fx';
36
36
  const fx = createScratchController(scratchCanvas, fxCanvas, { seed: 42 });
37
37
 
38
38
  revealButton.onclick = () => {
39
- fx.reveal(BurnRecipe({ duration: 1500 }), () => {
39
+ fx.reveal(BurnRecipe(), () => {
40
40
  console.log('prize revealed');
41
41
  });
42
42
  };
@@ -131,7 +131,7 @@ function MyRecipe({ count = 120, duration = 1000 } = {}) {
131
131
  let size;
132
132
  return {
133
133
  count, // 0 = pure-canvas reveal, no particles
134
- init(ctx, capacity, w, h) { // allocate parallel per-particle arrays
134
+ init(ctx, capacity, w, h, rng) { // allocate arrays; rng = seeded Random (same one passed to spawn), use instead of Math.random for reproducible reveals
135
135
  size = new Float32Array(capacity);
136
136
  },
137
137
  spawn(idx, rng, w, h, spot) { // one particle; spot is 0..1 on the layer
@@ -234,6 +234,8 @@ stage.createController(sourceCanvas, effectCanvas, options?): StageController
234
234
  stage.tick(dt): void // advance every active reveal one frame; dt in SECONDS
235
235
  stage.destroy(): void // stop everything and release the shared engine
236
236
  stage.remainingCapacity // slots still unreserved in the shared pool (read-only)
237
+ stage.remainingCapacityFor(capacity) // exact slots allocatable for a request of this size:
238
+ // a size-C createController succeeds iff this returns >= C
237
239
  ```
238
240
 
239
241
  `createController` options: `capacity` (slots reserved out of the pool, default `300` --
@@ -257,11 +259,22 @@ resolvePalette(colors, theme, fallback): string[]
257
259
  - **`resolvePalette(colors, theme, fallback)`** -- resolve a colour ramp for a themeable
258
260
  recipe: `colors` wins, then `theme`, then the recipe's own `fallback`.
259
261
 
262
+ Several recipes take extra tuning knobs alongside `count`/`duration`; all default to the
263
+ current built-in look:
264
+
265
+ - **`DissolveRecipe`** -- `fadeSpeed` (default `0.3`): fraction of `duration` over which the
266
+ source layer fades out.
267
+ - **`DragonBreathRecipe`** / **`IceBreathRecipe`** -- `spread` (jet half-angle in radians,
268
+ defaults `0.3` / `0.225`), `speedMin`/`speedMax` (spawn speed range, defaults `12`-`25`
269
+ / `15`-`28`).
270
+ - **`PeelRecipe`** / **`ShineWaveRecipe`** -- `ease` (`(t: number) => number`): the easing
271
+ applied to progress; defaults to the recipe's built-in curve (`easeIn` / `easeInOut`).
272
+
260
273
  ### Constants
261
274
 
262
275
  | Constant | Value | Meaning |
263
276
  | ------------------------- | ----------------------------------------------------------- | ---------------------------------------------------- |
264
- | `VERSION` | `'1.3.2'` | Package version string (synced to package.json). |
277
+ | `VERSION` | `'1.5.0'` | Package version string (synced to package.json). |
265
278
  | `maxParticles` (default) | `2000` | Controller / stage pool capacity. |
266
279
  | `scanPrecision` (default) | `32` | Horizontal resolution of the spawn-point scan. |
267
280
  | stage `capacity` (default)| `300` | Slots a stage controller reserves from the pool. |
@@ -271,9 +284,11 @@ resolvePalette(colors, theme, fallback): string[]
271
284
 
272
285
  `RECIPES` is an extensible null-prototype registry keyed by short name;
273
286
  `RECIPE_NAMES` is its keys at load time; `RECIPE_META` is a live array
274
- (`registerRecipe` keeps it in sync). `needsUntaintedCanvas` is `true` only for the 3
275
- image recipes, which snapshot the layer via `toDataURL()` and therefore need a same-origin
276
- canvas.
287
+ (`registerRecipe` keeps it in sync). Image recipes draw the source canvas live each
288
+ frame via `drawImage(src)`, so they work on cross-origin/tainted layers;
289
+ `needsUntaintedCanvas` is retained in `RECIPE_META` (all built-ins `false`) for a
290
+ third-party recipe that reads the canvas back (`getImageData`/`toDataURL`) and needs to
291
+ advertise a same-origin requirement to pickers.
277
292
 
278
293
  ---
279
294
 
@@ -328,6 +343,8 @@ The erase is plain canvas `destination-out` -- this library never touches your i
328
343
  stage holds **one** lane pool for the whole grid, hands each card a fixed 200-slot
329
344
  sub-range, and the single `stage.tick(dt)` fans out to every card that is mid-reveal. Add
330
345
  a recipe with `registerRecipe` and the `RECIPE_META` picker above serves it with no edit.
346
+ A recipe instance holds per-reveal state, so build a fresh one per reveal (as `RECIPES[pick()]()`
347
+ does above) rather than sharing a single instance across cards or reveals.
331
348
 
332
349
  ---
333
350
 
@@ -387,9 +404,11 @@ gate rejects its deliberately-broken variant.
387
404
  `{ colors, theme }`; `colors` wins, `theme` maps a `{ light, mid, dark }` triple onto the
388
405
  ramp, and omitting both keeps the recipe's own default. A skin threads one triple through
389
406
  the whole grid.
390
- - **Image recipes need an untainted canvas.** `shatter`, `pixelShatter`, and
391
- `glitchReveal` snapshot the layer via `toDataURL()` and an `Image`, so the scratch layer
392
- must be same-origin. `RECIPE_META[n].needsUntaintedCanvas` flags them for a picker.
407
+ - **Image recipes draw the source canvas live.** `shatter`, `pixelShatter`, and
408
+ `glitchReveal` draw the scratch layer each frame via `drawImage(src)` -- no snapshot --
409
+ so they work on cross-origin/tainted layers. `needsUntaintedCanvas` is retained in
410
+ `RECIPE_META` (all built-ins `false`) for a third-party recipe that reads the canvas back
411
+ (`getImageData`/`toDataURL`) and must advertise a same-origin requirement to a picker.
393
412
 
394
413
  ---
395
414
 
package/index.d.ts CHANGED
@@ -69,7 +69,8 @@ export interface RecipeRng {
69
69
  export interface Recipe {
70
70
  /** Particles to spawn. `0` means a pure-canvas reveal with no particles. */
71
71
  count: number;
72
- init(ctx: CanvasRenderingContext2D, capacity: number, w: number, h: number): void;
72
+ /** `rng` is the controller/stage's seeded Random instance (same one passed to spawn); use it instead of Math.random for reproducible reveals. */
73
+ init(ctx: CanvasRenderingContext2D, capacity: number, w: number, h: number, rng: RecipeRng): void;
73
74
  spawn(idx: number, rng: RecipeRng, w: number, h: number, spot: SpawnSpot): SpawnState;
74
75
  /** Return `true` when the effect is complete. `elapsedMs` is milliseconds since reveal. */
75
76
  tick(
@@ -183,7 +184,10 @@ export interface StageController {
183
184
  * A no-op when idle or destroyed. Does not touch other controllers' reveals.
184
185
  */
185
186
  cancel(): void;
186
- /** Remove this controller from the stage (its sub-range is not reclaimed). */
187
+ /**
188
+ * Remove this controller from the stage. Its sub-range is reclaimed into the stage
189
+ * free-list for exact-capacity reuse by a later createController.
190
+ */
187
191
  destroy(): void;
188
192
  }
189
193
 
@@ -204,6 +208,8 @@ export interface ScratchStage {
204
208
  destroy(): void;
205
209
  /** Slots still unreserved in the shared pool. */
206
210
  readonly remainingCapacity: number;
211
+ /** Exact particle-slots allocatable for a request of this capacity; a size-C createController succeeds iff this returns >= C. */
212
+ remainingCapacityFor(capacity: number): number;
207
213
  }
208
214
 
209
215
  /** Create a stage for concurrent reveals over one shared particle pool. */
@@ -227,14 +233,14 @@ export interface ThemeableRecipeOptions extends RecipeOptions {
227
233
 
228
234
  export function BurnRecipe(opts?: ThemeableRecipeOptions): Recipe;
229
235
  export function ShatterRecipe(opts?: RecipeOptions & { gravity?: number }): Recipe;
230
- export function DissolveRecipe(opts?: ThemeableRecipeOptions): Recipe;
236
+ export function DissolveRecipe(opts?: ThemeableRecipeOptions & { fadeSpeed?: number }): Recipe;
231
237
  export function ExplodeRecipe(opts?: ThemeableRecipeOptions & { force?: number }): Recipe;
232
- export function DragonBreathRecipe(opts?: ThemeableRecipeOptions): Recipe;
233
- export function IceBreathRecipe(opts?: ThemeableRecipeOptions): Recipe;
234
- export function ShineWaveRecipe(opts?: ThemeableRecipeOptions & { beamWidth?: number; particleCount?: number }): Recipe;
238
+ export function DragonBreathRecipe(opts?: ThemeableRecipeOptions & { spread?: number; speedMin?: number; speedMax?: number }): Recipe;
239
+ export function IceBreathRecipe(opts?: ThemeableRecipeOptions & { spread?: number; speedMin?: number; speedMax?: number }): Recipe;
240
+ export function ShineWaveRecipe(opts?: ThemeableRecipeOptions & { beamWidth?: number; particleCount?: number; ease?: (t: number) => number }): Recipe;
235
241
  export function LightningCrawlRecipe(opts?: ThemeableRecipeOptions & { boltCount?: number }): Recipe;
236
242
  export function ShineRecipe(opts?: RecipeOptions & { width?: number }): Recipe;
237
- export function PeelRecipe(opts?: RecipeOptions): Recipe;
243
+ export function PeelRecipe(opts?: RecipeOptions & { ease?: (t: number) => number }): Recipe;
238
244
  export function FadeRecipe(opts?: RecipeOptions): Recipe;
239
245
  export function ImplosionRecipe(opts?: RecipeOptions): Recipe;
240
246
 
package/index.js CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  // Three-place version sync: this constant, package.json "version", and the top
15
15
  // CHANGELOG.md heading must always match. /release keeps them locked.
16
- export const VERSION = '1.3.2';
16
+ export const VERSION = '1.5.0';
17
17
 
18
18
  export { createScratchController } from './src/ScratchController.js';
19
19
  export { default as ScratchController } from './src/ScratchController.js';
@@ -71,7 +71,11 @@ export const RECIPES = Object.assign(Object.create(null), {
71
71
  *
72
72
  * category 'particle' | 'image' | 'beam' | 'css' | (custom)
73
73
  * themeable accepts { colors, theme }
74
- * needsUntaintedCanvas snapshots the scratch layer via toDataURL() (same-origin only)
74
+ * needsUntaintedCanvas all built-in recipes are false: image recipes draw the
75
+ * scratch layer live via drawImage(src), never reading it
76
+ * back. The flag is retained so a third-party recipe that
77
+ * does read the canvas (getImageData/toDataURL) can
78
+ * advertise a same-origin requirement to pickers.
75
79
  */
76
80
  export const RECIPE_META = [
77
81
  { id: 'burn', name: 'Burn', category: 'particle', themeable: true, needsUntaintedCanvas: false },
@@ -84,9 +88,9 @@ export const RECIPE_META = [
84
88
  { id: 'cosmicDust', name: 'Cosmic Dust', category: 'particle', themeable: true, needsUntaintedCanvas: false },
85
89
  { id: 'matrixDecay', name: 'Matrix Decay', category: 'particle', themeable: true, needsUntaintedCanvas: false },
86
90
  { 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 },
91
+ { id: 'shatter', name: 'Shatter', category: 'image', themeable: false, needsUntaintedCanvas: false },
92
+ { id: 'pixelShatter', name: 'Pixel Shatter', category: 'image', themeable: false, needsUntaintedCanvas: false },
93
+ { id: 'glitchReveal', name: 'Glitch Reveal', category: 'image', themeable: false, needsUntaintedCanvas: false },
90
94
  { id: 'shine', name: 'Shine', category: 'beam', themeable: false, needsUntaintedCanvas: false },
91
95
  { id: 'shineWave', name: 'Shine Wave', category: 'beam', themeable: true, needsUntaintedCanvas: false },
92
96
  { id: 'laserScan', name: 'Laser Scan', category: 'beam', themeable: true, needsUntaintedCanvas: false },
package/llms.txt CHANGED
@@ -32,7 +32,7 @@ A second reveal while one is active is ignored.
32
32
 
33
33
  {
34
34
  count, // particles to spawn; 0 = pure-canvas reveal
35
- init(ctx, capacity, w, h), // allocate parallel per-particle arrays
35
+ init(ctx, capacity, w, h, rng), // allocate arrays; rng = seeded Random (same as spawn), use instead of Math.random for reproducible reveals
36
36
  spawn(idx, rng, w, h, spot), // -> { x, y, vx, vy, life }; spot is {x,y} in 0..1
37
37
  tick(dt, elapsedMs, p, ctx, src, w, h), // -> true when complete
38
38
  destroy(),
@@ -69,10 +69,17 @@ locks this because it couples recipes to engine internals (_head).
69
69
  All 21 live in src/ScratchRecipes.js, grouped by family with section banners:
70
70
  particle (spawn from covered pixels, own physics): burn, dissolve, explode, dragonBreath,
71
71
  iceBreath, goldDust, confettiBlast, cosmicDust, matrixDecay, liquidMelt
72
- image (snapshot layer via toDataURL()+Image; same-origin only): shatter, pixelShatter, glitchReveal
72
+ image (draw the layer live each frame via drawImage(src); works on any canvas, incl. cross-origin): shatter, pixelShatter, glitchReveal
73
73
  beam/canvas (draw light/lines on the overlay): shine, shineWave, laserScan, lightningCrawl, neonPulse
74
74
  css (drive the layer's own transform/opacity): peel, fade, implosion
75
75
 
76
+ Every factory takes an options bag; all options default to the shipped tuning, so calling with
77
+ no args reproduces the stock effect. Beyond { colors, theme } (the 14 themeable recipes) and
78
+ count/duration, the FX_CONFIG-style knobs are: dissolve fadeSpeed (fade window as a fraction of
79
+ duration); dragonBreath and iceBreath spread / speedMin / speedMax (spawn cone half-angle in
80
+ radians and speed range); peel and shineWave ease (a t -> t easing function). A non-function ease
81
+ falls back to the recipe's default curve.
82
+
76
83
  ## Registry, metadata, extension
77
84
 
78
85
  VERSION: the package version string (kept in sync with package.json and CHANGELOG).
@@ -80,8 +87,10 @@ RECIPES: an extensible null-prototype registry keyed by short name (burn, shatte
80
87
  RECIPE_NAMES: the built-in keys at load time.
81
88
  RECIPE_META: a live array of { id, name, category, themeable, needsUntaintedCanvas } for every
82
89
  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 }.
90
+ 'particle'|'image'|'beam'|'css'. needsUntaintedCanvas is false for every built-in (image recipes
91
+ draw the layer live via drawImage(src), never reading it back); the flag is retained so a
92
+ third-party recipe that reads the canvas (getImageData/toDataURL) can advertise a same-origin
93
+ requirement to pickers. themeable is true for the 14 recipes that take { colors, theme }.
85
94
  registerRecipe(id, factory, meta?): add a recipe or override a built-in; lands in RECIPES and
86
95
  RECIPE_META immediately so existing pickers keep working. Mirrors lite-ambient's registerTheme.
87
96
  Omitted meta fields fall back to the prior entry, then a de-camelCased name, category 'custom',
@@ -107,7 +116,7 @@ modes exist now:
107
116
 
108
117
  createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr = 1 })
109
118
  -> { createController(src, fx, { capacity = 300, seed?, scanPrecision = 32, dpr? }) -> stageController,
110
- tick(dt), destroy(), get remainingCapacity }
119
+ tick(dt), destroy(), get remainingCapacity, remainingCapacityFor(capacity) }
111
120
  stageController -> { reveal(recipe, onDone?), cancel(), seed(s), destroy() } // NO tick -- the stage drives.
112
121
 
113
122
  When { engine } (one reveal at a time) is not enough and you need MANY boxes revealing at once
@@ -116,6 +125,8 @@ start+capacity) of the lanes. reserving past maxParticles throws. Each controlle
116
125
  into its slots (data[start+i]=i), renders through subarray views of its range (built once per
117
126
  controller, reused every frame -> zero per-frame alloc), draws to its own ctx, keeps its own RNG.
118
127
  stage.tick(dt) advances the single engine once; the one onTick fans out to every active reveal.
128
+ controller.destroy() reclaims its sub-range into a stage free-list for exact-capacity reuse by a
129
+ later createController; cancel/completion hold the range (the controller stays revealable).
119
130
  Feasible with no engine change because raw tick(dt) is pure dispatch (no physics/culling) and the
120
131
  lanes are public typed arrays recipes already write. See decisions/0001-concurrent-shared-reveals.md.
121
132
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zakkster/lite-scratch-fx",
3
3
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
4
- "version": "1.3.2",
4
+ "version": "1.5.0",
5
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",
@@ -204,7 +204,7 @@ export function createScratchController(sourceCanvas, effectCanvas, {
204
204
  const spots = scanner.scan(scanPrecision);
205
205
 
206
206
  // Init recipe with LOGICAL size (recipes stay in CSS px).
207
- recipe.init(ctx, cap, logicalW, logicalH);
207
+ recipe.init(ctx, cap, logicalW, logicalH, rng);
208
208
 
209
209
  // Populate ring buffer. A recipe may declare `count: 0` (a pure-canvas reveal
210
210
  // like Shine or Implosion that draws no particles); use ?? so an explicit 0 is
@@ -5,7 +5,7 @@
5
5
  * ScratchController.reveal(recipe, onDone). Grouped by family below.
6
6
  *
7
7
  * Recipe interface:
8
- * { count, init(ctx, capacity, w, h),
8
+ * { count, init(ctx, capacity, w, h, rng),
9
9
  * spawn(idx, rng, w, h, spot),
10
10
  * tick(dt, elapsedMs, engine, ctx, sourceCanvas, w, h) -> boolean,
11
11
  * destroy() }
@@ -28,7 +28,7 @@ import {resolvePalette} from './Palette.js';
28
28
  // Spawn particles from the scratch layer's still-covered pixels and run their own physics.
29
29
  // ===========================================================================
30
30
 
31
- export function BurnRecipe({count = 150, duration = 1500, colors, theme} = {}) {
31
+ export function BurnRecipe({count = 100, duration = 200, colors, theme} = {}) {
32
32
  let pSize, pDecay, pColorIdx;
33
33
  const palette = resolvePalette(colors, theme, ['#ff6b00', '#ff8c00', '#ffaa00', '#ff4500', '#8B0000']);
34
34
 
@@ -40,10 +40,10 @@ export function BurnRecipe({count = 150, duration = 1500, colors, theme} = {}) {
40
40
  pColorIdx = new Uint8Array(capacity);
41
41
  },
42
42
  spawn(idx, rng, w, h, spot) {
43
- pSize[idx] = rng.range(2, 5);
44
- pDecay[idx] = rng.range(0.01, 0.03);
43
+ pSize[idx] = rng.range(3, 8);
44
+ pDecay[idx] = rng.range(0.02, 0.05);
45
45
  pColorIdx[idx] = rng.int(0, palette.length - 1);
46
- return {x: spot.x * w, y: spot.y * h, vx: rng.range(-1, 1), vy: rng.range(-1, -4), life: 1.0};
46
+ return {x: spot.x * w, y: spot.y * h, vx: rng.range(-1, 1), vy: rng.range(-7, -2), life: 1.0};
47
47
  },
48
48
  tick(dt, elapsed, engine, ctx, src, w, h) {
49
49
  const {x, y, vx, vy, life, data, max} = engine;
@@ -55,7 +55,6 @@ export function BurnRecipe({count = 150, duration = 1500, colors, theme} = {}) {
55
55
  for (let i = 0; i < max; i++) {
56
56
  if (life[i] <= 0) continue;
57
57
  const p = data[i];
58
- vy[i] += 0.1 * ds;
59
58
  x[i] += vx[i] * ds;
60
59
  y[i] += vy[i] * ds;
61
60
  life[i] -= pDecay[p] * ds;
@@ -84,7 +83,7 @@ export function BurnRecipe({count = 150, duration = 1500, colors, theme} = {}) {
84
83
  }
85
84
 
86
85
 
87
- export function DissolveRecipe({count = 150, duration = 1000, colors, theme} = {}) {
86
+ export function DissolveRecipe({count = 150, duration = 1000, fadeSpeed = 0.3, colors, theme} = {}) {
88
87
  let pSize;
89
88
  const palette = resolvePalette(colors, theme, ['#DC143C']);
90
89
 
@@ -100,7 +99,7 @@ export function DissolveRecipe({count = 150, duration = 1000, colors, theme} = {
100
99
  tick(dt, elapsed, engine, ctx, src, w, h) {
101
100
  const {x, y, vy, life, data, max} = engine;
102
101
  let alive = 0;
103
- src.style.opacity = 1 - clamp(elapsed / (duration * 0.3), 0, 1);
102
+ src.style.opacity = 1 - clamp(elapsed / (duration * fadeSpeed), 0, 1);
104
103
 
105
104
  for (let i = 0; i < max; i++) {
106
105
  if (life[i] <= 0) continue;
@@ -172,20 +171,21 @@ export function ExplodeRecipe({count = 80, duration = 750, force = 15, colors, t
172
171
  }
173
172
 
174
173
 
175
- export function DragonBreathRecipe({count = 200, duration = 1200, colors, theme} = {}) {
176
- let pSize, pDecay, pColorIdx;
174
+ export function DragonBreathRecipe({count = 200, duration = 1200, spread = 0.3, speedMin = 12, speedMax = 25, colors, theme} = {}) {
175
+ let pSize, pDecay, pColorIdx, r;
177
176
  const palette = resolvePalette(colors, theme, ['#FFF', '#FFD700', '#FF4500', '#8B0000', '#2F2F2F']);
178
177
 
179
178
  return {
180
179
  count,
181
- init(ctx, capacity) {
180
+ init(ctx, capacity, w, h, rng) {
181
+ r = rng;
182
182
  pSize = new Float32Array(capacity);
183
183
  pDecay = new Float32Array(capacity);
184
184
  pColorIdx = new Uint8Array(capacity);
185
185
  },
186
186
  spawn(idx, rng, w, h, spot) {
187
- const angle = -Math.PI / 2 + rng.range(-0.3, 0.3);
188
- const speed = rng.range(12, 25);
187
+ const angle = -Math.PI / 2 + rng.range(-spread, spread);
188
+ const speed = rng.range(speedMin, speedMax);
189
189
  pSize[idx] = rng.range(4, 12);
190
190
  pDecay[idx] = rng.range(0.01, 0.025);
191
191
  pColorIdx[idx] = rng.int(0, palette.length - 1);
@@ -203,14 +203,15 @@ export function DragonBreathRecipe({count = 200, duration = 1200, colors, theme}
203
203
  const dvy = Math.pow(0.98, ds);
204
204
  const dsz = Math.pow(1.04, ds);
205
205
  let alive = 0;
206
- src.style.opacity = 1 - clamp(elapsed / (duration * 0.4), 0, 1);
206
+ const fadeT = clamp((elapsed - 100) / 400, 0, 1);
207
+ src.style.opacity = 1 - fadeT * fadeT;
207
208
 
208
209
  for (let i = 0; i < max; i++) {
209
210
  if (life[i] <= 0) continue;
210
211
  const p = data[i];
211
212
  x[i] += vx[i] * ds;
212
213
  y[i] += vy[i] * ds;
213
- vx[i] += (Math.random() - 0.5) * 0.5 * ds;
214
+ vx[i] += (r.next() - 0.5) * 0.5 * ds;
214
215
  vy[i] *= dvy;
215
216
  pSize[p] *= dsz;
216
217
  life[i] -= pDecay[p] * ds;
@@ -233,13 +234,13 @@ export function DragonBreathRecipe({count = 200, duration = 1200, colors, theme}
233
234
  return alive === 0 && elapsed >= duration;
234
235
  },
235
236
  destroy() {
236
- pSize = pDecay = pColorIdx = null;
237
+ pSize = pDecay = pColorIdx = r = null;
237
238
  },
238
239
  };
239
240
  }
240
241
 
241
242
 
242
- export function IceBreathRecipe({count = 300, duration = 1500, colors, theme} = {}) {
243
+ export function IceBreathRecipe({count = 450, duration = 1500, spread = 0.225, speedMin = 15, speedMax = 28, colors, theme} = {}) {
243
244
  let pSize, pDecay, pRot, pRotSpd, pColorIdx;
244
245
  const palette = resolvePalette(colors, theme, ['#FFF', '#E0FFFF', '#00FFFF', '#1E90FF', '#4682B4']);
245
246
 
@@ -253,8 +254,8 @@ export function IceBreathRecipe({count = 300, duration = 1500, colors, theme} =
253
254
  pColorIdx = new Uint8Array(capacity);
254
255
  },
255
256
  spawn(idx, rng, w, h, spot) {
256
- const angle = -Math.PI / 2 + rng.range(-0.225, 0.225);
257
- const speed = rng.range(15, 28);
257
+ const angle = -Math.PI / 2 + rng.range(-spread, spread);
258
+ const speed = rng.range(speedMin, speedMax);
258
259
  pSize[idx] = rng.range(2, 7);
259
260
  pDecay[idx] = rng.range(0.005, 0.025);
260
261
  pRot[idx] = rng.range(0, Math.PI);
@@ -273,9 +274,9 @@ export function IceBreathRecipe({count = 300, duration = 1500, colors, theme} =
273
274
  const ds = dt * 60;
274
275
  const dv = Math.pow(0.96, ds);
275
276
  let alive = 0;
276
- const t = clamp(elapsed / (duration * 0.4), 0, 1);
277
- src.style.opacity = 1 - t;
278
- src.style.filter = `brightness(${1 + t})`;
277
+ const fadeT = clamp((elapsed - 100) / 500, 0, 1);
278
+ src.style.opacity = 1 - easeIn(fadeT);
279
+ src.style.filter = `brightness(${1 + fadeT})`;
279
280
 
280
281
  for (let i = 0; i < max; i++) {
281
282
  if (life[i] <= 0) continue;
@@ -629,12 +630,12 @@ export function LiquidMeltRecipe({count = 100, duration = 1200, colors, theme} =
629
630
  // ===========================================================================
630
631
  // IMAGE REVEALS
631
632
  // ===========================================================================
632
- // Snapshot the scratch layer via toDataURL() + Image and animate the pixels. Same-origin only.
633
+ // Draw the scratch layer live each frame via drawImage(src); no snapshot, works on any canvas (incl. cross-origin).
633
634
  // ===========================================================================
634
635
 
635
636
  export function ShatterRecipe({count = 30, duration = 1200, gravity = 0.5} = {}) {
636
637
  let pRot, pRotSpd, pSz, pOpacity;
637
- let img = null;
638
+ let hidden = false;
638
639
 
639
640
  return {
640
641
  count,
@@ -652,13 +653,7 @@ export function ShatterRecipe({count = 30, duration = 1200, gravity = 0.5} = {})
652
653
  return {x: spot.x * w, y: spot.y * h, vx: rng.range(-15, 15), vy: rng.range(-10, -2), life: 1.0};
653
654
  },
654
655
  tick(dt, elapsed, engine, ctx, src, w, h) {
655
- // Lazy-capture source image on first frame
656
- if (!img) {
657
- img = new Image();
658
- img.src = src.toDataURL();
659
- src.style.opacity = '0';
660
- }
661
- if (!img.complete) return false;
656
+ if (!hidden) { src.style.opacity = '0'; hidden = true; }
662
657
 
663
658
  const {x, y, vx, vy, life, data, max} = engine;
664
659
  const ds = dt * 60;
@@ -682,14 +677,14 @@ export function ShatterRecipe({count = 30, duration = 1200, gravity = 0.5} = {})
682
677
  ctx.globalAlpha = pOpacity[p];
683
678
  ctx.translate(x[i], y[i]);
684
679
  ctx.rotate(pRot[p]);
685
- ctx.drawImage(img, -pSz[p] / 2, -pSz[p] / 2, pSz[p], pSz[p]);
680
+ ctx.drawImage(src, -pSz[p] / 2, -pSz[p] / 2, pSz[p], pSz[p]);
686
681
  ctx.restore();
687
682
  }
688
683
  return alive === 0;
689
684
  },
690
685
  destroy() {
691
686
  pRot = pRotSpd = pSz = pOpacity = null;
692
- img = null;
687
+ hidden = false;
693
688
  },
694
689
  };
695
690
  }
@@ -697,7 +692,7 @@ export function ShatterRecipe({count = 30, duration = 1200, gravity = 0.5} = {})
697
692
 
698
693
  export function PixelShatterRecipe({count = 60, duration = 1200} = {}) {
699
694
  let pRot, pRotSpd, pSz, pOrigX, pOrigY; // <- FIX: Store ORIGINAL positions
700
- let img = null, imgReady = false;
695
+ let hidden = false;
701
696
 
702
697
  return {
703
698
  count,
@@ -721,16 +716,7 @@ export function PixelShatterRecipe({count = 60, duration = 1200} = {}) {
721
716
  return {x: sx, y: sy, vx: Math.cos(angle) * spd, vy: Math.sin(angle) * spd, life: 1.0};
722
717
  },
723
718
  tick(dt, elapsed, engine, ctx, src, w, h) {
724
- if (!img) {
725
- img = new Image();
726
- img.onload = () => {
727
- imgReady = true;
728
- };
729
- img.src = src.toDataURL();
730
- src.style.opacity = '0';
731
- return false;
732
- }
733
- if (!imgReady) return false;
719
+ if (!hidden) { src.style.opacity = '0'; hidden = true; }
734
720
 
735
721
  const {x, y, vx, vy, life, data, max} = engine;
736
722
  const ds = dt * 60;
@@ -757,23 +743,21 @@ export function PixelShatterRecipe({count = 60, duration = 1200} = {}) {
757
743
  ctx.translate(x[i], y[i]);
758
744
  ctx.rotate(pRot[p]);
759
745
  // <- FIX: Source crop uses ORIGINAL position, not current animated position
760
- ctx.drawImage(img, pOrigX[p], pOrigY[p], sz, sz, -sz / 2, -sz / 2, sz, sz);
746
+ ctx.drawImage(src, pOrigX[p], pOrigY[p], sz, sz, -sz / 2, -sz / 2, sz, sz);
761
747
  ctx.restore();
762
748
  }
763
749
  return alive === 0;
764
750
  },
765
751
  destroy() {
766
752
  pRot = pRotSpd = pSz = pOrigX = pOrigY = null;
767
- img = null;
768
- imgReady = false;
753
+ hidden = false;
769
754
  },
770
755
  };
771
756
  }
772
757
 
773
758
 
774
759
  export function GlitchRevealRecipe({duration = 800} = {}) {
775
- let img = null;
776
- let imgReady = false;
760
+ let hidden = false;
777
761
  let sliceSeeds = null; // Pre-generated random values for deterministic slicing
778
762
 
779
763
  return {
@@ -788,17 +772,7 @@ export function GlitchRevealRecipe({duration = 800} = {}) {
788
772
  return {x: 0, y: 0, vx: 0, vy: 0, life: 0};
789
773
  },
790
774
  tick(dt, elapsed, engine, ctx, src, w, h) {
791
- // Lazy capture with onload guard
792
- if (!img) {
793
- img = new Image();
794
- img.onload = () => {
795
- imgReady = true;
796
- };
797
- img.src = src.toDataURL();
798
- src.style.opacity = '0';
799
- return false;
800
- }
801
- if (!imgReady) return false;
775
+ if (!hidden) { src.style.opacity = '0'; hidden = true; }
802
776
 
803
777
  const raw = clamp(elapsed / duration, 0, 1);
804
778
 
@@ -817,9 +791,9 @@ export function GlitchRevealRecipe({duration = 800} = {}) {
817
791
 
818
792
  // Red channel
819
793
  ctx.globalCompositeOperation = 'screen';
820
- ctx.drawImage(img, 0, sliceY, w, sliceH, offsetX - rgbShift, sliceY, w, sliceH);
794
+ ctx.drawImage(src, 0, sliceY, w, sliceH, offsetX - rgbShift, sliceY, w, sliceH);
821
795
  // Cyan channel
822
- ctx.drawImage(img, 0, sliceY, w, sliceH, offsetX + rgbShift, sliceY, w, sliceH);
796
+ ctx.drawImage(src, 0, sliceY, w, sliceH, offsetX + rgbShift, sliceY, w, sliceH);
823
797
  }
824
798
 
825
799
  ctx.globalCompositeOperation = 'source-over';
@@ -827,9 +801,8 @@ export function GlitchRevealRecipe({duration = 800} = {}) {
827
801
  return raw >= 1;
828
802
  },
829
803
  destroy() {
830
- img = null;
831
- imgReady = false;
832
804
  sliceSeeds = null;
805
+ hidden = false;
833
806
  },
834
807
  };
835
808
  }
@@ -869,24 +842,26 @@ export function ShineRecipe({duration = 500, width = 250} = {}) {
869
842
  }
870
843
 
871
844
 
872
- export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount = 60, colors, theme} = {}) {
845
+ export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount = 60, ease = easeInOut, colors, theme} = {}) {
873
846
  const palette = resolvePalette(colors, theme, ['#FFF', '#E0E8FF', '#B0C4FF', '#88AAFF']);
874
- let py, pvy, pox, psz, pcol;
847
+ const ez = typeof ease === 'function' ? ease : easeInOut;
848
+ let py, pvy, pox, psz, pcol, r;
875
849
 
876
850
  return {
877
851
  count: 0, // No SoA particles -- managed internally
878
- init(ctx, capacity, w, h) {
852
+ init(ctx, capacity, w, h, rng) {
853
+ r = rng;
879
854
  py = new Float32Array(particleCount);
880
855
  pvy = new Float32Array(particleCount);
881
856
  pox = new Float32Array(particleCount);
882
857
  psz = new Float32Array(particleCount);
883
858
  pcol = new Uint8Array(particleCount);
884
859
  for (let i = 0; i < particleCount; i++) {
885
- py[i] = Math.random() * h;
886
- pvy[i] = (Math.random() - 0.5) * 2;
887
- pox[i] = (Math.random() - 0.5) * beamWidth * 0.6;
888
- psz[i] = Math.random() * 4 + 2;
889
- pcol[i] = (Math.random() * palette.length) | 0;
860
+ py[i] = r.next() * h;
861
+ pvy[i] = (r.next() - 0.5) * 2;
862
+ pox[i] = (r.next() - 0.5) * beamWidth * 0.6;
863
+ psz[i] = r.next() * 4 + 2;
864
+ pcol[i] = (r.next() * palette.length) | 0;
890
865
  }
891
866
  },
892
867
  spawn() {
@@ -894,7 +869,7 @@ export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount =
894
869
  },
895
870
  tick(dt, elapsed, engine, ctx, src, w, h) {
896
871
  const raw = clamp(elapsed / duration, 0, 1);
897
- const t = easeInOut(raw);
872
+ const t = ez(raw);
898
873
  const ds = dt * 60;
899
874
  const bx = lerp(-beamWidth, w + beamWidth, t);
900
875
  src.style.opacity = t > 0.3 ? 1 - (t - 0.3) / 0.7 : 1;
@@ -916,10 +891,10 @@ export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount =
916
891
  for (let i = 0; i < particleCount; i++) {
917
892
  const px2 = bx + pox[i];
918
893
  py[i] += pvy[i] * ds;
919
- pvy[i] += (Math.random() - 0.5) * 0.3 * ds;
894
+ pvy[i] += (r.next() - 0.5) * 0.3 * ds;
920
895
  if (py[i] < -10) py[i] = h + 10;
921
896
  if (py[i] > h + 10) py[i] = -10;
922
- psz[i] = clamp(psz[i] + (Math.random() - 0.5) * 0.3 * ds, 1, 8);
897
+ psz[i] = clamp(psz[i] + (r.next() - 0.5) * 0.3 * ds, 1, 8);
923
898
  ctx.globalAlpha = 0.7;
924
899
  ctx.fillStyle = palette[pcol[i]];
925
900
  ctx.save();
@@ -932,7 +907,7 @@ export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount =
932
907
  return raw >= 1;
933
908
  },
934
909
  destroy() {
935
- py = pvy = pox = psz = pcol = null;
910
+ py = pvy = pox = psz = pcol = r = null;
936
911
  },
937
912
  };
938
913
  }
@@ -1013,6 +988,7 @@ export function LightningCrawlRecipe({boltCount = 5, duration = 600, colors, the
1013
988
  const cMid = palette[(palette.length - 1) >> 1];
1014
989
  const cOuter = palette[palette.length - 1];
1015
990
  let bolts = [];
991
+ let r;
1016
992
 
1017
993
  function genPts(x1, y1, x2, y2, d) {
1018
994
  let p = [{x: x1, y: y1}, {x: x2, y: y2}];
@@ -1022,7 +998,7 @@ export function LightningCrawlRecipe({boltCount = 5, duration = 600, colors, the
1022
998
  const a = p[j], b = p[j + 1];
1023
999
  const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
1024
1000
  const dist = Math.hypot(b.x - a.x, b.y - a.y);
1025
- const off = (Math.random() - 0.5) * dist * 0.4;
1001
+ const off = (r.next() - 0.5) * dist * 0.4;
1026
1002
  const nx = -(b.y - a.y) / (dist || 1), ny = (b.x - a.x) / (dist || 1);
1027
1003
  np.push({x: mx + nx * off, y: my + ny * off});
1028
1004
  np.push(b);
@@ -1045,39 +1021,40 @@ export function LightningCrawlRecipe({boltCount = 5, duration = 600, colors, the
1045
1021
  ctx.stroke();
1046
1022
  });
1047
1023
  if (d > 2) for (let i = 2; i < pts.length - 1; i += 3) {
1048
- if (Math.random() > 0.5) continue;
1049
- const bl = Math.hypot(x2 - x1, y2 - y1) * (0.15 + Math.random() * 0.2);
1050
- const ba = Math.atan2(y2 - y1, x2 - x1) + (Math.random() - 0.5) * 1.5;
1024
+ if (r.next() > 0.5) continue;
1025
+ const bl = Math.hypot(x2 - x1, y2 - y1) * (0.15 + r.next() * 0.2);
1026
+ const ba = Math.atan2(y2 - y1, x2 - x1) + (r.next() - 0.5) * 1.5;
1051
1027
  drawBolt(ctx, pts[i].x, pts[i].y, pts[i].x + Math.cos(ba) * bl, pts[i].y + Math.sin(ba) * bl, th * 0.5, d - 2);
1052
1028
  }
1053
1029
  }
1054
1030
 
1055
1031
  return {
1056
1032
  count: 0,
1057
- init(ctx, capacity, w, h) {
1033
+ init(ctx, capacity, w, h, rng) {
1034
+ r = rng;
1058
1035
  bolts = [];
1059
1036
  for (let b = 0; b < boltCount; b++) {
1060
- const e = (Math.random() * 4) | 0;
1037
+ const e = (r.next() * 4) | 0;
1061
1038
  let sx, sy;
1062
1039
  if (e === 0) {
1063
1040
  sx = 0;
1064
- sy = Math.random() * h;
1041
+ sy = r.next() * h;
1065
1042
  } else if (e === 1) {
1066
1043
  sx = w;
1067
- sy = Math.random() * h;
1044
+ sy = r.next() * h;
1068
1045
  } else if (e === 2) {
1069
- sx = Math.random() * w;
1046
+ sx = r.next() * w;
1070
1047
  sy = 0;
1071
1048
  } else {
1072
- sx = Math.random() * w;
1049
+ sx = r.next() * w;
1073
1050
  sy = h;
1074
1051
  }
1075
1052
  bolts.push({
1076
1053
  sx,
1077
1054
  sy,
1078
- ex: w * (0.3 + Math.random() * 0.4),
1079
- ey: h * (0.3 + Math.random() * 0.4),
1080
- delay: Math.random() * 0.3
1055
+ ex: w * (0.3 + r.next() * 0.4),
1056
+ ey: h * (0.3 + r.next() * 0.4),
1057
+ delay: r.next() * 0.3
1081
1058
  });
1082
1059
  }
1083
1060
  },
@@ -1109,6 +1086,7 @@ export function LightningCrawlRecipe({boltCount = 5, duration = 600, colors, the
1109
1086
  },
1110
1087
  destroy() {
1111
1088
  bolts = [];
1089
+ r = null;
1112
1090
  },
1113
1091
  };
1114
1092
  }
@@ -1169,8 +1147,9 @@ export function NeonPulseRecipe({duration = 800, colors, theme} = {}) {
1169
1147
  // Drive the scratch layer's own transform/opacity; little or no particle work.
1170
1148
  // ===========================================================================
1171
1149
 
1172
- export function PeelRecipe({duration = 1000} = {}) {
1150
+ export function PeelRecipe({duration = 1000, ease = easeIn} = {}) {
1173
1151
  let peelW = 0;
1152
+ const ez = typeof ease === 'function' ? ease : easeIn;
1174
1153
  return {
1175
1154
  count: 0,
1176
1155
  init(ctx, capacity, w) {
@@ -1181,7 +1160,7 @@ export function PeelRecipe({duration = 1000} = {}) {
1181
1160
  },
1182
1161
  tick(dt, elapsed, engine, ctx, src) {
1183
1162
  const raw = clamp(elapsed / duration, 0, 1);
1184
- const t = easeIn(raw);
1163
+ const t = ez(raw);
1185
1164
  src.style.transformOrigin = 'right center';
1186
1165
  src.style.transform = `perspective(800px) rotateY(${t * 90}deg) rotateX(${t * -20}deg) translateX(${t * peelW}px)`;
1187
1166
  src.style.opacity = 1 - t;
@@ -46,6 +46,12 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
46
46
  const pending = new Set();
47
47
  let dispatching = false;
48
48
  let nextStart = 0;
49
+ // Free-list of reclaimed [start, capacity] ranges from destroyed controllers, in
50
+ // parallel arrays. Exact-match reuse only: a new controller reclaims a range solely
51
+ // when its capacity equals a freed one. Cold path (createController/destroy) -- never
52
+ // touched per frame.
53
+ const freeStart = [], freeCap = [];
54
+ let reclaimedCapacity = 0;
49
55
  let controllerCount = 0;
50
56
  let destroyed = false;
51
57
 
@@ -89,13 +95,32 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
89
95
  if (!(typeof dpr === 'number' && Number.isFinite(dpr) && dpr > 0)) {
90
96
  throw new TypeError('createController: dpr must be a positive finite number');
91
97
  }
92
- if (nextStart + capacity > maxParticles) {
93
- throw new RangeError(
94
- `createController: pool exhausted -- ${nextStart}+${capacity} exceeds maxParticles ${maxParticles}`,
95
- );
98
+ // Reclaim an exact-capacity range from a destroyed controller before growing the
99
+ // pool. On a hit we skip the nextStart bump and the exhaustion throw entirely.
100
+ let start;
101
+ let reused = -1;
102
+ for (let i = 0; i < freeCap.length; i++) {
103
+ if (freeCap[i] === capacity) { reused = i; break; }
96
104
  }
97
- const start = nextStart;
98
- nextStart += capacity;
105
+ if (reused >= 0) {
106
+ start = freeStart[reused];
107
+ // Swap-remove: order is irrelevant for exact-match reuse.
108
+ const last = freeCap.length - 1;
109
+ freeStart[reused] = freeStart[last];
110
+ freeCap[reused] = freeCap[last];
111
+ freeStart.pop();
112
+ freeCap.pop();
113
+ reclaimedCapacity -= capacity;
114
+ } else {
115
+ if (nextStart + capacity > maxParticles) {
116
+ throw new RangeError(
117
+ `createController: pool exhausted -- ${nextStart}+${capacity} exceeds maxParticles ${maxParticles}`,
118
+ );
119
+ }
120
+ start = nextStart;
121
+ nextStart += capacity;
122
+ }
123
+ // A reused range is a NEW controller, so the index always advances -> new default seed.
99
124
  const index = controllerCount++;
100
125
 
101
126
  const ctx = effectCanvas.getContext('2d');
@@ -133,7 +158,8 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
133
158
  // The single terminator for this controller's reveal. Completion (fireDone=true),
134
159
  // cancel() (false), and destroy() (false) all route here so the recipe teardown,
135
160
  // host-style restore, canvas clear, and active-set removal happen in one place.
136
- // Cold path: once per reveal, never per frame. Sub-range stays reserved (B-8).
161
+ // Cold path: once per reveal, never per frame. The sub-range is held across every
162
+ // completion and cancel, and reclaimed into the stage free-list only on destroy() (B-8).
137
163
  function endReveal(fireDone) {
138
164
  if (!activeRecipe) return;
139
165
  if (activeRecipe.destroy) activeRecipe.destroy();
@@ -171,7 +197,7 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
171
197
  hostSnapshot = captureHostStyle(sourceCanvas);
172
198
 
173
199
  const spots = scanner.scan(scanPrecision);
174
- recipe.init(ctx, capacity, cw, ch);
200
+ recipe.init(ctx, capacity, cw, ch, rng);
175
201
 
176
202
  const requested = recipe.count ?? capacity;
177
203
  const count = Math.min(requested, capacity);
@@ -224,7 +250,10 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
224
250
  /** Re-seed this controller's RNG. */
225
251
  seed(s) { rng.reset(s); },
226
252
 
227
- /** Remove this controller from the stage. Its sub-range is not reclaimed (v1). */
253
+ /**
254
+ * Remove this controller from the stage. Its sub-range is reclaimed into the
255
+ * stage free-list for exact-capacity reuse by a later createController.
256
+ */
228
257
  destroy() {
229
258
  if (ctlDestroyed) return;
230
259
  ctlDestroyed = true;
@@ -232,6 +261,9 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
232
261
  // host-style restore, active-set removal, no onDone) before scanner teardown.
233
262
  endReveal(false);
234
263
  scanner.destroy();
264
+ // Reclaim the sub-range. The early-return above guarantees one push per
265
+ // controller, so a double-destroy cannot double-free.
266
+ freeStart.push(start); freeCap.push(capacity); reclaimedCapacity += capacity;
235
267
  },
236
268
  };
237
269
  return controller;
@@ -255,8 +287,27 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
255
287
  engine.destroy();
256
288
  },
257
289
 
258
- /** @internal How many slots are still unreserved (for tests / diagnostics). */
259
- get remainingCapacity() { return maxParticles - nextStart; },
290
+ /**
291
+ * @internal Total unreserved slots (fresh tail + reclaimed). Reclaimed slots are
292
+ * reusable only on an EXACT capacity match, so for an allocation decision use
293
+ * remainingCapacityFor(capacity).
294
+ */
295
+ get remainingCapacity() { return maxParticles - nextStart + reclaimedCapacity; },
296
+
297
+ /**
298
+ * @internal Exact particle-slots allocatable for a request of this capacity; a
299
+ * size-C createController succeeds iff remainingCapacityFor(C) >= C.
300
+ */
301
+ remainingCapacityFor(capacity) {
302
+ if (!(Number.isInteger(capacity) && capacity >= 0)) {
303
+ throw new TypeError('remainingCapacityFor: capacity must be a non-negative integer');
304
+ }
305
+ let n = 0;
306
+ for (let i = 0; i < freeCap.length; i++) {
307
+ if (freeCap[i] === capacity) n++;
308
+ }
309
+ return (maxParticles - nextStart) + n * capacity;
310
+ },
260
311
  };
261
312
  }
262
313