@zakkster/lite-scratch-fx 1.0.0 → 1.3.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 +86 -5
- package/README.md +374 -113
- package/index.d.ts +148 -9
- package/index.js +102 -49
- package/llms.txt +55 -21
- package/package.json +8 -4
- package/src/HostStyle.js +42 -0
- package/src/PixelScan.js +74 -0
- package/src/ScratchController.js +146 -96
- package/src/ScratchRecipes.js +680 -131
- package/src/ScratchStage.js +235 -0
- package/src/ScratchRecipes2.js +0 -642
package/src/ScratchController.js
CHANGED
|
@@ -3,7 +3,20 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Mounts a reveal recipe, scans the remaining visible pixels of the scratch layer for
|
|
5
5
|
* spawn points, delegates physics + rendering to the recipe, and auto-cleans up on
|
|
6
|
-
* completion.
|
|
6
|
+
* completion.
|
|
7
|
+
*
|
|
8
|
+
* Driving model (three ways, all keep dt correct -- the double-drive that corrupts dt is
|
|
9
|
+
* never possible):
|
|
10
|
+
* - DEFAULT: the controller owns a SoaParticleEngine and lets it self-drive via its own
|
|
11
|
+
* requestAnimationFrame loop (start() on reveal, stop() on completion).
|
|
12
|
+
* - HOST-DRIVEN (`{ driven: true }`): the controller never calls start(); the host runs
|
|
13
|
+
* one clock (e.g. @zakkster/lite-ticker / lite-raf) and calls `controller.tick(dt)`
|
|
14
|
+
* every frame with dt in SECONDS. One page loop drives N controllers.
|
|
15
|
+
* - SHARED ENGINE (`{ engine }`): N controllers share one caller-supplied engine, so one
|
|
16
|
+
* lane pool serves the whole page instead of one per controller. The engine has a
|
|
17
|
+
* single render slot and one particle pool, so ONE reveal per shared engine at a time
|
|
18
|
+
* -- a reveal attempted while another is active on the same engine is ignored. Pair
|
|
19
|
+
* with `{ driven: true }` and drive the page from one clock.
|
|
7
20
|
*
|
|
8
21
|
* Recipe interface:
|
|
9
22
|
* {
|
|
@@ -29,97 +42,71 @@
|
|
|
29
42
|
|
|
30
43
|
import { SoaParticleEngine } from '@zakkster/lite-soa-particle-engine';
|
|
31
44
|
import { Random } from '@zakkster/lite-random';
|
|
45
|
+
import { createPixelScanner } from './PixelScan.js';
|
|
46
|
+
import { captureHostStyle, restoreHostStyle } from './HostStyle.js';
|
|
47
|
+
|
|
48
|
+
// Shared engines currently driving a reveal. A shared engine has one render slot and one
|
|
49
|
+
// particle pool, so only one controller may reveal on it at a time; a second reveal is
|
|
50
|
+
// ignored rather than clobbering the first. Owned engines never enter this set.
|
|
51
|
+
const busySharedEngines = new WeakSet();
|
|
32
52
|
|
|
33
53
|
/**
|
|
34
54
|
* @param {HTMLCanvasElement} sourceCanvas The scratch layer being revealed
|
|
35
55
|
* @param {HTMLCanvasElement} effectCanvas Overlay canvas for VFX
|
|
36
56
|
* @param {Object} [options]
|
|
57
|
+
* @param {number} [options.maxParticles=2000] Pool capacity (ignored when `engine` is given)
|
|
58
|
+
* @param {number} [options.seed=Date.now()] Deterministic RNG seed
|
|
59
|
+
* @param {number} [options.scanPrecision=32] Horizontal resolution of the pixel scan
|
|
60
|
+
* @param {boolean} [options.driven=false] Host-driven: skip start(), call tick(dt)
|
|
61
|
+
* @param {SoaParticleEngine} [options.engine] Share a caller-supplied engine + lane pool
|
|
62
|
+
* @param {number} [options.dpr=1] Device-pixel-ratio for sharp high-DPI output
|
|
37
63
|
*/
|
|
38
64
|
export function createScratchController(sourceCanvas, effectCanvas, {
|
|
39
65
|
maxParticles = 2000,
|
|
40
66
|
seed = Date.now(),
|
|
41
67
|
scanPrecision = 32,
|
|
68
|
+
driven = false,
|
|
69
|
+
engine: externalEngine = null,
|
|
70
|
+
dpr = 1,
|
|
42
71
|
} = {}) {
|
|
72
|
+
if (!(typeof dpr === 'number' && Number.isFinite(dpr) && dpr > 0)) {
|
|
73
|
+
throw new TypeError('createScratchController: dpr must be a positive finite number');
|
|
74
|
+
}
|
|
43
75
|
const ctx = effectCanvas.getContext('2d');
|
|
44
|
-
const
|
|
76
|
+
const ownEngine = !externalEngine;
|
|
77
|
+
const engine = externalEngine || new SoaParticleEngine(maxParticles);
|
|
45
78
|
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
79
|
|
|
52
80
|
let activeRecipe = null;
|
|
53
81
|
let destroyed = false;
|
|
54
82
|
let elapsed = 0;
|
|
55
83
|
let onComplete = null;
|
|
84
|
+
// The host layer's inline style as it was at reveal START -- one small object per
|
|
85
|
+
// reveal (cold path), restored on every terminal transition so recipes leave no
|
|
86
|
+
// surviving inline opacity/transform/filter/clipPath/transformOrigin.
|
|
87
|
+
let hostSnapshot = null;
|
|
88
|
+
// Logical (CSS-pixel) reveal dimensions, cached at reveal start. Recipes author and
|
|
89
|
+
// draw in these; the device-pixel canvas is dpr x larger and the 2D transform maps
|
|
90
|
+
// logical -> device. Every per-frame clearRect and recipe call uses these, never the
|
|
91
|
+
// device-px effectCanvas.width/height.
|
|
92
|
+
let logicalW = 0, logicalH = 0;
|
|
56
93
|
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
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;
|
|
94
|
+
// The pixel scanner owns the offscreen scan canvas + spot arrays, all allocated once
|
|
95
|
+
// and reused across reveals -- zero per-pixel allocation on the reveal-start frame.
|
|
96
|
+
const scanner = createPixelScanner(sourceCanvas);
|
|
68
97
|
const spot = { x: 0.5, y: 0.5 }; // the single shared, reused spawn point
|
|
69
98
|
|
|
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
99
|
// The particle-view object handed to recipe.tick every frame. Built ONCE and
|
|
111
100
|
// reused: the engine's SoA arrays are stable references for the life of the engine,
|
|
112
101
|
// so a fresh `{ x, y, ... }` literal per frame would be a pure zero-GC violation on
|
|
113
102
|
// the hottest path in the package. Reassign the fields each frame (free) instead.
|
|
114
103
|
let pView = null;
|
|
115
104
|
|
|
116
|
-
//
|
|
117
|
-
|
|
105
|
+
// -- Render callback (the engine calls this every frame with raw SoA arrays) --
|
|
106
|
+
function renderTick(dt, x, y, vx, vy, life, invLife, data, max) {
|
|
118
107
|
if (destroyed || !activeRecipe) return;
|
|
119
108
|
elapsed += dt;
|
|
120
|
-
|
|
121
|
-
const h = effectCanvas.height;
|
|
122
|
-
ctx.clearRect(0, 0, w, h);
|
|
109
|
+
ctx.clearRect(0, 0, logicalW, logicalH);
|
|
123
110
|
|
|
124
111
|
if (pView === null) {
|
|
125
112
|
pView = { x, y, vx, vy, life, invLife, data, max };
|
|
@@ -130,43 +117,84 @@ export function createScratchController(sourceCanvas, effectCanvas, {
|
|
|
130
117
|
|
|
131
118
|
// Delegate everything to the recipe. Returns true when done.
|
|
132
119
|
const isFinished = activeRecipe.tick(
|
|
133
|
-
dt, elapsed * 1000, pView, ctx, sourceCanvas,
|
|
120
|
+
dt, elapsed * 1000, pView, ctx, sourceCanvas, logicalW, logicalH,
|
|
134
121
|
);
|
|
135
122
|
|
|
136
|
-
if (isFinished)
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
123
|
+
if (isFinished) endReveal(true);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The single terminator for a reveal. Every terminal transition -- natural completion
|
|
127
|
+
// (fireDone=true), cancel() (false), and destroy() (false) -- routes through here, so
|
|
128
|
+
// the engine release, recipe teardown, host-style restore, and canvas clear happen in
|
|
129
|
+
// exactly one place and can never diverge. Cold path: called once per reveal, never
|
|
130
|
+
// per frame. onDone fires ONLY on natural completion (fireDone).
|
|
131
|
+
function endReveal(fireDone) {
|
|
132
|
+
if (!activeRecipe) return;
|
|
133
|
+
if (ownEngine && !driven) engine.stop();
|
|
134
|
+
releaseEngine();
|
|
135
|
+
if (activeRecipe.destroy) activeRecipe.destroy();
|
|
136
|
+
if (hostSnapshot) { restoreHostStyle(sourceCanvas, hostSnapshot); hostSnapshot = null; }
|
|
137
|
+
ctx.clearRect(0, 0, logicalW, logicalH);
|
|
138
|
+
const done = onComplete;
|
|
139
|
+
activeRecipe = null;
|
|
140
|
+
onComplete = null;
|
|
141
|
+
if (fireDone && done) done();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Release our hold on a shared engine's single render slot so the next controller can
|
|
145
|
+
// reveal on it. A no-op for an owned engine, which stays bound for its whole life.
|
|
146
|
+
function releaseEngine() {
|
|
147
|
+
if (ownEngine) return;
|
|
148
|
+
engine.onTick(null);
|
|
149
|
+
busySharedEngines.delete(engine);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// An owned engine is bound once for its whole life; the renderTick early-returns when no
|
|
153
|
+
// reveal is active. A shared engine is bound per-reveal (in reveal) and released on
|
|
154
|
+
// completion so it can serve other controllers.
|
|
155
|
+
if (ownEngine) engine.onTick(renderTick);
|
|
146
156
|
|
|
147
157
|
return {
|
|
148
158
|
/**
|
|
149
|
-
* Execute a reveal recipe.
|
|
159
|
+
* Execute a reveal recipe. Ignored if this controller is already revealing, is
|
|
160
|
+
* destroyed, or shares an engine that another controller is revealing on.
|
|
150
161
|
* @param {Object} recipe
|
|
151
162
|
* @param {Function} [onDone] Called when the effect completes
|
|
152
163
|
*/
|
|
153
164
|
reveal(recipe, onDone) {
|
|
154
165
|
if (destroyed || activeRecipe) return;
|
|
166
|
+
if (!ownEngine && busySharedEngines.has(engine)) return;
|
|
155
167
|
|
|
156
168
|
activeRecipe = recipe;
|
|
157
169
|
onComplete = onDone || null;
|
|
158
170
|
elapsed = 0;
|
|
171
|
+
|
|
172
|
+
if (!ownEngine) {
|
|
173
|
+
engine.onTick(renderTick);
|
|
174
|
+
busySharedEngines.add(engine);
|
|
175
|
+
}
|
|
159
176
|
engine.clear();
|
|
160
177
|
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
178
|
+
// Logical (CSS-pixel) size of the scratch layer -- recipes author in these.
|
|
179
|
+
logicalW = sourceCanvas.offsetWidth || sourceCanvas.width;
|
|
180
|
+
logicalH = sourceCanvas.offsetHeight || sourceCanvas.height;
|
|
181
|
+
// Device-pixel backing store: dpr x larger so output is sharp on high-DPI
|
|
182
|
+
// screens. Assigning width/height also resets the 2D transform, so map logical
|
|
183
|
+
// -> device with setTransform. At dpr === 1 skip it: behaviour is then
|
|
184
|
+
// byte-identical to the pre-DPR path (no setTransform call, no scaled dims).
|
|
185
|
+
effectCanvas.width = Math.round(logicalW * dpr);
|
|
186
|
+
effectCanvas.height = Math.round(logicalH * dpr);
|
|
187
|
+
if (dpr !== 1) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
188
|
+
|
|
189
|
+
// Borrow the host layer's inline style: snapshot it now (cold path, one small
|
|
190
|
+
// object) so endReveal can return it pristine on completion/cancel/destroy.
|
|
191
|
+
hostSnapshot = captureHostStyle(sourceCanvas);
|
|
164
192
|
|
|
165
|
-
// Scan spawn points from visible pixels
|
|
166
|
-
|
|
193
|
+
// Scan spawn points from visible pixels.
|
|
194
|
+
const spots = scanner.scan(scanPrecision);
|
|
167
195
|
|
|
168
|
-
// Init recipe
|
|
169
|
-
recipe.init(ctx, maxParticles,
|
|
196
|
+
// Init recipe with LOGICAL size (recipes stay in CSS px).
|
|
197
|
+
recipe.init(ctx, maxParticles, logicalW, logicalH);
|
|
170
198
|
|
|
171
199
|
// Populate ring buffer. A recipe may declare `count: 0` (a pure-canvas reveal
|
|
172
200
|
// like Shine or Implosion that draws no particles); use ?? so an explicit 0 is
|
|
@@ -175,20 +203,43 @@ export function createScratchController(sourceCanvas, effectCanvas, {
|
|
|
175
203
|
const count = Math.min(requested, maxParticles);
|
|
176
204
|
for (let i = 0; i < count; i++) {
|
|
177
205
|
const idx = engine._head;
|
|
178
|
-
if (
|
|
179
|
-
const k = rng.int(0,
|
|
180
|
-
spot.x = spotX[k];
|
|
181
|
-
spot.y = spotY[k];
|
|
206
|
+
if (spots.count > 0) {
|
|
207
|
+
const k = rng.int(0, spots.count - 1);
|
|
208
|
+
spot.x = spots.spotX[k];
|
|
209
|
+
spot.y = spots.spotY[k];
|
|
182
210
|
} else {
|
|
183
211
|
spot.x = 0.5;
|
|
184
212
|
spot.y = 0.5;
|
|
185
213
|
}
|
|
186
|
-
const state = recipe.spawn(idx, rng,
|
|
214
|
+
const state = recipe.spawn(idx, rng, logicalW, logicalH, spot);
|
|
187
215
|
engine.emit(state.x, state.y, state.vx, state.vy, state.life, idx);
|
|
188
216
|
}
|
|
189
217
|
|
|
190
|
-
//
|
|
191
|
-
|
|
218
|
+
// Self-driven: the engine's RAF loop drives renderTick. Host-driven: the host
|
|
219
|
+
// calls controller.tick(dt) instead; we schedule nothing.
|
|
220
|
+
if (!driven) engine.start();
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Advance one frame in host-driven mode. `dt` is in SECONDS. A no-op unless the
|
|
225
|
+
* controller was created with `{ driven: true }` AND a reveal is active -- so a host
|
|
226
|
+
* can safely call tick() on every controller each frame; only the revealing one (and,
|
|
227
|
+
* for a shared engine, only the single active controller) does work.
|
|
228
|
+
* @param {number} dt Seconds since the previous frame.
|
|
229
|
+
*/
|
|
230
|
+
tick(dt) {
|
|
231
|
+
if (destroyed || !driven || activeRecipe === null) return;
|
|
232
|
+
engine.tick(dt);
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Abort an in-flight reveal. Stops the effect, restores the host canvas's inline
|
|
237
|
+
* style to exactly what it was at reveal start, and does NOT fire onDone. The
|
|
238
|
+
* controller is revealable again afterwards. A no-op when idle or destroyed.
|
|
239
|
+
*/
|
|
240
|
+
cancel() {
|
|
241
|
+
if (destroyed || !activeRecipe) return;
|
|
242
|
+
endReveal(false);
|
|
192
243
|
},
|
|
193
244
|
|
|
194
245
|
/** Re-seed the RNG. */
|
|
@@ -197,13 +248,12 @@ export function createScratchController(sourceCanvas, effectCanvas, {
|
|
|
197
248
|
destroy() {
|
|
198
249
|
if (destroyed) return;
|
|
199
250
|
destroyed = true;
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
engine.destroy();
|
|
251
|
+
// Route any in-flight reveal through the single terminator (stops/releases the
|
|
252
|
+
// engine, restores host style, no onDone) before the controller's own teardown.
|
|
253
|
+
endReveal(false);
|
|
254
|
+
scanner.destroy();
|
|
255
|
+
// Only tear down an engine we created. A caller-supplied engine is theirs.
|
|
256
|
+
if (ownEngine) engine.destroy();
|
|
207
257
|
},
|
|
208
258
|
};
|
|
209
259
|
}
|