@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,719 @@
1
+ /**
2
+ * Scratch Card Reveal Recipes
3
+ *
4
+ * 12 one-shot reveal effects, each a standalone factory function.
5
+ * Designed to be passed to ScratchController.reveal(recipe, onDone).
6
+ *
7
+ * Recipe interface:
8
+ * { count, init(ctx, capacity, w, h),
9
+ * spawn(idx, rng, w, h, spot),
10
+ * tick(dt, elapsedMs, engine, ctx, sourceCanvas, w, h) → boolean,
11
+ * destroy() }
12
+ *
13
+ * tick() returns `true` when the effect is complete.
14
+ *
15
+ * Uses:
16
+ * @zakkster/lite-lerp — easeIn, easeOut, easeInOut, clamp, lerp
17
+ * ./Palette.js — resolvePalette (colors / theme option handling)
18
+ */
19
+
20
+ import {easeIn, easeOut, easeInOut, clamp, lerp} from '@zakkster/lite-lerp';
21
+ import {resolvePalette} from './Palette.js';
22
+
23
+
24
+ // ═══════════════════════════════════════════════════════════
25
+ // 1. BURN — Ember particles rising from visible pixels
26
+ // ═══════════════════════════════════════════════════════════
27
+
28
+ export function BurnRecipe({count = 150, duration = 1500, colors, theme} = {}) {
29
+ let pSize, pDecay, pColorIdx;
30
+ const palette = resolvePalette(colors, theme, ['#ff6b00', '#ff8c00', '#ffaa00', '#ff4500', '#8B0000']);
31
+
32
+ return {
33
+ count,
34
+ init(ctx, capacity) {
35
+ pSize = new Float32Array(capacity);
36
+ pDecay = new Float32Array(capacity);
37
+ pColorIdx = new Uint8Array(capacity);
38
+ },
39
+ spawn(idx, rng, w, h, spot) {
40
+ pSize[idx] = rng.range(2, 5);
41
+ pDecay[idx] = rng.range(0.01, 0.03);
42
+ pColorIdx[idx] = rng.int(0, palette.length - 1);
43
+ return {x: spot.x * w, y: spot.y * h, vx: rng.range(-1, 1), vy: rng.range(-1, -4), life: 1.0};
44
+ },
45
+ tick(dt, elapsed, engine, ctx, src, w, h) {
46
+ const {x, y, vx, vy, life, data, max} = engine;
47
+ let alive = 0;
48
+ const progress = clamp(elapsed / duration, 0, 1);
49
+ src.style.opacity = 1 - easeIn(progress);
50
+
51
+ for (let i = 0; i < max; i++) {
52
+ if (life[i] <= 0) continue;
53
+ const p = data[i];
54
+ vy[i] += 0.1;
55
+ x[i] += vx[i] * dt * 60;
56
+ y[i] += vy[i] * dt * 60;
57
+ life[i] -= pDecay[p] * dt * 60;
58
+ if (life[i] <= 0) continue;
59
+ alive++;
60
+ ctx.globalAlpha = life[i];
61
+ ctx.globalCompositeOperation = 'lighter';
62
+ ctx.fillStyle = palette[pColorIdx[p]];
63
+ ctx.shadowBlur = 10;
64
+ ctx.shadowColor = ctx.fillStyle;
65
+ ctx.beginPath();
66
+ ctx.arc(x[i], y[i], pSize[p], 0, Math.PI * 2);
67
+ ctx.fill();
68
+ }
69
+ ctx.shadowBlur = 0;
70
+ ctx.globalCompositeOperation = 'source-over';
71
+ ctx.globalAlpha = 1;
72
+ return alive === 0 && elapsed >= duration;
73
+ },
74
+ destroy() {
75
+ pSize = pDecay = pColorIdx = null;
76
+ },
77
+ };
78
+ }
79
+
80
+
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
+ export function DissolveRecipe({count = 150, duration = 1000, colors, theme} = {}) {
152
+ let pSize;
153
+ const palette = resolvePalette(colors, theme, ['#DC143C']);
154
+
155
+ return {
156
+ count,
157
+ init(ctx, capacity) {
158
+ pSize = new Float32Array(capacity);
159
+ },
160
+ spawn(idx, rng, w, h, spot) {
161
+ pSize[idx] = rng.range(2, 6);
162
+ return {x: spot.x * w, y: spot.y * h, vx: 0, vy: rng.range(-1, -3), life: 1.0};
163
+ },
164
+ tick(dt, elapsed, engine, ctx, src, w, h) {
165
+ const {x, y, vy, life, data, max} = engine;
166
+ let alive = 0;
167
+ src.style.opacity = 1 - clamp(elapsed / (duration * 0.3), 0, 1);
168
+
169
+ for (let i = 0; i < max; i++) {
170
+ if (life[i] <= 0) continue;
171
+ const p = data[i];
172
+ y[i] += vy[i] * dt * 60;
173
+ life[i] -= 0.02 * dt * 60;
174
+ if (life[i] <= 0) continue;
175
+ alive++;
176
+ ctx.globalAlpha = life[i];
177
+ ctx.fillStyle = palette[0];
178
+ ctx.beginPath();
179
+ ctx.arc(x[i], y[i], pSize[p], 0, Math.PI * 2);
180
+ ctx.fill();
181
+ }
182
+ ctx.globalAlpha = 1;
183
+ return alive === 0 && elapsed >= duration;
184
+ },
185
+ destroy() {
186
+ pSize = null;
187
+ },
188
+ };
189
+ }
190
+
191
+
192
+ // ═══════════════════════════════════════════════════════════
193
+ // 4. EXPLODE — Radial burst from centroid
194
+ // ═══════════════════════════════════════════════════════════
195
+
196
+ export function ExplodeRecipe({count = 80, duration = 750, force = 15, colors, theme} = {}) {
197
+ let pSize, pColorIdx;
198
+ const palette = resolvePalette(colors, theme, ['#DC143C', '#8B0000', '#5C0000']);
199
+
200
+ return {
201
+ count,
202
+ init(ctx, capacity) {
203
+ pSize = new Float32Array(capacity);
204
+ pColorIdx = new Uint8Array(capacity);
205
+ },
206
+ spawn(idx, rng, w, h, spot) {
207
+ pSize[idx] = rng.range(3, 8);
208
+ pColorIdx[idx] = rng.int(0, palette.length - 1);
209
+ const ang = (Math.PI * 2 * idx) / count;
210
+ const spd = rng.range(5, force);
211
+ return {x: w / 2, y: h / 2, vx: Math.cos(ang) * spd, vy: Math.sin(ang) * spd, life: 1.0};
212
+ },
213
+ tick(dt, elapsed, engine, ctx, src, w, h) {
214
+ const {x, y, vx, vy, life, data, max} = engine;
215
+ let alive = 0;
216
+ src.style.opacity = 1 - clamp(elapsed / (duration * 0.4), 0, 1);
217
+
218
+ for (let i = 0; i < max; i++) {
219
+ if (life[i] <= 0) continue;
220
+ const p = data[i];
221
+ x[i] += vx[i] * dt * 60;
222
+ y[i] += vy[i] * dt * 60;
223
+ life[i] -= 0.02 * dt * 60;
224
+ if (life[i] <= 0) continue;
225
+ alive++;
226
+ ctx.save();
227
+ ctx.globalAlpha = life[i];
228
+ ctx.fillStyle = palette[pColorIdx[p]];
229
+ ctx.beginPath();
230
+ ctx.arc(x[i], y[i], pSize[p], 0, Math.PI * 2);
231
+ ctx.fill();
232
+ ctx.restore();
233
+ }
234
+ return alive === 0 && elapsed >= duration;
235
+ },
236
+ destroy() {
237
+ pSize = pColorIdx = null;
238
+ },
239
+ };
240
+ }
241
+
242
+
243
+ // ═══════════════════════════════════════════════════════════
244
+ // 5. DRAGON BREATH — Fire cone from below, additive blend
245
+ // ═══════════════════════════════════════════════════════════
246
+
247
+ export function DragonBreathRecipe({count = 200, duration = 1200, colors, theme} = {}) {
248
+ let pSize, pDecay, pColorIdx;
249
+ const palette = resolvePalette(colors, theme, ['#FFF', '#FFD700', '#FF4500', '#8B0000', '#2F2F2F']);
250
+
251
+ return {
252
+ count,
253
+ init(ctx, capacity) {
254
+ pSize = new Float32Array(capacity);
255
+ pDecay = new Float32Array(capacity);
256
+ pColorIdx = new Uint8Array(capacity);
257
+ },
258
+ spawn(idx, rng, w, h, spot) {
259
+ const angle = -Math.PI / 2 + rng.range(-0.3, 0.3);
260
+ const speed = rng.range(12, 25);
261
+ pSize[idx] = rng.range(4, 12);
262
+ pDecay[idx] = rng.range(0.01, 0.025);
263
+ pColorIdx[idx] = rng.int(0, palette.length - 1);
264
+ return {
265
+ x: w / 2 + rng.range(-10, 10),
266
+ y: h + 20,
267
+ vx: Math.cos(angle) * speed,
268
+ vy: Math.sin(angle) * speed,
269
+ life: 1.0
270
+ };
271
+ },
272
+ tick(dt, elapsed, engine, ctx, src, w, h) {
273
+ const {x, y, vx, vy, life, data, max} = engine;
274
+ let alive = 0;
275
+ src.style.opacity = 1 - clamp(elapsed / (duration * 0.4), 0, 1);
276
+
277
+ for (let i = 0; i < max; i++) {
278
+ if (life[i] <= 0) continue;
279
+ const p = data[i];
280
+ x[i] += vx[i] * dt * 60;
281
+ y[i] += vy[i] * dt * 60;
282
+ vx[i] += (Math.random() - 0.5) * 0.5;
283
+ vy[i] *= 0.98;
284
+ pSize[p] *= 1 + 0.04 * dt * 60;
285
+ life[i] -= pDecay[p] * dt * 60;
286
+ if (life[i] <= 0) continue;
287
+ alive++;
288
+ ctx.save();
289
+ ctx.globalAlpha = life[i];
290
+ ctx.globalCompositeOperation = 'lighter';
291
+ ctx.shadowBlur = 15;
292
+ ctx.shadowColor = palette[pColorIdx[p]];
293
+ ctx.fillStyle = palette[pColorIdx[p]];
294
+ ctx.beginPath();
295
+ ctx.arc(x[i], y[i], pSize[p], 0, Math.PI * 2);
296
+ ctx.fill();
297
+ ctx.restore();
298
+ }
299
+ ctx.shadowBlur = 0;
300
+ ctx.globalCompositeOperation = 'source-over';
301
+ ctx.globalAlpha = 1;
302
+ return alive === 0 && elapsed >= duration;
303
+ },
304
+ destroy() {
305
+ pSize = pDecay = pColorIdx = null;
306
+ },
307
+ };
308
+ }
309
+
310
+
311
+ // ═══════════════════════════════════════════════════════════
312
+ // 6. ICE BREATH — Crystal shards, screen blend, friction
313
+ // ═══════════════════════════════════════════════════════════
314
+
315
+ export function IceBreathRecipe({count = 300, duration = 1500, colors, theme} = {}) {
316
+ let pSize, pDecay, pRot, pRotSpd, pColorIdx;
317
+ const palette = resolvePalette(colors, theme, ['#FFF', '#E0FFFF', '#00FFFF', '#1E90FF', '#4682B4']);
318
+
319
+ return {
320
+ count,
321
+ init(ctx, capacity) {
322
+ pSize = new Float32Array(capacity);
323
+ pDecay = new Float32Array(capacity);
324
+ pRot = new Float32Array(capacity);
325
+ pRotSpd = new Float32Array(capacity);
326
+ pColorIdx = new Uint8Array(capacity);
327
+ },
328
+ spawn(idx, rng, w, h, spot) {
329
+ const angle = -Math.PI / 2 + rng.range(-0.225, 0.225);
330
+ const speed = rng.range(15, 28);
331
+ pSize[idx] = rng.range(2, 7);
332
+ pDecay[idx] = rng.range(0.005, 0.025);
333
+ pRot[idx] = rng.range(0, Math.PI);
334
+ pRotSpd[idx] = rng.range(-0.2, 0.2);
335
+ pColorIdx[idx] = rng.int(0, palette.length - 1);
336
+ return {
337
+ x: w / 2 + rng.range(-8, 8),
338
+ y: h + 20,
339
+ vx: Math.cos(angle) * speed,
340
+ vy: Math.sin(angle) * speed,
341
+ life: 1.0
342
+ };
343
+ },
344
+ tick(dt, elapsed, engine, ctx, src, w, h) {
345
+ const {x, y, vx, vy, life, data, max} = engine;
346
+ let alive = 0;
347
+ const t = clamp(elapsed / (duration * 0.4), 0, 1);
348
+ src.style.opacity = 1 - t;
349
+ src.style.filter = `brightness(${1 + t})`;
350
+
351
+ for (let i = 0; i < max; i++) {
352
+ if (life[i] <= 0) continue;
353
+ const p = data[i];
354
+ x[i] += vx[i] * dt * 60;
355
+ y[i] += vy[i] * dt * 60;
356
+ vx[i] *= 0.96;
357
+ vy[i] *= 0.96;
358
+ pRot[p] += pRotSpd[p];
359
+ life[i] -= pDecay[p] * dt * 60;
360
+ if (life[i] <= 0) continue;
361
+ alive++;
362
+ ctx.save();
363
+ ctx.globalAlpha = life[i];
364
+ ctx.globalCompositeOperation = 'screen';
365
+ ctx.translate(x[i], y[i]);
366
+ ctx.rotate(pRot[p]);
367
+ const s = pSize[p];
368
+ ctx.fillStyle = palette[pColorIdx[p]];
369
+ ctx.beginPath();
370
+ ctx.moveTo(0, -s);
371
+ ctx.lineTo(s * 0.6, 0);
372
+ ctx.lineTo(0, s);
373
+ ctx.lineTo(-s * 0.6, 0);
374
+ ctx.closePath();
375
+ ctx.fill();
376
+ ctx.restore();
377
+ }
378
+ ctx.globalCompositeOperation = 'source-over';
379
+ ctx.globalAlpha = 1;
380
+ if (alive === 0 && elapsed >= duration) {
381
+ src.style.filter = '';
382
+ return true;
383
+ }
384
+ return false;
385
+ },
386
+ destroy() {
387
+ pSize = pDecay = pRot = pRotSpd = pColorIdx = null;
388
+ },
389
+ };
390
+ }
391
+
392
+
393
+ // ═══════════════════════════════════════════════════════════
394
+ // 7. SHINE WAVE — Energy beam sweep L→R
395
+ // ═══════════════════════════════════════════════════════════
396
+
397
+ export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount = 60, colors, theme} = {}) {
398
+ const palette = resolvePalette(colors, theme, ['#FFF', '#E0E8FF', '#B0C4FF', '#88AAFF']);
399
+ let py, pvy, pox, psz, pcol;
400
+
401
+ return {
402
+ count: 0, // No SoA particles — managed internally
403
+ init(ctx, capacity, w, h) {
404
+ py = new Float32Array(particleCount);
405
+ pvy = new Float32Array(particleCount);
406
+ pox = new Float32Array(particleCount);
407
+ psz = new Float32Array(particleCount);
408
+ pcol = new Uint8Array(particleCount);
409
+ for (let i = 0; i < particleCount; i++) {
410
+ py[i] = Math.random() * h;
411
+ pvy[i] = (Math.random() - 0.5) * 2;
412
+ pox[i] = (Math.random() - 0.5) * beamWidth * 0.6;
413
+ psz[i] = Math.random() * 4 + 2;
414
+ pcol[i] = (Math.random() * palette.length) | 0;
415
+ }
416
+ },
417
+ spawn() {
418
+ return {x: 0, y: 0, vx: 0, vy: 0, life: 0};
419
+ },
420
+ tick(dt, elapsed, engine, ctx, src, w, h) {
421
+ const raw = clamp(elapsed / duration, 0, 1);
422
+ const t = easeInOut(raw);
423
+ const bx = lerp(-beamWidth, w + beamWidth, t);
424
+ src.style.opacity = t > 0.3 ? 1 - (t - 0.3) / 0.7 : 1;
425
+
426
+ // Beam core gradient
427
+ if (bx > -beamWidth && bx < w + beamWidth) {
428
+ const g = ctx.createLinearGradient(bx - beamWidth, 0, bx + beamWidth, 0);
429
+ g.addColorStop(0, 'rgba(255,255,255,0)');
430
+ g.addColorStop(0.4, 'rgba(200,220,255,.3)');
431
+ g.addColorStop(0.5, 'rgba(255,255,255,.6)');
432
+ g.addColorStop(0.6, 'rgba(200,220,255,.3)');
433
+ g.addColorStop(1, 'rgba(255,255,255,0)');
434
+ ctx.globalCompositeOperation = 'lighter';
435
+ ctx.fillStyle = g;
436
+ ctx.fillRect(bx - beamWidth, 0, beamWidth * 2, h);
437
+ }
438
+ // Beam particles
439
+ ctx.globalCompositeOperation = 'lighter';
440
+ for (let i = 0; i < particleCount; i++) {
441
+ const px2 = bx + pox[i];
442
+ py[i] += pvy[i];
443
+ pvy[i] += (Math.random() - 0.5) * 0.3;
444
+ if (py[i] < -10) py[i] = h + 10;
445
+ if (py[i] > h + 10) py[i] = -10;
446
+ psz[i] = clamp(psz[i] + (Math.random() - 0.5) * 0.3, 1, 8);
447
+ ctx.globalAlpha = 0.7;
448
+ ctx.fillStyle = palette[pcol[i]];
449
+ ctx.save();
450
+ ctx.translate(px2, py[i]);
451
+ ctx.fillRect(-psz[i] * 2, -psz[i] * 0.3, psz[i] * 4, psz[i] * 0.6);
452
+ ctx.restore();
453
+ }
454
+ ctx.globalCompositeOperation = 'source-over';
455
+ ctx.globalAlpha = 1;
456
+ return raw >= 1;
457
+ },
458
+ destroy() {
459
+ py = pvy = pox = psz = pcol = null;
460
+ },
461
+ };
462
+ }
463
+
464
+
465
+ // ═══════════════════════════════════════════════════════════
466
+ // 8. LIGHTNING CRAWL — Procedural branching polylines
467
+ // ═══════════════════════════════════════════════════════════
468
+
469
+ export function LightningCrawlRecipe({boltCount = 5, duration = 600, colors, theme} = {}) {
470
+ const palette = resolvePalette(colors, theme, ['#FFF', '#E0E8FF', '#88CCFF', '#4488FF']);
471
+ // Bolt is drawn in three stacked layers (outer glow -> mid -> bright core). Derive
472
+ // each from the ramp by relative position so any length >= 1 stays valid.
473
+ const cCore = palette[0];
474
+ const cMid = palette[(palette.length - 1) >> 1];
475
+ const cOuter = palette[palette.length - 1];
476
+ let bolts = [];
477
+
478
+ function genPts(x1, y1, x2, y2, d) {
479
+ let p = [{x: x1, y: y1}, {x: x2, y: y2}];
480
+ for (let i = 0; i < d; i++) {
481
+ const np = [p[0]];
482
+ for (let j = 0; j < p.length - 1; j++) {
483
+ const a = p[j], b = p[j + 1];
484
+ const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
485
+ const dist = Math.hypot(b.x - a.x, b.y - a.y);
486
+ const off = (Math.random() - 0.5) * dist * 0.4;
487
+ const nx = -(b.y - a.y) / (dist || 1), ny = (b.x - a.x) / (dist || 1);
488
+ np.push({x: mx + nx * off, y: my + ny * off});
489
+ np.push(b);
490
+ }
491
+ p = np;
492
+ }
493
+ return p;
494
+ }
495
+
496
+ function drawBolt(ctx, x1, y1, x2, y2, th, d) {
497
+ if (d <= 0) return;
498
+ const pts = genPts(x1, y1, x2, y2, d);
499
+ [[cOuter, th * 3, .15], [cMid, th * 1.5, .5], [cCore, th, 1]].forEach(([c, lw, a]) => {
500
+ ctx.strokeStyle = c;
501
+ ctx.lineWidth = lw;
502
+ ctx.globalAlpha = a;
503
+ ctx.beginPath();
504
+ ctx.moveTo(pts[0].x, pts[0].y);
505
+ for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
506
+ ctx.stroke();
507
+ });
508
+ if (d > 2) for (let i = 2; i < pts.length - 1; i += 3) {
509
+ if (Math.random() > 0.5) continue;
510
+ const bl = Math.hypot(x2 - x1, y2 - y1) * (0.15 + Math.random() * 0.2);
511
+ const ba = Math.atan2(y2 - y1, x2 - x1) + (Math.random() - 0.5) * 1.5;
512
+ drawBolt(ctx, pts[i].x, pts[i].y, pts[i].x + Math.cos(ba) * bl, pts[i].y + Math.sin(ba) * bl, th * 0.5, d - 2);
513
+ }
514
+ }
515
+
516
+ return {
517
+ count: 0,
518
+ init(ctx, capacity, w, h) {
519
+ bolts = [];
520
+ for (let b = 0; b < boltCount; b++) {
521
+ const e = (Math.random() * 4) | 0;
522
+ let sx, sy;
523
+ if (e === 0) {
524
+ sx = 0;
525
+ sy = Math.random() * h;
526
+ } else if (e === 1) {
527
+ sx = w;
528
+ sy = Math.random() * h;
529
+ } else if (e === 2) {
530
+ sx = Math.random() * w;
531
+ sy = 0;
532
+ } else {
533
+ sx = Math.random() * w;
534
+ sy = h;
535
+ }
536
+ bolts.push({
537
+ sx,
538
+ sy,
539
+ ex: w * (0.3 + Math.random() * 0.4),
540
+ ey: h * (0.3 + Math.random() * 0.4),
541
+ delay: Math.random() * 0.3
542
+ });
543
+ }
544
+ },
545
+ spawn() {
546
+ return {x: 0, y: 0, vx: 0, vy: 0, life: 0};
547
+ },
548
+ tick(dt, elapsed, engine, ctx, src, w, h) {
549
+ const raw = clamp(elapsed / duration, 0, 1);
550
+ const prog = easeIn(clamp(raw / 0.6, 0, 1));
551
+ const flash = raw > 0.5 && raw < 0.58 ? (raw - 0.5) / 0.08 : raw >= 0.58 && raw < 0.73 ? 1 - (raw - 0.58) / 0.15 : 0;
552
+ const fade = raw > 0.65 ? 1 - (raw - 0.65) / 0.35 : 1;
553
+ src.style.opacity = raw > 0.4 ? 1 - (raw - 0.4) / 0.4 : 1;
554
+
555
+ if (fade <= 0.01) return raw >= 1;
556
+ ctx.save();
557
+ ctx.globalAlpha = fade;
558
+ if (flash > 0.01) {
559
+ ctx.fillStyle = `rgba(200,220,255,${flash * 0.3})`;
560
+ ctx.fillRect(0, 0, w, h);
561
+ }
562
+ ctx.globalCompositeOperation = 'lighter';
563
+ for (const b of bolts) {
564
+ const bp = clamp((prog - b.delay) / (1 - b.delay), 0, 1);
565
+ if (bp <= 0) continue;
566
+ drawBolt(ctx, b.sx, b.sy, b.sx + (b.ex - b.sx) * bp, b.sy + (b.ey - b.sy) * bp, 3, 4);
567
+ }
568
+ ctx.restore();
569
+ return raw >= 1;
570
+ },
571
+ destroy() {
572
+ bolts = [];
573
+ },
574
+ };
575
+ }
576
+
577
+
578
+ // ═══════════════════════════════════════════════════════════
579
+ // 9. SHINE — Flash/glint sweep
580
+ // ═══════════════════════════════════════════════════════════
581
+
582
+ export function ShineRecipe({duration = 500, width = 250} = {}) {
583
+ return {
584
+ count: 0,
585
+ init() {
586
+ },
587
+ spawn() {
588
+ return {x: 0, y: 0, vx: 0, vy: 0, life: 0};
589
+ },
590
+ tick(dt, elapsed, engine, ctx, src, w, h) {
591
+ 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;
602
+ },
603
+ destroy() {
604
+ },
605
+ };
606
+ }
607
+
608
+
609
+ // ═══════════════════════════════════════════════════════════
610
+ // 10. PEEL — Paper curl away (CSS perspective)
611
+ // ═══════════════════════════════════════════════════════════
612
+
613
+ export function PeelRecipe({duration = 1000} = {}) {
614
+ return {
615
+ count: 0,
616
+ init() {
617
+ },
618
+ spawn() {
619
+ return {x: 0, y: 0, vx: 0, vy: 0, life: 0};
620
+ },
621
+ tick(dt, elapsed, engine, ctx, src) {
622
+ const raw = clamp(elapsed / duration, 0, 1);
623
+ const t = easeIn(raw);
624
+ src.style.transformOrigin = 'right center';
625
+ src.style.transform = `perspective(800px) rotateY(${t * 90}deg) rotateX(${t * -20}deg) translateX(${t * src.offsetWidth}px)`;
626
+ src.style.opacity = 1 - t;
627
+ if (raw >= 1) {
628
+ src.style.transform = '';
629
+ return true;
630
+ }
631
+ return false;
632
+ },
633
+ destroy() {
634
+ },
635
+ };
636
+ }
637
+
638
+
639
+ // ═══════════════════════════════════════════════════════════
640
+ // 11. FADE — Simple opacity tween
641
+ // ═══════════════════════════════════════════════════════════
642
+
643
+ export function FadeRecipe({duration = 400} = {}) {
644
+ return {
645
+ count: 0,
646
+ init() {
647
+ },
648
+ spawn() {
649
+ return {x: 0, y: 0, vx: 0, vy: 0, life: 0};
650
+ },
651
+ tick(dt, elapsed, engine, ctx, src) {
652
+ const t = easeOut(clamp(elapsed / duration, 0, 1));
653
+ src.style.opacity = 1 - t;
654
+ return t >= 1;
655
+ },
656
+ destroy() {
657
+ },
658
+ };
659
+ }
660
+
661
+
662
+ // ═══════════════════════════════════════════════════════════
663
+ // 12. IMPLOSION — Spin + shrink + speed lines (zero particles)
664
+ // ═══════════════════════════════════════════════════════════
665
+
666
+ export function ImplosionRecipe({duration = 800} = {}) {
667
+ return {
668
+ count: 0,
669
+ init() {
670
+ },
671
+ spawn() {
672
+ return {x: 0, y: 0, vx: 0, vy: 0, life: 0};
673
+ },
674
+ tick(dt, elapsed, engine, ctx, src, w, h) {
675
+ const raw = clamp(elapsed / duration, 0, 1);
676
+ const t = easeIn(raw);
677
+ src.style.transformOrigin = 'center';
678
+ src.style.transform = `scale(${1 - t}) rotate(${t * 180}deg)`;
679
+ src.style.filter = `blur(${t * 10}px)`;
680
+ src.style.opacity = 1 - t * t;
681
+
682
+ // Speed lines
683
+ ctx.save();
684
+ ctx.translate(w / 2, h / 2);
685
+ ctx.beginPath();
686
+ for (let i = 0; i < 12; i++) {
687
+ const a = (i / 12) * Math.PI * 2 + t * 5;
688
+ ctx.moveTo(Math.cos(a) * w * t, Math.sin(a) * h * t);
689
+ ctx.lineTo(Math.cos(a) * w, Math.sin(a) * h);
690
+ }
691
+ ctx.strokeStyle = `rgba(255,255,255,${1 - t})`;
692
+ ctx.lineWidth = 2;
693
+ ctx.stroke();
694
+ ctx.restore();
695
+ if (raw >= 1) {
696
+ src.style.transform = '';
697
+ src.style.filter = '';
698
+ return true;
699
+ }
700
+ return false;
701
+ },
702
+ destroy() {
703
+ },
704
+ };
705
+ }
706
+
707
+
708
+ // ═══════════════════════════════════════════════════════════
709
+ // ALL RECIPES MAP
710
+ // ═══════════════════════════════════════════════════════════
711
+
712
+ export const ScratchRecipes = {
713
+ BurnRecipe, ShatterRecipe, DissolveRecipe, ExplodeRecipe,
714
+ DragonBreathRecipe, IceBreathRecipe,
715
+ ShineWaveRecipe, LightningCrawlRecipe, ShineRecipe,
716
+ PeelRecipe, FadeRecipe, ImplosionRecipe,
717
+ };
718
+
719
+ export default ScratchRecipes;