@zakkster/lite-scratch-fx 1.3.1 → 1.4.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,83 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.4.0] - 2026-08-23
4
+
5
+ Determinism, recipe tuning, and stage lane reclaim. One backward-compatible interface
6
+ addition -- a 5th `rng` argument to `recipe.init`; recipes that ignore it are unaffected.
7
+
8
+ ### Added
9
+ - **Seeded RNG passed to `recipe.init` (B-14).** `recipe.init(ctx, capacity, w, h, rng)` now
10
+ receives the controller's or stage's seeded `Random` as an additive 5th argument.
11
+ `dragonBreath`, `shineWave`, and `lightningCrawl` drew from `Math.random()` in `init`/`tick`,
12
+ breaking reproducibility; they now draw from `rng`, so two reveals at the same seed produce
13
+ byte-identical lanes. Recipes with a shorter `init` signature ignore the argument.
14
+ - **Stage sub-range reclaim (B-8).** `createScratchStage` keeps a free list of released lane
15
+ ranges; `controller.destroy()` returns its `[start, capacity)` range and `createController`
16
+ reuses an exact-capacity match before extending the pool, so a grid rebuilt after destroying
17
+ all controllers no longer throws `pool exhausted`. `remainingCapacity` counts the free list.
18
+ `cancel()` and completion do not reclaim -- the range stays reserved and the controller is
19
+ revealable. The per-frame tick path is byte-identical (the free list is touched only in
20
+ `createController`/`destroy`).
21
+
22
+ ### Changed
23
+ - **`burn` retuned to a 0.2 s flash (B-15).** Defaults `count` 150 -> 100, `duration` 1500 -> 200
24
+ 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.
27
+ - **`iceBreath` default `count` 300 -> 450 (B-16).**
28
+
29
+ ### Removed
30
+ - `Math.random()` from `dragonBreath`, `shineWave`, and `lightningCrawl` (replaced by the
31
+ seeded `rng`).
32
+
33
+ ## [1.3.2] - 2026-08-23
34
+
35
+ Recipe-fidelity pass against the consuming renderer. No API or signature change; the
36
+ default owned-engine path and 60 Hz playback are byte-identical to 1.3.1.
37
+
38
+ ### Changed
39
+ - **Frame-rate-independent recipe physics (B-12).** Every per-tick physics term in the
40
+ nine affected recipes (`burn`, `dragonBreath`, `iceBreath`, `shatter`, `pixelShatter`,
41
+ `cosmicDust`, `confettiBlast`, `liquidMelt`, `shineWave`) now scales with `ds = dt * 60`:
42
+ additive gravity/turbulence/rotation by `* ds`, multiplicative damping by
43
+ `Math.pow(k, ds)`, and the `dragonBreath` size ramp by `Math.pow(1.04, ds)`. Position
44
+ and life were already real-time while damping/gravity/rotation were still per-tick, so a
45
+ trajectory's shape changed with refresh rate -- `iceBreath`'s `*= 0.96` damping retained
46
+ 0.0864 of velocity per second at 60 Hz but 0.0075 at 120 Hz, an 11.6x divergence. At
47
+ `dt = 1/60`, `ds === 1` exactly, so every term collapses to its former constant
48
+ (0-ULP identical, locked by test). `Math.random()` calls are unchanged.
49
+ - **Particle glow is a painted halo instead of `ctx.shadowBlur` (B-13).** `burn`,
50
+ `dragonBreath`, and `laserScan` drew a per-particle Gaussian shadow blur every frame;
51
+ they now paint a second arc at twice the radius with `globalAlpha = life * 0.3` behind
52
+ the crisp core, in the particle's existing composite mode. `laserScan`'s halo uses the
53
+ themed `palette[0]` (previously inherited white).
54
+ - **Shared `{ engine }` capacity derives from `engine.x.length` (B-9).** On the shared-engine
55
+ path the recipe's parallel arrays and the `count` clamp are now sized to the supplied
56
+ engine's lane length rather than `maxParticles`, so an engine larger than `maxParticles`
57
+ can no longer write past the recipe arrays (previously silent out-of-range writes,
58
+ yielding size-0 particles). The owned-engine path is unaffected (`engine.x.length ===
59
+ maxParticles`). JSDoc, `index.d.ts`, and the README option table updated to match.
60
+
61
+ ### Added
62
+ - Offscreen draw cull (B-17): `burn`, `dragonBreath`, `iceBreath`, and `explode` skip the
63
+ draw for a particle once `y < -50`; the particle is still updated and alive (draw-only).
64
+ - Stage: a reveal started from within a controller's `onDone` (which fires during the
65
+ fan-out) is deferred to the next tick via a per-stage `pending` set, so the freshly
66
+ revealed controller is not given a frame in the tick it was created (B-11).
67
+
68
+ ### Removed
69
+ - All `ctx.shadowBlur` / `ctx.shadowColor` use in recipes (replaced by the painted halo).
70
+ - The two redundant `ctx.save()` / `ctx.restore()` pairs in `explode` and `dragonBreath`,
71
+ which set no per-particle transform; state is now set directly.
72
+
73
+ ### Fixed
74
+ - The stage `onTick` fan-out is wrapped in `try/finally` so a throwing `_frame` cannot
75
+ strand the internal `dispatching` flag, which would otherwise silently defer every
76
+ subsequent reveal.
77
+ - The `createScratchStage` fan-out comment no longer contradicts itself (it claimed both to
78
+ avoid a Set copy and to iterate a snapshot); it now states that Set deletion during
79
+ `for...of` is safe and that the real subtlety is `onDone` firing mid-loop.
80
+
3
81
  ## [1.3.1] - 2026-08-22
