@zakkster/lite-scratch-fx 1.5.1 → 1.7.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 +48 -0
- package/README.md +36 -3
- package/index.d.ts +27 -2
- package/index.js +27 -23
- package/llms.txt +30 -7
- package/package.json +1 -1
- package/src/ScratchController.js +15 -0
- package/src/ScratchRecipes.js +100 -10
- package/src/ScratchStage.js +12 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,53 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.7.0] - 2026-08-24
|
|
4
|
+
|
|
5
|
+
Optional per-frame glow cap for the two haloed particle recipes (B-20). Additive and
|
|
6
|
+
backward compatible: the knob defaults to `Infinity` (unlimited), so default output is
|
|
7
|
+
byte-identical to 1.6.0. The per-frame render path gains one counter and one guard.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **`glowBudget` option on `BurnRecipe` and `DragonBreathRecipe` (default `Infinity` =
|
|
11
|
+
unlimited).** Caps how many particles paint their outer glow halo per frame. Once the
|
|
12
|
+
budget is spent that tick, further particles draw only their brighter core arc; the
|
|
13
|
+
counter resets every frame. The core arc, physics, `alive` accounting, and the
|
|
14
|
+
offscreen cull are never capped. Uses a strict `g < glowBudget` compare (never a falsy
|
|
15
|
+
coercion), so `glowBudget: 0` means exactly zero halos (cores only) and the unset
|
|
16
|
+
default `Infinity` keeps every halo. `glowBudget: 80` matches the game's `GLOW_BUDGET`
|
|
17
|
+
parity. Zero new hot-path allocation.
|
|
18
|
+
|
|
19
|
+
## [1.6.0] - 2026-08-23
|
|
20
|
+
|
|
21
|
+
Reduced-motion support (N-4). Backward compatible: the new controller/stage option
|
|
22
|
+
defaults to `false`, the helper and metadata field are additive, and existing callers
|
|
23
|
+
are byte-unaffected. The per-frame render path is unchanged.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- **`reducedMotion` option on `createScratchController` and `stage.createController`
|
|
27
|
+
(default `false`).** When `true`, every `reveal(recipe)` ignores the passed recipe and
|
|
28
|
+
runs an internal `count: 0` opacity fade instead -- zero particles, no motion beyond the
|
|
29
|
+
fade, `onDone` still fires once on completion. The library does not read
|
|
30
|
+
`prefers-reduced-motion` itself; the host passes the result of its own media query. The
|
|
31
|
+
fallback recipe is built once per controller (cold path) and reused across reveals; it is
|
|
32
|
+
stateless, so it is re-entrant across successive reveals on the same controller.
|
|
33
|
+
- **`reducedMotionRecipe(recipe, { scale = 0.25, duration } = {})`.** A pure wrapper that
|
|
34
|
+
keeps a recipe's look but spawns `Math.round(recipe.count * scale)` particles (a quarter
|
|
35
|
+
by default). It delegates `init`/`spawn`/`tick`/`destroy` to the inner recipe and reuses
|
|
36
|
+
its arrays, so it adds no hot-path allocation. `duration`, if given, caps the reveal early
|
|
37
|
+
(`elapsedMs >= duration`); it does not re-time the inner easing curve. It reduces particle
|
|
38
|
+
count only and cannot suppress a recipe's own per-particle glow, which is drawn inside the
|
|
39
|
+
inner `tick`. Throws `TypeError` on a non-recipe argument or a non-finite/negative `scale`.
|
|
40
|
+
- **`RECIPE_META[*].motionSafe` (boolean).** Marks inherently calm built-ins so a picker can
|
|
41
|
+
prefer them under reduced motion. Strict: `true` for `fade` only (a pure opacity fade);
|
|
42
|
+
every recipe that moves geometry -- particles, the peel wipe, the implosion -- is `false`.
|
|
43
|
+
Threaded through `registerRecipe` with the same prev-fallback rule as the other flags.
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
- **Torture gate extended.** Two tiers added to `test/torture.mjs`: T6 gates the blanket
|
|
47
|
+
`reducedMotion` reveal/tick/complete cycle and T7 gates a `reducedMotionRecipe`-wrapped
|
|
48
|
+
steady state, both under the existing 64 B/op ceiling (measured 41.6 and 6.9 B/op; their
|
|
49
|
+
broken-variant controls allocate ~1193 and ~1149 B/op). Test count 283 -> 322.
|
|
50
|
+
|
|
3
51
|
## [1.5.1] - 2026-08-23
|
|
4
52
|
|
|
5
53
|
Cover-fade fidelity patch for `iceBreath`. Backward compatible; no API change.
|
package/README.md
CHANGED
|
@@ -58,6 +58,7 @@ time.
|
|
|
58
58
|
- [createScratchController](#createscratchcontroller)
|
|
59
59
|
- [createScratchStage](#createscratchstage)
|
|
60
60
|
- [Registry and palette helpers](#registry-and-palette-helpers)
|
|
61
|
+
- [Reduced motion](#reduced-motion)
|
|
61
62
|
- [Constants](#constants)
|
|
62
63
|
- [Composability](#composability)
|
|
63
64
|
- [Zero-GC design notes](#zero-gc-design-notes)
|
|
@@ -267,14 +268,46 @@ current built-in look:
|
|
|
267
268
|
- **`DragonBreathRecipe`** / **`IceBreathRecipe`** -- `spread` (jet half-angle in radians,
|
|
268
269
|
defaults `0.3` / `0.225`), `speedMin`/`speedMax` (spawn speed range, defaults `12`-`25`
|
|
269
270
|
/ `15`-`28`).
|
|
270
|
-
- **`
|
|
271
|
-
|
|
271
|
+
- **`BurnRecipe`** / **`DragonBreathRecipe`** -- `glowBudget` (default `Infinity` =
|
|
272
|
+
unlimited): a per-frame cap on how many particles paint their outer glow halo. The
|
|
273
|
+
brighter core arc, physics, and offscreen cull are never capped -- only the halo is
|
|
274
|
+
skipped once the budget is spent that frame; the counter resets every tick. `0` means
|
|
275
|
+
zero halos (cores only); `80` matches the game's `GLOW_BUDGET` parity. Default output is
|
|
276
|
+
byte-identical to earlier versions.
|
|
277
|
+
|
|
278
|
+
### Reduced motion
|
|
279
|
+
|
|
280
|
+
Reveals run in lottery / instant-win UIs, so honouring `prefers-reduced-motion` matters.
|
|
281
|
+
The library does not read the media query itself -- the host owns it -- but it gives you
|
|
282
|
+
two ways to respond, plus a flag for building a picker.
|
|
283
|
+
|
|
284
|
+
- **Blanket switch: `{ reducedMotion: true }`** on `createScratchController` or
|
|
285
|
+
`stage.createController`. Every `reveal(recipe)` then ignores the recipe it is handed and
|
|
286
|
+
runs a calm `count: 0` opacity fade instead -- zero particles, no spray, `onDone` still
|
|
287
|
+
fires once on completion. This is the honest "motion off" path.
|
|
288
|
+
|
|
289
|
+
```js
|
|
290
|
+
const calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
291
|
+
const ctrl = createScratchController(cover, fx, { reducedMotion: calm });
|
|
292
|
+
ctrl.reveal(RECIPES.dragonBreath()); // draws a plain fade when calm is true
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
- **Toned-down variant: `reducedMotionRecipe(recipe, { scale = 0.25, duration? })`**. Wraps
|
|
296
|
+
any recipe and keeps its look but spawns `round(count * scale)` particles (a quarter, by
|
|
297
|
+
default); an optional `duration` caps the reveal early. It reuses the inner recipe's
|
|
298
|
+
arrays, so it adds no hot-path allocation. Note it can only reduce particle *count* -- it
|
|
299
|
+
cannot suppress a recipe's own per-particle glow, which is drawn inside the recipe's
|
|
300
|
+
`tick`. For a full "motion off", use the blanket switch above.
|
|
301
|
+
|
|
302
|
+
- **`RECIPE_META[*].motionSafe`** marks the inherently calm built-ins so a picker can prefer
|
|
303
|
+
them. It is strict: only `fade` (a pure opacity fade) is `motionSafe: true`; every recipe
|
|
304
|
+
that moves geometry -- particles, a peel wipe, an implosion -- is `false`.
|
|
272
305
|
|
|
273
306
|
### Constants
|
|
274
307
|
|
|
275
308
|
| Constant | Value | Meaning |
|
|
276
309
|
| ------------------------- | ----------------------------------------------------------- | ---------------------------------------------------- |
|
|
277
|
-
| `VERSION` | `'1.
|
|
310
|
+
| `VERSION` | `'1.7.0'` | Package version string (synced to package.json). |
|
|
278
311
|
| `maxParticles` (default) | `2000` | Controller / stage pool capacity. |
|
|
279
312
|
| `scanPrecision` (default) | `32` | Horizontal resolution of the spawn-point scan. |
|
|
280
313
|
| stage `capacity` (default)| `300` | Slots a stage controller reserves from the pool. |
|
package/index.d.ts
CHANGED
|
@@ -108,6 +108,12 @@ export interface ScratchControllerOptions {
|
|
|
108
108
|
* window.devicePixelRatio. Recipes still author in CSS pixels.
|
|
109
109
|
*/
|
|
110
110
|
dpr?: number;
|
|
111
|
+
/**
|
|
112
|
+
* Honour prefers-reduced-motion. When true, every reveal runs a calm count:0
|
|
113
|
+
* opacity fade instead of the requested recipe (motion off, not a wrapped recipe).
|
|
114
|
+
* Default false. The host owns the media query and passes the result.
|
|
115
|
+
*/
|
|
116
|
+
reducedMotion?: boolean;
|
|
111
117
|
}
|
|
112
118
|
|
|
113
119
|
export interface ScratchController {
|
|
@@ -170,6 +176,11 @@ export interface StageControllerOptions {
|
|
|
170
176
|
* dpr). Pass window.devicePixelRatio. Recipes still author in CSS pixels.
|
|
171
177
|
*/
|
|
172
178
|
dpr?: number;
|
|
179
|
+
/**
|
|
180
|
+
* Honour prefers-reduced-motion. When true, every reveal runs a calm count:0
|
|
181
|
+
* opacity fade instead of the requested recipe. Default false. The host owns the query.
|
|
182
|
+
*/
|
|
183
|
+
reducedMotion?: boolean;
|
|
173
184
|
}
|
|
174
185
|
|
|
175
186
|
/** A stage-managed controller. The stage drives it, so it has no `tick` of its own. */
|
|
@@ -231,11 +242,11 @@ export interface ThemeableRecipeOptions extends RecipeOptions {
|
|
|
231
242
|
theme?: Theme;
|
|
232
243
|
}
|
|
233
244
|
|
|
234
|
-
export function BurnRecipe(opts?: ThemeableRecipeOptions): Recipe;
|
|
245
|
+
export function BurnRecipe(opts?: ThemeableRecipeOptions & { glowBudget?: number }): Recipe;
|
|
235
246
|
export function ShatterRecipe(opts?: RecipeOptions & { gravity?: number }): Recipe;
|
|
236
247
|
export function DissolveRecipe(opts?: ThemeableRecipeOptions & { fadeSpeed?: number }): Recipe;
|
|
237
248
|
export function ExplodeRecipe(opts?: ThemeableRecipeOptions & { force?: number }): Recipe;
|
|
238
|
-
export function DragonBreathRecipe(opts?: ThemeableRecipeOptions & { spread?: number; speedMin?: number; speedMax?: number }): Recipe;
|
|
249
|
+
export function DragonBreathRecipe(opts?: ThemeableRecipeOptions & { spread?: number; speedMin?: number; speedMax?: number; glowBudget?: number }): Recipe;
|
|
239
250
|
export function IceBreathRecipe(opts?: ThemeableRecipeOptions & { spread?: number; speedMin?: number; speedMax?: number }): Recipe;
|
|
240
251
|
export function ShineWaveRecipe(opts?: ThemeableRecipeOptions & { beamWidth?: number; particleCount?: number; ease?: (t: number) => number }): Recipe;
|
|
241
252
|
export function LightningCrawlRecipe(opts?: ThemeableRecipeOptions & { boltCount?: number }): Recipe;
|
|
@@ -244,6 +255,18 @@ export function PeelRecipe(opts?: RecipeOptions & { ease?: (t: number) => number
|
|
|
244
255
|
export function FadeRecipe(opts?: RecipeOptions): Recipe;
|
|
245
256
|
export function ImplosionRecipe(opts?: RecipeOptions): Recipe;
|
|
246
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Dial a recipe down for reduced motion: a pure delegating wrapper that reduces the
|
|
260
|
+
* inner recipe's particle COUNT (default a quarter) and, optionally, caps the reveal
|
|
261
|
+
* early via `duration`. Adds zero hot-path allocation (reuses the inner arrays). It
|
|
262
|
+
* cannot suppress the inner recipe's per-particle painted halo -- that lives inside
|
|
263
|
+
* its tick; `duration` caps the reveal early, it does not re-time the inner curve.
|
|
264
|
+
*/
|
|
265
|
+
export function reducedMotionRecipe(
|
|
266
|
+
recipe: Recipe,
|
|
267
|
+
opts?: { scale?: number; duration?: number },
|
|
268
|
+
): Recipe;
|
|
269
|
+
|
|
247
270
|
export function GlitchRevealRecipe(opts?: RecipeOptions): Recipe;
|
|
248
271
|
export function MatrixDecayRecipe(opts?: ThemeableRecipeOptions): Recipe;
|
|
249
272
|
export function GoldDustRecipe(opts?: ThemeableRecipeOptions): Recipe;
|
|
@@ -282,6 +305,8 @@ export interface RecipeMeta {
|
|
|
282
305
|
category: RecipeCategory;
|
|
283
306
|
themeable: boolean;
|
|
284
307
|
needsUntaintedCanvas: boolean;
|
|
308
|
+
/** Inherently-calm reveal a picker can prefer under prefers-reduced-motion; strict = pure-opacity fade only. */
|
|
309
|
+
motionSafe: boolean;
|
|
285
310
|
}
|
|
286
311
|
|
|
287
312
|
/** Live metadata array for every recipe; `registerRecipe` keeps it in sync. */
|
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.7.0';
|
|
17
17
|
|
|
18
18
|
export { createScratchController } from './src/ScratchController.js';
|
|
19
19
|
export { default as ScratchController } from './src/ScratchController.js';
|
|
@@ -27,6 +27,7 @@ export {
|
|
|
27
27
|
ShatterRecipe, PixelShatterRecipe, GlitchRevealRecipe,
|
|
28
28
|
ShineRecipe, ShineWaveRecipe, LaserScanRecipe, LightningCrawlRecipe, NeonPulseRecipe,
|
|
29
29
|
PeelRecipe, FadeRecipe, ImplosionRecipe,
|
|
30
|
+
reducedMotionRecipe,
|
|
30
31
|
} from './src/ScratchRecipes.js';
|
|
31
32
|
|
|
32
33
|
import {
|
|
@@ -76,29 +77,31 @@ export const RECIPES = Object.assign(Object.create(null), {
|
|
|
76
77
|
* back. The flag is retained so a third-party recipe that
|
|
77
78
|
* does read the canvas (getImageData/toDataURL) can
|
|
78
79
|
* advertise a same-origin requirement to pickers.
|
|
80
|
+
* motionSafe inherently-calm reveal a picker can prefer under
|
|
81
|
+
* prefers-reduced-motion; strict = pure-opacity fade only.
|
|
79
82
|
*/
|
|
80
83
|
export const RECIPE_META = [
|
|
81
|
-
{ id: 'burn', name: 'Burn', category: 'particle', themeable: true, needsUntaintedCanvas: false },
|
|
82
|
-
{ id: 'dissolve', name: 'Dissolve', category: 'particle', themeable: true, needsUntaintedCanvas: false },
|
|
83
|
-
{ id: 'explode', name: 'Explode', category: 'particle', themeable: true, needsUntaintedCanvas: false },
|
|
84
|
-
{ id: 'dragonBreath', name: 'Dragon Breath', category: 'particle', themeable: true, needsUntaintedCanvas: false },
|
|
85
|
-
{ id: 'iceBreath', name: 'Ice Breath', category: 'particle', themeable: true, needsUntaintedCanvas: false },
|
|
86
|
-
{ id: 'goldDust', name: 'Gold Dust', category: 'particle', themeable: true, needsUntaintedCanvas: false },
|
|
87
|
-
{ id: 'confettiBlast', name: 'Confetti Blast', category: 'particle', themeable: true, needsUntaintedCanvas: false },
|
|
88
|
-
{ id: 'cosmicDust', name: 'Cosmic Dust', category: 'particle', themeable: true, needsUntaintedCanvas: false },
|
|
89
|
-
{ id: 'matrixDecay', name: 'Matrix Decay', category: 'particle', themeable: true, needsUntaintedCanvas: false },
|
|
90
|
-
{ id: 'liquidMelt', name: 'Liquid Melt', category: 'particle', themeable: true, needsUntaintedCanvas: false },
|
|
91
|
-
{ id: 'shatter', name: 'Shatter', category: 'image', themeable: false, needsUntaintedCanvas: false },
|
|
92
|
-
{ id: 'pixelShatter', name: 'Pixel Shatter', category: 'image', themeable: false, needsUntaintedCanvas: false },
|
|
93
|
-
{ id: 'glitchReveal', name: 'Glitch Reveal', category: 'image', themeable: false, needsUntaintedCanvas: false },
|
|
94
|
-
{ id: 'shine', name: 'Shine', category: 'beam', themeable: false, needsUntaintedCanvas: false },
|
|
95
|
-
{ id: 'shineWave', name: 'Shine Wave', category: 'beam', themeable: true, needsUntaintedCanvas: false },
|
|
96
|
-
{ id: 'laserScan', name: 'Laser Scan', category: 'beam', themeable: true, needsUntaintedCanvas: false },
|
|
97
|
-
{ id: 'lightningCrawl', name: 'Lightning Crawl', category: 'beam', themeable: true, needsUntaintedCanvas: false },
|
|
98
|
-
{ id: 'neonPulse', name: 'Neon Pulse', category: 'beam', themeable: true, needsUntaintedCanvas: false },
|
|
99
|
-
{ id: 'peel', name: 'Peel', category: 'css', themeable: false, needsUntaintedCanvas: false },
|
|
100
|
-
{ id: 'fade', name: 'Fade', category: 'css', themeable: false, needsUntaintedCanvas: false },
|
|
101
|
-
{ id: 'implosion', name: 'Implosion', category: 'css', themeable: false, needsUntaintedCanvas: false },
|
|
84
|
+
{ id: 'burn', name: 'Burn', category: 'particle', themeable: true, needsUntaintedCanvas: false, motionSafe: false },
|
|
85
|
+
{ id: 'dissolve', name: 'Dissolve', category: 'particle', themeable: true, needsUntaintedCanvas: false, motionSafe: false },
|
|
86
|
+
{ id: 'explode', name: 'Explode', category: 'particle', themeable: true, needsUntaintedCanvas: false, motionSafe: false },
|
|
87
|
+
{ id: 'dragonBreath', name: 'Dragon Breath', category: 'particle', themeable: true, needsUntaintedCanvas: false, motionSafe: false },
|
|
88
|
+
{ id: 'iceBreath', name: 'Ice Breath', category: 'particle', themeable: true, needsUntaintedCanvas: false, motionSafe: false },
|
|
89
|
+
{ id: 'goldDust', name: 'Gold Dust', category: 'particle', themeable: true, needsUntaintedCanvas: false, motionSafe: false },
|
|
90
|
+
{ id: 'confettiBlast', name: 'Confetti Blast', category: 'particle', themeable: true, needsUntaintedCanvas: false, motionSafe: false },
|
|
91
|
+
{ id: 'cosmicDust', name: 'Cosmic Dust', category: 'particle', themeable: true, needsUntaintedCanvas: false, motionSafe: false },
|
|
92
|
+
{ id: 'matrixDecay', name: 'Matrix Decay', category: 'particle', themeable: true, needsUntaintedCanvas: false, motionSafe: false },
|
|
93
|
+
{ id: 'liquidMelt', name: 'Liquid Melt', category: 'particle', themeable: true, needsUntaintedCanvas: false, motionSafe: false },
|
|
94
|
+
{ id: 'shatter', name: 'Shatter', category: 'image', themeable: false, needsUntaintedCanvas: false, motionSafe: false },
|
|
95
|
+
{ id: 'pixelShatter', name: 'Pixel Shatter', category: 'image', themeable: false, needsUntaintedCanvas: false, motionSafe: false },
|
|
96
|
+
{ id: 'glitchReveal', name: 'Glitch Reveal', category: 'image', themeable: false, needsUntaintedCanvas: false, motionSafe: false },
|
|
97
|
+
{ id: 'shine', name: 'Shine', category: 'beam', themeable: false, needsUntaintedCanvas: false, motionSafe: false },
|
|
98
|
+
{ id: 'shineWave', name: 'Shine Wave', category: 'beam', themeable: true, needsUntaintedCanvas: false, motionSafe: false },
|
|
99
|
+
{ id: 'laserScan', name: 'Laser Scan', category: 'beam', themeable: true, needsUntaintedCanvas: false, motionSafe: false },
|
|
100
|
+
{ id: 'lightningCrawl', name: 'Lightning Crawl', category: 'beam', themeable: true, needsUntaintedCanvas: false, motionSafe: false },
|
|
101
|
+
{ id: 'neonPulse', name: 'Neon Pulse', category: 'beam', themeable: true, needsUntaintedCanvas: false, motionSafe: false },
|
|
102
|
+
{ id: 'peel', name: 'Peel', category: 'css', themeable: false, needsUntaintedCanvas: false, motionSafe: false },
|
|
103
|
+
{ id: 'fade', name: 'Fade', category: 'css', themeable: false, needsUntaintedCanvas: false, motionSafe: true },
|
|
104
|
+
{ id: 'implosion', name: 'Implosion', category: 'css', themeable: false, needsUntaintedCanvas: false, motionSafe: false },
|
|
102
105
|
];
|
|
103
106
|
|
|
104
107
|
/** Names of every built-in recipe (the keys of RECIPES at load time). */
|
|
@@ -111,7 +114,7 @@ export const RECIPE_NAMES = Object.freeze(Object.keys(RECIPES));
|
|
|
111
114
|
*
|
|
112
115
|
* @param {string} id short name (the RECIPES key)
|
|
113
116
|
* @param {Function} factory a recipe factory: (opts) => Recipe
|
|
114
|
-
* @param {{ name?: string, category?: string, themeable?: boolean, needsUntaintedCanvas?: boolean }} [meta]
|
|
117
|
+
* @param {{ name?: string, category?: string, themeable?: boolean, needsUntaintedCanvas?: boolean, motionSafe?: boolean }} [meta]
|
|
115
118
|
* Omitted fields fall back to the existing entry (when overriding), then to a
|
|
116
119
|
* de-camelCased name, category 'custom', and false flags.
|
|
117
120
|
* @returns {Function} the registered factory
|
|
@@ -135,6 +138,7 @@ export function registerRecipe(id, factory, meta) {
|
|
|
135
138
|
themeable: meta && 'themeable' in meta ? !!meta.themeable : (prev ? prev.themeable : false),
|
|
136
139
|
needsUntaintedCanvas: meta && 'needsUntaintedCanvas' in meta
|
|
137
140
|
? !!meta.needsUntaintedCanvas : (prev ? prev.needsUntaintedCanvas : false),
|
|
141
|
+
motionSafe: meta && 'motionSafe' in meta ? !!meta.motionSafe : (prev ? prev.motionSafe : false),
|
|
138
142
|
};
|
|
139
143
|
if (idx >= 0) RECIPE_META[idx] = entry; else RECIPE_META.push(entry);
|
|
140
144
|
return factory;
|
package/llms.txt
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
## Core model
|
|
9
9
|
|
|
10
10
|
createScratchController(sourceCanvas, effectCanvas,
|
|
11
|
-
{ maxParticles = 2000, seed = Date.now(), scanPrecision = 32, driven = false, engine, dpr = 1
|
|
11
|
+
{ maxParticles = 2000, seed = Date.now(), scanPrecision = 32, driven = false, engine, dpr = 1,
|
|
12
|
+
reducedMotion = false })
|
|
12
13
|
-> { reveal(recipe, onDone?), tick(dt), cancel(), seed(s), destroy() }
|
|
13
14
|
|
|
14
15
|
sourceCanvas = the scratch layer (what the user scratches off).
|
|
@@ -77,20 +78,25 @@ Every factory takes an options bag; all options default to the shipped tuning, s
|
|
|
77
78
|
no args reproduces the stock effect. Beyond { colors, theme } (the 14 themeable recipes) and
|
|
78
79
|
count/duration, the FX_CONFIG-style knobs are: dissolve fadeSpeed (fade window as a fraction of
|
|
79
80
|
duration); dragonBreath and iceBreath spread / speedMin / speedMax (spawn cone half-angle in
|
|
80
|
-
radians and speed range);
|
|
81
|
-
|
|
81
|
+
radians and speed range); burn and dragonBreath glowBudget (per-frame cap on how many particles
|
|
82
|
+
paint their outer glow halo, default Infinity = unlimited; 0 = zero halos, cores only; 80 =
|
|
83
|
+
game GLOW_BUDGET parity -- the core arc, physics and offscreen cull are never capped and the
|
|
84
|
+
counter resets each tick, so the default output is byte-identical to earlier versions); peel and
|
|
85
|
+
shineWave ease (a t -> t easing function). A non-function ease falls back to the recipe's default curve.
|
|
82
86
|
|
|
83
87
|
## Registry, metadata, extension
|
|
84
88
|
|
|
85
89
|
VERSION: the package version string (kept in sync with package.json and CHANGELOG).
|
|
86
90
|
RECIPES: an extensible null-prototype registry keyed by short name (burn, shatter, ...).
|
|
87
91
|
RECIPE_NAMES: the built-in keys at load time.
|
|
88
|
-
RECIPE_META: a live array of { id, name, category, themeable, needsUntaintedCanvas } for
|
|
89
|
-
recipe -- the source for building pickers without hardcoding. category is
|
|
92
|
+
RECIPE_META: a live array of { id, name, category, themeable, needsUntaintedCanvas, motionSafe } for
|
|
93
|
+
every recipe -- the source for building pickers without hardcoding. category is
|
|
90
94
|
'particle'|'image'|'beam'|'css'. needsUntaintedCanvas is false for every built-in (image recipes
|
|
91
95
|
draw the layer live via drawImage(src), never reading it back); the flag is retained so a
|
|
92
96
|
third-party recipe that reads the canvas (getImageData/toDataURL) can advertise a same-origin
|
|
93
|
-
requirement to pickers. themeable is true for the 14 recipes that take { colors, theme }.
|
|
97
|
+
requirement to pickers. themeable is true for the 14 recipes that take { colors, theme }. motionSafe
|
|
98
|
+
is an inherently-calm reveal a picker can prefer under prefers-reduced-motion; strict = true for
|
|
99
|
+
'fade' ONLY (pure-opacity cross-fade), false for every other built-in (all animate or paint).
|
|
94
100
|
registerRecipe(id, factory, meta?): add a recipe or override a built-in; lands in RECIPES and
|
|
95
101
|
RECIPE_META immediately so existing pickers keep working. Mirrors lite-ambient's registerTheme.
|
|
96
102
|
Omitted meta fields fall back to the prior entry, then a de-camelCased name, category 'custom',
|
|
@@ -115,7 +121,7 @@ modes exist now:
|
|
|
115
121
|
## Concurrent reveals over one pool: createScratchStage
|
|
116
122
|
|
|
117
123
|
createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr = 1 })
|
|
118
|
-
-> { createController(src, fx, { capacity = 300, seed?, scanPrecision = 32, dpr
|
|
124
|
+
-> { createController(src, fx, { capacity = 300, seed?, scanPrecision = 32, dpr?, reducedMotion = false }) -> stageController,
|
|
119
125
|
tick(dt), destroy(), get remainingCapacity, remainingCapacityFor(capacity) }
|
|
120
126
|
stageController -> { reveal(recipe, onDone?), cancel(), seed(s), destroy() } // NO tick -- the stage drives.
|
|
121
127
|
|
|
@@ -130,6 +136,23 @@ later createController; cancel/completion hold the range (the controller stays r
|
|
|
130
136
|
Feasible with no engine change because raw tick(dt) is pure dispatch (no physics/culling) and the
|
|
131
137
|
lanes are public typed arrays recipes already write. See decisions/0001-concurrent-shared-reveals.md.
|
|
132
138
|
|
|
139
|
+
## Reduced motion
|
|
140
|
+
|
|
141
|
+
The host owns the media query; the library never touches matchMedia (DOM-query-free, SSR-safe).
|
|
142
|
+
Read prefers-reduced-motion and pass the boolean in. Two shapes:
|
|
143
|
+
Blanket switch: createScratchController(src, fx, { reducedMotion }) (and stage.createController).
|
|
144
|
+
When true, EVERY reveal honestly REPLACES the requested recipe with a calm count:0 opacity fade
|
|
145
|
+
(~300ms, motion off -- not a wrapped recipe): zero particles spawn, no halo painted. The calm
|
|
146
|
+
fallback is built once per controller (cold) -> no per-reveal garbage. Default false = full motion.
|
|
147
|
+
Helper: reducedMotionRecipe(recipe, { scale = 0.25, duration }) -> Recipe. A pure delegating wrapper:
|
|
148
|
+
count becomes round(inner.count * scale) (fewer particles; 0 stays 0), init/spawn/tick/destroy
|
|
149
|
+
delegate verbatim -> zero added hot-path alloc. Reduces particle COUNT only -- it CANNOT suppress
|
|
150
|
+
the inner recipe's per-particle painted halo (that lives in the inner tick; deferred). duration is
|
|
151
|
+
an early-complete CAP (tick returns innerDone || elapsedMs >= duration), NOT a re-timing of the curve.
|
|
152
|
+
Throws TypeError if recipe has no tick function or scale is not a finite number >= 0.
|
|
153
|
+
RECIPE_META.motionSafe lets a picker prefer 'fade' (the only strict motion-safe reveal) under the query.
|
|
154
|
+
See decisions/0003-reduced-motion.md.
|
|
155
|
+
|
|
133
156
|
## Fixed in 1.0.0 (was broken pre-package)
|
|
134
157
|
|
|
135
158
|
- Reveals never completed (double-driven engine loop -> corrupted dt). Fixed: engine self-drives.
|
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.7.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",
|
package/src/ScratchController.js
CHANGED
|
@@ -44,6 +44,7 @@ import { SoaParticleEngine } from '@zakkster/lite-soa-particle-engine';
|
|
|
44
44
|
import { Random } from '@zakkster/lite-random';
|
|
45
45
|
import { createPixelScanner } from './PixelScan.js';
|
|
46
46
|
import { captureHostStyle, restoreHostStyle } from './HostStyle.js';
|
|
47
|
+
import { reducedMotionFallbackRecipe } from './ScratchRecipes.js';
|
|
47
48
|
|
|
48
49
|
// Shared engines currently driving a reveal. A shared engine has one render slot and one
|
|
49
50
|
// particle pool, so only one controller may reveal on it at a time; a second reveal is
|
|
@@ -62,6 +63,9 @@ const busySharedEngines = new WeakSet();
|
|
|
62
63
|
* @param {boolean} [options.driven=false] Host-driven: skip start(), call tick(dt)
|
|
63
64
|
* @param {SoaParticleEngine} [options.engine] Share a caller-supplied engine + lane pool
|
|
64
65
|
* @param {number} [options.dpr=1] Device-pixel-ratio for sharp high-DPI output
|
|
66
|
+
* @param {boolean} [options.reducedMotion=false] Honour prefers-reduced-motion: every reveal
|
|
67
|
+
* runs a calm count:0 opacity fade instead of
|
|
68
|
+
* the requested recipe (motion off, not wrapped)
|
|
65
69
|
*/
|
|
66
70
|
export function createScratchController(sourceCanvas, effectCanvas, {
|
|
67
71
|
maxParticles = 2000,
|
|
@@ -70,6 +74,7 @@ export function createScratchController(sourceCanvas, effectCanvas, {
|
|
|
70
74
|
driven = false,
|
|
71
75
|
engine: externalEngine = null,
|
|
72
76
|
dpr = 1,
|
|
77
|
+
reducedMotion = false,
|
|
73
78
|
} = {}) {
|
|
74
79
|
if (!(typeof dpr === 'number' && Number.isFinite(dpr) && dpr > 0)) {
|
|
75
80
|
throw new TypeError('createScratchController: dpr must be a positive finite number');
|
|
@@ -98,6 +103,11 @@ export function createScratchController(sourceCanvas, effectCanvas, {
|
|
|
98
103
|
const scanner = createPixelScanner(sourceCanvas);
|
|
99
104
|
const spot = { x: 0.5, y: 0.5 }; // the single shared, reused spawn point
|
|
100
105
|
|
|
106
|
+
// Reduced-motion blanket fallback: built ONCE here (cold) when the option is
|
|
107
|
+
// set, reused across every reveal so no per-reveal garbage. Stateless -- the
|
|
108
|
+
// controller tracks elapsed and the recipe only writes src.style.opacity.
|
|
109
|
+
const reducedFallback = reducedMotion ? reducedMotionFallbackRecipe() : null;
|
|
110
|
+
|
|
101
111
|
// The particle-view object handed to recipe.tick every frame. Built ONCE and
|
|
102
112
|
// reused: the engine's SoA arrays are stable references for the life of the engine,
|
|
103
113
|
// so a fresh `{ x, y, ... }` literal per frame would be a pure zero-GC violation on
|
|
@@ -167,6 +177,11 @@ export function createScratchController(sourceCanvas, effectCanvas, {
|
|
|
167
177
|
if (destroyed || activeRecipe) return;
|
|
168
178
|
if (!ownEngine && busySharedEngines.has(engine)) return;
|
|
169
179
|
|
|
180
|
+
// Reduced-motion blanket: honestly REPLACE the requested recipe with the
|
|
181
|
+
// calm count:0 opacity fade (never a wrap). Cold path, once per reveal;
|
|
182
|
+
// the count read + spawn loop below run against the swapped-in recipe.
|
|
183
|
+
if (reducedMotion) recipe = reducedFallback;
|
|
184
|
+
|
|
170
185
|
activeRecipe = recipe;
|
|
171
186
|
onComplete = onDone || null;
|
|
172
187
|
elapsed = 0;
|
package/src/ScratchRecipes.js
CHANGED
|
@@ -28,7 +28,7 @@ import {resolvePalette} from './Palette.js';
|
|
|
28
28
|
// Spawn particles from the scratch layer's still-covered pixels and run their own physics.
|
|
29
29
|
// ===========================================================================
|
|
30
30
|
|
|
31
|
-
export function BurnRecipe({count = 100, duration = 200, colors, theme} = {}) {
|
|
31
|
+
export function BurnRecipe({count = 100, duration = 200, glowBudget = Infinity, colors, theme} = {}) {
|
|
32
32
|
let pSize, pDecay, pColorIdx;
|
|
33
33
|
const palette = resolvePalette(colors, theme, ['#ff6b00', '#ff8c00', '#ffaa00', '#ff4500', '#8B0000']);
|
|
34
34
|
|
|
@@ -49,6 +49,7 @@ export function BurnRecipe({count = 100, duration = 200, colors, theme} = {}) {
|
|
|
49
49
|
const {x, y, vx, vy, life, data, max} = engine;
|
|
50
50
|
const ds = dt * 60;
|
|
51
51
|
let alive = 0;
|
|
52
|
+
let g = 0;
|
|
52
53
|
const progress = clamp(elapsed / duration, 0, 1);
|
|
53
54
|
src.style.opacity = 1 - easeIn(progress);
|
|
54
55
|
|
|
@@ -63,10 +64,13 @@ export function BurnRecipe({count = 100, duration = 200, colors, theme} = {}) {
|
|
|
63
64
|
if (y[i] < -50) continue;
|
|
64
65
|
ctx.globalCompositeOperation = 'lighter';
|
|
65
66
|
ctx.fillStyle = palette[pColorIdx[p]];
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
if (g < glowBudget) {
|
|
68
|
+
ctx.globalAlpha = life[i] * 0.3;
|
|
69
|
+
ctx.beginPath();
|
|
70
|
+
ctx.arc(x[i], y[i], pSize[p] * 2, 0, Math.PI * 2);
|
|
71
|
+
ctx.fill();
|
|
72
|
+
g++;
|
|
73
|
+
}
|
|
70
74
|
ctx.globalAlpha = life[i];
|
|
71
75
|
ctx.beginPath();
|
|
72
76
|
ctx.arc(x[i], y[i], pSize[p], 0, Math.PI * 2);
|
|
@@ -171,7 +175,7 @@ export function ExplodeRecipe({count = 80, duration = 750, force = 15, colors, t
|
|
|
171
175
|
}
|
|
172
176
|
|
|
173
177
|
|
|
174
|
-
export function DragonBreathRecipe({count = 200, duration = 1200, spread = 0.3, speedMin = 12, speedMax = 25, colors, theme} = {}) {
|
|
178
|
+
export function DragonBreathRecipe({count = 200, duration = 1200, spread = 0.3, speedMin = 12, speedMax = 25, glowBudget = Infinity, colors, theme} = {}) {
|
|
175
179
|
let pSize, pDecay, pColorIdx, r;
|
|
176
180
|
const palette = resolvePalette(colors, theme, ['#FFF', '#FFD700', '#FF4500', '#8B0000', '#2F2F2F']);
|
|
177
181
|
|
|
@@ -203,6 +207,7 @@ export function DragonBreathRecipe({count = 200, duration = 1200, spread = 0.3,
|
|
|
203
207
|
const dvy = Math.pow(0.98, ds);
|
|
204
208
|
const dsz = Math.pow(1.04, ds);
|
|
205
209
|
let alive = 0;
|
|
210
|
+
let g = 0;
|
|
206
211
|
const fadeT = clamp((elapsed - 100) / 400, 0, 1);
|
|
207
212
|
src.style.opacity = 1 - fadeT * fadeT;
|
|
208
213
|
|
|
@@ -220,10 +225,13 @@ export function DragonBreathRecipe({count = 200, duration = 1200, spread = 0.3,
|
|
|
220
225
|
if (y[i] < -50) continue;
|
|
221
226
|
ctx.globalCompositeOperation = 'lighter';
|
|
222
227
|
ctx.fillStyle = palette[pColorIdx[p]];
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
228
|
+
if (g < glowBudget) {
|
|
229
|
+
ctx.globalAlpha = life[i] * 0.3;
|
|
230
|
+
ctx.beginPath();
|
|
231
|
+
ctx.arc(x[i], y[i], pSize[p] * 2, 0, Math.PI * 2);
|
|
232
|
+
ctx.fill();
|
|
233
|
+
g++;
|
|
234
|
+
}
|
|
227
235
|
ctx.globalAlpha = life[i];
|
|
228
236
|
ctx.beginPath();
|
|
229
237
|
ctx.arc(x[i], y[i], pSize[p], 0, Math.PI * 2);
|
|
@@ -1238,6 +1246,88 @@ export function ImplosionRecipe({duration = 800} = {}) {
|
|
|
1238
1246
|
}
|
|
1239
1247
|
|
|
1240
1248
|
|
|
1249
|
+
// ===========================================================================
|
|
1250
|
+
// REDUCED MOTION
|
|
1251
|
+
// ===========================================================================
|
|
1252
|
+
|
|
1253
|
+
// Fixed fade duration for the reduced-motion blanket fallback (ms). A short,
|
|
1254
|
+
// calm pure-opacity dissolve -- honest "motion off", not a wrapped recipe.
|
|
1255
|
+
const REDUCED_MOTION_FADE_MS = 300;
|
|
1256
|
+
|
|
1257
|
+
/**
|
|
1258
|
+
* The calm fallback a controller/stage swaps in when created with
|
|
1259
|
+
* `{ reducedMotion: true }`. A count:0 recipe: zero particles spawn, and the
|
|
1260
|
+
* scratch layer fades its opacity 1 -> 0 over a short fixed duration. Shared by
|
|
1261
|
+
* ScratchController and ScratchStage so the calm body lives in exactly one place.
|
|
1262
|
+
* Satisfies the full recipe contract (init/spawn/tick/destroy).
|
|
1263
|
+
* @returns {Object} a fresh reduced-motion fallback recipe
|
|
1264
|
+
*/
|
|
1265
|
+
export function reducedMotionFallbackRecipe() {
|
|
1266
|
+
return {
|
|
1267
|
+
count: 0,
|
|
1268
|
+
init() {
|
|
1269
|
+
},
|
|
1270
|
+
// count:0 means the controller never calls spawn; kept for contract safety.
|
|
1271
|
+
spawn() {
|
|
1272
|
+
return {x: 0, y: 0, vx: 0, vy: 0, life: 0};
|
|
1273
|
+
},
|
|
1274
|
+
tick(dt, elapsedMs, engine, ctx, src) {
|
|
1275
|
+
src.style.opacity = 1 - clamp(elapsedMs / REDUCED_MOTION_FADE_MS, 0, 1);
|
|
1276
|
+
return elapsedMs >= REDUCED_MOTION_FADE_MS;
|
|
1277
|
+
},
|
|
1278
|
+
destroy() {
|
|
1279
|
+
},
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/**
|
|
1284
|
+
* A pure delegating wrapper that dials an existing recipe down for reduced
|
|
1285
|
+
* motion by reducing its particle COUNT (default a quarter) and, optionally,
|
|
1286
|
+
* capping the reveal early. It reuses the inner recipe's arrays and delegates
|
|
1287
|
+
* init/spawn/tick/destroy verbatim, so it adds ZERO hot-path allocation.
|
|
1288
|
+
*
|
|
1289
|
+
* Honest limits, on purpose:
|
|
1290
|
+
* - It reduces particle COUNT only. It CANNOT suppress the per-particle painted
|
|
1291
|
+
* halo/glow -- that lives inside the inner recipe's own tick. Suppressing it
|
|
1292
|
+
* needs a recipe-interface change and is deferred.
|
|
1293
|
+
* - `duration` is an early-complete CAP, not a re-timing. The inner curve's own
|
|
1294
|
+
* duration is baked into its closure and cannot be shortened from outside, so
|
|
1295
|
+
* tick returns `innerDone || (duration != null && elapsedMs >= duration)`.
|
|
1296
|
+
* The reveal ends sooner; the inner animation is not re-scaled onto it.
|
|
1297
|
+
*
|
|
1298
|
+
* @param {Object} recipe the inner recipe to dial down
|
|
1299
|
+
* @param {{ scale?: number, duration?: number }} [opts]
|
|
1300
|
+
* scale particle-count multiplier (default 0.25); finite, >= 0
|
|
1301
|
+
* duration optional early-complete cap in ms
|
|
1302
|
+
* @returns {Object} the wrapped recipe
|
|
1303
|
+
*/
|
|
1304
|
+
export function reducedMotionRecipe(recipe, {scale = 0.25, duration} = {}) {
|
|
1305
|
+
if (!recipe || typeof recipe !== 'object' || typeof recipe.tick !== 'function') {
|
|
1306
|
+
throw new TypeError('reducedMotionRecipe: recipe must be an object with a tick function');
|
|
1307
|
+
}
|
|
1308
|
+
if (!(typeof scale === 'number' && Number.isFinite(scale) && scale >= 0)) {
|
|
1309
|
+
throw new TypeError('reducedMotionRecipe: scale must be a finite number >= 0');
|
|
1310
|
+
}
|
|
1311
|
+
return {
|
|
1312
|
+
// Fewer particles. A count:0 inner recipe stays 0.
|
|
1313
|
+
count: Math.round((recipe.count || 0) * scale),
|
|
1314
|
+
init(ctx, capacity, w, h, rng) {
|
|
1315
|
+
return recipe.init(ctx, capacity, w, h, rng);
|
|
1316
|
+
},
|
|
1317
|
+
spawn(idx, rng, w, h, spot) {
|
|
1318
|
+
return recipe.spawn(idx, rng, w, h, spot);
|
|
1319
|
+
},
|
|
1320
|
+
tick(dt, elapsedMs, engine, ctx, src, w, h) {
|
|
1321
|
+
const innerDone = recipe.tick(dt, elapsedMs, engine, ctx, src, w, h);
|
|
1322
|
+
return innerDone || (duration != null && elapsedMs >= duration);
|
|
1323
|
+
},
|
|
1324
|
+
destroy() {
|
|
1325
|
+
if (recipe.destroy) recipe.destroy();
|
|
1326
|
+
},
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
|
|
1241
1331
|
// ===========================================================================
|
|
1242
1332
|
// RECIPE MAP
|
|
1243
1333
|
// ===========================================================================
|
package/src/ScratchStage.js
CHANGED
|
@@ -24,6 +24,7 @@ import { SoaParticleEngine } from '@zakkster/lite-soa-particle-engine';
|
|
|
24
24
|
import { Random } from '@zakkster/lite-random';
|
|
25
25
|
import { createPixelScanner } from './PixelScan.js';
|
|
26
26
|
import { captureHostStyle, restoreHostStyle } from './HostStyle.js';
|
|
27
|
+
import { reducedMotionFallbackRecipe } from './ScratchRecipes.js';
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* @param {Object} [options]
|
|
@@ -86,8 +87,10 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
|
|
|
86
87
|
* @param {number} [opts.seed] RNG seed; defaults to base seed + index.
|
|
87
88
|
* @param {number} [opts.scanPrecision=32] Pixel-scan resolution.
|
|
88
89
|
* @param {number} [opts.dpr] Per-controller device-pixel-ratio (default: stage dpr).
|
|
90
|
+
* @param {boolean} [opts.reducedMotion=false] Honour prefers-reduced-motion: every reveal runs
|
|
91
|
+
* a calm count:0 opacity fade instead of the recipe.
|
|
89
92
|
*/
|
|
90
|
-
function createController(sourceCanvas, effectCanvas, { capacity = 300, seed: cSeed, scanPrecision = 32, dpr = stageDpr } = {}) {
|
|
93
|
+
function createController(sourceCanvas, effectCanvas, { capacity = 300, seed: cSeed, scanPrecision = 32, dpr = stageDpr, reducedMotion = false } = {}) {
|
|
91
94
|
if (destroyed) throw new Error('createScratchStage: stage is destroyed');
|
|
92
95
|
if (!(Number.isInteger(capacity) && capacity >= 0)) {
|
|
93
96
|
throw new TypeError('createController: capacity must be a non-negative integer');
|
|
@@ -142,6 +145,10 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
|
|
|
142
145
|
};
|
|
143
146
|
const spot = { x: 0.5, y: 0.5 };
|
|
144
147
|
|
|
148
|
+
// Reduced-motion blanket fallback: built ONCE here (cold) when the option is
|
|
149
|
+
// set, reused across every reveal so no per-reveal garbage. Stateless.
|
|
150
|
+
const reducedFallback = reducedMotion ? reducedMotionFallbackRecipe() : null;
|
|
151
|
+
|
|
145
152
|
let activeRecipe = null;
|
|
146
153
|
let onComplete = null;
|
|
147
154
|
let elapsed = 0;
|
|
@@ -180,6 +187,10 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
|
|
|
180
187
|
/** Reveal a recipe. Ignored if this controller is already revealing or destroyed. */
|
|
181
188
|
reveal(recipe, onDone) {
|
|
182
189
|
if (ctlDestroyed || activeRecipe) return;
|
|
190
|
+
// Reduced-motion blanket: honestly REPLACE the requested recipe with the
|
|
191
|
+
// calm count:0 opacity fade (never a wrap). Cold path; the count read +
|
|
192
|
+
// spawn loop below run against the swapped-in recipe.
|
|
193
|
+
if (reducedMotion) recipe = reducedFallback;
|
|
183
194
|
activeRecipe = recipe;
|
|
184
195
|
onComplete = onDone || null;
|
|
185
196
|
elapsed = 0;
|