effect-motion 0.3.2 → 0.5.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 (91) hide show
  1. package/README.md +2 -2
  2. package/dist/Camera.d.ts +186 -49
  3. package/dist/Camera.js +343 -76
  4. package/dist/Color.d.ts +101 -1
  5. package/dist/Color.js +101 -1
  6. package/dist/EffectMotionError.d.ts +17 -0
  7. package/dist/EffectMotionError.js +17 -0
  8. package/dist/Entity.d.ts +682 -38
  9. package/dist/Entity.js +271 -27
  10. package/dist/Font.d.ts +108 -0
  11. package/dist/Font.js +95 -0
  12. package/dist/Image.d.ts +71 -0
  13. package/dist/Image.js +50 -0
  14. package/dist/Instance.d.ts +73 -11
  15. package/dist/Instance.js +44 -11
  16. package/dist/Motion.d.ts +328 -53
  17. package/dist/Motion.js +278 -46
  18. package/dist/Physics.d.ts +154 -20
  19. package/dist/Physics.js +92 -11
  20. package/dist/Projection.d.ts +37 -132
  21. package/dist/Projection.js +33 -292
  22. package/dist/Resource.d.ts +26 -0
  23. package/dist/Resource.js +41 -0
  24. package/dist/Runner.d.ts +653 -330
  25. package/dist/Runner.js +208 -158
  26. package/dist/Scene.d.ts +621 -171
  27. package/dist/Scene.js +620 -89
  28. package/dist/Timing.d.ts +170 -13
  29. package/dist/Timing.js +124 -6
  30. package/dist/Tree.d.ts +39 -0
  31. package/dist/Tree.js +127 -0
  32. package/dist/index.d.ts +54 -5
  33. package/dist/index.js +56 -5
  34. package/dist/particles/Particle.d.ts +2 -2
  35. package/dist/particles/ParticleField.d.ts +8 -6
  36. package/dist/particles/ParticleField.js +8 -9
  37. package/dist/particles/constructors.d.ts +5 -6
  38. package/dist/particles/constructors.js +8 -3
  39. package/dist/particles/legacy.d.ts +58 -0
  40. package/dist/particles/legacy.js +46 -0
  41. package/dist/particles/simulate.js +13 -5
  42. package/dist/particles/step.js +14 -10
  43. package/dist/types.d.ts +5 -0
  44. package/dist/types.js +1 -0
  45. package/package.json +2 -4
  46. package/dist/CameraHelpers.d.ts +0 -70
  47. package/dist/CameraHelpers.js +0 -239
  48. package/dist/CanvasExporter.d.ts +0 -12
  49. package/dist/CanvasExporter.js +0 -40
  50. package/dist/Fonts.d.ts +0 -41
  51. package/dist/Fonts.js +0 -27
  52. package/dist/Images.d.ts +0 -33
  53. package/dist/Images.js +0 -24
  54. package/dist/PngExporter.d.ts +0 -6
  55. package/dist/PngExporter.js +0 -85
  56. package/dist/Renderer.d.ts +0 -118
  57. package/dist/Renderer.js +0 -381
  58. package/dist/Shapes.d.ts +0 -11
  59. package/dist/Shapes.js +0 -11
  60. package/dist/demo.d.ts +0 -3
  61. package/dist/demo.js +0 -65
  62. package/dist/render/dof.d.ts +0 -27
  63. package/dist/render/dof.js +0 -37
  64. package/dist/render/paint.d.ts +0 -30
  65. package/dist/render/paint.js +0 -36
  66. package/dist/render/shapes.d.ts +0 -42
  67. package/dist/render/shapes.js +0 -310
  68. package/dist/shapes/Circle.d.ts +0 -32
  69. package/dist/shapes/Circle.js +0 -9
  70. package/dist/shapes/Ellipse.d.ts +0 -35
  71. package/dist/shapes/Ellipse.js +0 -10
  72. package/dist/shapes/Group.d.ts +0 -116
  73. package/dist/shapes/Group.js +0 -82
  74. package/dist/shapes/Hud.d.ts +0 -38
  75. package/dist/shapes/Hud.js +0 -35
  76. package/dist/shapes/Image.d.ts +0 -45
  77. package/dist/shapes/Image.js +0 -28
  78. package/dist/shapes/Line.d.ts +0 -47
  79. package/dist/shapes/Line.js +0 -35
  80. package/dist/shapes/Path.d.ts +0 -107
  81. package/dist/shapes/Path.js +0 -32
  82. package/dist/shapes/Rect.d.ts +0 -51
  83. package/dist/shapes/Rect.js +0 -22
  84. package/dist/shapes/Shape2D.d.ts +0 -49
  85. package/dist/shapes/Shape2D.js +0 -52
  86. package/dist/shapes/Shapes.d.ts +0 -11
  87. package/dist/shapes/Shapes.js +0 -11
  88. package/dist/shapes/Square.d.ts +0 -32
  89. package/dist/shapes/Square.js +0 -11
  90. package/dist/shapes/Text.d.ts +0 -51
  91. package/dist/shapes/Text.js +0 -24
