@zakkster/lite-scratch-fx 1.4.0 → 1.5.1

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,71 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.5.1] - 2026-08-23
4
+
5
+ Cover-fade fidelity patch for `iceBreath`. Backward compatible; no API change.
6
+
7
+ ### Fixed
8
+ - **`iceBreath` brightness ramp is now eased, matching the game's tween (B-21).** The
9
+ `brightness()` filter followed a linear `1 + fadeT` while the opacity one line above
10
+ followed the cubic `1 - fadeT^3`. GSAP applies a tween's ease to its numeric filter
11
+ target too, so both should share the cubic `power2.in` curve. The tick now computes
12
+ `ez = easeIn(fadeT)` once and drives opacity as `1 - ez` and brightness as `1 + ez`;
13
+ binding both writes to the same value removes the drift that caused the bug. Observable
14
+ change: at the fade midpoint brightness is `1.125`, not `1.5` (the cover no longer blows
15
+ out to near-white by mid-reveal). No allocation added; the torture gate is unchanged.
16
+
17
+ ### Changed
18
+ - **Docs: `iceBreath`/`dragonBreath` `spread` is a jet half-angle (B-22).** The catalog
19
+ card now states `spread` is a half-angle in radians, matching `README.md` and `llms.txt`.
20
+ `IceBreathRecipe({ spread: FX_CONFIG.iceBreath.spread })` doubles the cone; pass
21
+ `spread / 2`. No code change.
22
+
23
+ ## [1.5.0] - 2026-08-23
24
+
25
+ Cover-fade fidelity, live image reveals, and additive recipe/stage knobs. Backward
26
+ compatible: every new recipe option and the new stage method is additive, and calling
27
+ with no options reproduces prior output byte-identically.
28
+
29
+ ### Added
30
+ - **`stage.remainingCapacityFor(capacity)` (B-19).** Returns the exact particle-slots
31
+ allocatable for a request of that capacity: `(maxParticles - nextStart) + n * capacity`,
32
+ where `n` is the count of reclaimed ranges of that exact capacity. A size-`C`
33
+ `createController` succeeds iff `remainingCapacityFor(C) >= C`. Zero-allocation; throws
34
+ `TypeError` on a non-integer or negative argument. `remainingCapacity` is unchanged (total
35
+ free slots); its JSDoc now states reclaimed ranges are reusable only on an exact-capacity
36
+ match, so `remainingCapacityFor` is the number to consult for an allocation decision.
37
+ - **Additive recipe factory options.** `dissolve` gains `fadeSpeed` (cover-fade window as a
38
+ fraction of `duration`, default `0.3`); `dragonBreath` and `iceBreath` gain `spread`,
39
+ `speedMin`, `speedMax` (spawn cone half-angle in radians and speed range; defaults `0.3`/
40
+ `12`/`25` and `0.225`/`15`/`28`); `peel` and `shineWave` gain `ease` (a `t -> t` easing
41
+ function, default `easeIn`/`easeInOut`). A non-function `ease` falls back to the default.
42
+ Every default equals the previous literal, so existing callers are unaffected, and each
43
+ numeric knob is a single `rng` draw in the same position (determinism preserved).
44
+
45
+ ### Changed
46
+ - **Image recipes draw the scratch layer live (R5).** `shatter`, `pixelShatter`, and
47
+ `glitchReveal` now draw the source canvas each frame with `drawImage(src, ...)` instead of
48
+ snapshotting it once via `toDataURL()` + `Image`. This removes the synchronous PNG encode,
49
+ the async decode, and the blank first frames: they render on frame 1 and work on any
50
+ canvas, including cross-origin/tainted layers. `RECIPE_META[*].needsUntaintedCanvas` is now
51
+ `false` for every built-in; the field is retained so a third-party recipe that reads the
52
+ canvas back (`getImageData`/`toDataURL`) can still advertise a same-origin requirement.
53
+ - **`iceBreath` and `dragonBreath` cover fades match the consuming renderer.** `iceBreath`
54
+ fades with `power2.in` (cubic, `1 - easeIn`) over 0.5 s after a 0.1 s delay; `dragonBreath`
55
+ fades with `power1.in` (quadratic) over 0.4 s after a 0.1 s delay. Both were previously
56
+ linear with no delay.
57
+
58
+ ### Fixed
59
+ - **Burn cover fade restored to cubic (B-18).** 1.4.0 shipped a quadratic fade (`1 - t*t`).
60
+ The consuming renderer tweens the burn cover with `power2.in`, which is cubic in GSAP's
61
+ naming (`Power2 = Cubic`; verified `parseEase('power2.in')(0.5) === 0.125`), so the correct
62
+ curve is `1 - easeIn(progress)` (`easeIn = t*t*t`). Reverted to cubic: at `progress` 0.5 the
63
+ cover opacity is `0.875`, not `0.75`.
64
+
65
+ ### Removed
66
+ - The `toDataURL()` snapshot and `Image` decode from `shatter`, `pixelShatter`, and
67
+ `glitchReveal` (replaced by live `drawImage(src, ...)`).
68
+
3
69
  ## [1.4.0] - 2026-08-23
