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/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
+ }));
@@ -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;