@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/CHANGELOG.md +63 -0
- package/LICENSE +21 -0
- package/README.md +189 -0
- package/index.d.ts +150 -0
- package/index.js +84 -0
- package/llms.txt +97 -0
- package/package.json +69 -0
- package/src/Palette.js +37 -0
- package/src/ScratchController.js +211 -0
- package/src/ScratchRecipes.js +719 -0
- package/src/ScratchRecipes2.js +642 -0
|
@@ -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;
|