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
package/dist/Phaser.js ADDED
@@ -0,0 +1,170 @@
1
+ import { Context, Effect, Latch, Layer } from "effect";
2
+ /**
3
+ * An externally paced phaser (cf. java.util.concurrent.Phaser).
4
+ *
5
+ * Unlike java's Phaser, which advances the moment the last party arrives,
6
+ * this phaser HOLDS at quiescence until the controller calls `awaitAdvance`,
7
+ * which arms the advance and resolves once every registered party has
8
+ * arrived again. One call = one phase = one animation frame.
9
+ */
10
+ export class Phaser extends Context.Service()("motion/Phaser", {
11
+ make: Effect.sync(() => {
12
+ let phase = 0;
13
+ let parties = 0;
14
+ let arrived = 0;
15
+ let phaseLatch = Latch.makeUnsafe();
16
+ let state = "idle";
17
+ let waiter = null;
18
+ // The single invariant. Every event that touches `arrived` or
19
+ // `parties` re-runs this.
20
+ const checkQuiescence = () => {
21
+ if (arrived !== parties) {
22
+ return;
23
+ }
24
+ switch (state) {
25
+ case "idle": {
26
+ // all parties arrived, no waiter: hold until awaitAdvance
27
+ return;
28
+ }
29
+ case "pending": {
30
+ phase++;
31
+ arrived = 0;
32
+ const oldLatch = phaseLatch;
33
+ // swap BEFORE open: a party that synchronously re-arrives
34
+ // awaits the new latch and counts toward the new phase
35
+ phaseLatch = Latch.makeUnsafe();
36
+ state = "running";
37
+ oldLatch.openUnsafe();
38
+ // covers parties === 0 and synchronous re-arrivals
39
+ checkQuiescence();
40
+ return;
41
+ }
42
+ case "running": {
43
+ const resume = waiter;
44
+ waiter = null;
45
+ state = "idle";
46
+ resume?.();
47
+ return;
48
+ }
49
+ }
50
+ };
51
+ const register = (n) => {
52
+ parties += n;
53
+ };
54
+ const deregister = (n) => {
55
+ parties -= n;
56
+ checkQuiescence();
57
+ };
58
+ const arriveAndAwaitAdvance = Effect.uninterruptibleMask((restore) => Effect.suspend(() => {
59
+ const myLatch = phaseLatch; // generation token
60
+ arrived++;
61
+ checkQuiescence();
62
+ return restore(myLatch.await).pipe(Effect.onInterrupt(() => Effect.sync(() => {
63
+ // undo the phantom arrival, but only if our
64
+ // generation is still the current one
65
+ if (myLatch === phaseLatch) {
66
+ arrived--;
67
+ checkQuiescence();
68
+ }
69
+ })));
70
+ }));
71
+ const awaitAdvance = Effect.callback((resume) => {
72
+ if (waiter !== null) {
73
+ resume(Effect.die(new Error("Phaser: concurrent awaitAdvance — single controller only")));
74
+ return;
75
+ }
76
+ waiter = () => resume(Effect.succeed(phase));
77
+ if (arrived === parties) {
78
+ // quiescent: arm an advance so parties run the next phase
79
+ state = "pending";
80
+ checkQuiescence();
81
+ }
82
+ else {
83
+ // parties still running toward their next arrival (e.g.
84
+ // scene startup): resolve at the first quiescence, do not
85
+ // advance past it
86
+ state = "running";
87
+ }
88
+ return Effect.sync(() => {
89
+ // interrupted while suspended
90
+ waiter = null;
91
+ state = "idle";
92
+ });
93
+ });
94
+ return {
95
+ register,
96
+ deregister,
97
+ arriveAndAwaitAdvance,
98
+ awaitAdvance,
99
+ /** debug/test view of the internal counters */
100
+ snapshotUnsafe: () => ({ phase, parties, arrived, state }),
101
+ };
102
+ }),
103
+ }) {
104
+ }
105
+ /**
106
+ * Fork `scene` as the root party of `phaser`.
107
+ *
108
+ * Registration happens synchronously before the fork, so between combinators
109
+ * the scene is registered-but-running and `awaitAdvance` can never resolve
110
+ * during a sequential handoff or before the scene starts. The slot is
111
+ * released by a finalizer on success, failure, and interrupt alike.
112
+ */
113
+ export const run = (phaser, scene) => Effect.uninterruptibleMask(() => Effect.suspend(() => {
114
+ phaser.register(1);
115
+ return Effect.interruptible(scene).pipe(Effect.ensuring(Effect.sync(() => phaser.deregister(1))), Effect.provide(Layer.succeed(Phaser, phaser)), Effect.forkChild);
116
+ }));
117
+ /**
118
+ * Run `effect`, then arrive at the phase boundary.
119
+ *
120
+ * Borrows the caller's party slot (no register/deregister) — that is what
121
+ * makes consecutive `one` calls handoff-gap-free.
122
+ */
123
+ export const one = (effect) => Effect.gen(function* () {
124
+ const phaser = yield* Phaser;
125
+ const result = yield* effect;
126
+ yield* phaser.arriveAndAwaitAdvance;
127
+ return result;
128
+ });
129
+ /**
130
+ * Run branches in parallel, sharing phases.
131
+ *
132
+ * N branches share N slots: the caller's one (the caller is blocked, not
133
+ * arrived, so it must not hold a countable slot) plus N-1 minted here. A
134
+ * finished branch releases a slot immediately so it cannot hold the phase
135
+ * open — except the last branch, whose slot returns to the resuming caller
136
+ * with no deregister/register gap.
137
+ */
138
+ export const all = (effects) => Effect.gen(function* () {
139
+ const phaser = yield* Phaser;
140
+ const list = Array.from(effects);
141
+ const n = list.length;
142
+ if (n === 0) {
143
+ return;
144
+ }
145
+ if (n === 1) {
146
+ // biome-ignore lint/style/noNonNullAssertion: n === 1 guarantees it
147
+ yield* list[0];
148
+ return;
149
+ }
150
+ let live = n;
151
+ let held = n - 1; // extra slots this `all` still holds
152
+ yield* Effect.uninterruptibleMask((restore) => Effect.suspend(() => {
153
+ phaser.register(held);
154
+ const branches = list.map((branch) => branch.pipe(Effect.andThen(Effect.sync(() => {
155
+ live--;
156
+ if (live > 0) {
157
+ held--;
158
+ phaser.deregister(1);
159
+ }
160
+ }))));
161
+ return restore(Effect.all(branches, { concurrency: "unbounded" })).pipe(Effect.ensuring(Effect.sync(() => {
162
+ // failure/interrupt: release whatever is still held
163
+ if (held > 0) {
164
+ const leftover = held;
165
+ held = 0;
166
+ phaser.deregister(leftover);
167
+ }
168
+ })));
169
+ }));
170
+ });
@@ -0,0 +1,78 @@
1
+ import * as Effect from "effect/Effect";
2
+ import type * as Schema from "effect/Schema";
3
+ import * as Entity from "./Entity";
4
+ import * as Instance from "./Instance";
5
+ import * as Runner from "./Runner";
6
+ /**
7
+ * Physics-based motion: durationless animations that carry momentum and
8
+ * end on their own. A spring is a damped harmonic oscillator simulated
9
+ * until displacement and velocity settle within a tolerance — there is
10
+ * no duration to specify, so springs are combinators here rather than
11
+ * `TimingInput`s.
12
+ */
13
+ export interface Spring {
14
+ readonly mass: number;
15
+ readonly stiffness: number;
16
+ readonly damping: number;
17
+ readonly initialVelocity?: number;
18
+ }
19
+ /** fast, gently damped general-purpose spring */
20
+ export declare const defaultSpring: Spring;
21
+ export declare const springs: {
22
+ readonly beat: {
23
+ readonly mass: 0.13;
24
+ readonly stiffness: 5.7;
25
+ readonly damping: 1.2;
26
+ readonly initialVelocity: 10;
27
+ };
28
+ readonly plop: {
29
+ readonly mass: 0.2;
30
+ readonly stiffness: 20;
31
+ readonly damping: 0.68;
32
+ };
33
+ readonly bounce: {
34
+ readonly mass: 0.08;
35
+ readonly stiffness: 4.75;
36
+ readonly damping: 0.05;
37
+ };
38
+ readonly swing: {
39
+ readonly mass: 0.39;
40
+ readonly stiffness: 19.85;
41
+ readonly damping: 2.82;
42
+ };
43
+ readonly jump: {
44
+ readonly mass: 0.04;
45
+ readonly stiffness: 10;
46
+ readonly damping: 0.7;
47
+ readonly initialVelocity: 8;
48
+ };
49
+ readonly strike: {
50
+ readonly mass: 0.03;
51
+ readonly stiffness: 20;
52
+ readonly damping: 0.9;
53
+ readonly initialVelocity: 4.8;
54
+ };
55
+ readonly smooth: {
56
+ readonly mass: 0.16;
57
+ readonly stiffness: 15.35;
58
+ readonly damping: 1.88;
59
+ };
60
+ };
61
+ export type SpringName = keyof typeof springs;
62
+ /** a preset name (autocompleted) or a custom spring configuration */
63
+ export type SpringInput = SpringName | Spring;
64
+ export declare const resolve: (input: SpringInput) => Effect.Effect<Spring>;
65
+ type HasPosition<Data extends Schema.Top> = {
66
+ readonly "~position": Entity.TraitLens<Data["Type"], Entity.Position>;
67
+ };
68
+ /**
69
+ * Spring an instance to a position via its `~position` trait — the
70
+ * durationless counterpart of `Motion.moveTo` (settles exactly, length
71
+ * emerges from the physics). Dual:
72
+ * `springTo(instance, to, springInput?, settleTolerance?)` or
73
+ * `instance.pipe(springTo(to, springInput?, settleTolerance?))`.
74
+ */
75
+ export declare const springTo: (<Name extends string, Data extends Schema.Top, Traits extends Partial<Entity.EntityTraits<Data["Type"]>> & HasPosition<Data>>(to: Partial<Entity.Position>, springInput?: SpringInput, settleTolerance?: number) => <E = never, R = never>(instance: Instance.InstanceOrEffect<Name, Data, Traits, E, R>) => Effect.Effect<Instance.Instance<Name, Data, Traits>, E, R | Runner.Runner>) & (<Name extends string, Data extends Schema.Top, Traits extends Partial<Entity.EntityTraits<Data["Type"]>> & HasPosition<Data>, E = never, R = never>(instance: Instance.InstanceOrEffect<Name, Data, Traits, E, R>, to: Partial<Entity.Position>, springInput?: SpringInput, settleTolerance?: number) => Effect.Effect<Instance.Instance<Name, Data, Traits>, E, R | Runner.Runner>);
76
+ /** Like `springTo`, but from an explicit position (partials filled from current). */
77
+ export declare const spring: (<Name extends string, Data extends Schema.Top, Traits extends Partial<Entity.EntityTraits<Data["Type"]>> & HasPosition<Data>>(from: Partial<Entity.Position>, to: Partial<Entity.Position>, springInput?: SpringInput, settleTolerance?: number) => <E = never, R = never>(instance: Instance.InstanceOrEffect<Name, Data, Traits, E, R>) => Effect.Effect<Instance.Instance<Name, Data, Traits>, E, R | Runner.Runner>) & (<Name extends string, Data extends Schema.Top, Traits extends Partial<Entity.EntityTraits<Data["Type"]>> & HasPosition<Data>, E = never, R = never>(instance: Instance.InstanceOrEffect<Name, Data, Traits, E, R>, from: Partial<Entity.Position>, to: Partial<Entity.Position>, springInput?: SpringInput, settleTolerance?: number) => Effect.Effect<Instance.Instance<Name, Data, Traits>, E, R | Runner.Runner>);
78
+ export {};
@@ -0,0 +1,117 @@
1
+ import * as Effect from "effect/Effect";
2
+ import { dual } from "effect/Function";
3
+ import * as Entity from "./Entity";
4
+ import * as Instance from "./Instance";
5
+ import * as Runner from "./Runner";
6
+ import * as Scene from "./Scene";
7
+ /** fast, gently damped general-purpose spring */
8
+ export const defaultSpring = {
9
+ mass: 0.05,
10
+ stiffness: 10,
11
+ damping: 0.5,
12
+ };
13
+ export const springs = {
14
+ beat: { mass: 0.13, stiffness: 5.7, damping: 1.2, initialVelocity: 10 },
15
+ plop: { mass: 0.2, stiffness: 20, damping: 0.68 },
16
+ bounce: { mass: 0.08, stiffness: 4.75, damping: 0.05 },
17
+ swing: { mass: 0.39, stiffness: 19.85, damping: 2.82 },
18
+ jump: { mass: 0.04, stiffness: 10, damping: 0.7, initialVelocity: 8 },
19
+ strike: { mass: 0.03, stiffness: 20, damping: 0.9, initialVelocity: 4.8 },
20
+ smooth: { mass: 0.16, stiffness: 15.35, damping: 1.88 },
21
+ };
22
+ // invalid configs are defects for now; may graduate to typed errors in E
23
+ export const resolve = (input) => Effect.suspend(() => {
24
+ const spring = typeof input === "string" ? springs[input] : input;
25
+ if (spring === undefined) {
26
+ // unreachable for typed consumers; catches plain-JS typos
27
+ return Effect.die(new Error(`Physics: unknown spring "${String(input)}"`));
28
+ }
29
+ if (spring.mass <= 0) {
30
+ return Effect.die(new Error("Physics: spring mass must be greater than 0"));
31
+ }
32
+ if (spring.stiffness < 0) {
33
+ return Effect.die(new Error("Physics: spring stiffness must be >= 0"));
34
+ }
35
+ if (spring.damping < 0) {
36
+ return Effect.die(new Error("Physics: spring damping must be >= 0"));
37
+ }
38
+ return Effect.succeed(spring);
39
+ });
40
+ // fixed-rate integration keeps trajectories frame-rate independent:
41
+ // explicit Euler diverges with large dt, so each scene frame consumes
42
+ // its 1/frameRate seconds in 1/120 s substeps
43
+ const SIMULATION_STEP = 1 / 120;
44
+ /**
45
+ * The simulation engine: spring each key of `from` toward `to`, calling
46
+ * `fn` once per scene frame until every key's displacement AND velocity
47
+ * are within `settleTolerance`, then snap exactly onto `to`. Length
48
+ * emerges from the physics — a zero-damping spring never settles and
49
+ * animates indefinitely (the scene keeps ticking; stepping never
50
+ * blocks). Internal — public animators apply to instances.
51
+ */
52
+ const simulate = Effect.fnUntraced(function* (from, to, springInput, fn, settleTolerance = 0.001) {
53
+ const runner = yield* Runner.Runner;
54
+ const config = yield* resolve(springInput);
55
+ const frameDt = 1 / runner.settings.frameRate;
56
+ const keys = Object.keys(from);
57
+ const positions = {};
58
+ const velocities = {};
59
+ for (const key of keys) {
60
+ positions[key] = from[key];
61
+ velocities[key] = config.initialVelocity ?? 0;
62
+ }
63
+ const settled = () => keys.every((key) => Math.abs(to[key] - positions[key]) <
64
+ settleTolerance &&
65
+ Math.abs(velocities[key]) < settleTolerance);
66
+ const substep = (dt) => {
67
+ for (const key of keys) {
68
+ const position = positions[key];
69
+ const velocity = velocities[key];
70
+ const displacement = position - to[key];
71
+ const force = -config.stiffness * displacement - config.damping * velocity;
72
+ const nextVelocity = velocity + (force / config.mass) * dt;
73
+ velocities[key] = nextVelocity;
74
+ positions[key] = position + nextVelocity * dt;
75
+ }
76
+ };
77
+ let done = settled();
78
+ while (!done) {
79
+ let remaining = frameDt;
80
+ while (remaining > 0) {
81
+ substep(Math.min(SIMULATION_STEP, remaining));
82
+ remaining -= SIMULATION_STEP;
83
+ if (settled()) {
84
+ done = true;
85
+ break;
86
+ }
87
+ }
88
+ if (!done) {
89
+ yield* fn({ ...positions });
90
+ yield* Scene.tick;
91
+ }
92
+ }
93
+ // physics only approaches the target; the final frame lands exactly
94
+ yield* fn({ ...to });
95
+ yield* Scene.tick;
96
+ });
97
+ const springPosition = Effect.fnUntraced(function* (instanceOrEffect, from, to, springInput, settleTolerance) {
98
+ const instance = yield* Instance.flatten(instanceOrEffect);
99
+ const lens = Entity.traitOrDie(instance.entity, "~position");
100
+ const current = lens.get(yield* Scene.data(instance));
101
+ // partial targets/origins hold the missing axis at its current value
102
+ const target = { ...current, ...to };
103
+ const start = { ...current, ...(from ?? {}) };
104
+ yield* simulate(start, target, springInput ?? defaultSpring, (value) => Scene.update(instance, (data) => lens.set(data, value)), settleTolerance);
105
+ return instance;
106
+ });
107
+ const firstArgIsInstance = (args) => Instance.isInstance(args[0]);
108
+ /**
109
+ * Spring an instance to a position via its `~position` trait — the
110
+ * durationless counterpart of `Motion.moveTo` (settles exactly, length
111
+ * emerges from the physics). Dual:
112
+ * `springTo(instance, to, springInput?, settleTolerance?)` or
113
+ * `instance.pipe(springTo(to, springInput?, settleTolerance?))`.
114
+ */
115
+ export const springTo = dual(firstArgIsInstance, (instance, to, springInput, settleTolerance) => springPosition(instance, undefined, to, springInput, settleTolerance));
116
+ /** Like `springTo`, but from an explicit position (partials filled from current). */
117
+ export const spring = dual(firstArgIsInstance, (instance, from, to, springInput, settleTolerance) => springPosition(instance, from, to, springInput, settleTolerance));
@@ -0,0 +1,69 @@
1
+ import { Layer } from "effect";
2
+ import * as Context from "effect/Context";
3
+ import * as Effect from "effect/Effect";
4
+ import type * as Entity from "./Entity";
5
+ import type { EntriesFromEntities, Frame } from "./Scene";
6
+ /** Renders one entity instance to the renderer's per-entity output. */
7
+ export type RenderFunction<Success, Ent extends Entity.AnyEntity, E = never, R = never> = (payload: {
8
+ entity: Ent;
9
+ id: string;
10
+ data: Ent["data"]["Type"];
11
+ /**
12
+ * rendered output of this instance's children, post-order (empty for
13
+ * leaves). In frame data, a `children: string[]` field means child
14
+ * instance ids — containers embed these results in their own output.
15
+ */
16
+ children: ReadonlyArray<Success>;
17
+ }) => Effect.Effect<Success, E, R>;
18
+ /** the frame's render metadata, handed to sink render functions */
19
+ export interface FrameMeta {
20
+ readonly frameRate: number;
21
+ readonly width: number;
22
+ readonly height: number;
23
+ readonly backgroundColor: string;
24
+ /**
25
+ * the active camera's view — `{x, y}` pan in world units, `zoom` a
26
+ * uniform scale (1 = identity). Sinks apply it per top-level layer,
27
+ * scaled by that layer's `depth`. Absent camera work leaves this at
28
+ * identity `{0, 0, 1}` and output is unchanged.
29
+ */
30
+ readonly camera: {
31
+ readonly x: number;
32
+ readonly y: number;
33
+ readonly zoom: number;
34
+ };
35
+ }
36
+ export interface EntityRenderer<Name extends string, Success, Ent extends Entity.AnyEntity, E, R> {
37
+ readonly name: Name;
38
+ readonly render: RenderFunction<Success, Ent, E, R>;
39
+ }
40
+ type RendererName<Tag extends string, Ent extends Entity.AnyEntity> = `${Tag}/${Ent["name"]}`;
41
+ /**
42
+ * Build a renderer family.
43
+ *
44
+ * `make<EntityOutput>()(tag, { render })` creates a frame renderer that
45
+ * resolves one `EntityRenderer` per entity type from context (keyed
46
+ * `<tag>/<entity name>`) and combines the per-entity outputs with
47
+ * `config.render`. Provide per-entity renderers with
48
+ * `makeEntityRendererLayer` and the frame renderer with `layer`.
49
+ */
50
+ export declare const make: <RenderEntitySuccess, Config = void>() => <const Tag extends string, RenderSuccess>(tag: Tag, config: {
51
+ render: <Entities>(entities: Iterable<{
52
+ id: string;
53
+ render: Effect.Effect<RenderEntitySuccess>;
54
+ entry: EntriesFromEntities<Entities>;
55
+ }>, config: Config, meta: FrameMeta) => Effect.Effect<RenderSuccess>;
56
+ }) => {
57
+ Context: Context.Service<{
58
+ render: <const Entities extends Entity.AnyEntity>(frame: Frame<Entities>, config: Config) => Effect.Effect<RenderSuccess, never, Entities extends Entity.AnyEntity ? { [K in Entities as K["name"]]: EntityRenderer<`${Tag}/${K["name"]}`, RenderEntitySuccess, K, never, never>; }[Entities["name"]] : never>;
59
+ }, {
60
+ render: <const Entities extends Entity.AnyEntity>(frame: Frame<Entities>, config: Config) => Effect.Effect<RenderSuccess, never, Entities extends Entity.AnyEntity ? { [K in Entities as K["name"]]: EntityRenderer<`${Tag}/${K["name"]}`, RenderEntitySuccess, K, never, never>; }[Entities["name"]] : never>;
61
+ }>;
62
+ layer: Layer.Layer<{
63
+ render: <const Entities extends Entity.AnyEntity>(frame: Frame<Entities>, config: Config) => Effect.Effect<RenderSuccess, never, Entities extends Entity.AnyEntity ? { [K in Entities as K["name"]]: EntityRenderer<`${Tag}/${K["name"]}`, RenderEntitySuccess, K, never, never>; }[Entities["name"]] : never>;
64
+ }, never, never>;
65
+ makeEntityRendererContext: <const Ent extends Entity.AnyEntity, E = never, R = never>(entity: Ent) => Context.Service<EntityRenderer<`${Tag}/${Ent["name"]}`, RenderEntitySuccess, Ent, E, R>, EntityRenderer<`${Tag}/${Ent["name"]}`, RenderEntitySuccess, Ent, E, R>>;
66
+ makeEntityRendererService: <const Ent extends Entity.AnyEntity, E = never, R = never>(entity: Ent, render: RenderFunction<RenderEntitySuccess, Ent, E, R>) => EntityRenderer<`${Tag}/${Ent["name"]}`, RenderEntitySuccess, Ent, E, R>;
67
+ makeEntityRendererLayer: <const Ent extends Entity.AnyEntity, E = never, R = never>(entity: Ent, render: RenderFunction<RenderEntitySuccess, Ent, E, R>) => Layer.Layer<EntityRenderer<RendererName<Tag, Ent>, RenderEntitySuccess, Ent, E, R>>;
68
+ };
69
+ export {};
@@ -0,0 +1,90 @@
1
+ import { Layer } from "effect";
2
+ import * as Context from "effect/Context";
3
+ import * as Effect from "effect/Effect";
4
+ /**
5
+ * Build a renderer family.
6
+ *
7
+ * `make<EntityOutput>()(tag, { render })` creates a frame renderer that
8
+ * resolves one `EntityRenderer` per entity type from context (keyed
9
+ * `<tag>/<entity name>`) and combines the per-entity outputs with
10
+ * `config.render`. Provide per-entity renderers with
11
+ * `makeEntityRendererLayer` and the frame renderer with `layer`.
12
+ */
13
+ export const make = () => (tag, config) => {
14
+ const makeEntityRendererContext = (entity) => Context.Service(`${tag}/${entity.name}`);
15
+ const makeEntityRendererService = (entity, render) => makeEntityRendererContext(entity).of({
16
+ name: `${tag}/${entity.name}`,
17
+ render,
18
+ });
19
+ const makeEntityRendererLayer = (entity, render) => Layer.succeed(makeEntityRendererContext(entity), makeEntityRendererService(entity, render));
20
+ // The concrete member of Renderers<Entities> is only known at
21
+ // runtime (it depends on the instance's entity), hence the cast.
22
+ const getEntityRenderer = (entity) => Effect.gen(function* () {
23
+ return yield* makeEntityRendererContext(entity);
24
+ });
25
+ const context = Context.Service(tag);
26
+ // in frame data, a `children: string[]` field means child instance ids
27
+ const childIdsOf = (data) => {
28
+ const children = data?.children;
29
+ return Array.isArray(children) ? children : [];
30
+ };
31
+ // a hidden instance ($visible false) and its subtree are skipped
32
+ // entirely — target-agnostic, so every sink honors visibility for free
33
+ const isVisible = (frame, id) => frame.instances[id]?.$visible !== false;
34
+ const service = context.of({
35
+ render: Effect.fnUntraced(function* (frame, customConfig) {
36
+ const visited = new Set();
37
+ // post-order: children build (and later render) before their
38
+ // container, which receives the rendered results
39
+ const buildEntry = (id) => Effect.gen(function* () {
40
+ if (visited.has(id)) {
41
+ return yield* Effect.die(new Error(`Renderer: instance "${id}" is referenced more than once (duplicate parent or cycle)`));
42
+ }
43
+ visited.add(id);
44
+ const entry = frame.instances[id];
45
+ if (entry === undefined) {
46
+ return yield* Effect.die(new Error(`Renderer: unknown instance id "${id}"`));
47
+ }
48
+ const childEntries = yield* Effect.all(childIdsOf(entry.data)
49
+ .filter((childId) => isVisible(frame, childId))
50
+ .map(buildEntry));
51
+ const entityRenderer = yield* getEntityRenderer(entry.entity);
52
+ return {
53
+ id,
54
+ render: Effect.gen(function* () {
55
+ const children = yield* Effect.all(childEntries.map((child) => child.render));
56
+ return yield* entityRenderer.render({
57
+ id,
58
+ children,
59
+ ...entry,
60
+ });
61
+ }),
62
+ entry,
63
+ };
64
+ });
65
+ const rootEntry = frame.instances[frame.root];
66
+ if (rootEntry === undefined) {
67
+ return yield* Effect.die(new Error(`Renderer: missing root instance "${frame.root}"`));
68
+ }
69
+ visited.add(frame.root);
70
+ // the root group never renders; its children are the top level
71
+ const entries = yield* Effect.all(childIdsOf(rootEntry.data)
72
+ .filter((childId) => isVisible(frame, childId))
73
+ .map(buildEntry));
74
+ return yield* config.render(entries, customConfig, {
75
+ frameRate: frame.frameRate,
76
+ width: frame.width,
77
+ height: frame.height,
78
+ backgroundColor: frame.backgroundColor,
79
+ camera: frame.camera,
80
+ });
81
+ }),
82
+ });
83
+ return {
84
+ Context: context,
85
+ layer: Layer.succeed(context, service),
86
+ makeEntityRendererContext,
87
+ makeEntityRendererService,
88
+ makeEntityRendererLayer,
89
+ };
90
+ };