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