@zakkster/lite-scratch-fx 1.5.0 → 1.6.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 +30 -1
- package/index.d.ts +25 -0
- package/index.js +27 -23
- package/llms.txt +25 -5
- package/package.json +1 -1
- package/src/ScratchController.js +15 -0
- package/src/ScratchRecipes.js +85 -2
- package/src/ScratchStage.js +12 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,57 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.6.0] - 2026-08-23
|
|
4
|
+
|
|
5
|
+
Reduced-motion support (N-4). Backward compatible: the new controller/stage option
|
|
6
|
+
defaults to `false`, the helper and metadata field are additive, and existing callers
|
|
7
|
+
are byte-unaffected. The per-frame render path is unchanged.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **`reducedMotion` option on `createScratchController` and `stage.createController`
|
|
11
|
+
(default `false`).** When `true`, every `reveal(recipe)` ignores the passed recipe and
|
|
12
|
+
runs an internal `count: 0` opacity fade instead -- zero particles, no motion beyond the
|
|
13
|
+
fade, `onDone` still fires once on completion. The library does not read
|
|
14
|
+
`prefers-reduced-motion` itself; the host passes the result of its own media query. The
|
|
15
|
+
fallback recipe is built once per controller (cold path) and reused across reveals; it is
|
|
16
|
+
stateless, so it is re-entrant across successive reveals on the same controller.
|
|
17
|
+
- **`reducedMotionRecipe(recipe, { scale = 0.25, duration } = {})`.** A pure wrapper that
|
|
18
|
+
keeps a recipe's look but spawns `Math.round(recipe.count * scale)` particles (a quarter
|
|
19
|
+
by default). It delegates `init`/`spawn`/`tick`/`destroy` to the inner recipe and reuses
|
|
20
|
+
its arrays, so it adds no hot-path allocation. `duration`, if given, caps the reveal early
|
|
21
|
+
(`elapsedMs >= duration`); it does not re-time the inner easing curve. It reduces particle
|
|
22
|
+
count only and cannot suppress a recipe's own per-particle glow, which is drawn inside the
|
|
23
|
+
inner `tick`. Throws `TypeError` on a non-recipe argument or a non-finite/negative `scale`.
|
|
24
|
+
- **`RECIPE_META[*].motionSafe` (boolean).** Marks inherently calm built-ins so a picker can
|
|
25
|
+
prefer them under reduced motion. Strict: `true` for `fade` only (a pure opacity fade);
|
|
26
|
+
every recipe that moves geometry -- particles, the peel wipe, the implosion -- is `false`.
|
|
27
|
+
Threaded through `registerRecipe` with the same prev-fallback rule as the other flags.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
- **Torture gate extended.** Two tiers added to `test/torture.mjs`: T6 gates the blanket
|
|
31
|
+
`reducedMotion` reveal/tick/complete cycle and T7 gates a `reducedMotionRecipe`-wrapped
|
|
32
|
+
steady state, both under the existing 64 B/op ceiling (measured 41.6 and 6.9 B/op; their
|
|
33
|
+
broken-variant controls allocate ~1193 and ~1149 B/op). Test count 283 -> 322.
|
|
34
|
+
|
|
35
|
+
## [1.5.1] - 2026-08-23
|
|
36
|
+
|
|
37
|
+
Cover-fade fidelity patch for `iceBreath`. Backward compatible; no API change.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
- **`iceBreath` brightness ramp is now eased, matching the game's tween (B-21).** The
|
|
41
|
+
`brightness()` filter followed a linear `1 + fadeT` while the opacity one line above
|
|
42
|
+
followed the cubic `1 - fadeT^3`. GSAP applies a tween's ease to its numeric filter
|
|
43
|
+
target too, so both should share the cubic `power2.in` curve. The tick now computes
|
|
44
|
+
`ez = easeIn(fadeT)` once and drives opacity as `1 - ez` and brightness as `1 + ez`;
|
|
45
|
+
binding both writes to the same value removes the drift that caused the bug. Observable
|
|
46
|
+
change: at the fade midpoint brightness is `1.125`, not `1.5` (the cover no longer blows
|
|
47
|
+
out to near-white by mid-reveal). No allocation added; the torture gate is unchanged.
|
|
48
|
+
|
|
49
|
+
### Changed
|
|
50
|
+
- **Docs: `iceBreath`/`dragonBreath` `spread` is a jet half-angle (B-22).** The catalog
|
|
51
|
+
card now states `spread` is a half-angle in radians, matching `README.md` and `llms.txt`.
|
|
52
|
+
`IceBreathRecipe({ spread: FX_CONFIG.iceBreath.spread })` doubles the cone; pass
|
|
53
|
+
`spread / 2`. No code change.
|
|
54
|
+
|
|
3
55
|
## [1.5.0] - 2026-08-23
|
|
4
56
|
|
|
5
57
|
Cover-fade fidelity, live image reveals, and additive recipe/stage knobs. Backward
|
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)
|
|
@@ -270,11 +271,39 @@ current built-in look:
|
|
|
270
271
|
- **`PeelRecipe`** / **`ShineWaveRecipe`** -- `ease` (`(t: number) => number`): the easing
|
|
271
272
|
applied to progress; defaults to the recipe's built-in curve (`easeIn` / `easeInOut`).
|
|
272
273
|
|
|
274
|
+
### Reduced motion
|
|
275
|
+
|
|
276
|
+
Reveals run in lottery / instant-win UIs, so honouring `prefers-reduced-motion` matters.
|
|
277
|
+
The library does not read the media query itself -- the host owns it -- but it gives you
|
|
278
|
+
two ways to respond, plus a flag for building a picker.
|
|
279
|
+
|
|
280
|
+
- **Blanket switch: `{ reducedMotion: true }`** on `createScratchController` or
|
|
281
|
+
`stage.createController`. Every `reveal(recipe)` then ignores the recipe it is handed and
|
|
282
|
+
runs a calm `count: 0` opacity fade instead -- zero particles, no spray, `onDone` still
|
|
283
|
+
fires once on completion. This is the honest "motion off" path.
|
|
284
|
+
|
|
285
|
+
```js
|
|
286
|
+
const calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
287
|
+
const ctrl = createScratchController(cover, fx, { reducedMotion: calm });
|
|
288
|
+
ctrl.reveal(RECIPES.dragonBreath()); // draws a plain fade when calm is true
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
- **Toned-down variant: `reducedMotionRecipe(recipe, { scale = 0.25, duration? })`**. Wraps
|
|
292
|
+
any recipe and keeps its look but spawns `round(count * scale)` particles (a quarter, by
|
|
293
|
+
default); an optional `duration` caps the reveal early. It reuses the inner recipe's
|
|
294
|
+
arrays, so it adds no hot-path allocation. Note it can only reduce particle *count* -- it
|
|
295
|
+
cannot suppress a recipe's own per-particle glow, which is drawn inside the recipe's
|
|
296
|
+
`tick`. For a full "motion off", use the blanket switch above.
|
|
297
|
+
|
|
298
|
+
- **`RECIPE_META[*].motionSafe`** marks the inherently calm built-ins so a picker can prefer
|
|
299
|
+
them. It is strict: only `fade` (a pure opacity fade) is `motionSafe: true`; every recipe
|
|
300
|
+
that moves geometry -- particles, a peel wipe, an implosion -- is `false`.
|
|
301
|
+
|
|
273
302
|
### Constants
|
|
274
303
|
|
|
275
304
|
| Constant | Value | Meaning |
|
|
276
305
|
| ------------------------- | ----------------------------------------------------------- | ---------------------------------------------------- |
|
|
277
|
-
| `VERSION` | `'1.
|
|
306
|
+
| `VERSION` | `'1.6.0'` | Package version string (synced to package.json). |
|
|
278
307
|
| `maxParticles` (default) | `2000` | Controller / stage pool capacity. |
|
|
279
308
|
| `scanPrecision` (default) | `32` | Horizontal resolution of the spawn-point scan. |
|
|
280
309
|
| 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. */
|
|
@@ -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.6.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).
|
|
@@ -85,12 +86,14 @@ falls back to the recipe's default curve.
|
|
|
85
86
|
VERSION: the package version string (kept in sync with package.json and CHANGELOG).
|
|
86
87
|
RECIPES: an extensible null-prototype registry keyed by short name (burn, shatter, ...).
|
|
87
88
|
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
|
|
89
|
+
RECIPE_META: a live array of { id, name, category, themeable, needsUntaintedCanvas, motionSafe } for
|
|
90
|
+
every recipe -- the source for building pickers without hardcoding. category is
|
|
90
91
|
'particle'|'image'|'beam'|'css'. needsUntaintedCanvas is false for every built-in (image recipes
|
|
91
92
|
draw the layer live via drawImage(src), never reading it back); the flag is retained so a
|
|
92
93
|
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 }.
|
|
94
|
+
requirement to pickers. themeable is true for the 14 recipes that take { colors, theme }. motionSafe
|
|
95
|
+
is an inherently-calm reveal a picker can prefer under prefers-reduced-motion; strict = true for
|
|
96
|
+
'fade' ONLY (pure-opacity cross-fade), false for every other built-in (all animate or paint).
|
|
94
97
|
registerRecipe(id, factory, meta?): add a recipe or override a built-in; lands in RECIPES and
|
|
95
98
|
RECIPE_META immediately so existing pickers keep working. Mirrors lite-ambient's registerTheme.
|
|
96
99
|
Omitted meta fields fall back to the prior entry, then a de-camelCased name, category 'custom',
|
|
@@ -115,7 +118,7 @@ modes exist now:
|
|
|
115
118
|
## Concurrent reveals over one pool: createScratchStage
|
|
116
119
|
|
|
117
120
|
createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr = 1 })
|
|
118
|
-
-> { createController(src, fx, { capacity = 300, seed?, scanPrecision = 32, dpr
|
|
121
|
+
-> { createController(src, fx, { capacity = 300, seed?, scanPrecision = 32, dpr?, reducedMotion = false }) -> stageController,
|
|
119
122
|
tick(dt), destroy(), get remainingCapacity, remainingCapacityFor(capacity) }
|
|
120
123
|
stageController -> { reveal(recipe, onDone?), cancel(), seed(s), destroy() } // NO tick -- the stage drives.
|
|
121
124
|
|
|
@@ -130,6 +133,23 @@ later createController; cancel/completion hold the range (the controller stays r
|
|
|
130
133
|
Feasible with no engine change because raw tick(dt) is pure dispatch (no physics/culling) and the
|
|
131
134
|
lanes are public typed arrays recipes already write. See decisions/0001-concurrent-shared-reveals.md.
|
|
132
135
|
|
|
136
|
+
## Reduced motion
|
|
137
|
+
|
|
138
|
+
The host owns the media query; the library never touches matchMedia (DOM-query-free, SSR-safe).
|
|
139
|
+
Read prefers-reduced-motion and pass the boolean in. Two shapes:
|
|
140
|
+
Blanket switch: createScratchController(src, fx, { reducedMotion }) (and stage.createController).
|
|
141
|
+
When true, EVERY reveal honestly REPLACES the requested recipe with a calm count:0 opacity fade
|
|
142
|
+
(~300ms, motion off -- not a wrapped recipe): zero particles spawn, no halo painted. The calm
|
|
143
|
+
fallback is built once per controller (cold) -> no per-reveal garbage. Default false = full motion.
|
|
144
|
+
Helper: reducedMotionRecipe(recipe, { scale = 0.25, duration }) -> Recipe. A pure delegating wrapper:
|
|
145
|
+
count becomes round(inner.count * scale) (fewer particles; 0 stays 0), init/spawn/tick/destroy
|
|
146
|
+
delegate verbatim -> zero added hot-path alloc. Reduces particle COUNT only -- it CANNOT suppress
|
|
147
|
+
the inner recipe's per-particle painted halo (that lives in the inner tick; deferred). duration is
|
|
148
|
+
an early-complete CAP (tick returns innerDone || elapsedMs >= duration), NOT a re-timing of the curve.
|
|
149
|
+
Throws TypeError if recipe has no tick function or scale is not a finite number >= 0.
|
|
150
|
+
RECIPE_META.motionSafe lets a picker prefer 'fade' (the only strict motion-safe reveal) under the query.
|
|
151
|
+
See decisions/0003-reduced-motion.md.
|
|
152
|
+
|
|
133
153
|
## Fixed in 1.0.0 (was broken pre-package)
|
|
134
154
|
|
|
135
155
|
- 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.6.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
|
@@ -275,8 +275,9 @@ export function IceBreathRecipe({count = 450, duration = 1500, spread = 0.225, s
|
|
|
275
275
|
const dv = Math.pow(0.96, ds);
|
|
276
276
|
let alive = 0;
|
|
277
277
|
const fadeT = clamp((elapsed - 100) / 500, 0, 1);
|
|
278
|
-
|
|
279
|
-
src.style.
|
|
278
|
+
const ez = easeIn(fadeT);
|
|
279
|
+
src.style.opacity = 1 - ez;
|
|
280
|
+
src.style.filter = `brightness(${1 + ez})`;
|
|
280
281
|
|
|
281
282
|
for (let i = 0; i < max; i++) {
|
|
282
283
|
if (life[i] <= 0) continue;
|
|
@@ -1237,6 +1238,88 @@ export function ImplosionRecipe({duration = 800} = {}) {
|
|
|
1237
1238
|
}
|
|
1238
1239
|
|
|
1239
1240
|
|
|
1241
|
+
// ===========================================================================
|
|
1242
|
+
// REDUCED MOTION
|
|
1243
|
+
// ===========================================================================
|
|
1244
|
+
|
|
1245
|
+
// Fixed fade duration for the reduced-motion blanket fallback (ms). A short,
|
|
1246
|
+
// calm pure-opacity dissolve -- honest "motion off", not a wrapped recipe.
|
|
1247
|
+
const REDUCED_MOTION_FADE_MS = 300;
|
|
1248
|
+
|
|
1249
|
+
/**
|
|
1250
|
+
* The calm fallback a controller/stage swaps in when created with
|
|
1251
|
+
* `{ reducedMotion: true }`. A count:0 recipe: zero particles spawn, and the
|
|
1252
|
+
* scratch layer fades its opacity 1 -> 0 over a short fixed duration. Shared by
|
|
1253
|
+
* ScratchController and ScratchStage so the calm body lives in exactly one place.
|
|
1254
|
+
* Satisfies the full recipe contract (init/spawn/tick/destroy).
|
|
1255
|
+
* @returns {Object} a fresh reduced-motion fallback recipe
|
|
1256
|
+
*/
|
|
1257
|
+
export function reducedMotionFallbackRecipe() {
|
|
1258
|
+
return {
|
|
1259
|
+
count: 0,
|
|
1260
|
+
init() {
|
|
1261
|
+
},
|
|
1262
|
+
// count:0 means the controller never calls spawn; kept for contract safety.
|
|
1263
|
+
spawn() {
|
|
1264
|
+
return {x: 0, y: 0, vx: 0, vy: 0, life: 0};
|
|
1265
|
+
},
|
|
1266
|
+
tick(dt, elapsedMs, engine, ctx, src) {
|
|
1267
|
+
src.style.opacity = 1 - clamp(elapsedMs / REDUCED_MOTION_FADE_MS, 0, 1);
|
|
1268
|
+
return elapsedMs >= REDUCED_MOTION_FADE_MS;
|
|
1269
|
+
},
|
|
1270
|
+
destroy() {
|
|
1271
|
+
},
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* A pure delegating wrapper that dials an existing recipe down for reduced
|
|
1277
|
+
* motion by reducing its particle COUNT (default a quarter) and, optionally,
|
|
1278
|
+
* capping the reveal early. It reuses the inner recipe's arrays and delegates
|
|
1279
|
+
* init/spawn/tick/destroy verbatim, so it adds ZERO hot-path allocation.
|
|
1280
|
+
*
|
|
1281
|
+
* Honest limits, on purpose:
|
|
1282
|
+
* - It reduces particle COUNT only. It CANNOT suppress the per-particle painted
|
|
1283
|
+
* halo/glow -- that lives inside the inner recipe's own tick. Suppressing it
|
|
1284
|
+
* needs a recipe-interface change and is deferred.
|
|
1285
|
+
* - `duration` is an early-complete CAP, not a re-timing. The inner curve's own
|
|
1286
|
+
* duration is baked into its closure and cannot be shortened from outside, so
|
|
1287
|
+
* tick returns `innerDone || (duration != null && elapsedMs >= duration)`.
|
|
1288
|
+
* The reveal ends sooner; the inner animation is not re-scaled onto it.
|
|
1289
|
+
*
|
|
1290
|
+
* @param {Object} recipe the inner recipe to dial down
|
|
1291
|
+
* @param {{ scale?: number, duration?: number }} [opts]
|
|
1292
|
+
* scale particle-count multiplier (default 0.25); finite, >= 0
|
|
1293
|
+
* duration optional early-complete cap in ms
|
|
1294
|
+
* @returns {Object} the wrapped recipe
|
|
1295
|
+
*/
|
|
1296
|
+
export function reducedMotionRecipe(recipe, {scale = 0.25, duration} = {}) {
|
|
1297
|
+
if (!recipe || typeof recipe !== 'object' || typeof recipe.tick !== 'function') {
|
|
1298
|
+
throw new TypeError('reducedMotionRecipe: recipe must be an object with a tick function');
|
|
1299
|
+
}
|
|
1300
|
+
if (!(typeof scale === 'number' && Number.isFinite(scale) && scale >= 0)) {
|
|
1301
|
+
throw new TypeError('reducedMotionRecipe: scale must be a finite number >= 0');
|
|
1302
|
+
}
|
|
1303
|
+
return {
|
|
1304
|
+
// Fewer particles. A count:0 inner recipe stays 0.
|
|
1305
|
+
count: Math.round((recipe.count || 0) * scale),
|
|
1306
|
+
init(ctx, capacity, w, h, rng) {
|
|
1307
|
+
return recipe.init(ctx, capacity, w, h, rng);
|
|
1308
|
+
},
|
|
1309
|
+
spawn(idx, rng, w, h, spot) {
|
|
1310
|
+
return recipe.spawn(idx, rng, w, h, spot);
|
|
1311
|
+
},
|
|
1312
|
+
tick(dt, elapsedMs, engine, ctx, src, w, h) {
|
|
1313
|
+
const innerDone = recipe.tick(dt, elapsedMs, engine, ctx, src, w, h);
|
|
1314
|
+
return innerDone || (duration != null && elapsedMs >= duration);
|
|
1315
|
+
},
|
|
1316
|
+
destroy() {
|
|
1317
|
+
if (recipe.destroy) recipe.destroy();
|
|
1318
|
+
},
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
|
|
1240
1323
|
// ===========================================================================
|
|
1241
1324
|
// RECIPE MAP
|
|
1242
1325
|
// ===========================================================================
|
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;
|