@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.
@@ -1,29 +1,32 @@
1
1
  /**
2
2
  * Scratch Card Reveal Recipes
3
3
  *
4
- * 12 one-shot reveal effects, each a standalone factory function.
5
- * Designed to be passed to ScratchController.reveal(recipe, onDone).
4
+ * All 21 one-shot reveal effects, each a standalone factory function passed to
5
+ * ScratchController.reveal(recipe, onDone). Grouped by family below.
6
6
  *
7
7
  * Recipe interface:
8
8
  * { count, init(ctx, capacity, w, h),
9
9
  * spawn(idx, rng, w, h, spot),
10
- * tick(dt, elapsedMs, engine, ctx, sourceCanvas, w, h) boolean,
10
+ * tick(dt, elapsedMs, engine, ctx, sourceCanvas, w, h) -> boolean,
11
11
  * destroy() }
12
12
  *
13
- * tick() returns `true` when the effect is complete.
13
+ * tick() returns true when the effect is complete. `spot` is a shared, reused object:
14
+ * read spot.x / spot.y synchronously and never retain it.
14
15
  *
15
16
  * Uses:
16
- * @zakkster/lite-lerp easeIn, easeOut, easeInOut, clamp, lerp
17
- * ./Palette.js resolvePalette (colors / theme option handling)
17
+ * @zakkster/lite-lerp -- easeIn, easeOut, easeInOut, clamp, lerp
18
+ * ./Palette.js -- resolvePalette (colors / theme option handling)
18
19
  */
19
20
 
20
21
  import {easeIn, easeOut, easeInOut, clamp, lerp} from '@zakkster/lite-lerp';
21
22
  import {resolvePalette} from './Palette.js';
22
23
 
23
24
 
24
- // ═══════════════════════════════════════════════════════════
25
- // 1. BURN — Ember particles rising from visible pixels
26
- // ═══════════════════════════════════════════════════════════
25
+ // ===========================================================================
26
+ // PARTICLE REVEALS
27
+ // ===========================================================================
28
+ // Spawn particles from the scratch layer's still-covered pixels and run their own physics.
29
+ // ===========================================================================
27
30
 