package/dist/Scene.js CHANGED
@@ -1,37 +1,187 @@
1
- import { Context, Latch, Layer } from "effect";
1
+ /**
2
+ * Authoring and running scenes — the module you start from.
3
+ *
4
+ * @remarks
5
+ * A scene is a generator function that creates entities and yields
6
+ * animations. It is a pure description: running it twice produces the same
7
+ * frames, because scene time is counted in FRAMES rather than read from a
8
+ * clock, and randomness comes from a seeded generator.
9
+ *
10
+ * The surface divides into three jobs:
11
+ *
12
+ * - **Authoring** — {@link make} to declare a scene, {@link instantiate} to
13
+ * put something in it, {@link sleep} to hold, {@link data} / {@link update}
14
+ * to read and write entity state directly.
15
+ * - **Composition** — {@link all} (together), {@link chain} (one after
16
+ * another), {@link stagger} (overlapping), {@link repeat} (again, on a
17
+ * schedule), {@link fork} / {@link background} (alongside), {@link play}
18
+ * (a whole scene nested inside another).
19
+ * - **Consumption** — {@link run} to start one, {@link stream} to pull its
20
+ * frames lazily, {@link step} to advance it a frame at a time.
21
+ *
22
+ * Concurrency is frame-synchronized. Animations running "at the same time"
23
+ * all advance exactly one frame per tick and wait for each other at a
24
+ * barrier, so adding concurrency never changes the frames a scene produces —
25
+ * only how they are written.
26
+ *
27
+ * @example
28
+ * A complete scene: two shapes, one moving after the other.
29
+ * ```typescript
30
+ * import * as Motion from "effect-motion/Motion";
31
+ * import * as Scene from "effect-motion/Scene";
32
+ *
33
+ * const scene = Scene.make(
34
+ * function* () {
35
+ * const dot = yield* Scene.instantiate("Circle", { radius: 20 });
36
+ * yield* dot.pipe(Motion.moveTo({ x: 400 }, "1 second"));
37
+ * },
38
+ * { width: 500, height: 300 },
39
+ * );
40
+ * ```
41
+ */
42
+ import { Latch, Layer } from "effect";
2
43
  import * as Cause from "effect/Cause";
44
+ import * as Context from "effect/Context";
3
45
  import * as Effect from "effect/Effect";
4
46
  import * as Exit from "effect/Exit";
5
47
  import * as Fiber from "effect/Fiber";
6
48
  import * as Random from "effect/Random";
7
49
  import * as Stream from "effect/Stream";
50
+ import * as Entity from "./Entity.js";
8
51
  import * as Phaser from "./Phaser.js";
9
52
  import * as Runner from "./Runner.js";
10
53
  import * as Time from "./Time.js";
11
54
  export const TypeId = "~motion/Scene";
