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,167 @@
1
+ import * as Prng from "./Prng";
2
+ /**
3
+ * The per-frame simulation as a PURE fold: `(buffer, dt, births) → buffer`.
4
+ * No external entropy — the only randomness is each particle's own carried
5
+ * PRNG state, seeded at birth. `births` is the list of seeds for particles
6
+ * to birth THIS frame (how many, and their seeds, is decided by the caller
7
+ * — burst vs. stream — and drawn from the scene's seeded Random there, so
8
+ * this function stays pure and testable).
9
+ *
10
+ * Order per frame: integrate + age live particles, kill expired, then emit
11
+ * new ones into free slots (overwriting oldest when full).
12
+ *
13
+ * DETERMINISM: the draw order at birth is FIXED and part of the contract —
14
+ * angle, speed, life, size, color, in that sequence. Changing it shifts
15
+ * seeded output and is a breaking change.
16
+ */
17
+ const DEG = Math.PI / 180;
18
+ // positive modulo — JS `%` keeps the sign of the dividend, which would let a
19
+ // particle drifting off the left edge land at a negative coordinate
20
+ const mod = (n, m) => ((n % m) + m) % m;
21
+ // sample a uniform range from a particle's own rng, threading state
22
+ const draw = (rng, [min, max]) => Prng.nextBetween(rng, min, max);
23
+ // draw the baseline opacity — LAST in the draw order for both births, so
24
+ // adding it didn't shift earlier draws. Absent `opacity` config → 1 (and no
25
+ // draw is consumed, keeping opacity-less scenes byte-identical).
26
+ const drawOpacity = (rng, config) => config.opacity ? draw(rng, config.opacity) : [rng, 1];
27
+ /** birth one particle from a seed + config (fixed draw order). */
28
+ export const birth = (seed, config) => {
29
+ let rng = seed;
30
+ let angle;
31
+ let speed;
32
+ let life;
33
+ let size;
34
+ let colorPick;
35
+ [rng, angle] = draw(rng, config.angle);
36
+ [rng, speed] = draw(rng, config.speed);
37
+ [rng, life] = draw(rng, config.life);
38
+ [rng, size] = draw(rng, config.size);
39
+ [rng, colorPick] = Prng.nextBetween(rng, 0, config.palette.length);
40
+ let opacity;
41
+ [rng, opacity] = drawOpacity(rng, config);
42
+ // angle 0 = straight up; clockwise-positive
43
+ const rad = angle * DEG;
44
+ const color = config.palette[Math.min(config.palette.length - 1, Math.floor(colorPick))] ?? "white";
45
+ return {
46
+ x: config.x,
47
+ y: config.y,
48
+ vx: Math.sin(rad) * speed,
49
+ vy: -Math.cos(rad) * speed,
50
+ age: 0,
51
+ life,
52
+ size,
53
+ opacity,
54
+ color,
55
+ rng,
56
+ alive: true,
57
+ wrap: false,
58
+ };
59
+ };
60
+ /**
61
+ * Birth a `fill`-mode particle: scattered at a random point in the region
62
+ * (not at the origin), given a small isotropic drift velocity (not a
63
+ * launch), and marked to wrap forever instead of aging out. This is what
64
+ * makes an evenly-spread floating field rather than a fountain. Same fixed
65
+ * draw order — position x, position y, drift direction, drift speed, size,
66
+ * color — so seeded output is stable.
67
+ */
68
+ export const birthFill = (seed, config) => {
69
+ const w = config.region?.w ?? 0;
70
+ const h = config.region?.h ?? 0;
71
+ const drift = config.drift ?? [0, 0];
72
+ let rng = seed;
73
+ let px;
74
+ let py;
75
+ let dir;
76
+ let dspeed;
77
+ let size;
78
+ let colorPick;
79
+ [rng, px] = Prng.nextBetween(rng, 0, w);
80
+ [rng, py] = Prng.nextBetween(rng, 0, h);
81
+ [rng, dir] = Prng.nextBetween(rng, 0, 360);
82
+ [rng, dspeed] = draw(rng, drift);
83
+ [rng, size] = draw(rng, config.size);
84
+ [rng, colorPick] = Prng.nextBetween(rng, 0, config.palette.length);
85
+ let opacity;
86
+ [rng, opacity] = drawOpacity(rng, config);
87
+ const rad = dir * DEG;
88
+ const color = config.palette[Math.min(config.palette.length - 1, Math.floor(colorPick))] ?? "white";
89
+ return {
90
+ // scattered across the region, offset by the field's own origin
91
+ x: config.x + px,
92
+ y: config.y + py,
93
+ vx: Math.cos(rad) * dspeed,
94
+ vy: Math.sin(rad) * dspeed,
95
+ age: 0,
96
+ life: Number.POSITIVE_INFINITY,
97
+ size,
98
+ opacity,
99
+ color,
100
+ rng,
101
+ alive: true,
102
+ wrap: true,
103
+ };
104
+ };
105
+ /**
106
+ * Advance the buffer one frame. `dt` is seconds per frame. `seeds` are the
107
+ * per-particle seeds to birth this frame (already drawn upstream). The
108
+ * buffer is mutated in place and returned — it is the field's private
109
+ * representation, owned by one instance, so in-place is safe and flat.
110
+ * ponytail: array-of-structs, mutated in place. SoA + typed arrays is the
111
+ * upgrade path if a capacity-target benchmark shows this is the wall.
112
+ */
113
+ export const step = (buffer, capacity, dt, seeds, config, mode = "emit") => {
114
+ const spawn = mode === "fill" ? birthFill : birth;
115
+ // integrate live particles. Emitter particles age and die; fill (wrap)
116
+ // particles never age and instead wrap around the region's edges, so an
117
+ // evenly-spread field stays populated forever.
118
+ const rw = config.region?.w ?? 0;
119
+ const rh = config.region?.h ?? 0;
120
+ for (const p of buffer) {
121
+ if (!p.alive) {
122
+ continue;
123
+ }
124
+ p.vy += config.gravity * dt;
125
+ p.x += p.vx * dt;
126
+ p.y += p.vy * dt;
127
+ if (p.wrap) {
128
+ // wrap within the region, which sits at the field origin (config.x/y)
129
+ if (rw > 0) {
130
+ p.x = config.x + mod(p.x - config.x, rw);
131
+ }
132
+ if (rh > 0) {
133
+ p.y = config.y + mod(p.y - config.y, rh);
134
+ }
135
+ continue;
136
+ }
137
+ p.age += dt;
138
+ if (p.age >= p.life) {
139
+ p.alive = false;
140
+ }
141
+ }
142
+ // emit into free slots; when none, overwrite the oldest live particle
143
+ for (const seed of seeds) {
144
+ const fresh = spawn(seed, config);
145
+ const deadIdx = buffer.findIndex((p) => !p.alive);
146
+ if (deadIdx !== -1) {
147
+ buffer[deadIdx] = fresh;
148
+ }
149
+ else if (buffer.length < capacity) {
150
+ buffer.push(fresh);
151
+ }
152
+ else {
153
+ // full and all alive: overwrite the oldest (highest age)
154
+ let oldest = 0;
155
+ for (let i = 1; i < buffer.length; i++) {
156
+ // biome-ignore lint/style/noNonNullAssertion: indices are in range
157
+ if (buffer[i].age > buffer[oldest].age) {
158
+ oldest = i;
159
+ }
160
+ }
161
+ buffer[oldest] = fresh;
162
+ }
163
+ }
164
+ return buffer;
165
+ };
166
+ /** count of currently-live particles */
167
+ export const liveCount = (buffer) => buffer.reduce((n, p) => (p.alive ? n + 1 : n), 0);
@@ -0,0 +1,37 @@
1
+ import * as Entity from "../Entity";
2
+ export declare const Circle: Entity.Entity<"shapes/Circle", import("effect/Schema").Struct<{
3
+ x: import("effect/Schema").withConstructorDefault<import("effect/Schema").Number>;
4
+ y: import("effect/Schema").withConstructorDefault<import("effect/Schema").Number>;
5
+ opacity: import("effect/Schema").withConstructorDefault<import("effect/Schema").Number>;
6
+ fill: import("effect/Schema").withConstructorDefault<import("effect/Schema").String>;
7
+ stroke: import("effect/Schema").optionalKey<import("effect/Schema").String>;
8
+ strokeWidth: import("effect/Schema").optionalKey<import("effect/Schema").Number>;
9
+ radius: import("effect/Schema").withConstructorDefault<import("effect/Schema").Number>;
10
+ }>, {
11
+ readonly "~position": Entity.TraitLens<{
12
+ readonly x: number;
13
+ readonly y: number;
14
+ readonly opacity: number;
15
+ readonly fill: string;
16
+ readonly stroke?: string;
17
+ readonly strokeWidth?: number;
18
+ readonly radius: number;
19
+ }, Entity.Position>;
20
+ readonly "~opacity": Entity.TraitLens<{
21
+ readonly x: number;
22
+ readonly y: number;
23
+ readonly opacity: number;
24
+ readonly fill: string;
25
+ readonly stroke?: string;
26
+ readonly strokeWidth?: number;
27
+ readonly radius: number;
28
+ }, number>;
29
+ }, {
30
+ readonly x?: number;
31
+ readonly y?: number;
32
+ readonly opacity?: number;
33
+ readonly fill?: string;
34
+ readonly stroke?: string;
35
+ readonly strokeWidth?: number;
36
+ readonly radius?: number;
37
+ }>;
@@ -0,0 +1,9 @@
1
+ import * as Entity from "../Entity";
2
+ import * as Shape2D from "./Shape2D";
3
+ export const Circle = Entity.make("shapes/Circle", {
4
+ ...Shape2D.filled,
5
+ radius: Shape2D.defaultedNumber(10),
6
+ }, {
7
+ "~position": Shape2D.positionLens(),
8
+ "~opacity": Shape2D.opacityLens(),
9
+ });
@@ -0,0 +1,41 @@
1
+ import * as Entity from "../Entity";
2
+ export declare const Ellipse: Entity.Entity<"shapes/Ellipse", import("effect/Schema").Struct<{
3
+ x: import("effect/Schema").withConstructorDefault<import("effect/Schema").Number>;
4
+ y: import("effect/Schema").withConstructorDefault<import("effect/Schema").Number>;
5
+ opacity: import("effect/Schema").withConstructorDefault<import("effect/Schema").Number>;
6
+ fill: import("effect/Schema").withConstructorDefault<import("effect/Schema").String>;
7
+ stroke: import("effect/Schema").optionalKey<import("effect/Schema").String>;
8
+ strokeWidth: import("effect/Schema").optionalKey<import("effect/Schema").Number>;
9
+ rx: import("effect/Schema").withConstructorDefault<import("effect/Schema").Number>;
10
+ ry: import("effect/Schema").withConstructorDefault<import("effect/Schema").Number>;
11
+ }>, {
12
+ readonly "~position": Entity.TraitLens<{
13
+ readonly x: number;
14
+ readonly y: number;
15
+ readonly opacity: number;
16
+ readonly fill: string;
17
+ readonly stroke?: string;
18
+ readonly strokeWidth?: number;
19
+ readonly rx: number;
20
+ readonly ry: number;
21
+ }, Entity.Position>;
22
+ readonly "~opacity": Entity.TraitLens<{
23
+ readonly x: number;
24
+ readonly y: number;
25
+ readonly opacity: number;
26
+ readonly fill: string;
27
+ readonly stroke?: string;
28
+ readonly strokeWidth?: number;
29
+ readonly rx: number;
30
+ readonly ry: number;
31
+ }, number>;
32
+ }, {
33
+ readonly x?: number;
34
+ readonly y?: number;
35
+ readonly opacity?: number;
36
+ readonly fill?: string;
37
+ readonly stroke?: string;
38
+ readonly strokeWidth?: number;
39
+ readonly rx?: number;
40
+ readonly ry?: number;
41
+ }>;
@@ -0,0 +1,10 @@
1
+ import * as Entity from "../Entity";
2
+ import * as Shape2D from "./Shape2D";
3
+ export const Ellipse = Entity.make("shapes/Ellipse", {
4
+ ...Shape2D.filled,
5
+ rx: Shape2D.defaultedNumber(20),
6
+ ry: Shape2D.defaultedNumber(10),
7
+ }, {
8
+ "~position": Shape2D.positionLens(),
9
+ "~opacity": Shape2D.opacityLens(),
10
+ });
@@ -0,0 +1,150 @@
1
+ import * as Schema from "effect/Schema";
2
+ import * as Entity from "../Entity";
3
+ export declare const TransformMatrix: Schema.Struct<{
4
+ readonly a: Schema.Number;
5
+ readonly b: Schema.Number;
6
+ readonly c: Schema.Number;
7
+ readonly d: Schema.Number;
8
+ readonly e: Schema.Number;
9
+ readonly f: Schema.Number;
10
+ }>;
11
+ export type TransformMatrix = typeof TransformMatrix.Type;
12
+ export declare const identityTransform: {
13
+ a: number;
14
+ b: number;
15
+ c: number;
16
+ d: number;
17
+ e: number;
18
+ f: number;
19
+ };
20
+ export declare const multiplyTransforms: (left: TransformMatrix, right: TransformMatrix) => TransformMatrix;
21
+ export declare const TransformOperation: Schema.TaggedUnion<{
22
+ readonly "transform/matrix": Schema.TaggedStruct<"transform/matrix", {
23
+ readonly a: Schema.Number;
24
+ readonly b: Schema.Number;
25
+ readonly c: Schema.Number;
26
+ readonly d: Schema.Number;
27
+ readonly e: Schema.Number;
28
+ readonly f: Schema.Number;
29
+ }>;
30
+ readonly "transform/scale": Schema.TaggedStruct<"transform/scale", {
31
+ readonly x: Schema.Number;
32
+ readonly y: Schema.Number;
33
+ }>;
34
+ readonly "transform/translate": Schema.TaggedStruct<"transform/translate", {
35
+ readonly x: Schema.Number;
36
+ readonly y: Schema.Number;
37
+ }>;
38
+ }>;
39
+ declare const TransformOperations: Schema.$Array<Schema.TaggedUnion<{
40
+ readonly "transform/matrix": Schema.TaggedStruct<"transform/matrix", {
41
+ readonly a: Schema.Number;
42
+ readonly b: Schema.Number;
43
+ readonly c: Schema.Number;
44
+ readonly d: Schema.Number;
45
+ readonly e: Schema.Number;
46
+ readonly f: Schema.Number;
47
+ }>;
48
+ readonly "transform/scale": Schema.TaggedStruct<"transform/scale", {
49
+ readonly x: Schema.Number;
50
+ readonly y: Schema.Number;
51
+ }>;
52
+ readonly "transform/translate": Schema.TaggedStruct<"transform/translate", {
53
+ readonly x: Schema.Number;
54
+ readonly y: Schema.Number;
55
+ }>;
56
+ }>>;
57
+ export declare const Transform: Schema.decodeTo<Schema.Struct<{
58
+ readonly a: Schema.Number;
59
+ readonly b: Schema.Number;
60
+ readonly c: Schema.Number;
61
+ readonly d: Schema.Number;
62
+ readonly e: Schema.Number;
63
+ readonly f: Schema.Number;
64
+ }>, Schema.$Array<Schema.TaggedUnion<{
65
+ readonly "transform/matrix": Schema.TaggedStruct<"transform/matrix", {
66
+ readonly a: Schema.Number;
67
+ readonly b: Schema.Number;
68
+ readonly c: Schema.Number;
69
+ readonly d: Schema.Number;
70
+ readonly e: Schema.Number;
71
+ readonly f: Schema.Number;
72
+ }>;
73
+ readonly "transform/scale": Schema.TaggedStruct<"transform/scale", {
74
+ readonly x: Schema.Number;
75
+ readonly y: Schema.Number;
76
+ }>;
77
+ readonly "transform/translate": Schema.TaggedStruct<"transform/translate", {
78
+ readonly x: Schema.Number;
79
+ readonly y: Schema.Number;
80
+ }>;
81
+ }>>, never, never>;
82
+ declare const fields: {
83
+ x: Schema.withConstructorDefault<Schema.Number>;
84
+ y: Schema.withConstructorDefault<Schema.Number>;
85
+ opacity: Schema.withConstructorDefault<Schema.Number>;
86
+ transform: Schema.withConstructorDefault<Schema.Struct<{
87
+ readonly a: Schema.Number;
88
+ readonly b: Schema.Number;
89
+ readonly c: Schema.Number;
90
+ readonly d: Schema.Number;
91
+ readonly e: Schema.Number;
92
+ readonly f: Schema.Number;
93
+ }>>;
94
+ children: Schema.withConstructorDefault<Schema.$Array<Schema.String>>;
95
+ };
96
+ type StoredInput = Schema.Struct<typeof fields>["~type.make.in"];
97
+ type GroupInput = Omit<StoredInput, "transform"> & {
98
+ readonly transform?: typeof TransformOperations.Type;
99
+ };
100
+ export declare const Group: Entity.Entity<"shapes/Group", Schema.Struct<{
101
+ x: Schema.withConstructorDefault<Schema.Number>;
102
+ y: Schema.withConstructorDefault<Schema.Number>;
103
+ opacity: Schema.withConstructorDefault<Schema.Number>;
104
+ transform: Schema.withConstructorDefault<Schema.Struct<{
105
+ readonly a: Schema.Number;
106
+ readonly b: Schema.Number;
107
+ readonly c: Schema.Number;
108
+ readonly d: Schema.Number;
109
+ readonly e: Schema.Number;
110
+ readonly f: Schema.Number;
111
+ }>>;
112
+ children: Schema.withConstructorDefault<Schema.$Array<Schema.String>>;
113
+ }>, {
114
+ "~position": Entity.TraitLens<Schema.Struct.ReadonlySide<{
115
+ x: Schema.withConstructorDefault<Schema.Number>;
116
+ y: Schema.withConstructorDefault<Schema.Number>;
117
+ opacity: Schema.withConstructorDefault<Schema.Number>;
118
+ transform: Schema.withConstructorDefault<Schema.Struct<{
119
+ readonly a: Schema.Number;
120
+ readonly b: Schema.Number;
121
+ readonly c: Schema.Number;
122
+ readonly d: Schema.Number;
123
+ readonly e: Schema.Number;
124
+ readonly f: Schema.Number;
125
+ }>>;
126
+ children: Schema.withConstructorDefault<Schema.$Array<Schema.String>>;
127
+ }, "Type">, Entity.Position>;
128
+ "~opacity": Entity.TraitLens<Schema.Struct.ReadonlySide<{
129
+ x: Schema.withConstructorDefault<Schema.Number>;
130
+ y: Schema.withConstructorDefault<Schema.Number>;
131
+ opacity: Schema.withConstructorDefault<Schema.Number>;
132
+ transform: Schema.withConstructorDefault<Schema.Struct<{
133
+ readonly a: Schema.Number;
134
+ readonly b: Schema.Number;
135
+ readonly c: Schema.Number;
136
+ readonly d: Schema.Number;
137
+ readonly e: Schema.Number;
138
+ readonly f: Schema.Number;
139
+ }>>;
140
+ children: Schema.withConstructorDefault<Schema.$Array<Schema.String>>;
141
+ }, "Type">, number>;
142
+ }, GroupInput>;
143
+ /** The group's x/y position composed outside its normalized local transform. */
144
+ export declare const resolvedTransform: (data: {
145
+ readonly x: number;
146
+ readonly y: number;
147
+ readonly transform: TransformMatrix;
148
+ }) => TransformMatrix;
149
+ export declare const isIdentityTransform: (matrix: TransformMatrix) => boolean;
150
+ export {};
@@ -0,0 +1,82 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Schema from "effect/Schema";
3
+ import * as SchemaGetter from "effect/SchemaGetter";
4
+ import * as Entity from "../Entity";
5
+ import * as Shape2D from "./Shape2D";
6
+ export const TransformMatrix = Schema.Struct({
7
+ a: Schema.Number,
8
+ b: Schema.Number,
9
+ c: Schema.Number,
10
+ d: Schema.Number,
11
+ e: Schema.Number,
12
+ f: Schema.Number,
13
+ });
14
+ export const identityTransform = {
15
+ a: 1,
16
+ b: 0,
17
+ c: 0,
18
+ d: 1,
19
+ e: 0,
20
+ f: 0,
21
+ };
22
+ export const multiplyTransforms = (left, right) => ({
23
+ a: left.a * right.a + left.c * right.b,
24
+ b: left.b * right.a + left.d * right.b,
25
+ c: left.a * right.c + left.c * right.d,
26
+ d: left.b * right.c + left.d * right.d,
27
+ e: left.a * right.e + left.c * right.f + left.e,
28
+ f: left.b * right.e + left.d * right.f + left.f,
29
+ });
30
+ export const TransformOperation = Schema.TaggedUnion({
31
+ "transform/translate": { x: Schema.Number, y: Schema.Number },
32
+ "transform/scale": { x: Schema.Number, y: Schema.Number },
33
+ "transform/matrix": TransformMatrix.fields,
34
+ });
35
+ const TransformOperations = Schema.Array(TransformOperation);
36
+ const operationMatrix = (operation) => TransformOperation.match(operation, {
37
+ "transform/translate": ({ x, y }) => ({
38
+ ...identityTransform,
39
+ e: x,
40
+ f: y,
41
+ }),
42
+ "transform/scale": ({ x, y }) => ({
43
+ ...identityTransform,
44
+ a: x,
45
+ d: y,
46
+ }),
47
+ "transform/matrix": ({ a, b, c, d, e, f }) => ({ a, b, c, d, e, f }),
48
+ });
49
+ export const Transform = TransformOperations.pipe(Schema.decodeTo(TransformMatrix, {
50
+ // Post-multiplication preserves the authored transform-list order.
51
+ decode: SchemaGetter.transform((operations) => operations.reduce((matrix, operation) => multiplyTransforms(matrix, operationMatrix(operation)), identityTransform)),
52
+ encode: SchemaGetter.transform((matrix) => [
53
+ { _tag: "transform/matrix", ...matrix },
54
+ ]),
55
+ }));
56
+ const fields = {
57
+ ...Shape2D.position,
58
+ ...Shape2D.opacity,
59
+ transform: TransformMatrix.pipe(Schema.withConstructorDefault(Effect.succeed(identityTransform))),
60
+ children: Schema.Array(Schema.String).pipe(Schema.withConstructorDefault(Effect.sync(() => []))),
61
+ };
62
+ const decodeTransform = Schema.decodeUnknownSync(Transform);
63
+ const normalizeInput = (input) => Array.isArray(input.transform)
64
+ ? { ...input, transform: decodeTransform(input.transform) }
65
+ : input;
66
+ // A container: positions and structures its children, paints nothing.
67
+ // Transform operations are normalized once to a target-independent affine
68
+ // matrix; render targets never interpret the operation list.
69
+ const traits = {
70
+ // moving a group moves the subtree (children keep local coordinates)
71
+ "~position": Shape2D.positionLens(),
72
+ "~opacity": Shape2D.opacityLens(),
73
+ };
74
+ export const Group = Entity.make("shapes/Group", fields, traits, { normalize: normalizeInput });
75
+ /** The group's x/y position composed outside its normalized local transform. */
76
+ export const resolvedTransform = (data) => multiplyTransforms({ ...identityTransform, e: data.x, f: data.y }, data.transform);
77
+ export const isIdentityTransform = (matrix) => matrix.a === 1 &&
78
+ matrix.b === 0 &&
79
+ matrix.c === 0 &&
80
+ matrix.d === 1 &&
81
+ matrix.e === 0 &&
82
+ matrix.f === 0;
@@ -0,0 +1,9 @@
1
+ import * as Schema from "effect/Schema";
2
+ import * as Entity from "../Entity";
3
+ export declare const Layer: Entity.Entity<"shapes/Layer", Schema.Struct<{
4
+ depth: Schema.withConstructorDefault<Schema.Number>;
5
+ children: Schema.withConstructorDefault<Schema.$Array<Schema.String>>;
6
+ }>, {}, {
7
+ readonly depth?: number;
8
+ readonly children?: readonly string[];
9
+ }>;
@@ -0,0 +1,23 @@
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 "./Shape2D";
5
+ // A parallax layer: a container that holds children at a camera `depth`, and
6
+ // nothing else. Unlike Group it carries NO position or opacity of its own —
7
+ // `depth` is the only field, so it can never collide with the transform
8
+ // semantics a Group is expected to grow (position now, rotation/scale later),
9
+ // and a future guard can restrict Layers without touching Group.
10
+ //
11
+ // `depth` is the fraction of the camera this layer feels (pan AND zoom): 1 =
12
+ // full camera (default), 0 = pinned to the screen (a HUD), between = parallax.
13
+ // The sink reads it off each top-level layer (see svg/camera.ts).
14
+ //
15
+ // ponytail: a Layer nested inside another Layer is undefined behavior — the
16
+ // point of a dedicated entity is to leave room for a rule here, but the
17
+ // semantics ("a depth inside a depth"?) aren't decided yet, so no guard ships.
18
+ // Add one in Runner.instantiate (dispatch on entity.name === Layer.name) once
19
+ // nesting semantics are settled.
20
+ export const Layer = Entity.make("shapes/Layer", {
21
+ depth: Shape2D.defaultedNumber(1),
22
+ children: Schema.Array(Schema.String).pipe(Schema.withConstructorDefault(Effect.sync(() => []))),
23
+ });
@@ -0,0 +1,47 @@
1
+ import * as Schema from "effect/Schema";
2
+ import * as Entity from "../Entity";
3
+ declare const fields: {
4
+ x: Schema.withConstructorDefault<Schema.Number>;
5
+ y: Schema.withConstructorDefault<Schema.Number>;
6
+ opacity: Schema.withConstructorDefault<Schema.Number>;
7
+ x2: Schema.withConstructorDefault<Schema.Number>;
8
+ y2: Schema.withConstructorDefault<Schema.Number>;
9
+ stroke: Schema.withConstructorDefault<Schema.String>;
10
+ strokeWidth: Schema.withConstructorDefault<Schema.Number>;
11
+ };
12
+ type LineData = Schema.Struct<typeof fields>["Type"];
13
+ export declare const Line: Entity.Entity<"shapes/Line", Schema.Struct<{
14
+ x: Schema.withConstructorDefault<Schema.Number>;
15
+ y: Schema.withConstructorDefault<Schema.Number>;
16
+ opacity: Schema.withConstructorDefault<Schema.Number>;
17
+ x2: Schema.withConstructorDefault<Schema.Number>;
18
+ y2: Schema.withConstructorDefault<Schema.Number>;
19
+ stroke: Schema.withConstructorDefault<Schema.String>;
20
+ strokeWidth: Schema.withConstructorDefault<Schema.Number>;
21
+ }>, {
22
+ readonly "~position": {
23
+ get: (data: LineData) => {
24
+ x: number;
25
+ y: number;
26
+ };
27
+ set: (data: LineData, value: Entity.Position) => LineData;
28
+ };
29
+ readonly "~opacity": Entity.TraitLens<Schema.Struct.ReadonlySide<{
30
+ x: Schema.withConstructorDefault<Schema.Number>;
31
+ y: Schema.withConstructorDefault<Schema.Number>;
32
+ opacity: Schema.withConstructorDefault<Schema.Number>;
33
+ x2: Schema.withConstructorDefault<Schema.Number>;
34
+ y2: Schema.withConstructorDefault<Schema.Number>;
35
+ stroke: Schema.withConstructorDefault<Schema.String>;
36
+ strokeWidth: Schema.withConstructorDefault<Schema.Number>;
37
+ }, "Type">, number>;
38
+ }, {
39
+ readonly x?: number;
40
+ readonly y?: number;
41
+ readonly opacity?: number;
42
+ readonly x2?: number;
43
+ readonly y2?: number;
44
+ readonly stroke?: string;
45
+ readonly strokeWidth?: number;
46
+ }>;
47
+ export {};
@@ -0,0 +1,30 @@
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 "./Shape2D";
5
+ // No fill — a line is unfillable, so unlike the filled shapes it defaults
6
+ // stroke white / strokeWidth 1: the visible default (see Shape2D.ts).
7
+ // x/y is the start point, x2/y2 the end point.
8
+ const fields = {
9
+ ...Shape2D.position,
10
+ x2: Shape2D.defaultedNumber(0),
11
+ y2: Shape2D.defaultedNumber(0),
12
+ stroke: Schema.String.pipe(Schema.withConstructorDefault(Effect.succeed("white"))),
13
+ strokeWidth: Shape2D.defaultedNumber(1),
14
+ ...Shape2D.opacity,
15
+ };
16
+ export const Line = Entity.make("shapes/Line", fields, {
17
+ // position moves the WHOLE line: translating x/y alone would stretch
18
+ // it, so set shifts both endpoints by the delta (get = start point)
19
+ "~position": {
20
+ get: (data) => ({ x: data.x, y: data.y }),
21
+ set: (data, value) => ({
22
+ ...data,
23
+ x: value.x,
24
+ y: value.y,
25
+ x2: data.x2 + (value.x - data.x),
26
+ y2: data.y2 + (value.y - data.y),
27
+ }),
28
+ },
29
+ "~opacity": Shape2D.opacityLens(),
30
+ });
@@ -0,0 +1,38 @@
1
+ import * as Schema from "effect/Schema";
2
+ import * as Entity from "../Entity";
3
+ export declare const Path: Entity.Entity<"shapes/Path", Schema.Struct<{
4
+ x: Schema.withConstructorDefault<Schema.Number>;
5
+ y: Schema.withConstructorDefault<Schema.Number>;
6
+ opacity: Schema.withConstructorDefault<Schema.Number>;
7
+ fill: Schema.withConstructorDefault<Schema.String>;
8
+ stroke: Schema.optionalKey<Schema.String>;
9
+ strokeWidth: Schema.optionalKey<Schema.Number>;
10
+ d: Schema.String;
11
+ }>, {
12
+ readonly "~position": Entity.TraitLens<{
13
+ readonly x: number;
14
+ readonly y: number;
15
+ readonly opacity: number;
16
+ readonly fill: string;
17
+ readonly stroke?: string;
18
+ readonly strokeWidth?: number;
19
+ readonly d: string;
20
+ }, Entity.Position>;
21
+ readonly "~opacity": Entity.TraitLens<{
22
+ readonly x: number;
23
+ readonly y: number;
24
+ readonly opacity: number;
25
+ readonly fill: string;
26
+ readonly stroke?: string;
27
+ readonly strokeWidth?: number;
28
+ readonly d: string;
29
+ }, number>;
30
+ }, {
31
+ readonly x?: number;
32
+ readonly y?: number;
33
+ readonly opacity?: number;
34
+ readonly fill?: string;
35
+ readonly stroke?: string;
36
+ readonly strokeWidth?: number;
37
+ readonly d: string;
38
+ }>;
@@ -0,0 +1,13 @@
1
+ import * as Schema from "effect/Schema";
2
+ import * as Entity from "../Entity";
3
+ import * as Shape2D from "./Shape2D";
4
+ // `d` is required — an empty path can never be visible, so there is no
5
+ // sensible default. x/y offset the whole path (targets translate it),
6
+ // keeping position animatable without rewriting `d`.
7
+ export const Path = Entity.make("shapes/Path", {
8
+ ...Shape2D.filled,
9
+ d: Schema.String,
10
+ }, {
11
+ "~position": Shape2D.positionLens(),
12
+ "~opacity": Shape2D.opacityLens(),
13
+ });