4
82
 
5
83
  ### Fixed
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
@@ -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.4.0'` | 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
@@ -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(
@@ -85,7 +86,7 @@ export interface Recipe {
85
86
  }
86
87
 
87
88
  export interface ScratchControllerOptions {
88
- /** Particle pool capacity. Default 2000. Ignored when `engine` is supplied. */
89
+ /** Sizes the owned pool. Default 2000. When `engine` is supplied the capacity is taken from `engine.x.length` and this is ignored. */
89
90
  maxParticles?: number;
90
91
  /** Seed for the deterministic RNG. Default `Date.now()`. */
91
92
  seed?: number;
@@ -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
 
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.1';
16
+ export const VERSION = '1.4.0';
17
17
 
18
18
  export { createScratchController } from './src/ScratchController.js';
19
19
  export { default as ScratchController } from './src/ScratchController.js';
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(),
@@ -116,6 +116,8 @@ start+capacity) of the lanes. reserving past maxParticles throws. Each controlle
116
116
  into its slots (data[start+i]=i), renders through subarray views of its range (built once per
117
117
  controller, reused every frame -> zero per-frame alloc), draws to its own ctx, keeps its own RNG.
118
118
  stage.tick(dt) advances the single engine once; the one onTick fans out to every active reveal.
119
+ controller.destroy() reclaims its sub-range into a stage free-list for exact-capacity reuse by a
120
+ later createController; cancel/completion hold the range (the controller stays revealable).
119
121
  Feasible with no engine change because raw tick(dt) is pure dispatch (no physics/culling) and the
120
122
  lanes are public typed arrays recipes already write. See decisions/0001-concurrent-shared-reveals.md.
121
123
 
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.1",
4
+ "version": "1.4.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",
@@ -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, rng);
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) {
@@ -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,36 +40,38 @@ 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;
50
+ const ds = dt * 60;
50
51
  let alive = 0;
51
52
  const progress = clamp(elapsed / duration, 0, 1);
52
- src.style.opacity = 1 - easeIn(progress);
53
+ src.style.opacity = 1 - progress * progress;
53
54
 
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
+ x[i] += vx[i] * ds;
59
+ y[i] += vy[i] * ds;
60
+ life[i] -= pDecay[p] * ds;
61
61
  if (life[i] <= 0) continue;
62
62
  alive++;
63
- ctx.globalAlpha = life[i];
63
+ if (y[i] < -50) continue;
64
64
  ctx.globalCompositeOperation = 'lighter';
65
65
  ctx.fillStyle = palette[pColorIdx[p]];
66
- ctx.shadowBlur = 10;
67
- ctx.shadowColor = ctx.fillStyle;
66
+ ctx.globalAlpha = life[i] * 0.3;
67
+ ctx.beginPath();
68
+ ctx.arc(x[i], y[i], pSize[p] * 2, 0, Math.PI * 2);
69
+ ctx.fill();
70
+ ctx.globalAlpha = life[i];
68
71
  ctx.beginPath();
69
72
  ctx.arc(x[i], y[i], pSize[p], 0, Math.PI * 2);
70
73
  ctx.fill();
71
74
  }
