@glissade/scene 0.56.0 → 0.57.0-pre.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/dist/describe.js +25 -1
- package/dist/diagnostics.d.ts +41 -2
- package/dist/diagnostics.js +81 -2
- package/dist/each.d.ts +125 -0
- package/dist/each.js +168 -0
- package/dist/index.d.ts +3 -161
- package/dist/index.js +4 -246
- package/dist/motion.d.ts +207 -2
- package/dist/motion.js +396 -3
- package/package.json +2 -2
package/dist/motion.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { C as Mat2x3, r as DisplayListBuilder } from "./displayList.js";
|
|
2
2
|
import { B as Node, H as NodeProps, R as EvalContext, U as PropInit, a as Group, l as Path } from "./nodes.js";
|
|
3
|
-
import {
|
|
3
|
+
import { l as Place } from "./each.js";
|
|
4
|
+
import { BindableSignal, PathValue, Rng, Track, Vec2, Vec2Signal } from "@glissade/core";
|
|
4
5
|
|
|
5
6
|
//#region src/motionPath.d.ts
|
|
6
7
|
|
|
@@ -216,4 +217,208 @@ declare class Camera extends Group {
|
|
|
216
217
|
*/
|
|
217
218
|
declare function camera(layers: CameraLayer[], props?: CameraProps): Camera;
|
|
218
219
|
//#endregion
|
|
219
|
-
|
|
220
|
+
//#region src/particles.d.ts
|
|
221
|
+
/** Hard cap on the slot pool. `count` over this THROWS (never silent-clamps). */
|
|
222
|
+
declare const MAX_PARTICLE_COUNT = 200;
|
|
223
|
+
/** A life-fraction curve: `u` in [0,1] (age/lifetime) → a scalar (opacity/scale). */
|
|
224
|
+
type OverLife = (u: number) => number;
|
|
225
|
+
/** A spawn area spread around the origin (px), for scattering (drift) vs a point (sparks). */
|
|
226
|
+
type AreaSpec = {
|
|
227
|
+
kind: 'box';
|
|
228
|
+
w: number;
|
|
229
|
+
h: number;
|
|
230
|
+
} | {
|
|
231
|
+
kind: 'disc';
|
|
232
|
+
radius: number;
|
|
233
|
+
};
|
|
234
|
+
/** Constant accelerations folded into the per-step integration. */
|
|
235
|
+
interface ParticleForces {
|
|
236
|
+
/** px/s², applied on +y (down). */
|
|
237
|
+
gravity?: number;
|
|
238
|
+
/** velocity damping coefficient (1/s): `v -= v*drag*dt`. */
|
|
239
|
+
drag?: number;
|
|
240
|
+
/** px/s² wind acceleration `[ax, ay]`. */
|
|
241
|
+
wind?: readonly [number, number];
|
|
242
|
+
}
|
|
243
|
+
/** The mutable per-particle physics state (also what the `step` escape-hatch mutates). */
|
|
244
|
+
interface ParticleState {
|
|
245
|
+
x: number;
|
|
246
|
+
y: number;
|
|
247
|
+
vx: number;
|
|
248
|
+
vy: number;
|
|
249
|
+
/** rotation, degrees. */
|
|
250
|
+
rot: number;
|
|
251
|
+
/** angular velocity, deg/s. */
|
|
252
|
+
spin: number;
|
|
253
|
+
/** seconds since emit. */
|
|
254
|
+
age: number;
|
|
255
|
+
/** this particle's lifetime, seconds. */
|
|
256
|
+
life: number;
|
|
257
|
+
}
|
|
258
|
+
/** The per-slot authoring context handed to `appearance`. */
|
|
259
|
+
interface ParticleAppearanceContext {
|
|
260
|
+
/** Slot index, 0..count-1. */
|
|
261
|
+
i: number;
|
|
262
|
+
/** Slot pool size (== count). */
|
|
263
|
+
n: number;
|
|
264
|
+
/** Seeded per-slot generator (`each`'s `random(mix(seed, i))`). */
|
|
265
|
+
rng: Rng;
|
|
266
|
+
/** The resolved base seed. */
|
|
267
|
+
seed: number;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The appearance of one slot: a node, optionally with per-slot over-life curves
|
|
271
|
+
* (which override the spec-level defaults). The escape hatch — any Node subtree
|
|
272
|
+
* (a themed dot, a glyph Text, a small Group) works.
|
|
273
|
+
*/
|
|
274
|
+
interface ParticleAppearance {
|
|
275
|
+
node: Node;
|
|
276
|
+
opacityOverLife?: OverLife;
|
|
277
|
+
scaleOverLife?: OverLife;
|
|
278
|
+
}
|
|
279
|
+
interface ParticleSpec {
|
|
280
|
+
/** Stable id prefix — slots are `${id}/${i}`, the wrapping group is `${id}`. */
|
|
281
|
+
id: string;
|
|
282
|
+
/** MAX-CONCURRENT live-particle pool size (ring buffer). Bounded by MAX_PARTICLE_COUNT. */
|
|
283
|
+
count: number;
|
|
284
|
+
/** Seed for the physics rng; defaults to a stable `hashStr(id)`. Reseeded per call. */
|
|
285
|
+
seed?: number;
|
|
286
|
+
/** The pixel frame the RELATIVE origin resolves against (typically the scene size). */
|
|
287
|
+
box: {
|
|
288
|
+
w: number;
|
|
289
|
+
h: number;
|
|
290
|
+
};
|
|
291
|
+
/** Continuous emission (particles/sec). Supply this and/or `burst`. */
|
|
292
|
+
rate?: number;
|
|
293
|
+
/** Instantaneous emission — `n` particles at t=0, or timed bursts. */
|
|
294
|
+
burst?: number | readonly {
|
|
295
|
+
at: number;
|
|
296
|
+
n: number;
|
|
297
|
+
}[];
|
|
298
|
+
/** Per-particle lifetime, seconds — a scalar or a `[min,max]` range. */
|
|
299
|
+
lifetime: number | readonly [number, number];
|
|
300
|
+
/** Total sim seconds (the bake duration). */
|
|
301
|
+
duration: number;
|
|
302
|
+
/** Bake frame grid (match the render fps). */
|
|
303
|
+
fps: number;
|
|
304
|
+
/** Spawn point in RELATIVE viewport coords ([0.5,0.5]=center), resolved against `box`. */
|
|
305
|
+
origin: Place;
|
|
306
|
+
/** Optional spread around the origin (px). */
|
|
307
|
+
area?: AreaSpec;
|
|
308
|
+
/** Polar initial velocity — `speed` px/s, `angle` degrees (0 = +x / right). */
|
|
309
|
+
velocity: {
|
|
310
|
+
speed: readonly [number, number];
|
|
311
|
+
angle: readonly [number, number];
|
|
312
|
+
};
|
|
313
|
+
/** Constant forces. */
|
|
314
|
+
forces?: ParticleForces;
|
|
315
|
+
/** Optional angular-velocity range (deg/s) — emits a rotation channel when present. */
|
|
316
|
+
spin?: readonly [number, number];
|
|
317
|
+
/** Slot appearance — a Node, or `{ node, opacityOverLife?, scaleOverLife? }`. Escape hatch. */
|
|
318
|
+
appearance: (i: number, ctx: ParticleAppearanceContext) => Node | ParticleAppearance;
|
|
319
|
+
/** Spec-level opacity-over-life default (per-slot appearance wins). */
|
|
320
|
+
opacityOverLife?: OverLife;
|
|
321
|
+
/** Spec-level scale-over-life default — emits a scale channel when present. */
|
|
322
|
+
scaleOverLife?: OverLife;
|
|
323
|
+
/** ESCAPE HATCH: replace the built-in force integration with a raw per-particle step. */
|
|
324
|
+
step?: (p: ParticleState, dt: number, rng: Rng) => void;
|
|
325
|
+
}
|
|
326
|
+
interface ParticlesResult {
|
|
327
|
+
/** The wrapping group (`id`) holding every VISIBLE slot node. Draw THIS. */
|
|
328
|
+
node: Group;
|
|
329
|
+
/** The baked position/opacity/(scale/rotation) tracks — inject with `tl.tracks(...)`. */
|
|
330
|
+
tracks: Track[];
|
|
331
|
+
/** The sim end (== duration). */
|
|
332
|
+
end: number;
|
|
333
|
+
}
|
|
334
|
+
/** Thrown for a mis-built emitter (fail loud, never a silent no-op / clamp). */
|
|
335
|
+
declare class ParticleError extends Error {
|
|
336
|
+
constructor(message: string);
|
|
337
|
+
}
|
|
338
|
+
declare function particles(spec: ParticleSpec): ParticlesResult;
|
|
339
|
+
/** Overridable ParticleSpec fields common to the presets (the `...rest` escape hatch). */
|
|
340
|
+
interface ParticlePresetRest {
|
|
341
|
+
seed?: number;
|
|
342
|
+
lifetime?: number | readonly [number, number];
|
|
343
|
+
velocity?: {
|
|
344
|
+
speed: readonly [number, number];
|
|
345
|
+
angle: readonly [number, number];
|
|
346
|
+
};
|
|
347
|
+
forces?: ParticleForces;
|
|
348
|
+
spin?: readonly [number, number];
|
|
349
|
+
area?: AreaSpec;
|
|
350
|
+
opacityOverLife?: OverLife;
|
|
351
|
+
scaleOverLife?: OverLife;
|
|
352
|
+
appearance?: (i: number, ctx: ParticleAppearanceContext) => Node | ParticleAppearance;
|
|
353
|
+
step?: (p: ParticleState, dt: number, rng: Rng) => void;
|
|
354
|
+
}
|
|
355
|
+
interface DriftOptions extends ParticlePresetRest {
|
|
356
|
+
box: {
|
|
357
|
+
w: number;
|
|
358
|
+
h: number;
|
|
359
|
+
};
|
|
360
|
+
duration: number;
|
|
361
|
+
fps: number;
|
|
362
|
+
/** Max-concurrent motes (default 24 — a corporate-safe low density, NOT 200). */
|
|
363
|
+
count?: number;
|
|
364
|
+
/** Continuous emission rate, particles/sec (default 8). */
|
|
365
|
+
rate?: number;
|
|
366
|
+
/** Spawn point, relative viewport coords (default centered-lower [0.5,0.6]). */
|
|
367
|
+
origin?: Place;
|
|
368
|
+
/** Themed mote color (default a soft blue). */
|
|
369
|
+
color?: string;
|
|
370
|
+
/** Mote radius px (default 2.5). */
|
|
371
|
+
radius?: number;
|
|
372
|
+
/** Id prefix (default 'drift'). */
|
|
373
|
+
id?: string;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* `drift` — ambient low-opacity motes slowly floating up, complementing a bokeh
|
|
377
|
+
* background. Continuous low-rate emission; DEFAULTS to a small max-concurrent
|
|
378
|
+
* count so the exported layer count stays proportional to the live particles.
|
|
379
|
+
*/
|
|
380
|
+
declare function drift(opts: DriftOptions): ParticlesResult;
|
|
381
|
+
interface SparksOptions extends ParticlePresetRest {
|
|
382
|
+
box: {
|
|
383
|
+
w: number;
|
|
384
|
+
h: number;
|
|
385
|
+
};
|
|
386
|
+
duration: number;
|
|
387
|
+
fps: number;
|
|
388
|
+
/** Max-concurrent (== burst) count (default 20). */
|
|
389
|
+
count?: number;
|
|
390
|
+
/** Beat second the burst fires at (default 0). */
|
|
391
|
+
at?: number;
|
|
392
|
+
/** Themed spark color (default a warm amber). */
|
|
393
|
+
color?: string;
|
|
394
|
+
/** Spark radius px (default 2.5). */
|
|
395
|
+
radius?: number;
|
|
396
|
+
/** Id prefix (default 'sparks'). */
|
|
397
|
+
id?: string;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* `sparks` — a subtle, corporate-safe radial impact burst (a win-beat / habit-stamp
|
|
401
|
+
* flourish): short-life dots thrown outward from `origin`, shrinking + fading with
|
|
402
|
+
* a touch of gravity. LOW density by default.
|
|
403
|
+
*/
|
|
404
|
+
declare function sparks(origin: Place, opts: SparksOptions): ParticlesResult;
|
|
405
|
+
interface DispenseOptions extends SparksOptions {
|
|
406
|
+
/** Emission direction, degrees (default 90 = downward, the vending "drop"). */
|
|
407
|
+
angle?: number;
|
|
408
|
+
/** Half-spread around the direction, degrees (default 32). */
|
|
409
|
+
spread?: number;
|
|
410
|
+
/** A themed GLYPH character to sparkle instead of a dot (e.g. '✦', '★'). */
|
|
411
|
+
glyph?: string;
|
|
412
|
+
/** Glyph font size px (default 14). */
|
|
413
|
+
glyphSize?: number;
|
|
414
|
+
/** Glyph font family (default 'DejaVu Sans'). */
|
|
415
|
+
glyphFamily?: string;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* `dispense` — a directional `sparks` variant: a small themed sparkle emanating in
|
|
419
|
+
* one direction at a beat (the vending "AS ASKED" flourish ON the drop moment, not
|
|
420
|
+
* a continuous stream). Directional angle bias + an optional glyph node-template.
|
|
421
|
+
*/
|
|
422
|
+
declare function dispense(origin: Place, opts: DispenseOptions): ParticlesResult;
|
|
423
|
+
//#endregion
|
|
424
|
+
export { type AreaSpec, Camera, CameraError, type CameraLayer, type CameraProps, type DispenseOptions, type DriftOptions, FollowPath, type FollowPathProps, LookAt, type LookAtProps, MAX_PARTICLE_COUNT, OrientToPath, type OrientToPathProps, type OverLife, type ParticleAppearance, type ParticleAppearanceContext, ParticleError, type ParticleForces, type ParticlePresetRest, type ParticleSpec, type ParticleState, type ParticlesResult, type PathSampler, type ShakeSpec, type SparksOptions, camera, cameraLayerMatrix, dispense, drift, followPath, lookAt, motionPath, orientToPath, particles, pathLength, pointAtLength, shake, shakeOffset, shakenSpec, sparks };
|
package/dist/motion.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { $ as multiply, C as Node, X as fromTRS, r as Group } from "./nodes.js";
|
|
1
|
+
import { $ as multiply, C as Node, X as fromTRS, _ as hashStr, r as Group, s as Text, t as Circle } from "./nodes.js";
|
|
2
2
|
import { a as FollowPath, c as pathLength, i as orientToPath, l as pointAtLength, n as OrientToPath, o as followPath, r as lookAt, s as motionPath, t as LookAt } from "./orient.js";
|
|
3
|
-
import {
|
|
3
|
+
import { n as each } from "./each.js";
|
|
4
|
+
import { bake, signal, valueNoise, vec2Signal } from "@glissade/core";
|
|
4
5
|
//#region src/shake.ts
|
|
5
6
|
/**
|
|
6
7
|
* `shake` (0.55) — a standalone jitter driver that wobbles ANY node's pose with
|
|
@@ -284,4 +285,396 @@ function camera(layers, props = {}) {
|
|
|
284
285
|
return new Camera(layers, props);
|
|
285
286
|
}
|
|
286
287
|
//#endregion
|
|
287
|
-
|
|
288
|
+
//#region src/particles.ts
|
|
289
|
+
/**
|
|
290
|
+
* `particles()` (0.57) — a small, seeded, baked particle emitter. It is a COMPOSE
|
|
291
|
+
* of two already-shipped primitives, NOT new engine code:
|
|
292
|
+
*
|
|
293
|
+
* - `each()` makes `count` fixed slot nodes at stable ids `${id}/${i}` (the
|
|
294
|
+
* appearance layer — a themed dot/glyph per slot, with a seeded per-slot rng).
|
|
295
|
+
* - `bake()` simulates the seeded physics ONCE at a fixed dt and emits ordinary
|
|
296
|
+
* frame-indexed position/opacity/(scale/rotation) Tracks targeting those SAME
|
|
297
|
+
* slot ids.
|
|
298
|
+
*
|
|
299
|
+
* Every slot → a real node → real tracks → a real exportable Lottie layer:
|
|
300
|
+
* interchange is faithful BY CONSTRUCTION (there is no render-only / custom-draw
|
|
301
|
+
* path to silently drop — the 0.55 camera/echo trap avoided up front). Because
|
|
302
|
+
* bake reseeds its rng fresh per call and never touches Date/Math.random, the
|
|
303
|
+
* emitted tracks are byte-identical run-to-run and the goldens hold by
|
|
304
|
+
* construction; a DIFFERENT seed genuinely varies the output.
|
|
305
|
+
*
|
|
306
|
+
* `count` is the MAX-CONCURRENT live-particle pool size, NOT total emitted. Slots
|
|
307
|
+
* are a deterministic RING BUFFER: the emitIndex-th particle lands in slot
|
|
308
|
+
* `emitIndex % count`, reuse overwriting the oldest. A slot is opacity-0 before
|
|
309
|
+
* its particle's emit time and after its lifetime ends, and any slot that is
|
|
310
|
+
* opacity-0 for the ENTIRE sim window is PRUNED from the output — so a low-density
|
|
311
|
+
* drift exports a layer count proportional to its live particles, not `count`
|
|
312
|
+
* near-empty layers.
|
|
313
|
+
*
|
|
314
|
+
* Baked-only (v1): there is no GPU / unbounded / render-only mode. `count` is hard-
|
|
315
|
+
* capped at {@link MAX_PARTICLE_COUNT}; going over THROWS (never a silent clamp).
|
|
316
|
+
*
|
|
317
|
+
* Lives on `@glissade/scene/motion` (off the sacred base embed), factory-no-`new`.
|
|
318
|
+
*/
|
|
319
|
+
/** Hard cap on the slot pool. `count` over this THROWS (never silent-clamps). */
|
|
320
|
+
const MAX_PARTICLE_COUNT = 200;
|
|
321
|
+
const DEG = Math.PI / 180;
|
|
322
|
+
const clamp01 = (x) => x < 0 ? 0 : x > 1 ? 1 : x;
|
|
323
|
+
const lerp = (a, b, u) => a + (b - a) * u;
|
|
324
|
+
/** Thrown for a mis-built emitter (fail loud, never a silent no-op / clamp). */
|
|
325
|
+
var ParticleError = class extends Error {
|
|
326
|
+
constructor(message) {
|
|
327
|
+
super(message);
|
|
328
|
+
this.name = "ParticleError";
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
/** The default gentle fade: ramp in, hold at `peak`, ramp out. */
|
|
332
|
+
function fadeCurve(peak, fin, fout) {
|
|
333
|
+
return (u) => {
|
|
334
|
+
if (u < fin) return peak * (u / fin);
|
|
335
|
+
if (u > 1 - fout) return peak * ((1 - u) / fout);
|
|
336
|
+
return peak;
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
const DEFAULT_OPACITY = fadeCurve(1, .15, .3);
|
|
340
|
+
function assertFiniteNum(v, what) {
|
|
341
|
+
if (typeof v !== "number" || !Number.isFinite(v)) throw new ParticleError(`particles(): ${what} must be a finite number (got ${String(v)}).`);
|
|
342
|
+
return v;
|
|
343
|
+
}
|
|
344
|
+
function assertPositiveRange(r, what) {
|
|
345
|
+
assertFiniteNum(r[0], `${what}[0]`);
|
|
346
|
+
assertFiniteNum(r[1], `${what}[1]`);
|
|
347
|
+
}
|
|
348
|
+
/** Extract a Node + optional per-slot curves from an `appearance` return, or throw. */
|
|
349
|
+
function normalizeAppearance(raw, i) {
|
|
350
|
+
if (raw instanceof Node) return { node: raw };
|
|
351
|
+
if (raw != null && typeof raw === "object" && raw.node instanceof Node) return raw;
|
|
352
|
+
throw new ParticleError(`particles(): appearance(${i}) must return a Node (or { node: Node, opacityOverLife?, scaleOverLife? }) — got ${typeof raw}.`);
|
|
353
|
+
}
|
|
354
|
+
/** Build the sorted emission-time schedule (one entry per particle, in emit order). */
|
|
355
|
+
function buildEmitTimes(spec) {
|
|
356
|
+
const times = [];
|
|
357
|
+
if (spec.burst !== void 0) if (typeof spec.burst === "number") {
|
|
358
|
+
const n = spec.burst;
|
|
359
|
+
if (!Number.isInteger(n) || n < 1) throw new ParticleError(`particles(): burst count must be a positive integer (got ${String(n)}).`);
|
|
360
|
+
for (let k = 0; k < n; k++) times.push(0);
|
|
361
|
+
} else for (const b of spec.burst) {
|
|
362
|
+
assertFiniteNum(b?.at, "burst.at");
|
|
363
|
+
if (!Number.isInteger(b.n) || b.n < 1) throw new ParticleError(`particles(): burst.n must be a positive integer (got ${String(b?.n)}).`);
|
|
364
|
+
if (b.at < 0) throw new ParticleError(`particles(): burst.at must be >= 0 (got ${b.at}).`);
|
|
365
|
+
for (let k = 0; k < b.n; k++) times.push(b.at);
|
|
366
|
+
}
|
|
367
|
+
if (spec.rate !== void 0) {
|
|
368
|
+
const rate = spec.rate;
|
|
369
|
+
if (!Number.isFinite(rate) || rate <= 0) throw new ParticleError(`particles(): rate must be a finite number > 0 (got ${String(rate)}).`);
|
|
370
|
+
for (let k = 0; k / rate < spec.duration; k++) times.push(k / rate);
|
|
371
|
+
}
|
|
372
|
+
times.sort((a, b) => a - b);
|
|
373
|
+
return times;
|
|
374
|
+
}
|
|
375
|
+
function particles(spec) {
|
|
376
|
+
if (typeof spec.id !== "string" || spec.id.length === 0) throw new ParticleError("particles(): id must be a non-empty string (slots bind tracks against `${id}/${i}`).");
|
|
377
|
+
const { count } = spec;
|
|
378
|
+
if (!Number.isInteger(count) || count <= 0) throw new ParticleError(`particles(): count must be a positive integer (got ${String(count)}).`);
|
|
379
|
+
if (count > 200) throw new ParticleError(`particles(): count ${count} exceeds max 200.`);
|
|
380
|
+
const seed = (spec.seed ?? hashStr(spec.id)) >>> 0;
|
|
381
|
+
if (spec.seed !== void 0 && !Number.isFinite(spec.seed)) throw new ParticleError(`particles(): seed must be a finite number (got ${String(spec.seed)}).`);
|
|
382
|
+
if (!Number.isFinite(spec.duration) || spec.duration <= 0) throw new ParticleError(`particles(): duration must be a finite number > 0 (got ${String(spec.duration)}).`);
|
|
383
|
+
if (!Number.isFinite(spec.fps) || spec.fps <= 0) throw new ParticleError(`particles(): fps must be a finite number > 0 (got ${String(spec.fps)}).`);
|
|
384
|
+
if (spec.rate === void 0 && spec.burst === void 0) throw new ParticleError("particles(): supply `rate` and/or `burst` — an emitter with neither emits nothing.");
|
|
385
|
+
if (!spec.box || !(spec.box.w > 0) || !(spec.box.h > 0)) throw new ParticleError(`particles(): box must be { w > 0, h > 0 } (got ${JSON.stringify(spec.box)}).`);
|
|
386
|
+
if (!Array.isArray(spec.origin) || spec.origin.length !== 2) throw new ParticleError("particles(): origin must be a relative [fx, fy] place.");
|
|
387
|
+
assertFiniteNum(spec.origin[0], "origin[0]");
|
|
388
|
+
assertFiniteNum(spec.origin[1], "origin[1]");
|
|
389
|
+
if (typeof spec.appearance !== "function") throw new ParticleError("particles(): appearance must be a function (i, ctx) => Node | { node, ... }.");
|
|
390
|
+
const lifeIsRange = Array.isArray(spec.lifetime);
|
|
391
|
+
if (lifeIsRange) {
|
|
392
|
+
const r = spec.lifetime;
|
|
393
|
+
assertPositiveRange(r, "lifetime");
|
|
394
|
+
if (r[0] <= 0 || r[1] <= 0) throw new ParticleError(`particles(): lifetime range must be > 0 (got ${JSON.stringify(r)}).`);
|
|
395
|
+
} else {
|
|
396
|
+
const l = assertFiniteNum(spec.lifetime, "lifetime");
|
|
397
|
+
if (l <= 0) throw new ParticleError(`particles(): lifetime must be > 0 (got ${l}).`);
|
|
398
|
+
}
|
|
399
|
+
assertPositiveRange(spec.velocity.speed, "velocity.speed");
|
|
400
|
+
assertPositiveRange(spec.velocity.angle, "velocity.angle");
|
|
401
|
+
if (spec.spin !== void 0) assertPositiveRange(spec.spin, "spin");
|
|
402
|
+
if (spec.area?.kind === "box") {
|
|
403
|
+
assertFiniteNum(spec.area.w, "area.w");
|
|
404
|
+
assertFiniteNum(spec.area.h, "area.h");
|
|
405
|
+
} else if (spec.area?.kind === "disc") assertFiniteNum(spec.area.radius, "area.radius");
|
|
406
|
+
const emitTimes = buildEmitTimes(spec);
|
|
407
|
+
const opacityCurves = new Array(count);
|
|
408
|
+
const scaleCurves = new Array(count);
|
|
409
|
+
const built = each(count, (i, ctx) => {
|
|
410
|
+
const ap = normalizeAppearance(spec.appearance(i, {
|
|
411
|
+
i,
|
|
412
|
+
n: count,
|
|
413
|
+
rng: ctx.rng,
|
|
414
|
+
seed: ctx.seed
|
|
415
|
+
}), i);
|
|
416
|
+
opacityCurves[i] = ap.opacityOverLife ?? spec.opacityOverLife ?? DEFAULT_OPACITY;
|
|
417
|
+
scaleCurves[i] = ap.scaleOverLife ?? spec.scaleOverLife;
|
|
418
|
+
return ap.node;
|
|
419
|
+
}, {
|
|
420
|
+
id: spec.id,
|
|
421
|
+
layout: () => [0, 0],
|
|
422
|
+
seed
|
|
423
|
+
});
|
|
424
|
+
const emitScale = scaleCurves.some((c) => c !== void 0) || spec.scaleOverLife !== void 0;
|
|
425
|
+
const emitRot = spec.spin !== void 0;
|
|
426
|
+
const slotId = (i) => `${spec.id}/${i}`;
|
|
427
|
+
const [vsMin, vsMax] = spec.velocity.speed;
|
|
428
|
+
const [vaMin, vaMax] = spec.velocity.angle;
|
|
429
|
+
const spinMin = spec.spin?.[0] ?? 0;
|
|
430
|
+
const spinMax = spec.spin?.[1] ?? 0;
|
|
431
|
+
const gravity = spec.forces?.gravity ?? 0;
|
|
432
|
+
const drag = spec.forces?.drag ?? 0;
|
|
433
|
+
const [windX, windY] = spec.forces?.wind ?? [0, 0];
|
|
434
|
+
const ox = spec.origin[0] * spec.box.w;
|
|
435
|
+
const oy = spec.origin[1] * spec.box.h;
|
|
436
|
+
const area = spec.area;
|
|
437
|
+
const emitDue = (w, rng) => {
|
|
438
|
+
const t = w.frame / spec.fps;
|
|
439
|
+
while (w.next < emitTimes.length && emitTimes[w.next] <= t + 1e-9) {
|
|
440
|
+
const slot = w.next++ % count;
|
|
441
|
+
const s = w.slots[slot];
|
|
442
|
+
const speed = lerp(vsMin, vsMax, rng());
|
|
443
|
+
const ang = lerp(vaMin, vaMax, rng()) * DEG;
|
|
444
|
+
s.vx = Math.cos(ang) * speed;
|
|
445
|
+
s.vy = Math.sin(ang) * speed;
|
|
446
|
+
s.life = lifeIsRange ? lerp(spec.lifetime[0], spec.lifetime[1], rng()) : spec.lifetime;
|
|
447
|
+
let px = ox;
|
|
448
|
+
let py = oy;
|
|
449
|
+
if (area?.kind === "box") {
|
|
450
|
+
px += (rng() * 2 - 1) * (area.w / 2);
|
|
451
|
+
py += (rng() * 2 - 1) * (area.h / 2);
|
|
452
|
+
} else if (area?.kind === "disc") {
|
|
453
|
+
const rr = area.radius * Math.sqrt(rng());
|
|
454
|
+
const th = rng() * Math.PI * 2;
|
|
455
|
+
px += Math.cos(th) * rr;
|
|
456
|
+
py += Math.sin(th) * rr;
|
|
457
|
+
}
|
|
458
|
+
s.x = px;
|
|
459
|
+
s.y = py;
|
|
460
|
+
s.rot = 0;
|
|
461
|
+
s.spin = emitRot ? lerp(spinMin, spinMax, rng()) : 0;
|
|
462
|
+
s.age = 0;
|
|
463
|
+
w.alive[slot] = true;
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
const rawTracks = bake({
|
|
467
|
+
duration: spec.duration,
|
|
468
|
+
fps: spec.fps,
|
|
469
|
+
seed,
|
|
470
|
+
setup: (rng) => {
|
|
471
|
+
const w = {
|
|
472
|
+
frame: 0,
|
|
473
|
+
next: 0,
|
|
474
|
+
slots: Array.from({ length: count }, () => ({
|
|
475
|
+
x: 0,
|
|
476
|
+
y: 0,
|
|
477
|
+
vx: 0,
|
|
478
|
+
vy: 0,
|
|
479
|
+
rot: 0,
|
|
480
|
+
spin: 0,
|
|
481
|
+
age: 0,
|
|
482
|
+
life: 1
|
|
483
|
+
})),
|
|
484
|
+
alive: new Array(count).fill(false)
|
|
485
|
+
};
|
|
486
|
+
emitDue(w, rng);
|
|
487
|
+
return w;
|
|
488
|
+
},
|
|
489
|
+
step: (w, dt, rng) => {
|
|
490
|
+
for (let i = 0; i < count; i++) {
|
|
491
|
+
if (!w.alive[i]) continue;
|
|
492
|
+
const s = w.slots[i];
|
|
493
|
+
if (spec.step) spec.step(s, dt, rng);
|
|
494
|
+
else {
|
|
495
|
+
s.vx += windX * dt;
|
|
496
|
+
s.vy += (gravity + windY) * dt;
|
|
497
|
+
if (drag !== 0) {
|
|
498
|
+
const k = Math.max(0, 1 - drag * dt);
|
|
499
|
+
s.vx *= k;
|
|
500
|
+
s.vy *= k;
|
|
501
|
+
}
|
|
502
|
+
s.x += s.vx * dt;
|
|
503
|
+
s.y += s.vy * dt;
|
|
504
|
+
s.rot += s.spin * dt;
|
|
505
|
+
}
|
|
506
|
+
s.age += dt;
|
|
507
|
+
if (s.age >= s.life) w.alive[i] = false;
|
|
508
|
+
}
|
|
509
|
+
w.frame++;
|
|
510
|
+
emitDue(w, rng);
|
|
511
|
+
},
|
|
512
|
+
sample: (w) => {
|
|
513
|
+
const out = {};
|
|
514
|
+
for (let i = 0; i < count; i++) {
|
|
515
|
+
const s = w.slots[i];
|
|
516
|
+
const id = slotId(i);
|
|
517
|
+
const live = w.alive[i];
|
|
518
|
+
const u = live && s.life > 0 ? clamp01(s.age / s.life) : 0;
|
|
519
|
+
out[`${id}/position`] = [s.x, s.y];
|
|
520
|
+
out[`${id}/opacity`] = live ? clamp01(opacityCurves[i](u)) : 0;
|
|
521
|
+
if (emitScale) {
|
|
522
|
+
const k = live ? (scaleCurves[i] ?? spec.scaleOverLife ?? (() => 1))(u) : 1;
|
|
523
|
+
out[`${id}/scale`] = [k, k];
|
|
524
|
+
}
|
|
525
|
+
if (emitRot) out[`${id}/rotation`] = s.rot;
|
|
526
|
+
}
|
|
527
|
+
return out;
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
const byTarget = new Map(rawTracks.map((t) => [t.target, t]));
|
|
531
|
+
const visible = [];
|
|
532
|
+
for (let i = 0; i < count; i++) {
|
|
533
|
+
const op = byTarget.get(`${slotId(i)}/opacity`);
|
|
534
|
+
if (op && op.keys.some((k) => typeof k.value === "number" && k.value > 0)) visible.push(i);
|
|
535
|
+
}
|
|
536
|
+
const keep = new Set(visible.map((i) => slotId(i)));
|
|
537
|
+
const tracks = rawTracks.filter((t) => keep.has(t.target.slice(0, t.target.lastIndexOf("/"))));
|
|
538
|
+
const children = visible.map((i) => built.children[i]);
|
|
539
|
+
return {
|
|
540
|
+
node: new Group({
|
|
541
|
+
id: spec.id,
|
|
542
|
+
children
|
|
543
|
+
}),
|
|
544
|
+
tracks,
|
|
545
|
+
end: spec.duration
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
/** Merge the preset defaults with the caller's `...rest`, spreading conditionally
|
|
549
|
+
* (exactOptionalPropertyTypes: never pass `undefined`). */
|
|
550
|
+
function mergeSpec(base, rest) {
|
|
551
|
+
return {
|
|
552
|
+
...base,
|
|
553
|
+
...rest.seed !== void 0 ? { seed: rest.seed } : {},
|
|
554
|
+
...rest.lifetime !== void 0 ? { lifetime: rest.lifetime } : {},
|
|
555
|
+
...rest.velocity !== void 0 ? { velocity: rest.velocity } : {},
|
|
556
|
+
...rest.forces !== void 0 ? { forces: rest.forces } : {},
|
|
557
|
+
...rest.spin !== void 0 ? { spin: rest.spin } : {},
|
|
558
|
+
...rest.area !== void 0 ? { area: rest.area } : {},
|
|
559
|
+
...rest.opacityOverLife !== void 0 ? { opacityOverLife: rest.opacityOverLife } : {},
|
|
560
|
+
...rest.scaleOverLife !== void 0 ? { scaleOverLife: rest.scaleOverLife } : {},
|
|
561
|
+
...rest.appearance !== void 0 ? { appearance: rest.appearance } : {},
|
|
562
|
+
...rest.step !== void 0 ? { step: rest.step } : {}
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
/** A themed soft dot appearance — the default node-template for drift/sparks. */
|
|
566
|
+
function dotAppearance(color, radius) {
|
|
567
|
+
return () => new Circle({
|
|
568
|
+
radius,
|
|
569
|
+
fill: color
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* `drift` — ambient low-opacity motes slowly floating up, complementing a bokeh
|
|
574
|
+
* background. Continuous low-rate emission; DEFAULTS to a small max-concurrent
|
|
575
|
+
* count so the exported layer count stays proportional to the live particles.
|
|
576
|
+
*/
|
|
577
|
+
function drift(opts) {
|
|
578
|
+
const color = opts.color ?? "#9ec4ff";
|
|
579
|
+
const radius = opts.radius ?? 2.5;
|
|
580
|
+
return particles(mergeSpec({
|
|
581
|
+
id: opts.id ?? "drift",
|
|
582
|
+
count: opts.count ?? 24,
|
|
583
|
+
box: opts.box,
|
|
584
|
+
duration: opts.duration,
|
|
585
|
+
fps: opts.fps,
|
|
586
|
+
rate: opts.rate ?? 8,
|
|
587
|
+
origin: opts.origin ?? [.5, .6],
|
|
588
|
+
lifetime: [3, 6],
|
|
589
|
+
velocity: {
|
|
590
|
+
speed: [4, 14],
|
|
591
|
+
angle: [-110, -70]
|
|
592
|
+
},
|
|
593
|
+
forces: { drag: .2 },
|
|
594
|
+
area: {
|
|
595
|
+
kind: "box",
|
|
596
|
+
w: opts.box.w * .8,
|
|
597
|
+
h: opts.box.h * .5
|
|
598
|
+
},
|
|
599
|
+
opacityOverLife: fadeCurve(.5, .2, .35),
|
|
600
|
+
appearance: dotAppearance(color, radius)
|
|
601
|
+
}, opts));
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* `sparks` — a subtle, corporate-safe radial impact burst (a win-beat / habit-stamp
|
|
605
|
+
* flourish): short-life dots thrown outward from `origin`, shrinking + fading with
|
|
606
|
+
* a touch of gravity. LOW density by default.
|
|
607
|
+
*/
|
|
608
|
+
function sparks(origin, opts) {
|
|
609
|
+
const color = opts.color ?? "#ffd27f";
|
|
610
|
+
const radius = opts.radius ?? 2.5;
|
|
611
|
+
const count = opts.count ?? 20;
|
|
612
|
+
return particles(mergeSpec({
|
|
613
|
+
id: opts.id ?? "sparks",
|
|
614
|
+
count,
|
|
615
|
+
box: opts.box,
|
|
616
|
+
duration: opts.duration,
|
|
617
|
+
fps: opts.fps,
|
|
618
|
+
burst: [{
|
|
619
|
+
at: opts.at ?? 0,
|
|
620
|
+
n: count
|
|
621
|
+
}],
|
|
622
|
+
origin,
|
|
623
|
+
lifetime: [.4, .9],
|
|
624
|
+
velocity: {
|
|
625
|
+
speed: [80, 220],
|
|
626
|
+
angle: [0, 360]
|
|
627
|
+
},
|
|
628
|
+
forces: {
|
|
629
|
+
gravity: 140,
|
|
630
|
+
drag: .6
|
|
631
|
+
},
|
|
632
|
+
opacityOverLife: fadeCurve(1, .05, .5),
|
|
633
|
+
scaleOverLife: (u) => 1 - .6 * u,
|
|
634
|
+
appearance: dotAppearance(color, radius)
|
|
635
|
+
}, opts));
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* `dispense` — a directional `sparks` variant: a small themed sparkle emanating in
|
|
639
|
+
* one direction at a beat (the vending "AS ASKED" flourish ON the drop moment, not
|
|
640
|
+
* a continuous stream). Directional angle bias + an optional glyph node-template.
|
|
641
|
+
*/
|
|
642
|
+
function dispense(origin, opts) {
|
|
643
|
+
const dir = opts.angle ?? 90;
|
|
644
|
+
const spread = opts.spread ?? 32;
|
|
645
|
+
const color = opts.color ?? "#ffe6a3";
|
|
646
|
+
const count = opts.count ?? 14;
|
|
647
|
+
const glyphAppearance = opts.glyph !== void 0 ? () => new Text({
|
|
648
|
+
text: opts.glyph,
|
|
649
|
+
fill: color,
|
|
650
|
+
fontFamily: opts.glyphFamily ?? "DejaVu Sans",
|
|
651
|
+
fontSize: opts.glyphSize ?? 14,
|
|
652
|
+
align: "center"
|
|
653
|
+
}) : void 0;
|
|
654
|
+
return particles(mergeSpec({
|
|
655
|
+
id: opts.id ?? "dispense",
|
|
656
|
+
count,
|
|
657
|
+
box: opts.box,
|
|
658
|
+
duration: opts.duration,
|
|
659
|
+
fps: opts.fps,
|
|
660
|
+
burst: [{
|
|
661
|
+
at: opts.at ?? 0,
|
|
662
|
+
n: count
|
|
663
|
+
}],
|
|
664
|
+
origin,
|
|
665
|
+
lifetime: [.5, 1],
|
|
666
|
+
velocity: {
|
|
667
|
+
speed: [70, 170],
|
|
668
|
+
angle: [dir - spread, dir + spread]
|
|
669
|
+
},
|
|
670
|
+
forces: {
|
|
671
|
+
gravity: 90,
|
|
672
|
+
drag: .5
|
|
673
|
+
},
|
|
674
|
+
opacityOverLife: fadeCurve(1, .06, .5),
|
|
675
|
+
scaleOverLife: (u) => 1 - .5 * u,
|
|
676
|
+
appearance: glyphAppearance ?? dotAppearance(color, opts.radius ?? 2.5)
|
|
677
|
+
}, opts));
|
|
678
|
+
}
|
|
679
|
+
//#endregion
|
|
680
|
+
export { Camera, CameraError, FollowPath, LookAt, MAX_PARTICLE_COUNT, OrientToPath, ParticleError, camera, cameraLayerMatrix, dispense, drift, followPath, lookAt, motionPath, orientToPath, particles, pathLength, pointAtLength, shake, shakeOffset, shakenSpec, sparks };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glissade/scene",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.57.0-pre.0",
|
|
4
4
|
"description": "glissade scene graph: nodes, transforms, DisplayList emission. Renderer-agnostic; zero DOM/Node dependencies.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"engines": {
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
],
|
|
78
78
|
"dependencies": {
|
|
79
79
|
"yoga-layout": "^3.2.1",
|
|
80
|
-
"@glissade/core": "0.
|
|
80
|
+
"@glissade/core": "0.57.0-pre.0"
|
|
81
81
|
},
|
|
82
82
|
"repository": {
|
|
83
83
|
"type": "git",
|