4
70
 
5
71
  Determinism, recipe tuning, and stage lane reclaim. One backward-compatible interface
@@ -22,8 +88,8 @@ addition -- a 5th `rng` argument to `recipe.init`; recipes that ignore it are un
22
88
  ### Changed
23
89
  - **`burn` retuned to a 0.2 s flash (B-15).** Defaults `count` 150 -> 100, `duration` 1500 -> 200
24
90
  ms; spawn `size` 2-5 -> 3-8, `decay` 0.01-0.03 -> 0.02-0.05, upward `vy` -1..-4 -> -7..-2. The
25
- additive gravity term `vy += 0.1 * ds` is removed and the cover fade is power2.in (`1 - t*t`)
26
- instead of the cubic `easeIn`. Matches the consuming renderer's burn.
91
+ additive gravity term `vy += 0.1 * ds` is removed and the cover fade is quadratic (`1 - t*t`)
92
+ instead of the cubic `easeIn`. (The quadratic fade was a regression -- see 1.5.0 B-18.)
27
93
  - **`iceBreath` default `count` 300 -> 450 (B-16).**
28
94
 
29
95
  ### Removed
package/README.md CHANGED
@@ -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.4.0'` | Package version string (synced to package.json). |
277
+ | `VERSION` | `'1.5.1'` | 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
@@ -208,6 +208,8 @@ export interface ScratchStage {
208
208
  destroy(): void;
209
209
  /** Slots still unreserved in the shared pool. */
210
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;
211
213
  }
212
214
 
213
215
  /** Create a stage for concurrent reveals over one shared particle pool. */
