@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 +78 -0
- package/README.md +4 -4
- package/index.d.ts +7 -3
- package/index.js +1 -1
- package/llms.txt +3 -1
- package/package.json +1 -1
- package/src/ScratchController.js +15 -5
- package/src/ScratchRecipes.js +104 -81
- package/src/ScratchStage.js +74 -14
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(
|
|
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) {
|
|
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` |
|
|
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.
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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.
|
|
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),
|
|
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.
|
|
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",
|
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, 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
|
|
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
|
@@ -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 =
|
|
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(
|
|
44
|
-
pDecay[idx] = rng.range(0.
|
|
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(-
|
|
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 -
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
63
|
+
if (y[i] < -50) continue;
|
|
64
64
|
ctx.globalCompositeOperation = 'lighter';
|
|
65
65
|
ctx.fillStyle = palette[pColorIdx[p]];
|
|
66
|
-
ctx.
|
|
67
|
-
ctx.
|
|
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
|
-
|
|
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] *
|
|
206
|
-
y[i] += vy[i] *
|
|
207
|
-
vx[i] += (
|
|
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] += (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
|
-
|
|
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 =
|
|
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] *
|
|
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++;
|
|
@@ -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] =
|
|
874
|
-
pvy[i] = (
|
|
875
|
-
pox[i] = (
|
|
876
|
-
psz[i] =
|
|
877
|
-
pcol[i] = (
|
|
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] += (
|
|
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] + (
|
|
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
|
-
//
|
|
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(
|
|
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
|
|
970
|
-
ctx.
|
|
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 = (
|
|
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 (
|
|
1030
|
-
const bl = Math.hypot(x2 - x1, y2 - y1) * (0.15 +
|
|
1031
|
-
const ba = Math.atan2(y2 - y1, x2 - x1) + (
|
|
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 = (
|
|
1063
|
+
const e = (r.next() * 4) | 0;
|
|
1042
1064
|
let sx, sy;
|
|
1043
1065
|
if (e === 0) {
|
|
1044
1066
|
sx = 0;
|
|
1045
|
-
sy =
|
|
1067
|
+
sy = r.next() * h;
|
|
1046
1068
|
} else if (e === 1) {
|
|
1047
1069
|
sx = w;
|
|
1048
|
-
sy =
|
|
1070
|
+
sy = r.next() * h;
|
|
1049
1071
|
} else if (e === 2) {
|
|
1050
|
-
sx =
|
|
1072
|
+
sx = r.next() * w;
|
|
1051
1073
|
sy = 0;
|
|
1052
1074
|
} else {
|
|
1053
|
-
sx =
|
|
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 +
|
|
1060
|
-
ey: h * (0.3 +
|
|
1061
|
-
delay:
|
|
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
|
}
|
package/src/ScratchStage.js
CHANGED
|
@@ -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.
|
|
47
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
/**
|
|
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
|
|