12
- export const make = (f) => {
13
- return makeScene(Effect.scoped(Effect.gen(f)), Context.empty());
55
+ /**
56
+ * Declare a scene from a generator body.
57
+ *
58
+ * @remarks
59
+ * The generator is where a scene is written: `yield*` an
60
+ * {@link instantiate} to create something, `yield*` an animator to move it.
61
+ * Yielding is what makes time pass — statements between yields all happen on
62
+ * the same frame.
63
+ *
64
+ * The body does NOT run here. `make` captures it, so a scene can be
65
+ * declared once at module scope and run many times; each run re-executes
66
+ * the body from scratch with fresh entities.
67
+ *
68
+ * `meta` sets what the composition IS — its pixel dimensions and background.
69
+ * That is distinct from playback settings like frame rate and seed, which
70
+ * are chosen later at {@link run} / {@link stream}, because the same scene
71
+ * may legitimately be played back at different rates.
72
+ *
73
+ * An optional leading `name` is a display label for pickers and tooling
74
+ * only; it is never read during playback.
75
+ *
76
+ * @param f - The scene body.
77
+ * @param meta - Composition config: `width`, `height`, `backgroundColor`.
78
+ * @defaultValue `meta` — 1920×1080, transparent background
79
+ * @returns An inert {@link Scene}, ready to run, stream, or play.
80
+ *
81
+ * @example
82
+ * A named 500×300 scene on a dark background.
83
+ * ```typescript
84
+ * const scene = Scene.make(
85
+ * "intro",
86
+ * function* () {
87
+ * const dot = yield* Scene.instantiate("Circle", { radius: 20 });
88
+ * yield* dot.pipe(Motion.moveTo({ x: 400 }, "1 second"));
89
+ * },
90
+ * { width: 500, height: 300, backgroundColor: Color.hex("#16161d") },
91
+ * );
92
+ * ```
93
+ */
94
+ export const make = (first, second, third) => {
95
+ const [name, f, meta] = typeof first === "string"
96
+ ? [first, second, third ?? {}]
97
+ : [undefined, first, second ?? {}];
98
+ return makeScene(Effect.scoped(Effect.gen(f)), meta, name);
14
99
  };
15
- // annotate/annotateMerge return new scene values sharing the same body
16
- const makeScene = (runnerEffect, annotations) => ({
100
+ const makeScene = (runnerEffect, meta, name) => ({
17
101
  [TypeId]: TypeId,
102
+ // THE erasure seam (design D3): loader requirements in R are phantom —
103
+ // authored yields never dereference their tags — so the body is safe to
104
+ // run with only ExcludeLoaders<R>. Guarded by the loader-free-run test.
18
105
  runner: runnerEffect,
19
- annotations,
20
- annotate: (key, value) => makeScene(runnerEffect, Context.add(annotations, key, value)),
21
- annotateMerge: (context) => makeScene(runnerEffect, Context.merge(annotations, context)),
106
+ "~resources": undefined,
107
+ ...(name !== undefined ? { name } : {}),
108
+ width: meta.width ?? Runner.defaultComp.width,
109
+ height: meta.height ?? Runner.defaultComp.height,
110
+ backgroundColor: meta.backgroundColor ?? Runner.defaultComp.backgroundColor,
22
111
  });
23
- export const instantiate = Effect.fnUntraced(function* (entity, props) {
112
+ /**
113
+ * Create an entity and put it in the scene.
114
+ *
115
+ * @remarks
116
+ * `kind` selects the entity and, with it, the exact props allowed — asking
117
+ * for a `"Circle"` gets you `radius`, a `"Text"` gets `text` and
118
+ * `fontSize`. Everything is optional and defaulted, so `instantiate("Circle",
119
+ * {})` is a valid white circle at the origin.
120
+ *
121
+ * The entity appears immediately and stays for the rest of the scene. It is
122
+ * mounted under the ambient parent — the root, or the enclosing Group when
123
+ * created inside one.
124
+ *
125
+ * What you get back is a lightweight HANDLE, not the entity's data. It is
126
+ * what animators take, and it stays valid as the data changes underneath;
127
+ * to read the current state use {@link data}. Because the handle is itself
128
+ * pipeable, you can animate straight off the call without binding it first.
129
+ *
130
+ * Containers (`Group`, `Hud`) accept a `children` array that is deliberately
131
+ * permissive: a bare string becomes a Text, an existing handle is adopted,
132
+ * and an un-yielded `instantiate` is resolved for you.
133
+ *
134
+ * @param kind - Which entity: `"Circle"`, `"Rect"`, `"Text"`, `"Line"`,
135
+ * `"Path"`, `"Ellipse"`, `"Group"`, `"Hud"`, `"Image"`, or `"Camera"`.
136
+ * @param props - Initial field values; all optional.
137
+ * @returns A handle to the live entity.
138
+ *
139
+ * @example
140
+ * A shape, and a Group adopting mixed children.
141
+ * ```typescript
142
+ * const dot = yield* Scene.instantiate("Circle", {
143
+ * position: Entity.vec3({ x: 100, y: 50 }),
144
+ * radius: 20,
145
+ * fillColor: Color.hex("#7f5af0"),
146
+ * });
147
+ *
148
+ * const panel = yield* Scene.instantiate("Group", {
149
+ * children: [
150
+ * "a bare string becomes a Text",
151
+ * Scene.instantiate("Rect", { width: 200, height: 40 }),
152
+ * dot,
153
+ * ],
154
+ * });
155
+ * ```
156
+ */
157
+ export const instantiate = Effect.fnUntraced(function* (kind, props) {
24
158
  const runner = yield* Runner.Runner;
25
- return yield* runner.instantiate(entity, props);
159
+ return yield* runner.instantiate(kind, props);
26
160
  });
27
161
  export const tick = Effect.gen(function* () {
28
162
  const runner = yield* Runner.Runner;
29
163
  return yield* runner.phaser.arriveAndAwaitAdvance;
30
164
  });
31
165
  /**
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.
166
+ * Hold the scene still for `duration`.
167
+ *
168
+ * @remarks
169
+ * `Effect.sleep`'s sibling, but counted in FRAMES at the runner's frame
170
+ * rate rather than read from a clock. That distinction is load-bearing:
171
+ * a wall-clock sleep would produce a different number of frames on a slow
172
+ * machine, and scenes must be reproducible.
173
+ *
174
+ * A zero-length duration is a no-op — unlike an animator, which always
175
+ * consumes at least one frame.
176
+ *
177
+ * Use `Motion.wait` instead when the hold belongs inside an animator chain.
178
+ *
179
+ * @param duration - How long to hold, in scene time.
180
+ *
181
+ * @example
182
+ * ```typescript
183
+ * yield* Scene.sleep("500 millis");
184
+ * ```
35
185
  */
36
186
  export const sleep = (duration) => Effect.gen(function* () {
37
187
  const runner = yield* Runner.Runner;
@@ -40,7 +190,25 @@ export const sleep = (duration) => Effect.gen(function* () {
40
190
  yield* tick;
41
191
  }
42
192
  });
43
- export const step = (runningScene) => Effect.gen(function* () {
193
+ /**
194
+ * Advance a running scene by exactly one frame.
195
+ *
196
+ * @remarks
197
+ * Returns the frame that was produced, or `null` once the scene is over —
198
+ * which is the signal to stop pulling. Every concurrent branch advances
199
+ * together on each call, which is what keeps concurrency from affecting the
200
+ * frames a scene produces.
201
+ *
202
+ * A scene that runs past its `maxFrames` cap dies here with a message
203
+ * naming the limit, rather than looping forever — the guard against an
204
+ * accidental `Schedule.forever` with nothing to stop it.
205
+ *
206
+ * @param runningScene - The handle from {@link run}.
207
+ * @returns The next frame, or `null` when the scene has ended.
208
+ */
209
+ // R is never: everything step touches is bound to the runningScene value
210
+ // (runner instance, phaser, fiber) — no ambient service is read
211
+ export const step = Effect.fnUntraced(function* (runningScene) {
44
212
  // done: the scene fiber ended. awaitedCount === 0: the body and every
45
213
  // fork completed — the scene is over even if its fiber is still
46
214
  // winding down through finalizers. Deciding here, from synchronous
@@ -71,12 +239,57 @@ export const step = (runningScene) => Effect.gen(function* () {
71
239
  if (runningScene.framesDelivered >= maxFrames) {
72
240
  return yield* Effect.die(new Error(`Scene exceeded maxFrames (${maxFrames}). Raise the maxFrames setting, or set maxFrames: Infinity for an intentionally infinite scene.`));
73
241
  }
74
- yield* runningScene.runner.phaser.awaitAdvance;
242
+ // The end-check above reads bookkeeping that the scene fiber writes, and
243
+ // on the very first step that fiber has not been scheduled yet: the body
244
+ // may still spawn branches and finish before producing anything. A body
245
+ // whose only statement is `Scene.background` does exactly that — it
246
+ // registers a phaser party (synchronously, in Phaser.run) and returns, so
247
+ // the advance we are about to await can never complete: the root has
248
+ // deregistered and the background will never arrive. Racing the scene
249
+ // fiber closes that window without teaching the phaser about branch kinds.
250
+ // After the first advance the fiber is running and the race is already
251
+ // settled, so this costs nothing on the hot path.
252
+ const advanced = yield* Effect.raceFirst(runningScene.runner.phaser.awaitAdvance.pipe(Effect.as(true)), Fiber.await(runningScene.fiber).pipe(Effect.as(false)));
253
+ if (!advanced) {
254
+ // the scene ended before this frame could advance — re-run the end
255
+ // path, which owns failure propagation and background teardown
256
+ return yield* step(runningScene);
257
+ }
75
258
  runningScene.framesDelivered++;
76
- return (yield* runningScene.runner.state);
259
+ return yield* runningScene.runner.state;
77
260
  });
261
+ /**
262
+ * Start a scene and hand back a handle for advancing it manually.
263
+ *
264
+ * @remarks
265
+ * The low-level entry point, for drivers that need to own the frame loop —
266
+ * an exporter writing files, or a player synchronizing to its own clock.
267
+ * Starting a scene does not produce any frames; pair this with {@link step}
268
+ * to pull them one at a time.
269
+ *
270
+ * Most code wants {@link stream} instead, which wraps exactly this pairing
271
+ * in a stream.
272
+ *
273
+ * @param scene - The scene to start.
274
+ * @param settings - Playback settings.
275
+ * @returns A running-scene handle to pass to {@link step}.
276
+ *
277
+ * @example
278
+ * ```typescript
279
+ * const running = yield* Scene.run(scene, { frameRate: 30 });
280
+ * let frame = yield* Scene.step(running);
281
+ * while (frame !== null) {
282
+ * frame = yield* Scene.step(running);
283
+ * }
284
+ * ```
285
+ */
78
286
  export const run = (scene, settings = {}) => Effect.gen(function* () {
79
- const runner = yield* Runner.Runner.make(settings);
287
+ // the runner inherits the ROOT scene's composition config
288
+ const runner = yield* Runner.Runner.make(settings, {
289
+ width: scene.width,
290
+ height: scene.height,
291
+ backgroundColor: scene.backgroundColor,
292
+ });
80
293
  let done = false;
81
294
  // the body is itself a branch: Scene.finish inside it demotes the
82
295
  // root (count--) while the body keeps ticking as a tail
@@ -145,11 +358,67 @@ export const run = (scene, settings = {}) => Effect.gen(function* () {
145
358
  };
146
359
  return runningScene;
147
360
  });
361
+ /**
362
+ * Play a scene and get its frames as a lazy stream — the usual way to
363
+ * consume one.
364
+ *
365
+ * @remarks
366
+ * Frames are produced on demand, so a player can pull at its own pace and a
367
+ * long scene never has to be materialized all at once. The stream ends when
368
+ * the scene does.
369
+ *
370
+ * `settings` is where playback choices live — `frameRate`, `seed`,
371
+ * `maxFrames` — as opposed to what the composition IS (its size and
372
+ * background), which was fixed at {@link make}. The same scene can therefore
373
+ * be streamed at 30fps for a preview and 60fps for a final render without
374
+ * being rewritten.
375
+ *
376
+ * Note the frame count depends on the frame rate: a one-second animation is
377
+ * 30 frames at 30fps and 60 at 60fps, plus a final resting frame.
378
+ *
379
+ * @param scene - The scene to play.
380
+ * @param settings - Playback settings.
381
+ * @defaultValue `frameRate` 60, `seed` `"effect-motion"`, `maxFrames` 36_000
382
+ * @returns A stream of frames.
383
+ *
384
+ * @example
385
+ * Collect every frame of a scene at 30fps.
386
+ * ```typescript
387
+ * const frames = yield* Scene.stream(scene, { frameRate: 30 }).pipe(
388
+ * Stream.runCollect,
389
+ * );
390
+ * ```
391
+ */
148
392
  export const stream = (scene, settings = {}) => run(scene, settings).pipe(Effect.map((runningScene) => Stream.fromEffectRepeat(step(runningScene)).pipe(
149
393
  // refinement: the stream ends at the first null, so the
150
394
  // element type is Frame<Entities>, not Frame | null
151
395
  Stream.takeWhile((state) => state !== null))), Stream.unwrap);
152
396
  const isUpdaterFn = (props) => typeof props === "function";
397
+ /**
398
+ * Read an entity's current data.
399
+ *
400
+ * @remarks
401
+ * An {@link Instance} is only a handle, so this is how you get at the live
402
+ * values behind it — to branch on where something is, or to compute a target
403
+ * relative to its current state.
404
+ *
405
+ * The result is a snapshot for THIS frame, not a live view; read again on a
406
+ * later frame to see later values. The returned type is narrowed by the
407
+ * handle's kind, so a Circle's `radius` is available without casting.
408
+ *
409
+ * Reading a destroyed entity is a loud defect rather than a silent
410
+ * `undefined`.
411
+ *
412
+ * @param instance - Handle to read.
413
+ * @returns The entity's data as of this frame.
414
+ * @see {@link update} to write it.
415
+ *
416
+ * @example
417
+ * ```typescript
418
+ * const { position, radius } = yield* Scene.data(dot);
419
+ * yield* dot.pipe(Motion.moveTo({ x: position.x + radius * 4 }, "1 second"));
420
+ * ```
421
+ */
153
422
  export const data = (instance) => Effect.gen(function* () {
154
423
  const runner = yield* Runner.Runner;
155
424
  const current = runner.getDataUnsafe(instance);
@@ -158,6 +427,34 @@ export const data = (instance) => Effect.gen(function* () {
158
427
  }
159
428
  return current;
160
429
  });
430
+ /**
431
+ * Set an entity's data immediately, with no animation.
432
+ *
433
+ * @remarks
434
+ * A hard cut on the current frame — the counterpart to the animators, which
435
+ * interpolate. Use it to set something up before animating (jolt the camera,
436
+ * then spring it back), or to change a field no animator covers, like
437
+ * `text`, `visible`, or a Path's `commands`.
438
+ *
439
+ * Pass an object to replace the data, or a function to derive it from the
440
+ * current values — the function form is preferred, since it reads and writes
441
+ * atomically.
442
+ *
443
+ * Updating a destroyed entity is a no-op returning `false`, not an error.
444
+ *
445
+ * @param instance - Handle to update.
446
+ * @param props - New data, or `(current) => next`.
447
+ *
448
+ * @example
449
+ * Retitle a label and jolt the camera, both on this frame.
450
+ * ```typescript
451
+ * yield* Scene.update(label, (d) => ({ ...d, text: "done" }));
452
+ * yield* Scene.update(camera, (d) => ({
453
+ * ...d,
454
+ * position: Entity.vec3({ ...d.position, x: 22 }),
455
+ * }));
456
+ * ```
457
+ */
161
458
  export const update = (instance, props) => Effect.gen(function* () {
162
459
  const runner = yield* Runner.Runner;
163
460
  if (isUpdaterFn(props)) {
@@ -189,16 +486,36 @@ export const settings = Effect.fnUntraced(function* () {
189
486
  const runner = yield* Runner.Runner;
190
487
  return runner.settings;
191
488
  });
489
+ /** the movie's composition config — the ROOT scene's width/height/background */
490
+ export const comp = Effect.fnUntraced(function* () {
491
+ const runner = yield* Runner.Runner;
492
+ return runner.comp;
493
+ });
192
494
  /**
193
- * The active camera instance — an ordinary instance carrying `~position`
194
- * (world x/y/z), Euler orientation (`rotX`/`rotY`/`rotZ`), and
195
- * `focalLength` (perspective strength — see Projection.defaultFocalLength),
196
- * so the existing animators drive it: `Scene.make(function* () { const cam
197
- * = yield* Scene.camera; yield* cam.pipe(Motion.moveTo({ z: -400 })) })`.
198
- * A default resting camera is always present (width-relative 50mm-equivalent
199
- * focal length, projecting z=0 content to plain-2D placement); animate it
200
- * directly, or `Scene.setCamera` to swap in another instance. The camera is
201
- * never drawn.
495
+ * The active camera, as an ordinary animatable instance.
496
+ *
497
+ * @remarks
498
+ * There is always a camera — a resting one is present from the first frame,
499
+ * placed so that content at `z = 0` renders exactly as flat 2D. A scene that
500
+ * never touches the camera looks like a plain 2D scene, and reaching for
501
+ * `Scene.camera` is how you opt into depth.
502
+ *
503
+ * It is a normal instance, so every animator drives it with no special
504
+ * vocabulary: `moveTo` flies it (including along `z` to push in or pull
505
+ * back), `tweenTo` on `focalLength` changes the lens, springs and forks work
506
+ * as they do anywhere. `Camera` helpers add aiming on top.
507
+ *
508
+ * The camera is view state and is never itself drawn.
509
+ *
510
+ * @returns A handle to the active camera.
511
+ * @see {@link setCamera} to swap in a different one.
512
+ *
513
+ * @example
514
+ * Push the camera in, revealing depth in the scene.
515
+ * ```typescript
516
+ * const camera = yield* Scene.camera;
517
+ * yield* camera.pipe(Motion.moveTo({ z: -300 }, "1200 millis", "easeInOutCubic"));
518
+ * ```
202
519
  */
203
520
  export const camera = Effect.gen(function* () {
204
521
  const runner = yield* Runner.Runner;
@@ -243,13 +560,36 @@ const makeBranch = (runner, kind) => {
243
560
  return { entry, finishUnsafe, isFinished: () => finished };
244
561
  };
245
562
  /**
246
- * Finish the innermost enclosing branch (the current fork, played scene,
247
- * or the scene body itself): whoever awaits the branch's `finished`
248
- * proceeds, the branch stops blocking its parent's end, and the code
249
- * after `finish` keeps running as a TAIL — bounded by the parent, which
250
- * interrupts it at scene end like a background. Idempotent; completion
251
- * implies finish. NOTE: a failure in the tail (after finish) is NOT
252
- * reported — by then nothing is listening.
563
+ * Declare the current branch semantically over, while its code keeps
564
+ * running.
565
+ *
566
+ * @remarks
567
+ * Separates "this is done as far as everyone else is concerned" from "this
568
+ * fiber has stopped". Anyone awaiting the branch's `finished` proceeds
569
+ * immediately, and the branch stops holding the scene open — but code after
570
+ * `finish` keeps running as a TAIL, bounded by the parent exactly like a
571
+ * {@link background}.
572
+ *
573
+ * The use is a beat that should hand off early: an entrance whose successor
574
+ * starts as soon as the element has landed, while a slow ring-out continues
575
+ * underneath. Without `finish`, the successor would wait for the tail.
576
+ *
577
+ * Idempotent, and completion implies finish. Note that a failure in the tail
578
+ * is NOT reported — by then nothing is listening.
579
+ *
580
+ * Calling it outside a running scene is a loud defect.
581
+ *
582
+ * @example
583
+ * Hand off after the landing; the wobble plays on borrowed time.
584
+ * ```typescript
585
+ * yield* Scene.fork(
586
+ * Effect.gen(function* () {
587
+ * yield* badge.pipe(Motion.moveTo({ y: 100 }, "400 millis"));
588
+ * yield* Scene.finish;
589
+ * yield* badge.pipe(Physics.springTo({ y: 96 }, "bounce"));
590
+ * }),
591
+ * );
592
+ * ```
253
593
  */
254
594
  export const finish = Effect.gen(function* () {
255
595
  const branch = yield* currentBranch();
@@ -301,13 +641,35 @@ const forkBranch = (runner, effect, kind) => Effect.gen(function* () {
301
641
  // the phaser's phase counter IS the current frame index
302
642
  const frameOf = (runner) => runner.phaser.snapshotUnsafe().phase;
303
643
  /**
304
- * Run `effect`, then repeat it as long as `schedule` recurs, with the
305
- * schedule evaluated in scene time (frames at the runner's frame rate) —
306
- * `Effect.repeat`'s sibling, but paced by frames instead of the wall
307
- * clock. The first run is immediate; the schedule paces the gaps after
308
- * runs; each run's result is fed to the schedule as input. Resolves with
309
- * the schedule's final output once it is done; a failed run fails
310
- * immediately without consulting the schedule again.
644
+ * Play an animation again and again, on a schedule.
645
+ *
646
+ * @remarks
647
+ * `Effect.repeat`'s sibling, paced by FRAMES rather than the wall clock —
648
+ * which is what keeps a looping scene deterministic.
649
+ *
650
+ * The first run happens immediately, and the schedule paces the gaps AFTER
651
+ * each run. So `Schedule.spaced("400 millis")` means "run, rest 400ms, run
652
+ * again", and the loop count comes from the schedule: `Schedule.forever`
653
+ * for ambient motion (usually inside {@link background}), or
654
+ * `Schedule.upTo({ times: 2 })` for a bounded three-run sequence.
655
+ *
656
+ * A failing run fails immediately, without consulting the schedule again.
657
+ *
658
+ * @param effect - The animation to repeat.
659
+ * @param schedule - How often, and how many times.
660
+ * @returns The schedule's final output.
661
+ *
662
+ * @example
663
+ * Three round-trips, resting 400ms between them.
664
+ * ```typescript
665
+ * yield* Scene.repeat(
666
+ * ball.pipe(
667
+ * Motion.moveTo({ x: 430 }, "600 millis", "easeInOutCubic"),
668
+ * Motion.moveTo({ x: 70 }, "600 millis", "easeInOutCubic"),
669
+ * ),
670
+ * Schedule.spaced("400 millis").pipe(Schedule.upTo({ times: 2 })),
671
+ * );
672
+ * ```
311
673
  */
312
674
  export const repeat = (effect, schedule) => Effect.gen(function* () {
313
675
  const runner = yield* Runner.Runner;
@@ -325,58 +687,187 @@ export const repeat = (effect, schedule) => Effect.gen(function* () {
325
687
  }
326
688
  });
327
689
  /**
328
- * Run `effect` concurrently with the rest of the scene, sharing frame
329
- * phases, and return its fiber immediately.
330
- *
331
- * NOTE: this inverts Effect's own `fork` semantics — the scene's end
332
- * WAITS for forked work. A scene whose body returns while forks are
333
- * still animating keeps producing frames until the last fork finishes
334
- * (so a scene containing only a fork still plays). Use
335
- * {@link background} for work that should be cut off at scene end
336
- * instead. Forks are supervised by the fiber that spawned them: a fork
337
- * made inside another fork is interrupted when its spawner completes.
690
+ * Start an animation alongside the rest of the scene and continue
691
+ * immediately, without waiting for it.
692
+ *
693
+ * @remarks
694
+ * Where {@link all} blocks until its branches finish, `fork` returns at
695
+ * once — so the scene body carries on while the forked animation plays.
696
+ * That is what lets independent timelines overlap, and what makes spawning
697
+ * work in a loop possible.
698
+ *
699
+ * Note this INVERTS Effect's own `fork`: the scene's end waits for forked
700
+ * work. A body that returns while forks are still animating keeps producing
701
+ * frames until the last one finishes, so a scene consisting only of a fork
702
+ * still plays in full. For work that should instead be cut off when the
703
+ * scene ends, use {@link background}.
704
+ *
705
+ * The returned handle carries `finished` — yield it to wait for this branch
706
+ * specifically — and `fiber`, to interrupt it early.
707
+ *
708
+ * @param effect - The animation to run alongside.
709
+ * @returns A handle with `finished` and `fiber`.
710
+ * @see {@link background} for work bounded by the scene's end.
711
+ *
712
+ * @example
713
+ * Spawn overlapping dots; the scene lives until the last one has faded.
714
+ * ```typescript
715
+ * yield* Scene.repeat(
716
+ * Scene.fork(
717
+ * Effect.gen(function* () {
718
+ * const dot = yield* Scene.instantiate("Circle", { radius: 8 });
719
+ * yield* dot.pipe(
720
+ * Motion.moveTo({ x: 440 }, "1200 millis"),
721
+ * Motion.fadeTo(0, "300 millis"),
722
+ * );
723
+ * }),
724
+ * ),
725
+ * Schedule.fixed("200 millis").pipe(Schedule.upTo({ times: 5 })),
726
+ * );
727
+ * ```
338
728
  */
339
729
  export const fork = (effect) => Effect.gen(function* () {
340
730
  const runner = yield* Runner.Runner;
341
731
  return yield* forkBranch(runner, effect, "fork");
342
732
  });
343
733
  /**
344
- * Like {@link fork}, but the fiber is INTERRUPTED at scene end instead
345
- * of awaited — for indefinite work (`Scene.repeat(…, Schedule.forever)`)
346
- * that should play for the duration of the scene without keeping it
347
- * alive. "Scene end" includes the fork drain: backgrounds keep animating
348
- * while awaited forks finish, and are stopped after the last one.
734
+ * Like {@link fork}, but the animation is CUT OFF at scene end rather than
735
+ * awaited.
736
+ *
737
+ * @remarks
738
+ * For ambient motion that should play for as long as the scene lasts
739
+ * without deciding how long that is — a pulsing indicator, a drifting
740
+ * backdrop, anything paired with `Schedule.forever`. A background never
741
+ * holds the scene open, so the scene's real content governs its length and
742
+ * the ambient loop simply stops when everything else is done.
743
+ *
744
+ * "Scene end" includes the fork drain: backgrounds keep animating while
745
+ * awaited forks finish, and are stopped only after the last one.
746
+ *
747
+ * Because backgrounds do not keep a scene alive, a body that spawns only
748
+ * backgrounds ends immediately and produces NO frames — the background is
749
+ * not content, so there is nothing to give the scene a length. Pair one
750
+ * with something that does define the length, whether a real animation or
751
+ * an explicit {@link sleep}, or the ambient motion never gets a frame to
752
+ * play on.
753
+ *
754
+ * @param effect - The ambient animation.
755
+ * @returns A handle with `finished` and `fiber`.
756
+ *
757
+ * @example
758
+ * A pulse that runs the whole scene, with the scene's length set by the
759
+ * animation after it.
760
+ * ```typescript
761
+ * yield* Scene.background(
762
+ * Scene.repeat(
763
+ * pulse.pipe(
764
+ * Motion.tweenTo({ radius: 24 }, "400 millis"),
765
+ * Motion.tweenTo({ radius: 10 }, "400 millis"),
766
+ * ),
767
+ * Schedule.forever,
768
+ * ),
769
+ * );
770
+ * yield* title.pipe(Motion.moveTo({ y: 100 }, "2 seconds"));
771
+ * ```
349
772
  */
350
773
  export const background = (effect) => Effect.gen(function* () {
351
774
  const runner = yield* Runner.Runner;
352
775
  return yield* forkBranch(runner, effect, "background");
353
776
  });
354
777
  /**
355
- * Play a scene as a branch of the current scene — the explicit door to
356
- * nesting. The child shares the movie's runner, phaser, frame rate, and
357
- * frame cap, and gets its own scope, branch handle, mount parent, and a
358
- * FRESH seeded Random stream: `play(scene)` inside a movie seeded `S`
359
- * animates exactly like `run(scene, { seed: S })` standalone. Awaited
360
- * like a fork — `yield* handle.finished` for sequential nesting, or
361
- * don't await for concurrent scenes.
778
+ * Nest a whole scene inside the current one — the precomp.
779
+ *
780
+ * @remarks
781
+ * The door to composing scenes rather than writing one flat timeline. A
782
+ * played scene is authored and tested independently, then dropped into a
783
+ * larger one as a unit: an intro built alone becomes the first beat of a
784
+ * longer piece without edits.
785
+ *
786
+ * The child mounts under an implicit group carrying its OWN bounds. Content
787
+ * clips to them, a non-transparent background paints within them, and the
788
+ * group is placed so those bounds sit centered in the enclosing composition
789
+ * — so a child smaller or larger than its parent still lands sensibly. The
790
+ * handle's `group` is that mount point: move or fade it to transform the
791
+ * entire nested scene as one object.
792
+ *
793
+ * The child shares the movie's frame clock but gets a FRESH seeded random
794
+ * stream, so a nested scene animates exactly as it did standalone under the
795
+ * same seed — nesting never perturbs a child's randomness.
796
+ *
797
+ * Awaited like a {@link fork}: yield `handle.finished` to play children in
798
+ * sequence, or skip the await to run them concurrently.
799
+ *
800
+ * @param scene - The scene to nest.
801
+ * @param options - `parent` to mount elsewhere, `seed` to vary this
802
+ * evaluation.
803
+ * @returns A handle with `finished`, `fiber`, and the mount `group`.
804
+ *
805
+ * @example
806
+ * Play one scene, then another, and fade the second out as a whole.
807
+ * ```typescript
808
+ * const intro = yield* Scene.play(introScene);
809
+ * yield* intro.finished;
810
+ *
811
+ * const outro = yield* Scene.play(outroScene);
812
+ * yield* outro.group.pipe(Motion.fadeTo(0, "500 millis"));
813
+ * ```
362
814
  */
363
815
  export const play = (scene, options) => Effect.gen(function* () {
364
816
  const runner = yield* Runner.Runner;
365
- const mounted = options?.parent === undefined
366
- ? scene.runner.pipe(Effect.scoped)
367
- : scene.runner.pipe(Effect.scoped, Effect.provideService(Runner.CurrentParent, options.parent));
368
- const body = mounted.pipe(
817
+ // default placement: comps are center-anchored in the center-origin
818
+ // frame, so "centered in the enclosing comp" is position (0, 0) by
819
+ // construction — no bounds arithmetic needed.
820
+ const ambient = options?.parent ?? (yield* Runner.CurrentParent);
821
+ // A mounted scene is a render-to-texture boundary: the renderer clips
822
+ // its subtree to the child's bounds and paints the child's background
823
+ // within them. Those bounds are the SCENE's, so they are registered
824
+ // against the mount group's id rather than copied onto it as fields —
825
+ // a Group that happens to carry a size is not what makes a comp.
826
+ const group = yield* runner
827
+ .instantiate("Group", {
828
+ position: Entity.vec3({}),
829
+ })
830
+ .pipe(Effect.provideService(Runner.CurrentParent, ambient));
831
+ runner.registerComp(group.id, {
832
+ width: scene.width,
833
+ height: scene.height,
834
+ backgroundColor: scene.backgroundColor,
835
+ });
836
+ const body = scene.runner.pipe(Effect.scoped,
837
+ // the child's instances mount under its bounds group
838
+ Effect.provideService(Runner.CurrentParent, group),
369
839
  // fresh stream per evaluation: nested playback must equal a
370
840
  // standalone run with the same seed, never inherit the parent's
371
841
  // stream position
372
842
  Random.withSeed(options?.seed ?? runner.settings.seed));
373
- return (yield* forkBranch(runner, body, "fork"));
843
+ const handle = (yield* forkBranch(runner, body, "fork"));
844
+ return { ...handle, group };
374
845
  });
375
846
  /**
376
- * Run effects in lockstep parallel, sharing frame phases — the public
377
- * counterpart to the low-level `Phaser.all`. Takes no schedule: pacing a
378
- * list sequentially belongs to {@link chain}, overlapping staggered
379
- * starts to {@link stagger}.
847
+ * Run animations simultaneously, and resolve when the last one finishes.
848
+ *
849
+ * @remarks
850
+ * The everyday way to make things move at once. Every branch advances
851
+ * exactly one frame per tick in lockstep, so two one-second animations run
852
+ * as one second of frames — not two.
853
+ *
854
+ * Branches need not be the same length; `all` waits for the slowest. This
855
+ * is also the idiom for synchronizing springs, whose durations are emergent
856
+ * and unknown up front.
857
+ *
858
+ * There is deliberately no schedule parameter: pacing a list one-at-a-time
859
+ * is {@link chain}, and overlapping starts is {@link stagger}.
860
+ *
861
+ * @param effects - The animations to run together.
862
+ *
863
+ * @example
864
+ * A dot slides while the camera pushes in — one second of frames total.
865
+ * ```typescript
866
+ * yield* Scene.all([
867
+ * dot.pipe(Motion.moveTo({ x: 400 }, "1 second")),
868
+ * camera.pipe(Motion.moveTo({ z: -300 }, "1 second")),
869
+ * ]);
870
+ * ```
380
871
  */
381
872
  export const all = Effect.fnUntraced(function* (effects) {
382
873
  const runner = yield* Runner.Runner;
@@ -384,16 +875,34 @@ export const all = Effect.fnUntraced(function* (effects) {
384
875
  return yield* Phaser.all(effects).pipe(Effect.provideService(Phaser.Phaser, runner.phaser));
385
876
  });
386
877
  /**
387
- * Run items one at a time, in order — items NEVER overlap, mirroring
388
- * Effect's guarantee for scheduled effects. The first item runs
389
- * immediately; after each item completes, `schedule` is stepped once
390
- * (with the item's result as input) to pace the next start. `fixed`
391
- * gives a start cadence with catch-up, `spaced` gives rests between
392
- * items. When the schedule ends early, the remaining items are skipped —
393
- * it is the release policy, including how many. Without a schedule,
394
- * plain sequential composition. Resolves with how many items completed.
395
- * For overlapping runs, reach for {@link stagger} or {@link fork}
396
- * explicitly.
878
+ * Run animations one at a time, in order, optionally resting between them.
879
+ *
880
+ * @remarks
881
+ * Items NEVER overlap — each begins only after the previous one has fully
882
+ * finished. That guarantee is the difference between this and
883
+ * {@link stagger}, and it holds no matter what schedule you pass.
884
+ *
885
+ * Without a schedule this is plain sequencing, equivalent to yielding each
886
+ * item in turn but composable as a list. With one, the schedule paces the
887
+ * GAPS after each item: `Schedule.spaced("400 millis")` rests 400ms between
888
+ * items, while `Schedule.fixed` targets a steady start-to-start cadence.
889
+ *
890
+ * The schedule also decides how many items run: when it ends, the remaining
891
+ * items are skipped. `Schedule.recurs(2)` therefore plays three items — the
892
+ * first, plus two more the schedule released.
893
+ *
894
+ * @param effects - The animations, in order.
895
+ * @param schedule - Optional pacing for the gaps between them.
896
+ * @returns `{ completed }` — how many items actually ran.
897
+ *
898
+ * @example
899
+ * Three shapes flashing in turn, resting 400ms between each.
900
+ * ```typescript
901
+ * const { completed } = yield* Scene.chain(
902
+ * [a, b, c].map((shape) => shape.pipe(Motion.fadeTo(1, "300 millis"))),
903
+ * Schedule.spaced("400 millis"),
904
+ * );
905
+ * ```
397
906
  */
398
907
  export const chain = (effects, schedule) => Effect.gen(function* () {
399
908
  const list = Array.from(effects);
@@ -421,14 +930,36 @@ export const chain = (effects, schedule) => Effect.gen(function* () {
421
930
  return { completed };
422
931
  });
423
932
  /**
424
- * Release effects on `schedule` with OVERLAP: the first starts
425
- * immediately, each next one on the schedule's next emission, and
426
- * released effects run concurrently — semantically
427
- * `chain(effects.map(Scene.fork))`, but resolving when all released
428
- * effects finish rather than at the last release. When the schedule ends
429
- * early, the remaining effects are skipped. Overlap is this
430
- * combinator's purpose; the schedule-paced default ({@link chain})
431
- * never overlaps.
933
+ * Start animations one after another WITHOUT waiting for each to finish —
934
+ * the cascade.
935
+ *
936
+ * @remarks
937
+ * The first starts immediately and each next one on the schedule's next
938
+ * emission, so earlier items are still running when later ones begin. That
939
+ * overlap is the entire point, and the difference from {@link chain}: use
940
+ * `stagger` for a ripple across many elements, `chain` when items must not
941
+ * coincide.
942
+ *
943
+ * The schedule paces the STARTS here, not the gaps. Resolution waits for
944
+ * every released animation to finish, not merely for the last one to be
945
+ * released — so the whole cascade is complete when this returns.
946
+ *
947
+ * When the schedule ends before the list does, the remaining effects are
948
+ * skipped.
949
+ *
950
+ * @param effects - The animations to release in order.
951
+ * @param schedule - When to release each subsequent one.
952
+ * @returns `{ released }` — how many actually started.
953
+ *
954
+ * @example
955
+ * A row of bars rising in a ripple, each starting 80ms after the last while
956
+ * the earlier ones keep going.
957
+ * ```typescript
958
+ * yield* Scene.stagger(
959
+ * bars.map((bar) => bar.pipe(Motion.moveTo({ y: 40 }, "600 millis"))),
960
+ * Schedule.spaced("80 millis"),
961
+ * );
962
+ * ```
432
963
  */
433
964
  export const stagger = (effects, schedule) => Effect.gen(function* () {
434
965
  const list = Array.from(effects);