@zakkster/lite-scratch-fx 1.3.0 → 1.3.2

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,65 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.3.2] - 2026-08-23
4
+
5
+ Recipe-fidelity pass against the consuming renderer. No API or signature change; the
6
+ default owned-engine path and 60 Hz playback are byte-identical to 1.3.1.
7
+
8
+ ### Changed
9
+ - **Frame-rate-independent recipe physics (B-12).** Every per-tick physics term in the
10
+ nine affected recipes (`burn`, `dragonBreath`, `iceBreath`, `shatter`, `pixelShatter`,
11
+ `cosmicDust`, `confettiBlast`, `liquidMelt`, `shineWave`) now scales with `ds = dt * 60`:
12
+ additive gravity/turbulence/rotation by `* ds`, multiplicative damping by
13
+ `Math.pow(k, ds)`, and the `dragonBreath` size ramp by `Math.pow(1.04, ds)`. Position
14
+ and life were already real-time while damping/gravity/rotation were still per-tick, so a
15
+ trajectory's shape changed with refresh rate -- `iceBreath`'s `*= 0.96` damping retained
16
+ 0.0864 of velocity per second at 60 Hz but 0.0075 at 120 Hz, an 11.6x divergence. At
17
+ `dt = 1/60`, `ds === 1` exactly, so every term collapses to its former constant
18
+ (0-ULP identical, locked by test). `Math.random()` calls are unchanged.
19
+ - **Particle glow is a painted halo instead of `ctx.shadowBlur` (B-13).** `burn`,
20
+ `dragonBreath`, and `laserScan` drew a per-particle Gaussian shadow blur every frame;
21
+ they now paint a second arc at twice the radius with `globalAlpha = life * 0.3` behind
22
+ the crisp core, in the particle's existing composite mode. `laserScan`'s halo uses the
23
+ themed `palette[0]` (previously inherited white).
24
+ - **Shared `{ engine }` capacity derives from `engine.x.length` (B-9).** On the shared-engine
25
+ path the recipe's parallel arrays and the `count` clamp are now sized to the supplied
26
+ engine's lane length rather than `maxParticles`, so an engine larger than `maxParticles`
27
+ can no longer write past the recipe arrays (previously silent out-of-range writes,
28
+ yielding size-0 particles). The owned-engine path is unaffected (`engine.x.length ===
29
+ maxParticles`). JSDoc, `index.d.ts`, and the README option table updated to match.
30
+
31
+ ### Added
32
+ - Offscreen draw cull (B-17): `burn`, `dragonBreath`, `iceBreath`, and `explode` skip the
33
+ draw for a particle once `y < -50`; the particle is still updated and alive (draw-only).
34
+ - Stage: a reveal started from within a controller's `onDone` (which fires during the
35
+ fan-out) is deferred to the next tick via a per-stage `pending` set, so the freshly
36
+ revealed controller is not given a frame in the tick it was created (B-11).
37
+
38
+ ### Removed
39
+ - All `ctx.shadowBlur` / `ctx.shadowColor` use in recipes (replaced by the painted halo).
40
+ - The two redundant `ctx.save()` / `ctx.restore()` pairs in `explode` and `dragonBreath`,
41
+ which set no per-particle transform; state is now set directly.
42
+
43
+ ### Fixed
44
+ - The stage `onTick` fan-out is wrapped in `try/finally` so a throwing `_frame` cannot
45
+ strand the internal `dispatching` flag, which would otherwise silently defer every
46
+ subsequent reveal.
47
+ - The `createScratchStage` fan-out comment no longer contradicts itself (it claimed both to
48
+ avoid a Set copy and to iterate a snapshot); it now states that Set deletion during
49
+ `for...of` is safe and that the real subtlety is `onDone` firing mid-loop.
50
+
51
+ ## [1.3.1] - 2026-08-22
52
+
53
+ ### Fixed
54
+ - **`PeelRecipe`** -- removed a per-frame forced synchronous layout read. `tick`
55
+ previously read `src.offsetWidth` (a layout property) inside the transform
56
+ template string on every frame, immediately after writing `style.transform` /
57
+ `style.transformOrigin`, forcing a synchronous reflow each frame while a peel
58
+ reveal was active. The source layer's logical width is now captured once in
59
+ `init` (the engine already measures it to size the effect canvas and passes it
60
+ as `w`) and reused, so the per-frame `tick` body is write-only. No visual or
61
+ API change.
62
+
3
63
  ## [1.3.0] - 2026-08-22