72
- ctx.shadowBlur = 0;
73
75
  ctx.globalCompositeOperation = 'source-over';
74
76
  ctx.globalAlpha = 1;
75
77
  return alive === 0 && elapsed >= duration;
@@ -152,14 +154,14 @@ export function ExplodeRecipe({count = 80, duration = 750, force = 15, colors, t
152
154
  life[i] -= 0.02 * dt * 60;
153
155
  if (life[i] <= 0) continue;
154
156
  alive++;
155
- ctx.save();
157
+ if (y[i] < -50) continue;
156
158
  ctx.globalAlpha = life[i];
157
159
  ctx.fillStyle = palette[pColorIdx[p]];
158
160
  ctx.beginPath();
159
161
  ctx.arc(x[i], y[i], pSize[p], 0, Math.PI * 2);
160
162
  ctx.fill();
161
- ctx.restore();
162
163
  }
164
+ ctx.globalAlpha = 1;
163
165
  return alive === 0 && elapsed >= duration;
164
166
  },
165
167
  destroy() {
@@ -170,12 +172,13 @@ export function ExplodeRecipe({count = 80, duration = 750, force = 15, colors, t
170
172
 
171
173
 
172
174
  export function DragonBreathRecipe({count = 200, duration = 1200, colors, theme} = {}) {
173
- let pSize, pDecay, pColorIdx;
175
+ let pSize, pDecay, pColorIdx, r;
174
176
  const palette = resolvePalette(colors, theme, ['#FFF', '#FFD700', '#FF4500', '#8B0000', '#2F2F2F']);
175
177
 
176
178
  return {
177
179
  count,
178
- init(ctx, capacity) {
180
+ init(ctx, capacity, w, h, rng) {
181
+ r = rng;
179
182
  pSize = new Float32Array(capacity);
180
183
  pDecay = new Float32Array(capacity);
181
184
  pColorIdx = new Uint8Array(capacity);
@@ -196,44 +199,47 @@ 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] += (r.next() - 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;
228
234
  },
229
235
  destroy() {
230
- pSize = pDecay = pColorIdx = null;
236
+ pSize = pDecay = pColorIdx = r = null;
231
237
  },
232
238
  };
233
239
  }
234
240
 
235
241
 
236
- export function IceBreathRecipe({count = 300, duration = 1500, colors, theme} = {}) {
242
+ export function IceBreathRecipe({count = 450, duration = 1500, colors, theme} = {}) {
237
243
  let pSize, pDecay, pRot, pRotSpd, pColorIdx;
238
244
  const palette = resolvePalette(colors, theme, ['#FFF', '#E0FFFF', '#00FFFF', '#1E90FF', '#4682B4']);
239
245
 
@@ -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++;
@@ -859,22 +871,23 @@ export function ShineRecipe({duration = 500, width = 250} = {}) {
859
871
 
860
872
  export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount = 60, colors, theme} = {}) {
861
873
  const palette = resolvePalette(colors, theme, ['#FFF', '#E0E8FF', '#B0C4FF', '#88AAFF']);
862
- let py, pvy, pox, psz, pcol;
874
+ let py, pvy, pox, psz, pcol, r;
863
875
 
864
876
  return {
865
877
  count: 0, // No SoA particles -- managed internally
866
- init(ctx, capacity, w, h) {
878
+ init(ctx, capacity, w, h, rng) {
879
+ r = rng;
867
880
  py = new Float32Array(particleCount);
868
881
  pvy = new Float32Array(particleCount);
869
882
  pox = new Float32Array(particleCount);
870
883
  psz = new Float32Array(particleCount);
871
884
  pcol = new Uint8Array(particleCount);
872
885
  for (let i = 0; i < particleCount; i++) {
873
- py[i] = Math.random() * h;
874
- pvy[i] = (Math.random() - 0.5) * 2;
875
- pox[i] = (Math.random() - 0.5) * beamWidth * 0.6;
876
- psz[i] = Math.random() * 4 + 2;
877
- pcol[i] = (Math.random() * palette.length) | 0;
886
+ py[i] = r.next() * h;
887
+ pvy[i] = (r.next() - 0.5) * 2;
888
+ pox[i] = (r.next() - 0.5) * beamWidth * 0.6;
889
+ psz[i] = r.next() * 4 + 2;
890
+ pcol[i] = (r.next() * palette.length) | 0;
878
891
  }
879
892
  },
880
893
  spawn() {
@@ -883,6 +896,7 @@ export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount =
883
896
  tick(dt, elapsed, engine, ctx, src, w, h) {
884
897
  const raw = clamp(elapsed / duration, 0, 1);
885
898
  const t = easeInOut(raw);
899
+ const ds = dt * 60;
886
900
  const bx = lerp(-beamWidth, w + beamWidth, t);
887
901
  src.style.opacity = t > 0.3 ? 1 - (t - 0.3) / 0.7 : 1;
888
902
 
@@ -902,11 +916,11 @@ export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount =
902
916
  ctx.globalCompositeOperation = 'lighter';
903
917
  for (let i = 0; i < particleCount; i++) {
904
918
  const px2 = bx + pox[i];
905
- py[i] += pvy[i];
906
- pvy[i] += (Math.random() - 0.5) * 0.3;
919
+ py[i] += pvy[i] * ds;
920
+ pvy[i] += (r.next() - 0.5) * 0.3 * ds;
907
921
  if (py[i] < -10) py[i] = h + 10;
908
922
  if (py[i] > h + 10) py[i] = -10;
909
- psz[i] = clamp(psz[i] + (Math.random() - 0.5) * 0.3, 1, 8);
923
+ psz[i] = clamp(psz[i] + (r.next() - 0.5) * 0.3 * ds, 1, 8);
910
924
  ctx.globalAlpha = 0.7;
911
925
  ctx.fillStyle = palette[pcol[i]];
912
926
  ctx.save();
@@ -919,7 +933,7 @@ export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount =
919
933
  return raw >= 1;
920
934
  },
921
935
  destroy() {
922
- py = pvy = pox = psz = pcol = null;
936
+ py = pvy = pox = psz = pcol = r = null;
923
937
  },
924
938
  };
925
939
  }
@@ -955,20 +969,26 @@ export function LaserScanRecipe({duration = 1000, colors, theme} = {}) {
955
969
  ctx.fillStyle = '#ffffff';
956
970
  ctx.fillRect(0, scanY - 1, w, 2);
957
971
 
958
- // Glow
959
- ctx.shadowBlur = 15;
960
- ctx.shadowColor = palette[0];
961
-
962
- // Sparks (deterministic positions)
972
+ // Sparks (deterministic positions) with a painted halo
963
973
  for (let i = 0; i < 5; i++) {
974
+ const sx = sparkSeeds[i * 2] * w;
975
+ const sy = scanY - sparkSeeds[i * 2 + 1] * 20;
976
+ const sr = sparkSeeds[i * 2] * 2;
977
+ // Themed glow halo (was shadowColor = palette[0]) behind a white core
978
+ ctx.fillStyle = palette[0];
979
+ ctx.globalAlpha = 0.3;
964
980
  ctx.beginPath();
965
- ctx.arc(sparkSeeds[i * 2] * w, scanY - sparkSeeds[i * 2 + 1] * 20, sparkSeeds[i * 2] * 2, 0, Math.PI * 2);
981
+ ctx.arc(sx, sy, sr * 2, 0, Math.PI * 2);
982
+ ctx.fill();
983
+ ctx.fillStyle = '#ffffff';
984
+ ctx.globalAlpha = 1;
985
+ ctx.beginPath();
986
+ ctx.arc(sx, sy, sr, 0, Math.PI * 2);
966
987
  ctx.fill();
967
988
  }
968
989
 
969
- // <- FIX: Reset shadow + composite every frame
970
- ctx.shadowBlur = 0;
971
- ctx.shadowColor = 'transparent';
990
+ // <- FIX: Reset alpha + composite every frame
991
+ ctx.globalAlpha = 1;
972
992
  ctx.globalCompositeOperation = 'source-over';
973
993
  }
974
994
 
@@ -994,6 +1014,7 @@ export function LightningCrawlRecipe({boltCount = 5, duration = 600, colors, the
994
1014
  const cMid = palette[(palette.length - 1) >> 1];
995
1015
  const cOuter = palette[palette.length - 1];
996
1016
  let bolts = [];
1017
+ let r;
997
1018
 
998
1019
  function genPts(x1, y1, x2, y2, d) {
999
1020
  let p = [{x: x1, y: y1}, {x: x2, y: y2}];
@@ -1003,7 +1024,7 @@ export function LightningCrawlRecipe({boltCount = 5, duration = 600, colors, the
1003
1024
  const a = p[j], b = p[j + 1];
1004
1025
  const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
1005
1026
  const dist = Math.hypot(b.x - a.x, b.y - a.y);
1006
- const off = (Math.random() - 0.5) * dist * 0.4;
1027
+ const off = (r.next() - 0.5) * dist * 0.4;
1007
1028
  const nx = -(b.y - a.y) / (dist || 1), ny = (b.x - a.x) / (dist || 1);
1008
1029
  np.push({x: mx + nx * off, y: my + ny * off});
1009
1030
  np.push(b);
@@ -1026,39 +1047,40 @@ export function LightningCrawlRecipe({boltCount = 5, duration = 600, colors, the
1026
1047
  ctx.stroke();
1027
1048
  });
1028
1049
  if (d > 2) for (let i = 2; i < pts.length - 1; i += 3) {
1029
- if (Math.random() > 0.5) continue;
1030
- const bl = Math.hypot(x2 - x1, y2 - y1) * (0.15 + Math.random() * 0.2);
1031
- const ba = Math.atan2(y2 - y1, x2 - x1) + (Math.random() - 0.5) * 1.5;
1050
+ if (r.next() > 0.5) continue;
1051
+ const bl = Math.hypot(x2 - x1, y2 - y1) * (0.15 + r.next() * 0.2);
1052
+ const ba = Math.atan2(y2 - y1, x2 - x1) + (r.next() - 0.5) * 1.5;
1032
1053
  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);
1033
1054
  }
1034
1055
  }
1035
1056
 
1036
1057
  return {
1037
1058
  count: 0,
1038
- init(ctx, capacity, w, h) {
1059
+ init(ctx, capacity, w, h, rng) {
1060
+ r = rng;
1039
1061
  bolts = [];
1040
1062
  for (let b = 0; b < boltCount; b++) {
1041
- const e = (Math.random() * 4) | 0;
1063
+ const e = (r.next() * 4) | 0;
1042
1064
  let sx, sy;
1043
1065
  if (e === 0) {
1044
1066
  sx = 0;
1045
- sy = Math.random() * h;
1067
+ sy = r.next() * h;
1046
1068
  } else if (e === 1) {
1047
1069
  sx = w;
1048
- sy = Math.random() * h;
1070
+ sy = r.next() * h;
1049
1071
  } else if (e === 2) {
1050
- sx = Math.random() * w;
1072
+ sx = r.next() * w;
1051
1073
  sy = 0;
1052
1074
  } else {
1053
- sx = Math.random() * w;
1075
+ sx = r.next() * w;
1054
1076
  sy = h;
1055
1077
  }
1056
1078
  bolts.push({
1057
1079
  sx,
1058
1080
  sy,
1059
- ex: w * (0.3 + Math.random() * 0.4),
1060
- ey: h * (0.3 + Math.random() * 0.4),
1061
- delay: Math.random() * 0.3
1081
+ ex: w * (0.3 + r.next() * 0.4),
1082
+ ey: h * (0.3 + r.next() * 0.4),
1083
+ delay: r.next() * 0.3
1062
1084
  });
1063
1085
  }
1064
1086
  },
@@ -1090,6 +1112,7 @@ export function LightningCrawlRecipe({boltCount = 5, duration = 600, colors, the
1090
1112
  },
1091
1113
  destroy() {
1092
1114
  bolts = [];
1115
+ r = null;
1093
1116
  },
1094
1117
  };
1095
1118
  }
@@ -37,17 +37,43 @@ 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;
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;
41
55
  let controllerCount = 0;
42
56
  let destroyed = false;
43
57
 
44
58
  // One render dispatch for the whole pool. Raw tick(dt) hands us the lanes and `max`;
45
59
  // 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.
60
+ // fan out to every active reveal. Deleting the current or a later entry during a
61
+ // for...of over a Set is already safe, so no snapshot copy is needed for that. The real
62
+ // subtlety is that endReveal fires onDone DURING this loop, so a reveal started from a
63
+ // controller's onDone would otherwise be visited in the SAME frame -- `dispatching`
64
+ // parks such adds in `pending` and merges them once the loop is done.
48
65
  engine.onTick((dt) => {
49
66
  if (destroyed || active.size === 0) return;
50
- for (const c of active) c._frame(dt);
67
+ dispatching = true;
68
+ // finally: a throwing _frame must not strand `dispatching` true -- it routes every
69
+ // future reveal into `pending`, so leaving it set would silently defer them forever.
70
+ try {
71
+ for (const c of active) c._frame(dt);
72
+ } finally {
73
+ for (const c of pending) active.add(c);
74
+ pending.clear();
75
+ dispatching = false;
76
+ }
51
77
  });
52
78
 
53
79
  /**
@@ -69,13 +95,32 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
69
95
  if (!(typeof dpr === 'number' && Number.isFinite(dpr) && dpr > 0)) {
70
96
  throw new TypeError('createController: dpr must be a positive finite number');
71
97
  }
72
- if (nextStart + capacity > maxParticles) {
73
- throw new RangeError(
74
- `createController: pool exhausted -- ${nextStart}+${capacity} exceeds maxParticles ${maxParticles}`,
75
- );
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; }
104
+ }
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;
76
122
  }
77
- const start = nextStart;
78
- nextStart += capacity;
123
+ // A reused range is a NEW controller, so the index always advances -> new default seed.
79
124
  const index = controllerCount++;
80
125
 
81
126
  const ctx = effectCanvas.getContext('2d');
@@ -113,13 +158,18 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
113
158
  // The single terminator for this controller's reveal. Completion (fireDone=true),
114
159
  // cancel() (false), and destroy() (false) all route here so the recipe teardown,
115
160
  // host-style restore, canvas clear, and active-set removal happen in one place.
116
- // 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).
117
163
  function endReveal(fireDone) {
118
164
  if (!activeRecipe) return;
119
165
  if (activeRecipe.destroy) activeRecipe.destroy();
120
166
  if (hostSnapshot) { restoreHostStyle(sourceCanvas, hostSnapshot); hostSnapshot = null; }
121
167
  ctx.clearRect(0, 0, cw, ch);
122
168
  active.delete(controller);
169
+ // Also drop from pending: if this controller was revealed earlier in THIS same
170
+ // dispatch (parked in pending) and now completes, the post-loop merge must not
171
+ // resurrect it into active.
172
+ pending.delete(controller);
123
173
  const done = onComplete;
124
174
  activeRecipe = null;
125
175
  onComplete = null;
@@ -147,7 +197,7 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
147
197
  hostSnapshot = captureHostStyle(sourceCanvas);
148
198
 
149
199
  const spots = scanner.scan(scanPrecision);
150
- recipe.init(ctx, capacity, cw, ch);
200
+ recipe.init(ctx, capacity, cw, ch, rng);
151
201
 
152
202
  const requested = recipe.count ?? capacity;
153
203
  const count = Math.min(requested, capacity);
@@ -171,7 +221,10 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
171
221
  engine.data[g] = i; // local index -> the recipe reads data[i] === i in its window
172
222
  }
173
223
  pView.max = count;
174
- active.add(controller);
224
+ // A reveal started OUTSIDE a dispatch goes straight to active and runs the
225
+ // same frame. A reveal started DURING a dispatch (from another controller's
226
+ // onDone) is parked; the post-loop merge adds it, so it starts next frame.
227
+ (dispatching ? pending : active).add(controller);
175
228
  },
176
229
 
177
230
  /** @internal Called by the stage each frame. */
@@ -197,7 +250,10 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
197
250
  /** Re-seed this controller's RNG. */
198
251
  seed(s) { rng.reset(s); },
199
252
 
200
- /** 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
+ */
201
257
  destroy() {
202
258
  if (ctlDestroyed) return;
203
259
  ctlDestroyed = true;
@@ -205,6 +261,9 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
205
261
  // host-style restore, active-set removal, no onDone) before scanner teardown.
206
262
  endReveal(false);
207
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;
208
267
  },
209
268
  };
210
269
  return controller;
@@ -224,11 +283,12 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
224
283
  if (destroyed) return;
225
284
  destroyed = true;
226
285
  active.clear();
286
+ pending.clear();
227
287
  engine.destroy();
228
288
  },
229
289
 
230
290
  /** @internal How many slots are still unreserved (for tests / diagnostics). */
231
- get remainingCapacity() { return maxParticles - nextStart; },
291
+ get remainingCapacity() { return maxParticles - nextStart + reclaimedCapacity; },
232
292
  };
233
293
  }
234
294