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.
- package/README.md +40 -0
- package/dist/Camera.d.ts +49 -0
- package/dist/Camera.js +33 -0
- package/dist/Entity.d.ts +40 -0
- package/dist/Entity.js +32 -0
- package/dist/Fonts.d.ts +31 -0
- package/dist/Fonts.js +11 -0
- package/dist/Instance.d.ts +16 -0
- package/dist/Instance.js +18 -0
- package/dist/Motion.d.ts +74 -0
- package/dist/Motion.js +125 -0
- package/dist/Phaser.d.ts +65 -0
- package/dist/Phaser.js +170 -0
- package/dist/Physics.d.ts +78 -0
- package/dist/Physics.js +117 -0
- package/dist/Renderer.d.ts +69 -0
- package/dist/Renderer.js +90 -0
- package/dist/Runner.d.ts +360 -0
- package/dist/Runner.js +257 -0
- package/dist/Scene.d.ts +241 -0
- package/dist/Scene.js +454 -0
- package/dist/Time.d.ts +38 -0
- package/dist/Time.js +43 -0
- package/dist/Timing.d.ts +96 -0
- package/dist/Timing.js +151 -0
- package/dist/demo.d.ts +186 -0
- package/dist/demo.js +76 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +13 -0
- package/dist/particles/Particle.d.ts +91 -0
- package/dist/particles/Particle.js +1 -0
- package/dist/particles/ParticleField.d.ts +170 -0
- package/dist/particles/ParticleField.js +69 -0
- package/dist/particles/Prng.d.ts +28 -0
- package/dist/particles/Prng.js +43 -0
- package/dist/particles/constructors.d.ts +78 -0
- package/dist/particles/constructors.js +15 -0
- package/dist/particles/index.d.ts +6 -0
- package/dist/particles/index.js +6 -0
- package/dist/particles/overLife.d.ts +16 -0
- package/dist/particles/overLife.js +21 -0
- package/dist/particles/render.d.ts +13 -0
- package/dist/particles/render.js +33 -0
- package/dist/particles/simulate.d.ts +39 -0
- package/dist/particles/simulate.js +81 -0
- package/dist/particles/step.d.ts +24 -0
- package/dist/particles/step.js +167 -0
- package/dist/shapes/Circle.d.ts +37 -0
- package/dist/shapes/Circle.js +9 -0
- package/dist/shapes/Ellipse.d.ts +41 -0
- package/dist/shapes/Ellipse.js +10 -0
- package/dist/shapes/Group.d.ts +150 -0
- package/dist/shapes/Group.js +82 -0
- package/dist/shapes/Layer.d.ts +9 -0
- package/dist/shapes/Layer.js +23 -0
- package/dist/shapes/Line.d.ts +47 -0
- package/dist/shapes/Line.js +30 -0
- package/dist/shapes/Path.d.ts +38 -0
- package/dist/shapes/Path.js +13 -0
- package/dist/shapes/Rect.d.ts +41 -0
- package/dist/shapes/Rect.js +10 -0
- package/dist/shapes/Shape2D.d.ts +40 -0
- package/dist/shapes/Shape2D.js +41 -0
- package/dist/shapes/Square.d.ts +37 -0
- package/dist/shapes/Square.js +11 -0
- package/dist/shapes/Text.d.ts +60 -0
- package/dist/shapes/Text.js +24 -0
- package/dist/shapes/index.d.ts +10 -0
- package/dist/shapes/index.js +10 -0
- package/dist/svg/SvgDomRenderer.d.ts +28 -0
- package/dist/svg/SvgDomRenderer.js +50 -0
- package/dist/svg/SvgNode.d.ts +13 -0
- package/dist/svg/SvgNode.js +18 -0
- package/dist/svg/SvgRenderer.d.ts +22 -0
- package/dist/svg/SvgRenderer.js +20 -0
- package/dist/svg/camera.d.ts +21 -0
- package/dist/svg/camera.js +43 -0
- package/dist/svg/index.d.ts +6 -0
- package/dist/svg/index.js +6 -0
- package/dist/svg/layers.d.ts +18 -0
- package/dist/svg/layers.js +13 -0
- package/dist/svg/shapes.d.ts +1089 -0
- package/dist/svg/shapes.js +119 -0
- 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 {};
|
package/dist/Physics.js
ADDED
|
@@ -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 {};
|
package/dist/Renderer.js
ADDED
|
@@ -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
|
+
};
|