@zakkster/lite-scratch-fx 1.0.0 → 1.2.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.
@@ -0,0 +1,192 @@
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
+
27
+ /**
28
+ * @param {Object} [options]
29
+ * @param {number} [options.maxParticles=2000] Total shared pool capacity.
30
+ * @param {number} [options.seed=Date.now()] Base seed; each controller derives its own.
31
+ */
32
+ export function createScratchStage({ maxParticles = 2000, seed = Date.now() } = {}) {
33
+ const engine = new SoaParticleEngine(maxParticles);
34
+ const active = new Set();
35
+ let nextStart = 0;
36
+ let controllerCount = 0;
37
+ let destroyed = false;
38
+
39
+ // One render dispatch for the whole pool. Raw tick(dt) hands us the lanes and `max`;
40
+ // we ignore them (each controller holds stable subarray views of its own sub-range) and
41
+ // fan out to every active reveal. A Set copy is avoided: a controller that completes
42
+ // removes itself, so iterate a snapshot to stay safe against mid-iteration deletion.
43
+ engine.onTick((dt) => {
44
+ if (destroyed || active.size === 0) return;
45
+ for (const c of active) c._frame(dt);
46
+ });
47
+
48
+ /**
49
+ * Reserve a sub-range and return a stage-managed controller. The stage drives it; the
50
+ * controller has no tick() of its own.
51
+ * @param {HTMLCanvasElement} sourceCanvas
52
+ * @param {HTMLCanvasElement} effectCanvas
53
+ * @param {Object} [opts]
54
+ * @param {number} [opts.capacity=300] Slots reserved for this controller's particles.
55
+ * @param {number} [opts.seed] RNG seed; defaults to base seed + index.
56
+ * @param {number} [opts.scanPrecision=32] Pixel-scan resolution.
57
+ */
58
+ function createController(sourceCanvas, effectCanvas, { capacity = 300, seed: cSeed, scanPrecision = 32 } = {}) {
59
+ if (destroyed) throw new Error('createScratchStage: stage is destroyed');
60
+ if (!(Number.isInteger(capacity) && capacity >= 0)) {
61
+ throw new TypeError('createController: capacity must be a non-negative integer');
62
+ }
63
+ if (nextStart + capacity > maxParticles) {
64
+ throw new RangeError(
65
+ `createController: pool exhausted -- ${nextStart}+${capacity} exceeds maxParticles ${maxParticles}`,
66
+ );
67
+ }
68
+ const start = nextStart;
69
+ nextStart += capacity;
70
+ const index = controllerCount++;
71
+
72
+ const ctx = effectCanvas.getContext('2d');
73
+ const rng = new Random(cSeed ?? (seed + index));
74
+ const scanner = createPixelScanner(sourceCanvas);
75
+
76
+ // Stable subarray views of this controller's sub-range, built ONCE (the engine's
77
+ // lanes are fixed for its life). Reused every frame -> zero per-frame allocation.
78
+ const end = start + capacity;
79
+ const pView = {
80
+ x: engine.x.subarray(start, end),
81
+ y: engine.y.subarray(start, end),
82
+ vx: engine.vx.subarray(start, end),
83
+ vy: engine.vy.subarray(start, end),
84
+ life: engine.life.subarray(start, end),
85
+ invLife: engine.invLife.subarray(start, end),
86
+ data: engine.data.subarray(start, end),
87
+ max: 0,
88
+ };
89
+ const spot = { x: 0.5, y: 0.5 };
90
+
91
+ let activeRecipe = null;
92
+ let onComplete = null;
93
+ let elapsed = 0;
94
+ let cw = 0, ch = 0;
95
+ let ctlDestroyed = false;
96
+
97
+ const controller = {
98
+ /** Reveal a recipe. Ignored if this controller is already revealing or destroyed. */
99
+ reveal(recipe, onDone) {
100
+ if (ctlDestroyed || activeRecipe) return;
101
+ activeRecipe = recipe;
102
+ onComplete = onDone || null;
103
+ elapsed = 0;
104
+
105
+ cw = effectCanvas.width = sourceCanvas.offsetWidth || sourceCanvas.width;
106
+ ch = effectCanvas.height = sourceCanvas.offsetHeight || sourceCanvas.height;
107
+
108
+ const spots = scanner.scan(scanPrecision);
109
+ recipe.init(ctx, capacity, cw, ch);
110
+
111
+ const requested = recipe.count ?? capacity;
112
+ const count = Math.min(requested, capacity);
113
+ for (let i = 0; i < count; i++) {
114
+ if (spots.count > 0) {
115
+ const k = rng.int(0, spots.count - 1);
116
+ spot.x = spots.spotX[k];
117
+ spot.y = spots.spotY[k];
118
+ } else {
119
+ spot.x = 0.5;
120
+ spot.y = 0.5;
121
+ }
122
+ const state = recipe.spawn(i, rng, cw, ch, spot);
123
+ const g = start + i;
124
+ engine.x[g] = state.x;
125
+ engine.y[g] = state.y;
126
+ engine.vx[g] = state.vx;
127
+ engine.vy[g] = state.vy;
128
+ engine.life[g] = state.life;
129
+ engine.invLife[g] = 1.0 / state.life;
130
+ engine.data[g] = i; // local index -> the recipe reads data[i] === i in its window
131
+ }
132
+ pView.max = count;
133
+ active.add(controller);
134
+ },
135
+
136
+ /** @internal Called by the stage each frame. */
137
+ _frame(dt) {
138
+ if (ctlDestroyed || !activeRecipe) return;
139
+ elapsed += dt;
140
+ ctx.clearRect(0, 0, cw, ch);
141
+ const isFinished = activeRecipe.tick(dt, elapsed * 1000, pView, ctx, sourceCanvas, cw, ch);
142
+ if (isFinished) {
143
+ if (activeRecipe.destroy) activeRecipe.destroy();
144
+ activeRecipe = null;
145
+ ctx.clearRect(0, 0, cw, ch);
146
+ active.delete(controller);
147
+ const done = onComplete;
148
+ onComplete = null;
149
+ if (done) done();
150
+ }
151
+ },
152
+
153
+ /** Re-seed this controller's RNG. */
154
+ seed(s) { rng.reset(s); },
155
+
156
+ /** Remove this controller from the stage. Its sub-range is not reclaimed (v1). */
157
+ destroy() {
158
+ if (ctlDestroyed) return;
159
+ ctlDestroyed = true;
160
+ if (activeRecipe && activeRecipe.destroy) activeRecipe.destroy();
161
+ activeRecipe = null;
162
+ onComplete = null;
163
+ active.delete(controller);
164
+ scanner.destroy();
165
+ },
166
+ };
167
+ return controller;
168
+ }
169
+
170
+ return {
171
+ createController,
172
+
173
+ /** Advance every active reveal one frame. `dt` is in SECONDS. */
174
+ tick(dt) {
175
+ if (destroyed) return;
176
+ engine.tick(dt);
177
+ },
178
+
179
+ /** Stop everything and release the shared engine. Controllers become inert. */
180
+ destroy() {
181
+ if (destroyed) return;
182
+ destroyed = true;
183
+ active.clear();
184
+ engine.destroy();
185
+ },
186
+
187
+ /** @internal How many slots are still unreserved (for tests / diagnostics). */
188
+ get remainingCapacity() { return maxParticles - nextStart; },
189
+ };
190
+ }
191
+
192
+ export default createScratchStage;