4
64
 
5
65
  Abortable, self-restoring reveals. A reveal can now be cancelled, and the host scratch
package/README.md CHANGED
@@ -190,7 +190,7 @@ createScratchController(sourceCanvas, effectCanvas, options?): ScratchController
190
190
 
191
191
  | Option | Type | Default | Meaning |
192
192
  | -------------- | ----------------- | ------------- | --------------------------------------------------- |
193
- | `maxParticles` | number | `2000` | Particle pool capacity (ignored when `engine` given) |
193
+ | `maxParticles` | number | `2000` | Sizes the owned pool; when `engine` is given the capacity is taken from `engine.x.length` and this is ignored |
194
194
  | `seed` | number | `Date.now()` | Deterministic RNG seed |
195
195
  | `scanPrecision`| number | `32` | Horizontal resolution of the spawn-point pixel scan |
196
196
  | `driven` | boolean | `false` | Host-driven mode: you call `tick(dt)` each frame |
@@ -261,7 +261,7 @@ resolvePalette(colors, theme, fallback): string[]
261
261
 
262
262
  | Constant | Value | Meaning |
263
263
  | ------------------------- | ----------------------------------------------------------- | ---------------------------------------------------- |
264
- | `VERSION` | `'1.3.0'` | Package version string (synced to package.json). |
264
+ | `VERSION` | `'1.3.2'` | Package version string (synced to package.json). |
265
265
  | `maxParticles` (default) | `2000` | Controller / stage pool capacity. |
266
266
  | `scanPrecision` (default) | `32` | Horizontal resolution of the spawn-point scan. |
267
267
  | stage `capacity` (default)| `300` | Slots a stage controller reserves from the pool. |
package/index.d.ts CHANGED
@@ -85,7 +85,7 @@ export interface Recipe {
85
85
  }
86
86
 