28
31
  export function BurnRecipe({count = 150, duration = 1500, colors, theme} = {}) {
29
32
  let pSize, pDecay, pColorIdx;
@@ -78,76 +81,6 @@ export function BurnRecipe({count = 150, duration = 1500, colors, theme} = {}) {
78
81
  }
79
82
 
80
83
 
81
- // ═══════════════════════════════════════════════════════════
82
- // 2. SHATTER — Glass pieces with gravity + rotation
83
- // ═══════════════════════════════════════════════════════════
84
-
85
- export function ShatterRecipe({count = 30, duration = 1200, gravity = 0.5} = {}) {
86
- let pRot, pRotSpd, pSz, pOpacity;
87
- let img = null;
88
-
89
- return {
90
- count,
91
- init(ctx, capacity, w, h) {
92
- pRot = new Float32Array(capacity);
93
- pRotSpd = new Float32Array(capacity);
94
- pSz = new Float32Array(capacity);
95
- pOpacity = new Float32Array(capacity);
96
- },
97
- spawn(idx, rng, w, h, spot) {
98
- pRot[idx] = rng.range(0, Math.PI * 2);
99
- pRotSpd[idx] = rng.range(-0.2, 0.2);
100
- pSz[idx] = Math.sqrt((w * h) / count);
101
- pOpacity[idx] = 1;
102
- return {x: spot.x * w, y: spot.y * h, vx: rng.range(-15, 15), vy: rng.range(-10, -2), life: 1.0};
103
- },
104
- tick(dt, elapsed, engine, ctx, src, w, h) {
105
- // Lazy-capture source image on first frame
106
- if (!img) {
107
- img = new Image();
108
- img.src = src.toDataURL();
109
- src.style.opacity = '0';
110
- }
111
- if (!img.complete) return false;
112
-
113
- const {x, y, vx, vy, life, data, max} = engine;
114
- const progress = clamp(elapsed / duration, 0, 1);
115
- let alive = 0;
116
-
117
- for (let i = 0; i < max; i++) {
118
- if (life[i] <= 0) continue;
119
- const p = data[i];
120
- x[i] += vx[i] * dt * 60;
121
- y[i] += vy[i] * dt * 60;
122
- vy[i] += gravity;
123
- pRot[p] += pRotSpd[p];
124
- pOpacity[p] = 1 - progress;
125
- if (pOpacity[p] <= 0.01) {
126
- life[i] = 0;
127
- continue;
128
- }
129
- alive++;
130
- ctx.save();
131
- ctx.globalAlpha = pOpacity[p];
132
- ctx.translate(x[i], y[i]);
133
- ctx.rotate(pRot[p]);
134
- ctx.drawImage(img, -pSz[p] / 2, -pSz[p] / 2, pSz[p], pSz[p]);
135
- ctx.restore();
136
- }
137
- return alive === 0;
138
- },
139
- destroy() {
140
- pRot = pRotSpd = pSz = pOpacity = null;
141
- img = null;
142
- },
143
- };
144
- }
145
-
146
-
147
- // ═══════════════════════════════════════════════════════════
148
- // 3. DISSOLVE — Particles drift upward (Thanos snap)
149
- // ═══════════════════════════════════════════════════════════
150
-
151
84
  export function DissolveRecipe({count = 150, duration = 1000, colors, theme} = {}) {
152
85
  let pSize;
153
86
  const palette = resolvePalette(colors, theme, ['#DC143C']);
@@ -189,10 +122,6 @@ export function DissolveRecipe({count = 150, duration = 1000, colors, theme} = {
189
122
  }
190
123
 
191
124
 
192
- // ═══════════════════════════════════════════════════════════
193
- // 4. EXPLODE — Radial burst from centroid
194
- // ═══════════════════════════════════════════════════════════
195
-
196
125
  export function ExplodeRecipe({count = 80, duration = 750, force = 15, colors, theme} = {}) {
197
126
  let pSize, pColorIdx;
198
127
  const palette = resolvePalette(colors, theme, ['#DC143C', '#8B0000', '#5C0000']);
@@ -240,10 +169,6 @@ export function ExplodeRecipe({count = 80, duration = 750, force = 15, colors, t
240
169
  }
241
170
 
242
171
 
243
- // ═══════════════════════════════════════════════════════════
244
- // 5. DRAGON BREATH — Fire cone from below, additive blend
245
- // ═══════════════════════════════════════════════════════════
246
-
247
172
  export function DragonBreathRecipe({count = 200, duration = 1200, colors, theme} = {}) {
248
173
  let pSize, pDecay, pColorIdx;
249
174
  const palette = resolvePalette(colors, theme, ['#FFF', '#FFD700', '#FF4500', '#8B0000', '#2F2F2F']);
@@ -308,10 +233,6 @@ export function DragonBreathRecipe({count = 200, duration = 1200, colors, theme}
308
233
  }
309
234
 
310
235
 
311
- // ═══════════════════════════════════════════════════════════
312
- // 6. ICE BREATH — Crystal shards, screen blend, friction
313
- // ═══════════════════════════════════════════════════════════
314
-
315
236
  export function IceBreathRecipe({count = 300, duration = 1500, colors, theme} = {}) {
316
237
  let pSize, pDecay, pRot, pRotSpd, pColorIdx;
317
238
  const palette = resolvePalette(colors, theme, ['#FFF', '#E0FFFF', '#00FFFF', '#1E90FF', '#4682B4']);
@@ -390,9 +311,551 @@ export function IceBreathRecipe({count = 300, duration = 1500, colors, theme} =
390
311
  }
391
312
 
392
313
 
393
- // ═══════════════════════════════════════════════════════════
394
- // 7. SHINE WAVE Energy beam sweep L→R
395
- // ═══════════════════════════════════════════════════════════
314
+ export function GoldDustRecipe({count = 150, duration = 2000, colors, theme} = {}) {
315
+ let pSize, pPhase; // pPhase drives twinkle, not rotation
316
+ const palette = resolvePalette(colors, theme, ['#FFD700']);
317
+
318
+ return {
319
+ count,
320
+ init(ctx, capacity) {
321
+ pSize = new Float32Array(capacity);
322
+ pPhase = new Float32Array(capacity);
323
+ },
324
+ spawn(idx, rng, w, h, spot) {
325
+ pSize[idx] = rng.range(1, 4);
326
+ pPhase[idx] = rng.range(0, Math.PI * 2);
327
+ return {
328
+ x: spot.x * w,
329
+ y: spot.y * h,
330
+ vx: rng.range(-1, 1),
331
+ vy: rng.range(-0.5, -2.5),
332
+ life: 1.0,
333
+ };
334
+ },
335
+ tick(dt, elapsed, engine, ctx, src, w, h) {
336
+ const {x, y, vx, vy, life, data, max} = engine;
337
+ const ds = dt * 60;
338
+ let alive = 0;
339
+
340
+ src.style.opacity = 1 - easeOut(clamp(elapsed / (duration * 0.5), 0, 1));
341
+
342
+ ctx.globalCompositeOperation = 'lighter';
343
+
344
+ for (let i = 0; i < max; i++) {
345
+ if (life[i] <= 0) continue;
346
+ const p = data[i];
347
+ x[i] += vx[i] * ds;
348
+ y[i] += vy[i] * ds;
349
+ pPhase[p] += 0.1 * ds;
350
+ life[i] -= 0.008 * ds;
351
+ if (life[i] <= 0) continue;
352
+ alive++;
353
+
354
+ const twinkle = (Math.sin(pPhase[p]) + 1) / 2;
355
+ ctx.globalAlpha = life[i] * twinkle;
356
+ ctx.fillStyle = palette[0];
357
+ ctx.beginPath();
358
+ ctx.arc(x[i], y[i], pSize[p], 0, Math.PI * 2);
359
+ ctx.fill();
360
+ }
361
+
362
+ // ← FIX: Reset composite operation every frame
363
+ ctx.globalCompositeOperation = 'source-over';
364
+ ctx.globalAlpha = 1;
365
+ return alive === 0 && elapsed >= duration;
366
+ },
367
+ destroy() {
368
+ pSize = pPhase = null;
369
+ },
370
+ };
371
+ }
372
+
373
+
374
+ export function ConfettiBlastRecipe({count = 80, duration = 1500, colors, theme} = {}) {
375
+ // ← FIX: Dedicated parallel arrays instead of repurposing size/decay
376
+ let pWobblePhase, pWobbleSpeed, pColorIdx;
377
+ const palette = resolvePalette(colors, theme, ['#ff0055', '#00ffcc', '#ffcc00', '#aa00ff', '#ff6600', '#00aaff']);
378
+
379
+ return {
380
+ count,
381
+ init(ctx, capacity) {
382
+ pWobblePhase = new Float32Array(capacity);
383
+ pWobbleSpeed = new Float32Array(capacity);
384
+ pColorIdx = new Uint8Array(capacity);
385
+ },
386
+ spawn(idx, rng, w, h, spot) {
387
+ pWobblePhase[idx] = rng.range(0, Math.PI * 2);
388
+ pWobbleSpeed[idx] = rng.range(0.1, 0.3);
389
+ pColorIdx[idx] = rng.int(0, palette.length - 1);
390
+
391
+ const angle = rng.range(0, Math.PI * 2);
392
+ const spd = rng.range(5, 20);
393
+ return {x: w / 2, y: h / 2, vx: Math.cos(angle) * spd, vy: Math.sin(angle) * spd, life: 1.0};
394
+ },
395
+ tick(dt, elapsed, engine, ctx, src, w, h) {
396
+ const {x, y, vx, vy, life, data, max} = engine;
397
+ const ds = dt * 60;
398
+ let alive = 0;
399
+
400
+ src.style.opacity = 1 - easeOut(clamp(elapsed / (duration * 0.3), 0, 1));
401
+
402
+ for (let i = 0; i < max; i++) {
403
+ if (life[i] <= 0) continue;
404
+ const p = data[i];
405
+
406
+ vy[i] += 0.5; // Gravity
407
+ x[i] += vx[i] * ds;
408
+ y[i] += vy[i] * ds;
409
+ pWobblePhase[p] += pWobbleSpeed[p] * ds;
410
+ life[i] -= 0.015 * ds;
411
+ if (life[i] <= 0) continue;
412
+ alive++;
413
+
414
+ ctx.save();
415
+ ctx.globalAlpha = life[i];
416
+ ctx.translate(x[i], y[i]);
417
+ ctx.rotate(pWobblePhase[p]); // Spin
418
+ ctx.scale(1, Math.cos(pWobblePhase[p] * 1.7)); // 3D tumble
419
+ ctx.fillStyle = palette[pColorIdx[p]];
420
+ ctx.fillRect(-6, -4, 12, 8);
421
+ ctx.restore();
422
+ }
423
+ return alive === 0 && elapsed >= duration;
424
+ },
425
+ destroy() {
426
+ pWobblePhase = pWobbleSpeed = pColorIdx = null;
427
+ },
428
+ };
429
+ }
430
+
431
+
432
+ export function CosmicDustRecipe({count = 200, duration = 1800, colors, theme} = {}) {
433
+ let pAngle, pRadius, pColorIdx;
434
+ const palette = resolvePalette(colors, theme, ['#a78bfa', '#38bdf8', '#c084fc', '#6ee7b6']);
435
+
436
+ return {
437
+ count,
438
+ init(ctx, capacity) {
439
+ pAngle = new Float32Array(capacity);
440
+ pRadius = new Float32Array(capacity);
441
+ pColorIdx = new Uint8Array(capacity);
442
+ },
443
+ spawn(idx, rng, w, h, spot) {
444
+ const cx = w / 2, cy = h / 2;
445
+ const sx = spot.x * w, sy = spot.y * h;
446
+ pAngle[idx] = Math.atan2(sy - cy, sx - cx);
447
+ pRadius[idx] = Math.hypot(sx - cx, sy - cy);
448
+ pColorIdx[idx] = rng.int(0, palette.length - 1);
449
+ return {x: sx, y: sy, vx: 0, vy: 0, life: 1.0};
450
+ },
451
+ tick(dt, elapsed, engine, ctx, src, w, h) {
452
+ const {x, y, life, data, max} = engine;
453
+ const ds = dt * 60;
454
+ let alive = 0;
455
+ const cx = w / 2, cy = h / 2;
456
+
457
+ src.style.opacity = 1 - clamp(elapsed / (duration * 0.15), 0, 1);
458
+ ctx.globalCompositeOperation = 'lighter';
459
+
460
+ for (let i = 0; i < max; i++) {
461
+ if (life[i] <= 0) continue;
462
+ const p = data[i];
463
+
464
+ pRadius[p] *= 0.95; // Spiral inward
465
+ pAngle[p] += 0.2 * ds; // Spin
466
+ x[i] = cx + Math.cos(pAngle[p]) * pRadius[p];
467
+ y[i] = cy + Math.sin(pAngle[p]) * pRadius[p];
468
+ life[i] -= 0.01 * ds;
469
+
470
+ if (life[i] <= 0 || pRadius[p] < 2) {
471
+ life[i] = 0;
472
+ continue;
473
+ }
474
+ alive++;
475
+
476
+ ctx.globalAlpha = life[i];
477
+ ctx.fillStyle = palette[pColorIdx[p]];
478
+ ctx.beginPath();
479
+ ctx.arc(x[i], y[i], 2 + life[i] * 2, 0, Math.PI * 2);
480
+ ctx.fill();
481
+ }
482
+
483
+ // ← FIX: Reset composite operation every frame
484
+ ctx.globalCompositeOperation = 'source-over';
485
+ ctx.globalAlpha = 1;
486
+ return alive === 0 && elapsed >= duration;
487
+ },
488
+ destroy() {
489
+ pAngle = pRadius = pColorIdx = null;
490
+ },
491
+ };
492
+ }
493
+
494
+
495
+ export function MatrixDecayRecipe({count = 200, duration = 1500, colors, theme} = {}) {
496
+ let pCharSeed; // Pre-computed char index per particle
497
+ const palette = resolvePalette(colors, theme, ['#00ff41']);
498
+ const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789$+-*/=%#&_(),.;:?!\\|{}<>[]^~';
499
+
500
+ return {
501
+ count,
502
+ init(ctx, capacity) {
503
+ pCharSeed = new Float32Array(capacity);
504
+ },
505
+ spawn(idx, rng, w, h, spot) {
506
+ pCharSeed[idx] = rng.next(); // Deterministic char offset
507
+ return {
508
+ x: spot.x * w,
509
+ y: spot.y * h,
510
+ vx: 0,
511
+ vy: rng.range(5, 10),
512
+ life: 1.0,
513
+ };
514
+ },
515
+ tick(dt, elapsed, engine, ctx, src, w, h) {
516
+ const {x, y, vx, vy, life, data, max} = engine;
517
+ const ds = dt * 60;
518
+ let alive = 0;
519
+
520
+ // Fade source
521
+ src.style.opacity = 1 - clamp(elapsed / (duration * 0.4), 0, 1);
522
+
523
+ // NOTE: No trail fillRect here. The ScratchController clears the canvas
524
+ // every frame before calling tick(). Crisp falling text over the fading
525
+ // scratch layer is cleaner for a reveal effect — no persistent black box
526
+ // obscuring the prize underneath.
527
+
528
+ ctx.font = '14px monospace';
529
+ ctx.textAlign = 'center';
530
+
531
+ for (let i = 0; i < max; i++) {
532
+ if (life[i] <= 0) continue;
533
+ const p = data[i];
534
+ y[i] += vy[i] * ds;
535
+ life[i] -= 0.015 * ds;
536
+ if (life[i] <= 0 || y[i] > h + 20) {
537
+ life[i] = 0;
538
+ continue;
539
+ }
540
+ alive++;
541
+
542
+ // Cycle through chars using seed + elapsed time
543
+ const charIdx = ((pCharSeed[p] * CHARS.length + elapsed * 0.03) | 0) % CHARS.length;
544
+ ctx.globalAlpha = life[i];
545
+ ctx.fillStyle = palette[0];
546
+ ctx.fillText(CHARS[charIdx], x[i], y[i]);
547
+ }
548
+
549
+ ctx.globalAlpha = 1;
550
+ ctx.textAlign = 'start';
551
+ return alive === 0 && elapsed >= duration;
552
+ },
553
+ destroy() {
554
+ pCharSeed = null;
555
+ },
556
+ };
557
+ }
558
+
559
+
560
+ export function LiquidMeltRecipe({count = 100, duration = 1200, colors, theme} = {}) {
561
+ let pSize, pOriginY; // pOriginY = where the drip started (top of the streak)
562
+ const palette = resolvePalette(colors, theme, ['#1e293b']);
563
+
564
+ return {
565
+ count,
566
+ init(ctx, capacity) {
567
+ pSize = new Float32Array(capacity);
568
+ pOriginY = new Float32Array(capacity);
569
+ },
570
+ spawn(idx, rng, w, h, spot) {
571
+ const sy = spot.y * h;
572
+ pSize[idx] = rng.range(2, 8);
573
+ pOriginY[idx] = sy;
574
+ return {x: spot.x * w, y: sy, vx: 0, vy: rng.range(0, 2), life: 1.0};
575
+ },
576
+ tick(dt, elapsed, engine, ctx, src, w, h) {
577
+ const {x, y, vy, life, data, max} = engine;
578
+ const ds = dt * 60;
579
+ let alive = 0;
580
+
581
+ src.style.opacity = 1 - clamp(elapsed / (duration * 0.2), 0, 1);
582
+
583
+ for (let i = 0; i < max; i++) {
584
+ if (life[i] <= 0) continue;
585
+ const p = data[i];
586
+
587
+ vy[i] += 0.4; // Heavy gravity
588
+ y[i] += vy[i] * ds;
589
+ life[i] -= 0.015 * ds;
590
+ if (life[i] <= 0 || y[i] > h + 30) {
591
+ life[i] = 0;
592
+ continue;
593
+ }
594
+ alive++;
595
+
596
+ const sz = pSize[p];
597
+ const streakHeight = y[i] - pOriginY[p] + sz;
598
+
599
+ ctx.globalAlpha = life[i];
600
+ ctx.fillStyle = palette[0];
601
+ // ← FIX: Use fillRect instead of roundRect for Safari < 17.4 compat
602
+ ctx.fillRect(x[i] - sz / 2, pOriginY[p], sz, streakHeight);
603
+ // Rounded tip at bottom
604
+ ctx.beginPath();
605
+ ctx.arc(x[i], y[i], sz / 2, 0, Math.PI * 2);
606
+ ctx.fill();
607
+ }
608
+ ctx.globalAlpha = 1;
609
+ return alive === 0 && elapsed >= duration;
610
+ },
611
+ destroy() {
612
+ pSize = pOriginY = null;
613
+ },
614
+ };
615
+ }
616
+
617
+
618
+
619
+ // ===========================================================================
620
+ // IMAGE REVEALS
621
+ // ===========================================================================
622
+ // Snapshot the scratch layer via toDataURL() + Image and animate the pixels. Same-origin only.
623
+ // ===========================================================================
624
+
625
+ export function ShatterRecipe({count = 30, duration = 1200, gravity = 0.5} = {}) {
626
+ let pRot, pRotSpd, pSz, pOpacity;
627
+ let img = null;
628
+
629
+ return {
630
+ count,
631
+ init(ctx, capacity, w, h) {
632
+ pRot = new Float32Array(capacity);
633
+ pRotSpd = new Float32Array(capacity);
634
+ pSz = new Float32Array(capacity);
635
+ pOpacity = new Float32Array(capacity);
636
+ },
637
+ spawn(idx, rng, w, h, spot) {
638
+ pRot[idx] = rng.range(0, Math.PI * 2);
639
+ pRotSpd[idx] = rng.range(-0.2, 0.2);
640
+ pSz[idx] = Math.sqrt((w * h) / count);
641
+ pOpacity[idx] = 1;
642
+ return {x: spot.x * w, y: spot.y * h, vx: rng.range(-15, 15), vy: rng.range(-10, -2), life: 1.0};
643
+ },
644
+ tick(dt, elapsed, engine, ctx, src, w, h) {
645
+ // Lazy-capture source image on first frame
646
+ if (!img) {
647
+ img = new Image();
648
+ img.src = src.toDataURL();
649
+ src.style.opacity = '0';
650
+ }
651
+ if (!img.complete) return false;
652
+
653
+ const {x, y, vx, vy, life, data, max} = engine;
654
+ const progress = clamp(elapsed / duration, 0, 1);
655
+ let alive = 0;
656
+
657
+ for (let i = 0; i < max; i++) {
658
+ if (life[i] <= 0) continue;
659
+ const p = data[i];
660
+ x[i] += vx[i] * dt * 60;
661
+ y[i] += vy[i] * dt * 60;
662
+ vy[i] += gravity;
663
+ pRot[p] += pRotSpd[p];
664
+ pOpacity[p] = 1 - progress;
665
+ if (pOpacity[p] <= 0.01) {
666
+ life[i] = 0;
667
+ continue;
668
+ }
669
+ alive++;
670
+ ctx.save();
671
+ ctx.globalAlpha = pOpacity[p];
672
+ ctx.translate(x[i], y[i]);
673
+ ctx.rotate(pRot[p]);
674
+ ctx.drawImage(img, -pSz[p] / 2, -pSz[p] / 2, pSz[p], pSz[p]);
675
+ ctx.restore();
676
+ }
677
+ return alive === 0;
678
+ },
679
+ destroy() {
680
+ pRot = pRotSpd = pSz = pOpacity = null;
681
+ img = null;
682
+ },
683
+ };
684
+ }
685
+
686
+
687
+ export function PixelShatterRecipe({count = 60, duration = 1200} = {}) {
688
+ let pRot, pRotSpd, pSz, pOrigX, pOrigY; // ← FIX: Store ORIGINAL positions
689
+ let img = null, imgReady = false;
690
+
691
+ return {
692
+ count,
693
+ init(ctx, capacity) {
694
+ pRot = new Float32Array(capacity);
695
+ pRotSpd = new Float32Array(capacity);
696
+ pSz = new Float32Array(capacity);
697
+ pOrigX = new Float32Array(capacity);
698
+ pOrigY = new Float32Array(capacity);
699
+ },
700
+ spawn(idx, rng, w, h, spot) {
701
+ const sx = spot.x * w, sy = spot.y * h;
702
+ pRot[idx] = rng.range(0, Math.PI);
703
+ pRotSpd[idx] = rng.range(-0.3, 0.3);
704
+ pSz[idx] = rng.range(5, 15);
705
+ pOrigX[idx] = sx; // ← FIX: Store spawn position for source crop
706
+ pOrigY[idx] = sy;
707
+
708
+ const angle = rng.range(0, Math.PI * 2);
709
+ const spd = rng.range(5, 15);
710
+ return {x: sx, y: sy, vx: Math.cos(angle) * spd, vy: Math.sin(angle) * spd, life: 1.0};
711
+ },
712
+ tick(dt, elapsed, engine, ctx, src, w, h) {
713
+ if (!img) {
714
+ img = new Image();
715
+ img.onload = () => {
716
+ imgReady = true;
717
+ };
718
+ img.src = src.toDataURL();
719
+ src.style.opacity = '0';
720
+ return false;
721
+ }
722
+ if (!imgReady) return false;
723
+
724
+ const {x, y, vx, vy, life, data, max} = engine;
725
+ const ds = dt * 60;
726
+ const progress = clamp(elapsed / duration, 0, 1);
727
+ let alive = 0;
728
+
729
+
730
+ for (let i = 0; i < max; i++) {
731
+ if (life[i] <= 0) continue;
732
+ const p = data[i];
733
+ x[i] += vx[i] * ds;
734
+ y[i] += vy[i] * ds;
735
+ vx[i] *= 0.95;
736
+ vy[i] *= 0.95; // Drag
737
+ pRot[p] += pRotSpd[p];
738
+ life[i] -= 0.02 * ds;
739
+ if (life[i] <= 0) continue;
740
+ alive++;
741
+
742
+ const sz = pSz[p];
743
+ ctx.save();
744
+ ctx.globalAlpha = life[i];
745
+ ctx.translate(x[i], y[i]);
746
+ ctx.rotate(pRot[p]);
747
+ // ← FIX: Source crop uses ORIGINAL position, not current animated position
748
+ ctx.drawImage(img, pOrigX[p], pOrigY[p], sz, sz, -sz / 2, -sz / 2, sz, sz);
749
+ ctx.restore();
750
+ }
751
+ return alive === 0;
752
+ },
753
+ destroy() {
754
+ pRot = pRotSpd = pSz = pOrigX = pOrigY = null;
755
+ img = null;
756
+ imgReady = false;
757
+ },
758
+ };
759
+ }
760
+
761
+
762
+ export function GlitchRevealRecipe({duration = 800} = {}) {
763
+ let img = null;
764
+ let imgReady = false;
765
+ let sliceSeeds = null; // Pre-generated random values for deterministic slicing
766
+
767
+ return {
768
+ count: 0,
769
+ init(ctx, capacity, w, h) {
770
+ // Pre-generate slice randomness so tick() is deterministic
771
+ sliceSeeds = new Float32Array(200); // 20 slices × 10 values per slice
772
+ },
773
+ spawn(idx, rng, w, h, spot) {
774
+ // Populate slice seeds during spawn phase (has rng access)
775
+ for (let i = 0; i < sliceSeeds.length; i++) sliceSeeds[i] = rng.next();
776
+ return {x: 0, y: 0, vx: 0, vy: 0, life: 0};
777
+ },
778
+ tick(dt, elapsed, engine, ctx, src, w, h) {
779
+ // Lazy capture with onload guard
780
+ if (!img) {
781
+ img = new Image();
782
+ img.onload = () => {
783
+ imgReady = true;
784
+ };
785
+ img.src = src.toDataURL();
786
+ src.style.opacity = '0';
787
+ return false;
788
+ }
789
+ if (!imgReady) return false;
790
+
791
+ const raw = clamp(elapsed / duration, 0, 1);
792
+
793
+ if (raw > 0.9) return raw >= 1;
794
+
795
+ const intensity = 1 - easeIn(raw);
796
+ const sliceCount = 10 + (sliceSeeds[0] * 20) | 0;
797
+
798
+ ctx.globalAlpha = 1 - raw;
799
+ for (let i = 0; i < sliceCount; i++) {
800
+ const si = (i * 4) % sliceSeeds.length;
801
+ const sliceY = sliceSeeds[si] * h;
802
+ const sliceH = sliceSeeds[si + 1] * (h / 4);
803
+ const offsetX = (sliceSeeds[si + 2] - 0.5) * 100 * intensity;
804
+ const rgbShift = 5 * intensity;
805
+
806
+ // Red channel
807
+ ctx.globalCompositeOperation = 'screen';
808
+ ctx.drawImage(img, 0, sliceY, w, sliceH, offsetX - rgbShift, sliceY, w, sliceH);
809
+ // Cyan channel
810
+ ctx.drawImage(img, 0, sliceY, w, sliceH, offsetX + rgbShift, sliceY, w, sliceH);
811
+ }
812
+
813
+ ctx.globalCompositeOperation = 'source-over';
814
+ ctx.globalAlpha = 1;
815
+ return raw >= 1;
816
+ },
817
+ destroy() {
818
+ img = null;
819
+ imgReady = false;
820
+ sliceSeeds = null;
821
+ },
822
+ };
823
+ }
824
+
825
+
826
+
827
+ // ===========================================================================
828
+ // BEAM / CANVAS REVEALS
829
+ // ===========================================================================
830
+ // Draw light, beams, or lines straight onto the overlay (mostly count: 0).
831
+ // ===========================================================================
832
+
833
+ export function ShineRecipe({duration = 500, width = 250} = {}) {
834
+ return {
835
+ count: 0,
836
+ init() {
837
+ },
838
+ spawn() {
839
+ return {x: 0, y: 0, vx: 0, vy: 0, life: 0};
840
+ },
841
+ tick(dt, elapsed, engine, ctx, src, w, h) {
842
+ const raw = clamp(elapsed / duration, 0, 1);
843
+ const t = easeInOut(raw);
844
+ const posX = lerp(-width, w + width, t);
845
+ const g = ctx.createLinearGradient(posX - width / 2, 0, posX + width / 2, 0);
846
+ g.addColorStop(0, 'rgba(255,255,255,0)');
847
+ g.addColorStop(0.5, `rgba(255,255,255,${0.8 * (1 - Math.abs(t - 0.5) * 2)})`);
848
+ g.addColorStop(1, 'rgba(255,255,255,0)');
849
+ ctx.fillStyle = g;
850
+ ctx.fillRect(0, 0, w, h);
851
+ if (t > 0.4) src.style.opacity = 1 - (t - 0.4) / 0.5;
852
+ return raw >= 1;
853
+ },
854
+ destroy() {
855
+ },
856
+ };
857
+ }
858
+
396
859
 
397
860
  export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount = 60, colors, theme} = {}) {
398
861
  const palette = resolvePalette(colors, theme, ['#FFF', '#E0E8FF', '#B0C4FF', '#88AAFF']);
@@ -462,9 +925,66 @@ export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount =
462
925
  }
463
926
 
464
927
 
465
- // ═══════════════════════════════════════════════════════════
466
- // 8. LIGHTNING CRAWL — Procedural branching polylines
467
- // ═══════════════════════════════════════════════════════════
928
+ export function LaserScanRecipe({duration = 1000, colors, theme} = {}) {
929
+ let sparkSeeds = null;
930
+ const palette = resolvePalette(colors, theme, ['#00ffff']);
931
+
932
+ return {
933
+ count: 0,
934
+ init(ctx, capacity, w, h) {
935
+ sparkSeeds = new Float32Array(20); // Pre-computed spark X positions
936
+ },
937
+ spawn(idx, rng) {
938
+ for (let i = 0; i < sparkSeeds.length; i++) sparkSeeds[i] = rng.next();
939
+ return {x: 0, y: 0, vx: 0, vy: 0, life: 0};
940
+ },
941
+ tick(dt, elapsed, engine, ctx, src, w, h) {
942
+ const raw = clamp(elapsed / duration, 0, 1);
943
+ const scanY = raw * h;
944
+
945
+ // Clip source canvas to only show below the scan line
946
+ src.style.clipPath = `polygon(0 ${scanY}px, 100% ${scanY}px, 100% 100%, 0 100%)`;
947
+
948
+
949
+ if (raw < 0.98) {
950
+ ctx.globalCompositeOperation = 'lighter';
951
+
952
+ // Laser line core (themed) with a hot white centre
953
+ ctx.fillStyle = palette[0];
954
+ ctx.fillRect(0, scanY - 2, w, 4);
955
+ ctx.fillStyle = '#ffffff';
956
+ ctx.fillRect(0, scanY - 1, w, 2);
957
+
958
+ // Glow
959
+ ctx.shadowBlur = 15;
960
+ ctx.shadowColor = palette[0];
961
+
962
+ // Sparks (deterministic positions)
963
+ for (let i = 0; i < 5; i++) {
964
+ ctx.beginPath();
965
+ ctx.arc(sparkSeeds[i * 2] * w, scanY - sparkSeeds[i * 2 + 1] * 20, sparkSeeds[i * 2] * 2, 0, Math.PI * 2);
966
+ ctx.fill();
967
+ }
968
+
969
+ // ← FIX: Reset shadow + composite every frame
970
+ ctx.shadowBlur = 0;
971
+ ctx.shadowColor = 'transparent';
972
+ ctx.globalCompositeOperation = 'source-over';
973
+ }
974
+
975
+ if (raw >= 1) {
976
+ // ← FIX: Clean up clipPath on completion
977
+ src.style.clipPath = '';
978
+ return true;
979
+ }
980
+ return false;
981
+ },
982
+ destroy() {
983
+ sparkSeeds = null;
984
+ },
985
+ };
986
+ }
987
+
468
988
 
469
989
  export function LightningCrawlRecipe({boltCount = 5, duration = 600, colors, theme} = {}) {
470
990
  const palette = resolvePalette(colors, theme, ['#FFF', '#E0E8FF', '#88CCFF', '#4488FF']);
@@ -575,11 +1095,8 @@ export function LightningCrawlRecipe({boltCount = 5, duration = 600, colors, the
575
1095
  }
576
1096
 
577
1097
 
578
- // ═══════════════════════════════════════════════════════════
579
- // 9. SHINE Flash/glint sweep
580
- // ═══════════════════════════════════════════════════════════
581
-
582
- export function ShineRecipe({duration = 500, width = 250} = {}) {
1098
+ export function NeonPulseRecipe({duration = 800, colors, theme} = {}) {
1099
+ const palette = resolvePalette(colors, theme, ['#00ffcc']);
583
1100
  return {
584
1101
  count: 0,
585
1102
  init() {
@@ -589,16 +1106,36 @@ export function ShineRecipe({duration = 500, width = 250} = {}) {
589
1106
  },
590
1107
  tick(dt, elapsed, engine, ctx, src, w, h) {
591
1108
  const raw = clamp(elapsed / duration, 0, 1);
592
- const t = easeInOut(raw);
593
- const posX = lerp(-width, w + width, t);
594
- const g = ctx.createLinearGradient(posX - width / 2, 0, posX + width / 2, 0);
595
- g.addColorStop(0, 'rgba(255,255,255,0)');
596
- g.addColorStop(0.5, `rgba(255,255,255,${0.8 * (1 - Math.abs(t - 0.5) * 2)})`);
597
- g.addColorStop(1, 'rgba(255,255,255,0)');
598
- ctx.fillStyle = g;
599
- ctx.fillRect(0, 0, w, h);
600
- if (t > 0.4) src.style.opacity = 1 - (t - 0.4) / 0.5;
601
- return raw >= 1;
1109
+ const t = easeOut(raw);
1110
+ const cx = w / 2, cy = h / 2;
1111
+
1112
+ src.style.opacity = 1 - t;
1113
+ // FIX: Clamp scale to prevent overflow; clean up on completion
1114
+ src.style.transform = `scale(${1 + t * 0.3})`;
1115
+ src.style.transformOrigin = 'center';
1116
+
1117
+ ctx.globalCompositeOperation = 'lighter';
1118
+
1119
+ for (let i = 0; i < 3; i++) {
1120
+ const r = t * Math.max(w, h) * 0.8 * (1 - i * 0.2);
1121
+ if (r < 1) continue;
1122
+ ctx.beginPath();
1123
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
1124
+ ctx.globalAlpha = (1 - t) * (0.8 - i * 0.2);
1125
+ ctx.strokeStyle = palette[0];
1126
+ ctx.lineWidth = 10 - i * 3;
1127
+ ctx.stroke();
1128
+ }
1129
+
1130
+ // ← FIX: Reset composite operation
1131
+ ctx.globalCompositeOperation = 'source-over';
1132
+ ctx.globalAlpha = 1;
1133
+
1134
+ if (raw >= 1) {
1135
+ src.style.transform = '';
1136
+ return true;
1137
+ }
1138
+ return false;
602
1139
  },
603
1140
  destroy() {
604
1141
  },
@@ -606,9 +1143,12 @@ export function ShineRecipe({duration = 500, width = 250} = {}) {
606
1143
  }
607
1144
 
608
1145
 
609
- // ═══════════════════════════════════════════════════════════
610
- // 10. PEEL — Paper curl away (CSS perspective)
611
- // ═══════════════════════════════════════════════════════════
1146
+
1147
+ // ===========================================================================
1148
+ // CSS REVEALS
1149
+ // ===========================================================================
1150
+ // Drive the scratch layer's own transform/opacity; little or no particle work.
1151
+ // ===========================================================================
612
1152
 
613
1153
  export function PeelRecipe({duration = 1000} = {}) {
614
1154
  return {
@@ -636,10 +1176,6 @@ export function PeelRecipe({duration = 1000} = {}) {
636
1176
  }
637
1177
 
638
1178
 
639
- // ═══════════════════════════════════════════════════════════
640
- // 11. FADE — Simple opacity tween
641
- // ═══════════════════════════════════════════════════════════
642
-
643
1179
  export function FadeRecipe({duration = 400} = {}) {
644
1180
  return {
645
1181
  count: 0,
@@ -659,10 +1195,6 @@ export function FadeRecipe({duration = 400} = {}) {
659
1195
  }
660
1196
 
661
1197
 
662
- // ═══════════════════════════════════════════════════════════
663
- // 12. IMPLOSION — Spin + shrink + speed lines (zero particles)
664
- // ═══════════════════════════════════════════════════════════
665
-
666
1198
  export function ImplosionRecipe({duration = 800} = {}) {
667
1199
  return {
668
1200
  count: 0,
@@ -705,15 +1237,32 @@ export function ImplosionRecipe({duration = 800} = {}) {
705
1237
  }
706
1238
 
707
1239
 
708
- // ═══════════════════════════════════════════════════════════
709
- // ALL RECIPES MAP
710
- // ═══════════════════════════════════════════════════════════
1240
+ // ===========================================================================
1241
+ // RECIPE MAP
1242
+ // ===========================================================================
711
1243
 
712
1244
  export const ScratchRecipes = {
713
- BurnRecipe, ShatterRecipe, DissolveRecipe, ExplodeRecipe,
714
- DragonBreathRecipe, IceBreathRecipe,
715
- ShineWaveRecipe, LightningCrawlRecipe, ShineRecipe,
716
- PeelRecipe, FadeRecipe, ImplosionRecipe,
1245
+ BurnRecipe,
1246
+ DissolveRecipe,
1247
+ ExplodeRecipe,
1248
+ DragonBreathRecipe,
1249
+ IceBreathRecipe,
1250
+ GoldDustRecipe,
1251
+ ConfettiBlastRecipe,
1252
+ CosmicDustRecipe,
1253
+ MatrixDecayRecipe,
1254
+ LiquidMeltRecipe,
1255
+ ShatterRecipe,
1256
+ PixelShatterRecipe,
1257
+ GlitchRevealRecipe,
1258
+ ShineRecipe,
1259
+ ShineWaveRecipe,
1260
+ LaserScanRecipe,
1261
+ LightningCrawlRecipe,
1262
+ NeonPulseRecipe,
1263
+ PeelRecipe,
1264
+ FadeRecipe,
1265
+ ImplosionRecipe,
717
1266
  };
718
1267
 
719
1268
  export default ScratchRecipes;