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/Scene.js
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import { Context, Latch, Layer } from "effect";
|
|
2
|
+
import * as Cause from "effect/Cause";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Exit from "effect/Exit";
|
|
5
|
+
import * as Fiber from "effect/Fiber";
|
|
6
|
+
import * as Random from "effect/Random";
|
|
7
|
+
import * as Stream from "effect/Stream";
|
|
8
|
+
import * as Phaser from "./Phaser";
|
|
9
|
+
import * as Runner from "./Runner";
|
|
10
|
+
import * as Time from "./Time";
|
|
11
|
+
export const TypeId = "~motion/Scene";
|
|
12
|
+
export const make = (f) => {
|
|
13
|
+
return makeScene(Effect.scoped(Effect.gen(f)), Context.empty());
|
|
14
|
+
};
|
|
15
|
+
// annotate/annotateMerge return new scene values sharing the same body
|
|
16
|
+
const makeScene = (runnerEffect, annotations) => ({
|
|
17
|
+
[TypeId]: TypeId,
|
|
18
|
+
runner: runnerEffect,
|
|
19
|
+
annotations,
|
|
20
|
+
annotate: (key, value) => makeScene(runnerEffect, Context.add(annotations, key, value)),
|
|
21
|
+
annotateMerge: (context) => makeScene(runnerEffect, Context.merge(annotations, context)),
|
|
22
|
+
});
|
|
23
|
+
export const instantiate = Effect.fnUntraced(function* (entity, props) {
|
|
24
|
+
const runner = yield* Runner.Runner;
|
|
25
|
+
return yield* runner.instantiate(entity, props);
|
|
26
|
+
});
|
|
27
|
+
export const tick = Effect.gen(function* () {
|
|
28
|
+
const runner = yield* Runner.Runner;
|
|
29
|
+
return yield* runner.phaser.arriveAndAwaitAdvance;
|
|
30
|
+
});
|
|
31
|
+
/**
|
|
32
|
+
* Hold the scene for `duration` of scene time (frames at the runner's
|
|
33
|
+
* frame rate) — `Effect.sleep`'s sibling, but in frames, not wall time.
|
|
34
|
+
* A zero-length duration is a no-op.
|
|
35
|
+
*/
|
|
36
|
+
export const sleep = (duration) => Effect.gen(function* () {
|
|
37
|
+
const runner = yield* Runner.Runner;
|
|
38
|
+
const frames = Time.toFrames(duration, runner.settings.frameRate);
|
|
39
|
+
for (let i = 0; i < frames; i++) {
|
|
40
|
+
yield* tick;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
export const step = (runningScene) => Effect.gen(function* () {
|
|
44
|
+
// done: the scene fiber ended. awaitedCount === 0: the body and every
|
|
45
|
+
// fork completed — the scene is over even if its fiber is still
|
|
46
|
+
// winding down through finalizers. Deciding here, from synchronous
|
|
47
|
+
// bookkeeping, keeps frame counts deterministic: the hot frame loop
|
|
48
|
+
// can starve the scene fiber for many frames, so waiting for its own
|
|
49
|
+
// drain to stop backgrounds would leak extra frames.
|
|
50
|
+
if (runningScene.done || runningScene.runner.awaitedCount() === 0) {
|
|
51
|
+
// scene end: stop the backgrounds (including demoted tails), and
|
|
52
|
+
// cut the root's own tail if the body finished-and-continued —
|
|
53
|
+
// interrupting a completed fiber is a no-op, so the ordinary path
|
|
54
|
+
// is unchanged (idempotent with the scene fiber's own drain)
|
|
55
|
+
yield* Fiber.interruptAll([...runningScene.runner.backgrounds].map((b) => b.fiber));
|
|
56
|
+
yield* Fiber.interrupt(runningScene.fiber);
|
|
57
|
+
const exit = yield* Fiber.await(runningScene.fiber);
|
|
58
|
+
// propagate a failed scene's cause instead of ending silently
|
|
59
|
+
if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) {
|
|
60
|
+
return yield* Effect.failCause(exit.cause);
|
|
61
|
+
}
|
|
62
|
+
// branch failures are recorded out-of-band: our interrupt may
|
|
63
|
+
// have cut the scene fiber's drain before it could re-raise them
|
|
64
|
+
const recorded = runningScene.runner.failureCause();
|
|
65
|
+
if (recorded !== undefined) {
|
|
66
|
+
return yield* Effect.failCause(recorded);
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const { maxFrames } = runningScene.runner.settings;
|
|
71
|
+
if (runningScene.framesDelivered >= maxFrames) {
|
|
72
|
+
return yield* Effect.die(new Error(`Scene exceeded maxFrames (${maxFrames}). Raise the maxFrames setting, or set maxFrames: Infinity for an intentionally infinite scene.`));
|
|
73
|
+
}
|
|
74
|
+
yield* runningScene.runner.phaser.awaitAdvance;
|
|
75
|
+
runningScene.framesDelivered++;
|
|
76
|
+
return (yield* runningScene.runner.state);
|
|
77
|
+
});
|
|
78
|
+
export const run = (scene, settings = {}) => Effect.gen(function* () {
|
|
79
|
+
const runner = yield* Runner.Runner.make(settings);
|
|
80
|
+
let done = false;
|
|
81
|
+
// the body is itself a branch: Scene.finish inside it demotes the
|
|
82
|
+
// root (count--) while the body keeps ticking as a tail
|
|
83
|
+
const rootBranch = makeBranch(runner, "root");
|
|
84
|
+
// Once the body can no longer tick, its party slot must go — a
|
|
85
|
+
// registered-but-never-arriving root would deadlock quiescence while
|
|
86
|
+
// forks drain. finishUnsafe handles count/latch (at most once, and
|
|
87
|
+
// possibly already done by Scene.finish); the party goes here.
|
|
88
|
+
let partyReleased = false;
|
|
89
|
+
const releaseRoot = Effect.sync(() => {
|
|
90
|
+
// count/latch BEFORE deregister: deregistering can synchronously
|
|
91
|
+
// resume the frame consumer, which must observe consistent
|
|
92
|
+
// bookkeeping or it will spin out empty frames
|
|
93
|
+
rootBranch.finishUnsafe();
|
|
94
|
+
if (!partyReleased) {
|
|
95
|
+
partyReleased = true;
|
|
96
|
+
runner.phaser.deregister(1);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
// Wait for every fork's SEMANTIC end (finish or completion both
|
|
100
|
+
// remove it from the set; forks spawned while draining are picked up
|
|
101
|
+
// by the size re-check), then stop the backgrounds — including
|
|
102
|
+
// demoted tails; "scene end" includes the drain. Un-finished branch
|
|
103
|
+
// failures were recorded by their own finalizers.
|
|
104
|
+
const drain = Effect.gen(function* () {
|
|
105
|
+
while (runner.forks.size > 0) {
|
|
106
|
+
const [next] = runner.forks;
|
|
107
|
+
// biome-ignore lint/style/noNonNullAssertion: size > 0
|
|
108
|
+
yield* next.finished;
|
|
109
|
+
}
|
|
110
|
+
yield* Fiber.interruptAll([...runner.backgrounds].map((b) => b.fiber));
|
|
111
|
+
const recorded = runner.failureCause();
|
|
112
|
+
if (recorded !== undefined) {
|
|
113
|
+
return yield* Effect.failCause(recorded);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
// register the root party BEFORE forking (no startup race); the
|
|
117
|
+
// Phaser service is provided so Phaser.one / Phaser.all work inside
|
|
118
|
+
// scenes
|
|
119
|
+
const fiber = yield* Effect.uninterruptibleMask(() => Effect.suspend(() => {
|
|
120
|
+
runner.phaser.register(1);
|
|
121
|
+
runner.countAwaited(1);
|
|
122
|
+
return Effect.interruptible(scene.runner.pipe(Effect.scoped, Effect.matchCauseEffect({
|
|
123
|
+
onSuccess: () => releaseRoot.pipe(Effect.andThen(drain)),
|
|
124
|
+
// a failed body takes everything down with it. The cause
|
|
125
|
+
// is ALSO recorded out-of-band: the consumer's scene-end
|
|
126
|
+
// interrupt can cut this fiber before failCause runs,
|
|
127
|
+
// and the root branch records like any other branch
|
|
128
|
+
onFailure: (cause) => Effect.sync(() => runner.recordFailure(cause)).pipe(Effect.andThen(releaseRoot), Effect.andThen(Fiber.interruptAll([...runner.forks, ...runner.backgrounds].map((b) => b.fiber))), Effect.andThen(Effect.failCause(cause))),
|
|
129
|
+
}))).pipe(Effect.ensuring(releaseRoot), Effect.provideService(CurrentBranch, rootBranch),
|
|
130
|
+
// success, failure, or interrupt: the scene is over either way
|
|
131
|
+
Effect.ensuring(Effect.sync(() => {
|
|
132
|
+
done = true;
|
|
133
|
+
})), Effect.provide(Layer.succeed(Phaser.Phaser, runner.phaser)), Effect.provide(Layer.succeed(Runner.Runner, runner)),
|
|
134
|
+
// seeded pseudo-randomness, scoped to the scene fiber
|
|
135
|
+
Random.withSeed(runner.settings.seed), Effect.forkChild);
|
|
136
|
+
}));
|
|
137
|
+
const runningScene = {
|
|
138
|
+
runner,
|
|
139
|
+
fiber,
|
|
140
|
+
scene,
|
|
141
|
+
get done() {
|
|
142
|
+
return done;
|
|
143
|
+
},
|
|
144
|
+
framesDelivered: 0,
|
|
145
|
+
};
|
|
146
|
+
return runningScene;
|
|
147
|
+
});
|
|
148
|
+
export const stream = (scene, settings = {}) => run(scene, settings).pipe(Effect.map((runningScene) => Stream.fromEffectRepeat(step(runningScene)).pipe(
|
|
149
|
+
// refinement: the stream ends at the first null, so the
|
|
150
|
+
// element type is Frame<Entities>, not Frame | null
|
|
151
|
+
Stream.takeWhile((state) => state !== null))), Stream.unwrap);
|
|
152
|
+
const isUpdaterFn = (props) => typeof props === "function";
|
|
153
|
+
export const data = (instance) => Effect.gen(function* () {
|
|
154
|
+
const runner = yield* Runner.Runner;
|
|
155
|
+
const current = runner.getDataUnsafe(instance);
|
|
156
|
+
if (current === null) {
|
|
157
|
+
return yield* Effect.die(new Error(`Instance ${instance.id} was destroyed`));
|
|
158
|
+
}
|
|
159
|
+
return current;
|
|
160
|
+
});
|
|
161
|
+
export const update = (instance, props) => Effect.gen(function* () {
|
|
162
|
+
const runner = yield* Runner.Runner;
|
|
163
|
+
if (isUpdaterFn(props)) {
|
|
164
|
+
const current = runner.getDataUnsafe(instance);
|
|
165
|
+
// instance was destroyed: nothing to update
|
|
166
|
+
if (current === null) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
return runner.setDataUnsafe(instance, props(current));
|
|
170
|
+
}
|
|
171
|
+
return runner.setDataUnsafe(instance, props);
|
|
172
|
+
});
|
|
173
|
+
/**
|
|
174
|
+
* Move `child` under `parent`, detaching it from its current parent first
|
|
175
|
+
* (so it is never double-referenced). Instances are born mounted under the
|
|
176
|
+
* ambient parent; `appendChild` is the explicit reparent — the door to
|
|
177
|
+
* placing a lazily-created node into an existing group.
|
|
178
|
+
*/
|
|
179
|
+
export const appendChild = (parent, child) => Effect.gen(function* () {
|
|
180
|
+
const runner = yield* Runner.Runner;
|
|
181
|
+
runner.appendChild(parent, child);
|
|
182
|
+
});
|
|
183
|
+
/** Detach `child` from `parent` (no-op unless it is currently its child). */
|
|
184
|
+
export const removeChild = (parent, child) => Effect.gen(function* () {
|
|
185
|
+
const runner = yield* Runner.Runner;
|
|
186
|
+
runner.removeChild(parent, child);
|
|
187
|
+
});
|
|
188
|
+
export const settings = Effect.fnUntraced(function* () {
|
|
189
|
+
const runner = yield* Runner.Runner;
|
|
190
|
+
return runner.settings;
|
|
191
|
+
});
|
|
192
|
+
/**
|
|
193
|
+
* The active camera instance — an ordinary instance carrying `~position`
|
|
194
|
+
* (x/y pan) and a `zoom` field, so the existing animators drive it:
|
|
195
|
+
* `Scene.make(function* () { const cam = yield* Scene.camera; yield*
|
|
196
|
+
* cam.pipe(Motion.moveTo({ x: 400 })) })`. A default identity camera is
|
|
197
|
+
* always present; animate it directly, or `Scene.setCamera` to swap in
|
|
198
|
+
* another instance. The camera is never drawn.
|
|
199
|
+
*/
|
|
200
|
+
export const camera = Effect.gen(function* () {
|
|
201
|
+
const runner = yield* Runner.Runner;
|
|
202
|
+
return runner.camera;
|
|
203
|
+
});
|
|
204
|
+
/** Swap the active camera to `instance`; its live data becomes the view. */
|
|
205
|
+
export const setCamera = (instance) => Effect.gen(function* () {
|
|
206
|
+
const runner = yield* Runner.Runner;
|
|
207
|
+
runner.setCamera(instance);
|
|
208
|
+
});
|
|
209
|
+
/** the innermost enclosing branch; null = outside any running scene */
|
|
210
|
+
const CurrentBranch = Context.Reference("motion/Scene/CurrentBranch", { defaultValue: () => null });
|
|
211
|
+
const makeBranch = (runner, kind) => {
|
|
212
|
+
const latch = Latch.makeUnsafe();
|
|
213
|
+
let finished = false;
|
|
214
|
+
const entry = {
|
|
215
|
+
// assigned immediately after forking, before anyone can observe it
|
|
216
|
+
fiber: null,
|
|
217
|
+
finished: latch.await,
|
|
218
|
+
};
|
|
219
|
+
const finishUnsafe = () => {
|
|
220
|
+
if (finished) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
finished = true;
|
|
224
|
+
// demotion happens BEFORE the latch opens: awaiters resumed by the
|
|
225
|
+
// latch must observe consistent bookkeeping
|
|
226
|
+
if (kind !== "background") {
|
|
227
|
+
runner.countAwaited(-1);
|
|
228
|
+
}
|
|
229
|
+
if (kind === "fork") {
|
|
230
|
+
// fork -> background: keeps its phaser party (it may still be
|
|
231
|
+
// animating), stops holding the scene open; the tail is
|
|
232
|
+
// interrupted with the backgrounds at scene end
|
|
233
|
+
runner.forks.delete(entry);
|
|
234
|
+
runner.backgrounds.add(entry);
|
|
235
|
+
}
|
|
236
|
+
latch.openUnsafe();
|
|
237
|
+
};
|
|
238
|
+
return { entry, finishUnsafe, isFinished: () => finished };
|
|
239
|
+
};
|
|
240
|
+
/**
|
|
241
|
+
* Finish the innermost enclosing branch (the current fork, played scene,
|
|
242
|
+
* or the scene body itself): whoever awaits the branch's `finished`
|
|
243
|
+
* proceeds, the branch stops blocking its parent's end, and the code
|
|
244
|
+
* after `finish` keeps running as a TAIL — bounded by the parent, which
|
|
245
|
+
* interrupts it at scene end like a background. Idempotent; completion
|
|
246
|
+
* implies finish. NOTE: a failure in the tail (after finish) is NOT
|
|
247
|
+
* reported — by then nothing is listening.
|
|
248
|
+
*/
|
|
249
|
+
export const finish = Effect.gen(function* () {
|
|
250
|
+
const branch = yield* CurrentBranch;
|
|
251
|
+
if (branch === null) {
|
|
252
|
+
return yield* Effect.die(new Error("Scene.finish called outside a running scene"));
|
|
253
|
+
}
|
|
254
|
+
branch.finishUnsafe();
|
|
255
|
+
});
|
|
256
|
+
// shared by fork/background/play: register the party synchronously,
|
|
257
|
+
// fork, record un-finished failures, finish implicitly on completion
|
|
258
|
+
const forkBranch = (runner, effect, kind) => Effect.gen(function* () {
|
|
259
|
+
const branch = makeBranch(runner, kind);
|
|
260
|
+
if (kind === "fork") {
|
|
261
|
+
// counted before the fork (no gap); undone exactly once by the
|
|
262
|
+
// branch's finish/completion — synchronous with its party release
|
|
263
|
+
runner.countAwaited(1);
|
|
264
|
+
}
|
|
265
|
+
const fiber = yield* Phaser.run(runner.phaser, effect.pipe(Effect.onExit((exit) => Effect.sync(() => {
|
|
266
|
+
// tail failures (post-finish) are deliberately dropped
|
|
267
|
+
if (!branch.isFinished() &&
|
|
268
|
+
Exit.isFailure(exit) &&
|
|
269
|
+
!Cause.hasInterruptsOnly(exit.cause)) {
|
|
270
|
+
runner.recordFailure(exit.cause);
|
|
271
|
+
}
|
|
272
|
+
branch.finishUnsafe();
|
|
273
|
+
})), Effect.provideService(CurrentBranch, branch)));
|
|
274
|
+
branch.entry.fiber = fiber;
|
|
275
|
+
if (kind === "fork" && !branch.isFinished()) {
|
|
276
|
+
runner.forks.add(branch.entry);
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
// backgrounds — and forks that completed synchronously before we
|
|
280
|
+
// could track them (their demotion already targeted these sets)
|
|
281
|
+
runner.backgrounds.add(branch.entry);
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
// the PUBLIC wait ticks while waiting: an awaiting scene fiber is
|
|
285
|
+
// a phaser party and must keep arriving, or quiescence deadlocks.
|
|
286
|
+
// (The internal drain awaits the latch instead — it holds no
|
|
287
|
+
// party by the time it runs.)
|
|
288
|
+
finished: Effect.gen(function* () {
|
|
289
|
+
while (!branch.isFinished()) {
|
|
290
|
+
yield* tick;
|
|
291
|
+
}
|
|
292
|
+
}),
|
|
293
|
+
fiber,
|
|
294
|
+
};
|
|
295
|
+
});
|
|
296
|
+
// the phaser's phase counter IS the current frame index
|
|
297
|
+
const frameOf = (runner) => runner.phaser.snapshotUnsafe().phase;
|
|
298
|
+
/**
|
|
299
|
+
* Run `effect`, then repeat it as long as `schedule` recurs, with the
|
|
300
|
+
* schedule evaluated in scene time (frames at the runner's frame rate) —
|
|
301
|
+
* `Effect.repeat`'s sibling, but paced by frames instead of the wall
|
|
302
|
+
* clock. The first run is immediate; the schedule paces the gaps after
|
|
303
|
+
* runs; each run's result is fed to the schedule as input. Resolves with
|
|
304
|
+
* the schedule's final output once it is done; a failed run fails
|
|
305
|
+
* immediately without consulting the schedule again.
|
|
306
|
+
*/
|
|
307
|
+
export const repeat = (effect, schedule) => Effect.gen(function* () {
|
|
308
|
+
const runner = yield* Runner.Runner;
|
|
309
|
+
const driver = yield* Time.scheduleDriver(schedule, runner.settings.frameRate);
|
|
310
|
+
const currentFrame = () => frameOf(runner);
|
|
311
|
+
while (true) {
|
|
312
|
+
const result = yield* effect;
|
|
313
|
+
const decision = yield* driver.next(currentFrame(), result);
|
|
314
|
+
if (decision.done) {
|
|
315
|
+
return decision.output;
|
|
316
|
+
}
|
|
317
|
+
while (currentFrame() < decision.frame) {
|
|
318
|
+
yield* tick;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
/**
|
|
323
|
+
* Run `effect` concurrently with the rest of the scene, sharing frame
|
|
324
|
+
* phases, and return its fiber immediately.
|
|
325
|
+
*
|
|
326
|
+
* NOTE: this inverts Effect's own `fork` semantics — the scene's end
|
|
327
|
+
* WAITS for forked work. A scene whose body returns while forks are
|
|
328
|
+
* still animating keeps producing frames until the last fork finishes
|
|
329
|
+
* (so a scene containing only a fork still plays). Use
|
|
330
|
+
* {@link background} for work that should be cut off at scene end
|
|
331
|
+
* instead. Forks are supervised by the fiber that spawned them: a fork
|
|
332
|
+
* made inside another fork is interrupted when its spawner completes.
|
|
333
|
+
*/
|
|
334
|
+
export const fork = (effect) => Effect.gen(function* () {
|
|
335
|
+
const runner = yield* Runner.Runner;
|
|
336
|
+
return yield* forkBranch(runner, effect, "fork");
|
|
337
|
+
});
|
|
338
|
+
/**
|
|
339
|
+
* Like {@link fork}, but the fiber is INTERRUPTED at scene end instead
|
|
340
|
+
* of awaited — for indefinite work (`Scene.repeat(…, Schedule.forever)`)
|
|
341
|
+
* that should play for the duration of the scene without keeping it
|
|
342
|
+
* alive. "Scene end" includes the fork drain: backgrounds keep animating
|
|
343
|
+
* while awaited forks finish, and are stopped after the last one.
|
|
344
|
+
*/
|
|
345
|
+
export const background = (effect) => Effect.gen(function* () {
|
|
346
|
+
const runner = yield* Runner.Runner;
|
|
347
|
+
return yield* forkBranch(runner, effect, "background");
|
|
348
|
+
});
|
|
349
|
+
/**
|
|
350
|
+
* Play a scene as a branch of the current scene — the explicit door to
|
|
351
|
+
* nesting. The child shares the movie's runner, phaser, frame rate, and
|
|
352
|
+
* frame cap, and gets its own scope, branch handle, mount parent, and a
|
|
353
|
+
* FRESH seeded Random stream: `play(scene)` inside a movie seeded `S`
|
|
354
|
+
* animates exactly like `run(scene, { seed: S })` standalone. Awaited
|
|
355
|
+
* like a fork — `yield* handle.finished` for sequential nesting, or
|
|
356
|
+
* don't await for concurrent scenes.
|
|
357
|
+
*/
|
|
358
|
+
export const play = (scene, options) => Effect.gen(function* () {
|
|
359
|
+
const runner = yield* Runner.Runner;
|
|
360
|
+
const mounted = options?.parent === undefined
|
|
361
|
+
? scene.runner.pipe(Effect.scoped)
|
|
362
|
+
: scene.runner.pipe(Effect.scoped, Effect.provideService(Runner.CurrentParent, options.parent));
|
|
363
|
+
const body = mounted.pipe(
|
|
364
|
+
// fresh stream per evaluation: nested playback must equal a
|
|
365
|
+
// standalone run with the same seed, never inherit the parent's
|
|
366
|
+
// stream position
|
|
367
|
+
Random.withSeed(options?.seed ?? runner.settings.seed));
|
|
368
|
+
return (yield* forkBranch(runner, body, "fork"));
|
|
369
|
+
});
|
|
370
|
+
/**
|
|
371
|
+
* Run effects in lockstep parallel, sharing frame phases — the public
|
|
372
|
+
* counterpart to the low-level `Phaser.all`. Takes no schedule: pacing a
|
|
373
|
+
* list sequentially belongs to {@link chain}, overlapping staggered
|
|
374
|
+
* starts to {@link stagger}.
|
|
375
|
+
*/
|
|
376
|
+
export const all = (effects) => Phaser.all(effects);
|
|
377
|
+
/**
|
|
378
|
+
* Run items one at a time, in order — items NEVER overlap, mirroring
|
|
379
|
+
* Effect's guarantee for scheduled effects. The first item runs
|
|
380
|
+
* immediately; after each item completes, `schedule` is stepped once
|
|
381
|
+
* (with the item's result as input) to pace the next start. `fixed`
|
|
382
|
+
* gives a start cadence with catch-up, `spaced` gives rests between
|
|
383
|
+
* items. When the schedule ends early, the remaining items are skipped —
|
|
384
|
+
* it is the release policy, including how many. Without a schedule,
|
|
385
|
+
* plain sequential composition. Resolves with how many items completed.
|
|
386
|
+
* For overlapping runs, reach for {@link stagger} or {@link fork}
|
|
387
|
+
* explicitly.
|
|
388
|
+
*/
|
|
389
|
+
export const chain = (effects, schedule) => Effect.gen(function* () {
|
|
390
|
+
const list = Array.from(effects);
|
|
391
|
+
const runner = yield* Runner.Runner;
|
|
392
|
+
const driver = schedule === undefined
|
|
393
|
+
? undefined
|
|
394
|
+
: yield* Time.scheduleDriver(schedule, runner.settings.frameRate);
|
|
395
|
+
let completed = 0;
|
|
396
|
+
for (const effect of list) {
|
|
397
|
+
const result = yield* effect;
|
|
398
|
+
completed++;
|
|
399
|
+
// no step after the last item: no tail, no recurrence consumed
|
|
400
|
+
if (completed === list.length || driver === undefined) {
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
const decision = yield* driver.next(frameOf(runner), result);
|
|
404
|
+
if (decision.done) {
|
|
405
|
+
// schedule over: the remaining items are skipped
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
while (frameOf(runner) < decision.frame) {
|
|
409
|
+
yield* tick;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return { completed };
|
|
413
|
+
});
|
|
414
|
+
/**
|
|
415
|
+
* Release effects on `schedule` with OVERLAP: the first starts
|
|
416
|
+
* immediately, each next one on the schedule's next emission, and
|
|
417
|
+
* released effects run concurrently — semantically
|
|
418
|
+
* `chain(effects.map(Scene.fork))`, but resolving when all released
|
|
419
|
+
* effects finish rather than at the last release. When the schedule ends
|
|
420
|
+
* early, the remaining effects are skipped. Overlap is this
|
|
421
|
+
* combinator's purpose; the schedule-paced default ({@link chain})
|
|
422
|
+
* never overlaps.
|
|
423
|
+
*/
|
|
424
|
+
export const stagger = (effects, schedule) => Effect.gen(function* () {
|
|
425
|
+
const list = Array.from(effects);
|
|
426
|
+
const runner = yield* Runner.Runner;
|
|
427
|
+
const driver = yield* Time.scheduleDriver(schedule, runner.settings.frameRate);
|
|
428
|
+
// Stagger decisions don't depend on the effects' results, so every
|
|
429
|
+
// release frame is decided up front — still exactly one driver step
|
|
430
|
+
// per release, each fed the scene time it would observe live. The
|
|
431
|
+
// branches then tick to their frame inside a plain Phaser.all, which
|
|
432
|
+
// owns all party accounting.
|
|
433
|
+
const start = frameOf(runner);
|
|
434
|
+
const releaseFrames = list.length > 0 ? [start] : [];
|
|
435
|
+
let now = start;
|
|
436
|
+
while (releaseFrames.length < list.length) {
|
|
437
|
+
const decision = yield* driver.next(now, void 0);
|
|
438
|
+
if (decision.done) {
|
|
439
|
+
// schedule over: the remaining effects are skipped
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
now = Math.max(now, decision.frame);
|
|
443
|
+
releaseFrames.push(now);
|
|
444
|
+
}
|
|
445
|
+
const branches = releaseFrames.map((frame, i) => Effect.gen(function* () {
|
|
446
|
+
while (frameOf(runner) < frame) {
|
|
447
|
+
yield* tick;
|
|
448
|
+
}
|
|
449
|
+
// biome-ignore lint/style/noNonNullAssertion: same length as releaseFrames
|
|
450
|
+
yield* list[i];
|
|
451
|
+
}));
|
|
452
|
+
yield* Phaser.all(branches);
|
|
453
|
+
return { released: releaseFrames.length };
|
|
454
|
+
});
|
package/dist/Time.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as Duration from "effect/Duration";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Schedule from "effect/Schedule";
|
|
4
|
+
export declare const toFrames: (duration: Duration.Input, fps: number) => number;
|
|
5
|
+
/** exact scene time in ms of a frame index — deliberately not rounded */
|
|
6
|
+
export declare const frameToMillis: (frame: number, fps: number) => number;
|
|
7
|
+
export type StepDecision<Output> = {
|
|
8
|
+
readonly done: false;
|
|
9
|
+
readonly output: Output;
|
|
10
|
+
/** absolute frame at which the next recurrence is due */
|
|
11
|
+
readonly frame: number;
|
|
12
|
+
} | {
|
|
13
|
+
readonly done: true;
|
|
14
|
+
readonly output: Output;
|
|
15
|
+
};
|
|
16
|
+
export interface ScheduleDriver<Output, Input, Error, Env> {
|
|
17
|
+
readonly next: (nowFrame: number, input: Input) => Effect.Effect<StepDecision<Output>, Error, Env>;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Drive an Effect `Schedule` in scene time (frames at `fps`). Internal.
|
|
21
|
+
*
|
|
22
|
+
* Contract: call `next` exactly once per decision, at the moment of the
|
|
23
|
+
* decision (schedule start or effect/release completion) — NEVER poll it
|
|
24
|
+
* per frame. Schedules like `Schedule.spaced` compute their target
|
|
25
|
+
* relative to the `now` they are called with, so polling pushes the
|
|
26
|
+
* target forward forever.
|
|
27
|
+
*
|
|
28
|
+
* Each absolute target (now + delay) resolves once to the FIRST frame at
|
|
29
|
+
* or after it (ceil, never round): frames are discrete, and a frame
|
|
30
|
+
* before the target would make the next decision happen before the
|
|
31
|
+
* schedule's boundary — a stateful schedule like `fixed` would then
|
|
32
|
+
* re-emit the same boundary forever (observed as a whole `recurs` budget
|
|
33
|
+
* burning in one frame). Rounded deltas are never accumulated, so
|
|
34
|
+
* non-frame-aligned schedules keep their own continuous bookkeeping and
|
|
35
|
+
* do not drift. A zero delay resolves to the current frame — callers run
|
|
36
|
+
* without ticking.
|
|
37
|
+
*/
|
|
38
|
+
export declare const scheduleDriver: <Output, Input, Error, Env>(schedule: Schedule.Schedule<Output, Input, Error, Env>, fps: number) => Effect.Effect<ScheduleDriver<Output, Input, Error, Env>, never, Env>;
|
package/dist/Time.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import * as Duration from "effect/Duration";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Pull from "effect/Pull";
|
|
4
|
+
import * as Schedule from "effect/Schedule";
|
|
5
|
+
export const toFrames = (duration, fps) => {
|
|
6
|
+
return Math.round(Duration.toSeconds(duration) * fps);
|
|
7
|
+
};
|
|
8
|
+
/** exact scene time in ms of a frame index — deliberately not rounded */
|
|
9
|
+
export const frameToMillis = (frame, fps) => (frame * 1000) / fps;
|
|
10
|
+
/**
|
|
11
|
+
* Drive an Effect `Schedule` in scene time (frames at `fps`). Internal.
|
|
12
|
+
*
|
|
13
|
+
* Contract: call `next` exactly once per decision, at the moment of the
|
|
14
|
+
* decision (schedule start or effect/release completion) — NEVER poll it
|
|
15
|
+
* per frame. Schedules like `Schedule.spaced` compute their target
|
|
16
|
+
* relative to the `now` they are called with, so polling pushes the
|
|
17
|
+
* target forward forever.
|
|
18
|
+
*
|
|
19
|
+
* Each absolute target (now + delay) resolves once to the FIRST frame at
|
|
20
|
+
* or after it (ceil, never round): frames are discrete, and a frame
|
|
21
|
+
* before the target would make the next decision happen before the
|
|
22
|
+
* schedule's boundary — a stateful schedule like `fixed` would then
|
|
23
|
+
* re-emit the same boundary forever (observed as a whole `recurs` budget
|
|
24
|
+
* burning in one frame). Rounded deltas are never accumulated, so
|
|
25
|
+
* non-frame-aligned schedules keep their own continuous bookkeeping and
|
|
26
|
+
* do not drift. A zero delay resolves to the current frame — callers run
|
|
27
|
+
* without ticking.
|
|
28
|
+
*/
|
|
29
|
+
export const scheduleDriver = (schedule, fps) => Effect.map(Schedule.toStep(schedule), (step) => ({
|
|
30
|
+
next: (nowFrame, input) => {
|
|
31
|
+
const nowMs = frameToMillis(nowFrame, fps);
|
|
32
|
+
return step(nowMs, input).pipe(Effect.map(([output, delay]) => ({
|
|
33
|
+
done: false,
|
|
34
|
+
output,
|
|
35
|
+
// epsilon absorbs float noise so an exact-on-frame target
|
|
36
|
+
// (e.g. 1000ms at 60fps) doesn't ceil up a frame
|
|
37
|
+
frame: Math.ceil(((nowMs + Duration.toMillis(delay)) * fps) / 1000 - 1e-9),
|
|
38
|
+
})), Pull.catchDone((output) => Effect.succeed({
|
|
39
|
+
done: true,
|
|
40
|
+
output: output,
|
|
41
|
+
})));
|
|
42
|
+
},
|
|
43
|
+
}));
|
package/dist/Timing.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timing (easing) functions: map linear progress t in [0, 1] to eased
|
|
3
|
+
* progress. Every non-periodic easing satisfies f(0) = 0 and f(1) = 1,
|
|
4
|
+
* so tweens land exactly on their target. `sin`/`cos` are periodic
|
|
5
|
+
* helpers over one full cycle and deliberately do NOT end at 1.
|
|
6
|
+
* Back/Elastic curves overshoot outside [0, 1] mid-animation by design —
|
|
7
|
+
* consumers must extrapolate, not clamp.
|
|
8
|
+
*/
|
|
9
|
+
export type TimingFunction = (t: number) => number;
|
|
10
|
+
export declare const linear: TimingFunction;
|
|
11
|
+
/** one full sine cycle: 0 → 1 → 0 */
|
|
12
|
+
export declare const sin: TimingFunction;
|
|
13
|
+
/** one full cosine cycle: 1 → 0 → 1 */
|
|
14
|
+
export declare const cos: TimingFunction;
|
|
15
|
+
export declare const easeInSine: TimingFunction;
|
|
16
|
+
export declare const easeOutSine: TimingFunction;
|
|
17
|
+
export declare const easeInOutSine: TimingFunction;
|
|
18
|
+
export declare const easeInQuad: TimingFunction;
|
|
19
|
+
export declare const easeOutQuad: TimingFunction;
|
|
20
|
+
export declare const easeInOutQuad: TimingFunction;
|
|
21
|
+
export declare const easeInCubic: TimingFunction;
|
|
22
|
+
export declare const easeOutCubic: TimingFunction;
|
|
23
|
+
export declare const easeInOutCubic: TimingFunction;
|
|
24
|
+
export declare const easeInQuart: TimingFunction;
|
|
25
|
+
export declare const easeOutQuart: TimingFunction;
|
|
26
|
+
export declare const easeInOutQuart: TimingFunction;
|
|
27
|
+
export declare const easeInQuint: TimingFunction;
|
|
28
|
+
export declare const easeOutQuint: TimingFunction;
|
|
29
|
+
export declare const easeInOutQuint: TimingFunction;
|
|
30
|
+
export declare const easeInExpo: TimingFunction;
|
|
31
|
+
export declare const easeOutExpo: TimingFunction;
|
|
32
|
+
export declare const easeInOutExpo: TimingFunction;
|
|
33
|
+
export declare const easeInCirc: TimingFunction;
|
|
34
|
+
export declare const easeOutCirc: TimingFunction;
|
|
35
|
+
export declare const easeInOutCirc: TimingFunction;
|
|
36
|
+
/** `s` is the overshoot amount */
|
|
37
|
+
export declare const createEaseInBack: (s?: number) => TimingFunction;
|
|
38
|
+
export declare const createEaseOutBack: (s?: number) => TimingFunction;
|
|
39
|
+
export declare const createEaseInOutBack: (s?: number, v?: number) => TimingFunction;
|
|
40
|
+
/** `s` is the angular frequency (default 2π/3) */
|
|
41
|
+
export declare const createEaseInElastic: (s?: number) => TimingFunction;
|
|
42
|
+
export declare const createEaseOutElastic: (s?: number) => TimingFunction;
|
|
43
|
+
/** `s` is the angular frequency (default 2π/4.5) */
|
|
44
|
+
export declare const createEaseInOutElastic: (s?: number) => TimingFunction;
|
|
45
|
+
/** `n` is the bounce stiffness, `d` the interval divisor */
|
|
46
|
+
export declare const createEaseOutBounce: (n?: number, d?: number) => TimingFunction;
|
|
47
|
+
export declare const createEaseInBounce: (n?: number, d?: number) => TimingFunction;
|
|
48
|
+
export declare const createEaseInOutBounce: (n?: number, d?: number) => TimingFunction;
|
|
49
|
+
export declare const easeInBack: TimingFunction;
|
|
50
|
+
export declare const easeOutBack: TimingFunction;
|
|
51
|
+
export declare const easeInOutBack: TimingFunction;
|
|
52
|
+
export declare const easeInElastic: TimingFunction;
|
|
53
|
+
export declare const easeOutElastic: TimingFunction;
|
|
54
|
+
export declare const easeInOutElastic: TimingFunction;
|
|
55
|
+
export declare const easeInBounce: TimingFunction;
|
|
56
|
+
export declare const easeOutBounce: TimingFunction;
|
|
57
|
+
export declare const easeInOutBounce: TimingFunction;
|
|
58
|
+
export declare const timingFunctions: {
|
|
59
|
+
readonly linear: TimingFunction;
|
|
60
|
+
readonly sin: TimingFunction;
|
|
61
|
+
readonly cos: TimingFunction;
|
|
62
|
+
readonly easeInSine: TimingFunction;
|
|
63
|
+
readonly easeOutSine: TimingFunction;
|
|
64
|
+
readonly easeInOutSine: TimingFunction;
|
|
65
|
+
readonly easeInQuad: TimingFunction;
|
|
66
|
+
readonly easeOutQuad: TimingFunction;
|
|
67
|
+
readonly easeInOutQuad: TimingFunction;
|
|
68
|
+
readonly easeInCubic: TimingFunction;
|
|
69
|
+
readonly easeOutCubic: TimingFunction;
|
|
70
|
+
readonly easeInOutCubic: TimingFunction;
|
|
71
|
+
readonly easeInQuart: TimingFunction;
|
|
72
|
+
readonly easeOutQuart: TimingFunction;
|
|
73
|
+
readonly easeInOutQuart: TimingFunction;
|
|
74
|
+
readonly easeInQuint: TimingFunction;
|
|
75
|
+
readonly easeOutQuint: TimingFunction;
|
|
76
|
+
readonly easeInOutQuint: TimingFunction;
|
|
77
|
+
readonly easeInExpo: TimingFunction;
|
|
78
|
+
readonly easeOutExpo: TimingFunction;
|
|
79
|
+
readonly easeInOutExpo: TimingFunction;
|
|
80
|
+
readonly easeInCirc: TimingFunction;
|
|
81
|
+
readonly easeOutCirc: TimingFunction;
|
|
82
|
+
readonly easeInOutCirc: TimingFunction;
|
|
83
|
+
readonly easeInBack: TimingFunction;
|
|
84
|
+
readonly easeOutBack: TimingFunction;
|
|
85
|
+
readonly easeInOutBack: TimingFunction;
|
|
86
|
+
readonly easeInElastic: TimingFunction;
|
|
87
|
+
readonly easeOutElastic: TimingFunction;
|
|
88
|
+
readonly easeInOutElastic: TimingFunction;
|
|
89
|
+
readonly easeInBounce: TimingFunction;
|
|
90
|
+
readonly easeOutBounce: TimingFunction;
|
|
91
|
+
readonly easeInOutBounce: TimingFunction;
|
|
92
|
+
};
|
|
93
|
+
export type TimingFunctionName = keyof typeof timingFunctions;
|
|
94
|
+
/** a built-in name (autocompleted) or a custom timing function */
|
|
95
|
+
export type TimingInput = TimingFunctionName | TimingFunction;
|
|
96
|
+
export declare const resolve: (input: TimingInput) => TimingFunction;
|