87
87
  export interface ScratchControllerOptions {
88
- /** Particle pool capacity. Default 2000. Ignored when `engine` is supplied. */
88
+ /** Sizes the owned pool. Default 2000. When `engine` is supplied the capacity is taken from `engine.x.length` and this is ignored. */
89
89
  maxParticles?: number;
90
90
  /** Seed for the deterministic RNG. Default `Date.now()`. */
91
91
  seed?: number;
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.0';
16
+ export const VERSION = '1.3.2';
17
17
 
18
18
  export { createScratchController } from './src/ScratchController.js';
19
19
  export { default as ScratchController } from './src/ScratchController.js';
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.0",
4
+ "version": "1.3.2",
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",
@@ -54,7 +54,9 @@ const busySharedEngines = new WeakSet();
54
54
  * @param {HTMLCanvasElement} sourceCanvas The scratch layer being revealed
55
55
  * @param {HTMLCanvasElement} effectCanvas Overlay canvas for VFX
56
56
  * @param {Object} [options]
57
- * @param {number} [options.maxParticles=2000] Pool capacity (ignored when `engine` is given)
57
+ * @param {number} [options.maxParticles=2000] Sizes the owned pool; when `engine` is supplied
58
+ * the capacity is taken from `engine.x.length` and
59
+ * maxParticles is ignored.
58
60
  * @param {number} [options.seed=Date.now()] Deterministic RNG seed
59
61
  * @param {number} [options.scanPrecision=32] Horizontal resolution of the pixel scan
60
62
  * @param {boolean} [options.driven=false] Host-driven: skip start(), call tick(dt)
@@ -175,6 +177,14 @@ export function createScratchController(sourceCanvas, effectCanvas, {
175
177
  }
176
178
  engine.clear();
177
179
 
180
+ // Real pool capacity of the engine we actually write into. The slot index comes
181
+ // from engine._head, which ranges over the ENGINE's capacity, so the recipe arrays
182
+ // and count clamp must be sized to THAT, not maxParticles. For an owned engine
183
+ // (new SoaParticleEngine(maxParticles)) cap === maxParticles -- byte-identical to
184
+ // the old path. For a shared engine larger than maxParticles this stops spawn()
185
+ // from writing past the recipe arrays (size-0 particles, palette[undefined]).
186
+ const cap = engine.x.length;
187
+
178
188
  // Logical (CSS-pixel) size of the scratch layer -- recipes author in these.
179
189
  logicalW = sourceCanvas.offsetWidth || sourceCanvas.width;
180
190
  logicalH = sourceCanvas.offsetHeight || sourceCanvas.height;
@@ -194,13 +204,13 @@ export function createScratchController(sourceCanvas, effectCanvas, {
194
204
  const spots = scanner.scan(scanPrecision);
195
205
 
196
206
  // Init recipe with LOGICAL size (recipes stay in CSS px).
197
- recipe.init(ctx, maxParticles, logicalW, logicalH);
207
+ recipe.init(ctx, cap, logicalW, logicalH);
198
208
 
199
209
  // Populate ring buffer. A recipe may declare `count: 0` (a pure-canvas reveal
200
210
  // like Shine or Implosion that draws no particles); use ?? so an explicit 0 is
201
- // honoured rather than falling back to maxParticles.
202
- const requested = recipe.count ?? maxParticles;
203
- const count = Math.min(requested, maxParticles);
211
+ // honoured rather than falling back to the pool capacity.
212
+ const requested = recipe.count ?? cap;
213
+ const count = Math.min(requested, cap);
204
214
  for (let i = 0; i < count; i++) {
205
215
  const idx = engine._head;
206
216
  if (spots.count > 0) {
@@ -47,6 +47,7 @@ export function BurnRecipe({count = 150, duration = 1500, colors, theme} = {}) {
47
47
  },
48
48
  tick(dt, elapsed, engine, ctx, src, w, h) {
49
49
  const {x, y, vx, vy, life, data, max} = engine;
50
+ const ds = dt * 60;
50
51
  let alive = 0;
51
52
  const progress = clamp(elapsed / duration, 0, 1);
52
53
  src.style.opacity = 1 - easeIn(progress);
@@ -54,22 +55,24 @@ export function BurnRecipe({count = 150, duration = 1500, colors, theme} = {}) {
54
55
  for (let i = 0; i < max; i++) {
55
56
  if (life[i] <= 0) continue;
56
57
  const p = data[i];
57
- vy[i] += 0.1;
58
- x[i] += vx[i] * dt * 60;
59
- y[i] += vy[i] * dt * 60;
60
- life[i] -= pDecay[p] * dt * 60;
58
+ vy[i] += 0.1 * ds;
59
+ x[i] += vx[i] * ds;
60
+ y[i] += vy[i] * ds;
61
+ life[i] -= pDecay[p] * ds;
61
62
  if (life[i] <= 0) continue;
62
63
  alive++;
63
- ctx.globalAlpha = life[i];
64
+ if (y[i] < -50) continue;
64
65
  ctx.globalCompositeOperation = 'lighter';
65
66
  ctx.fillStyle = palette[pColorIdx[p]];
66
- ctx.shadowBlur = 10;
67
- ctx.shadowColor = ctx.fillStyle;
67
+ ctx.globalAlpha = life[i] * 0.3;
68
+ ctx.beginPath();
69
+ ctx.arc(x[i], y[i], pSize[p] * 2, 0, Math.PI * 2);
70
+ ctx.fill();
71
+ ctx.globalAlpha = life[i];
68
72
  ctx.beginPath();
69
73
  ctx.arc(x[i], y[i], pSize[p], 0, Math.PI * 2);
70
74
  ctx.fill();
71
75
  }
72
- ctx.shadowBlur = 0;
73
76
  ctx.globalCompositeOperation = 'source-over';
74
77
  ctx.globalAlpha = 1;
75
78
  return alive === 0 && elapsed >= duration;
@@ -152,14 +155,14 @@ export function ExplodeRecipe({count = 80, duration = 750, force = 15, colors, t
152
155
  life[i] -= 0.02 * dt * 60;
153
156
  if (life[i] <= 0) continue;
154
157
  alive++;
155
- ctx.save();
158
+ if (y[i] < -50) continue;
156
159
  ctx.globalAlpha = life[i];
157
160
  ctx.fillStyle = palette[pColorIdx[p]];
158
161
  ctx.beginPath();
159
162
  ctx.arc(x[i], y[i], pSize[p], 0, Math.PI * 2);
160
163
  ctx.fill();
161
- ctx.restore();
162
164
  }
165
+ ctx.globalAlpha = 1;
163
166
  return alive === 0 && elapsed >= duration;
164
167
  },
165
168
  destroy() {
@@ -196,32 +199,35 @@ export function DragonBreathRecipe({count = 200, duration = 1200, colors, theme}
196
199
  },
197
200
  tick(dt, elapsed, engine, ctx, src, w, h) {
198
201
  const {x, y, vx, vy, life, data, max} = engine;
202
+ const ds = dt * 60;
203
+ const dvy = Math.pow(0.98, ds);
204
+ const dsz = Math.pow(1.04, ds);
199
205
  let alive = 0;
200
206
  src.style.opacity = 1 - clamp(elapsed / (duration * 0.4), 0, 1);
201
207
 
202
208
  for (let i = 0; i < max; i++) {
203
209
  if (life[i] <= 0) continue;
204
210
  const p = data[i];
205
- x[i] += vx[i] * dt * 60;
206
- y[i] += vy[i] * dt * 60;
207
- vx[i] += (Math.random() - 0.5) * 0.5;
208
- vy[i] *= 0.98;
209
- pSize[p] *= 1 + 0.04 * dt * 60;
210
- life[i] -= pDecay[p] * dt * 60;
211
+ x[i] += vx[i] * ds;
212
+ y[i] += vy[i] * ds;
213
+ vx[i] += (Math.random() - 0.5) * 0.5 * ds;
214
+ vy[i] *= dvy;
215
+ pSize[p] *= dsz;
216
+ life[i] -= pDecay[p] * ds;
211
217
  if (life[i] <= 0) continue;
212
218
  alive++;
213
- ctx.save();
214
- ctx.globalAlpha = life[i];
219
+ if (y[i] < -50) continue;
215
220
  ctx.globalCompositeOperation = 'lighter';
216
- ctx.shadowBlur = 15;
217
- ctx.shadowColor = palette[pColorIdx[p]];
218
221
  ctx.fillStyle = palette[pColorIdx[p]];
222
+ ctx.globalAlpha = life[i] * 0.3;
223
+ ctx.beginPath();
224
+ ctx.arc(x[i], y[i], pSize[p] * 2, 0, Math.PI * 2);
225
+ ctx.fill();
226
+ ctx.globalAlpha = life[i];
219
227
  ctx.beginPath();
220
228
  ctx.arc(x[i], y[i], pSize[p], 0, Math.PI * 2);
221
229
  ctx.fill();
222
- ctx.restore();
223
230
  }
224
- ctx.shadowBlur = 0;
225
231
  ctx.globalCompositeOperation = 'source-over';
226
232
  ctx.globalAlpha = 1;
227
233
  return alive === 0 && elapsed >= duration;
@@ -264,6 +270,8 @@ export function IceBreathRecipe({count = 300, duration = 1500, colors, theme} =
264
270
  },
265
271
  tick(dt, elapsed, engine, ctx, src, w, h) {
266
272
  const {x, y, vx, vy, life, data, max} = engine;
273
+ const ds = dt * 60;
274
+ const dv = Math.pow(0.96, ds);
267
275
  let alive = 0;
268
276
  const t = clamp(elapsed / (duration * 0.4), 0, 1);
269
277
  src.style.opacity = 1 - t;
@@ -272,14 +280,15 @@ export function IceBreathRecipe({count = 300, duration = 1500, colors, theme} =
272
280
  for (let i = 0; i < max; i++) {
273
281
  if (life[i] <= 0) continue;
274
282
  const p = data[i];
275
- x[i] += vx[i] * dt * 60;
276
- y[i] += vy[i] * dt * 60;
277
- vx[i] *= 0.96;
278
- vy[i] *= 0.96;
279
- pRot[p] += pRotSpd[p];
280
- life[i] -= pDecay[p] * dt * 60;
283
+ x[i] += vx[i] * ds;
284
+ y[i] += vy[i] * ds;
285
+ vx[i] *= dv;
286
+ vy[i] *= dv;
287
+ pRot[p] += pRotSpd[p] * ds;
288
+ life[i] -= pDecay[p] * ds;
281
289
  if (life[i] <= 0) continue;
282
290
  alive++;
291
+ if (y[i] < -50) continue;
283
292
  ctx.save();
284
293
  ctx.globalAlpha = life[i];
285
294
  ctx.globalCompositeOperation = 'screen';
@@ -403,7 +412,7 @@ export function ConfettiBlastRecipe({count = 80, duration = 1500, colors, theme}
403
412
  if (life[i] <= 0) continue;
404
413
  const p = data[i];
405
414
 
406
- vy[i] += 0.5; // Gravity
415
+ vy[i] += 0.5 * ds; // Gravity
407
416
  x[i] += vx[i] * ds;
408
417
  y[i] += vy[i] * ds;
409
418
  pWobblePhase[p] += pWobbleSpeed[p] * ds;
@@ -451,6 +460,7 @@ export function CosmicDustRecipe({count = 200, duration = 1800, colors, theme} =
451
460
  tick(dt, elapsed, engine, ctx, src, w, h) {
452
461
  const {x, y, life, data, max} = engine;
453
462
  const ds = dt * 60;
463
+ const dr = Math.pow(0.95, ds);
454
464
  let alive = 0;
455
465
  const cx = w / 2, cy = h / 2;
456
466
 
@@ -461,7 +471,7 @@ export function CosmicDustRecipe({count = 200, duration = 1800, colors, theme} =
461
471
  if (life[i] <= 0) continue;
462
472
  const p = data[i];
463
473
 
464
- pRadius[p] *= 0.95; // Spiral inward
474
+ pRadius[p] *= dr; // Spiral inward
465
475
  pAngle[p] += 0.2 * ds; // Spin
466
476
  x[i] = cx + Math.cos(pAngle[p]) * pRadius[p];
467
477
  y[i] = cy + Math.sin(pAngle[p]) * pRadius[p];
@@ -584,7 +594,7 @@ export function LiquidMeltRecipe({count = 100, duration = 1200, colors, theme} =
584
594
  if (life[i] <= 0) continue;
585
595
  const p = data[i];
586
596
 
587
- vy[i] += 0.4; // Heavy gravity
597
+ vy[i] += 0.4 * ds; // Heavy gravity
588
598
  y[i] += vy[i] * ds;
589
599
  life[i] -= 0.015 * ds;
590
600
  if (life[i] <= 0 || y[i] > h + 30) {
@@ -651,16 +661,17 @@ export function ShatterRecipe({count = 30, duration = 1200, gravity = 0.5} = {})
651
661
  if (!img.complete) return false;
652
662
 
653
663
  const {x, y, vx, vy, life, data, max} = engine;
664
+ const ds = dt * 60;
654
665
  const progress = clamp(elapsed / duration, 0, 1);
655
666
  let alive = 0;
656
667
 
657
668
  for (let i = 0; i < max; i++) {
658
669
  if (life[i] <= 0) continue;
659
670
  const p = data[i];
660
- x[i] += vx[i] * dt * 60;
661
- y[i] += vy[i] * dt * 60;
662
- vy[i] += gravity;
663
- pRot[p] += pRotSpd[p];
671
+ x[i] += vx[i] * ds;
672
+ y[i] += vy[i] * ds;
673
+ vy[i] += gravity * ds;
674
+ pRot[p] += pRotSpd[p] * ds;
664
675
  pOpacity[p] = 1 - progress;
665
676
  if (pOpacity[p] <= 0.01) {
666
677
  life[i] = 0;
@@ -723,6 +734,7 @@ export function PixelShatterRecipe({count = 60, duration = 1200} = {}) {
723
734
 
724
735
  const {x, y, vx, vy, life, data, max} = engine;
725
736
  const ds = dt * 60;
737
+ const dv = Math.pow(0.95, ds);
726
738
  const progress = clamp(elapsed / duration, 0, 1);
727
739
  let alive = 0;
728
740
 
@@ -732,9 +744,9 @@ export function PixelShatterRecipe({count = 60, duration = 1200} = {}) {
732
744
  const p = data[i];
733
745
  x[i] += vx[i] * ds;
734
746
  y[i] += vy[i] * ds;
735
- vx[i] *= 0.95;
736
- vy[i] *= 0.95; // Drag
737
- pRot[p] += pRotSpd[p];
747
+ vx[i] *= dv;
748
+ vy[i] *= dv; // Drag
749
+ pRot[p] += pRotSpd[p] * ds;
738
750
  life[i] -= 0.02 * ds;
739
751
  if (life[i] <= 0) continue;
740
752
  alive++;
@@ -883,6 +895,7 @@ export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount =
883
895
  tick(dt, elapsed, engine, ctx, src, w, h) {
884
896
  const raw = clamp(elapsed / duration, 0, 1);
885
897
  const t = easeInOut(raw);
898
+ const ds = dt * 60;
886
899
  const bx = lerp(-beamWidth, w + beamWidth, t);
887
900
  src.style.opacity = t > 0.3 ? 1 - (t - 0.3) / 0.7 : 1;
888
901
 
@@ -902,11 +915,11 @@ export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount =
902
915
  ctx.globalCompositeOperation = 'lighter';
903
916
  for (let i = 0; i < particleCount; i++) {
904
917
  const px2 = bx + pox[i];
905
- py[i] += pvy[i];
906
- pvy[i] += (Math.random() - 0.5) * 0.3;
918
+ py[i] += pvy[i] * ds;
919
+ pvy[i] += (Math.random() - 0.5) * 0.3 * ds;
907
920
  if (py[i] < -10) py[i] = h + 10;
908
921
  if (py[i] > h + 10) py[i] = -10;
909
- psz[i] = clamp(psz[i] + (Math.random() - 0.5) * 0.3, 1, 8);
922
+ psz[i] = clamp(psz[i] + (Math.random() - 0.5) * 0.3 * ds, 1, 8);
910
923
  ctx.globalAlpha = 0.7;
911
924
  ctx.fillStyle = palette[pcol[i]];
912
925
  ctx.save();
@@ -955,20 +968,26 @@ export function LaserScanRecipe({duration = 1000, colors, theme} = {}) {
955
968
  ctx.fillStyle = '#ffffff';
956
969
  ctx.fillRect(0, scanY - 1, w, 2);
957
970
 
958
- // Glow
959
- ctx.shadowBlur = 15;
960
- ctx.shadowColor = palette[0];
961
-
962
- // Sparks (deterministic positions)
971
+ // Sparks (deterministic positions) with a painted halo
963
972
  for (let i = 0; i < 5; i++) {
973
+ const sx = sparkSeeds[i * 2] * w;
974
+ const sy = scanY - sparkSeeds[i * 2 + 1] * 20;
975
+ const sr = sparkSeeds[i * 2] * 2;
976
+ // Themed glow halo (was shadowColor = palette[0]) behind a white core
977
+ ctx.fillStyle = palette[0];
978
+ ctx.globalAlpha = 0.3;
979
+ ctx.beginPath();
980
+ ctx.arc(sx, sy, sr * 2, 0, Math.PI * 2);
981
+ ctx.fill();
982
+ ctx.fillStyle = '#ffffff';
983
+ ctx.globalAlpha = 1;
964
984
  ctx.beginPath();
965
- ctx.arc(sparkSeeds[i * 2] * w, scanY - sparkSeeds[i * 2 + 1] * 20, sparkSeeds[i * 2] * 2, 0, Math.PI * 2);
985
+ ctx.arc(sx, sy, sr, 0, Math.PI * 2);
966
986
  ctx.fill();
967
987
  }
968
988
 
969
- // <- FIX: Reset shadow + composite every frame
970
- ctx.shadowBlur = 0;
971
- ctx.shadowColor = 'transparent';
989
+ // <- FIX: Reset alpha + composite every frame
990
+ ctx.globalAlpha = 1;
972
991
  ctx.globalCompositeOperation = 'source-over';
973
992
  }
974
993
 
@@ -1151,9 +1170,11 @@ export function NeonPulseRecipe({duration = 800, colors, theme} = {}) {
1151
1170
  // ===========================================================================
1152
1171
 
1153
1172
  export function PeelRecipe({duration = 1000} = {}) {
1173
+ let peelW = 0;
1154
1174
  return {
1155
1175
  count: 0,
1156
- init() {
1176
+ init(ctx, capacity, w) {
1177
+ peelW = w > 0 ? w : 0;
1157
1178
  },
1158
1179
  spawn() {
1159
1180
  return {x: 0, y: 0, vx: 0, vy: 0, life: 0};
@@ -1162,7 +1183,7 @@ export function PeelRecipe({duration = 1000} = {}) {
1162
1183
  const raw = clamp(elapsed / duration, 0, 1);
1163
1184
  const t = easeIn(raw);
1164
1185
  src.style.transformOrigin = 'right center';
1165
- src.style.transform = `perspective(800px) rotateY(${t * 90}deg) rotateX(${t * -20}deg) translateX(${t * src.offsetWidth}px)`;
1186
+ src.style.transform = `perspective(800px) rotateY(${t * 90}deg) rotateX(${t * -20}deg) translateX(${t * peelW}px)`;
1166
1187
  src.style.opacity = 1 - t;
1167
1188
  if (raw >= 1) {
1168
1189
  src.style.transform = '';
@@ -37,17 +37,37 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
37
37
  }
38
38
  const engine = new SoaParticleEngine(maxParticles);
39
39
  const active = new Set();
40
+ // Adds that arrive DURING a fan-out (a reveal started from another controller's onDone)
41
+ // are parked here and merged after the loop, so the freshly revealed controller is not
42
+ // visited in the frame it was born. Reused for the life of the stage (created once,
43
+ // cleared -- never re-created), so the per-frame path allocates nothing. Per-stage, not
44
+ // module scope: a reveal on a DIFFERENT stage during this dispatch must land in ITS OWN
45
+ // active set. tick() is synchronous, so only one stage is ever mid-dispatch.
46
+ const pending = new Set();
47
+ let dispatching = false;
40
48
  let nextStart = 0;
41
49
  let controllerCount = 0;
42
50
  let destroyed = false;
43
51
 
44
52
  // One render dispatch for the whole pool. Raw tick(dt) hands us the lanes and `max`;
45
53
  // we ignore them (each controller holds stable subarray views of its own sub-range) and
46
- // fan out to every active reveal. A Set copy is avoided: a controller that completes
47
- // removes itself, so iterate a snapshot to stay safe against mid-iteration deletion.
54
+ // fan out to every active reveal. Deleting the current or a later entry during a
55
+ // for...of over a Set is already safe, so no snapshot copy is needed for that. The real
56
+ // subtlety is that endReveal fires onDone DURING this loop, so a reveal started from a
57
+ // controller's onDone would otherwise be visited in the SAME frame -- `dispatching`
58
+ // parks such adds in `pending` and merges them once the loop is done.
48
59
  engine.onTick((dt) => {
49
60
  if (destroyed || active.size === 0) return;
50
- for (const c of active) c._frame(dt);
61
+ dispatching = true;
62
+ // finally: a throwing _frame must not strand `dispatching` true -- it routes every
63
+ // future reveal into `pending`, so leaving it set would silently defer them forever.
64
+ try {
65
+ for (const c of active) c._frame(dt);
66
+ } finally {
67
+ for (const c of pending) active.add(c);
68
+ pending.clear();
69
+ dispatching = false;
70
+ }
51
71
  });
52
72
 
53
73
  /**
@@ -120,6 +140,10 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
120
140
  if (hostSnapshot) { restoreHostStyle(sourceCanvas, hostSnapshot); hostSnapshot = null; }
121
141
  ctx.clearRect(0, 0, cw, ch);
122
142
  active.delete(controller);
143
+ // Also drop from pending: if this controller was revealed earlier in THIS same
144
+ // dispatch (parked in pending) and now completes, the post-loop merge must not
145
+ // resurrect it into active.
146
+ pending.delete(controller);
123
147
  const done = onComplete;
124
148
  activeRecipe = null;
125
149
  onComplete = null;
@@ -171,7 +195,10 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
171
195
  engine.data[g] = i; // local index -> the recipe reads data[i] === i in its window
172
196
  }
173
197
  pView.max = count;
174
- active.add(controller);
198
+ // A reveal started OUTSIDE a dispatch goes straight to active and runs the
199
+ // same frame. A reveal started DURING a dispatch (from another controller's
200
+ // onDone) is parked; the post-loop merge adds it, so it starts next frame.
201
+ (dispatching ? pending : active).add(controller);
175
202
  },
176
203
 
177
204
  /** @internal Called by the stage each frame. */
@@ -224,6 +251,7 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
224
251
  if (destroyed) return;
225
252
  destroyed = true;
226
253
  active.clear();
254
+ pending.clear();
227
255
  engine.destroy();
228
256
  },
229
257