@zakkster/lite-scratch-fx 1.3.1 → 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 +48 -0
- package/README.md +2 -2
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/ScratchController.js +15 -5
- package/src/ScratchRecipes.js +69 -50
- package/src/ScratchStage.js +32 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,53 @@
|
|
|
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
|
+
|
|
3
51
|
## [1.3.1] - 2026-08-22
|
|
4
52
|
|
|
5
53
|
### Fixed
|
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` |
|
|
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.
|
|
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
|
-
/**
|
|
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.
|
|
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.
|
|
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",
|
package/src/ScratchController.js
CHANGED
|
@@ -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]
|
|
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,
|
|
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
|
|
202
|
-
const requested = recipe.count ??
|
|
203
|
-
const count = Math.min(requested,
|
|
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) {
|
package/src/ScratchRecipes.js
CHANGED
|
@@ -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] *
|
|
59
|
-
y[i] += vy[i] *
|
|
60
|
-
life[i] -= pDecay[p] *
|
|
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
|
-
|
|
64
|
+
if (y[i] < -50) continue;
|
|
64
65
|
ctx.globalCompositeOperation = 'lighter';
|
|
65
66
|
ctx.fillStyle = palette[pColorIdx[p]];
|
|
66
|
-
ctx.
|
|
67
|
-
ctx.
|
|
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
|
-
|
|
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] *
|
|
206
|
-
y[i] += vy[i] *
|
|
207
|
-
vx[i] += (Math.random() - 0.5) * 0.5;
|
|
208
|
-
vy[i] *=
|
|
209
|
-
pSize[p] *=
|
|
210
|
-
life[i] -= pDecay[p] *
|
|
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
|
-
|
|
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] *
|
|
276
|
-
y[i] += vy[i] *
|
|
277
|
-
vx[i] *=
|
|
278
|
-
vy[i] *=
|
|
279
|
-
pRot[p] += pRotSpd[p];
|
|
280
|
-
life[i] -= pDecay[p] *
|
|
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] *=
|
|
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] *
|
|
661
|
-
y[i] += vy[i] *
|
|
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] *=
|
|
736
|
-
vy[i] *=
|
|
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
|
-
//
|
|
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(
|
|
985
|
+
ctx.arc(sx, sy, sr, 0, Math.PI * 2);
|
|
966
986
|
ctx.fill();
|
|
967
987
|
}
|
|
968
988
|
|
|
969
|
-
// <- FIX: Reset
|
|
970
|
-
ctx.
|
|
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
|
|
package/src/ScratchStage.js
CHANGED
|
@@ -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.
|
|
47
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|