@zakkster/lite-scratch-fx 1.3.2 → 1.5.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 +76 -0
- package/README.md +28 -9
- package/index.d.ts +13 -7
- package/index.js +9 -5
- package/llms.txt +16 -5
- package/package.json +1 -1
- package/src/ScratchController.js +1 -1
- package/src/ScratchRecipes.js +70 -91
- package/src/ScratchStage.js +62 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,81 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.5.0] - 2026-08-23
|
|
4
|
+
|
|
5
|
+
Cover-fade fidelity, live image reveals, and additive recipe/stage knobs. Backward
|
|
6
|
+
compatible: every new recipe option and the new stage method is additive, and calling
|
|
7
|
+
with no options reproduces prior output byte-identically.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **`stage.remainingCapacityFor(capacity)` (B-19).** Returns the exact particle-slots
|
|
11
|
+
allocatable for a request of that capacity: `(maxParticles - nextStart) + n * capacity`,
|
|
12
|
+
where `n` is the count of reclaimed ranges of that exact capacity. A size-`C`
|
|
13
|
+
`createController` succeeds iff `remainingCapacityFor(C) >= C`. Zero-allocation; throws
|
|
14
|
+
`TypeError` on a non-integer or negative argument. `remainingCapacity` is unchanged (total
|
|
15
|
+
free slots); its JSDoc now states reclaimed ranges are reusable only on an exact-capacity
|
|
16
|
+
match, so `remainingCapacityFor` is the number to consult for an allocation decision.
|
|
17
|
+
- **Additive recipe factory options.** `dissolve` gains `fadeSpeed` (cover-fade window as a
|
|
18
|
+
fraction of `duration`, default `0.3`); `dragonBreath` and `iceBreath` gain `spread`,
|
|
19
|
+
`speedMin`, `speedMax` (spawn cone half-angle in radians and speed range; defaults `0.3`/
|
|
20
|
+
`12`/`25` and `0.225`/`15`/`28`); `peel` and `shineWave` gain `ease` (a `t -> t` easing
|
|
21
|
+
function, default `easeIn`/`easeInOut`). A non-function `ease` falls back to the default.
|
|
22
|
+
Every default equals the previous literal, so existing callers are unaffected, and each
|
|
23
|
+
numeric knob is a single `rng` draw in the same position (determinism preserved).
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- **Image recipes draw the scratch layer live (R5).** `shatter`, `pixelShatter`, and
|
|
27
|
+
`glitchReveal` now draw the source canvas each frame with `drawImage(src, ...)` instead of
|
|
28
|
+
snapshotting it once via `toDataURL()` + `Image`. This removes the synchronous PNG encode,
|
|
29
|
+
the async decode, and the blank first frames: they render on frame 1 and work on any
|
|
30
|
+
canvas, including cross-origin/tainted layers. `RECIPE_META[*].needsUntaintedCanvas` is now
|
|
31
|
+
`false` for every built-in; the field is retained so a third-party recipe that reads the
|
|
32
|
+
canvas back (`getImageData`/`toDataURL`) can still advertise a same-origin requirement.
|
|
33
|
+
- **`iceBreath` and `dragonBreath` cover fades match the consuming renderer.** `iceBreath`
|
|
34
|
+
fades with `power2.in` (cubic, `1 - easeIn`) over 0.5 s after a 0.1 s delay; `dragonBreath`
|
|
35
|
+
fades with `power1.in` (quadratic) over 0.4 s after a 0.1 s delay. Both were previously
|
|
36
|
+
linear with no delay.
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
- **Burn cover fade restored to cubic (B-18).** 1.4.0 shipped a quadratic fade (`1 - t*t`).
|
|
40
|
+
The consuming renderer tweens the burn cover with `power2.in`, which is cubic in GSAP's
|
|
41
|
+
naming (`Power2 = Cubic`; verified `parseEase('power2.in')(0.5) === 0.125`), so the correct
|
|
42
|
+
curve is `1 - easeIn(progress)` (`easeIn = t*t*t`). Reverted to cubic: at `progress` 0.5 the
|
|
43
|
+
cover opacity is `0.875`, not `0.75`.
|
|
44
|
+
|
|
45
|
+
### Removed
|
|
46
|
+
- The `toDataURL()` snapshot and `Image` decode from `shatter`, `pixelShatter`, and
|
|
47
|
+
`glitchReveal` (replaced by live `drawImage(src, ...)`).
|
|
48
|
+
|
|
49
|
+
## [1.4.0] - 2026-08-23
|
|
50
|
+
|
|
51
|
+
Determinism, recipe tuning, and stage lane reclaim. One backward-compatible interface
|
|
52
|
+
addition -- a 5th `rng` argument to `recipe.init`; recipes that ignore it are unaffected.
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
- **Seeded RNG passed to `recipe.init` (B-14).** `recipe.init(ctx, capacity, w, h, rng)` now
|
|
56
|
+
receives the controller's or stage's seeded `Random` as an additive 5th argument.
|
|
57
|
+
`dragonBreath`, `shineWave`, and `lightningCrawl` drew from `Math.random()` in `init`/`tick`,
|
|
58
|
+
breaking reproducibility; they now draw from `rng`, so two reveals at the same seed produce
|
|
59
|
+
byte-identical lanes. Recipes with a shorter `init` signature ignore the argument.
|
|
60
|
+
- **Stage sub-range reclaim (B-8).** `createScratchStage` keeps a free list of released lane
|
|
61
|
+
ranges; `controller.destroy()` returns its `[start, capacity)` range and `createController`
|
|
62
|
+
reuses an exact-capacity match before extending the pool, so a grid rebuilt after destroying
|
|
63
|
+
all controllers no longer throws `pool exhausted`. `remainingCapacity` counts the free list.
|
|
64
|
+
`cancel()` and completion do not reclaim -- the range stays reserved and the controller is
|
|
65
|
+
revealable. The per-frame tick path is byte-identical (the free list is touched only in
|
|
66
|
+
`createController`/`destroy`).
|
|
67
|
+
|
|
68
|
+
### Changed
|
|
69
|
+
- **`burn` retuned to a 0.2 s flash (B-15).** Defaults `count` 150 -> 100, `duration` 1500 -> 200
|
|
70
|
+
ms; spawn `size` 2-5 -> 3-8, `decay` 0.01-0.03 -> 0.02-0.05, upward `vy` -1..-4 -> -7..-2. The
|
|
71
|
+
additive gravity term `vy += 0.1 * ds` is removed and the cover fade is quadratic (`1 - t*t`)
|
|
72
|
+
instead of the cubic `easeIn`. (The quadratic fade was a regression -- see 1.5.0 B-18.)
|
|
73
|
+
- **`iceBreath` default `count` 300 -> 450 (B-16).**
|
|
74
|
+
|
|
75
|
+
### Removed
|
|
76
|
+
- `Math.random()` from `dragonBreath`, `shineWave`, and `lightningCrawl` (replaced by the
|
|
77
|
+
seeded `rng`).
|
|
78
|
+
|
|
3
79
|
## [1.3.2] - 2026-08-23
|
|
4
80
|
|
|
5
81
|
Recipe-fidelity pass against the consuming renderer. No API or signature change; the
|
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ import { createScratchController, BurnRecipe } from '@zakkster/lite-scratch-fx';
|
|
|
36
36
|
const fx = createScratchController(scratchCanvas, fxCanvas, { seed: 42 });
|
|
37
37
|
|
|
38
38
|
revealButton.onclick = () => {
|
|
39
|
-
fx.reveal(BurnRecipe(
|
|
39
|
+
fx.reveal(BurnRecipe(), () => {
|
|
40
40
|
console.log('prize revealed');
|
|
41
41
|
});
|
|
42
42
|
};
|
|
@@ -131,7 +131,7 @@ function MyRecipe({ count = 120, duration = 1000 } = {}) {
|
|
|
131
131
|
let size;
|
|
132
132
|
return {
|
|
133
133
|
count, // 0 = pure-canvas reveal, no particles
|
|
134
|
-
init(ctx, capacity, w, h) {
|
|
134
|
+
init(ctx, capacity, w, h, rng) { // allocate arrays; rng = seeded Random (same one passed to spawn), use instead of Math.random for reproducible reveals
|
|
135
135
|
size = new Float32Array(capacity);
|
|
136
136
|
},
|
|
137
137
|
spawn(idx, rng, w, h, spot) { // one particle; spot is 0..1 on the layer
|
|
@@ -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.0'` | 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
|
@@ -69,7 +69,8 @@ export interface RecipeRng {
|
|
|
69
69
|
export interface Recipe {
|
|
70
70
|
/** Particles to spawn. `0` means a pure-canvas reveal with no particles. */
|
|
71
71
|
count: number;
|
|
72
|
-
|
|
72
|
+
/** `rng` is the controller/stage's seeded Random instance (same one passed to spawn); use it instead of Math.random for reproducible reveals. */
|
|
73
|
+
init(ctx: CanvasRenderingContext2D, capacity: number, w: number, h: number, rng: RecipeRng): void;
|
|
73
74
|
spawn(idx: number, rng: RecipeRng, w: number, h: number, spot: SpawnSpot): SpawnState;
|
|
74
75
|
/** Return `true` when the effect is complete. `elapsedMs` is milliseconds since reveal. */
|
|
75
76
|
tick(
|
|
@@ -183,7 +184,10 @@ export interface StageController {
|
|
|
183
184
|
* A no-op when idle or destroyed. Does not touch other controllers' reveals.
|
|
184
185
|
*/
|
|
185
186
|
cancel(): void;
|
|
186
|
-
/**
|
|
187
|
+
/**
|
|
188
|
+
* Remove this controller from the stage. Its sub-range is reclaimed into the stage
|
|
189
|
+
* free-list for exact-capacity reuse by a later createController.
|
|
190
|
+
*/
|
|
187
191
|
destroy(): void;
|
|
188
192
|
}
|
|
189
193
|
|
|
@@ -204,6 +208,8 @@ export interface ScratchStage {
|
|
|
204
208
|
destroy(): void;
|
|
205
209
|
/** Slots still unreserved in the shared pool. */
|
|
206
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;
|
|
207
213
|
}
|
|
208
214
|
|
|
209
215
|
/** Create a stage for concurrent reveals over one shared particle pool. */
|
|
@@ -227,14 +233,14 @@ export interface ThemeableRecipeOptions extends RecipeOptions {
|
|
|
227
233
|
|
|
228
234
|
export function BurnRecipe(opts?: ThemeableRecipeOptions): Recipe;
|
|
229
235
|
export function ShatterRecipe(opts?: RecipeOptions & { gravity?: number }): Recipe;
|
|
230
|
-
export function DissolveRecipe(opts?: ThemeableRecipeOptions): Recipe;
|
|
236
|
+
export function DissolveRecipe(opts?: ThemeableRecipeOptions & { fadeSpeed?: number }): Recipe;
|
|
231
237
|
export function ExplodeRecipe(opts?: ThemeableRecipeOptions & { force?: number }): Recipe;
|
|
232
|
-
export function DragonBreathRecipe(opts?: ThemeableRecipeOptions): Recipe;
|
|
233
|
-
export function IceBreathRecipe(opts?: ThemeableRecipeOptions): Recipe;
|
|
234
|
-
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;
|
|
235
241
|
export function LightningCrawlRecipe(opts?: ThemeableRecipeOptions & { boltCount?: number }): Recipe;
|
|
236
242
|
export function ShineRecipe(opts?: RecipeOptions & { width?: number }): Recipe;
|
|
237
|
-
export function PeelRecipe(opts?: RecipeOptions): Recipe;
|
|
243
|
+
export function PeelRecipe(opts?: RecipeOptions & { ease?: (t: number) => number }): Recipe;
|
|
238
244
|
export function FadeRecipe(opts?: RecipeOptions): Recipe;
|
|
239
245
|
export function ImplosionRecipe(opts?: RecipeOptions): Recipe;
|
|
240
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.0';
|
|
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
|
@@ -32,7 +32,7 @@ A second reveal while one is active is ignored.
|
|
|
32
32
|
|
|
33
33
|
{
|
|
34
34
|
count, // particles to spawn; 0 = pure-canvas reveal
|
|
35
|
-
init(ctx, capacity, w, h),
|
|
35
|
+
init(ctx, capacity, w, h, rng), // allocate arrays; rng = seeded Random (same as spawn), use instead of Math.random for reproducible reveals
|
|
36
36
|
spawn(idx, rng, w, h, spot), // -> { x, y, vx, vy, life }; spot is {x,y} in 0..1
|
|
37
37
|
tick(dt, elapsedMs, p, ctx, src, w, h), // -> true when complete
|
|
38
38
|
destroy(),
|
|
@@ -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
|
|
@@ -116,6 +125,8 @@ start+capacity) of the lanes. reserving past maxParticles throws. Each controlle
|
|
|
116
125
|
into its slots (data[start+i]=i), renders through subarray views of its range (built once per
|
|
117
126
|
controller, reused every frame -> zero per-frame alloc), draws to its own ctx, keeps its own RNG.
|
|
118
127
|
stage.tick(dt) advances the single engine once; the one onTick fans out to every active reveal.
|
|
128
|
+
controller.destroy() reclaims its sub-range into a stage free-list for exact-capacity reuse by a
|
|
129
|
+
later createController; cancel/completion hold the range (the controller stays revealable).
|
|
119
130
|
Feasible with no engine change because raw tick(dt) is pure dispatch (no physics/culling) and the
|
|
120
131
|
lanes are public typed arrays recipes already write. See decisions/0001-concurrent-shared-reveals.md.
|
|
121
132
|
|
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.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
|
@@ -204,7 +204,7 @@ export function createScratchController(sourceCanvas, effectCanvas, {
|
|
|
204
204
|
const spots = scanner.scan(scanPrecision);
|
|
205
205
|
|
|
206
206
|
// Init recipe with LOGICAL size (recipes stay in CSS px).
|
|
207
|
-
recipe.init(ctx, cap, logicalW, logicalH);
|
|
207
|
+
recipe.init(ctx, cap, logicalW, logicalH, rng);
|
|
208
208
|
|
|
209
209
|
// Populate ring buffer. A recipe may declare `count: 0` (a pure-canvas reveal
|
|
210
210
|
// like Shine or Implosion that draws no particles); use ?? so an explicit 0 is
|
package/src/ScratchRecipes.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* ScratchController.reveal(recipe, onDone). Grouped by family below.
|
|
6
6
|
*
|
|
7
7
|
* Recipe interface:
|
|
8
|
-
* { count, init(ctx, capacity, w, h),
|
|
8
|
+
* { count, init(ctx, capacity, w, h, rng),
|
|
9
9
|
* spawn(idx, rng, w, h, spot),
|
|
10
10
|
* tick(dt, elapsedMs, engine, ctx, sourceCanvas, w, h) -> boolean,
|
|
11
11
|
* destroy() }
|
|
@@ -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 =
|
|
31
|
+
export function BurnRecipe({count = 100, duration = 200, colors, theme} = {}) {
|
|
32
32
|
let pSize, pDecay, pColorIdx;
|
|
33
33
|
const palette = resolvePalette(colors, theme, ['#ff6b00', '#ff8c00', '#ffaa00', '#ff4500', '#8B0000']);
|
|
34
34
|
|
|
@@ -40,10 +40,10 @@ export function BurnRecipe({count = 150, duration = 1500, colors, theme} = {}) {
|
|
|
40
40
|
pColorIdx = new Uint8Array(capacity);
|
|
41
41
|
},
|
|
42
42
|
spawn(idx, rng, w, h, spot) {
|
|
43
|
-
pSize[idx] = rng.range(
|
|
44
|
-
pDecay[idx] = rng.range(0.
|
|
43
|
+
pSize[idx] = rng.range(3, 8);
|
|
44
|
+
pDecay[idx] = rng.range(0.02, 0.05);
|
|
45
45
|
pColorIdx[idx] = rng.int(0, palette.length - 1);
|
|
46
|
-
return {x: spot.x * w, y: spot.y * h, vx: rng.range(-1, 1), vy: rng.range(-
|
|
46
|
+
return {x: spot.x * w, y: spot.y * h, vx: rng.range(-1, 1), vy: rng.range(-7, -2), life: 1.0};
|
|
47
47
|
},
|
|
48
48
|
tick(dt, elapsed, engine, ctx, src, w, h) {
|
|
49
49
|
const {x, y, vx, vy, life, data, max} = engine;
|
|
@@ -55,7 +55,6 @@ export function BurnRecipe({count = 150, duration = 1500, colors, theme} = {}) {
|
|
|
55
55
|
for (let i = 0; i < max; i++) {
|
|
56
56
|
if (life[i] <= 0) continue;
|
|
57
57
|
const p = data[i];
|
|
58
|
-
vy[i] += 0.1 * ds;
|
|
59
58
|
x[i] += vx[i] * ds;
|
|
60
59
|
y[i] += vy[i] * ds;
|
|
61
60
|
life[i] -= pDecay[p] * ds;
|
|
@@ -84,7 +83,7 @@ export function BurnRecipe({count = 150, duration = 1500, colors, theme} = {}) {
|
|
|
84
83
|
}
|
|
85
84
|
|
|
86
85
|
|
|
87
|
-
export function DissolveRecipe({count = 150, duration = 1000, colors, theme} = {}) {
|
|
86
|
+
export function DissolveRecipe({count = 150, duration = 1000, fadeSpeed = 0.3, colors, theme} = {}) {
|
|
88
87
|
let pSize;
|
|
89
88
|
const palette = resolvePalette(colors, theme, ['#DC143C']);
|
|
90
89
|
|
|
@@ -100,7 +99,7 @@ export function DissolveRecipe({count = 150, duration = 1000, colors, theme} = {
|
|
|
100
99
|
tick(dt, elapsed, engine, ctx, src, w, h) {
|
|
101
100
|
const {x, y, vy, life, data, max} = engine;
|
|
102
101
|
let alive = 0;
|
|
103
|
-
src.style.opacity = 1 - clamp(elapsed / (duration *
|
|
102
|
+
src.style.opacity = 1 - clamp(elapsed / (duration * fadeSpeed), 0, 1);
|
|
104
103
|
|
|
105
104
|
for (let i = 0; i < max; i++) {
|
|
106
105
|
if (life[i] <= 0) continue;
|
|
@@ -172,20 +171,21 @@ export function ExplodeRecipe({count = 80, duration = 750, force = 15, colors, t
|
|
|
172
171
|
}
|
|
173
172
|
|
|
174
173
|
|
|
175
|
-
export function DragonBreathRecipe({count = 200, duration = 1200, colors, theme} = {}) {
|
|
176
|
-
let pSize, pDecay, pColorIdx;
|
|
174
|
+
export function DragonBreathRecipe({count = 200, duration = 1200, spread = 0.3, speedMin = 12, speedMax = 25, colors, theme} = {}) {
|
|
175
|
+
let pSize, pDecay, pColorIdx, r;
|
|
177
176
|
const palette = resolvePalette(colors, theme, ['#FFF', '#FFD700', '#FF4500', '#8B0000', '#2F2F2F']);
|
|
178
177
|
|
|
179
178
|
return {
|
|
180
179
|
count,
|
|
181
|
-
init(ctx, capacity) {
|
|
180
|
+
init(ctx, capacity, w, h, rng) {
|
|
181
|
+
r = rng;
|
|
182
182
|
pSize = new Float32Array(capacity);
|
|
183
183
|
pDecay = new Float32Array(capacity);
|
|
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,14 +203,15 @@ 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;
|
|
210
211
|
const p = data[i];
|
|
211
212
|
x[i] += vx[i] * ds;
|
|
212
213
|
y[i] += vy[i] * ds;
|
|
213
|
-
vx[i] += (
|
|
214
|
+
vx[i] += (r.next() - 0.5) * 0.5 * ds;
|
|
214
215
|
vy[i] *= dvy;
|
|
215
216
|
pSize[p] *= dsz;
|
|
216
217
|
life[i] -= pDecay[p] * ds;
|
|
@@ -233,13 +234,13 @@ export function DragonBreathRecipe({count = 200, duration = 1200, colors, theme}
|
|
|
233
234
|
return alive === 0 && elapsed >= duration;
|
|
234
235
|
},
|
|
235
236
|
destroy() {
|
|
236
|
-
pSize = pDecay = pColorIdx = null;
|
|
237
|
+
pSize = pDecay = pColorIdx = r = null;
|
|
237
238
|
},
|
|
238
239
|
};
|
|
239
240
|
}
|
|
240
241
|
|
|
241
242
|
|
|
242
|
-
export function IceBreathRecipe({count =
|
|
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 = 300, 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,9 @@ export function IceBreathRecipe({count = 300, 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
|
-
src.style.opacity = 1 -
|
|
278
|
-
src.style.filter = `brightness(${1 +
|
|
277
|
+
const fadeT = clamp((elapsed - 100) / 500, 0, 1);
|
|
278
|
+
src.style.opacity = 1 - easeIn(fadeT);
|
|
279
|
+
src.style.filter = `brightness(${1 + fadeT})`;
|
|
279
280
|
|
|
280
281
|
for (let i = 0; i < max; i++) {
|
|
281
282
|
if (life[i] <= 0) continue;
|
|
@@ -629,12 +630,12 @@ export function LiquidMeltRecipe({count = 100, duration = 1200, colors, theme} =
|
|
|
629
630
|
// ===========================================================================
|
|
630
631
|
// IMAGE REVEALS
|
|
631
632
|
// ===========================================================================
|
|
632
|
-
//
|
|
633
|
+
// Draw the scratch layer live each frame via drawImage(src); no snapshot, works on any canvas (incl. cross-origin).
|
|
633
634
|
// ===========================================================================
|
|
634
635
|
|
|
635
636
|
export function ShatterRecipe({count = 30, duration = 1200, gravity = 0.5} = {}) {
|
|
636
637
|
let pRot, pRotSpd, pSz, pOpacity;
|
|
637
|
-
let
|
|
638
|
+
let hidden = false;
|
|
638
639
|
|
|
639
640
|
return {
|
|
640
641
|
count,
|
|
@@ -652,13 +653,7 @@ export function ShatterRecipe({count = 30, duration = 1200, gravity = 0.5} = {})
|
|
|
652
653
|
return {x: spot.x * w, y: spot.y * h, vx: rng.range(-15, 15), vy: rng.range(-10, -2), life: 1.0};
|
|
653
654
|
},
|
|
654
655
|
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;
|
|
656
|
+
if (!hidden) { src.style.opacity = '0'; hidden = true; }
|
|
662
657
|
|
|
663
658
|
const {x, y, vx, vy, life, data, max} = engine;
|
|
664
659
|
const ds = dt * 60;
|
|
@@ -682,14 +677,14 @@ export function ShatterRecipe({count = 30, duration = 1200, gravity = 0.5} = {})
|
|
|
682
677
|
ctx.globalAlpha = pOpacity[p];
|
|
683
678
|
ctx.translate(x[i], y[i]);
|
|
684
679
|
ctx.rotate(pRot[p]);
|
|
685
|
-
ctx.drawImage(
|
|
680
|
+
ctx.drawImage(src, -pSz[p] / 2, -pSz[p] / 2, pSz[p], pSz[p]);
|
|
686
681
|
ctx.restore();
|
|
687
682
|
}
|
|
688
683
|
return alive === 0;
|
|
689
684
|
},
|
|
690
685
|
destroy() {
|
|
691
686
|
pRot = pRotSpd = pSz = pOpacity = null;
|
|
692
|
-
|
|
687
|
+
hidden = false;
|
|
693
688
|
},
|
|
694
689
|
};
|
|
695
690
|
}
|
|
@@ -697,7 +692,7 @@ export function ShatterRecipe({count = 30, duration = 1200, gravity = 0.5} = {})
|
|
|
697
692
|
|
|
698
693
|
export function PixelShatterRecipe({count = 60, duration = 1200} = {}) {
|
|
699
694
|
let pRot, pRotSpd, pSz, pOrigX, pOrigY; // <- FIX: Store ORIGINAL positions
|
|
700
|
-
let
|
|
695
|
+
let hidden = false;
|
|
701
696
|
|
|
702
697
|
return {
|
|
703
698
|
count,
|
|
@@ -721,16 +716,7 @@ export function PixelShatterRecipe({count = 60, duration = 1200} = {}) {
|
|
|
721
716
|
return {x: sx, y: sy, vx: Math.cos(angle) * spd, vy: Math.sin(angle) * spd, life: 1.0};
|
|
722
717
|
},
|
|
723
718
|
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;
|
|
719
|
+
if (!hidden) { src.style.opacity = '0'; hidden = true; }
|
|
734
720
|
|
|
735
721
|
const {x, y, vx, vy, life, data, max} = engine;
|
|
736
722
|
const ds = dt * 60;
|
|
@@ -757,23 +743,21 @@ export function PixelShatterRecipe({count = 60, duration = 1200} = {}) {
|
|
|
757
743
|
ctx.translate(x[i], y[i]);
|
|
758
744
|
ctx.rotate(pRot[p]);
|
|
759
745
|
// <- FIX: Source crop uses ORIGINAL position, not current animated position
|
|
760
|
-
ctx.drawImage(
|
|
746
|
+
ctx.drawImage(src, pOrigX[p], pOrigY[p], sz, sz, -sz / 2, -sz / 2, sz, sz);
|
|
761
747
|
ctx.restore();
|
|
762
748
|
}
|
|
763
749
|
return alive === 0;
|
|
764
750
|
},
|
|
765
751
|
destroy() {
|
|
766
752
|
pRot = pRotSpd = pSz = pOrigX = pOrigY = null;
|
|
767
|
-
|
|
768
|
-
imgReady = false;
|
|
753
|
+
hidden = false;
|
|
769
754
|
},
|
|
770
755
|
};
|
|
771
756
|
}
|
|
772
757
|
|
|
773
758
|
|
|
774
759
|
export function GlitchRevealRecipe({duration = 800} = {}) {
|
|
775
|
-
let
|
|
776
|
-
let imgReady = false;
|
|
760
|
+
let hidden = false;
|
|
777
761
|
let sliceSeeds = null; // Pre-generated random values for deterministic slicing
|
|
778
762
|
|
|
779
763
|
return {
|
|
@@ -788,17 +772,7 @@ export function GlitchRevealRecipe({duration = 800} = {}) {
|
|
|
788
772
|
return {x: 0, y: 0, vx: 0, vy: 0, life: 0};
|
|
789
773
|
},
|
|
790
774
|
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;
|
|
775
|
+
if (!hidden) { src.style.opacity = '0'; hidden = true; }
|
|
802
776
|
|
|
803
777
|
const raw = clamp(elapsed / duration, 0, 1);
|
|
804
778
|
|
|
@@ -817,9 +791,9 @@ export function GlitchRevealRecipe({duration = 800} = {}) {
|
|
|
817
791
|
|
|
818
792
|
// Red channel
|
|
819
793
|
ctx.globalCompositeOperation = 'screen';
|
|
820
|
-
ctx.drawImage(
|
|
794
|
+
ctx.drawImage(src, 0, sliceY, w, sliceH, offsetX - rgbShift, sliceY, w, sliceH);
|
|
821
795
|
// Cyan channel
|
|
822
|
-
ctx.drawImage(
|
|
796
|
+
ctx.drawImage(src, 0, sliceY, w, sliceH, offsetX + rgbShift, sliceY, w, sliceH);
|
|
823
797
|
}
|
|
824
798
|
|
|
825
799
|
ctx.globalCompositeOperation = 'source-over';
|
|
@@ -827,9 +801,8 @@ export function GlitchRevealRecipe({duration = 800} = {}) {
|
|
|
827
801
|
return raw >= 1;
|
|
828
802
|
},
|
|
829
803
|
destroy() {
|
|
830
|
-
img = null;
|
|
831
|
-
imgReady = false;
|
|
832
804
|
sliceSeeds = null;
|
|
805
|
+
hidden = false;
|
|
833
806
|
},
|
|
834
807
|
};
|
|
835
808
|
}
|
|
@@ -869,24 +842,26 @@ export function ShineRecipe({duration = 500, width = 250} = {}) {
|
|
|
869
842
|
}
|
|
870
843
|
|
|
871
844
|
|
|
872
|
-
export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount = 60, colors, theme} = {}) {
|
|
845
|
+
export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount = 60, ease = easeInOut, colors, theme} = {}) {
|
|
873
846
|
const palette = resolvePalette(colors, theme, ['#FFF', '#E0E8FF', '#B0C4FF', '#88AAFF']);
|
|
874
|
-
|
|
847
|
+
const ez = typeof ease === 'function' ? ease : easeInOut;
|
|
848
|
+
let py, pvy, pox, psz, pcol, r;
|
|
875
849
|
|
|
876
850
|
return {
|
|
877
851
|
count: 0, // No SoA particles -- managed internally
|
|
878
|
-
init(ctx, capacity, w, h) {
|
|
852
|
+
init(ctx, capacity, w, h, rng) {
|
|
853
|
+
r = rng;
|
|
879
854
|
py = new Float32Array(particleCount);
|
|
880
855
|
pvy = new Float32Array(particleCount);
|
|
881
856
|
pox = new Float32Array(particleCount);
|
|
882
857
|
psz = new Float32Array(particleCount);
|
|
883
858
|
pcol = new Uint8Array(particleCount);
|
|
884
859
|
for (let i = 0; i < particleCount; i++) {
|
|
885
|
-
py[i] =
|
|
886
|
-
pvy[i] = (
|
|
887
|
-
pox[i] = (
|
|
888
|
-
psz[i] =
|
|
889
|
-
pcol[i] = (
|
|
860
|
+
py[i] = r.next() * h;
|
|
861
|
+
pvy[i] = (r.next() - 0.5) * 2;
|
|
862
|
+
pox[i] = (r.next() - 0.5) * beamWidth * 0.6;
|
|
863
|
+
psz[i] = r.next() * 4 + 2;
|
|
864
|
+
pcol[i] = (r.next() * palette.length) | 0;
|
|
890
865
|
}
|
|
891
866
|
},
|
|
892
867
|
spawn() {
|
|
@@ -894,7 +869,7 @@ export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount =
|
|
|
894
869
|
},
|
|
895
870
|
tick(dt, elapsed, engine, ctx, src, w, h) {
|
|
896
871
|
const raw = clamp(elapsed / duration, 0, 1);
|
|
897
|
-
const t =
|
|
872
|
+
const t = ez(raw);
|
|
898
873
|
const ds = dt * 60;
|
|
899
874
|
const bx = lerp(-beamWidth, w + beamWidth, t);
|
|
900
875
|
src.style.opacity = t > 0.3 ? 1 - (t - 0.3) / 0.7 : 1;
|
|
@@ -916,10 +891,10 @@ export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount =
|
|
|
916
891
|
for (let i = 0; i < particleCount; i++) {
|
|
917
892
|
const px2 = bx + pox[i];
|
|
918
893
|
py[i] += pvy[i] * ds;
|
|
919
|
-
pvy[i] += (
|
|
894
|
+
pvy[i] += (r.next() - 0.5) * 0.3 * ds;
|
|
920
895
|
if (py[i] < -10) py[i] = h + 10;
|
|
921
896
|
if (py[i] > h + 10) py[i] = -10;
|
|
922
|
-
psz[i] = clamp(psz[i] + (
|
|
897
|
+
psz[i] = clamp(psz[i] + (r.next() - 0.5) * 0.3 * ds, 1, 8);
|
|
923
898
|
ctx.globalAlpha = 0.7;
|
|
924
899
|
ctx.fillStyle = palette[pcol[i]];
|
|
925
900
|
ctx.save();
|
|
@@ -932,7 +907,7 @@ export function ShineWaveRecipe({duration = 600, beamWidth = 80, particleCount =
|
|
|
932
907
|
return raw >= 1;
|
|
933
908
|
},
|
|
934
909
|
destroy() {
|
|
935
|
-
py = pvy = pox = psz = pcol = null;
|
|
910
|
+
py = pvy = pox = psz = pcol = r = null;
|
|
936
911
|
},
|
|
937
912
|
};
|
|
938
913
|
}
|
|
@@ -1013,6 +988,7 @@ export function LightningCrawlRecipe({boltCount = 5, duration = 600, colors, the
|
|
|
1013
988
|
const cMid = palette[(palette.length - 1) >> 1];
|
|
1014
989
|
const cOuter = palette[palette.length - 1];
|
|
1015
990
|
let bolts = [];
|
|
991
|
+
let r;
|
|
1016
992
|
|
|
1017
993
|
function genPts(x1, y1, x2, y2, d) {
|
|
1018
994
|
let p = [{x: x1, y: y1}, {x: x2, y: y2}];
|
|
@@ -1022,7 +998,7 @@ export function LightningCrawlRecipe({boltCount = 5, duration = 600, colors, the
|
|
|
1022
998
|
const a = p[j], b = p[j + 1];
|
|
1023
999
|
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
|
|
1024
1000
|
const dist = Math.hypot(b.x - a.x, b.y - a.y);
|
|
1025
|
-
const off = (
|
|
1001
|
+
const off = (r.next() - 0.5) * dist * 0.4;
|
|
1026
1002
|
const nx = -(b.y - a.y) / (dist || 1), ny = (b.x - a.x) / (dist || 1);
|
|
1027
1003
|
np.push({x: mx + nx * off, y: my + ny * off});
|
|
1028
1004
|
np.push(b);
|
|
@@ -1045,39 +1021,40 @@ export function LightningCrawlRecipe({boltCount = 5, duration = 600, colors, the
|
|
|
1045
1021
|
ctx.stroke();
|
|
1046
1022
|
});
|
|
1047
1023
|
if (d > 2) for (let i = 2; i < pts.length - 1; i += 3) {
|
|
1048
|
-
if (
|
|
1049
|
-
const bl = Math.hypot(x2 - x1, y2 - y1) * (0.15 +
|
|
1050
|
-
const ba = Math.atan2(y2 - y1, x2 - x1) + (
|
|
1024
|
+
if (r.next() > 0.5) continue;
|
|
1025
|
+
const bl = Math.hypot(x2 - x1, y2 - y1) * (0.15 + r.next() * 0.2);
|
|
1026
|
+
const ba = Math.atan2(y2 - y1, x2 - x1) + (r.next() - 0.5) * 1.5;
|
|
1051
1027
|
drawBolt(ctx, pts[i].x, pts[i].y, pts[i].x + Math.cos(ba) * bl, pts[i].y + Math.sin(ba) * bl, th * 0.5, d - 2);
|
|
1052
1028
|
}
|
|
1053
1029
|
}
|
|
1054
1030
|
|
|
1055
1031
|
return {
|
|
1056
1032
|
count: 0,
|
|
1057
|
-
init(ctx, capacity, w, h) {
|
|
1033
|
+
init(ctx, capacity, w, h, rng) {
|
|
1034
|
+
r = rng;
|
|
1058
1035
|
bolts = [];
|
|
1059
1036
|
for (let b = 0; b < boltCount; b++) {
|
|
1060
|
-
const e = (
|
|
1037
|
+
const e = (r.next() * 4) | 0;
|
|
1061
1038
|
let sx, sy;
|
|
1062
1039
|
if (e === 0) {
|
|
1063
1040
|
sx = 0;
|
|
1064
|
-
sy =
|
|
1041
|
+
sy = r.next() * h;
|
|
1065
1042
|
} else if (e === 1) {
|
|
1066
1043
|
sx = w;
|
|
1067
|
-
sy =
|
|
1044
|
+
sy = r.next() * h;
|
|
1068
1045
|
} else if (e === 2) {
|
|
1069
|
-
sx =
|
|
1046
|
+
sx = r.next() * w;
|
|
1070
1047
|
sy = 0;
|
|
1071
1048
|
} else {
|
|
1072
|
-
sx =
|
|
1049
|
+
sx = r.next() * w;
|
|
1073
1050
|
sy = h;
|
|
1074
1051
|
}
|
|
1075
1052
|
bolts.push({
|
|
1076
1053
|
sx,
|
|
1077
1054
|
sy,
|
|
1078
|
-
ex: w * (0.3 +
|
|
1079
|
-
ey: h * (0.3 +
|
|
1080
|
-
delay:
|
|
1055
|
+
ex: w * (0.3 + r.next() * 0.4),
|
|
1056
|
+
ey: h * (0.3 + r.next() * 0.4),
|
|
1057
|
+
delay: r.next() * 0.3
|
|
1081
1058
|
});
|
|
1082
1059
|
}
|
|
1083
1060
|
},
|
|
@@ -1109,6 +1086,7 @@ export function LightningCrawlRecipe({boltCount = 5, duration = 600, colors, the
|
|
|
1109
1086
|
},
|
|
1110
1087
|
destroy() {
|
|
1111
1088
|
bolts = [];
|
|
1089
|
+
r = null;
|
|
1112
1090
|
},
|
|
1113
1091
|
};
|
|
1114
1092
|
}
|
|
@@ -1169,8 +1147,9 @@ export function NeonPulseRecipe({duration = 800, colors, theme} = {}) {
|
|
|
1169
1147
|
// Drive the scratch layer's own transform/opacity; little or no particle work.
|
|
1170
1148
|
// ===========================================================================
|
|
1171
1149
|
|
|
1172
|
-
export function PeelRecipe({duration = 1000} = {}) {
|
|
1150
|
+
export function PeelRecipe({duration = 1000, ease = easeIn} = {}) {
|
|
1173
1151
|
let peelW = 0;
|
|
1152
|
+
const ez = typeof ease === 'function' ? ease : easeIn;
|
|
1174
1153
|
return {
|
|
1175
1154
|
count: 0,
|
|
1176
1155
|
init(ctx, capacity, w) {
|
|
@@ -1181,7 +1160,7 @@ export function PeelRecipe({duration = 1000} = {}) {
|
|
|
1181
1160
|
},
|
|
1182
1161
|
tick(dt, elapsed, engine, ctx, src) {
|
|
1183
1162
|
const raw = clamp(elapsed / duration, 0, 1);
|
|
1184
|
-
const t =
|
|
1163
|
+
const t = ez(raw);
|
|
1185
1164
|
src.style.transformOrigin = 'right center';
|
|
1186
1165
|
src.style.transform = `perspective(800px) rotateY(${t * 90}deg) rotateX(${t * -20}deg) translateX(${t * peelW}px)`;
|
|
1187
1166
|
src.style.opacity = 1 - t;
|
package/src/ScratchStage.js
CHANGED
|
@@ -46,6 +46,12 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
|
|
|
46
46
|
const pending = new Set();
|
|
47
47
|
let dispatching = false;
|
|
48
48
|
let nextStart = 0;
|
|
49
|
+
// Free-list of reclaimed [start, capacity] ranges from destroyed controllers, in
|
|
50
|
+
// parallel arrays. Exact-match reuse only: a new controller reclaims a range solely
|
|
51
|
+
// when its capacity equals a freed one. Cold path (createController/destroy) -- never
|
|
52
|
+
// touched per frame.
|
|
53
|
+
const freeStart = [], freeCap = [];
|
|
54
|
+
let reclaimedCapacity = 0;
|
|
49
55
|
let controllerCount = 0;
|
|
50
56
|
let destroyed = false;
|
|
51
57
|
|
|
@@ -89,13 +95,32 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
|
|
|
89
95
|
if (!(typeof dpr === 'number' && Number.isFinite(dpr) && dpr > 0)) {
|
|
90
96
|
throw new TypeError('createController: dpr must be a positive finite number');
|
|
91
97
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
98
|
+
// Reclaim an exact-capacity range from a destroyed controller before growing the
|
|
99
|
+
// pool. On a hit we skip the nextStart bump and the exhaustion throw entirely.
|
|
100
|
+
let start;
|
|
101
|
+
let reused = -1;
|
|
102
|
+
for (let i = 0; i < freeCap.length; i++) {
|
|
103
|
+
if (freeCap[i] === capacity) { reused = i; break; }
|
|
96
104
|
}
|
|
97
|
-
|
|
98
|
-
|
|
105
|
+
if (reused >= 0) {
|
|
106
|
+
start = freeStart[reused];
|
|
107
|
+
// Swap-remove: order is irrelevant for exact-match reuse.
|
|
108
|
+
const last = freeCap.length - 1;
|
|
109
|
+
freeStart[reused] = freeStart[last];
|
|
110
|
+
freeCap[reused] = freeCap[last];
|
|
111
|
+
freeStart.pop();
|
|
112
|
+
freeCap.pop();
|
|
113
|
+
reclaimedCapacity -= capacity;
|
|
114
|
+
} else {
|
|
115
|
+
if (nextStart + capacity > maxParticles) {
|
|
116
|
+
throw new RangeError(
|
|
117
|
+
`createController: pool exhausted -- ${nextStart}+${capacity} exceeds maxParticles ${maxParticles}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
start = nextStart;
|
|
121
|
+
nextStart += capacity;
|
|
122
|
+
}
|
|
123
|
+
// A reused range is a NEW controller, so the index always advances -> new default seed.
|
|
99
124
|
const index = controllerCount++;
|
|
100
125
|
|
|
101
126
|
const ctx = effectCanvas.getContext('2d');
|
|
@@ -133,7 +158,8 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
|
|
|
133
158
|
// The single terminator for this controller's reveal. Completion (fireDone=true),
|
|
134
159
|
// cancel() (false), and destroy() (false) all route here so the recipe teardown,
|
|
135
160
|
// host-style restore, canvas clear, and active-set removal happen in one place.
|
|
136
|
-
// Cold path: once per reveal, never per frame.
|
|
161
|
+
// Cold path: once per reveal, never per frame. The sub-range is held across every
|
|
162
|
+
// completion and cancel, and reclaimed into the stage free-list only on destroy() (B-8).
|
|
137
163
|
function endReveal(fireDone) {
|
|
138
164
|
if (!activeRecipe) return;
|
|
139
165
|
if (activeRecipe.destroy) activeRecipe.destroy();
|
|
@@ -171,7 +197,7 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
|
|
|
171
197
|
hostSnapshot = captureHostStyle(sourceCanvas);
|
|
172
198
|
|
|
173
199
|
const spots = scanner.scan(scanPrecision);
|
|
174
|
-
recipe.init(ctx, capacity, cw, ch);
|
|
200
|
+
recipe.init(ctx, capacity, cw, ch, rng);
|
|
175
201
|
|
|
176
202
|
const requested = recipe.count ?? capacity;
|
|
177
203
|
const count = Math.min(requested, capacity);
|
|
@@ -224,7 +250,10 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
|
|
|
224
250
|
/** Re-seed this controller's RNG. */
|
|
225
251
|
seed(s) { rng.reset(s); },
|
|
226
252
|
|
|
227
|
-
/**
|
|
253
|
+
/**
|
|
254
|
+
* Remove this controller from the stage. Its sub-range is reclaimed into the
|
|
255
|
+
* stage free-list for exact-capacity reuse by a later createController.
|
|
256
|
+
*/
|
|
228
257
|
destroy() {
|
|
229
258
|
if (ctlDestroyed) return;
|
|
230
259
|
ctlDestroyed = true;
|
|
@@ -232,6 +261,9 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
|
|
|
232
261
|
// host-style restore, active-set removal, no onDone) before scanner teardown.
|
|
233
262
|
endReveal(false);
|
|
234
263
|
scanner.destroy();
|
|
264
|
+
// Reclaim the sub-range. The early-return above guarantees one push per
|
|
265
|
+
// controller, so a double-destroy cannot double-free.
|
|
266
|
+
freeStart.push(start); freeCap.push(capacity); reclaimedCapacity += capacity;
|
|
235
267
|
},
|
|
236
268
|
};
|
|
237
269
|
return controller;
|
|
@@ -255,8 +287,27 @@ export function createScratchStage({ maxParticles = 2000, seed = Date.now(), dpr
|
|
|
255
287
|
engine.destroy();
|
|
256
288
|
},
|
|
257
289
|
|
|
258
|
-
/**
|
|
259
|
-
|
|
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
|
+
*/
|
|
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
|
+
},
|
|
260
311
|
};
|
|
261
312
|
}
|
|
262
313
|
|