@@ -231,14 +233,14 @@ export interface ThemeableRecipeOptions extends RecipeOptions {
231
233
 
232
234
  export function BurnRecipe(opts?: ThemeableRecipeOptions): Recipe;
233
235
  export function ShatterRecipe(opts?: RecipeOptions & { gravity?: number }): Recipe;
234
- export function DissolveRecipe(opts?: ThemeableRecipeOptions): Recipe;
236
+ export function DissolveRecipe(opts?: ThemeableRecipeOptions & { fadeSpeed?: number }): Recipe;
235
237
  export function ExplodeRecipe(opts?: ThemeableRecipeOptions & { force?: number }): Recipe;
236
- export function DragonBreathRecipe(opts?: ThemeableRecipeOptions): Recipe;
237
- export function IceBreathRecipe(opts?: ThemeableRecipeOptions): Recipe;
238
- 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;
239
241
  export function LightningCrawlRecipe(opts?: ThemeableRecipeOptions & { boltCount?: number }): Recipe;
240
242
  export function ShineRecipe(opts?: RecipeOptions & { width?: number }): Recipe;
241
- export function PeelRecipe(opts?: RecipeOptions): Recipe;
243
+ export function PeelRecipe(opts?: RecipeOptions & { ease?: (t: number) => number }): Recipe;
242
244
  export function FadeRecipe(opts?: RecipeOptions): Recipe;
243
245
  export function ImplosionRecipe(opts?: RecipeOptions): Recipe;
244
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.4.0';
16
+ export const VERSION = '1.5.1';
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
@@ -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
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.4.0",
4
+ "version": "1.5.1",
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",
@@ -50,7 +50,7 @@ export function BurnRecipe({count = 100, duration = 200, colors, theme} = {}) {
50
50
  const ds = dt * 60;
51
51
  let alive = 0;
52
52
  const progress = clamp(elapsed / duration, 0, 1);
53
- src.style.opacity = 1 - progress * progress;
53
+ src.style.opacity = 1 - easeIn(progress);
54
54
 
55
55
  for (let i = 0; i < max; i++) {
56
56
  if (life[i] <= 0) continue;
@@ -83,7 +83,7 @@ export function BurnRecipe({count = 100, duration = 200, colors, theme} = {}) {
83
83
  }
84
84
 
85
85
 
86
- export function DissolveRecipe({count = 150, duration = 1000, colors, theme} = {}) {
86
+ export function DissolveRecipe({count = 150, duration = 1000, fadeSpeed = 0.3, colors, theme} = {}) {
87
87
  let pSize;
88
88
  const palette = resolvePalette(colors, theme, ['#DC143C']);
89
89
 
@@ -99,7 +99,7 @@ export function DissolveRecipe({count = 150, duration = 1000, colors, theme} = {
99
99
  tick(dt, elapsed, engine, ctx, src, w, h) {
100
100
  const {x, y, vy, life, data, max} = engine;
101
101
  let alive = 0;
102
- src.style.opacity = 1 - clamp(elapsed / (duration * 0.3), 0, 1);
102
+ src.style.opacity = 1 - clamp(elapsed / (duration * fadeSpeed), 0, 1);
103
103
 
104
104
  for (let i = 0; i < max; i++) {
105
105
  if (life[i] <= 0) continue;
@@ -171,7 +171,7 @@ export function ExplodeRecipe({count = 80, duration = 750, force = 15, colors, t
171
171
  }
172
172
 
173
173
 
174
- export function DragonBreathRecipe({count = 200, duration = 1200, colors, theme} = {}) {
174
+ export function DragonBreathRecipe({count = 200, duration = 1200, spread = 0.3, speedMin = 12, speedMax = 25, colors, theme} = {}) {
175
175
  let pSize, pDecay, pColorIdx, r;
176
176
  const palette = resolvePalette(colors, theme, ['#FFF', '#FFD700', '#FF4500', '#8B0000', '#2F2F2F']);
177
177
 
@@ -184,8 +184,8 @@ export function DragonBreathRecipe({count = 200, duration = 1200, colors, theme}
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,7 +203,8 @@ 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;
@@ -239,7 +240,7 @@ export function DragonBreathRecipe({count = 200, duration = 1200, colors, theme}
239
240
  }
240
241
 
241
242
 
242
- export function IceBreathRecipe({count = 450, 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 = 450, 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,10 @@ export function IceBreathRecipe({count = 450, 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
+ const ez = easeIn(fadeT);
279
+ src.style.opacity = 1 - ez;
280
+ src.style.filter = `brightness(${1 + ez})`;
279
281
 
280
282
  for (let i = 0; i < max; i++) {
281
283
  if (life[i] <= 0) continue;
@@ -629,12 +631,12 @@ export function LiquidMeltRecipe({count = 100, duration = 1200, colors, theme} =
629
631
  // ===========================================================================
630
632
  // IMAGE REVEALS
631
633
  // ===========================================================================
632
- // Snapshot the scratch layer via toDataURL() + Image and animate the pixels. Same-origin only.
634
+ // Draw the scratch layer live each frame via drawImage(src); no snapshot, works on any canvas (incl. cross-origin).
633
635
  // ===========================================================================
634
636
 
635
637
  export function ShatterRecipe({count = 30, duration = 1200, gravity = 0.5} = {}) {
636
638
  let pRot, pRotSpd, pSz, pOpacity;
637
- let img = null;
639
+ let hidden = false;
638
640
 
639
641
  return {
640
642
  count,
@@ -652,13 +654,7 @@ export function ShatterRecipe({count = 30, duration = 1200, gravity = 0.5} = {})
652
654
  return {x: spot.x * w, y: spot.y * h, vx: rng.range(-15, 15), vy: rng.range(-10, -2), life: 1.0};
653
655
  },
654
656
  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;
657
+ if (!hidden) { src.style.opacity = '0'; hidden = true; }
662
658
 
663
659
  const {x, y, vx, vy, life, data, max} = engine;
664
660
  const ds = dt * 60;
@@ -682,14 +678,14 @@ export function ShatterRecipe({count = 30, duration = 1200, gravity = 0.5} = {})
682
678
  ctx.globalAlpha = pOpacity[p];
683
679
  ctx.translate(x[i], y[i]);
684
680
  ctx.rotate(pRot[p]);
685
- ctx.drawImage(img, -pSz[p] / 2, -pSz[p] / 2, pSz[p], pSz[p]);
681
+ ctx.drawImage(src, -pSz[p] / 2, -pSz[p] / 2, pSz[p], pSz[p]);
686
682
  ctx.restore();
687
683
  }
688
684
  return alive === 0;
689
685
  },
690
686
  destroy() {
691
687
  pRot = pRotSpd = pSz = pOpacity = null;
692
- img = null;
688
+ hidden = false;
693
689
  },
694
690
  };
695
691
  }
@@ -697,7 +693,7 @@ export function ShatterRecipe({count = 30, duration = 1200, gravity = 0.5} = {})
697
693
 
698
694
  export function PixelShatterRecipe({count = 60, duration = 1200} = {}) {
699
695
  let pRot, pRotSpd, pSz, pOrigX, pOrigY; // <- FIX: Store ORIGINAL positions
700
- let img = null, imgReady = false;
696
+ let hidden = false;
701
697
 
702
698
  return {
703
699
  count,
@@ -721,16 +717,7 @@ export function PixelShatterRecipe({count = 60, duration = 1200} = {}) {
721
717
  return {x: sx, y: sy, vx: Math.cos(angle) * spd, vy: Math.sin(angle) * spd, life: 1.0};
722
718
  },
723
719
  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;
720
+ if (!hidden) { src.style.opacity = '0'; hidden = true; }
734
721
 
735
722
  const {x, y, vx, vy, life, data, max} = engine;
736
723
  const ds = dt * 60;
@@ -757,23 +744,21 @@ export function PixelShatterRecipe({count = 60, duration = 1200} = {}) {
757
744
  ctx.translate(x[i], y[i]);
758
745
  ctx.rotate(pRot[p]);
759
746
  // <- 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);
747
+ ctx.drawImage(src, pOrigX[p], pOrigY[p], sz, sz, -sz / 2, -sz / 2, sz, sz);
761
748
  ctx.restore();
762
749
  }
763
750
  return alive === 0;
764
751
  },
765
752
  destroy() {
766
753
  pRot = pRotSpd = pSz = pOrigX = pOrigY = null;
767
- img = null;
768
- imgReady = false;
754
+ hidden = false;
769
755
  },
770
756
  };
771
757
  }
772
758
 
773
759
 
774
760
  export function GlitchRevealRecipe({duration = 800} = {}) {
775
- let img = null;
776
- let imgReady = false;
761
+ let hidden = false;
777
762
  let sliceSeeds = null; // Pre-generated random values for deterministic slicing
778
763
 
779
764
  return {
@@ -788,17 +773,7 @@ export function GlitchRevealRecipe({duration = 800} = {}) {
788
773
  return {x: 0, y: 0, vx: 0, vy: 0, life: 0};
789
774
  },
790
775
  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;
776
+ if (!hidden) { src.style.opacity = '0'; hidden = true; }
802
777
 
803
778
  const raw = clamp(elapsed / duration, 0, 1);
804
779
 
@@ -817,9 +792,9 @@ export function GlitchRevealRecipe({duration = 800} = {}) {
817
792
 
818
793
  // Red channel
819
794
  ctx.globalCompositeOperation = 'screen';
820
- ctx.drawImage(img, 0, sliceY, w, sliceH, offsetX - rgbShift, sliceY, w, sliceH);
795
+ ctx.drawImage(src, 0, sliceY, w, sliceH, offsetX - rgbShift, sliceY, w, sliceH);
821
796
  // Cyan channel
822
- ctx.drawImage(img, 0, sliceY, w, sliceH, offsetX + rgbShift, sliceY, w, sliceH);
797
+ ctx.drawImage(src, 0, sliceY, w, sliceH, offsetX + rgbShift, sliceY, w, sliceH);
823
798
  }
824
799
 
825
800
  ctx.globalCompositeOperation = 'source-over';
@@ -827,9 +802,8 @@ export function GlitchRevealRecipe({duration = 800} = {}) {
827
802
  return raw >= 1;
828
803
  },
829
804
  destroy() {
830
- img = null;
831
- imgReady = false;
832
805
  sliceSeeds = null;
806
+ hidden = false;
833
807
  },
834
808
  };
835
809
  }
@@ -869,8 +843,9 @@ export function ShineRecipe({duration = 500, width = 250} = {}) {
869
843
  }
870
844
 
871
845
 
872
- export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount = 60, colors, theme} = {}) {
846
+ export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount = 60, ease = easeInOut, colors, theme} = {}) {
873
847
  const palette = resolvePalette(colors, theme, ['#FFF', '#E0E8FF', '#B0C4FF', '#88AAFF']);
848
+ const ez = typeof ease === 'function' ? ease : easeInOut;
874
849
  let py, pvy, pox, psz, pcol, r;
875
850
 
876
851
  return {
@@ -895,7 +870,7 @@ export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount =
895
870
  },
896
871
  tick(dt, elapsed, engine, ctx, src, w, h) {
897
872
  const raw = clamp(elapsed / duration, 0, 1);
898
- const t = easeInOut(raw);
873
+ const t = ez(raw);
899
874
  const ds = dt * 60;
900
875
  const bx = lerp(-beamWidth, w + beamWidth, t);
901
876
  src.style.opacity = t > 0.3 ? 1 - (t - 0.3) / 0.7 : 1;
@@ -1173,8 +1148,9 @@ export function NeonPulseRecipe({duration = 800, colors, theme} = {}) {
1173
1148
  // Drive the scratch layer's own transform/opacity; little or no particle work.
1174
1149
  // ===========================================================================
1175
1150
 
1176
- export function PeelRecipe({duration = 1000} = {}) {
1151
+ export function PeelRecipe({duration = 1000, ease = easeIn} = {}) {
1177
1152
  let peelW = 0;
1153
+ const ez = typeof ease === 'function' ? ease : easeIn;
1178
1154
  return {
1179
1155
  count: 0,
1180
1156
  init(ctx, capacity, w) {
@@ -1185,7 +1161,7 @@ export function PeelRecipe({duration = 1000} = {}) {
1185
1161
  },
1186
1162
  tick(dt, elapsed, engine, ctx, src) {
1187
1163
  const raw = clamp(elapsed / duration, 0, 1);
1188
- const t = easeIn(raw);
1164
+ const t = ez(raw);
1189
1165
  src.style.transformOrigin = 'right center';
1190
1166
  src.style.transform = `perspective(800px) rotateY(${t * 90}deg) rotateX(${t * -20}deg) translateX(${t * peelW}px)`;
1191
1167
  src.style.opacity = 1 - t;
@@ -287,8 +287,27 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
287
287
  engine.destroy();
288
288
  },
289
289
 
290
- /** @internal How many slots are still unreserved (for tests / diagnostics). */
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
+ */
291
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
+ },
292
311
  };
293
312
  }
294
313