@zakkster/lite-scratch-fx 1.0.0 → 1.3.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 +86 -5
- package/README.md +374 -113
- package/index.d.ts +148 -9
- package/index.js +102 -49
- package/llms.txt +55 -21
- package/package.json +8 -4
- package/src/HostStyle.js +42 -0
- package/src/PixelScan.js +74 -0
- package/src/ScratchController.js +146 -96
- package/src/ScratchRecipes.js +680 -131
- package/src/ScratchStage.js +235 -0
- package/src/ScratchRecipes2.js +0 -642
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ScratchStage -- concurrent scratch-card reveals over ONE shared particle pool.
|
|
3
|
+
*
|
|
4
|
+
* A stage owns a single raw SoaParticleEngine and hands each controller a fixed,
|
|
5
|
+
* contiguous sub-range of its lanes. Any number of controllers can reveal at the same
|
|
6
|
+
* time, all rendering from one pool, driven by one clock -- so a grid of scratch boxes
|
|
7
|
+
* holds one lane pool instead of one per box.
|
|
8
|
+
*
|
|
9
|
+
* See decisions/0001-concurrent-shared-reveals.md for why this needs no engine change:
|
|
10
|
+
* raw-mode tick(dt) is pure dispatch (no physics, no culling), the lanes are public typed
|
|
11
|
+
* arrays the recipes already write, and each controller owns disjoint slots.
|
|
12
|
+
*
|
|
13
|
+
* const stage = createScratchStage({ maxParticles: 1500, seed: 1 });
|
|
14
|
+
* const boxes = cards.map((c) => stage.createController(c.scratch, c.fx, { capacity: 200 }));
|
|
15
|
+
* // one clock drives the grid; many boxes reveal at once, one pool:
|
|
16
|
+
* function frame(now) { const dt = (now - last) / 1000; last = now; stage.tick(dt);
|
|
17
|
+
* requestAnimationFrame(frame); }
|
|
18
|
+
*
|
|
19
|
+
* Copyright (c) Zahary Shinikchiev <shinikchiev@yahoo.com>
|
|
20
|
+
* MIT License.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { SoaParticleEngine } from '@zakkster/lite-soa-particle-engine';
|
|
24
|
+
import { Random } from '@zakkster/lite-random';
|
|
25
|
+
import { createPixelScanner } from './PixelScan.js';
|
|
26
|
+
import { captureHostStyle, restoreHostStyle } from './HostStyle.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {Object} [options]
|
|
30
|
+
* @param {number} [options.maxParticles=2000] Total shared pool capacity.
|
|
31
|
+
* @param {number} [options.seed=Date.now()] Base seed; each controller derives its own.
|
|
32
|
+
* @param {number} [options.dpr=1] Default device-pixel-ratio for controllers.
|
|
33
|
+
*/
|
|
34
|
+
export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr: stageDpr = 1 } = {}) {
|
|
35
|
+
if (!(typeof stageDpr === 'number' && Number.isFinite(stageDpr) && stageDpr > 0)) {
|
|
36
|
+
throw new TypeError('createScratchStage: dpr must be a positive finite number');
|
|
37
|
+
}
|
|
38
|
+
const engine = new SoaParticleEngine(maxParticles);
|
|
39
|
+
const active = new Set();
|
|
40
|
+
let nextStart = 0;
|
|
41
|
+
let controllerCount = 0;
|
|
42
|
+
let destroyed = false;
|
|
43
|
+
|
|
44
|
+
// One render dispatch for the whole pool. Raw tick(dt) hands us the lanes and `max`;
|
|
45
|
+
// we ignore them (each controller holds stable subarray views of its own sub-range) and
|
|
46
|
+
// fan out to every active reveal. A Set copy is avoided: a controller that completes
|
|
47
|
+
// removes itself, so iterate a snapshot to stay safe against mid-iteration deletion.
|
|
48
|
+
engine.onTick((dt) => {
|
|
49
|
+
if (destroyed || active.size === 0) return;
|
|
50
|
+
for (const c of active) c._frame(dt);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Reserve a sub-range and return a stage-managed controller. The stage drives it; the
|
|
55
|
+
* controller has no tick() of its own.
|
|
56
|
+
* @param {HTMLCanvasElement} sourceCanvas
|
|
57
|
+
* @param {HTMLCanvasElement} effectCanvas
|
|
58
|
+
* @param {Object} [opts]
|
|
59
|
+
* @param {number} [opts.capacity=300] Slots reserved for this controller's particles.
|
|
60
|
+
* @param {number} [opts.seed] RNG seed; defaults to base seed + index.
|
|
61
|
+
* @param {number} [opts.scanPrecision=32] Pixel-scan resolution.
|
|
62
|
+
* @param {number} [opts.dpr] Per-controller device-pixel-ratio (default: stage dpr).
|
|
63
|
+
*/
|
|
64
|
+
function createController(sourceCanvas, effectCanvas, { capacity = 300, seed: cSeed, scanPrecision = 32, dpr = stageDpr } = {}) {
|
|
65
|
+
if (destroyed) throw new Error('createScratchStage: stage is destroyed');
|
|
66
|
+
if (!(Number.isInteger(capacity) && capacity >= 0)) {
|
|
67
|
+
throw new TypeError('createController: capacity must be a non-negative integer');
|
|
68
|
+
}
|
|
69
|
+
if (!(typeof dpr === 'number' && Number.isFinite(dpr) && dpr > 0)) {
|
|
70
|
+
throw new TypeError('createController: dpr must be a positive finite number');
|
|
71
|
+
}
|
|
72
|
+
if (nextStart + capacity > maxParticles) {
|
|
73
|
+
throw new RangeError(
|
|
74
|
+
`createController: pool exhausted -- ${nextStart}+${capacity} exceeds maxParticles ${maxParticles}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const start = nextStart;
|
|
78
|
+
nextStart += capacity;
|
|
79
|
+
const index = controllerCount++;
|
|
80
|
+
|
|
81
|
+
const ctx = effectCanvas.getContext('2d');
|
|
82
|
+
const rng = new Random(cSeed ?? (seed + index));
|
|
83
|
+
const scanner = createPixelScanner(sourceCanvas);
|
|
84
|
+
|
|
85
|
+
// Stable subarray views of this controller's sub-range, built ONCE (the engine's
|
|
86
|
+
// lanes are fixed for its life). Reused every frame -> zero per-frame allocation.
|
|
87
|
+
const end = start + capacity;
|
|
88
|
+
const pView = {
|
|
89
|
+
x: engine.x.subarray(start, end),
|
|
90
|
+
y: engine.y.subarray(start, end),
|
|
91
|
+
vx: engine.vx.subarray(start, end),
|
|
92
|
+
vy: engine.vy.subarray(start, end),
|
|
93
|
+
life: engine.life.subarray(start, end),
|
|
94
|
+
invLife: engine.invLife.subarray(start, end),
|
|
95
|
+
data: engine.data.subarray(start, end),
|
|
96
|
+
max: 0,
|
|
97
|
+
};
|
|
98
|
+
const spot = { x: 0.5, y: 0.5 };
|
|
99
|
+
|
|
100
|
+
let activeRecipe = null;
|
|
101
|
+
let onComplete = null;
|
|
102
|
+
let elapsed = 0;
|
|
103
|
+
// Logical (CSS-pixel) reveal size. Recipes author and draw in these; the device-px
|
|
104
|
+
// effect canvas is dpr x larger and the 2D transform maps logical -> device. Every
|
|
105
|
+
// read of cw/ch (the _frame clearRect, recipe init/spawn/tick, endReveal clearRect)
|
|
106
|
+
// wants logical, so cw/ch are logical throughout.
|
|
107
|
+
let cw = 0, ch = 0;
|
|
108
|
+
let ctlDestroyed = false;
|
|
109
|
+
// Host layer inline style at reveal START -- one small object per reveal (cold
|
|
110
|
+
// path), restored on every terminal transition (completion/cancel/destroy).
|
|
111
|
+
let hostSnapshot = null;
|
|
112
|
+
|
|
113
|
+
// The single terminator for this controller's reveal. Completion (fireDone=true),
|
|
114
|
+
// cancel() (false), and destroy() (false) all route here so the recipe teardown,
|
|
115
|
+
// host-style restore, canvas clear, and active-set removal happen in one place.
|
|
116
|
+
// Cold path: once per reveal, never per frame. Sub-range stays reserved (B-8).
|
|
117
|
+
function endReveal(fireDone) {
|
|
118
|
+
if (!activeRecipe) return;
|
|
119
|
+
if (activeRecipe.destroy) activeRecipe.destroy();
|
|
120
|
+
if (hostSnapshot) { restoreHostStyle(sourceCanvas, hostSnapshot); hostSnapshot = null; }
|
|
121
|
+
ctx.clearRect(0, 0, cw, ch);
|
|
122
|
+
active.delete(controller);
|
|
123
|
+
const done = onComplete;
|
|
124
|
+
activeRecipe = null;
|
|
125
|
+
onComplete = null;
|
|
126
|
+
if (fireDone && done) done();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const controller = {
|
|
130
|
+
/** Reveal a recipe. Ignored if this controller is already revealing or destroyed. */
|
|
131
|
+
reveal(recipe, onDone) {
|
|
132
|
+
if (ctlDestroyed || activeRecipe) return;
|
|
133
|
+
activeRecipe = recipe;
|
|
134
|
+
onComplete = onDone || null;
|
|
135
|
+
elapsed = 0;
|
|
136
|
+
|
|
137
|
+
cw = sourceCanvas.offsetWidth || sourceCanvas.width;
|
|
138
|
+
ch = sourceCanvas.offsetHeight || sourceCanvas.height;
|
|
139
|
+
// Device-pixel backing store: dpr x larger for sharp high-DPI output.
|
|
140
|
+
// Assigning width/height resets the 2D transform, so map logical -> device
|
|
141
|
+
// with setTransform. At dpr === 1 skip it for byte-identical behaviour.
|
|
142
|
+
effectCanvas.width = Math.round(cw * dpr);
|
|
143
|
+
effectCanvas.height = Math.round(ch * dpr);
|
|
144
|
+
if (dpr !== 1) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
145
|
+
|
|
146
|
+
// Borrow the host layer's inline style; endReveal restores it pristine.
|
|
147
|
+
hostSnapshot = captureHostStyle(sourceCanvas);
|
|
148
|
+
|
|
149
|
+
const spots = scanner.scan(scanPrecision);
|
|
150
|
+
recipe.init(ctx, capacity, cw, ch);
|
|
151
|
+
|
|
152
|
+
const requested = recipe.count ?? capacity;
|
|
153
|
+
const count = Math.min(requested, capacity);
|
|
154
|
+
for (let i = 0; i < count; i++) {
|
|
155
|
+
if (spots.count > 0) {
|
|
156
|
+
const k = rng.int(0, spots.count - 1);
|
|
157
|
+
spot.x = spots.spotX[k];
|
|
158
|
+
spot.y = spots.spotY[k];
|
|
159
|
+
} else {
|
|
160
|
+
spot.x = 0.5;
|
|
161
|
+
spot.y = 0.5;
|
|
162
|
+
}
|
|
163
|
+
const state = recipe.spawn(i, rng, cw, ch, spot);
|
|
164
|
+
const g = start + i;
|
|
165
|
+
engine.x[g] = state.x;
|
|
166
|
+
engine.y[g] = state.y;
|
|
167
|
+
engine.vx[g] = state.vx;
|
|
168
|
+
engine.vy[g] = state.vy;
|
|
169
|
+
engine.life[g] = state.life;
|
|
170
|
+
engine.invLife[g] = 1.0 / state.life;
|
|
171
|
+
engine.data[g] = i; // local index -> the recipe reads data[i] === i in its window
|
|
172
|
+
}
|
|
173
|
+
pView.max = count;
|
|
174
|
+
active.add(controller);
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
/** @internal Called by the stage each frame. */
|
|
178
|
+
_frame(dt) {
|
|
179
|
+
if (ctlDestroyed || !activeRecipe) return;
|
|
180
|
+
elapsed += dt;
|
|
181
|
+
ctx.clearRect(0, 0, cw, ch);
|
|
182
|
+
const isFinished = activeRecipe.tick(dt, elapsed * 1000, pView, ctx, sourceCanvas, cw, ch);
|
|
183
|
+
if (isFinished) endReveal(true);
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Abort this controller's in-flight reveal: stop the effect, restore the host
|
|
188
|
+
* canvas's inline style, and do NOT fire onDone; the controller is revealable
|
|
189
|
+
* again. A no-op when idle or destroyed. Does not reclaim the sub-range (B-8)
|
|
190
|
+
* and never touches other controllers' in-flight reveals.
|
|
191
|
+
*/
|
|
192
|
+
cancel() {
|
|
193
|
+
if (ctlDestroyed || !activeRecipe) return;
|
|
194
|
+
endReveal(false);
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
/** Re-seed this controller's RNG. */
|
|
198
|
+
seed(s) { rng.reset(s); },
|
|
199
|
+
|
|
200
|
+
/** Remove this controller from the stage. Its sub-range is not reclaimed (v1). */
|
|
201
|
+
destroy() {
|
|
202
|
+
if (ctlDestroyed) return;
|
|
203
|
+
ctlDestroyed = true;
|
|
204
|
+
// Route any in-flight reveal through the single terminator (recipe teardown,
|
|
205
|
+
// host-style restore, active-set removal, no onDone) before scanner teardown.
|
|
206
|
+
endReveal(false);
|
|
207
|
+
scanner.destroy();
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
return controller;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
createController,
|
|
215
|
+
|
|
216
|
+
/** Advance every active reveal one frame. `dt` is in SECONDS. */
|
|
217
|
+
tick(dt) {
|
|
218
|
+
if (destroyed) return;
|
|
219
|
+
engine.tick(dt);
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
/** Stop everything and release the shared engine. Controllers become inert. */
|
|
223
|
+
destroy() {
|
|
224
|
+
if (destroyed) return;
|
|
225
|
+
destroyed = true;
|
|
226
|
+
active.clear();
|
|
227
|
+
engine.destroy();
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
/** @internal How many slots are still unreserved (for tests / diagnostics). */
|
|
231
|
+
get remainingCapacity() { return maxParticles - nextStart; },
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export default createScratchStage;
|