effect-motion 0.2.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.
Files changed (84) hide show
  1. package/README.md +40 -0
  2. package/dist/Camera.d.ts +49 -0
  3. package/dist/Camera.js +33 -0
  4. package/dist/Entity.d.ts +40 -0
  5. package/dist/Entity.js +32 -0
  6. package/dist/Fonts.d.ts +31 -0
  7. package/dist/Fonts.js +11 -0
  8. package/dist/Instance.d.ts +16 -0
  9. package/dist/Instance.js +18 -0
  10. package/dist/Motion.d.ts +74 -0
  11. package/dist/Motion.js +125 -0
  12. package/dist/Phaser.d.ts +65 -0
  13. package/dist/Phaser.js +170 -0
  14. package/dist/Physics.d.ts +78 -0
  15. package/dist/Physics.js +117 -0
  16. package/dist/Renderer.d.ts +69 -0
  17. package/dist/Renderer.js +90 -0
  18. package/dist/Runner.d.ts +360 -0
  19. package/dist/Runner.js +257 -0
  20. package/dist/Scene.d.ts +241 -0
  21. package/dist/Scene.js +454 -0
  22. package/dist/Time.d.ts +38 -0
  23. package/dist/Time.js +43 -0
  24. package/dist/Timing.d.ts +96 -0
  25. package/dist/Timing.js +151 -0
  26. package/dist/demo.d.ts +186 -0
  27. package/dist/demo.js +76 -0
  28. package/dist/index.d.ts +13 -0
  29. package/dist/index.js +13 -0
  30. package/dist/particles/Particle.d.ts +91 -0
  31. package/dist/particles/Particle.js +1 -0
  32. package/dist/particles/ParticleField.d.ts +170 -0
  33. package/dist/particles/ParticleField.js +69 -0
  34. package/dist/particles/Prng.d.ts +28 -0
  35. package/dist/particles/Prng.js +43 -0
  36. package/dist/particles/constructors.d.ts +78 -0
  37. package/dist/particles/constructors.js +15 -0
  38. package/dist/particles/index.d.ts +6 -0
  39. package/dist/particles/index.js +6 -0
  40. package/dist/particles/overLife.d.ts +16 -0
  41. package/dist/particles/overLife.js +21 -0
  42. package/dist/particles/render.d.ts +13 -0
  43. package/dist/particles/render.js +33 -0
  44. package/dist/particles/simulate.d.ts +39 -0
  45. package/dist/particles/simulate.js +81 -0
  46. package/dist/particles/step.d.ts +24 -0
  47. package/dist/particles/step.js +167 -0
  48. package/dist/shapes/Circle.d.ts +37 -0
  49. package/dist/shapes/Circle.js +9 -0
  50. package/dist/shapes/Ellipse.d.ts +41 -0
  51. package/dist/shapes/Ellipse.js +10 -0
  52. package/dist/shapes/Group.d.ts +150 -0
  53. package/dist/shapes/Group.js +82 -0
  54. package/dist/shapes/Layer.d.ts +9 -0
  55. package/dist/shapes/Layer.js +23 -0
  56. package/dist/shapes/Line.d.ts +47 -0
  57. package/dist/shapes/Line.js +30 -0
  58. package/dist/shapes/Path.d.ts +38 -0
  59. package/dist/shapes/Path.js +13 -0
  60. package/dist/shapes/Rect.d.ts +41 -0
  61. package/dist/shapes/Rect.js +10 -0
  62. package/dist/shapes/Shape2D.d.ts +40 -0
  63. package/dist/shapes/Shape2D.js +41 -0
  64. package/dist/shapes/Square.d.ts +37 -0
  65. package/dist/shapes/Square.js +11 -0
  66. package/dist/shapes/Text.d.ts +60 -0
  67. package/dist/shapes/Text.js +24 -0
  68. package/dist/shapes/index.d.ts +10 -0
  69. package/dist/shapes/index.js +10 -0
  70. package/dist/svg/SvgDomRenderer.d.ts +28 -0
  71. package/dist/svg/SvgDomRenderer.js +50 -0
  72. package/dist/svg/SvgNode.d.ts +13 -0
  73. package/dist/svg/SvgNode.js +18 -0
  74. package/dist/svg/SvgRenderer.d.ts +22 -0
  75. package/dist/svg/SvgRenderer.js +20 -0
  76. package/dist/svg/camera.d.ts +21 -0
  77. package/dist/svg/camera.js +43 -0
  78. package/dist/svg/index.d.ts +6 -0
  79. package/dist/svg/index.js +6 -0
  80. package/dist/svg/layers.d.ts +18 -0
  81. package/dist/svg/layers.js +13 -0
  82. package/dist/svg/shapes.d.ts +1089 -0
  83. package/dist/svg/shapes.js +119 -0
  84. package/package.json +51 -0
