@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.
- package/CHANGELOG.md +52 -0
- package/README.md +107 -5
- package/index.d.ts +118 -6
- package/index.js +102 -49
- package/llms.txt +55 -21
- package/package.json +7 -4
- package/src/PixelScan.js +74 -0
- package/src/ScratchController.js +83 -74
- package/src/ScratchRecipes.js +679 -130
- package/src/ScratchStage.js +192 -0
- package/src/ScratchRecipes2.js +0 -642
package/src/PixelScan.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pixel scanner -- finds the still-covered pixels of a scratch layer as normalized
|
|
3
|
+
* 0..1 spawn points. Allocated ONCE per scanner and reused across every scan: the
|
|
4
|
+
* offscreen canvas, its context, and the spot arrays are built lazily and grown only
|
|
5
|
+
* when the source canvas is resized, so a scan allocates nothing per pixel (only
|
|
6
|
+
* getImageData's single buffer, which the 2D canvas API mandates).
|
|
7
|
+
*
|
|
8
|
+
* Shared by ScratchController and ScratchStage.
|
|
9
|
+
*
|
|
10
|
+
* Copyright (c) Zahary Shinikchiev <shinikchiev@yahoo.com>
|
|
11
|
+
* MIT License.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {HTMLCanvasElement} sourceCanvas The scratch layer to scan.
|
|
16
|
+
* @returns {{ scan(precision:number): { spotX: Float32Array, spotY: Float32Array, count: number },
|
|
17
|
+
* destroy(): void }}
|
|
18
|
+
*/
|
|
19
|
+
export function createPixelScanner(sourceCanvas) {
|
|
20
|
+
let scanCanvas = null;
|
|
21
|
+
let scanCtx = null;
|
|
22
|
+
let scanW = 0, scanH = 0;
|
|
23
|
+
let spotX = new Float32Array(0);
|
|
24
|
+
let spotY = new Float32Array(0);
|
|
25
|
+
const result = { spotX, spotY, count: 0 }; // reused -- no per-scan allocation
|
|
26
|
+
|
|
27
|
+
function ensure(precision) {
|
|
28
|
+
const srcW = sourceCanvas.width || sourceCanvas.offsetWidth || 1;
|
|
29
|
+
const srcH = sourceCanvas.height || sourceCanvas.offsetHeight || 1;
|
|
30
|
+
const h = Math.max(1, Math.floor(srcH * (precision / srcW)));
|
|
31
|
+
if (scanCanvas && scanW === precision && scanH === h) return;
|
|
32
|
+
scanW = precision;
|
|
33
|
+
scanH = h;
|
|
34
|
+
if (!scanCanvas) {
|
|
35
|
+
scanCanvas = document.createElement('canvas');
|
|
36
|
+
scanCtx = scanCanvas.getContext('2d', { willReadFrequently: true });
|
|
37
|
+
}
|
|
38
|
+
scanCanvas.width = scanW;
|
|
39
|
+
scanCanvas.height = scanH;
|
|
40
|
+
const cap = scanW * scanH;
|
|
41
|
+
if (spotX.length < cap) {
|
|
42
|
+
spotX = new Float32Array(cap);
|
|
43
|
+
spotY = new Float32Array(cap);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
scan(precision) {
|
|
49
|
+
ensure(precision);
|
|
50
|
+
scanCtx.clearRect(0, 0, scanW, scanH);
|
|
51
|
+
scanCtx.drawImage(sourceCanvas, 0, 0, scanW, scanH);
|
|
52
|
+
const data = scanCtx.getImageData(0, 0, scanW, scanH).data;
|
|
53
|
+
let n = 0;
|
|
54
|
+
for (let i = 3; i < data.length; i += 4) {
|
|
55
|
+
if (data[i] > 128) {
|
|
56
|
+
const p = (i - 3) / 4;
|
|
57
|
+
spotX[n] = (p % scanW) / scanW;
|
|
58
|
+
spotY[n] = ((p / scanW) | 0) / scanH;
|
|
59
|
+
n++;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
result.spotX = spotX;
|
|
63
|
+
result.spotY = spotY;
|
|
64
|
+
result.count = n;
|
|
65
|
+
return result;
|
|
66
|
+
},
|
|
67
|
+
destroy() {
|
|
68
|
+
scanCanvas = null;
|
|
69
|
+
scanCtx = null;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export default createPixelScanner;
|
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,84 +42,45 @@
|
|
|
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
|
+
|
|
47
|
+
// Shared engines currently driving a reveal. A shared engine has one render slot and one
|
|
48
|
+
// particle pool, so only one controller may reveal on it at a time; a second reveal is
|
|
49
|
+
// ignored rather than clobbering the first. Owned engines never enter this set.
|
|
50
|
+
const busySharedEngines = new WeakSet();
|
|
32
51
|
|
|
33
52
|
/**
|
|
34
53
|
* @param {HTMLCanvasElement} sourceCanvas The scratch layer being revealed
|
|
35
54
|
* @param {HTMLCanvasElement} effectCanvas Overlay canvas for VFX
|
|
36
55
|
* @param {Object} [options]
|
|
56
|
+
* @param {number} [options.maxParticles=2000] Pool capacity (ignored when `engine` is given)
|
|
57
|
+
* @param {number} [options.seed=Date.now()] Deterministic RNG seed
|
|
58
|
+
* @param {number} [options.scanPrecision=32] Horizontal resolution of the pixel scan
|
|
59
|
+
* @param {boolean} [options.driven=false] Host-driven: skip start(), call tick(dt)
|
|
60
|
+
* @param {SoaParticleEngine} [options.engine] Share a caller-supplied engine + lane pool
|
|
37
61
|
*/
|
|
38
62
|
export function createScratchController(sourceCanvas, effectCanvas, {
|
|
39
63
|
maxParticles = 2000,
|
|
40
64
|
seed = Date.now(),
|
|
41
65
|
scanPrecision = 32,
|
|
66
|
+
driven = false,
|
|
67
|
+
engine: externalEngine = null,
|
|
42
68
|
} = {}) {
|
|
43
69
|
const ctx = effectCanvas.getContext('2d');
|
|
44
|
-
const
|
|
70
|
+
const ownEngine = !externalEngine;
|
|
71
|
+
const engine = externalEngine || new SoaParticleEngine(maxParticles);
|
|
45
72
|
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
73
|
|
|
52
74
|
let activeRecipe = null;
|
|
53
75
|
let destroyed = false;
|
|
54
76
|
let elapsed = 0;
|
|
55
77
|
let onComplete = null;
|
|
56
78
|
|
|
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;
|
|
79
|
+
// The pixel scanner owns the offscreen scan canvas + spot arrays, all allocated once
|
|
80
|
+
// and reused across reveals -- zero per-pixel allocation on the reveal-start frame.
|
|
81
|
+
const scanner = createPixelScanner(sourceCanvas);
|
|
68
82
|
const spot = { x: 0.5, y: 0.5 }; // the single shared, reused spawn point
|
|
69
83
|
|
|
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
84
|
// The particle-view object handed to recipe.tick every frame. Built ONCE and
|
|
111
85
|
// reused: the engine's SoA arrays are stable references for the life of the engine,
|
|
112
86
|
// so a fresh `{ x, y, ... }` literal per frame would be a pure zero-GC violation on
|
|
@@ -114,7 +88,7 @@ export function createScratchController(sourceCanvas, effectCanvas, {
|
|
|
114
88
|
let pView = null;
|
|
115
89
|
|
|
116
90
|
// ── Render callback (the engine calls this every frame with raw SoA arrays) ──
|
|
117
|
-
|
|
91
|
+
function renderTick(dt, x, y, vx, vy, life, invLife, data, max) {
|
|
118
92
|
if (destroyed || !activeRecipe) return;
|
|
119
93
|
elapsed += dt;
|
|
120
94
|
const w = effectCanvas.width;
|
|
@@ -134,7 +108,8 @@ export function createScratchController(sourceCanvas, effectCanvas, {
|
|
|
134
108
|
);
|
|
135
109
|
|
|
136
110
|
if (isFinished) {
|
|
137
|
-
engine.stop();
|
|
111
|
+
if (!driven) engine.stop();
|
|
112
|
+
releaseEngine();
|
|
138
113
|
if (activeRecipe.destroy) activeRecipe.destroy();
|
|
139
114
|
activeRecipe = null;
|
|
140
115
|
ctx.clearRect(0, 0, w, h);
|
|
@@ -142,28 +117,48 @@ export function createScratchController(sourceCanvas, effectCanvas, {
|
|
|
142
117
|
onComplete = null;
|
|
143
118
|
if (done) done();
|
|
144
119
|
}
|
|
145
|
-
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Release our hold on a shared engine's single render slot so the next controller can
|
|
123
|
+
// reveal on it. A no-op for an owned engine, which stays bound for its whole life.
|
|
124
|
+
function releaseEngine() {
|
|
125
|
+
if (ownEngine) return;
|
|
126
|
+
engine.onTick(null);
|
|
127
|
+
busySharedEngines.delete(engine);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// An owned engine is bound once for its whole life; the renderTick early-returns when no
|
|
131
|
+
// reveal is active. A shared engine is bound per-reveal (in reveal) and released on
|
|
132
|
+
// completion so it can serve other controllers.
|
|
133
|
+
if (ownEngine) engine.onTick(renderTick);
|
|
146
134
|
|
|
147
135
|
return {
|
|
148
136
|
/**
|
|
149
|
-
* Execute a reveal recipe.
|
|
137
|
+
* Execute a reveal recipe. Ignored if this controller is already revealing, is
|
|
138
|
+
* destroyed, or shares an engine that another controller is revealing on.
|
|
150
139
|
* @param {Object} recipe
|
|
151
140
|
* @param {Function} [onDone] Called when the effect completes
|
|
152
141
|
*/
|
|
153
142
|
reveal(recipe, onDone) {
|
|
154
143
|
if (destroyed || activeRecipe) return;
|
|
144
|
+
if (!ownEngine && busySharedEngines.has(engine)) return;
|
|
155
145
|
|
|
156
146
|
activeRecipe = recipe;
|
|
157
147
|
onComplete = onDone || null;
|
|
158
148
|
elapsed = 0;
|
|
149
|
+
|
|
150
|
+
if (!ownEngine) {
|
|
151
|
+
engine.onTick(renderTick);
|
|
152
|
+
busySharedEngines.add(engine);
|
|
153
|
+
}
|
|
159
154
|
engine.clear();
|
|
160
155
|
|
|
161
156
|
// Size the effect canvas to match the scratch layer
|
|
162
157
|
const w = effectCanvas.width = sourceCanvas.offsetWidth || sourceCanvas.width;
|
|
163
158
|
const h = effectCanvas.height = sourceCanvas.offsetHeight || sourceCanvas.height;
|
|
164
159
|
|
|
165
|
-
// Scan spawn points from visible pixels
|
|
166
|
-
|
|
160
|
+
// Scan spawn points from visible pixels.
|
|
161
|
+
const spots = scanner.scan(scanPrecision);
|
|
167
162
|
|
|
168
163
|
// Init recipe
|
|
169
164
|
recipe.init(ctx, maxParticles, w, h);
|
|
@@ -175,10 +170,10 @@ export function createScratchController(sourceCanvas, effectCanvas, {
|
|
|
175
170
|
const count = Math.min(requested, maxParticles);
|
|
176
171
|
for (let i = 0; i < count; i++) {
|
|
177
172
|
const idx = engine._head;
|
|
178
|
-
if (
|
|
179
|
-
const k = rng.int(0,
|
|
180
|
-
spot.x = spotX[k];
|
|
181
|
-
spot.y = spotY[k];
|
|
173
|
+
if (spots.count > 0) {
|
|
174
|
+
const k = rng.int(0, spots.count - 1);
|
|
175
|
+
spot.x = spots.spotX[k];
|
|
176
|
+
spot.y = spots.spotY[k];
|
|
182
177
|
} else {
|
|
183
178
|
spot.x = 0.5;
|
|
184
179
|
spot.y = 0.5;
|
|
@@ -187,8 +182,21 @@ export function createScratchController(sourceCanvas, effectCanvas, {
|
|
|
187
182
|
engine.emit(state.x, state.y, state.vx, state.vy, state.life, idx);
|
|
188
183
|
}
|
|
189
184
|
|
|
190
|
-
//
|
|
191
|
-
|
|
185
|
+
// Self-driven: the engine's RAF loop drives renderTick. Host-driven: the host
|
|
186
|
+
// calls controller.tick(dt) instead; we schedule nothing.
|
|
187
|
+
if (!driven) engine.start();
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Advance one frame in host-driven mode. `dt` is in SECONDS. A no-op unless the
|
|
192
|
+
* controller was created with `{ driven: true }` AND a reveal is active -- so a host
|
|
193
|
+
* can safely call tick() on every controller each frame; only the revealing one (and,
|
|
194
|
+
* for a shared engine, only the single active controller) does work.
|
|
195
|
+
* @param {number} dt Seconds since the previous frame.
|
|
196
|
+
*/
|
|
197
|
+
tick(dt) {
|
|
198
|
+
if (destroyed || !driven || activeRecipe === null) return;
|
|
199
|
+
engine.tick(dt);
|
|
192
200
|
},
|
|
193
201
|
|
|
194
202
|
/** Re-seed the RNG. */
|
|
@@ -197,13 +205,14 @@ export function createScratchController(sourceCanvas, effectCanvas, {
|
|
|
197
205
|
destroy() {
|
|
198
206
|
if (destroyed) return;
|
|
199
207
|
destroyed = true;
|
|
200
|
-
engine.stop();
|
|
208
|
+
if (!driven) engine.stop();
|
|
209
|
+
releaseEngine();
|
|
201
210
|
if (activeRecipe && activeRecipe.destroy) activeRecipe.destroy();
|
|
202
211
|
activeRecipe = null;
|
|
203
212
|
onComplete = null;
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
engine.destroy();
|
|
213
|
+
scanner.destroy();
|
|
214
|
+
// Only tear down an engine we created. A caller-supplied engine is theirs.
|
|
215
|
+
if (ownEngine) engine.destroy();
|
|
207
216
|
},
|
|
208
217
|
};
|
|
209
218
|
}
|