@zakkster/lite-scratch-fx 1.2.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 +42 -13
- package/README.md +348 -189
- package/index.d.ts +31 -4
- package/index.js +2 -2
- package/llms.txt +6 -6
- package/package.json +2 -1
- package/src/HostStyle.js +42 -0
- package/src/ScratchController.js +67 -26
- package/src/ScratchRecipes.js +13 -13
- package/src/ScratchStage.js +60 -17
package/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @zakkster/lite-scratch-fx
|
|
2
|
+
* @zakkster/lite-scratch-fx -- type declarations.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
/** The package version. Kept in sync with package.json and CHANGELOG. */
|
|
@@ -40,7 +40,7 @@ export interface SpawnState {
|
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* A normalized spawn point (0..1 in each axis) taken from the scratch layer.
|
|
43
|
-
* NOTE: this object is shared and reused across every `spawn` call in a reveal
|
|
43
|
+
* NOTE: this object is shared and reused across every `spawn` call in a reveal --
|
|
44
44
|
* read `x`/`y` synchronously and never retain the reference.
|
|
45
45
|
*/
|
|
46
46
|
export interface SpawnSpot {
|
|
@@ -102,6 +102,11 @@ export interface ScratchControllerOptions {
|
|
|
102
102
|
* at a time; `destroy()` will not tear a shared engine down.
|
|
103
103
|
*/
|
|
104
104
|
engine?: SoaParticleEngine;
|
|
105
|
+
/**
|
|
106
|
+
* Device-pixel-ratio for sharp rendering on high-DPI screens. Default 1. Pass
|
|
107
|
+
* window.devicePixelRatio. Recipes still author in CSS pixels.
|
|
108
|
+
*/
|
|
109
|
+
dpr?: number;
|
|
105
110
|
}
|
|
106
111
|
|
|
107
112
|
export interface ScratchController {
|
|
@@ -117,6 +122,12 @@ export interface ScratchController {
|
|
|
117
122
|
tick(dt: number): void;
|
|
118
123
|
/** Re-seed the RNG. */
|
|
119
124
|
seed(s: number): void;
|
|
125
|
+
/**
|
|
126
|
+
* Abort an in-flight reveal: stop the effect and restore the host canvas's inline
|
|
127
|
+
* style to what it was at reveal start. Does NOT fire `onDone`; the controller is
|
|
128
|
+
* revealable again afterwards. A no-op when idle or destroyed.
|
|
129
|
+
*/
|
|
130
|
+
cancel(): void;
|
|
120
131
|
/** Stop, clean up the active recipe, and release the engine (owned engines only). */
|
|
121
132
|
destroy(): void;
|
|
122
133
|
}
|
|
@@ -132,13 +143,18 @@ export function createScratchController(
|
|
|
132
143
|
|
|
133
144
|
export { createScratchController as ScratchController };
|
|
134
145
|
|
|
135
|
-
//
|
|
146
|
+
// -- Concurrent reveals over one shared pool (createScratchStage) --
|
|
136
147
|
|
|
137
148
|
export interface ScratchStageOptions {
|
|
138
149
|
/** Total shared pool capacity, split across controllers. Default 2000. */
|
|
139
150
|
maxParticles?: number;
|
|
140
151
|
/** Base seed; each controller derives its own (base + index) unless it passes one. */
|
|
141
152
|
seed?: number;
|
|
153
|
+
/**
|
|
154
|
+
* Device-pixel-ratio for sharp rendering on high-DPI screens. Default 1. Pass
|
|
155
|
+
* window.devicePixelRatio. Recipes still author in CSS pixels.
|
|
156
|
+
*/
|
|
157
|
+
dpr?: number;
|
|
142
158
|
}
|
|
143
159
|
|
|
144
160
|
export interface StageControllerOptions {
|
|
@@ -148,6 +164,11 @@ export interface StageControllerOptions {
|
|
|
148
164
|
seed?: number;
|
|
149
165
|
/** Horizontal resolution of the spawn-point pixel scan. Default 32. */
|
|
150
166
|
scanPrecision?: number;
|
|
167
|
+
/**
|
|
168
|
+
* Device-pixel-ratio for sharp rendering on high-DPI screens. Default 1 (the stage
|
|
169
|
+
* dpr). Pass window.devicePixelRatio. Recipes still author in CSS pixels.
|
|
170
|
+
*/
|
|
171
|
+
dpr?: number;
|
|
151
172
|
}
|
|
152
173
|
|
|
153
174
|
/** A stage-managed controller. The stage drives it, so it has no `tick` of its own. */
|
|
@@ -156,6 +177,12 @@ export interface StageController {
|
|
|
156
177
|
reveal(recipe: Recipe, onDone?: () => void): void;
|
|
157
178
|
/** Re-seed this controller's RNG. */
|
|
158
179
|
seed(s: number): void;
|
|
180
|
+
/**
|
|
181
|
+
* Abort this controller's in-flight reveal: stop the effect and restore the host
|
|
182
|
+
* canvas's inline style. Does NOT fire `onDone`; the controller is revealable again.
|
|
183
|
+
* A no-op when idle or destroyed. Does not touch other controllers' reveals.
|
|
184
|
+
*/
|
|
185
|
+
cancel(): void;
|
|
159
186
|
/** Remove this controller from the stage (its sub-range is not reclaimed). */
|
|
160
187
|
destroy(): void;
|
|
161
188
|
}
|
|
@@ -182,7 +209,7 @@ export interface ScratchStage {
|
|
|
182
209
|
/** Create a stage for concurrent reveals over one shared particle pool. */
|
|
183
210
|
export function createScratchStage(options?: ScratchStageOptions): ScratchStage;
|
|
184
211
|
|
|
185
|
-
//
|
|
212
|
+
// -- Recipe factories. Each returns a Recipe; all options are optional. --
|
|
186
213
|
|
|
187
214
|
export interface RecipeOptions {
|
|
188
215
|
count?: number;
|
package/index.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
// Three-place version sync: this constant, package.json "version", and the top
|
|
15
15
|
// CHANGELOG.md heading must always match. /release keeps them locked.
|
|
16
|
-
export const VERSION = '1.
|
|
16
|
+
export const VERSION = '1.3.0';
|
|
17
17
|
|
|
18
18
|
export { createScratchController } from './src/ScratchController.js';
|
|
19
19
|
export { default as ScratchController } from './src/ScratchController.js';
|
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
PeelRecipe, FadeRecipe, ImplosionRecipe,
|
|
38
38
|
} from './src/ScratchRecipes.js';
|
|
39
39
|
|
|
40
|
-
//
|
|
40
|
+
// -- Recipe registry (extensible: registerRecipe adds to it) --
|
|
41
41
|
// Keyed by short name, for data-driven pickers (demo dropdowns, random selection,
|
|
42
42
|
// config files). A null-prototype object so keys never collide with Object.prototype.
|
|
43
43
|
export const RECIPES = Object.assign(Object.create(null), {
|
package/llms.txt
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
## Core model
|
|
9
9
|
|
|
10
10
|
createScratchController(sourceCanvas, effectCanvas,
|
|
11
|
-
{ maxParticles = 2000, seed = Date.now(), scanPrecision = 32, driven = false, engine })
|
|
12
|
-
-> { reveal(recipe, onDone?), tick(dt), seed(s), destroy() }
|
|
11
|
+
{ maxParticles = 2000, seed = Date.now(), scanPrecision = 32, driven = false, engine, dpr = 1 })
|
|
12
|
+
-> { reveal(recipe, onDone?), tick(dt), cancel(), seed(s), destroy() }
|
|
13
13
|
|
|
14
14
|
sourceCanvas = the scratch layer (what the user scratches off).
|
|
15
15
|
effectCanvas = an overlay canvas the particles render onto.
|
|
@@ -76,7 +76,7 @@ All 21 live in src/ScratchRecipes.js, grouped by family with section banners:
|
|
|
76
76
|
## Registry, metadata, extension
|
|
77
77
|
|
|
78
78
|
VERSION: the package version string (kept in sync with package.json and CHANGELOG).
|
|
79
|
-
RECIPES: an extensible null-prototype registry keyed by short name (burn, shatter,
|
|
79
|
+
RECIPES: an extensible null-prototype registry keyed by short name (burn, shatter, ...).
|
|
80
80
|
RECIPE_NAMES: the built-in keys at load time.
|
|
81
81
|
RECIPE_META: a live array of { id, name, category, themeable, needsUntaintedCanvas } for every
|
|
82
82
|
recipe -- the source for building pickers without hardcoding. category is
|
|
@@ -105,10 +105,10 @@ modes exist now:
|
|
|
105
105
|
|
|
106
106
|
## Concurrent reveals over one pool: createScratchStage
|
|
107
107
|
|
|
108
|
-
createScratchStage({ maxParticles = 2000, seed = Date.now() })
|
|
109
|
-
-> { createController(src, fx, { capacity = 300, seed?, scanPrecision = 32 }) -> stageController,
|
|
108
|
+
createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr = 1 })
|
|
109
|
+
-> { createController(src, fx, { capacity = 300, seed?, scanPrecision = 32, dpr? }) -> stageController,
|
|
110
110
|
tick(dt), destroy(), get remainingCapacity }
|
|
111
|
-
stageController -> { reveal(recipe, onDone?), seed(s), destroy() } // NO tick -- the stage drives.
|
|
111
|
+
stageController -> { reveal(recipe, onDone?), cancel(), seed(s), destroy() } // NO tick -- the stage drives.
|
|
112
112
|
|
|
113
113
|
When { engine } (one reveal at a time) is not enough and you need MANY boxes revealing at once
|
|
114
114
|
over one pool: a stage owns one raw engine and gives each controller a fixed sub-range [start,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-scratch-fx",
|
|
3
3
|
"author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.3.0",
|
|
5
5
|
"description": "One-shot scratch-card reveal effects on canvas. A controller scans the still-covered pixels of a scratch layer, spawns particles from them, and delegates physics and rendering to a recipe. 21 themeable recipes (burn, shatter, dissolve, glitch, gold dust, cosmic dust, and more), a registry with a register hook, zero GSAP, a zero-GC hot path, deterministic seeded RNG, and a stage that runs a grid of simultaneous reveals over one shared pool.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./index.js",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"src/ScratchController.js",
|
|
22
22
|
"src/ScratchRecipes.js",
|
|
23
23
|
"src/ScratchStage.js",
|
|
24
|
+
"src/HostStyle.js",
|
|
24
25
|
"src/PixelScan.js",
|
|
25
26
|
"src/Palette.js",
|
|
26
27
|
"README.md",
|
package/src/HostStyle.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HostStyle -- snapshot and restore the host layer's inline style across a reveal.
|
|
3
|
+
*
|
|
4
|
+
* lite-scratch-fx BORROWS the host scratch layer's inline style for the duration of a
|
|
5
|
+
* reveal (recipes write these 5 props during tick) and returns it to exactly what it
|
|
6
|
+
* found on every terminal transition -- completion, cancel, and destroy. The host owns
|
|
7
|
+
* the final revealed state via onDone; the library leaves no fingerprints.
|
|
8
|
+
*
|
|
9
|
+
* The snapshot is one small plain object per reveal. This is a COLD path -- capture at
|
|
10
|
+
* reveal start, restore at reveal end. Never call these per frame.
|
|
11
|
+
*
|
|
12
|
+
* See decisions/0002-host-style-and-cancel.md for the contract.
|
|
13
|
+
*
|
|
14
|
+
* Copyright (c) Zahary Shinikchiev <shinikchiev@yahoo.com>
|
|
15
|
+
* MIT License.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// The 5 inline style props the built-in recipes write during tick (particle/beam/css/
|
|
19
|
+
// image families combined). Capturing every element's CURRENT inline value (usually '')
|
|
20
|
+
// means restore returns the host to pristine regardless of what a custom recipe touched
|
|
21
|
+
// among these props.
|
|
22
|
+
export function captureHostStyle(el) {
|
|
23
|
+
if (!el || !el.style) return null; // fail closed: nothing to borrow, nothing to restore
|
|
24
|
+
const s = el.style;
|
|
25
|
+
return {
|
|
26
|
+
opacity: s.opacity,
|
|
27
|
+
transform: s.transform,
|
|
28
|
+
filter: s.filter,
|
|
29
|
+
clipPath: s.clipPath,
|
|
30
|
+
transformOrigin: s.transformOrigin,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function restoreHostStyle(el, snap) {
|
|
35
|
+
if (!el || !el.style || !snap) return; // fail closed
|
|
36
|
+
const s = el.style;
|
|
37
|
+
s.opacity = snap.opacity;
|
|
38
|
+
s.transform = snap.transform;
|
|
39
|
+
s.filter = snap.filter;
|
|
40
|
+
s.clipPath = snap.clipPath;
|
|
41
|
+
s.transformOrigin = snap.transformOrigin;
|
|
42
|
+
}
|
package/src/ScratchController.js
CHANGED
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
import { SoaParticleEngine } from '@zakkster/lite-soa-particle-engine';
|
|
44
44
|
import { Random } from '@zakkster/lite-random';
|
|
45
45
|
import { createPixelScanner } from './PixelScan.js';
|
|
46
|
+
import { captureHostStyle, restoreHostStyle } from './HostStyle.js';
|
|
46
47
|
|
|
47
48
|
// Shared engines currently driving a reveal. A shared engine has one render slot and one
|
|
48
49
|
// particle pool, so only one controller may reveal on it at a time; a second reveal is
|
|
@@ -58,6 +59,7 @@ const busySharedEngines = new WeakSet();
|
|
|
58
59
|
* @param {number} [options.scanPrecision=32] Horizontal resolution of the pixel scan
|
|
59
60
|
* @param {boolean} [options.driven=false] Host-driven: skip start(), call tick(dt)
|
|
60
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
|
|
61
63
|
*/
|
|
62
64
|
export function createScratchController(sourceCanvas, effectCanvas, {
|
|
63
65
|
maxParticles = 2000,
|
|
@@ -65,7 +67,11 @@ export function createScratchController(sourceCanvas, effectCanvas, {
|
|
|
65
67
|
scanPrecision = 32,
|
|
66
68
|
driven = false,
|
|
67
69
|
engine: externalEngine = null,
|
|
70
|
+
dpr = 1,
|
|
68
71
|
} = {}) {
|
|
72
|
+
if (!(typeof dpr === 'number' && Number.isFinite(dpr) && dpr > 0)) {
|
|
73
|
+
throw new TypeError('createScratchController: dpr must be a positive finite number');
|
|
74
|
+
}
|
|
69
75
|
const ctx = effectCanvas.getContext('2d');
|
|
70
76
|
const ownEngine = !externalEngine;
|
|
71
77
|
const engine = externalEngine || new SoaParticleEngine(maxParticles);
|
|
@@ -75,6 +81,15 @@ export function createScratchController(sourceCanvas, effectCanvas, {
|
|
|
75
81
|
let destroyed = false;
|
|
76
82
|
let elapsed = 0;
|
|
77
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;
|
|
78
93
|
|
|
79
94
|
// The pixel scanner owns the offscreen scan canvas + spot arrays, all allocated once
|
|
80
95
|
// and reused across reveals -- zero per-pixel allocation on the reveal-start frame.
|
|
@@ -87,13 +102,11 @@ export function createScratchController(sourceCanvas, effectCanvas, {
|
|
|
87
102
|
// the hottest path in the package. Reassign the fields each frame (free) instead.
|
|
88
103
|
let pView = null;
|
|
89
104
|
|
|
90
|
-
//
|
|
105
|
+
// -- Render callback (the engine calls this every frame with raw SoA arrays) --
|
|
91
106
|
function renderTick(dt, x, y, vx, vy, life, invLife, data, max) {
|
|
92
107
|
if (destroyed || !activeRecipe) return;
|
|
93
108
|
elapsed += dt;
|
|
94
|
-
|
|
95
|
-
const h = effectCanvas.height;
|
|
96
|
-
ctx.clearRect(0, 0, w, h);
|
|
109
|
+
ctx.clearRect(0, 0, logicalW, logicalH);
|
|
97
110
|
|
|
98
111
|
if (pView === null) {
|
|
99
112
|
pView = { x, y, vx, vy, life, invLife, data, max };
|
|
@@ -104,19 +117,28 @@ export function createScratchController(sourceCanvas, effectCanvas, {
|
|
|
104
117
|
|
|
105
118
|
// Delegate everything to the recipe. Returns true when done.
|
|
106
119
|
const isFinished = activeRecipe.tick(
|
|
107
|
-
dt, elapsed * 1000, pView, ctx, sourceCanvas,
|
|
120
|
+
dt, elapsed * 1000, pView, ctx, sourceCanvas, logicalW, logicalH,
|
|
108
121
|
);
|
|
109
122
|
|
|
110
|
-
if (isFinished)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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();
|
|
120
142
|
}
|
|
121
143
|
|
|
122
144
|
// Release our hold on a shared engine's single render slot so the next controller can
|
|
@@ -153,15 +175,26 @@ export function createScratchController(sourceCanvas, effectCanvas, {
|
|
|
153
175
|
}
|
|
154
176
|
engine.clear();
|
|
155
177
|
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
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);
|
|
159
192
|
|
|
160
193
|
// Scan spawn points from visible pixels.
|
|
161
194
|
const spots = scanner.scan(scanPrecision);
|
|
162
195
|
|
|
163
|
-
// Init recipe
|
|
164
|
-
recipe.init(ctx, maxParticles,
|
|
196
|
+
// Init recipe with LOGICAL size (recipes stay in CSS px).
|
|
197
|
+
recipe.init(ctx, maxParticles, logicalW, logicalH);
|
|
165
198
|
|
|
166
199
|
// Populate ring buffer. A recipe may declare `count: 0` (a pure-canvas reveal
|
|
167
200
|
// like Shine or Implosion that draws no particles); use ?? so an explicit 0 is
|
|
@@ -178,7 +211,7 @@ export function createScratchController(sourceCanvas, effectCanvas, {
|
|
|
178
211
|
spot.x = 0.5;
|
|
179
212
|
spot.y = 0.5;
|
|
180
213
|
}
|
|
181
|
-
const state = recipe.spawn(idx, rng,
|
|
214
|
+
const state = recipe.spawn(idx, rng, logicalW, logicalH, spot);
|
|
182
215
|
engine.emit(state.x, state.y, state.vx, state.vy, state.life, idx);
|
|
183
216
|
}
|
|
184
217
|
|
|
@@ -199,17 +232,25 @@ export function createScratchController(sourceCanvas, effectCanvas, {
|
|
|
199
232
|
engine.tick(dt);
|
|
200
233
|
},
|
|
201
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);
|
|
243
|
+
},
|
|
244
|
+
|
|
202
245
|
/** Re-seed the RNG. */
|
|
203
246
|
seed(s) { rng.reset(s); },
|
|
204
247
|
|
|
205
248
|
destroy() {
|
|
206
249
|
if (destroyed) return;
|
|
207
250
|
destroyed = true;
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
activeRecipe = null;
|
|
212
|
-
onComplete = null;
|
|
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);
|
|
213
254
|
scanner.destroy();
|
|
214
255
|
// Only tear down an engine we created. A caller-supplied engine is theirs.
|
|
215
256
|
if (ownEngine) engine.destroy();
|
package/src/ScratchRecipes.js
CHANGED
|
@@ -359,7 +359,7 @@ export function GoldDustRecipe({count = 150, duration = 2000, colors, theme} = {
|
|
|
359
359
|
ctx.fill();
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
-
//
|
|
362
|
+
// <- FIX: Reset composite operation every frame
|
|
363
363
|
ctx.globalCompositeOperation = 'source-over';
|
|
364
364
|
ctx.globalAlpha = 1;
|
|
365
365
|
return alive === 0 && elapsed >= duration;
|
|
@@ -372,7 +372,7 @@ export function GoldDustRecipe({count = 150, duration = 2000, colors, theme} = {
|
|
|
372
372
|
|
|
373
373
|
|
|
374
374
|
export function ConfettiBlastRecipe({count = 80, duration = 1500, colors, theme} = {}) {
|
|
375
|
-
//
|
|
375
|
+
// <- FIX: Dedicated parallel arrays instead of repurposing size/decay
|
|
376
376
|
let pWobblePhase, pWobbleSpeed, pColorIdx;
|
|
377
377
|
const palette = resolvePalette(colors, theme, ['#ff0055', '#00ffcc', '#ffcc00', '#aa00ff', '#ff6600', '#00aaff']);
|
|
378
378
|
|
|
@@ -480,7 +480,7 @@ export function CosmicDustRecipe({count = 200, duration = 1800, colors, theme} =
|
|
|
480
480
|
ctx.fill();
|
|
481
481
|
}
|
|
482
482
|
|
|
483
|
-
//
|
|
483
|
+
// <- FIX: Reset composite operation every frame
|
|
484
484
|
ctx.globalCompositeOperation = 'source-over';
|
|
485
485
|
ctx.globalAlpha = 1;
|
|
486
486
|
return alive === 0 && elapsed >= duration;
|
|
@@ -522,7 +522,7 @@ export function MatrixDecayRecipe({count = 200, duration = 1500, colors, theme}
|
|
|
522
522
|
|
|
523
523
|
// NOTE: No trail fillRect here. The ScratchController clears the canvas
|
|
524
524
|
// every frame before calling tick(). Crisp falling text over the fading
|
|
525
|
-
// scratch layer is cleaner for a reveal effect
|
|
525
|
+
// scratch layer is cleaner for a reveal effect -- no persistent black box
|
|
526
526
|
// obscuring the prize underneath.
|
|
527
527
|
|
|
528
528
|
ctx.font = '14px monospace';
|
|
@@ -598,7 +598,7 @@ export function LiquidMeltRecipe({count = 100, duration = 1200, colors, theme} =
|
|
|
598
598
|
|
|
599
599
|
ctx.globalAlpha = life[i];
|
|
600
600
|
ctx.fillStyle = palette[0];
|
|
601
|
-
//
|
|
601
|
+
// <- FIX: Use fillRect instead of roundRect for Safari < 17.4 compat
|
|
602
602
|
ctx.fillRect(x[i] - sz / 2, pOriginY[p], sz, streakHeight);
|
|
603
603
|
// Rounded tip at bottom
|
|
604
604
|
ctx.beginPath();
|
|
@@ -685,7 +685,7 @@ export function ShatterRecipe({count = 30, duration = 1200, gravity = 0.5} = {})
|
|
|
685
685
|
|
|
686
686
|
|
|
687
687
|
export function PixelShatterRecipe({count = 60, duration = 1200} = {}) {
|
|
688
|
-
let pRot, pRotSpd, pSz, pOrigX, pOrigY; //
|
|
688
|
+
let pRot, pRotSpd, pSz, pOrigX, pOrigY; // <- FIX: Store ORIGINAL positions
|
|
689
689
|
let img = null, imgReady = false;
|
|
690
690
|
|
|
691
691
|
return {
|
|
@@ -702,7 +702,7 @@ export function PixelShatterRecipe({count = 60, duration = 1200} = {}) {
|
|
|
702
702
|
pRot[idx] = rng.range(0, Math.PI);
|
|
703
703
|
pRotSpd[idx] = rng.range(-0.3, 0.3);
|
|
704
704
|
pSz[idx] = rng.range(5, 15);
|
|
705
|
-
pOrigX[idx] = sx; //
|
|
705
|
+
pOrigX[idx] = sx; // <- FIX: Store spawn position for source crop
|
|
706
706
|
pOrigY[idx] = sy;
|
|
707
707
|
|
|
708
708
|
const angle = rng.range(0, Math.PI * 2);
|
|
@@ -744,7 +744,7 @@ export function PixelShatterRecipe({count = 60, duration = 1200} = {}) {
|
|
|
744
744
|
ctx.globalAlpha = life[i];
|
|
745
745
|
ctx.translate(x[i], y[i]);
|
|
746
746
|
ctx.rotate(pRot[p]);
|
|
747
|
-
//
|
|
747
|
+
// <- FIX: Source crop uses ORIGINAL position, not current animated position
|
|
748
748
|
ctx.drawImage(img, pOrigX[p], pOrigY[p], sz, sz, -sz / 2, -sz / 2, sz, sz);
|
|
749
749
|
ctx.restore();
|
|
750
750
|
}
|
|
@@ -862,7 +862,7 @@ export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount =
|
|
|
862
862
|
let py, pvy, pox, psz, pcol;
|
|
863
863
|
|
|
864
864
|
return {
|
|
865
|
-
count: 0, // No SoA particles
|
|
865
|
+
count: 0, // No SoA particles -- managed internally
|
|
866
866
|
init(ctx, capacity, w, h) {
|
|
867
867
|
py = new Float32Array(particleCount);
|
|
868
868
|
pvy = new Float32Array(particleCount);
|
|
@@ -966,14 +966,14 @@ export function LaserScanRecipe({duration = 1000, colors, theme} = {}) {
|
|
|
966
966
|
ctx.fill();
|
|
967
967
|
}
|
|
968
968
|
|
|
969
|
-
//
|
|
969
|
+
// <- FIX: Reset shadow + composite every frame
|
|
970
970
|
ctx.shadowBlur = 0;
|
|
971
971
|
ctx.shadowColor = 'transparent';
|
|
972
972
|
ctx.globalCompositeOperation = 'source-over';
|
|
973
973
|
}
|
|
974
974
|
|
|
975
975
|
if (raw >= 1) {
|
|
976
|
-
//
|
|
976
|
+
// <- FIX: Clean up clipPath on completion
|
|
977
977
|
src.style.clipPath = '';
|
|
978
978
|
return true;
|
|
979
979
|
}
|
|
@@ -1110,7 +1110,7 @@ export function NeonPulseRecipe({duration = 800, colors, theme} = {}) {
|
|
|
1110
1110
|
const cx = w / 2, cy = h / 2;
|
|
1111
1111
|
|
|
1112
1112
|
src.style.opacity = 1 - t;
|
|
1113
|
-
//
|
|
1113
|
+
// <- FIX: Clamp scale to prevent overflow; clean up on completion
|
|
1114
1114
|
src.style.transform = `scale(${1 + t * 0.3})`;
|
|
1115
1115
|
src.style.transformOrigin = 'center';
|
|
1116
1116
|
|
|
@@ -1127,7 +1127,7 @@ export function NeonPulseRecipe({duration = 800, colors, theme} = {}) {
|
|
|
1127
1127
|
ctx.stroke();
|
|
1128
1128
|
}
|
|
1129
1129
|
|
|
1130
|
-
//
|
|
1130
|
+
// <- FIX: Reset composite operation
|
|
1131
1131
|
ctx.globalCompositeOperation = 'source-over';
|
|
1132
1132
|
ctx.globalAlpha = 1;
|
|
1133
1133
|
|
package/src/ScratchStage.js
CHANGED
|
@@ -23,13 +23,18 @@
|
|
|
23
23
|
import { SoaParticleEngine } from '@zakkster/lite-soa-particle-engine';
|
|
24
24
|
import { Random } from '@zakkster/lite-random';
|
|
25
25
|
import { createPixelScanner } from './PixelScan.js';
|
|
26
|
+
import { captureHostStyle, restoreHostStyle } from './HostStyle.js';
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* @param {Object} [options]
|
|
29
30
|
* @param {number} [options.maxParticles=2000] Total shared pool capacity.
|
|
30
31
|
* @param {number} [options.seed=Date.now()] Base seed; each controller derives its own.
|
|
32
|
+
* @param {number} [options.dpr=1] Default device-pixel-ratio for controllers.
|
|
31
33
|
*/
|
|
32
|
-
export function createScratchStage({ maxParticles = 2000, seed = Date.now() } = {}) {
|
|
34
|
+
export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr: stageDpr = 1 } = {}) {
|
|
35
|
+
if (!(typeof stageDpr === 'number' && Number.isFinite(stageDpr) && stageDpr > 0)) {
|
|
36
|
+
throw new TypeError('createScratchStage: dpr must be a positive finite number');
|
|
37
|
+
}
|
|
33
38
|
const engine = new SoaParticleEngine(maxParticles);
|
|
34
39
|
const active = new Set();
|
|
35
40
|
let nextStart = 0;
|
|
@@ -54,12 +59,16 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now() } =
|
|
|
54
59
|
* @param {number} [opts.capacity=300] Slots reserved for this controller's particles.
|
|
55
60
|
* @param {number} [opts.seed] RNG seed; defaults to base seed + index.
|
|
56
61
|
* @param {number} [opts.scanPrecision=32] Pixel-scan resolution.
|
|
62
|
+
* @param {number} [opts.dpr] Per-controller device-pixel-ratio (default: stage dpr).
|
|
57
63
|
*/
|
|
58
|
-
function createController(sourceCanvas, effectCanvas, { capacity = 300, seed: cSeed, scanPrecision = 32 } = {}) {
|
|
64
|
+
function createController(sourceCanvas, effectCanvas, { capacity = 300, seed: cSeed, scanPrecision = 32, dpr = stageDpr } = {}) {
|
|
59
65
|
if (destroyed) throw new Error('createScratchStage: stage is destroyed');
|
|
60
66
|
if (!(Number.isInteger(capacity) && capacity >= 0)) {
|
|
61
67
|
throw new TypeError('createController: capacity must be a non-negative integer');
|
|
62
68
|
}
|
|
69
|
+
if (!(typeof dpr === 'number' && Number.isFinite(dpr) && dpr > 0)) {
|
|
70
|
+
throw new TypeError('createController: dpr must be a positive finite number');
|
|
71
|
+
}
|
|
63
72
|
if (nextStart + capacity > maxParticles) {
|
|
64
73
|
throw new RangeError(
|
|
65
74
|
`createController: pool exhausted -- ${nextStart}+${capacity} exceeds maxParticles ${maxParticles}`,
|
|
@@ -91,8 +100,31 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now() } =
|
|
|
91
100
|
let activeRecipe = null;
|
|
92
101
|
let onComplete = null;
|
|
93
102
|
let elapsed = 0;
|
|
103
|
+
// Logical (CSS-pixel) reveal size. Recipes author and draw in these; the device-px
|
|
104
|
+
// effect canvas is dpr x larger and the 2D transform maps logical -> device. Every
|
|
105
|
+
// read of cw/ch (the _frame clearRect, recipe init/spawn/tick, endReveal clearRect)
|
|
106
|
+
// wants logical, so cw/ch are logical throughout.
|
|
94
107
|
let cw = 0, ch = 0;
|
|
95
108
|
let ctlDestroyed = false;
|
|
109
|
+
// Host layer inline style at reveal START -- one small object per reveal (cold
|
|
110
|
+
// path), restored on every terminal transition (completion/cancel/destroy).
|
|
111
|
+
let hostSnapshot = null;
|
|
112
|
+
|
|
113
|
+
// The single terminator for this controller's reveal. Completion (fireDone=true),
|
|
114
|
+
// cancel() (false), and destroy() (false) all route here so the recipe teardown,
|
|
115
|
+
// host-style restore, canvas clear, and active-set removal happen in one place.
|
|
116
|
+
// Cold path: once per reveal, never per frame. Sub-range stays reserved (B-8).
|
|
117
|
+
function endReveal(fireDone) {
|
|
118
|
+
if (!activeRecipe) return;
|
|
119
|
+
if (activeRecipe.destroy) activeRecipe.destroy();
|
|
120
|
+
if (hostSnapshot) { restoreHostStyle(sourceCanvas, hostSnapshot); hostSnapshot = null; }
|
|
121
|
+
ctx.clearRect(0, 0, cw, ch);
|
|
122
|
+
active.delete(controller);
|
|
123
|
+
const done = onComplete;
|
|
124
|
+
activeRecipe = null;
|
|
125
|
+
onComplete = null;
|
|
126
|
+
if (fireDone && done) done();
|
|
127
|
+
}
|
|
96
128
|
|
|
97
129
|
const controller = {
|
|
98
130
|
/** Reveal a recipe. Ignored if this controller is already revealing or destroyed. */
|
|
@@ -102,8 +134,17 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now() } =
|
|
|
102
134
|
onComplete = onDone || null;
|
|
103
135
|
elapsed = 0;
|
|
104
136
|
|
|
105
|
-
cw =
|
|
106
|
-
ch =
|
|
137
|
+
cw = sourceCanvas.offsetWidth || sourceCanvas.width;
|
|
138
|
+
ch = sourceCanvas.offsetHeight || sourceCanvas.height;
|
|
139
|
+
// Device-pixel backing store: dpr x larger for sharp high-DPI output.
|
|
140
|
+
// Assigning width/height resets the 2D transform, so map logical -> device
|
|
141
|
+
// with setTransform. At dpr === 1 skip it for byte-identical behaviour.
|
|
142
|
+
effectCanvas.width = Math.round(cw * dpr);
|
|
143
|
+
effectCanvas.height = Math.round(ch * dpr);
|
|
144
|
+
if (dpr !== 1) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
145
|
+
|
|
146
|
+
// Borrow the host layer's inline style; endReveal restores it pristine.
|
|
147
|
+
hostSnapshot = captureHostStyle(sourceCanvas);
|
|
107
148
|
|
|
108
149
|
const spots = scanner.scan(scanPrecision);
|
|
109
150
|
recipe.init(ctx, capacity, cw, ch);
|
|
@@ -139,15 +180,18 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now() } =
|
|
|
139
180
|
elapsed += dt;
|
|
140
181
|
ctx.clearRect(0, 0, cw, ch);
|
|
141
182
|
const isFinished = activeRecipe.tick(dt, elapsed * 1000, pView, ctx, sourceCanvas, cw, ch);
|
|
142
|
-
if (isFinished)
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
183
|
+
if (isFinished) endReveal(true);
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Abort this controller's in-flight reveal: stop the effect, restore the host
|
|
188
|
+
* canvas's inline style, and do NOT fire onDone; the controller is revealable
|
|
189
|
+
* again. A no-op when idle or destroyed. Does not reclaim the sub-range (B-8)
|
|
190
|
+
* and never touches other controllers' in-flight reveals.
|
|
191
|
+
*/
|
|
192
|
+
cancel() {
|
|
193
|
+
if (ctlDestroyed || !activeRecipe) return;
|
|
194
|
+
endReveal(false);
|
|
151
195
|
},
|
|
152
196
|
|
|
153
197
|
/** Re-seed this controller's RNG. */
|
|
@@ -157,10 +201,9 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now() } =
|
|
|
157
201
|
destroy() {
|
|
158
202
|
if (ctlDestroyed) return;
|
|
159
203
|
ctlDestroyed = true;
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
active.delete(controller);
|
|
204
|
+
// Route any in-flight reveal through the single terminator (recipe teardown,
|
|
205
|
+
// host-style restore, active-set removal, no onDone) before scanner teardown.
|
|
206
|
+
endReveal(false);
|
|
164
207
|
scanner.destroy();
|
|
165
208
|
},
|
|
166
209
|
};
|