@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.
@@ -0,0 +1,642 @@
1
+ /**
2
+ * ScratchRecipes2 — 10 New Reveal Effects
3
+ *
4
+ * All implement the ScratchController recipe interface:
5
+ * { count, init(ctx, capacity, w, h),
6
+ * spawn(idx, rng, w, h, spot),
7
+ * tick(dt, elapsedMs, engine, ctx, sourceCanvas, w, h) → boolean,
8
+ * destroy() }
9
+ *
10
+ * Bugs fixed vs proposal:
11
+ * - glitchReveal: Added onload guard for toDataURL image decode
12
+ * - goldDust/cosmicDust: globalCompositeOperation reset after every frame
13
+ * - pixelShatter: Stores ORIGINAL position for source crop (was using animated pos)
14
+ * - laserScan: shadowBlur reset after draw; clipPath cleaned on destroy
15
+ * - confettiBlast: Dedicated parallel arrays instead of repurposing size/decay
16
+ * - liquidMelt: Replaced roundRect() with fillRect (Safari < 17.4 compat)
17
+ * - neonPulse: Transform cleaned up on completion
18
+ * - All: Use rng param instead of Math.random() for deterministic replay
19
+ * - All: Proper globalCompositeOperation/globalAlpha reset on every frame
20
+ *
21
+ * Uses: @zakkster/lite-lerp (easeIn, easeOut, easeInOut, clamp, lerp)
22
+ *
23
+ * IMPORTANT CONTRACT: The ScratchController calls ctx.clearRect() before
24
+ * every tick(). Recipes must NOT clear the canvas themselves. If a recipe
25
+ * needs persistent state (e.g. motion trails), it must use an offscreen
26
+ * canvas internally and drawImage() the result onto the provided ctx.
27
+ */
28
+
29
+ import {easeIn, easeOut, easeInOut, clamp, lerp} from '@zakkster/lite-lerp';
30
+ import {resolvePalette} from './Palette.js';
31
+
32
+
33
+ // ═══════════════════════════════════════════════════════════
34
+ // 1. GLITCH REVEAL — CRT slicing + RGB channel split
35
+ // ═══════════════════════════════════════════════════════════
36
+
37
+ export function GlitchRevealRecipe({duration = 800} = {}) {
38
+ let img = null;
39
+ let imgReady = false;
40
+ let sliceSeeds = null; // Pre-generated random values for deterministic slicing
41
+
42
+ return {
43
+ count: 0,
44
+ init(ctx, capacity, w, h) {
45
+ // Pre-generate slice randomness so tick() is deterministic
46
+ sliceSeeds = new Float32Array(200); // 20 slices × 10 values per slice
47
+ },
48
+ spawn(idx, rng, w, h, spot) {
49
+ // Populate slice seeds during spawn phase (has rng access)
50
+ for (let i = 0; i < sliceSeeds.length; i++) sliceSeeds[i] = rng.next();
51
+ return {x: 0, y: 0, vx: 0, vy: 0, life: 0};
52
+ },
53
+ tick(dt, elapsed, engine, ctx, src, w, h) {
54
+ // Lazy capture with onload guard
55
+ if (!img) {
56
+ img = new Image();
57
+ img.onload = () => {
58
+ imgReady = true;
59
+ };
60
+ img.src = src.toDataURL();
61
+ src.style.opacity = '0';
62
+ return false;
63
+ }
64
+ if (!imgReady) return false;
65
+
66
+ const raw = clamp(elapsed / duration, 0, 1);
67
+
68
+ if (raw > 0.9) return raw >= 1;
69
+
70
+ const intensity = 1 - easeIn(raw);
71
+ const sliceCount = 10 + (sliceSeeds[0] * 20) | 0;
72
+
73
+ ctx.globalAlpha = 1 - raw;
74
+ for (let i = 0; i < sliceCount; i++) {
75
+ const si = (i * 4) % sliceSeeds.length;
76
+ const sliceY = sliceSeeds[si] * h;
77
+ const sliceH = sliceSeeds[si + 1] * (h / 4);
78
+ const offsetX = (sliceSeeds[si + 2] - 0.5) * 100 * intensity;
79
+ const rgbShift = 5 * intensity;
80
+
81
+ // Red channel
82
+ ctx.globalCompositeOperation = 'screen';
83
+ ctx.drawImage(img, 0, sliceY, w, sliceH, offsetX - rgbShift, sliceY, w, sliceH);
84
+ // Cyan channel
85
+ ctx.drawImage(img, 0, sliceY, w, sliceH, offsetX + rgbShift, sliceY, w, sliceH);
86
+ }
87
+
88
+ ctx.globalCompositeOperation = 'source-over';
89
+ ctx.globalAlpha = 1;
90
+ return raw >= 1;
91
+ },
92
+ destroy() {
93
+ img = null;
94
+ imgReady = false;
95
+ sliceSeeds = null;
96
+ },
97
+ };
98
+ }
99
+
100
+
101
+ // ═══════════════════════════════════════════════════════════
102
+ // 2. MATRIX DECAY — Digital rain characters falling
103
+ // ═══════════════════════════════════════════════════════════
104
+
105
+ export function MatrixDecayRecipe({count = 200, duration = 1500, colors, theme} = {}) {
106
+ let pCharSeed; // Pre-computed char index per particle
107
+ const palette = resolvePalette(colors, theme, ['#00ff41']);
108
+ const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789$+-*/=%#&_(),.;:?!\\|{}<>[]^~';
109
+
110
+ return {
111
+ count,
112
+ init(ctx, capacity) {
113
+ pCharSeed = new Float32Array(capacity);
114
+ },
115
+ spawn(idx, rng, w, h, spot) {
116
+ pCharSeed[idx] = rng.next(); // Deterministic char offset
117
+ return {
118
+ x: spot.x * w,
119
+ y: spot.y * h,
120
+ vx: 0,
121
+ vy: rng.range(5, 10),
122
+ life: 1.0,
123
+ };
124
+ },
125
+ tick(dt, elapsed, engine, ctx, src, w, h) {
126
+ const {x, y, vx, vy, life, data, max} = engine;
127
+ const ds = dt * 60;
128
+ let alive = 0;
129
+
130
+ // Fade source
131
+ src.style.opacity = 1 - clamp(elapsed / (duration * 0.4), 0, 1);
132
+
133
+ // NOTE: No trail fillRect here. The ScratchController clears the canvas
134
+ // every frame before calling tick(). Crisp falling text over the fading
135
+ // scratch layer is cleaner for a reveal effect — no persistent black box
136
+ // obscuring the prize underneath.
137
+
138
+ ctx.font = '14px monospace';
139
+ ctx.textAlign = 'center';
140
+
141
+ for (let i = 0; i < max; i++) {
142
+ if (life[i] <= 0) continue;
143
+ const p = data[i];
144
+ y[i] += vy[i] * ds;
145
+ life[i] -= 0.015 * ds;
146
+ if (life[i] <= 0 || y[i] > h + 20) {
147
+ life[i] = 0;
148
+ continue;
149
+ }
150
+ alive++;
151
+
152
+ // Cycle through chars using seed + elapsed time
153
+ const charIdx = ((pCharSeed[p] * CHARS.length + elapsed * 0.03) | 0) % CHARS.length;
154
+ ctx.globalAlpha = life[i];
155
+ ctx.fillStyle = palette[0];
156
+ ctx.fillText(CHARS[charIdx], x[i], y[i]);
157
+ }
158
+
159
+ ctx.globalAlpha = 1;
160
+ ctx.textAlign = 'start';
161
+ return alive === 0 && elapsed >= duration;
162
+ },
163
+ destroy() {
164
+ pCharSeed = null;
165
+ },
166
+ };
167
+ }
168
+
169
+
170
+ // ═══════════════════════════════════════════════════════════
171
+ // 3. GOLD DUST — Shimmering gold particles floating up
172
+ // ═══════════════════════════════════════════════════════════
173
+
174
+ export function GoldDustRecipe({count = 150, duration = 2000, colors, theme} = {}) {
175
+ let pSize, pPhase; // pPhase drives twinkle, not rotation
176
+ const palette = resolvePalette(colors, theme, ['#FFD700']);
177
+
178
+ return {
179
+ count,
180
+ init(ctx, capacity) {
181
+ pSize = new Float32Array(capacity);
182
+ pPhase = new Float32Array(capacity);
183
+ },
184
+ spawn(idx, rng, w, h, spot) {
185
+ pSize[idx] = rng.range(1, 4);
186
+ pPhase[idx] = rng.range(0, Math.PI * 2);
187
+ return {
188
+ x: spot.x * w,
189
+ y: spot.y * h,
190
+ vx: rng.range(-1, 1),
191
+ vy: rng.range(-0.5, -2.5),
192
+ life: 1.0,
193
+ };
194
+ },
195
+ tick(dt, elapsed, engine, ctx, src, w, h) {
196
+ const {x, y, vx, vy, life, data, max} = engine;
197
+ const ds = dt * 60;
198
+ let alive = 0;
199
+
200
+ src.style.opacity = 1 - easeOut(clamp(elapsed / (duration * 0.5), 0, 1));
201
+
202
+ ctx.globalCompositeOperation = 'lighter';
203
+
204
+ for (let i = 0; i < max; i++) {
205
+ if (life[i] <= 0) continue;
206
+ const p = data[i];
207
+ x[i] += vx[i] * ds;
208
+ y[i] += vy[i] * ds;
209
+ pPhase[p] += 0.1 * ds;
210
+ life[i] -= 0.008 * ds;
211
+ if (life[i] <= 0) continue;
212
+ alive++;
213
+
214
+ const twinkle = (Math.sin(pPhase[p]) + 1) / 2;
215
+ ctx.globalAlpha = life[i] * twinkle;
216
+ ctx.fillStyle = palette[0];
217
+ ctx.beginPath();
218
+ ctx.arc(x[i], y[i], pSize[p], 0, Math.PI * 2);
219
+ ctx.fill();
220
+ }
221
+
222
+ // ← FIX: Reset composite operation every frame
223
+ ctx.globalCompositeOperation = 'source-over';
224
+ ctx.globalAlpha = 1;
225
+ return alive === 0 && elapsed >= duration;
226
+ },
227
+ destroy() {
228
+ pSize = pPhase = null;
229
+ },
230
+ };
231
+ }
232
+
233
+
234
+ // ═══════════════════════════════════════════════════════════
235
+ // 4. PIXEL SHATTER — Voxel blocks exploding outward
236
+ // ═══════════════════════════════════════════════════════════
237
+
238
+ export function PixelShatterRecipe({count = 60, duration = 1200} = {}) {
239
+ let pRot, pRotSpd, pSz, pOrigX, pOrigY; // ← FIX: Store ORIGINAL positions
240
+ let img = null, imgReady = false;
241
+
242
+ return {
243
+ count,
244
+ init(ctx, capacity) {
245
+ pRot = new Float32Array(capacity);
246
+ pRotSpd = new Float32Array(capacity);
247
+ pSz = new Float32Array(capacity);
248
+ pOrigX = new Float32Array(capacity);
249
+ pOrigY = new Float32Array(capacity);
250
+ },
251
+ spawn(idx, rng, w, h, spot) {
252
+ const sx = spot.x * w, sy = spot.y * h;
253
+ pRot[idx] = rng.range(0, Math.PI);
254
+ pRotSpd[idx] = rng.range(-0.3, 0.3);
255
+ pSz[idx] = rng.range(5, 15);
256
+ pOrigX[idx] = sx; // ← FIX: Store spawn position for source crop
257
+ pOrigY[idx] = sy;
258
+
259
+ const angle = rng.range(0, Math.PI * 2);
260
+ const spd = rng.range(5, 15);
261
+ return {x: sx, y: sy, vx: Math.cos(angle) * spd, vy: Math.sin(angle) * spd, life: 1.0};
262
+ },
263
+ tick(dt, elapsed, engine, ctx, src, w, h) {
264
+ if (!img) {
265
+ img = new Image();
266
+ img.onload = () => {
267
+ imgReady = true;
268
+ };
269
+ img.src = src.toDataURL();
270
+ src.style.opacity = '0';
271
+ return false;
272
+ }
273
+ if (!imgReady) return false;
274
+
275
+ const {x, y, vx, vy, life, data, max} = engine;
276
+ const ds = dt * 60;
277
+ const progress = clamp(elapsed / duration, 0, 1);
278
+ let alive = 0;
279
+
280
+
281
+ for (let i = 0; i < max; i++) {
282
+ if (life[i] <= 0) continue;
283
+ const p = data[i];
284
+ x[i] += vx[i] * ds;
285
+ y[i] += vy[i] * ds;
286
+ vx[i] *= 0.95;
287
+ vy[i] *= 0.95; // Drag
288
+ pRot[p] += pRotSpd[p];
289
+ life[i] -= 0.02 * ds;
290
+ if (life[i] <= 0) continue;
291
+ alive++;
292
+
293
+ const sz = pSz[p];
294
+ ctx.save();
295
+ ctx.globalAlpha = life[i];
296
+ ctx.translate(x[i], y[i]);
297
+ ctx.rotate(pRot[p]);
298
+ // ← FIX: Source crop uses ORIGINAL position, not current animated position
299
+ ctx.drawImage(img, pOrigX[p], pOrigY[p], sz, sz, -sz / 2, -sz / 2, sz, sz);
300
+ ctx.restore();
301
+ }
302
+ return alive === 0;
303
+ },
304
+ destroy() {
305
+ pRot = pRotSpd = pSz = pOrigX = pOrigY = null;
306
+ img = null;
307
+ imgReady = false;
308
+ },
309
+ };
310
+ }
311
+
312
+
313
+ // ═══════════════════════════════════════════════════════════
314
+ // 5. IMPLOSION — Spin + shrink + speed lines
315
+ // ═══════════════════════════════════════════════════════════
316
+
317
+
318
+ // ═══════════════════════════════════════════════════════════
319
+ // 6. LASER SCAN — Horizontal sweeping line burns the canvas
320
+ // ═══════════════════════════════════════════════════════════
321
+
322
+ export function LaserScanRecipe({duration = 1000, colors, theme} = {}) {
323
+ let sparkSeeds = null;
324
+ const palette = resolvePalette(colors, theme, ['#00ffff']);
325
+
326
+ return {
327
+ count: 0,
328
+ init(ctx, capacity, w, h) {
329
+ sparkSeeds = new Float32Array(20); // Pre-computed spark X positions
330
+ },
331
+ spawn(idx, rng) {
332
+ for (let i = 0; i < sparkSeeds.length; i++) sparkSeeds[i] = rng.next();
333
+ return {x: 0, y: 0, vx: 0, vy: 0, life: 0};
334
+ },
335
+ tick(dt, elapsed, engine, ctx, src, w, h) {
336
+ const raw = clamp(elapsed / duration, 0, 1);
337
+ const scanY = raw * h;
338
+
339
+ // Clip source canvas to only show below the scan line
340
+ src.style.clipPath = `polygon(0 ${scanY}px, 100% ${scanY}px, 100% 100%, 0 100%)`;
341
+
342
+
343
+ if (raw < 0.98) {
344
+ ctx.globalCompositeOperation = 'lighter';
345
+
346
+ // Laser line core (themed) with a hot white centre
347
+ ctx.fillStyle = palette[0];
348
+ ctx.fillRect(0, scanY - 2, w, 4);
349
+ ctx.fillStyle = '#ffffff';
350
+ ctx.fillRect(0, scanY - 1, w, 2);
351
+
352
+ // Glow
353
+ ctx.shadowBlur = 15;
354
+ ctx.shadowColor = palette[0];
355
+
356
+ // Sparks (deterministic positions)
357
+ for (let i = 0; i < 5; i++) {
358
+ ctx.beginPath();
359
+ ctx.arc(sparkSeeds[i * 2] * w, scanY - sparkSeeds[i * 2 + 1] * 20, sparkSeeds[i * 2] * 2, 0, Math.PI * 2);
360
+ ctx.fill();
361
+ }
362
+
363
+ // ← FIX: Reset shadow + composite every frame
364
+ ctx.shadowBlur = 0;
365
+ ctx.shadowColor = 'transparent';
366
+ ctx.globalCompositeOperation = 'source-over';
367
+ }
368
+
369
+ if (raw >= 1) {
370
+ // ← FIX: Clean up clipPath on completion
371
+ src.style.clipPath = '';
372
+ return true;
373
+ }
374
+ return false;
375
+ },
376
+ destroy() {
377
+ sparkSeeds = null;
378
+ },
379
+ };
380
+ }
381
+
382
+
383
+ // ═══════════════════════════════════════════════════════════
384
+ // 7. CONFETTI BLAST — 3D-tumbling rectangles from center
385
+ // ═══════════════════════════════════════════════════════════
386
+
387
+ export function ConfettiBlastRecipe({count = 80, duration = 1500, colors, theme} = {}) {
388
+ // ← FIX: Dedicated parallel arrays instead of repurposing size/decay
389
+ let pWobblePhase, pWobbleSpeed, pColorIdx;
390
+ const palette = resolvePalette(colors, theme, ['#ff0055', '#00ffcc', '#ffcc00', '#aa00ff', '#ff6600', '#00aaff']);
391
+
392
+ return {
393
+ count,
394
+ init(ctx, capacity) {
395
+ pWobblePhase = new Float32Array(capacity);
396
+ pWobbleSpeed = new Float32Array(capacity);
397
+ pColorIdx = new Uint8Array(capacity);
398
+ },
399
+ spawn(idx, rng, w, h, spot) {
400
+ pWobblePhase[idx] = rng.range(0, Math.PI * 2);
401
+ pWobbleSpeed[idx] = rng.range(0.1, 0.3);
402
+ pColorIdx[idx] = rng.int(0, palette.length - 1);
403
+
404
+ const angle = rng.range(0, Math.PI * 2);
405
+ const spd = rng.range(5, 20);
406
+ return {x: w / 2, y: h / 2, vx: Math.cos(angle) * spd, vy: Math.sin(angle) * spd, life: 1.0};
407
+ },
408
+ tick(dt, elapsed, engine, ctx, src, w, h) {
409
+ const {x, y, vx, vy, life, data, max} = engine;
410
+ const ds = dt * 60;
411
+ let alive = 0;
412
+
413
+ src.style.opacity = 1 - easeOut(clamp(elapsed / (duration * 0.3), 0, 1));
414
+
415
+ for (let i = 0; i < max; i++) {
416
+ if (life[i] <= 0) continue;
417
+ const p = data[i];
418
+
419
+ vy[i] += 0.5; // Gravity
420
+ x[i] += vx[i] * ds;
421
+ y[i] += vy[i] * ds;
422
+ pWobblePhase[p] += pWobbleSpeed[p] * ds;
423
+ life[i] -= 0.015 * ds;
424
+ if (life[i] <= 0) continue;
425
+ alive++;
426
+
427
+ ctx.save();
428
+ ctx.globalAlpha = life[i];
429
+ ctx.translate(x[i], y[i]);
430
+ ctx.rotate(pWobblePhase[p]); // Spin
431
+ ctx.scale(1, Math.cos(pWobblePhase[p] * 1.7)); // 3D tumble
432
+ ctx.fillStyle = palette[pColorIdx[p]];
433
+ ctx.fillRect(-6, -4, 12, 8);
434
+ ctx.restore();
435
+ }
436
+ return alive === 0 && elapsed >= duration;
437
+ },
438
+ destroy() {
439
+ pWobblePhase = pWobbleSpeed = pColorIdx = null;
440
+ },
441
+ };
442
+ }
443
+
444
+
445
+ // ═══════════════════════════════════════════════════════════
446
+ // 8. LIQUID MELT — Pixels stretch and drip downward
447
+ // ═══════════════════════════════════════════════════════════
448
+
449
+ export function LiquidMeltRecipe({count = 100, duration = 1200, colors, theme} = {}) {
450
+ let pSize, pOriginY; // pOriginY = where the drip started (top of the streak)
451
+ const palette = resolvePalette(colors, theme, ['#1e293b']);
452
+
453
+ return {
454
+ count,
455
+ init(ctx, capacity) {
456
+ pSize = new Float32Array(capacity);
457
+ pOriginY = new Float32Array(capacity);
458
+ },
459
+ spawn(idx, rng, w, h, spot) {
460
+ const sy = spot.y * h;
461
+ pSize[idx] = rng.range(2, 8);
462
+ pOriginY[idx] = sy;
463
+ return {x: spot.x * w, y: sy, vx: 0, vy: rng.range(0, 2), life: 1.0};
464
+ },
465
+ tick(dt, elapsed, engine, ctx, src, w, h) {
466
+ const {x, y, vy, life, data, max} = engine;
467
+ const ds = dt * 60;
468
+ let alive = 0;
469
+
470
+ src.style.opacity = 1 - clamp(elapsed / (duration * 0.2), 0, 1);
471
+
472
+ for (let i = 0; i < max; i++) {
473
+ if (life[i] <= 0) continue;
474
+ const p = data[i];
475
+
476
+ vy[i] += 0.4; // Heavy gravity
477
+ y[i] += vy[i] * ds;
478
+ life[i] -= 0.015 * ds;
479
+ if (life[i] <= 0 || y[i] > h + 30) {
480
+ life[i] = 0;
481
+ continue;
482
+ }
483
+ alive++;
484
+
485
+ const sz = pSize[p];
486
+ const streakHeight = y[i] - pOriginY[p] + sz;
487
+
488
+ ctx.globalAlpha = life[i];
489
+ ctx.fillStyle = palette[0];
490
+ // ← FIX: Use fillRect instead of roundRect for Safari < 17.4 compat
491
+ ctx.fillRect(x[i] - sz / 2, pOriginY[p], sz, streakHeight);
492
+ // Rounded tip at bottom
493
+ ctx.beginPath();
494
+ ctx.arc(x[i], y[i], sz / 2, 0, Math.PI * 2);
495
+ ctx.fill();
496
+ }
497
+ ctx.globalAlpha = 1;
498
+ return alive === 0 && elapsed >= duration;
499
+ },
500
+ destroy() {
501
+ pSize = pOriginY = null;
502
+ },
503
+ };
504
+ }
505
+
506
+
507
+ // ═══════════════════════════════════════════════════════════
508
+ // 9. NEON PULSE — Expanding concentric rings from center
509
+ // ═══════════════════════════════════════════════════════════
510
+
511
+ export function NeonPulseRecipe({duration = 800, colors, theme} = {}) {
512
+ const palette = resolvePalette(colors, theme, ['#00ffcc']);
513
+ return {
514
+ count: 0,
515
+ init() {
516
+ },
517
+ spawn() {
518
+ return {x: 0, y: 0, vx: 0, vy: 0, life: 0};
519
+ },
520
+ tick(dt, elapsed, engine, ctx, src, w, h) {
521
+ const raw = clamp(elapsed / duration, 0, 1);
522
+ const t = easeOut(raw);
523
+ const cx = w / 2, cy = h / 2;
524
+
525
+ src.style.opacity = 1 - t;
526
+ // ← FIX: Clamp scale to prevent overflow; clean up on completion
527
+ src.style.transform = `scale(${1 + t * 0.3})`;
528
+ src.style.transformOrigin = 'center';
529
+
530
+ ctx.globalCompositeOperation = 'lighter';
531
+
532
+ for (let i = 0; i < 3; i++) {
533
+ const r = t * Math.max(w, h) * 0.8 * (1 - i * 0.2);
534
+ if (r < 1) continue;
535
+ ctx.beginPath();
536
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
537
+ ctx.globalAlpha = (1 - t) * (0.8 - i * 0.2);
538
+ ctx.strokeStyle = palette[0];
539
+ ctx.lineWidth = 10 - i * 3;
540
+ ctx.stroke();
541
+ }
542
+
543
+ // ← FIX: Reset composite operation
544
+ ctx.globalCompositeOperation = 'source-over';
545
+ ctx.globalAlpha = 1;
546
+
547
+ if (raw >= 1) {
548
+ src.style.transform = '';
549
+ return true;
550
+ }
551
+ return false;
552
+ },
553
+ destroy() {
554
+ },
555
+ };
556
+ }
557
+
558
+
559
+ // ═══════════════════════════════════════════════════════════
560
+ // 10. COSMIC DUST — Spiral vortex consuming particles
561
+ // ═══════════════════════════════════════════════════════════
562
+
563
+ export function CosmicDustRecipe({count = 200, duration = 1800, colors, theme} = {}) {
564
+ let pAngle, pRadius, pColorIdx;
565
+ const palette = resolvePalette(colors, theme, ['#a78bfa', '#38bdf8', '#c084fc', '#6ee7b6']);
566
+
567
+ return {
568
+ count,
569
+ init(ctx, capacity) {
570
+ pAngle = new Float32Array(capacity);
571
+ pRadius = new Float32Array(capacity);
572
+ pColorIdx = new Uint8Array(capacity);
573
+ },
574
+ spawn(idx, rng, w, h, spot) {
575
+ const cx = w / 2, cy = h / 2;
576
+ const sx = spot.x * w, sy = spot.y * h;
577
+ pAngle[idx] = Math.atan2(sy - cy, sx - cx);
578
+ pRadius[idx] = Math.hypot(sx - cx, sy - cy);
579
+ pColorIdx[idx] = rng.int(0, palette.length - 1);
580
+ return {x: sx, y: sy, vx: 0, vy: 0, life: 1.0};
581
+ },
582
+ tick(dt, elapsed, engine, ctx, src, w, h) {
583
+ const {x, y, life, data, max} = engine;
584
+ const ds = dt * 60;
585
+ let alive = 0;
586
+ const cx = w / 2, cy = h / 2;
587
+
588
+ src.style.opacity = 1 - clamp(elapsed / (duration * 0.15), 0, 1);
589
+ ctx.globalCompositeOperation = 'lighter';
590
+
591
+ for (let i = 0; i < max; i++) {
592
+ if (life[i] <= 0) continue;
593
+ const p = data[i];
594
+
595
+ pRadius[p] *= 0.95; // Spiral inward
596
+ pAngle[p] += 0.2 * ds; // Spin
597
+ x[i] = cx + Math.cos(pAngle[p]) * pRadius[p];
598
+ y[i] = cy + Math.sin(pAngle[p]) * pRadius[p];
599
+ life[i] -= 0.01 * ds;
600
+
601
+ if (life[i] <= 0 || pRadius[p] < 2) {
602
+ life[i] = 0;
603
+ continue;
604
+ }
605
+ alive++;
606
+
607
+ ctx.globalAlpha = life[i];
608
+ ctx.fillStyle = palette[pColorIdx[p]];
609
+ ctx.beginPath();
610
+ ctx.arc(x[i], y[i], 2 + life[i] * 2, 0, Math.PI * 2);
611
+ ctx.fill();
612
+ }
613
+
614
+ // ← FIX: Reset composite operation every frame
615
+ ctx.globalCompositeOperation = 'source-over';
616
+ ctx.globalAlpha = 1;
617
+ return alive === 0 && elapsed >= duration;
618
+ },
619
+ destroy() {
620
+ pAngle = pRadius = pColorIdx = null;
621
+ },
622
+ };
623
+ }
624
+
625
+
626
+ // ═══════════════════════════════════════════════════════════
627
+ // ALL RECIPES MAP
628
+ // ═══════════════════════════════════════════════════════════
629
+
630
+ export const ScratchRecipes2 = {
631
+ GlitchRevealRecipe,
632
+ MatrixDecayRecipe,
633
+ GoldDustRecipe,
634
+ PixelShatterRecipe,
635
+ LaserScanRecipe,
636
+ ConfettiBlastRecipe,
637
+ LiquidMeltRecipe,
638
+ NeonPulseRecipe,
639
+ CosmicDustRecipe,
640
+ };
641
+
642
+ export default ScratchRecipes2;