@zakkster/lite-scratch-fx 1.4.0 → 1.5.1
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 +68 -2
- package/README.md +26 -7
- package/index.d.ts +7 -5
- package/index.js +9 -5
- package/llms.txt +13 -4
- package/package.json +1 -1
- package/src/ScratchRecipes.js +35 -59
- package/src/ScratchStage.js +20 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,71 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.5.1] - 2026-08-23
|
|
4
|
+
|
|
5
|
+
Cover-fade fidelity patch for `iceBreath`. Backward compatible; no API change.
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **`iceBreath` brightness ramp is now eased, matching the game's tween (B-21).** The
|
|
9
|
+
`brightness()` filter followed a linear `1 + fadeT` while the opacity one line above
|
|
10
|
+
followed the cubic `1 - fadeT^3`. GSAP applies a tween's ease to its numeric filter
|
|
11
|
+
target too, so both should share the cubic `power2.in` curve. The tick now computes
|
|
12
|
+
`ez = easeIn(fadeT)` once and drives opacity as `1 - ez` and brightness as `1 + ez`;
|
|
13
|
+
binding both writes to the same value removes the drift that caused the bug. Observable
|
|
14
|
+
change: at the fade midpoint brightness is `1.125`, not `1.5` (the cover no longer blows
|
|
15
|
+
out to near-white by mid-reveal). No allocation added; the torture gate is unchanged.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- **Docs: `iceBreath`/`dragonBreath` `spread` is a jet half-angle (B-22).** The catalog
|
|
19
|
+
card now states `spread` is a half-angle in radians, matching `README.md` and `llms.txt`.
|
|
20
|
+
`IceBreathRecipe({ spread: FX_CONFIG.iceBreath.spread })` doubles the cone; pass
|
|
21
|
+
`spread / 2`. No code change.
|
|
22
|
+
|
|
23
|
+
## [1.5.0] - 2026-08-23
|
|
24
|
+
|
|
25
|
+
Cover-fade fidelity, live image reveals, and additive recipe/stage knobs. Backward
|
|
26
|
+
compatible: every new recipe option and the new stage method is additive, and calling
|
|
27
|
+
with no options reproduces prior output byte-identically.
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
- **`stage.remainingCapacityFor(capacity)` (B-19).** Returns the exact particle-slots
|
|
31
|
+
allocatable for a request of that capacity: `(maxParticles - nextStart) + n * capacity`,
|
|
32
|
+
where `n` is the count of reclaimed ranges of that exact capacity. A size-`C`
|
|
33
|
+
`createController` succeeds iff `remainingCapacityFor(C) >= C`. Zero-allocation; throws
|
|
34
|
+
`TypeError` on a non-integer or negative argument. `remainingCapacity` is unchanged (total
|
|
35
|
+
free slots); its JSDoc now states reclaimed ranges are reusable only on an exact-capacity
|
|
36
|
+
match, so `remainingCapacityFor` is the number to consult for an allocation decision.
|
|
37
|
+
- **Additive recipe factory options.** `dissolve` gains `fadeSpeed` (cover-fade window as a
|
|
38
|
+
fraction of `duration`, default `0.3`); `dragonBreath` and `iceBreath` gain `spread`,
|
|
39
|
+
`speedMin`, `speedMax` (spawn cone half-angle in radians and speed range; defaults `0.3`/
|
|
40
|
+
`12`/`25` and `0.225`/`15`/`28`); `peel` and `shineWave` gain `ease` (a `t -> t` easing
|
|
41
|
+
function, default `easeIn`/`easeInOut`). A non-function `ease` falls back to the default.
|
|
42
|
+
Every default equals the previous literal, so existing callers are unaffected, and each
|
|
43
|
+
numeric knob is a single `rng` draw in the same position (determinism preserved).
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
- **Image recipes draw the scratch layer live (R5).** `shatter`, `pixelShatter`, and
|
|
47
|
+
`glitchReveal` now draw the source canvas each frame with `drawImage(src, ...)` instead of
|
|
48
|
+
snapshotting it once via `toDataURL()` + `Image`. This removes the synchronous PNG encode,
|
|
49
|
+
the async decode, and the blank first frames: they render on frame 1 and work on any
|
|
50
|
+
canvas, including cross-origin/tainted layers. `RECIPE_META[*].needsUntaintedCanvas` is now
|
|
51
|
+
`false` for every built-in; the field is retained so a third-party recipe that reads the
|
|
52
|
+
canvas back (`getImageData`/`toDataURL`) can still advertise a same-origin requirement.
|
|
53
|
+
- **`iceBreath` and `dragonBreath` cover fades match the consuming renderer.** `iceBreath`
|
|
54
|
+
fades with `power2.in` (cubic, `1 - easeIn`) over 0.5 s after a 0.1 s delay; `dragonBreath`
|
|
55
|
+
fades with `power1.in` (quadratic) over 0.4 s after a 0.1 s delay. Both were previously
|
|
56
|
+
linear with no delay.
|
|
57
|
+
|
|
58
|
+
### Fixed
|
|
59
|
+
- **Burn cover fade restored to cubic (B-18).** 1.4.0 shipped a quadratic fade (`1 - t*t`).
|
|
60
|
+
The consuming renderer tweens the burn cover with `power2.in`, which is cubic in GSAP's
|
|
61
|
+
naming (`Power2 = Cubic`; verified `parseEase('power2.in')(0.5) === 0.125`), so the correct
|
|
62
|
+
curve is `1 - easeIn(progress)` (`easeIn = t*t*t`). Reverted to cubic: at `progress` 0.5 the
|
|
63
|
+
cover opacity is `0.875`, not `0.75`.
|
|
64
|
+
|
|
65
|
+
### Removed
|
|
66
|
+
- The `toDataURL()` snapshot and `Image` decode from `shatter`, `pixelShatter`, and
|
|
67
|
+
`glitchReveal` (replaced by live `drawImage(src, ...)`).
|
|
68
|
+
|
|
3
69
|
## [1.4.0] - 2026-08-23
|
|
4
70
|
|
|
5
71
|
Determinism, recipe tuning, and stage lane reclaim. One backward-compatible interface
|
|
@@ -22,8 +88,8 @@ addition -- a 5th `rng` argument to `recipe.init`; recipes that ignore it are un
|
|
|
22
88
|
### Changed
|
|
23
89
|
- **`burn` retuned to a 0.2 s flash (B-15).** Defaults `count` 150 -> 100, `duration` 1500 -> 200
|
|
24
90
|
ms; spawn `size` 2-5 -> 3-8, `decay` 0.01-0.03 -> 0.02-0.05, upward `vy` -1..-4 -> -7..-2. The
|
|
25
|
-
additive gravity term `vy += 0.1 * ds` is removed and the cover fade is
|
|
26
|
-
instead of the cubic `easeIn`.
|
|
91
|
+
additive gravity term `vy += 0.1 * ds` is removed and the cover fade is quadratic (`1 - t*t`)
|
|
92
|
+
instead of the cubic `easeIn`. (The quadratic fade was a regression -- see 1.5.0 B-18.)
|
|
27
93
|
- **`iceBreath` default `count` 300 -> 450 (B-16).**
|
|
28
94
|
|
|
29
95
|
### Removed
|
package/README.md
CHANGED
|
@@ -234,6 +234,8 @@ stage.createController(sourceCanvas, effectCanvas, options?): StageController
|
|
|
234
234
|
stage.tick(dt): void // advance every active reveal one frame; dt in SECONDS
|
|
235
235
|
stage.destroy(): void // stop everything and release the shared engine
|
|
236
236
|
stage.remainingCapacity // slots still unreserved in the shared pool (read-only)
|
|
237
|
+
stage.remainingCapacityFor(capacity) // exact slots allocatable for a request of this size:
|
|
238
|
+
// a size-C createController succeeds iff this returns >= C
|
|
237
239
|
```
|
|
238
240
|
|
|
239
241
|
`createController` options: `capacity` (slots reserved out of the pool, default `300` --
|
|
@@ -257,11 +259,22 @@ resolvePalette(colors, theme, fallback): string[]
|
|
|
257
259
|
- **`resolvePalette(colors, theme, fallback)`** -- resolve a colour ramp for a themeable
|
|
258
260
|
recipe: `colors` wins, then `theme`, then the recipe's own `fallback`.
|
|
259
261
|
|
|
262
|
+
Several recipes take extra tuning knobs alongside `count`/`duration`; all default to the
|
|
263
|
+
current built-in look:
|
|
264
|
+
|
|
265
|
+
- **`DissolveRecipe`** -- `fadeSpeed` (default `0.3`): fraction of `duration` over which the
|
|
266
|
+
source layer fades out.
|
|
267
|
+
- **`DragonBreathRecipe`** / **`IceBreathRecipe`** -- `spread` (jet half-angle in radians,
|
|
268
|
+
defaults `0.3` / `0.225`), `speedMin`/`speedMax` (spawn speed range, defaults `12`-`25`
|
|
269
|
+
/ `15`-`28`).
|
|
270
|
+
- **`PeelRecipe`** / **`ShineWaveRecipe`** -- `ease` (`(t: number) => number`): the easing
|
|
271
|
+
applied to progress; defaults to the recipe's built-in curve (`easeIn` / `easeInOut`).
|
|
272
|
+
|
|
260
273
|
### Constants
|
|
261
274
|
|
|
262
275
|
| Constant | Value | Meaning |
|
|
263
276
|
| ------------------------- | ----------------------------------------------------------- | ---------------------------------------------------- |
|
|
264
|
-
| `VERSION` | `'1.
|
|
277
|
+
| `VERSION` | `'1.5.1'` | Package version string (synced to package.json). |
|
|
265
278
|
| `maxParticles` (default) | `2000` | Controller / stage pool capacity. |
|
|
266
279
|
| `scanPrecision` (default) | `32` | Horizontal resolution of the spawn-point scan. |
|
|
267
280
|
| stage `capacity` (default)| `300` | Slots a stage controller reserves from the pool. |
|
|
@@ -271,9 +284,11 @@ resolvePalette(colors, theme, fallback): string[]
|
|
|
271
284
|
|
|
272
285
|
`RECIPES` is an extensible null-prototype registry keyed by short name;
|
|
273
286
|
`RECIPE_NAMES` is its keys at load time; `RECIPE_META` is a live array
|
|
274
|
-
(`registerRecipe` keeps it in sync).
|
|
275
|
-
|
|
276
|
-
|
|
287
|
+
(`registerRecipe` keeps it in sync). Image recipes draw the source canvas live each
|
|
288
|
+
frame via `drawImage(src)`, so they work on cross-origin/tainted layers;
|
|
289
|
+
`needsUntaintedCanvas` is retained in `RECIPE_META` (all built-ins `false`) for a
|
|
290
|
+
third-party recipe that reads the canvas back (`getImageData`/`toDataURL`) and needs to
|
|
291
|
+
advertise a same-origin requirement to pickers.
|
|
277
292
|
|
|
278
293
|
---
|
|
279
294
|
|
|
@@ -328,6 +343,8 @@ The erase is plain canvas `destination-out` -- this library never touches your i
|
|
|
328
343
|
stage holds **one** lane pool for the whole grid, hands each card a fixed 200-slot
|
|
329
344
|
sub-range, and the single `stage.tick(dt)` fans out to every card that is mid-reveal. Add
|
|
330
345
|
a recipe with `registerRecipe` and the `RECIPE_META` picker above serves it with no edit.
|
|
346
|
+
A recipe instance holds per-reveal state, so build a fresh one per reveal (as `RECIPES[pick()]()`
|
|
347
|
+
does above) rather than sharing a single instance across cards or reveals.
|
|
331
348
|
|
|
332
349
|
---
|
|
333
350
|
|
|
@@ -387,9 +404,11 @@ gate rejects its deliberately-broken variant.
|
|
|
387
404
|
`{ colors, theme }`; `colors` wins, `theme` maps a `{ light, mid, dark }` triple onto the
|
|
388
405
|
ramp, and omitting both keeps the recipe's own default. A skin threads one triple through
|
|
389
406
|
the whole grid.
|
|
390
|
-
- **Image recipes
|
|
391
|
-
`glitchReveal`
|
|
392
|
-
|
|
407
|
+
- **Image recipes draw the source canvas live.** `shatter`, `pixelShatter`, and
|
|
408
|
+
`glitchReveal` draw the scratch layer each frame via `drawImage(src)` -- no snapshot --
|
|
409
|
+
so they work on cross-origin/tainted layers. `needsUntaintedCanvas` is retained in
|
|
410
|
+
`RECIPE_META` (all built-ins `false`) for a third-party recipe that reads the canvas back
|
|
411
|
+
(`getImageData`/`toDataURL`) and must advertise a same-origin requirement to a picker.
|
|
393
412
|
|
|
394
413
|
---
|
|
395
414
|
|
package/index.d.ts
CHANGED
|
@@ -208,6 +208,8 @@ export interface ScratchStage {
|
|
|
208
208
|
destroy(): void;
|
|
209
209
|
/** Slots still unreserved in the shared pool. */
|
|
210
210
|
readonly remainingCapacity: number;
|
|
211
|
+
/** Exact particle-slots allocatable for a request of this capacity; a size-C createController succeeds iff this returns >= C. */
|
|
212
|
+
remainingCapacityFor(capacity: number): number;
|
|
211
213
|
}
|
|
212
214
|
|
|
213
215
|
/** Create a stage for concurrent reveals over one shared particle pool. */
|
|
@@ -231,14 +233,14 @@ export interface ThemeableRecipeOptions extends RecipeOptions {
|
|
|
231
233
|
|
|
232
234
|
export function BurnRecipe(opts?: ThemeableRecipeOptions): Recipe;
|
|
233
235
|
export function ShatterRecipe(opts?: RecipeOptions & { gravity?: number }): Recipe;
|
|
234
|
-
export function DissolveRecipe(opts?: ThemeableRecipeOptions): Recipe;
|
|
236
|
+
export function DissolveRecipe(opts?: ThemeableRecipeOptions & { fadeSpeed?: number }): Recipe;
|
|
235
237
|
export function ExplodeRecipe(opts?: ThemeableRecipeOptions & { force?: number }): Recipe;
|
|
236
|
-
export function DragonBreathRecipe(opts?: ThemeableRecipeOptions): Recipe;
|
|
237
|
-
export function IceBreathRecipe(opts?: ThemeableRecipeOptions): Recipe;
|
|
238
|
-
export function ShineWaveRecipe(opts?: ThemeableRecipeOptions & { beamWidth?: number; particleCount?: number }): Recipe;
|
|
238
|
+
export function DragonBreathRecipe(opts?: ThemeableRecipeOptions & { spread?: number; speedMin?: number; speedMax?: number }): Recipe;
|
|
239
|
+
export function IceBreathRecipe(opts?: ThemeableRecipeOptions & { spread?: number; speedMin?: number; speedMax?: number }): Recipe;
|
|
240
|
+
export function ShineWaveRecipe(opts?: ThemeableRecipeOptions & { beamWidth?: number; particleCount?: number; ease?: (t: number) => number }): Recipe;
|
|
239
241
|
export function LightningCrawlRecipe(opts?: ThemeableRecipeOptions & { boltCount?: number }): Recipe;
|
|
240
242
|
export function ShineRecipe(opts?: RecipeOptions & { width?: number }): Recipe;
|
|
241
|
-
export function PeelRecipe(opts?: RecipeOptions): Recipe;
|
|
243
|
+
export function PeelRecipe(opts?: RecipeOptions & { ease?: (t: number) => number }): Recipe;
|
|
242
244
|
export function FadeRecipe(opts?: RecipeOptions): Recipe;
|
|
243
245
|
export function ImplosionRecipe(opts?: RecipeOptions): Recipe;
|
|
244
246
|
|
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.5.1';
|
|
17
17
|
|
|
18
18
|
export { createScratchController } from './src/ScratchController.js';
|
|
19
19
|
export { default as ScratchController } from './src/ScratchController.js';
|
|
@@ -71,7 +71,11 @@ export const RECIPES = Object.assign(Object.create(null), {
|
|
|
71
71
|
*
|
|
72
72
|
* category 'particle' | 'image' | 'beam' | 'css' | (custom)
|
|
73
73
|
* themeable accepts { colors, theme }
|
|
74
|
-
* needsUntaintedCanvas
|
|
74
|
+
* needsUntaintedCanvas all built-in recipes are false: image recipes draw the
|
|
75
|
+
* scratch layer live via drawImage(src), never reading it
|
|
76
|
+
* back. The flag is retained so a third-party recipe that
|
|
77
|
+
* does read the canvas (getImageData/toDataURL) can
|
|
78
|
+
* advertise a same-origin requirement to pickers.
|
|
75
79
|
*/
|
|
76
80
|
export const RECIPE_META = [
|
|
77
81
|
{ id: 'burn', name: 'Burn', category: 'particle', themeable: true, needsUntaintedCanvas: false },
|
|
@@ -84,9 +88,9 @@ export const RECIPE_META = [
|
|
|
84
88
|
{ id: 'cosmicDust', name: 'Cosmic Dust', category: 'particle', themeable: true, needsUntaintedCanvas: false },
|
|
85
89
|
{ id: 'matrixDecay', name: 'Matrix Decay', category: 'particle', themeable: true, needsUntaintedCanvas: false },
|
|
86
90
|
{ id: 'liquidMelt', name: 'Liquid Melt', category: 'particle', themeable: true, needsUntaintedCanvas: false },
|
|
87
|
-
{ id: 'shatter', name: 'Shatter', category: 'image', themeable: false, needsUntaintedCanvas:
|
|
88
|
-
{ id: 'pixelShatter', name: 'Pixel Shatter', category: 'image', themeable: false, needsUntaintedCanvas:
|
|
89
|
-
{ id: 'glitchReveal', name: 'Glitch Reveal', category: 'image', themeable: false, needsUntaintedCanvas:
|
|
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 },
|
|
90
94
|
{ id: 'shine', name: 'Shine', category: 'beam', themeable: false, needsUntaintedCanvas: false },
|
|
91
95
|
{ id: 'shineWave', name: 'Shine Wave', category: 'beam', themeable: true, needsUntaintedCanvas: false },
|
|
92
96
|
{ id: 'laserScan', name: 'Laser Scan', category: 'beam', themeable: true, needsUntaintedCanvas: false },
|
package/llms.txt
CHANGED
|
@@ -69,10 +69,17 @@ locks this because it couples recipes to engine internals (_head).
|
|
|
69
69
|
All 21 live in src/ScratchRecipes.js, grouped by family with section banners:
|
|
70
70
|
particle (spawn from covered pixels, own physics): burn, dissolve, explode, dragonBreath,
|
|
71
71
|
iceBreath, goldDust, confettiBlast, cosmicDust, matrixDecay, liquidMelt
|
|
72
|
-
image (
|
|
72
|
+
image (draw the layer live each frame via drawImage(src); works on any canvas, incl. cross-origin): shatter, pixelShatter, glitchReveal
|
|
73
73
|
beam/canvas (draw light/lines on the overlay): shine, shineWave, laserScan, lightningCrawl, neonPulse
|
|
74
74
|
css (drive the layer's own transform/opacity): peel, fade, implosion
|
|
75
75
|
|
|
76
|
+
Every factory takes an options bag; all options default to the shipped tuning, so calling with
|
|
77
|
+
no args reproduces the stock effect. Beyond { colors, theme } (the 14 themeable recipes) and
|
|
78
|
+
count/duration, the FX_CONFIG-style knobs are: dissolve fadeSpeed (fade window as a fraction of
|
|
79
|
+
duration); dragonBreath and iceBreath spread / speedMin / speedMax (spawn cone half-angle in
|
|
80
|
+
radians and speed range); peel and shineWave ease (a t -> t easing function). A non-function ease
|
|
81
|
+
falls back to the recipe's default curve.
|
|
82
|
+
|
|
76
83
|
## Registry, metadata, extension
|
|
77
84
|
|
|
78
85
|
VERSION: the package version string (kept in sync with package.json and CHANGELOG).
|
|
@@ -80,8 +87,10 @@ RECIPES: an extensible null-prototype registry keyed by short name (burn, shatte
|
|
|
80
87
|
RECIPE_NAMES: the built-in keys at load time.
|
|
81
88
|
RECIPE_META: a live array of { id, name, category, themeable, needsUntaintedCanvas } for every
|
|
82
89
|
recipe -- the source for building pickers without hardcoding. category is
|
|
83
|
-
'particle'|'image'|'beam'|'css'. needsUntaintedCanvas is
|
|
84
|
-
|
|
90
|
+
'particle'|'image'|'beam'|'css'. needsUntaintedCanvas is false for every built-in (image recipes
|
|
91
|
+
draw the layer live via drawImage(src), never reading it back); the flag is retained so a
|
|
92
|
+
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 }.
|
|
85
94
|
registerRecipe(id, factory, meta?): add a recipe or override a built-in; lands in RECIPES and
|
|
86
95
|
RECIPE_META immediately so existing pickers keep working. Mirrors lite-ambient's registerTheme.
|
|
87
96
|
Omitted meta fields fall back to the prior entry, then a de-camelCased name, category 'custom',
|
|
@@ -107,7 +116,7 @@ modes exist now:
|
|
|
107
116
|
|
|
108
117
|
createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr = 1 })
|
|
109
118
|
-> { createController(src, fx, { capacity = 300, seed?, scanPrecision = 32, dpr? }) -> stageController,
|
|
110
|
-
tick(dt), destroy(), get remainingCapacity }
|
|
119
|
+
tick(dt), destroy(), get remainingCapacity, remainingCapacityFor(capacity) }
|
|
111
120
|
stageController -> { reveal(recipe, onDone?), cancel(), seed(s), destroy() } // NO tick -- the stage drives.
|
|
112
121
|
|
|
113
122
|
When { engine } (one reveal at a time) is not enough and you need MANY boxes revealing at once
|
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.5.1",
|
|
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/ScratchRecipes.js
CHANGED
|
@@ -50,7 +50,7 @@ export function BurnRecipe({count = 100, duration = 200, colors, theme} = {}) {
|
|
|
50
50
|
const ds = dt * 60;
|
|
51
51
|
let alive = 0;
|
|
52
52
|
const progress = clamp(elapsed / duration, 0, 1);
|
|
53
|
-
src.style.opacity = 1 - progress
|
|
53
|
+
src.style.opacity = 1 - easeIn(progress);
|
|
54
54
|
|
|
55
55
|
for (let i = 0; i < max; i++) {
|
|
56
56
|
if (life[i] <= 0) continue;
|
|
@@ -83,7 +83,7 @@ export function BurnRecipe({count = 100, duration = 200, colors, theme} = {}) {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
|
|
86
|
-
export function DissolveRecipe({count = 150, duration = 1000, colors, theme} = {}) {
|
|
86
|
+
export function DissolveRecipe({count = 150, duration = 1000, fadeSpeed = 0.3, colors, theme} = {}) {
|
|
87
87
|
let pSize;
|
|
88
88
|
const palette = resolvePalette(colors, theme, ['#DC143C']);
|
|
89
89
|
|
|
@@ -99,7 +99,7 @@ export function DissolveRecipe({count = 150, duration = 1000, colors, theme} = {
|
|
|
99
99
|
tick(dt, elapsed, engine, ctx, src, w, h) {
|
|
100
100
|
const {x, y, vy, life, data, max} = engine;
|
|
101
101
|
let alive = 0;
|
|
102
|
-
src.style.opacity = 1 - clamp(elapsed / (duration *
|
|
102
|
+
src.style.opacity = 1 - clamp(elapsed / (duration * fadeSpeed), 0, 1);
|
|
103
103
|
|
|
104
104
|
for (let i = 0; i < max; i++) {
|
|
105
105
|
if (life[i] <= 0) continue;
|
|
@@ -171,7 +171,7 @@ export function ExplodeRecipe({count = 80, duration = 750, force = 15, colors, t
|
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
|
|
174
|
-
export function DragonBreathRecipe({count = 200, duration = 1200, colors, theme} = {}) {
|
|
174
|
+
export function DragonBreathRecipe({count = 200, duration = 1200, spread = 0.3, speedMin = 12, speedMax = 25, colors, theme} = {}) {
|
|
175
175
|
let pSize, pDecay, pColorIdx, r;
|
|
176
176
|
const palette = resolvePalette(colors, theme, ['#FFF', '#FFD700', '#FF4500', '#8B0000', '#2F2F2F']);
|
|
177
177
|
|
|
@@ -184,8 +184,8 @@ export function DragonBreathRecipe({count = 200, duration = 1200, colors, theme}
|
|
|
184
184
|
pColorIdx = new Uint8Array(capacity);
|
|
185
185
|
},
|
|
186
186
|
spawn(idx, rng, w, h, spot) {
|
|
187
|
-
const angle = -Math.PI / 2 + rng.range(-
|
|
188
|
-
const speed = rng.range(
|
|
187
|
+
const angle = -Math.PI / 2 + rng.range(-spread, spread);
|
|
188
|
+
const speed = rng.range(speedMin, speedMax);
|
|
189
189
|
pSize[idx] = rng.range(4, 12);
|
|
190
190
|
pDecay[idx] = rng.range(0.01, 0.025);
|
|
191
191
|
pColorIdx[idx] = rng.int(0, palette.length - 1);
|
|
@@ -203,7 +203,8 @@ export function DragonBreathRecipe({count = 200, duration = 1200, colors, theme}
|
|
|
203
203
|
const dvy = Math.pow(0.98, ds);
|
|
204
204
|
const dsz = Math.pow(1.04, ds);
|
|
205
205
|
let alive = 0;
|
|
206
|
-
|
|
206
|
+
const fadeT = clamp((elapsed - 100) / 400, 0, 1);
|
|
207
|
+
src.style.opacity = 1 - fadeT * fadeT;
|
|
207
208
|
|
|
208
209
|
for (let i = 0; i < max; i++) {
|
|
209
210
|
if (life[i] <= 0) continue;
|
|
@@ -239,7 +240,7 @@ export function DragonBreathRecipe({count = 200, duration = 1200, colors, theme}
|
|
|
239
240
|
}
|
|
240
241
|
|
|
241
242
|
|
|
242
|
-
export function IceBreathRecipe({count = 450, duration = 1500, colors, theme} = {}) {
|
|
243
|
+
export function IceBreathRecipe({count = 450, duration = 1500, spread = 0.225, speedMin = 15, speedMax = 28, colors, theme} = {}) {
|
|
243
244
|
let pSize, pDecay, pRot, pRotSpd, pColorIdx;
|
|
244
245
|
const palette = resolvePalette(colors, theme, ['#FFF', '#E0FFFF', '#00FFFF', '#1E90FF', '#4682B4']);
|
|
245
246
|
|
|
@@ -253,8 +254,8 @@ export function IceBreathRecipe({count = 450, duration = 1500, colors, theme} =
|
|
|
253
254
|
pColorIdx = new Uint8Array(capacity);
|
|
254
255
|
},
|
|
255
256
|
spawn(idx, rng, w, h, spot) {
|
|
256
|
-
const angle = -Math.PI / 2 + rng.range(-
|
|
257
|
-
const speed = rng.range(
|
|
257
|
+
const angle = -Math.PI / 2 + rng.range(-spread, spread);
|
|
258
|
+
const speed = rng.range(speedMin, speedMax);
|
|
258
259
|
pSize[idx] = rng.range(2, 7);
|
|
259
260
|
pDecay[idx] = rng.range(0.005, 0.025);
|
|
260
261
|
pRot[idx] = rng.range(0, Math.PI);
|
|
@@ -273,9 +274,10 @@ export function IceBreathRecipe({count = 450, duration = 1500, colors, theme} =
|
|
|
273
274
|
const ds = dt * 60;
|
|
274
275
|
const dv = Math.pow(0.96, ds);
|
|
275
276
|
let alive = 0;
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
src.style.
|
|
277
|
+
const fadeT = clamp((elapsed - 100) / 500, 0, 1);
|
|
278
|
+
const ez = easeIn(fadeT);
|
|
279
|
+
src.style.opacity = 1 - ez;
|
|
280
|
+
src.style.filter = `brightness(${1 + ez})`;
|
|
279
281
|
|
|
280
282
|
for (let i = 0; i < max; i++) {
|
|
281
283
|
if (life[i] <= 0) continue;
|
|
@@ -629,12 +631,12 @@ export function LiquidMeltRecipe({count = 100, duration = 1200, colors, theme} =
|
|
|
629
631
|
// ===========================================================================
|
|
630
632
|
// IMAGE REVEALS
|
|
631
633
|
// ===========================================================================
|
|
632
|
-
//
|
|
634
|
+
// Draw the scratch layer live each frame via drawImage(src); no snapshot, works on any canvas (incl. cross-origin).
|
|
633
635
|
// ===========================================================================
|
|
634
636
|
|
|
635
637
|
export function ShatterRecipe({count = 30, duration = 1200, gravity = 0.5} = {}) {
|
|
636
638
|
let pRot, pRotSpd, pSz, pOpacity;
|
|
637
|
-
let
|
|
639
|
+
let hidden = false;
|
|
638
640
|
|
|
639
641
|
return {
|
|
640
642
|
count,
|
|
@@ -652,13 +654,7 @@ export function ShatterRecipe({count = 30, duration = 1200, gravity = 0.5} = {})
|
|
|
652
654
|
return {x: spot.x * w, y: spot.y * h, vx: rng.range(-15, 15), vy: rng.range(-10, -2), life: 1.0};
|
|
653
655
|
},
|
|
654
656
|
tick(dt, elapsed, engine, ctx, src, w, h) {
|
|
655
|
-
|
|
656
|
-
if (!img) {
|
|
657
|
-
img = new Image();
|
|
658
|
-
img.src = src.toDataURL();
|
|
659
|
-
src.style.opacity = '0';
|
|
660
|
-
}
|
|
661
|
-
if (!img.complete) return false;
|
|
657
|
+
if (!hidden) { src.style.opacity = '0'; hidden = true; }
|
|
662
658
|
|
|
663
659
|
const {x, y, vx, vy, life, data, max} = engine;
|
|
664
660
|
const ds = dt * 60;
|
|
@@ -682,14 +678,14 @@ export function ShatterRecipe({count = 30, duration = 1200, gravity = 0.5} = {})
|
|
|
682
678
|
ctx.globalAlpha = pOpacity[p];
|
|
683
679
|
ctx.translate(x[i], y[i]);
|
|
684
680
|
ctx.rotate(pRot[p]);
|
|
685
|
-
ctx.drawImage(
|
|
681
|
+
ctx.drawImage(src, -pSz[p] / 2, -pSz[p] / 2, pSz[p], pSz[p]);
|
|
686
682
|
ctx.restore();
|
|
687
683
|
}
|
|
688
684
|
return alive === 0;
|
|
689
685
|
},
|
|
690
686
|
destroy() {
|
|
691
687
|
pRot = pRotSpd = pSz = pOpacity = null;
|
|
692
|
-
|
|
688
|
+
hidden = false;
|
|
693
689
|
},
|
|
694
690
|
};
|
|
695
691
|
}
|
|
@@ -697,7 +693,7 @@ export function ShatterRecipe({count = 30, duration = 1200, gravity = 0.5} = {})
|
|
|
697
693
|
|
|
698
694
|
export function PixelShatterRecipe({count = 60, duration = 1200} = {}) {
|
|
699
695
|
let pRot, pRotSpd, pSz, pOrigX, pOrigY; // <- FIX: Store ORIGINAL positions
|
|
700
|
-
let
|
|
696
|
+
let hidden = false;
|
|
701
697
|
|
|
702
698
|
return {
|
|
703
699
|
count,
|
|
@@ -721,16 +717,7 @@ export function PixelShatterRecipe({count = 60, duration = 1200} = {}) {
|
|
|
721
717
|
return {x: sx, y: sy, vx: Math.cos(angle) * spd, vy: Math.sin(angle) * spd, life: 1.0};
|
|
722
718
|
},
|
|
723
719
|
tick(dt, elapsed, engine, ctx, src, w, h) {
|
|
724
|
-
if (!
|
|
725
|
-
img = new Image();
|
|
726
|
-
img.onload = () => {
|
|
727
|
-
imgReady = true;
|
|
728
|
-
};
|
|
729
|
-
img.src = src.toDataURL();
|
|
730
|
-
src.style.opacity = '0';
|
|
731
|
-
return false;
|
|
732
|
-
}
|
|
733
|
-
if (!imgReady) return false;
|
|
720
|
+
if (!hidden) { src.style.opacity = '0'; hidden = true; }
|
|
734
721
|
|
|
735
722
|
const {x, y, vx, vy, life, data, max} = engine;
|
|
736
723
|
const ds = dt * 60;
|
|
@@ -757,23 +744,21 @@ export function PixelShatterRecipe({count = 60, duration = 1200} = {}) {
|
|
|
757
744
|
ctx.translate(x[i], y[i]);
|
|
758
745
|
ctx.rotate(pRot[p]);
|
|
759
746
|
// <- FIX: Source crop uses ORIGINAL position, not current animated position
|
|
760
|
-
ctx.drawImage(
|
|
747
|
+
ctx.drawImage(src, pOrigX[p], pOrigY[p], sz, sz, -sz / 2, -sz / 2, sz, sz);
|
|
761
748
|
ctx.restore();
|
|
762
749
|
}
|
|
763
750
|
return alive === 0;
|
|
764
751
|
},
|
|
765
752
|
destroy() {
|
|
766
753
|
pRot = pRotSpd = pSz = pOrigX = pOrigY = null;
|
|
767
|
-
|
|
768
|
-
imgReady = false;
|
|
754
|
+
hidden = false;
|
|
769
755
|
},
|
|
770
756
|
};
|
|
771
757
|
}
|
|
772
758
|
|
|
773
759
|
|
|
774
760
|
export function GlitchRevealRecipe({duration = 800} = {}) {
|
|
775
|
-
let
|
|
776
|
-
let imgReady = false;
|
|
761
|
+
let hidden = false;
|
|
777
762
|
let sliceSeeds = null; // Pre-generated random values for deterministic slicing
|
|
778
763
|
|
|
779
764
|
return {
|
|
@@ -788,17 +773,7 @@ export function GlitchRevealRecipe({duration = 800} = {}) {
|
|
|
788
773
|
return {x: 0, y: 0, vx: 0, vy: 0, life: 0};
|
|
789
774
|
},
|
|
790
775
|
tick(dt, elapsed, engine, ctx, src, w, h) {
|
|
791
|
-
|
|
792
|
-
if (!img) {
|
|
793
|
-
img = new Image();
|
|
794
|
-
img.onload = () => {
|
|
795
|
-
imgReady = true;
|
|
796
|
-
};
|
|
797
|
-
img.src = src.toDataURL();
|
|
798
|
-
src.style.opacity = '0';
|
|
799
|
-
return false;
|
|
800
|
-
}
|
|
801
|
-
if (!imgReady) return false;
|
|
776
|
+
if (!hidden) { src.style.opacity = '0'; hidden = true; }
|
|
802
777
|
|
|
803
778
|
const raw = clamp(elapsed / duration, 0, 1);
|
|
804
779
|
|
|
@@ -817,9 +792,9 @@ export function GlitchRevealRecipe({duration = 800} = {}) {
|
|
|
817
792
|
|
|
818
793
|
// Red channel
|
|
819
794
|
ctx.globalCompositeOperation = 'screen';
|
|
820
|
-
ctx.drawImage(
|
|
795
|
+
ctx.drawImage(src, 0, sliceY, w, sliceH, offsetX - rgbShift, sliceY, w, sliceH);
|
|
821
796
|
// Cyan channel
|
|
822
|
-
ctx.drawImage(
|
|
797
|
+
ctx.drawImage(src, 0, sliceY, w, sliceH, offsetX + rgbShift, sliceY, w, sliceH);
|
|
823
798
|
}
|
|
824
799
|
|
|
825
800
|
ctx.globalCompositeOperation = 'source-over';
|
|
@@ -827,9 +802,8 @@ export function GlitchRevealRecipe({duration = 800} = {}) {
|
|
|
827
802
|
return raw >= 1;
|
|
828
803
|
},
|
|
829
804
|
destroy() {
|
|
830
|
-
img = null;
|
|
831
|
-
imgReady = false;
|
|
832
805
|
sliceSeeds = null;
|
|
806
|
+
hidden = false;
|
|
833
807
|
},
|
|
834
808
|
};
|
|
835
809
|
}
|
|
@@ -869,8 +843,9 @@ export function ShineRecipe({duration = 500, width = 250} = {}) {
|
|
|
869
843
|
}
|
|
870
844
|
|
|
871
845
|
|
|
872
|
-
export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount = 60, colors, theme} = {}) {
|
|
846
|
+
export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount = 60, ease = easeInOut, colors, theme} = {}) {
|
|
873
847
|
const palette = resolvePalette(colors, theme, ['#FFF', '#E0E8FF', '#B0C4FF', '#88AAFF']);
|
|
848
|
+
const ez = typeof ease === 'function' ? ease : easeInOut;
|
|
874
849
|
let py, pvy, pox, psz, pcol, r;
|
|
875
850
|
|
|
876
851
|
return {
|
|
@@ -895,7 +870,7 @@ export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount =
|
|
|
895
870
|
},
|
|
896
871
|
tick(dt, elapsed, engine, ctx, src, w, h) {
|
|
897
872
|
const raw = clamp(elapsed / duration, 0, 1);
|
|
898
|
-
const t =
|
|
873
|
+
const t = ez(raw);
|
|
899
874
|
const ds = dt * 60;
|
|
900
875
|
const bx = lerp(-beamWidth, w + beamWidth, t);
|
|
901
876
|
src.style.opacity = t > 0.3 ? 1 - (t - 0.3) / 0.7 : 1;
|
|
@@ -1173,8 +1148,9 @@ export function NeonPulseRecipe({duration = 800, colors, theme} = {}) {
|
|
|
1173
1148
|
// Drive the scratch layer's own transform/opacity; little or no particle work.
|
|
1174
1149
|
// ===========================================================================
|
|
1175
1150
|
|
|
1176
|
-
export function PeelRecipe({duration = 1000} = {}) {
|
|
1151
|
+
export function PeelRecipe({duration = 1000, ease = easeIn} = {}) {
|
|
1177
1152
|
let peelW = 0;
|
|
1153
|
+
const ez = typeof ease === 'function' ? ease : easeIn;
|
|
1178
1154
|
return {
|
|
1179
1155
|
count: 0,
|
|
1180
1156
|
init(ctx, capacity, w) {
|
|
@@ -1185,7 +1161,7 @@ export function PeelRecipe({duration = 1000} = {}) {
|
|
|
1185
1161
|
},
|
|
1186
1162
|
tick(dt, elapsed, engine, ctx, src) {
|
|
1187
1163
|
const raw = clamp(elapsed / duration, 0, 1);
|
|
1188
|
-
const t =
|
|
1164
|
+
const t = ez(raw);
|
|
1189
1165
|
src.style.transformOrigin = 'right center';
|
|
1190
1166
|
src.style.transform = `perspective(800px) rotateY(${t * 90}deg) rotateX(${t * -20}deg) translateX(${t * peelW}px)`;
|
|
1191
1167
|
src.style.opacity = 1 - t;
|
package/src/ScratchStage.js
CHANGED
|
@@ -287,8 +287,27 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
|
|
|
287
287
|
engine.destroy();
|
|
288
288
|
},
|
|
289
289
|
|
|
290
|
-
/**
|
|
290
|
+
/**
|
|
291
|
+
* @internal Total unreserved slots (fresh tail + reclaimed). Reclaimed slots are
|
|
292
|
+
* reusable only on an EXACT capacity match, so for an allocation decision use
|
|
293
|
+
* remainingCapacityFor(capacity).
|
|
294
|
+
*/
|
|
291
295
|
get remainingCapacity() { return maxParticles - nextStart + reclaimedCapacity; },
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* @internal Exact particle-slots allocatable for a request of this capacity; a
|
|
299
|
+
* size-C createController succeeds iff remainingCapacityFor(C) >= C.
|
|
300
|
+
*/
|
|
301
|
+
remainingCapacityFor(capacity) {
|
|
302
|
+
if (!(Number.isInteger(capacity) && capacity >= 0)) {
|
|
303
|
+
throw new TypeError('remainingCapacityFor: capacity must be a non-negative integer');
|
|
304
|
+
}
|
|
305
|
+
let n = 0;
|
|
306
|
+
for (let i = 0; i < freeCap.length; i++) {
|
|
307
|
+
if (freeCap[i] === capacity) n++;
|
|
308
|
+
}
|
|
309
|
+
return (maxParticles - nextStart) + n * capacity;
|
|
310
|
+
},
|
|
292
311
|
};
|
|
293
312
|
}
|
|
294
313
|
|