@zakkster/lite-scratch-fx 1.0.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/src/Palette.js ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Palette resolution -- the one convention every themeable recipe shares.
3
+ *
4
+ * A recipe factory takes `{ colors, theme }` alongside its own options and calls
5
+ * resolvePalette() ONCE at construction (never on the hot path) to pick its ramp:
6
+ *
7
+ * colors string[] an explicit ramp -- wins outright
8
+ * theme { light, mid, dark } shorthand; mapped to [light, mid, dark]
9
+ * (else) the recipe's own default literal
10
+ *
11
+ * Multi-colour recipes index the returned array by a per-particle colour index;
12
+ * single-colour recipes use ramp[0]. Host code that threads one {light, mid, dark}
13
+ * triple through every effect gets consistent theming without importing 14 factories.
14
+ *
15
+ * Copyright (c) Zahary Shinikchiev <shinikchiev@yahoo.com>
16
+ * MIT License.
17
+ */
18
+
19
+ /**
20
+ * @param {string[]} [colors] explicit ramp; highest precedence
21
+ * @param {{light?:string, mid?:string, dark?:string}} [theme] shorthand ramp
22
+ * @param {string[]} fallback the recipe's default ramp
23
+ * @returns {string[]} a non-empty ramp of CSS colour strings
24
+ */
25
+ export function resolvePalette(colors, theme, fallback) {
26
+ if (Array.isArray(colors) && colors.length > 0) return colors;
27
+ if (theme) {
28
+ const ramp = [];
29
+ if (theme.light) ramp.push(theme.light);
30
+ if (theme.mid) ramp.push(theme.mid);
31
+ if (theme.dark) ramp.push(theme.dark);
32
+ if (ramp.length > 0) return ramp;
33
+ }
34
+ return fallback;
35
+ }
36
+
37
+ export default resolvePalette;
@@ -0,0 +1,211 @@
1
+ /**
2
+ * ScratchController -- one-shot scratch-card reveal engine.
3
+ *
4
+ * Mounts a reveal recipe, scans the remaining visible pixels of the scratch layer for
5
+ * spawn points, delegates physics + rendering to the recipe, and auto-cleans up on
6
+ * completion. The particle engine owns its own requestAnimationFrame loop.
7
+ *
8
+ * Recipe interface:
9
+ * {
10
+ * count, // particles to spawn (0 = pure canvas)
11
+ * init(ctx, capacity, w, h), // allocate per-particle arrays
12
+ * spawn(idx, rng, w, h, spot), // -> { x, y, vx, vy, life }
13
+ * tick(dt, elapsedMs, engine, ctx, src, w, h),// -> true when the effect is complete
14
+ * destroy(), // release arrays
15
+ * }
16
+ *
17
+ * `spot` is a SHARED, MUTABLE object reused for every spawn call in a reveal. A recipe
18
+ * must read `spot.x` / `spot.y` synchronously inside `spawn` (as all built-ins do) and
19
+ * never retain the reference -- the next spawn overwrites it in place. This is what keeps
20
+ * the reveal-start frame allocation-free: no per-pixel `{x, y}` literals.
21
+ *
22
+ * Depends on:
23
+ * @zakkster/lite-soa-particle-engine
24
+ * @zakkster/lite-random
25
+ *
26
+ * Copyright (c) Zahary Shinikchiev <shinikchiev@yahoo.com>
27
+ * MIT License.
28
+ */
29
+
30
+ import { SoaParticleEngine } from '@zakkster/lite-soa-particle-engine';
31
+ import { Random } from '@zakkster/lite-random';
32
+
33
+ /**
34
+ * @param {HTMLCanvasElement} sourceCanvas The scratch layer being revealed
35
+ * @param {HTMLCanvasElement} effectCanvas Overlay canvas for VFX
36
+ * @param {Object} [options]
37
+ */
38
+ export function createScratchController(sourceCanvas, effectCanvas, {
39
+ maxParticles = 2000,
40
+ seed = Date.now(),
41
+ scanPrecision = 32,
42
+ } = {}) {
43
+ const ctx = effectCanvas.getContext('2d');
44
+ const engine = new SoaParticleEngine(maxParticles);
45
+ const rng = new Random(seed);
46
+ // The SoA engine owns its own requestAnimationFrame loop: start() schedules it, stop()
47
+ // cancels it, and it computes dt internally. The controller must NOT also drive
48
+ // engine._loop from an external ticker -- that double-drives the loop and corrupts its
49
+ // dt, which breaks both particle life decay and completion detection. (An earlier
50
+ // version accepted a `sharedTicker` and did exactly that; it is removed.)
51
+
52
+ let activeRecipe = null;
53
+ let destroyed = false;
54
+ let elapsed = 0;
55
+ let onComplete = null;
56
+
57
+ // ── Reveal-scan scratch, all allocated ONCE (zero-GC on the reveal-start frame) ──
58
+ // The offscreen scan canvas, its context, and the spawn-point arrays are built here
59
+ // and reused across every reveal. Only getImageData() -- a single small typed array
60
+ // per reveal, mandated by the 2D canvas API -- is not reusable; the per-pixel object
61
+ // churn of the previous design (one `{x, y}` literal per surviving pixel) is gone.
62
+ let scanCanvas = null;
63
+ let scanCtx = null;
64
+ let scanW = 0, scanH = 0;
65
+ let spotX = new Float32Array(0); // grown lazily if the source canvas is ever resized
66
+ let spotY = new Float32Array(0);
67
+ let spotCount = 0;
68
+ const spot = { x: 0.5, y: 0.5 }; // the single shared, reused spawn point
69
+
70
+ function ensureScanBuffers(precision) {
71
+ const srcW = sourceCanvas.width || sourceCanvas.offsetWidth || 1;
72
+ const srcH = sourceCanvas.height || sourceCanvas.offsetHeight || 1;
73
+ const h = Math.max(1, Math.floor(srcH * (precision / srcW)));
74
+ if (scanCanvas && scanW === precision && scanH === h) return;
75
+ // Dims changed (or first run): (re)build. Off the hot path -- once per resize.
76
+ scanW = precision;
77
+ scanH = h;
78
+ if (!scanCanvas) {
79
+ scanCanvas = document.createElement('canvas');
80
+ scanCtx = scanCanvas.getContext('2d', { willReadFrequently: true });
81
+ }
82
+ scanCanvas.width = scanW;
83
+ scanCanvas.height = scanH;
84
+ const cap = scanW * scanH;
85
+ if (spotX.length < cap) {
86
+ spotX = new Float32Array(cap);
87
+ spotY = new Float32Array(cap);
88
+ }
89
+ }
90
+
91
+ // ── Scan remaining visible pixels from the scratch canvas into spotX/spotY ──
92
+ // Fills the preallocated arrays and sets spotCount. Allocates nothing per pixel.
93
+ function scanPixels(precision = scanPrecision) {
94
+ ensureScanBuffers(precision);
95
+ scanCtx.clearRect(0, 0, scanW, scanH);
96
+ scanCtx.drawImage(sourceCanvas, 0, 0, scanW, scanH);
97
+ const data = scanCtx.getImageData(0, 0, scanW, scanH).data;
98
+ let n = 0;
99
+ for (let i = 3; i < data.length; i += 4) {
100
+ if (data[i] > 128) {
101
+ const p = (i - 3) / 4;
102
+ spotX[n] = (p % scanW) / scanW;
103
+ spotY[n] = ((p / scanW) | 0) / scanH;
104
+ n++;
105
+ }
106
+ }
107
+ spotCount = n;
108
+ }
109
+
110
+ // The particle-view object handed to recipe.tick every frame. Built ONCE and
111
+ // reused: the engine's SoA arrays are stable references for the life of the engine,
112
+ // so a fresh `{ x, y, ... }` literal per frame would be a pure zero-GC violation on
113
+ // the hottest path in the package. Reassign the fields each frame (free) instead.
114
+ let pView = null;
115
+
116
+ // ── Render callback (the engine calls this every frame with raw SoA arrays) ──
117
+ engine.onTick((dt, x, y, vx, vy, life, invLife, data, max) => {
118
+ if (destroyed || !activeRecipe) return;
119
+ elapsed += dt;
120
+ const w = effectCanvas.width;
121
+ const h = effectCanvas.height;
122
+ ctx.clearRect(0, 0, w, h);
123
+
124
+ if (pView === null) {
125
+ pView = { x, y, vx, vy, life, invLife, data, max };
126
+ } else {
127
+ pView.x = x; pView.y = y; pView.vx = vx; pView.vy = vy;
128
+ pView.life = life; pView.invLife = invLife; pView.data = data; pView.max = max;
129
+ }
130
+
131
+ // Delegate everything to the recipe. Returns true when done.
132
+ const isFinished = activeRecipe.tick(
133
+ dt, elapsed * 1000, pView, ctx, sourceCanvas, w, h,
134
+ );
135
+
136
+ if (isFinished) {
137
+ engine.stop();
138
+ if (activeRecipe.destroy) activeRecipe.destroy();
139
+ activeRecipe = null;
140
+ ctx.clearRect(0, 0, w, h);
141
+ const done = onComplete;
142
+ onComplete = null;
143
+ if (done) done();
144
+ }
145
+ });
146
+
147
+ return {
148
+ /**
149
+ * Execute a reveal recipe.
150
+ * @param {Object} recipe
151
+ * @param {Function} [onDone] Called when the effect completes
152
+ */
153
+ reveal(recipe, onDone) {
154
+ if (destroyed || activeRecipe) return;
155
+
156
+ activeRecipe = recipe;
157
+ onComplete = onDone || null;
158
+ elapsed = 0;
159
+ engine.clear();
160
+
161
+ // Size the effect canvas to match the scratch layer
162
+ const w = effectCanvas.width = sourceCanvas.offsetWidth || sourceCanvas.width;
163
+ const h = effectCanvas.height = sourceCanvas.offsetHeight || sourceCanvas.height;
164
+
165
+ // Scan spawn points from visible pixels (fills spotX/spotY, sets spotCount).
166
+ scanPixels();
167
+
168
+ // Init recipe
169
+ recipe.init(ctx, maxParticles, w, h);
170
+
171
+ // Populate ring buffer. A recipe may declare `count: 0` (a pure-canvas reveal
172
+ // like Shine or Implosion that draws no particles); use ?? so an explicit 0 is
173
+ // honoured rather than falling back to maxParticles.
174
+ const requested = recipe.count ?? maxParticles;
175
+ const count = Math.min(requested, maxParticles);
176
+ for (let i = 0; i < count; i++) {
177
+ const idx = engine._head;
178
+ if (spotCount > 0) {
179
+ const k = rng.int(0, spotCount - 1);
180
+ spot.x = spotX[k];
181
+ spot.y = spotY[k];
182
+ } else {
183
+ spot.x = 0.5;
184
+ spot.y = 0.5;
185
+ }
186
+ const state = recipe.spawn(idx, rng, w, h, spot);
187
+ engine.emit(state.x, state.y, state.vx, state.vy, state.life, idx);
188
+ }
189
+
190
+ // The engine owns its RAF loop; start() schedules it and drives onTick.
191
+ engine.start();
192
+ },
193
+
194
+ /** Re-seed the RNG. */
195
+ seed(s) { rng.reset(s); },
196
+
197
+ destroy() {
198
+ if (destroyed) return;
199
+ destroyed = true;
200
+ engine.stop();
201
+ if (activeRecipe && activeRecipe.destroy) activeRecipe.destroy();
202
+ activeRecipe = null;
203
+ onComplete = null;
204
+ scanCanvas = null;
205
+ scanCtx = null;
206
+ engine.destroy();
207
+ },
208
+ };
209
+ }
210
+
211
+ export default createScratchController;