@@ -0,0 +1,170 @@
1
+ import * as Schema from "effect/Schema";
2
+ import * as Entity from "../Entity";
3
+ export declare const ParticleField: Entity.Entity<"particles/ParticleField", Schema.Struct<{
4
+ x: Schema.withConstructorDefault<Schema.Number>;
5
+ y: Schema.withConstructorDefault<Schema.Number>;
6
+ opacity: Schema.withConstructorDefault<Schema.Number>;
7
+ speed: Schema.withConstructorDefault<Schema.Tuple<readonly [Schema.Number, Schema.Number]>>;
8
+ angle: Schema.withConstructorDefault<Schema.Tuple<readonly [Schema.Number, Schema.Number]>>;
9
+ life: Schema.withConstructorDefault<Schema.Tuple<readonly [Schema.Number, Schema.Number]>>;
10
+ size: Schema.withConstructorDefault<Schema.Tuple<readonly [Schema.Number, Schema.Number]>>;
11
+ opacityRange: Schema.withConstructorDefault<Schema.Tuple<readonly [Schema.Number, Schema.Number]>>;
12
+ gravity: Schema.withConstructorDefault<Schema.Number>;
13
+ palette: Schema.withConstructorDefault<Schema.$Array<Schema.String>>;
14
+ region: Schema.optionalKey<Schema.Struct<{
15
+ readonly w: Schema.Number;
16
+ readonly h: Schema.Number;
17
+ }>>;
18
+ drift: Schema.optionalKey<Schema.Tuple<readonly [Schema.Number, Schema.Number]>>;
19
+ sizeOverLife: Schema.optionalKey<Schema.Struct<{
20
+ readonly from: Schema.Number;
21
+ readonly to: Schema.Number;
22
+ readonly ease: Schema.optionalKey<Schema.String>;
23
+ }>>;
24
+ opacityOverLife: Schema.optionalKey<Schema.Struct<{
25
+ readonly from: Schema.Number;
26
+ readonly to: Schema.Number;
27
+ readonly ease: Schema.optionalKey<Schema.String>;
28
+ }>>;
29
+ capacity: Schema.withConstructorDefault<Schema.Number>;
30
+ buffer: Schema.withConstructorDefault<Schema.$Array<Schema.Struct<{
31
+ readonly x: Schema.Number;
32
+ readonly y: Schema.Number;
33
+ readonly vx: Schema.Number;
34
+ readonly vy: Schema.Number;
35
+ readonly age: Schema.Number;
36
+ readonly life: Schema.Number;
37
+ readonly size: Schema.Number;
38
+ readonly opacity: Schema.Number;
39
+ readonly color: Schema.String;
40
+ readonly rng: Schema.Number;
41
+ readonly alive: Schema.Boolean;
42
+ readonly wrap: Schema.Boolean;
43
+ }>>>;
44
+ }>, {
45
+ readonly "~position": Entity.TraitLens<{
46
+ readonly x: number;
47
+ readonly y: number;
48
+ readonly opacity: number;
49
+ readonly speed: readonly [number, number];
50
+ readonly angle: readonly [number, number];
51
+ readonly life: readonly [number, number];
52
+ readonly size: readonly [number, number];
53
+ readonly opacityRange: readonly [number, number];
54
+ readonly gravity: number;
55
+ readonly palette: readonly string[];
56
+ readonly region?: Schema.Struct.ReadonlySide<{
57
+ readonly w: Schema.Number;
58
+ readonly h: Schema.Number;
59
+ }, "Type">;
60
+ readonly drift?: readonly [number, number];
61
+ readonly sizeOverLife?: {
62
+ readonly from: number;
63
+ readonly to: number;
64
+ readonly ease?: string;
65
+ };
66
+ readonly opacityOverLife?: {
67
+ readonly from: number;
68
+ readonly to: number;
69
+ readonly ease?: string;
70
+ };
71
+ readonly capacity: number;
72
+ readonly buffer: readonly Schema.Struct.ReadonlySide<{
73
+ readonly x: Schema.Number;
74
+ readonly y: Schema.Number;
75
+ readonly vx: Schema.Number;
76
+ readonly vy: Schema.Number;
77
+ readonly age: Schema.Number;
78
+ readonly life: Schema.Number;
79
+ readonly size: Schema.Number;
80
+ readonly opacity: Schema.Number;
81
+ readonly color: Schema.String;
82
+ readonly rng: Schema.Number;
83
+ readonly alive: Schema.Boolean;
84
+ readonly wrap: Schema.Boolean;
85
+ }, "Type">[];
86
+ }, Entity.Position>;
87
+ readonly "~opacity": Entity.TraitLens<{
88
+ readonly x: number;
89
+ readonly y: number;
90
+ readonly opacity: number;
91
+ readonly speed: readonly [number, number];
92
+ readonly angle: readonly [number, number];
93
+ readonly life: readonly [number, number];
94
+ readonly size: readonly [number, number];
95
+ readonly opacityRange: readonly [number, number];
96
+ readonly gravity: number;
97
+ readonly palette: readonly string[];
98
+ readonly region?: Schema.Struct.ReadonlySide<{
99
+ readonly w: Schema.Number;
100
+ readonly h: Schema.Number;
101
+ }, "Type">;
102
+ readonly drift?: readonly [number, number];
103
+ readonly sizeOverLife?: {
104
+ readonly from: number;
105
+ readonly to: number;
106
+ readonly ease?: string;
107
+ };
108
+ readonly opacityOverLife?: {
109
+ readonly from: number;
110
+ readonly to: number;
111
+ readonly ease?: string;
112
+ };
113
+ readonly capacity: number;
114
+ readonly buffer: readonly Schema.Struct.ReadonlySide<{
115
+ readonly x: Schema.Number;
116
+ readonly y: Schema.Number;
117
+ readonly vx: Schema.Number;
118
+ readonly vy: Schema.Number;
119
+ readonly age: Schema.Number;
120
+ readonly life: Schema.Number;
121
+ readonly size: Schema.Number;
122
+ readonly opacity: Schema.Number;
123
+ readonly color: Schema.String;
124
+ readonly rng: Schema.Number;
125
+ readonly alive: Schema.Boolean;
126
+ readonly wrap: Schema.Boolean;
127
+ }, "Type">[];
128
+ }, number>;
129
+ }, {
130
+ readonly x?: number;
131
+ readonly y?: number;
132
+ readonly opacity?: number;
133
+ readonly speed?: readonly [number, number];
134
+ readonly angle?: readonly [number, number];
135
+ readonly life?: readonly [number, number];
136
+ readonly size?: readonly [number, number];
137
+ readonly opacityRange?: readonly [number, number];
138
+ readonly gravity?: number;
139
+ readonly palette?: readonly string[];
140
+ readonly region?: Schema.Struct.ReadonlyMakeIn<{
141
+ readonly w: Schema.Number;
142
+ readonly h: Schema.Number;
143
+ }>;
144
+ readonly drift?: readonly [number, number];
145
+ readonly sizeOverLife?: {
146
+ readonly from: number;
147
+ readonly to: number;
148
+ readonly ease?: string;
149
+ };
150
+ readonly opacityOverLife?: {
151
+ readonly from: number;
152
+ readonly to: number;
153
+ readonly ease?: string;
154
+ };
155
+ readonly capacity?: number;
156
+ readonly buffer?: readonly Schema.Struct.ReadonlyMakeIn<{
157
+ readonly x: Schema.Number;
158
+ readonly y: Schema.Number;
159
+ readonly vx: Schema.Number;
160
+ readonly vy: Schema.Number;
161
+ readonly age: Schema.Number;
162
+ readonly life: Schema.Number;
163
+ readonly size: Schema.Number;
164
+ readonly opacity: Schema.Number;
165
+ readonly color: Schema.String;
166
+ readonly rng: Schema.Number;
167
+ readonly alive: Schema.Boolean;
168
+ readonly wrap: Schema.Boolean;
169
+ }>[];
170
+ }>;
@@ -0,0 +1,69 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Schema from "effect/Schema";
3
+ import * as Entity from "../Entity";
4
+ import * as Shape2D from "../shapes/Shape2D";
5
+ /**
6
+ * A ParticleField is ONE entity/instance backing many particles. Its data
7
+ * holds the emitter config plus a fixed-capacity buffer of live particle
8
+ * state; the `simulate` animator advances it one frame at a time, so N
9
+ * particles cost one fiber and one phaser party — O(1) on the barrier.
10
+ *
11
+ * The buffer lives in the entity data (so the Runner owns it like any
12
+ * instance state and it round-trips through the seeded, deterministic
13
+ * scene). Its representation is the field's private business — see
14
+ * particles/step.ts.
15
+ */
16
+ const Range = Schema.Tuple([Schema.Number, Schema.Number]);
17
+ // a Range with a constructor default, so a mode that doesn't use this prop
18
+ // can omit it and still construct valid field data
19
+ const defaultedRange = (value) => Range.pipe(Schema.withConstructorDefault(Effect.sync(() => [value[0], value[1]])));
20
+ const OverLifeSchema = Schema.Struct({
21
+ from: Schema.Number,
22
+ to: Schema.Number,
23
+ ease: Schema.optionalKey(Schema.String),
24
+ });
25
+ const ParticleSchema = Schema.Struct({
26
+ x: Schema.Number,
27
+ y: Schema.Number,
28
+ vx: Schema.Number,
29
+ vy: Schema.Number,
30
+ age: Schema.Number,
31
+ life: Schema.Number,
32
+ size: Schema.Number,
33
+ opacity: Schema.Number,
34
+ color: Schema.String,
35
+ rng: Schema.Number,
36
+ alive: Schema.Boolean,
37
+ wrap: Schema.Boolean,
38
+ });
39
+ export const ParticleField = Entity.make("particles/ParticleField", {
40
+ // emitter origin doubles as the field's position (x/y), so the field
41
+ // participates in the position trait like any shape
42
+ ...Shape2D.position,
43
+ ...Shape2D.opacity,
44
+ // emitter config. speed/angle/life are emitter-mode props; the typed
45
+ // `emitter()`/`field()` constructors gate which an author may set, but
46
+ // the underlying schema defaults them so ONE struct serves both modes.
47
+ speed: defaultedRange([0, 0]),
48
+ angle: defaultedRange([0, 0]),
49
+ life: defaultedRange([1, 1]),
50
+ size: defaultedRange([2, 2]),
51
+ // per-particle birth opacity range (0..1), drawn at birth; the over-life
52
+ // opacity curve multiplies it. Named distinctly from the field's own
53
+ // `opacity` (the ~opacity trait above, which fades the whole field).
54
+ opacityRange: defaultedRange([1, 1]),
55
+ gravity: Shape2D.defaultedNumber(0),
56
+ palette: Schema.Array(Schema.String).pipe(Schema.withConstructorDefault(Effect.sync(() => ["white"]))),
57
+ // fill mode: the region particles spread across and wrap within
58
+ // (defaults to the frame in `simulate`) and their drift speed range
59
+ region: Schema.optionalKey(Schema.Struct({ w: Schema.Number, h: Schema.Number })),
60
+ drift: Schema.optionalKey(Range),
61
+ sizeOverLife: Schema.optionalKey(OverLifeSchema),
62
+ opacityOverLife: Schema.optionalKey(OverLifeSchema),
63
+ // lifecycle state
64
+ capacity: Shape2D.defaultedNumber(1024),
65
+ buffer: Schema.Array(ParticleSchema).pipe(Schema.withConstructorDefault(Effect.sync(() => []))),
66
+ }, {
67
+ "~position": Shape2D.positionLens(),
68
+ "~opacity": Shape2D.opacityLens(),
69
+ });
@@ -0,0 +1,28 @@
1
+ /**
2
+ * A plain, seedable pseudo-random generator carried BY each particle.
3
+ *
4
+ * Why not the scene's `Random` service? That service is fiber-scoped —
5
+ * reading it needs an Effect and a running fiber. A particle is data in a
6
+ * buffer, not a fiber; it can't `yield*`. So each particle carries its own
7
+ * generator state (a single uint32) advanced by pure arithmetic. The
8
+ * generator's SEED is drawn once from the scene's seeded `Random` at birth
9
+ * (see Particle.emit), so the whole thing stays deterministic under the
10
+ * scene seed while every particle's stream is independent: consuming one
11
+ * particle's randomness cannot shift another's.
12
+ *
13
+ * The algorithm is mulberry32 — a well-known 32-bit generator: one
14
+ * multiply-xor-shift chain per draw, good enough for visual scatter and
15
+ * byte-identical across platforms (all math forced through `| 0` / `>>> 0`
16
+ * so it stays in 32-bit integer space).
17
+ *
18
+ * State is a bare number so it packs into a particle buffer with no object
19
+ * overhead; `next` returns the new state alongside the drawn value rather
20
+ * than mutating, keeping the per-frame step a pure fold.
21
+ */
22
+ export type PrngState = number;
23
+ /** derive a well-mixed uint32 seed from an arbitrary number in [0, 1) or any float */
24
+ export declare const seedFrom: (value: number) => PrngState;
25
+ /** advance the state; returns the next state and a float in [0, 1) */
26
+ export declare const next: (state: PrngState) => readonly [PrngState, number];
27
+ /** advance the state; returns the next state and a float in [min, max) */
28
+ export declare const nextBetween: (state: PrngState, min: number, max: number) => readonly [PrngState, number];
@@ -0,0 +1,43 @@
1
+ /**
2
+ * A plain, seedable pseudo-random generator carried BY each particle.
3
+ *
4
+ * Why not the scene's `Random` service? That service is fiber-scoped —
5
+ * reading it needs an Effect and a running fiber. A particle is data in a
6
+ * buffer, not a fiber; it can't `yield*`. So each particle carries its own
7
+ * generator state (a single uint32) advanced by pure arithmetic. The
8
+ * generator's SEED is drawn once from the scene's seeded `Random` at birth
9
+ * (see Particle.emit), so the whole thing stays deterministic under the
10
+ * scene seed while every particle's stream is independent: consuming one
11
+ * particle's randomness cannot shift another's.
12
+ *
13
+ * The algorithm is mulberry32 — a well-known 32-bit generator: one
14
+ * multiply-xor-shift chain per draw, good enough for visual scatter and
15
+ * byte-identical across platforms (all math forced through `| 0` / `>>> 0`
16
+ * so it stays in 32-bit integer space).
17
+ *
18
+ * State is a bare number so it packs into a particle buffer with no object
19
+ * overhead; `next` returns the new state alongside the drawn value rather
20
+ * than mutating, keeping the per-frame step a pure fold.
21
+ */
22
+ /** derive a well-mixed uint32 seed from an arbitrary number in [0, 1) or any float */
23
+ export const seedFrom = (value) => {
24
+ // spread the input across the 32-bit range, then mix once so nearby
25
+ // seeds (e.g. 0.0001 apart) don't produce correlated first draws
26
+ let s = (Math.floor(value * 0x100000000) ^ 0x9e3779b9) >>> 0;
27
+ s = Math.imul(s ^ (s >>> 16), 0x45d9f3b) >>> 0;
28
+ return s >>> 0;
29
+ };
30
+ /** advance the state; returns the next state and a float in [0, 1) */
31
+ export const next = (state) => {
32
+ let t = (state + 0x6d2b79f5) | 0;
33
+ const nextState = t >>> 0;
34
+ t = Math.imul(t ^ (t >>> 15), 1 | t);
35
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
36
+ const value = ((t ^ (t >>> 14)) >>> 0) / 0x100000000;
37
+ return [nextState, value];
38
+ };
39
+ /** advance the state; returns the next state and a float in [min, max) */
40
+ export const nextBetween = (state, min, max) => {
41
+ const [nextState, value] = next(state);
42
+ return [nextState, min + value * (max - min)];
43
+ };
@@ -0,0 +1,78 @@
1
+ import type * as Effect from "effect/Effect";
2
+ import type * as Instance from "../Instance";
3
+ import type * as Runner from "../Runner";
4
+ import type { Range } from "./Particle";
5
+ import { ParticleField } from "./ParticleField";
6
+ /**
7
+ * Two typed front doors onto the SAME `ParticleField` entity. The field's
8
+ * emission model is chosen at `simulate`, but the properties that make sense
9
+ * differ by model — a fountain has speed/angle/life, a floating field has
10
+ * drift/region. These constructors gate which props an author may set and
11
+ * brand the returned instance so `simulate` only accepts the matching
12
+ * emission (see simulate.ts). The brand is compile-time only; both build an
13
+ * ordinary ParticleField instance with identical runtime data.
14
+ */
15
+ declare const EmitterBrand: unique symbol;
16
+ declare const FloatBrand: unique symbol;
17
+ type BaseField = Instance.Of<typeof ParticleField>;
18
+ /** an emitter (source) field — simulate with `{ burst }` or `{ rate }` */
19
+ export type EmitterField = BaseField & {
20
+ readonly [EmitterBrand]: true;
21
+ };
22
+ /** a floating field — simulate with `{ fill }` */
23
+ export type FloatField = BaseField & {
24
+ readonly [FloatBrand]: true;
25
+ };
26
+ /** an over-life curve `{ from, to, ease? }` (see Particle.OverLife) */
27
+ interface OverLifeInput {
28
+ readonly from: number;
29
+ readonly to: number;
30
+ readonly ease?: string;
31
+ }
32
+ /** props shared by both field kinds */
33
+ interface CommonInput {
34
+ readonly x?: number;
35
+ readonly y?: number;
36
+ /** birth size range (px radius) */
37
+ readonly size?: Range;
38
+ /** per-particle birth opacity range (0..1); over-life curve multiplies it */
39
+ readonly opacityRange?: Range;
40
+ /** colors drawn from uniformly at birth */
41
+ readonly palette?: ReadonlyArray<string>;
42
+ /** shared downward acceleration (px/sec²) */
43
+ readonly gravity?: number;
44
+ readonly sizeOverLife?: OverLifeInput;
45
+ readonly opacityOverLife?: OverLifeInput;
46
+ /** fixed particle capacity */
47
+ readonly capacity?: number;
48
+ }
49
+ /** props unique to a source emitter (launched particles with a lifetime) */
50
+ export interface EmitterInput extends CommonInput {
51
+ /** launch speed range (px/sec) */
52
+ readonly speed: Range;
53
+ /** launch angle range (degrees; 0 = up, clockwise-positive) */
54
+ readonly angle: Range;
55
+ /** lifetime range (seconds) */
56
+ readonly life: Range;
57
+ }
58
+ /** props unique to a floating field (scattered, drifting, wrapping) */
59
+ export interface FieldInput extends CommonInput {
60
+ /** drift speed range (px/sec); direction is uniform-random */
61
+ readonly drift: Range;
62
+ /** spread/wrap region in local coords; defaults to the whole frame */
63
+ readonly region?: {
64
+ readonly w: number;
65
+ readonly h: number;
66
+ };
67
+ }
68
+ /**
69
+ * Create a source emitter field. Simulate it with `{ burst: n }` (all at
70
+ * once) or `{ rate: n }` (a continuous stream).
71
+ */
72
+ export declare const emitter: (props: EmitterInput) => Effect.Effect<EmitterField, unknown, typeof ParticleField | Runner.Runner>;
73
+ /**
74
+ * Create a floating field: particles spread evenly across the region,
75
+ * drifting and wrapping at the edges. Simulate it with `{ fill: n }`.
76
+ */
77
+ export declare const field: (props: FieldInput) => Effect.Effect<FloatField, unknown, typeof ParticleField | Runner.Runner>;
78
+ export {};
@@ -0,0 +1,15 @@
1
+ import * as Scene from "../Scene";
2
+ import { ParticleField } from "./ParticleField";
3
+ /**
4
+ * Create a source emitter field. Simulate it with `{ burst: n }` (all at
5
+ * once) or `{ rate: n }` (a continuous stream).
6
+ */
7
+ export const emitter = (props) =>
8
+ // the entity schema is one struct with defaults; the branded return type
9
+ // is a compile-time cast, runtime data is a plain ParticleField
10
+ Scene.instantiate(ParticleField, props);
11
+ /**
12
+ * Create a floating field: particles spread evenly across the region,
13
+ * drifting and wrapping at the edges. Simulate it with `{ fill: n }`.
14
+ */
15
+ export const field = (props) => Scene.instantiate(ParticleField, props);
@@ -0,0 +1,6 @@
1
+ export * from "./constructors";
2
+ export * from "./Particle";
3
+ export * from "./ParticleField";
4
+ export * as Prng from "./Prng";
5
+ export { particleField as render } from "./render";
6
+ export * from "./simulate";
@@ -0,0 +1,6 @@
1
+ export * from "./constructors";
2
+ export * from "./Particle";
3
+ export * from "./ParticleField";
4
+ export * as Prng from "./Prng";
5
+ export { particleField as render } from "./render";
6
+ export * from "./simulate";
@@ -0,0 +1,16 @@
1
+ import type { OverLife, Particle } from "./Particle";
2
+ /**
3
+ * Evaluate an over-life curve at a particle's current age. Purely a
4
+ * function of `age/life` (clamped to [0,1]) eased by the configured timing
5
+ * function — no randomness after birth, so two particles at the same age
6
+ * always agree.
7
+ */
8
+ export declare const evalOverLife: (curve: OverLife, particle: Particle) => number;
9
+ /** rendered radius: drawn size, optionally scaled by the size-over-life curve */
10
+ export declare const renderSize: (particle: Particle, curve: OverLife | undefined) => number;
11
+ /**
12
+ * Rendered opacity: the particle's baseline opacity (drawn at birth),
13
+ * multiplied by the opacity-over-life curve when one is set. So a field can
14
+ * randomize per-particle opacity, fade over life, or both.
15
+ */
16
+ export declare const renderOpacity: (particle: Particle, curve: OverLife | undefined) => number;
@@ -0,0 +1,21 @@
1
+ import * as Timing from "../Timing";
2
+ /**
3
+ * Evaluate an over-life curve at a particle's current age. Purely a
4
+ * function of `age/life` (clamped to [0,1]) eased by the configured timing
5
+ * function — no randomness after birth, so two particles at the same age
6
+ * always agree.
7
+ */
8
+ export const evalOverLife = (curve, particle) => {
9
+ const t = particle.life > 0 ? Math.min(1, particle.age / particle.life) : 1;
10
+ // ease is stored as a plain string (schema data); resolve validates it
11
+ const eased = Timing.resolve((curve.ease ?? "linear"))(t);
12
+ return curve.from + (curve.to - curve.from) * eased;
13
+ };
14
+ /** rendered radius: drawn size, optionally scaled by the size-over-life curve */
15
+ export const renderSize = (particle, curve) => curve ? particle.size * evalOverLife(curve, particle) : particle.size;
16
+ /**
17
+ * Rendered opacity: the particle's baseline opacity (drawn at birth),
18
+ * multiplied by the opacity-over-life curve when one is set. So a field can
19
+ * randomize per-particle opacity, fade over life, or both.
20
+ */
21
+ export const renderOpacity = (particle, curve) => curve ? particle.opacity * evalOverLife(curve, particle) : particle.opacity;
@@ -0,0 +1,13 @@
1
+ import type * as Renderer from "../Renderer";
2
+ import type { SvgNode } from "../svg/SvgNode";
3
+ import type { ParticleField } from "./ParticleField";
4
+ /**
5
+ * Render a ParticleField: one <g> whose children are one <circle> per LIVE
6
+ * particle, each sized/faded by the over-life curves. Dead slots emit
7
+ * nothing. This is where the O(1)-simulation buffer expands back into N
8
+ * SVG nodes.
9
+ * ponytail: N SVG nodes is the RENDERING ceiling (the DOM/string cost), not
10
+ * the simulation ceiling. A batched/instanced sink is the upgrade path if
11
+ * node count becomes the wall.
12
+ */
13
+ export declare const particleField: Renderer.RenderFunction<SvgNode, typeof ParticleField>;
@@ -0,0 +1,33 @@
1
+ import * as Effect from "effect/Effect";
2
+ import { renderOpacity, renderSize } from "./overLife";
3
+ /**
4
+ * Render a ParticleField: one <g> whose children are one <circle> per LIVE
5
+ * particle, each sized/faded by the over-life curves. Dead slots emit
6
+ * nothing. This is where the O(1)-simulation buffer expands back into N
7
+ * SVG nodes.
8
+ * ponytail: N SVG nodes is the RENDERING ceiling (the DOM/string cost), not
9
+ * the simulation ceiling. A batched/instanced sink is the upgrade path if
10
+ * node count becomes the wall.
11
+ */
12
+ export const particleField = ({ data }) => Effect.succeed({
13
+ tag: "g",
14
+ props: data.x !== 0 || data.y !== 0
15
+ ? { transform: `translate(${data.x} ${data.y})` }
16
+ : {},
17
+ children: data.buffer
18
+ .filter((p) => p.alive)
19
+ .map((p) => {
20
+ const r = renderSize(p, data.sizeOverLife);
21
+ const opacity = renderOpacity(p, data.opacityOverLife);
22
+ return {
23
+ tag: "circle",
24
+ props: {
25
+ cx: p.x,
26
+ cy: p.y,
27
+ r,
28
+ fill: p.color,
29
+ ...(opacity !== 1 ? { opacity } : {}),
30
+ },
31
+ };
32
+ }),
33
+ });
@@ -0,0 +1,39 @@
1
+ import type * as Duration from "effect/Duration";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Runner from "../Runner";
4
+ import type { EmitterField, FloatField } from "./constructors";
5
+ /**
6
+ * Emission model for a single `simulate` call:
7
+ * - `{ burst: n }` — birth `n` particles on the FIRST frame, then none
8
+ * - `{ rate: n }` — birth `n` particles per second, continuously
9
+ * - `{ fill: n }` — seed `n` particles EVENLY across the region on the
10
+ * first frame; they drift and wrap at the edges forever (no source, no
11
+ * lifecycle). This is the "floating field" model.
12
+ */
13
+ export type EmitterEmission = {
14
+ readonly burst: number;
15
+ } | {
16
+ readonly rate: number;
17
+ };
18
+ export type FillEmission = {
19
+ readonly fill: number;
20
+ };
21
+ export type Emission = EmitterEmission | FillEmission;
22
+ /**
23
+ * Advance a `ParticleField` for `duration`, running its per-frame step
24
+ * (emit, integrate, kill) once per frame and arriving at the phaser once
25
+ * per frame — O(1) on the barrier regardless of live particle count.
26
+ *
27
+ * Dual: `simulate(field, duration, emission)` or
28
+ * `field.pipe(simulate(duration, emission))`. The emission is constrained by
29
+ * how the field was created: a `Particles.emitter(...)` takes `{ burst }` or
30
+ * `{ rate }`, a `Particles.field(...)` takes `{ fill }`. Resolves with the
31
+ * field so calls chain.
32
+ */
33
+ export interface Simulate {
34
+ (field: EmitterField, duration: Duration.Input, emission: EmitterEmission): Effect.Effect<EmitterField, never, Runner.Runner>;
35
+ (field: FloatField, duration: Duration.Input, emission: FillEmission): Effect.Effect<FloatField, never, Runner.Runner>;
36
+ (duration: Duration.Input, emission: EmitterEmission): (field: EmitterField) => Effect.Effect<EmitterField, never, Runner.Runner>;
37
+ (duration: Duration.Input, emission: FillEmission): (field: FloatField) => Effect.Effect<FloatField, never, Runner.Runner>;
38
+ }
39
+ export declare const simulate: Simulate;
@@ -0,0 +1,81 @@
1
+ import * as Effect from "effect/Effect";
2
+ import { dual } from "effect/Function";
3
+ import * as Random from "effect/Random";
4
+ import * as Instance from "../Instance";
5
+ import * as Runner from "../Runner";
6
+ import * as Scene from "../Scene";
7
+ import * as Time from "../Time";
8
+ import * as Prng from "./Prng";
9
+ import { step } from "./step";
10
+ // how many particles to birth on frame `i` (1-based) of this call
11
+ const birthsForFrame = (emission, i, fps) => {
12
+ if ("burst" in emission) {
13
+ return i === 1 ? emission.burst : 0;
14
+ }
15
+ if ("fill" in emission) {
16
+ // fill seeds the whole field once, up front, like a burst
17
+ return i === 1 ? emission.fill : 0;
18
+ }
19
+ // stream: rate/sec → per-frame, accumulating fractional remainder so the
20
+ // long-run average matches `rate` exactly (deterministic, no drift)
21
+ const perFrame = emission.rate / fps;
22
+ const before = Math.floor(perFrame * (i - 1));
23
+ const now = Math.floor(perFrame * i);
24
+ return now - before;
25
+ };
26
+ // read the field's current config out of its data. `region` is the fill
27
+ // area (defaulted to the frame by the caller); particles are positioned in
28
+ // the field's LOCAL space (origin 0,0) — the field's x/y transform offsets
29
+ // the whole subtree at render time.
30
+ const configOf = (data, region) => ({
31
+ x: 0, // emitter origin is the field's own transform; particles are local
32
+ y: 0,
33
+ speed: data.speed,
34
+ angle: data.angle,
35
+ life: data.life,
36
+ size: data.size,
37
+ gravity: data.gravity,
38
+ palette: data.palette,
39
+ region,
40
+ ...(data.opacityRange ? { opacity: data.opacityRange } : {}),
41
+ ...(data.drift ? { drift: data.drift } : {}),
42
+ ...(data.sizeOverLife ? { sizeOverLife: data.sizeOverLife } : {}),
43
+ ...(data.opacityOverLife ? { opacityOverLife: data.opacityOverLife } : {}),
44
+ });
45
+ const run = Effect.fnUntraced(function* (instance, duration, emission) {
46
+ const runner = yield* Runner.Runner;
47
+ const fps = runner.settings.frameRate;
48
+ const frames = Math.max(1, Time.toFrames(duration, fps));
49
+ const dt = 1 / fps;
50
+ const mode = "fill" in emission ? "fill" : "emit";
51
+ // fill spreads across a region: the field's own if set, else the frame
52
+ const current = yield* Scene.data(instance);
53
+ const region = {
54
+ w: current.region?.w ?? runner.settings.width,
55
+ h: current.region?.h ?? runner.settings.height,
56
+ };
57
+ for (let i = 1; i <= frames; i++) {
58
+ const n = birthsForFrame(emission, i, fps);
59
+ // draw one seed per birth from the scene's seeded Random — the ONLY
60
+ // entropy; each seeds a particle's own independent PRNG
61
+ const seeds = [];
62
+ for (let s = 0; s < n; s++) {
63
+ seeds.push(Prng.seedFrom(yield* Random.next));
64
+ }
65
+ yield* Scene.update(instance, (data) => {
66
+ // buffer is the field's private mutable representation; clone the
67
+ // array reference so setDataUnsafe stores a fresh value
68
+ const buffer = [
69
+ ...data.buffer,
70
+ ];
71
+ step(buffer, data.capacity, dt, seeds, configOf(data, region), mode);
72
+ return { ...data, buffer };
73
+ });
74
+ yield* Scene.tick;
75
+ }
76
+ return instance;
77
+ });
78
+ const firstArgIsInstance = (args) => Instance.isInstance(args[0]);
79
+ // the untyped runtime dual; the public `simulate` below narrows it per brand
80
+ const simulateImpl = dual(firstArgIsInstance, (instance, duration, emission) => run(instance, duration, emission));
81
+ export const simulate = simulateImpl;
@@ -0,0 +1,24 @@
1
+ import type { EmitterConfig, Particle } from "./Particle";
2
+ import * as Prng from "./Prng";
3
+ /** birth one particle from a seed + config (fixed draw order). */
4
+ export declare const birth: (seed: Prng.PrngState, config: EmitterConfig) => Particle;
5
+ /**
6
+ * Birth a `fill`-mode particle: scattered at a random point in the region
7
+ * (not at the origin), given a small isotropic drift velocity (not a
8
+ * launch), and marked to wrap forever instead of aging out. This is what
9
+ * makes an evenly-spread floating field rather than a fountain. Same fixed
10
+ * draw order — position x, position y, drift direction, drift speed, size,
11
+ * color — so seeded output is stable.
12
+ */
13
+ export declare const birthFill: (seed: Prng.PrngState, config: EmitterConfig) => Particle;
14
+ /**
15
+ * Advance the buffer one frame. `dt` is seconds per frame. `seeds` are the
16
+ * per-particle seeds to birth this frame (already drawn upstream). The
17
+ * buffer is mutated in place and returned — it is the field's private
18
+ * representation, owned by one instance, so in-place is safe and flat.
19
+ * ponytail: array-of-structs, mutated in place. SoA + typed arrays is the
20
+ * upgrade path if a capacity-target benchmark shows this is the wall.
21
+ */
22
+ export declare const step: (buffer: Particle[], capacity: number, dt: number, seeds: ReadonlyArray<Prng.PrngState>, config: EmitterConfig, mode?: "emit" | "fill") => Particle[];
23
+ /** count of currently-live particles */
24
+ export declare const liveCount: (buffer: ReadonlyArray<Particle>) => number;