@zakkster/lite-scratch-fx 1.3.2 → 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 +30 -0
- package/README.md +3 -3
- package/index.d.ts +6 -2
- package/index.js +1 -1
- package/llms.txt +3 -1
- package/package.json +1 -1
- package/src/ScratchController.js +1 -1
- package/src/ScratchRecipes.js +39 -35
- package/src/ScratchStage.js +42 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
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
|
+
|
|
3
33
|
## [1.3.2] - 2026-08-23
|
|
4
34
|
|
|
5
35
|
Recipe-fidelity pass against the consuming renderer. No API or signature change; the
|
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
|
|
@@ -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(
|
|
@@ -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
|
@@ -204,7 +204,7 @@ export function createScratchController(sourceCanvas, effectCanvas, {
|
|
|
204
204
|
const spots = scanner.scan(scanPrecision);
|
|
205
205
|
|
|
206
206
|
// Init recipe with LOGICAL size (recipes stay in CSS px).
|
|
207
|
-
recipe.init(ctx, cap, logicalW, logicalH);
|
|
207
|
+
recipe.init(ctx, cap, logicalW, logicalH, rng);
|
|
208
208
|
|
|
209
209
|
// Populate ring buffer. A recipe may declare `count: 0` (a pure-canvas reveal
|
|
210
210
|
// like Shine or Implosion that draws no particles); use ?? so an explicit 0 is
|
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,22 +40,21 @@ 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
50
|
const ds = dt * 60;
|
|
51
51
|
let alive = 0;
|
|
52
52
|
const progress = clamp(elapsed / duration, 0, 1);
|
|
53
|
-
src.style.opacity = 1 -
|
|
53
|
+
src.style.opacity = 1 - progress * progress;
|
|
54
54
|
|
|
55
55
|
for (let i = 0; i < max; i++) {
|
|
56
56
|
if (life[i] <= 0) continue;
|
|
57
57
|
const p = data[i];
|
|
58
|
-
vy[i] += 0.1 * ds;
|
|
59
58
|
x[i] += vx[i] * ds;
|
|
60
59
|
y[i] += vy[i] * ds;
|
|
61
60
|
life[i] -= pDecay[p] * ds;
|
|
@@ -173,12 +172,13 @@ export function ExplodeRecipe({count = 80, duration = 750, force = 15, colors, t
|
|
|
173
172
|
|
|
174
173
|
|
|
175
174
|
export function DragonBreathRecipe({count = 200, duration = 1200, colors, theme} = {}) {
|
|
176
|
-
let pSize, pDecay, pColorIdx;
|
|
175
|
+
let pSize, pDecay, pColorIdx, r;
|
|
177
176
|
const palette = resolvePalette(colors, theme, ['#FFF', '#FFD700', '#FF4500', '#8B0000', '#2F2F2F']);
|
|
178
177
|
|
|
179
178
|
return {
|
|
180
179
|
count,
|
|
181
|
-
init(ctx, capacity) {
|
|
180
|
+
init(ctx, capacity, w, h, rng) {
|
|
181
|
+
r = rng;
|
|
182
182
|
pSize = new Float32Array(capacity);
|
|
183
183
|
pDecay = new Float32Array(capacity);
|
|
184
184
|
pColorIdx = new Uint8Array(capacity);
|
|
@@ -210,7 +210,7 @@ export function DragonBreathRecipe({count = 200, duration = 1200, colors, theme}
|
|
|
210
210
|
const p = data[i];
|
|
211
211
|
x[i] += vx[i] * ds;
|
|
212
212
|
y[i] += vy[i] * ds;
|
|
213
|
-
vx[i] += (
|
|
213
|
+
vx[i] += (r.next() - 0.5) * 0.5 * ds;
|
|
214
214
|
vy[i] *= dvy;
|
|
215
215
|
pSize[p] *= dsz;
|
|
216
216
|
life[i] -= pDecay[p] * ds;
|
|
@@ -233,13 +233,13 @@ export function DragonBreathRecipe({count = 200, duration = 1200, colors, theme}
|
|
|
233
233
|
return alive === 0 && elapsed >= duration;
|
|
234
234
|
},
|
|
235
235
|
destroy() {
|
|
236
|
-
pSize = pDecay = pColorIdx = null;
|
|
236
|
+
pSize = pDecay = pColorIdx = r = null;
|
|
237
237
|
},
|
|
238
238
|
};
|
|
239
239
|
}
|
|
240
240
|
|
|
241
241
|
|
|
242
|
-
export function IceBreathRecipe({count =
|
|
242
|
+
export function IceBreathRecipe({count = 450, duration = 1500, colors, theme} = {}) {
|
|
243
243
|
let pSize, pDecay, pRot, pRotSpd, pColorIdx;
|
|
244
244
|
const palette = resolvePalette(colors, theme, ['#FFF', '#E0FFFF', '#00FFFF', '#1E90FF', '#4682B4']);
|
|
245
245
|
|
|
@@ -871,22 +871,23 @@ export function ShineRecipe({duration = 500, width = 250} = {}) {
|
|
|
871
871
|
|
|
872
872
|
export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount = 60, colors, theme} = {}) {
|
|
873
873
|
const palette = resolvePalette(colors, theme, ['#FFF', '#E0E8FF', '#B0C4FF', '#88AAFF']);
|
|
874
|
-
let py, pvy, pox, psz, pcol;
|
|
874
|
+
let py, pvy, pox, psz, pcol, r;
|
|
875
875
|
|
|
876
876
|
return {
|
|
877
877
|
count: 0, // No SoA particles -- managed internally
|
|
878
|
-
init(ctx, capacity, w, h) {
|
|
878
|
+
init(ctx, capacity, w, h, rng) {
|
|
879
|
+
r = rng;
|
|
879
880
|
py = new Float32Array(particleCount);
|
|
880
881
|
pvy = new Float32Array(particleCount);
|
|
881
882
|
pox = new Float32Array(particleCount);
|
|
882
883
|
psz = new Float32Array(particleCount);
|
|
883
884
|
pcol = new Uint8Array(particleCount);
|
|
884
885
|
for (let i = 0; i < particleCount; i++) {
|
|
885
|
-
py[i] =
|
|
886
|
-
pvy[i] = (
|
|
887
|
-
pox[i] = (
|
|
888
|
-
psz[i] =
|
|
889
|
-
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;
|
|
890
891
|
}
|
|
891
892
|
},
|
|
892
893
|
spawn() {
|
|
@@ -916,10 +917,10 @@ export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount =
|
|
|
916
917
|
for (let i = 0; i < particleCount; i++) {
|
|
917
918
|
const px2 = bx + pox[i];
|
|
918
919
|
py[i] += pvy[i] * ds;
|
|
919
|
-
pvy[i] += (
|
|
920
|
+
pvy[i] += (r.next() - 0.5) * 0.3 * ds;
|
|
920
921
|
if (py[i] < -10) py[i] = h + 10;
|
|
921
922
|
if (py[i] > h + 10) py[i] = -10;
|
|
922
|
-
psz[i] = clamp(psz[i] + (
|
|
923
|
+
psz[i] = clamp(psz[i] + (r.next() - 0.5) * 0.3 * ds, 1, 8);
|
|
923
924
|
ctx.globalAlpha = 0.7;
|
|
924
925
|
ctx.fillStyle = palette[pcol[i]];
|
|
925
926
|
ctx.save();
|
|
@@ -932,7 +933,7 @@ export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount =
|
|
|
932
933
|
return raw >= 1;
|
|
933
934
|
},
|
|
934
935
|
destroy() {
|
|
935
|
-
py = pvy = pox = psz = pcol = null;
|
|
936
|
+
py = pvy = pox = psz = pcol = r = null;
|
|
936
937
|
},
|
|
937
938
|
};
|
|
938
939
|
}
|
|
@@ -1013,6 +1014,7 @@ export function LightningCrawlRecipe({boltCount = 5, duration = 600, colors, the
|
|
|
1013
1014
|
const cMid = palette[(palette.length - 1) >> 1];
|
|
1014
1015
|
const cOuter = palette[palette.length - 1];
|
|
1015
1016
|
let bolts = [];
|
|
1017
|
+
let r;
|
|
1016
1018
|
|
|
1017
1019
|
function genPts(x1, y1, x2, y2, d) {
|
|
1018
1020
|
let p = [{x: x1, y: y1}, {x: x2, y: y2}];
|
|
@@ -1022,7 +1024,7 @@ export function LightningCrawlRecipe({boltCount = 5, duration = 600, colors, the
|
|
|
1022
1024
|
const a = p[j], b = p[j + 1];
|
|
1023
1025
|
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
|
|
1024
1026
|
const dist = Math.hypot(b.x - a.x, b.y - a.y);
|
|
1025
|
-
const off = (
|
|
1027
|
+
const off = (r.next() - 0.5) * dist * 0.4;
|
|
1026
1028
|
const nx = -(b.y - a.y) / (dist || 1), ny = (b.x - a.x) / (dist || 1);
|
|
1027
1029
|
np.push({x: mx + nx * off, y: my + ny * off});
|
|
1028
1030
|
np.push(b);
|
|
@@ -1045,39 +1047,40 @@ export function LightningCrawlRecipe({boltCount = 5, duration = 600, colors, the
|
|
|
1045
1047
|
ctx.stroke();
|
|
1046
1048
|
});
|
|
1047
1049
|
if (d > 2) for (let i = 2; i < pts.length - 1; i += 3) {
|
|
1048
|
-
if (
|
|
1049
|
-
const bl = Math.hypot(x2 - x1, y2 - y1) * (0.15 +
|
|
1050
|
-
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;
|
|
1051
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);
|
|
1052
1054
|
}
|
|
1053
1055
|
}
|
|
1054
1056
|
|
|
1055
1057
|
return {
|
|
1056
1058
|
count: 0,
|
|
1057
|
-
init(ctx, capacity, w, h) {
|
|
1059
|
+
init(ctx, capacity, w, h, rng) {
|
|
1060
|
+
r = rng;
|
|
1058
1061
|
bolts = [];
|
|
1059
1062
|
for (let b = 0; b < boltCount; b++) {
|
|
1060
|
-
const e = (
|
|
1063
|
+
const e = (r.next() * 4) | 0;
|
|
1061
1064
|
let sx, sy;
|
|
1062
1065
|
if (e === 0) {
|
|
1063
1066
|
sx = 0;
|
|
1064
|
-
sy =
|
|
1067
|
+
sy = r.next() * h;
|
|
1065
1068
|
} else if (e === 1) {
|
|
1066
1069
|
sx = w;
|
|
1067
|
-
sy =
|
|
1070
|
+
sy = r.next() * h;
|
|
1068
1071
|
} else if (e === 2) {
|
|
1069
|
-
sx =
|
|
1072
|
+
sx = r.next() * w;
|
|
1070
1073
|
sy = 0;
|
|
1071
1074
|
} else {
|
|
1072
|
-
sx =
|
|
1075
|
+
sx = r.next() * w;
|
|
1073
1076
|
sy = h;
|
|
1074
1077
|
}
|
|
1075
1078
|
bolts.push({
|
|
1076
1079
|
sx,
|
|
1077
1080
|
sy,
|
|
1078
|
-
ex: w * (0.3 +
|
|
1079
|
-
ey: h * (0.3 +
|
|
1080
|
-
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
|
|
1081
1084
|
});
|
|
1082
1085
|
}
|
|
1083
1086
|
},
|
|
@@ -1109,6 +1112,7 @@ export function LightningCrawlRecipe({boltCount = 5, duration = 600, colors, the
|
|
|
1109
1112
|
},
|
|
1110
1113
|
destroy() {
|
|
1111
1114
|
bolts = [];
|
|
1115
|
+
r = null;
|
|
1112
1116
|
},
|
|
1113
1117
|
};
|
|
1114
1118
|
}
|
package/src/ScratchStage.js
CHANGED
|
@@ -46,6 +46,12 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
|
|
|
46
46
|
const pending = new Set();
|
|
47
47
|
let dispatching = false;
|
|
48
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;
|
|
49
55
|
let controllerCount = 0;
|
|
50
56
|
let destroyed = false;
|
|
51
57
|
|
|
@@ -89,13 +95,32 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
|
|
|
89
95
|
if (!(typeof dpr === 'number' && Number.isFinite(dpr) && dpr > 0)) {
|
|
90
96
|
throw new TypeError('createController: dpr must be a positive finite number');
|
|
91
97
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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; }
|
|
96
104
|
}
|
|
97
|
-
|
|
98
|
-
|
|
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;
|
|
122
|
+
}
|
|
123
|
+
// A reused range is a NEW controller, so the index always advances -> new default seed.
|
|
99
124
|
const index = controllerCount++;
|
|
100
125
|
|
|
101
126
|
const ctx = effectCanvas.getContext('2d');
|
|
@@ -133,7 +158,8 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
|
|
|
133
158
|
// The single terminator for this controller's reveal. Completion (fireDone=true),
|
|
134
159
|
// cancel() (false), and destroy() (false) all route here so the recipe teardown,
|
|
135
160
|
// host-style restore, canvas clear, and active-set removal happen in one place.
|
|
136
|
-
// 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).
|
|
137
163
|
function endReveal(fireDone) {
|
|
138
164
|
if (!activeRecipe) return;
|
|
139
165
|
if (activeRecipe.destroy) activeRecipe.destroy();
|
|
@@ -171,7 +197,7 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
|
|
|
171
197
|
hostSnapshot = captureHostStyle(sourceCanvas);
|
|
172
198
|
|
|
173
199
|
const spots = scanner.scan(scanPrecision);
|
|
174
|
-
recipe.init(ctx, capacity, cw, ch);
|
|
200
|
+
recipe.init(ctx, capacity, cw, ch, rng);
|
|
175
201
|
|
|
176
202
|
const requested = recipe.count ?? capacity;
|
|
177
203
|
const count = Math.min(requested, capacity);
|
|
@@ -224,7 +250,10 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
|
|
|
224
250
|
/** Re-seed this controller's RNG. */
|
|
225
251
|
seed(s) { rng.reset(s); },
|
|
226
252
|
|
|
227
|
-
/**
|
|
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
|
+
*/
|
|
228
257
|
destroy() {
|
|
229
258
|
if (ctlDestroyed) return;
|
|
230
259
|
ctlDestroyed = true;
|
|
@@ -232,6 +261,9 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
|
|
|
232
261
|
// host-style restore, active-set removal, no onDone) before scanner teardown.
|
|
233
262
|
endReveal(false);
|
|
234
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;
|
|
235
267
|
},
|
|
236
268
|
};
|
|
237
269
|
return controller;
|
|
@@ -256,7 +288,7 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
|
|
|
256
288
|
},
|
|
257
289
|
|
|
258
290
|
/** @internal How many slots are still unreserved (for tests / diagnostics). */
|
|
259
|
-
get remainingCapacity() { return maxParticles - nextStart; },
|
|
291
|
+
get remainingCapacity() { return maxParticles - nextStart + reclaimedCapacity; },
|
|
260
292
|
};
|
|
261
293
|
}
|
|
262
294
|
|