@moku-labs/game 0.15.28 → 0.15.29

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.
@@ -9467,7 +9467,8 @@ function registerFeatures(ctx, ecs, projection) {
9467
9467
  * callback records the frame history before it clears the change sets; in an agent build the
9468
9468
  * timing table starts with the page clock of `time` and the `input` callback zeroes it each
9469
9469
  * frame. The resource `Now` gets `clock.now()` once here and first thing in every `input`
9470
- * callback, so every system of a frame reads one moment. `onStart` has no access to the
9470
+ * callback, so every system of a frame reads one moment; a running scene build counts its frame
9471
+ * budget from that moment too. `onStart` has no access to the
9471
9472
  * plugin's own API, so it builds its own module objects — they are views on `ctx.state`, which is
9472
9473
  * the one place the modules keep their data.
9473
9474
  *
@@ -9486,7 +9487,9 @@ function connectWorld(ctx) {
9486
9487
  registerFeatures(worldCtx, ecs, projection);
9487
9488
  ecs.resource(Now).now = clock.now();
9488
9489
  worldCtx.state.ecs.offFrame.push(ecs.onOwnerLeft((owner) => projection.ownerLeft(owner)), time.onFrame("input", (frame) => {
9489
- ecs.resource(Now).now = clock.now();
9490
+ const now = clock.now();
9491
+ ecs.resource(Now).now = now;
9492
+ projection.startFrame(now);
9490
9493
  worldCtx.state.ecs.timing?.ms.fill(0);
9491
9494
  projection.reconcileIfDirty();
9492
9495
  ecs.runPhase("input", frame);
@@ -9501,8 +9504,8 @@ function connectWorld(ctx) {
9501
9504
  }
9502
9505
  /**
9503
9506
  * Empties the world in `onStop`: the frame callbacks and the hint listener are removed, then
9504
- * every track, view, queued view, entity and resource is dropped, and the scenes and the frame
9505
- * history are reset.
9507
+ * every track, view, queued view, entity and resource is dropped, a running scene build and the
9508
+ * `onBuild` checks with them, and the scenes and the frame history are reset.
9506
9509
  * No `onRemoved` fires, because `renderer` stopped earlier and destroyed its own objects.
9507
9510
  *
9508
9511
  * @param state - The plugin state, the only thing a teardown context carries.
@@ -9528,6 +9531,8 @@ function clearWorld(state) {
9528
9531
  state.projection.membershipVersion += 1;
9529
9532
  state.projection.moving = false;
9530
9533
  state.projection.owedLoops = 0;
9534
+ delete state.projection.building;
9535
+ state.projection.buildChecks.length = 0;
9531
9536
  state.ecs.generations.length = 0;
9532
9537
  state.ecs.free.length = 0;
9533
9538
  state.ecs.owners.clear();
@@ -9920,9 +9925,10 @@ function isNode(output) {
9920
9925
  /**
9921
9926
  * Runs `view(item)` and collects its output by component name. A view that describes a screen
9922
9927
  * returns one node, which becomes the `Tree` of the entity, and `ui` reconciles it from there;
9923
- * the root draws at the `Order` of its place in the mounted list, written before `Tree` because the
9924
- * ecs fires `onAdded` in list order and the `Tree` hook of `ui` reads it. `Order` is part of the
9925
- * view's rest, so a new node compares it by value and writes nothing.
9928
+ * the root draws at the `Order` of its place in the mounted list (or the list of the running build,
9929
+ * which mounts it aside), written before `Tree` because the ecs fires `onAdded` in list order and the
9930
+ * `Tree` hook of `ui` reads it. `Order` is part of the view's rest, so a new node compares it by value
9931
+ * and writes nothing.
9926
9932
  * `Layer` is the projection's own component: a view that returns it is reported and ignored.
9927
9933
  *
9928
9934
  * @param pctx - Domain context of the projection module.
@@ -9935,7 +9941,8 @@ function collectView(pctx, spec, item) {
9935
9941
  const layerName = pctx.deps.components.Layer.componentName;
9936
9942
  const output = spec.view(item);
9937
9943
  const { Order, Tree } = pctx.deps.components;
9938
- const order = pctx.ctx.state.projection.mounted.get(spec.name)?.order ?? 0;
9944
+ const state = pctx.ctx.state.projection;
9945
+ const order = (state.mounted.get(spec.name) ?? state.building?.mounted.get(spec.name))?.order ?? 0;
9939
9946
  const produced = isNode(output) ? [Order({ value: order }), Tree({ node: output })] : output;
9940
9947
  for (const value of produced) {
9941
9948
  if (value.type.componentName === layerName) {
@@ -10862,6 +10869,8 @@ function hintFor$1(pctx, projection, key, used) {
10862
10869
  * play the `enter` hook and the `loop` hook when the reconcile plays motions. The loop is not
10863
10870
  * kept with the view's handles: a change cancels those, and the loop runs until the view dies.
10864
10871
  * A direct reconcile plays neither hook. The view then owes its loop, which the sweep starts.
10872
+ * A view of a projection the running build mounts is flagged building right after its spawn: the
10873
+ * spawn fires `onAdded` inside the call, so no flag can come before it.
10865
10874
  *
10866
10875
  * @param pctx - Domain context of the projection module.
10867
10876
  * @param spec - The projection spec.
@@ -10880,14 +10889,17 @@ function enterEntry(pctx, spec, mounted, entry) {
10880
10889
  name: spec.name
10881
10890
  }, [pctx.deps.components.Layer({ name: spec.layer }), ...values.values()]);
10882
10891
  const view = createViewRecord(entity, spec.name, entry.key, entry.item, values, entry.direct ? "mount" : "change");
10892
+ const state = pctx.ctx.state.projection;
10893
+ const building = state.building;
10883
10894
  mounted.live.set(entry.key, view);
10884
- pctx.ctx.state.projection.byEntity.set(entity, view);
10885
- pctx.ctx.state.projection.membershipVersion += 1;
10895
+ state.byEntity.set(entity, view);
10896
+ state.membershipVersion += 1;
10897
+ if (building?.mounted.get(spec.name) === mounted) building.entities.add(entity);
10886
10898
  const hook = spec.motion?.enter;
10887
10899
  const loop = spec.motion?.loop;
10888
10900
  if (entry.direct) {
10889
10901
  view.loopOwed = true;
10890
- pctx.ctx.state.projection.owedLoops += 1;
10902
+ state.owedLoops += 1;
10891
10903
  return;
10892
10904
  }
10893
10905
  if (hook === void 0 && loop === void 0) return;
@@ -11258,6 +11270,7 @@ function reconcileOne(pctx, spec, mounted, snapshot, options, counts, used) {
11258
11270
  }, counts);
11259
11271
  exitGone(pctx, spec, mounted, next, entry, counts);
11260
11272
  } catch (error) {
11273
+ if (options.strict === true) throw error;
11261
11274
  pctx.ctx.log.error("world:projection-failed", { projection: spec.name }, asError$2(error));
11262
11275
  return;
11263
11276
  }
@@ -11286,8 +11299,46 @@ function isCurrent$1(mounted, snapshot, options) {
11286
11299
  return seen !== void 0 && !options.direct && !options.force && !options.converge && !options.settle && seen.player === snapshot.player && seen.session === snapshot.session;
11287
11300
  }
11288
11301
  /**
11289
- * Runs one reconcile over every mounted projection, or over the named ones only. A converging
11290
- * one then brings the elements other plugins own home and tells their owners.
11302
+ * Reconciles the projections of one table against the snapshot: every one, or the named ones only.
11303
+ * A projection whose last reconcile read the same trees is passed over as `skipBy` decides; `runWith`
11304
+ * is how the others run.
11305
+ *
11306
+ * @param pctx - Domain context of the projection module.
11307
+ * @param table - The mounted projections, or the ones a build mounts aside.
11308
+ * @param snapshot - The model snapshot of this reconcile.
11309
+ * @param skipBy - The options of the reconcile that was asked for.
11310
+ * @param runWith - The options each projection of this table is reconciled with.
11311
+ * @param counts - The counters of this reconcile.
11312
+ * @param used - The hints that reached an entry.
11313
+ */
11314
+ function reconcileTable(pctx, table, snapshot, skipBy, runWith, counts, used) {
11315
+ const specs = pctx.ctx.state.projection.specs;
11316
+ for (const [name, mounted] of table) {
11317
+ if (skipBy.names !== void 0 && !skipBy.names.includes(name)) continue;
11318
+ const spec = specs.get(name);
11319
+ if (spec === void 0 || isCurrent$1(mounted, snapshot, skipBy)) continue;
11320
+ counts.projections += 1;
11321
+ reconcileOne(pctx, spec, mounted, snapshot, runWith, counts, used);
11322
+ }
11323
+ }
11324
+ /**
11325
+ * Tells whether a mounted projection is not on a list: one a reconcile limited to the list passes over.
11326
+ *
11327
+ * @param mounted - The mounted projections.
11328
+ * @param names - The list, the coming scene's.
11329
+ * @returns True when one is left out.
11330
+ */
11331
+ function passesOver(mounted, names) {
11332
+ for (const name of mounted.keys()) if (!names.includes(name)) return true;
11333
+ return false;
11334
+ }
11335
+ /**
11336
+ * Runs one reconcile over every mounted projection, or over the named ones only, or over the table
11337
+ * the options name. A reconcile of every mounted projection reaches the projections a running build
11338
+ * mounts aside too, direct, so a store change during the build reaches the coming scene and none of
11339
+ * its views plays a motion unseen; the mounted projections the coming scene drops are passed over
11340
+ * until the build ends. A converging one then brings the elements other plugins own home and tells
11341
+ * their owners.
11291
11342
  *
11292
11343
  * @param pctx - Domain context of the projection module.
11293
11344
  * @param options - Which projections, play or direct, whether `view` is forced, whether every
@@ -11299,12 +11350,20 @@ function reconcile(pctx, options) {
11299
11350
  const snapshot = pctx.ctx.deps.model.store.snapshot();
11300
11351
  const counts = emptyCounts();
11301
11352
  const used = emptyHintSet();
11302
- for (const [name, mounted] of state.mounted) {
11303
- if (options.names !== void 0 && !options.names.includes(name)) continue;
11304
- const spec = state.specs.get(name);
11305
- if (spec === void 0 || isCurrent$1(mounted, snapshot, options)) continue;
11306
- counts.projections += 1;
11307
- reconcileOne(pctx, spec, mounted, snapshot, options, counts, used);
11353
+ const building = state.building;
11354
+ const whole = options.table === void 0 && options.names === void 0;
11355
+ const skipBy = whole && building !== void 0 ? {
11356
+ ...options,
11357
+ names: building.names
11358
+ } : options;
11359
+ if (whole && building !== void 0 && passesOver(state.mounted, building.names)) building.held = options.force || building.held === "rerun" ? "rerun" : "change";
11360
+ reconcileTable(pctx, options.table ?? state.mounted, snapshot, skipBy, options, counts, used);
11361
+ if (whole && building !== void 0) {
11362
+ const aside = {
11363
+ ...options,
11364
+ direct: true
11365
+ };
11366
+ reconcileTable(pctx, building.mounted, snapshot, options, aside, counts, used);
11308
11367
  }
11309
11368
  if (options.converge) {
11310
11369
  convergeElements(pctx);
@@ -11320,9 +11379,7 @@ function reconcile(pctx, options) {
11320
11379
  //#endregion
11321
11380
  //#region src/plugins/world/projection/mount.ts
11322
11381
  /**
11323
- * Creates the empty view tables of a freshly mounted projection. These live in their own
11324
- * non-exported functions because lint rule L5 refuses a collection built inside an exported
11325
- * declaration.
11382
+ * Creates the empty view tables of a freshly mounted projection.
11326
11383
  *
11327
11384
  * @param owner - Who owns the entities of this projection.
11328
11385
  * @param order - The `Order` its screen roots draw at.
@@ -11340,14 +11397,29 @@ function emptyMounted(owner, order, layers) {
11340
11397
  };
11341
11398
  }
11342
11399
  /**
11343
- * Checks that every layer a projection names is declared by the scene.
11400
+ * The `Order` a screen root of a projection draws at: its place in the list of its scene minus the
11401
+ * length of the list, so the roots of one scene take −n … −1 (spec 16 U1.6).
11402
+ *
11403
+ * @param names - The projection list of the scene.
11404
+ * @param name - One projection of it.
11405
+ * @returns The order of its screen roots.
11406
+ * @example
11407
+ * ```ts
11408
+ * orderIn(["board.items", "hud"], "hud"); // -1
11409
+ * ```
11410
+ */
11411
+ function orderIn(names, name) {
11412
+ return names.indexOf(name) - names.length;
11413
+ }
11414
+ /**
11415
+ * Checks that every layer a projection names is declared by a layer list.
11344
11416
  *
11345
- * @param pctx - Domain context of the projection module.
11346
11417
  * @param spec - The projection spec.
11418
+ * @param layers - The layer list of the scene: the current one, or the one a build comes with.
11347
11419
  * @throws {Error} When `layer` or `lift` is not in the layer list.
11348
11420
  */
11349
- function checkLayers(pctx, spec) {
11350
- const declared = new Set(pctx.ctx.state.projection.layers.map((layer) => layer.name));
11421
+ function checkLayers(spec, layers) {
11422
+ const declared = new Set(layers.map((layer) => layer.name));
11351
11423
  for (const name of [spec.layer, spec.lift]) {
11352
11424
  if (name === void 0 || declared.has(name)) continue;
11353
11425
  throw new Error(`[game] Projection "${spec.name}" names layer "${name}", which the scene does not declare.\n Add it to the layers of the scene.`);
@@ -11404,16 +11476,17 @@ function unmountNames(pctx, names) {
11404
11476
  }
11405
11477
  }
11406
11478
  /**
11407
- * The first pass of a mount, which touches nothing: every name is checked, a projection mounted
11408
- * into this scene already is reported and passed over, and the rest is sorted into the ones that
11409
- * come and the ones a scene before mounted that have to go and come back.
11479
+ * The first pass of a mount, which touches nothing: every name is checked against a layer list, a
11480
+ * projection mounted under that very list already is reported and passed over, and the rest is
11481
+ * sorted into the ones that come and the ones a scene before mounted that have to go and come back.
11410
11482
  *
11411
11483
  * @param pctx - Domain context of the projection module.
11412
11484
  * @param names - Names of the projections to mount.
11485
+ * @param layers - The layer list they are mounted under: the current one, or the one a build comes with.
11413
11486
  * @returns The specs to mount and reconcile, and the names to unmount first.
11414
- * @throws {Error} For an unknown name, or a `layer` or `lift` the scene does not declare.
11487
+ * @throws {Error} For an unknown name, or a `layer` or `lift` the list does not declare.
11415
11488
  */
11416
- function sortMount(pctx, names) {
11489
+ function sortMount(pctx, names, layers) {
11417
11490
  const state = pctx.ctx.state.projection;
11418
11491
  const fresh = [];
11419
11492
  const again = [];
@@ -11421,12 +11494,12 @@ function sortMount(pctx, names) {
11421
11494
  const spec = state.specs.get(name);
11422
11495
  if (spec === void 0) throw new Error(`[game] Projection "${name}" is not registered.\n Register it before a scene mounts it.`);
11423
11496
  const mounted = state.mounted.get(name);
11424
- if (mounted?.layers === state.layers) {
11497
+ if (mounted?.layers === layers) {
11425
11498
  pctx.ctx.log.warn("world:already-mounted", { projection: name });
11426
11499
  continue;
11427
11500
  }
11428
- checkLayers(pctx, spec);
11429
- if (mounted !== void 0 && drawsAlike(mounted.layers, state.layers, spec)) continue;
11501
+ checkLayers(spec, layers);
11502
+ if (mounted !== void 0 && drawsAlike(mounted.layers, layers, spec)) continue;
11430
11503
  if (mounted !== void 0) again.push(name);
11431
11504
  fresh.push(spec);
11432
11505
  }
@@ -11448,18 +11521,40 @@ function sortMount(pctx, names) {
11448
11521
  * @throws {Error} For an unknown name, or a `layer` or `lift` the scene does not declare.
11449
11522
  */
11450
11523
  function mountNames(pctx, names, owner) {
11451
- const state = pctx.ctx.state.projection;
11452
- const { fresh, again } = sortMount(pctx, names);
11524
+ const { fresh, again } = sortMount(pctx, names, pctx.ctx.state.projection.layers);
11453
11525
  unmountNames(pctx, again);
11526
+ placeNames(pctx, names, owner);
11527
+ reconcileFresh(pctx, fresh.map((spec) => spec.name));
11528
+ }
11529
+ /**
11530
+ * Gives every projection of a list its place under the current layer list: a missing one gets an
11531
+ * empty record at the `Order` of its place, a kept one mounted under another list takes its new
11532
+ * place (`placeMounted`). A screen root draws at its place in the list (spec 16 U1.6).
11533
+ *
11534
+ * @param pctx - Domain context of the projection module.
11535
+ * @param names - The projection list of the scene.
11536
+ * @param owner - Who owns the entities of a missing one.
11537
+ */
11538
+ function placeNames(pctx, names, owner) {
11539
+ const state = pctx.ctx.state.projection;
11454
11540
  for (const name of names) {
11455
- const order = names.indexOf(name) - names.length;
11541
+ const order = orderIn(names, name);
11456
11542
  const mounted = state.mounted.get(name);
11457
11543
  if (mounted === void 0) state.mounted.set(name, emptyMounted(owner, order, state.layers));
11458
11544
  else if (mounted.layers !== state.layers) placeMounted(pctx, mounted, order);
11459
11545
  }
11460
- if (fresh.length === 0) return;
11546
+ }
11547
+ /**
11548
+ * Reconciles freshly placed projections at once, direct, as a mount does. Nothing runs for an
11549
+ * empty list.
11550
+ *
11551
+ * @param pctx - Domain context of the projection module.
11552
+ * @param names - The projections that came.
11553
+ */
11554
+ function reconcileFresh(pctx, names) {
11555
+ if (names.length === 0) return;
11461
11556
  reconcile(pctx, {
11462
- names: fresh.map((spec) => spec.name),
11557
+ names,
11463
11558
  direct: true,
11464
11559
  force: true,
11465
11560
  converge: false,
@@ -11467,6 +11562,148 @@ function mountNames(pctx, names, owner) {
11467
11562
  });
11468
11563
  }
11469
11564
  //#endregion
11565
+ //#region src/plugins/world/projection/build.ts
11566
+ /**
11567
+ * Creates the empty table of a build: nothing mounted aside and nothing spawned yet.
11568
+ *
11569
+ * @param names - The projection list of the coming scene.
11570
+ * @param layers - The layer list of the coming scene.
11571
+ * @param owner - Who owns the entities of the projections it mounts.
11572
+ * @param timing - The frame budget and the moment the build starts.
11573
+ * @param timing.budgetMs - Milliseconds per frame the building work may take.
11574
+ * @param timing.now - The game clock at the build start.
11575
+ * @returns The table, its frame starting at the build start.
11576
+ */
11577
+ function emptyBuilding(names, layers, owner, timing) {
11578
+ return {
11579
+ names: [...names],
11580
+ layers,
11581
+ owner,
11582
+ mounted: /* @__PURE__ */ new Map(),
11583
+ entities: /* @__PURE__ */ new Set(),
11584
+ frameStart: timing.now,
11585
+ budgetMs: timing.budgetMs
11586
+ };
11587
+ }
11588
+ /**
11589
+ * Creates the empty set of dirty roots. It lives in its own non-exported function because lint
11590
+ * rule L5 refuses a collection built inside an exported declaration.
11591
+ *
11592
+ * @returns An empty set of roots.
11593
+ */
11594
+ function emptyRoots() {
11595
+ return /* @__PURE__ */ new Set();
11596
+ }
11597
+ /**
11598
+ * Marks the world dirty at the end of a build that passed mounted projections over (`held`), so the
11599
+ * next frame's reconcile reaches the ones still mounted: their last reconcile read an older state,
11600
+ * every other projection read this one and is passed over. A build that held a forced reconcile
11601
+ * forces the next one too. A build that held nothing marks nothing.
11602
+ *
11603
+ * @param pctx - Domain context of the projection module.
11604
+ * @param building - The build that ends.
11605
+ */
11606
+ function handBack(pctx, building) {
11607
+ const state = pctx.ctx.state.projection;
11608
+ const held = building.held;
11609
+ if (held === void 0) return;
11610
+ const dirty = state.dirty ?? {
11611
+ causes: [],
11612
+ roots: emptyRoots(),
11613
+ force: false
11614
+ };
11615
+ dirty.causes.push(held === "rerun" ? "rerun" : "edge");
11616
+ dirty.roots.add("player");
11617
+ dirty.roots.add("session");
11618
+ dirty.force ||= held === "rerun";
11619
+ state.dirty = dirty;
11620
+ }
11621
+ /**
11622
+ * Starts the build of a scene's list under its layer list. A projection mounted now under other
11623
+ * layer rules makes it answer false before anything is touched. The rest of the list is checked as
11624
+ * a mount checks it, against the coming layers; a projection the current scene mounts and the
11625
+ * coming one declares alike stays as it is, and every other one is mounted aside, direct. A view
11626
+ * that throws leaves nothing behind: what the build spawned is despawned and the throw goes on.
11627
+ *
11628
+ * @param pctx - Domain context of the projection module.
11629
+ * @param names - The projection list of the coming scene.
11630
+ * @param owner - Who owns the entities of the projections it mounts.
11631
+ * @param layers - The layer list of the coming scene.
11632
+ * @param budgetMs - Milliseconds per frame the building work may take.
11633
+ * @returns True when the build runs, false on a projection mounted under other layer rules.
11634
+ * @throws {Error} When a build already runs, for an unknown name, for a `layer` or `lift` the
11635
+ * coming list does not declare, and whatever a `view` threw.
11636
+ */
11637
+ function buildNames(pctx, names, owner, layers, budgetMs) {
11638
+ const state = pctx.ctx.state.projection;
11639
+ if (state.building !== void 0) throw new Error("[game] A scene is already being built.\n Commit or discard the running build before the next one starts.");
11640
+ const { fresh, again } = sortMount(pctx, names, layers);
11641
+ if (again.length > 0) return false;
11642
+ const building = emptyBuilding(names, layers, owner, {
11643
+ budgetMs,
11644
+ now: pctx.ctx.deps.clock.now()
11645
+ });
11646
+ for (const spec of fresh) building.mounted.set(spec.name, emptyMounted(owner, orderIn(names, spec.name), layers));
11647
+ state.building = building;
11648
+ if (fresh.length === 0) return true;
11649
+ try {
11650
+ reconcile(pctx, {
11651
+ table: building.mounted,
11652
+ direct: true,
11653
+ force: true,
11654
+ converge: false,
11655
+ settle: false,
11656
+ strict: true
11657
+ });
11658
+ } catch (error) {
11659
+ discardBuild(pctx);
11660
+ throw error;
11661
+ }
11662
+ return true;
11663
+ }
11664
+ /**
11665
+ * The swap of the running build. Its projections move into the mounted table, and the list is
11666
+ * placed as a mount places it: every moved and kept projection takes the layer list `setLayers`
11667
+ * stored just before (`placeMounted`), so the next scene compares it by identity as after a mount;
11668
+ * a kept one takes its new place, and one the build kept that left before the commit is mounted
11669
+ * now, direct. The views that moved owe their loop, also when nothing left with the commit, and the
11670
+ * hosts of `ui` look at the views again.
11671
+ *
11672
+ * @param pctx - Domain context of the projection module.
11673
+ */
11674
+ function commitBuild(pctx) {
11675
+ const state = pctx.ctx.state.projection;
11676
+ const building = state.building;
11677
+ if (building === void 0) return;
11678
+ delete state.building;
11679
+ for (const [name, mounted] of building.mounted) state.mounted.set(name, mounted);
11680
+ const missing = building.names.filter((name) => !state.mounted.has(name));
11681
+ placeNames(pctx, building.names, building.owner);
11682
+ state.owedLoops += 1;
11683
+ state.membershipVersion += 1;
11684
+ reconcileFresh(pctx, missing);
11685
+ handBack(pctx, building);
11686
+ }
11687
+ /**
11688
+ * Drops the running build: every view it spawned leaves with the cause `"discard"` and no motion,
11689
+ * then every entity `adopt` marked that is still there is despawned too, and the table goes. The
11690
+ * removal hooks still see the build while they run.
11691
+ *
11692
+ * @param pctx - Domain context of the projection module.
11693
+ */
11694
+ function discardBuild(pctx) {
11695
+ const state = pctx.ctx.state.projection;
11696
+ const building = state.building;
11697
+ if (building === void 0) return;
11698
+ for (const mounted of building.mounted.values()) for (const view of [...mounted.live.values()]) {
11699
+ view.cause = "discard";
11700
+ despawnView(pctx, mounted, view);
11701
+ }
11702
+ for (const entity of building.entities) if (pctx.deps.ecs.ownerOf(entity) !== void 0) pctx.deps.ecs.despawn(entity);
11703
+ delete state.building;
11704
+ handBack(pctx, building);
11705
+ }
11706
+ //#endregion
11470
11707
  //#region src/plugins/world/projection/api.ts
11471
11708
  /**
11472
11709
  * Tells whether a cause sets the picture instead of playing it.
@@ -11641,7 +11878,7 @@ function createProjectionApi(ctx, deps) {
11641
11878
  replace: (spec) => {
11642
11879
  if (!state.specs.has(spec.name)) throw new Error(`[game] Projection "${spec.name}" is not registered.\n Register it before replacing it.`);
11643
11880
  const mounted = state.mounted.has(spec.name);
11644
- if (mounted) checkLayers(pctx, spec);
11881
+ if (mounted) checkLayers(spec, state.layers);
11645
11882
  state.specs.set(spec.name, spec);
11646
11883
  if (mounted) forceRerun();
11647
11884
  },
@@ -11716,7 +11953,7 @@ function createProjectionApi(ctx, deps) {
11716
11953
  motionsOf: (entity) => runningComponents(state.tracks, entity),
11717
11954
  entityOf: (projection, key) => state.mounted.get(projection)?.live.get(key)?.entity ?? state.keys.get(projection)?.get(key),
11718
11955
  entitiesOf: (name) => {
11719
- const mounted = state.mounted.get(name);
11956
+ const mounted = state.mounted.get(name) ?? state.building?.mounted.get(name);
11720
11957
  return mounted === void 0 ? [] : liveEntities(mounted);
11721
11958
  },
11722
11959
  setDriver: (driver) => {
@@ -11762,6 +11999,38 @@ function createProjectionApi(ctx, deps) {
11762
11999
  };
11763
12000
  },
11764
12001
  rerunAll: forceRerun,
12002
+ build: (names, owner, layers, budgetMs) => buildNames(pctx, names, owner, layers, budgetMs),
12003
+ built: () => {
12004
+ if (state.building === void 0) return false;
12005
+ for (const check of state.buildChecks) if (!check()) return false;
12006
+ return true;
12007
+ },
12008
+ commit: () => {
12009
+ commitBuild(pctx);
12010
+ },
12011
+ discard: () => {
12012
+ discardBuild(pctx);
12013
+ },
12014
+ left: () => {
12015
+ const building = state.building;
12016
+ if (building === void 0) return Number.POSITIVE_INFINITY;
12017
+ return building.budgetMs - (ctx.deps.clock.now() - building.frameStart);
12018
+ },
12019
+ isBuilding: (entity) => state.building?.entities.has(entity) === true,
12020
+ onBuild: (check) => {
12021
+ state.buildChecks.push(check);
12022
+ return () => {
12023
+ const at = state.buildChecks.indexOf(check);
12024
+ if (at !== -1) state.buildChecks.splice(at, 1);
12025
+ };
12026
+ },
12027
+ adopt: (entity) => {
12028
+ state.building?.entities.add(entity);
12029
+ },
12030
+ startFrame: (now) => {
12031
+ const building = state.building;
12032
+ if (building !== void 0) building.frameStart = now;
12033
+ },
11765
12034
  markDirty: (roots, cause) => {
11766
12035
  const dirty = state.dirty ?? emptyDirty();
11767
12036
  dirty.causes.push(cause);
@@ -11800,6 +12069,7 @@ function createProjectionApi(ctx, deps) {
11800
12069
  if (mounted !== void 0) clearMounted(pctx, mounted);
11801
12070
  },
11802
12071
  clear: () => {
12072
+ discardBuild(pctx);
11803
12073
  for (const mounted of [...state.mounted.values()]) clearMounted(pctx, mounted);
11804
12074
  state.mounted.clear();
11805
12075
  state.specs.clear();
@@ -11908,8 +12178,8 @@ function exposeEcs(ecs, projection) {
11908
12178
  };
11909
12179
  }
11910
12180
  /**
11911
- * Reduces the projection module to what `scenes`, `input`, `i18n` and the `/inspect` door call.
11912
- * The frame methods and the dirty flag stay with the plugin root.
12181
+ * Reduces the projection module to what `scenes`, `input`, `i18n`, `ui`, `renderer` and the
12182
+ * `/inspect` door call. The frame methods and the dirty flag stay with the plugin root.
11913
12183
  *
11914
12184
  * @param projection - The full projection module.
11915
12185
  * @returns The public projection API.
@@ -11937,7 +12207,15 @@ function exposeProjection(projection) {
11937
12207
  restOf: projection.restOf,
11938
12208
  registerKey: projection.registerKey,
11939
12209
  onConverged: projection.onConverged,
11940
- rerunAll: projection.rerunAll
12210
+ rerunAll: projection.rerunAll,
12211
+ build: projection.build,
12212
+ built: projection.built,
12213
+ commit: projection.commit,
12214
+ discard: projection.discard,
12215
+ left: projection.left,
12216
+ isBuilding: projection.isBuilding,
12217
+ onBuild: projection.onBuild,
12218
+ adopt: projection.adopt
11941
12219
  };
11942
12220
  }
11943
12221
  /**
@@ -12080,7 +12358,8 @@ function emptyWeakMap$2() {
12080
12358
  }
12081
12359
  /**
12082
12360
  * Creates the initial projection state: nothing registered, nothing mounted, no layer list, no
12083
- * running track and no driver, so every motion is instant until `anim` installs one.
12361
+ * running track and no driver, so every motion is instant until `anim` installs one. No build
12362
+ * runs: `building` is absent.
12084
12363
  *
12085
12364
  * @returns The projection branch of the plugin state.
12086
12365
  */
@@ -12106,7 +12385,8 @@ function createProjectionState() {
12106
12385
  membershipVersion: 0,
12107
12386
  moving: false,
12108
12387
  owedLoops: 0,
12109
- sweptDespawns: 0
12388
+ sweptDespawns: 0,
12389
+ buildChecks: []
12110
12390
  };
12111
12391
  }
12112
12392
  //#endregion
@@ -23563,7 +23843,7 @@ function heldFilters(target) {
23563
23843
  * @param slots - The slots to compare with.
23564
23844
  * @returns True when nothing has to be written.
23565
23845
  */
23566
- function holds(target, slots) {
23846
+ function holds$1(target, slots) {
23567
23847
  const held = heldFilters(target);
23568
23848
  let at = 0;
23569
23849
  for (let index = 0; index < slots.length; index += 1) {
@@ -23620,7 +23900,7 @@ function clearFilters(target) {
23620
23900
  * @param target - Where the filters hang: the view's top unless the kept cache says otherwise.
23621
23901
  */
23622
23902
  function applyFilters(view, slots, before, target = view.wrapper ?? view.object) {
23623
- if (holds(target, slots)) return;
23903
+ if (holds$1(target, slots)) return;
23624
23904
  before();
23625
23905
  writeFilters(target, slots);
23626
23906
  }
@@ -23732,7 +24012,7 @@ function applySwitchedOne(slots, entity) {
23732
24012
  const view = sync?.views.get(entity);
23733
24013
  if (view === void 0 || kept === void 0) return;
23734
24014
  const target = kept.filterTarget(view);
23735
- if (holds(target, slots)) return;
24015
+ if (holds$1(target, slots)) return;
23736
24016
  writeFilters(target, slots);
23737
24017
  switched.wrote = true;
23738
24018
  }
@@ -24643,6 +24923,348 @@ function clearFonts(state) {
24643
24923
  state.fontCache = void 0;
24644
24924
  }
24645
24925
  //#endregion
24926
+ //#region src/plugins/renderer/sync/pools.ts
24927
+ /**
24928
+ * Tells whether a display object is a sprite. A `NineSliceSprite` carries the slice widths and
24929
+ * is recognised by them.
24930
+ *
24931
+ * @param object - The display object.
24932
+ * @returns True for a plain sprite.
24933
+ */
24934
+ function isSprite(object) {
24935
+ return "texture" in object && !("leftWidth" in object);
24936
+ }
24937
+ /**
24938
+ * Tells whether a display object draws paths, so a `Shape` can be written onto it.
24939
+ *
24940
+ * @param object - The display object.
24941
+ * @returns True for a graphics object.
24942
+ */
24943
+ function isGraphics(object) {
24944
+ return "roundRect" in object;
24945
+ }
24946
+ /**
24947
+ * Tells whether a display object draws a nine-slice: Pixi's `NineSliceSprite`, or the engine's rim-only mesh of a
24948
+ * `hollow` one. Both carry the slice widths.
24949
+ *
24950
+ * @param object - The display object.
24951
+ * @returns True for either nine-slice object.
24952
+ */
24953
+ function isNineSlice(object) {
24954
+ return "leftWidth" in object;
24955
+ }
24956
+ /**
24957
+ * Tells whether a display object is the engine's rim-only nine-slice (F59).
24958
+ *
24959
+ * @param object - The display object.
24960
+ * @returns True for the hollow mesh, false for Pixi's `NineSliceSprite` and every other object.
24961
+ */
24962
+ function isHollow(object) {
24963
+ return "hollow" in object;
24964
+ }
24965
+ /**
24966
+ * Takes a display object out of the Pixi tree. Pixi containers are not DOM nodes, so the DOM
24967
+ * helper the linter suggests does not exist on them.
24968
+ *
24969
+ * @param object - The display object.
24970
+ */
24971
+ function detach$1(object) {
24972
+ object.parent?.removeChild(object);
24973
+ }
24974
+ /**
24975
+ * Takes the masked container off a view: its children go back to the wrapper, unmasked and in
24976
+ * their order, and the container is freed.
24977
+ *
24978
+ * @param view - The view of a shape that clips, or of anything that does not.
24979
+ */
24980
+ function dropClipped(view) {
24981
+ const clipped = view.clipped;
24982
+ if (clipped === void 0) return;
24983
+ clipped.mask = null;
24984
+ const wrapper = view.wrapper;
24985
+ if (wrapper !== void 0) for (const child of clipped.children) wrapper.addChild(child);
24986
+ detach$1(clipped);
24987
+ clipped.destroy();
24988
+ view.clipped = void 0;
24989
+ }
24990
+ /**
24991
+ * Takes the clip rectangle off a view: the children stop being masked and the graphics goes.
24992
+ *
24993
+ * @param view - The view of a shape that clips, or of anything that does not.
24994
+ */
24995
+ function dropMask(view) {
24996
+ dropClipped(view);
24997
+ const mask = view.mask;
24998
+ if (mask === void 0) return;
24999
+ detach$1(mask);
25000
+ mask.destroy();
25001
+ view.mask = void 0;
25002
+ }
25003
+ /**
25004
+ * Takes the debug outline off a view: the graphics leaves the wrapper and is freed.
25005
+ *
25006
+ * @param view - The view of a nine-slice that was outlined, or of anything that was not.
25007
+ */
25008
+ function dropOutline(view) {
25009
+ const outline = view.outline;
25010
+ if (outline === void 0) return;
25011
+ detach$1(outline);
25012
+ outline.destroy();
25013
+ view.outline = void 0;
25014
+ }
25015
+ /**
25016
+ * The pool an object belongs to: its class and the texture it draws. A rim-only nine-slice has a pool of its own, so
25017
+ * a filled panel never gets a hollow object back, nor the other way round.
25018
+ *
25019
+ * @param kind - Which component gave the entity its display object.
25020
+ * @param textureKey - The asset key.
25021
+ * @param hollow - True for the rim-only object of a `NineSlice` with `hollow: true`.
25022
+ * @returns The pool key.
25023
+ * @example
25024
+ * ```ts
25025
+ * poolKeyOf("NineSlice", "glass.frost", true); // "NineSlice.hollow:glass.frost"
25026
+ * ```
25027
+ */
25028
+ function poolKeyOf(kind, textureKey, hollow) {
25029
+ return hollow ? `${kind}.hollow:${textureKey}` : `${kind}:${textureKey}`;
25030
+ }
25031
+ /**
25032
+ * Brings the object of a view that left back to the state a fresh one has: detached, drawn, opaque, unmoved, blending
25033
+ * normally, white.
25034
+ * Its hidden reasons go with `clearHidden`, so a pooled object never brings the reason of the view it belonged to; a
25035
+ * sprite and a graphics lose their tint (a fill-only shape is coloured by it, spec 26 R2.1).
25036
+ *
25037
+ * @param view - The view whose object goes back to its pool; its wrapper is gone already.
25038
+ */
25039
+ function resetObject(view) {
25040
+ detach$1(view.object);
25041
+ clearHidden(view);
25042
+ resetNode(view.object);
25043
+ }
25044
+ /**
25045
+ * Brings a detached object back to a fresh one's pose and look: drawn, opaque, unmoved, blending as its parent (no
25046
+ * glow of the view it drew for), white for a sprite and a graphics, and a graphics without a path.
25047
+ *
25048
+ * @param object - The object, out of the tree.
25049
+ */
25050
+ function resetNode(object) {
25051
+ object.visible = true;
25052
+ object.alpha = 1;
25053
+ object.blendMode = "inherit";
25054
+ object.rotation = 0;
25055
+ object.zIndex = 0;
25056
+ object.position.set(0, 0);
25057
+ object.pivot.set(0, 0);
25058
+ object.scale.set(1);
25059
+ if (isSprite(object)) object.tint = 16777215;
25060
+ if (isGraphics(object)) {
25061
+ object.tint = 16777215;
25062
+ object.clear();
25063
+ }
25064
+ }
25065
+ /**
25066
+ * Takes an object out of its pool, if one is waiting.
25067
+ *
25068
+ * @param state - The sync branch of the plugin state.
25069
+ * @param poolKey - Class and texture key.
25070
+ * @returns A reusable object, or `undefined`.
25071
+ */
25072
+ function acquire(state, poolKey) {
25073
+ const object = state.pools.get(poolKey)?.pop();
25074
+ if (object !== void 0) state.pooled -= 1;
25075
+ return object;
25076
+ }
25077
+ /**
25078
+ * Creates the array a pool keeps its objects in. Lint rule L5 refuses a collection built inside
25079
+ * an exported declaration, so it lives here.
25080
+ *
25081
+ * @returns An empty pool.
25082
+ */
25083
+ function emptyPool() {
25084
+ return [];
25085
+ }
25086
+ /**
25087
+ * The pool that holds the object that has waited longest.
25088
+ *
25089
+ * @param state - The sync branch of the plugin state.
25090
+ * @returns A non-empty pool, or `undefined` when every pool is empty.
25091
+ */
25092
+ function oldestPool(state) {
25093
+ for (const pool of state.pools.values()) if (pool.length > 0) return pool;
25094
+ }
25095
+ /**
25096
+ * Puts a view's object back into its pool and evicts the oldest one when the pool grew past the
25097
+ * configured limit. A `Display` object belongs to the game and is only detached. The filters and the
25098
+ * filter area the renderer hung on the object or its wrapper come off first, so a pooled object never
25099
+ * carries a filter into its next life; the filter instances themselves belong to `effects`.
25100
+ *
25101
+ * @param sctx - Domain context of the sync module.
25102
+ * @param view - The view that left.
25103
+ */
25104
+ function release$1(sctx, view) {
25105
+ dropMask(view);
25106
+ dropOutline(view);
25107
+ clearFilters(view.object);
25108
+ clearArea(view.object);
25109
+ if (view.wrapper !== void 0) {
25110
+ clearFilters(view.wrapper);
25111
+ clearArea(view.wrapper);
25112
+ detach$1(view.wrapper);
25113
+ view.wrapper.destroy({ children: false });
25114
+ view.wrapper = void 0;
25115
+ }
25116
+ if (view.kind === "Display") {
25117
+ detach$1(view.object);
25118
+ clearHidden(view);
25119
+ return;
25120
+ }
25121
+ if (view.kind === "adapter") {
25122
+ detach$1(view.object);
25123
+ clearHidden(view);
25124
+ view.display?.adapter.destroy(view.object);
25125
+ return;
25126
+ }
25127
+ dropGroup(sctx, view.object);
25128
+ resetObject(view);
25129
+ store$1(sctx, view.object, view.poolKey);
25130
+ }
25131
+ /**
25132
+ * Puts an object a view no longer draws with back into a pool, while the view goes on with another one: the
25133
+ * nine-slice whose `hollow` flipped (F59). Reset as `release` resets a view's object; its view keeps its hidden
25134
+ * reasons, which hang on the view's wrapper.
25135
+ *
25136
+ * @param sctx - Domain context of the sync module.
25137
+ * @param object - The object, still in the view's wrapper.
25138
+ * @param poolKey - The pool it belongs to.
25139
+ */
25140
+ function releaseObject(sctx, object, poolKey) {
25141
+ dropGroup(sctx, object);
25142
+ detach$1(object);
25143
+ resetNode(object);
25144
+ store$1(sctx, object, poolKey);
25145
+ }
25146
+ /**
25147
+ * Takes away the render group the kept cache made of an object (spec 23 P9.3), before it is pooled.
25148
+ *
25149
+ * @param sctx - Domain context of the sync module.
25150
+ * @param object - The object.
25151
+ */
25152
+ function dropGroup(sctx, object) {
25153
+ const groups = sctx.ctx.state.kept.madeGroups;
25154
+ if (!groups.has(object)) return;
25155
+ object.disableRenderGroup();
25156
+ groups.delete(object);
25157
+ }
25158
+ /**
25159
+ * Pushes a reset object into its pool and evicts the one that waited longest while the pools hold more than
25160
+ * `poolLimit`.
25161
+ *
25162
+ * @param sctx - Domain context of the sync module.
25163
+ * @param object - The reset object.
25164
+ * @param poolKey - Its pool.
25165
+ */
25166
+ function store$1(sctx, object, poolKey) {
25167
+ const state = sctx.ctx.state.sync;
25168
+ const pool = state.pools.get(poolKey) ?? emptyPool();
25169
+ pool.push(object);
25170
+ state.pools.set(poolKey, pool);
25171
+ state.pooled += 1;
25172
+ while (state.pooled > sctx.ctx.config.poolLimit) {
25173
+ const oldest = oldestPool(state);
25174
+ if (oldest === void 0) return;
25175
+ oldest.shift()?.destroy({ texture: false });
25176
+ state.pooled -= 1;
25177
+ }
25178
+ }
25179
+ /**
25180
+ * Destroys the pooled objects that draw one of these keys, so a bundle that left frees its GPU
25181
+ * memory instead of waiting for a reuse that resolves nothing: sprites, nine-slices and rim-only nine-slices.
25182
+ *
25183
+ * @param state - The sync branch of the plugin state.
25184
+ * @param keys - The asset keys that went stale.
25185
+ */
25186
+ function dropPooled(state, keys) {
25187
+ for (const key of keys) {
25188
+ dropPool(state, poolKeyOf("Sprite", key, false));
25189
+ dropPool(state, poolKeyOf("NineSlice", key, false));
25190
+ dropPool(state, poolKeyOf("NineSlice", key, true));
25191
+ }
25192
+ }
25193
+ /**
25194
+ * Destroys one pool and everything in it.
25195
+ *
25196
+ * @param state - The sync branch of the plugin state.
25197
+ * @param poolKey - The pool.
25198
+ */
25199
+ function dropPool(state, poolKey) {
25200
+ const pool = state.pools.get(poolKey);
25201
+ if (pool === void 0) return;
25202
+ for (const object of pool) object.destroy({ texture: false });
25203
+ state.pooled -= pool.length;
25204
+ state.pools.delete(poolKey);
25205
+ }
25206
+ /**
25207
+ * Destroys everything the pools hold. Works on the state alone, because `onStop` has no context.
25208
+ *
25209
+ * @param state - The sync branch of the plugin state.
25210
+ */
25211
+ function destroyPools(state) {
25212
+ for (const pool of state.pools.values()) for (const object of pool) object.destroy({ texture: false });
25213
+ state.pools.clear();
25214
+ state.pooled = 0;
25215
+ }
25216
+ //#endregion
25217
+ //#region src/plugins/renderer/sync/held.ts
25218
+ /**
25219
+ * Tells whether a view is held: its entity belongs to the running build, and it has no parent or its parent is live.
25220
+ * A building view under a building parent hangs in the parent's tree, which is held as a whole. One set lookup for a
25221
+ * live entity.
25222
+ *
25223
+ * @param sctx - Domain context of the sync module.
25224
+ * @param entity - The entity of the view.
25225
+ * @param parent - The entity its `Parent` names, 0 without one.
25226
+ * @returns True when the view must hang nowhere until the commit.
25227
+ */
25228
+ function holds(sctx, entity, parent) {
25229
+ const projection = sctx.ctx.deps.world.projection;
25230
+ return projection.isBuilding(entity) && (parent === 0 || !projection.isBuilding(parent));
25231
+ }
25232
+ /**
25233
+ * Holds a view of the build: out of the tree, no orphan, listed once in `heldViews`. A top keeps the name of the layer
25234
+ * it will hang in, which the coming scene may declare only after the commit.
25235
+ *
25236
+ * @param sctx - Domain context of the sync module.
25237
+ * @param entity - The entity.
25238
+ * @param view - Its view.
25239
+ * @param layer - The layer it names; `""` for a view whose parent is live.
25240
+ */
25241
+ function holdView(sctx, entity, view, layer) {
25242
+ const state = sctx.ctx.state.sync;
25243
+ const held = state.heldViews;
25244
+ state.orphans.delete(entity);
25245
+ view.layer = layer;
25246
+ detach$1(view.wrapper ?? view.object);
25247
+ if (view.held) return;
25248
+ view.held = true;
25249
+ held.entities[held.count] = entity;
25250
+ held.count += 1;
25251
+ }
25252
+ /**
25253
+ * Forgets the queues of a scene build with the tree, on a device loss, a rebuild or a stop: the views they name are
25254
+ * gone, and a rebuild holds every building view again. Works on the state alone.
25255
+ *
25256
+ * @param state - The sync branch of the plugin state.
25257
+ */
25258
+ function forgetBuild(state) {
25259
+ const { aside, heldViews } = state;
25260
+ aside.count = 0;
25261
+ aside.head = 0;
25262
+ aside.made = 0;
25263
+ aside.waiting.clear();
25264
+ heldViews.count = 0;
25265
+ heldViews.reported = false;
25266
+ }
25267
+ //#endregion
24646
25268
  //#region src/plugins/renderer/sync/outline.ts
24647
25269
  /** Above every child of the wrapper, whatever `Order` a child carries. */
24648
25270
  const OUTLINE_Z = Number.MAX_SAFE_INTEGER;
@@ -25071,297 +25693,6 @@ function labelOf$1(sctx, name, entity) {
25071
25693
  return key === void 0 ? base : `${base} ${key.projection}:${key.key}`;
25072
25694
  }
25073
25695
  //#endregion
25074
- //#region src/plugins/renderer/sync/pools.ts
25075
- /**
25076
- * Tells whether a display object is a sprite. A `NineSliceSprite` carries the slice widths and
25077
- * is recognised by them.
25078
- *
25079
- * @param object - The display object.
25080
- * @returns True for a plain sprite.
25081
- */
25082
- function isSprite(object) {
25083
- return "texture" in object && !("leftWidth" in object);
25084
- }
25085
- /**
25086
- * Tells whether a display object draws paths, so a `Shape` can be written onto it.
25087
- *
25088
- * @param object - The display object.
25089
- * @returns True for a graphics object.
25090
- */
25091
- function isGraphics(object) {
25092
- return "roundRect" in object;
25093
- }
25094
- /**
25095
- * Tells whether a display object draws a nine-slice: Pixi's `NineSliceSprite`, or the engine's rim-only mesh of a
25096
- * `hollow` one. Both carry the slice widths.
25097
- *
25098
- * @param object - The display object.
25099
- * @returns True for either nine-slice object.
25100
- */
25101
- function isNineSlice(object) {
25102
- return "leftWidth" in object;
25103
- }
25104
- /**
25105
- * Tells whether a display object is the engine's rim-only nine-slice (F59).
25106
- *
25107
- * @param object - The display object.
25108
- * @returns True for the hollow mesh, false for Pixi's `NineSliceSprite` and every other object.
25109
- */
25110
- function isHollow(object) {
25111
- return "hollow" in object;
25112
- }
25113
- /**
25114
- * Takes a display object out of the Pixi tree. Pixi containers are not DOM nodes, so the DOM
25115
- * helper the linter suggests does not exist on them.
25116
- *
25117
- * @param object - The display object.
25118
- */
25119
- function detach$1(object) {
25120
- object.parent?.removeChild(object);
25121
- }
25122
- /**
25123
- * Takes the masked container off a view: its children go back to the wrapper, unmasked and in
25124
- * their order, and the container is freed.
25125
- *
25126
- * @param view - The view of a shape that clips, or of anything that does not.
25127
- */
25128
- function dropClipped(view) {
25129
- const clipped = view.clipped;
25130
- if (clipped === void 0) return;
25131
- clipped.mask = null;
25132
- const wrapper = view.wrapper;
25133
- if (wrapper !== void 0) for (const child of clipped.children) wrapper.addChild(child);
25134
- detach$1(clipped);
25135
- clipped.destroy();
25136
- view.clipped = void 0;
25137
- }
25138
- /**
25139
- * Takes the clip rectangle off a view: the children stop being masked and the graphics goes.
25140
- *
25141
- * @param view - The view of a shape that clips, or of anything that does not.
25142
- */
25143
- function dropMask(view) {
25144
- dropClipped(view);
25145
- const mask = view.mask;
25146
- if (mask === void 0) return;
25147
- detach$1(mask);
25148
- mask.destroy();
25149
- view.mask = void 0;
25150
- }
25151
- /**
25152
- * Takes the debug outline off a view: the graphics leaves the wrapper and is freed.
25153
- *
25154
- * @param view - The view of a nine-slice that was outlined, or of anything that was not.
25155
- */
25156
- function dropOutline(view) {
25157
- const outline = view.outline;
25158
- if (outline === void 0) return;
25159
- detach$1(outline);
25160
- outline.destroy();
25161
- view.outline = void 0;
25162
- }
25163
- /**
25164
- * The pool an object belongs to: its class and the texture it draws. A rim-only nine-slice has a pool of its own, so
25165
- * a filled panel never gets a hollow object back, nor the other way round.
25166
- *
25167
- * @param kind - Which component gave the entity its display object.
25168
- * @param textureKey - The asset key.
25169
- * @param hollow - True for the rim-only object of a `NineSlice` with `hollow: true`.
25170
- * @returns The pool key.
25171
- * @example
25172
- * ```ts
25173
- * poolKeyOf("NineSlice", "glass.frost", true); // "NineSlice.hollow:glass.frost"
25174
- * ```
25175
- */
25176
- function poolKeyOf(kind, textureKey, hollow) {
25177
- return hollow ? `${kind}.hollow:${textureKey}` : `${kind}:${textureKey}`;
25178
- }
25179
- /**
25180
- * Brings the object of a view that left back to the state a fresh one has: detached, drawn, opaque, unmoved, blending
25181
- * normally, white.
25182
- * Its hidden reasons go with `clearHidden`, so a pooled object never brings the reason of the view it belonged to; a
25183
- * sprite and a graphics lose their tint (a fill-only shape is coloured by it, spec 26 R2.1).
25184
- *
25185
- * @param view - The view whose object goes back to its pool; its wrapper is gone already.
25186
- */
25187
- function resetObject(view) {
25188
- detach$1(view.object);
25189
- clearHidden(view);
25190
- resetNode(view.object);
25191
- }
25192
- /**
25193
- * Brings a detached object back to a fresh one's pose and look: drawn, opaque, unmoved, blending as its parent (no
25194
- * glow of the view it drew for), white for a sprite and a graphics, and a graphics without a path.
25195
- *
25196
- * @param object - The object, out of the tree.
25197
- */
25198
- function resetNode(object) {
25199
- object.visible = true;
25200
- object.alpha = 1;
25201
- object.blendMode = "inherit";
25202
- object.rotation = 0;
25203
- object.zIndex = 0;
25204
- object.position.set(0, 0);
25205
- object.pivot.set(0, 0);
25206
- object.scale.set(1);
25207
- if (isSprite(object)) object.tint = 16777215;
25208
- if (isGraphics(object)) {
25209
- object.tint = 16777215;
25210
- object.clear();
25211
- }
25212
- }
25213
- /**
25214
- * Takes an object out of its pool, if one is waiting.
25215
- *
25216
- * @param state - The sync branch of the plugin state.
25217
- * @param poolKey - Class and texture key.
25218
- * @returns A reusable object, or `undefined`.
25219
- */
25220
- function acquire(state, poolKey) {
25221
- const object = state.pools.get(poolKey)?.pop();
25222
- if (object !== void 0) state.pooled -= 1;
25223
- return object;
25224
- }
25225
- /**
25226
- * Creates the array a pool keeps its objects in. Lint rule L5 refuses a collection built inside
25227
- * an exported declaration, so it lives here.
25228
- *
25229
- * @returns An empty pool.
25230
- */
25231
- function emptyPool() {
25232
- return [];
25233
- }
25234
- /**
25235
- * The pool that holds the object that has waited longest.
25236
- *
25237
- * @param state - The sync branch of the plugin state.
25238
- * @returns A non-empty pool, or `undefined` when every pool is empty.
25239
- */
25240
- function oldestPool(state) {
25241
- for (const pool of state.pools.values()) if (pool.length > 0) return pool;
25242
- }
25243
- /**
25244
- * Puts a view's object back into its pool and evicts the oldest one when the pool grew past the
25245
- * configured limit. A `Display` object belongs to the game and is only detached. The filters and the
25246
- * filter area the renderer hung on the object or its wrapper come off first, so a pooled object never
25247
- * carries a filter into its next life; the filter instances themselves belong to `effects`.
25248
- *
25249
- * @param sctx - Domain context of the sync module.
25250
- * @param view - The view that left.
25251
- */
25252
- function release$1(sctx, view) {
25253
- dropMask(view);
25254
- dropOutline(view);
25255
- clearFilters(view.object);
25256
- clearArea(view.object);
25257
- if (view.wrapper !== void 0) {
25258
- clearFilters(view.wrapper);
25259
- clearArea(view.wrapper);
25260
- detach$1(view.wrapper);
25261
- view.wrapper.destroy({ children: false });
25262
- view.wrapper = void 0;
25263
- }
25264
- if (view.kind === "Display") {
25265
- detach$1(view.object);
25266
- clearHidden(view);
25267
- return;
25268
- }
25269
- if (view.kind === "adapter") {
25270
- detach$1(view.object);
25271
- clearHidden(view);
25272
- view.display?.adapter.destroy(view.object);
25273
- return;
25274
- }
25275
- dropGroup(sctx, view.object);
25276
- resetObject(view);
25277
- store$1(sctx, view.object, view.poolKey);
25278
- }
25279
- /**
25280
- * Puts an object a view no longer draws with back into a pool, while the view goes on with another one: the
25281
- * nine-slice whose `hollow` flipped (F59). Reset as `release` resets a view's object; its view keeps its hidden
25282
- * reasons, which hang on the view's wrapper.
25283
- *
25284
- * @param sctx - Domain context of the sync module.
25285
- * @param object - The object, still in the view's wrapper.
25286
- * @param poolKey - The pool it belongs to.
25287
- */
25288
- function releaseObject(sctx, object, poolKey) {
25289
- dropGroup(sctx, object);
25290
- detach$1(object);
25291
- resetNode(object);
25292
- store$1(sctx, object, poolKey);
25293
- }
25294
- /**
25295
- * Takes away the render group the kept cache made of an object (spec 23 P9.3), before it is pooled.
25296
- *
25297
- * @param sctx - Domain context of the sync module.
25298
- * @param object - The object.
25299
- */
25300
- function dropGroup(sctx, object) {
25301
- const groups = sctx.ctx.state.kept.madeGroups;
25302
- if (!groups.has(object)) return;
25303
- object.disableRenderGroup();
25304
- groups.delete(object);
25305
- }
25306
- /**
25307
- * Pushes a reset object into its pool and evicts the one that waited longest while the pools hold more than
25308
- * `poolLimit`.
25309
- *
25310
- * @param sctx - Domain context of the sync module.
25311
- * @param object - The reset object.
25312
- * @param poolKey - Its pool.
25313
- */
25314
- function store$1(sctx, object, poolKey) {
25315
- const state = sctx.ctx.state.sync;
25316
- const pool = state.pools.get(poolKey) ?? emptyPool();
25317
- pool.push(object);
25318
- state.pools.set(poolKey, pool);
25319
- state.pooled += 1;
25320
- while (state.pooled > sctx.ctx.config.poolLimit) {
25321
- const oldest = oldestPool(state);
25322
- if (oldest === void 0) return;
25323
- oldest.shift()?.destroy({ texture: false });
25324
- state.pooled -= 1;
25325
- }
25326
- }
25327
- /**
25328
- * Destroys the pooled objects that draw one of these keys, so a bundle that left frees its GPU
25329
- * memory instead of waiting for a reuse that resolves nothing: sprites, nine-slices and rim-only nine-slices.
25330
- *
25331
- * @param state - The sync branch of the plugin state.
25332
- * @param keys - The asset keys that went stale.
25333
- */
25334
- function dropPooled(state, keys) {
25335
- for (const key of keys) {
25336
- dropPool(state, poolKeyOf("Sprite", key, false));
25337
- dropPool(state, poolKeyOf("NineSlice", key, false));
25338
- dropPool(state, poolKeyOf("NineSlice", key, true));
25339
- }
25340
- }
25341
- /**
25342
- * Destroys one pool and everything in it.
25343
- *
25344
- * @param state - The sync branch of the plugin state.
25345
- * @param poolKey - The pool.
25346
- */
25347
- function dropPool(state, poolKey) {
25348
- const pool = state.pools.get(poolKey);
25349
- if (pool === void 0) return;
25350
- for (const object of pool) object.destroy({ texture: false });
25351
- state.pooled -= pool.length;
25352
- state.pools.delete(poolKey);
25353
- }
25354
- /**
25355
- * Destroys everything the pools hold. Works on the state alone, because `onStop` has no context.
25356
- *
25357
- * @param state - The sync branch of the plugin state.
25358
- */
25359
- function destroyPools(state) {
25360
- for (const pool of state.pools.values()) for (const object of pool) object.destroy({ texture: false });
25361
- state.pools.clear();
25362
- state.pooled = 0;
25363
- }
25364
- //#endregion
25365
25696
  //#region src/plugins/renderer/sync/layers.ts
25366
25697
  /**
25367
25698
  * @file renderer/sync — the layers. `scenes` declares them through `world.projection.setLayers`;
@@ -28066,7 +28397,9 @@ function keepOrphan(sctx, entity, view, parentEntity) {
28066
28397
  /**
28067
28398
  * Hangs a view where it belongs: under its parent, or in the container of the layer it names.
28068
28399
  * An entity with no layer and no parent is not drawn. A view whose parent has no view yet waits
28069
- * as an orphan, detached, so it is never drawn in a layer while its parent waits.
28400
+ * as an orphan, detached, so it is never drawn in a layer while its parent waits. A view of a scene
28401
+ * build with no parent, or with a live parent, is held: it hangs nowhere until the commit (spec 31),
28402
+ * and no wrapper is made on the live parent before it.
28070
28403
  *
28071
28404
  * @param sctx - Domain context of the sync module.
28072
28405
  * @param entity - The entity.
@@ -28077,6 +28410,11 @@ function attach$1(sctx, entity, view) {
28077
28410
  const parentEntity = parentOf$1(sctx.ctx.deps.world.ecs, entity);
28078
28411
  markView(sctx, entity);
28079
28412
  noteLeaving(state, view);
28413
+ if (holds(sctx, entity, parentEntity)) {
28414
+ holdView(sctx, entity, view, parentEntity === 0 ? layerNameOf(sctx, entity) : "");
28415
+ return;
28416
+ }
28417
+ if (view.held) view.held = false;
28080
28418
  if (parentEntity !== 0) {
28081
28419
  const host = childHostOf(sctx, parentEntity);
28082
28420
  view.layer = "";
@@ -28204,7 +28542,8 @@ function createView(sctx, entity) {
28204
28542
  clipDrawn: void 0,
28205
28543
  hiddenSelf: 0,
28206
28544
  hiddenTop: 0,
28207
- hud: 1
28545
+ hud: 1,
28546
+ held: false
28208
28547
  };
28209
28548
  object.label = labelOf$1(sctx, nameOf$1(visual), entity);
28210
28549
  state.views.set(entity, view);
@@ -28305,10 +28644,10 @@ function dropView(sctx, entity) {
28305
28644
  }
28306
28645
  /**
28307
28646
  * Drops every view, every layer, every pool, every filter slot and filter area, the warm-up (its waiting promises
28308
- * answer false, its probe tree is destroyed) and the upload queue (its waiting calls answer false). Works on the state
28309
- * alone,
28310
- * because `onStop` has no context. A `Display` object belongs to the game: it is detached, never
28311
- * destroyed, and leaves without the filters, the filter area and the hidden reasons the renderer gave it.
28647
+ * answer false, its probe tree is destroyed), the upload queue (its waiting calls answer false) and the queues of a
28648
+ * scene build. Works on the state alone, because `onStop` has no context. A `Display` object belongs to the game: it
28649
+ * is detached, never destroyed, and leaves without the filters, the filter area and the hidden reasons the renderer
28650
+ * gave it.
28312
28651
  *
28313
28652
  * @param state - The sync branch of the plugin state.
28314
28653
  */
@@ -28354,6 +28693,7 @@ function stopSync(state) {
28354
28693
  stopWarm(state);
28355
28694
  stopUploads(state);
28356
28695
  state.held.clear();
28696
+ forgetBuild(state);
28357
28697
  state.redraws = 0;
28358
28698
  state.overdraw.listeners.length = 0;
28359
28699
  clearLayers(state);
@@ -28497,8 +28837,7 @@ function withDeps$8(ctx) {
28497
28837
  * overlay step (spec 22), then `kept` reads the marks before `demand` decides whether the frame is
28498
28838
  * drawn and clears them; a drawn frame draws the probes of the warm-up onto the canvas first (F4: the
28499
28839
  * stage pass clears them, in the same task, so they never show), then uploads the queued texture sources (F60), then
28500
- * the kept pictures
28501
- * (`kept.flush`, so the canvas stays one pass, E7), then the stage, then the pixel leg of the kept
28840
+ * the kept pictures (`kept.flush`, so the canvas stays one pass, E7), then the stage, then the pixel leg of the kept
28502
28841
  * oracle; `demand` settles the
28503
28842
  * frame (the dev check, the pixel oracle), and `monitor` closes it and hands a drawn one to a
28504
28843
  * waiting capture; last, `host` trims Pixi's texture pool when a rest or a scene change is due. The display objects
@@ -28509,7 +28848,8 @@ function withDeps$8(ctx) {
28509
28848
  * last picture (WebGPU presents a canvas texture only when one is rendered; a WebGL canvas keeps
28510
28849
  * what it composited until the next draw). A drawn frame is reported to `time` for the adaptive
28511
28850
  * frame rate, and only when Pixi drew it: an inert or lost host reports none. A frame that also drew
28512
- * probes is reported as probed: it created pipelines, so it is never a sample of the rate.
28851
+ * probes, uploaded sources or made views of the next scene aside is reported as probed: it did load work, so it is
28852
+ * never a sample of the rate.
28513
28853
  *
28514
28854
  * @param ctx - Domain context of the renderer plugin.
28515
28855
  * @param modules - The six modules.
@@ -28533,10 +28873,11 @@ function renderFrame(ctx, modules) {
28533
28873
  if (drawn) {
28534
28874
  const probed = modules.sync.warmStep();
28535
28875
  const uploaded = modules.sync.uploadStep();
28876
+ const built = modules.sync.takeBuildWork();
28536
28877
  modules.kept.flush();
28537
28878
  if (overdraw ? modules.sync.drawOverdraw(modules.host.render) : modules.host.render()) {
28538
28879
  ctx.deps.time.frameDrawn();
28539
- if (probed || uploaded) ctx.deps.time.frameProbed();
28880
+ if (probed || uploaded || built) ctx.deps.time.frameProbed();
28540
28881
  }
28541
28882
  modules.kept.after();
28542
28883
  }
@@ -29339,6 +29680,233 @@ function layerOf(sctx, entity) {
29339
29680
  return ecs.get(top, Layer)?.name;
29340
29681
  }
29341
29682
  //#endregion
29683
+ //#region src/plugins/renderer/sync/build.ts
29684
+ /** Views made between two reads of the clock: a read costs more than a small view. */
29685
+ const CLOCK_EVERY = 8;
29686
+ /**
29687
+ * Tells whether a scene is being built: `left()` answers a finite number only while a build runs.
29688
+ *
29689
+ * @param sctx - Domain context of the sync module.
29690
+ * @returns True while a build runs.
29691
+ */
29692
+ function buildRuns(sctx) {
29693
+ return sctx.ctx.deps.world.projection.left() !== Number.POSITIVE_INFINITY;
29694
+ }
29695
+ /**
29696
+ * Queues one building entity for its view, once.
29697
+ *
29698
+ * @param aside - The aside queue.
29699
+ * @param entity - The entity.
29700
+ */
29701
+ function pushAside(aside, entity) {
29702
+ if (aside.waiting.has(entity)) return;
29703
+ aside.waiting.add(entity);
29704
+ aside.entities[aside.count] = entity;
29705
+ aside.count += 1;
29706
+ }
29707
+ /**
29708
+ * Moves the building entities of the pass's added set into the aside queue, in their order: the live pass builds the
29709
+ * rest. One set lookup per added entity; called only while a build runs.
29710
+ *
29711
+ * @param sctx - Domain context of the sync module.
29712
+ * @param taken - The entities the pass took out of `added`; the building ones leave it.
29713
+ */
29714
+ function sortAside(sctx, taken) {
29715
+ const projection = sctx.ctx.deps.world.projection;
29716
+ const aside = sctx.ctx.state.sync.aside;
29717
+ for (const entity of taken) {
29718
+ if (!projection.isBuilding(entity)) continue;
29719
+ taken.delete(entity);
29720
+ pushAside(aside, entity);
29721
+ }
29722
+ }
29723
+ /**
29724
+ * Forgets a queued entity that left before its view was made: the queue passes over it.
29725
+ *
29726
+ * @param state - The sync branch of the plugin state.
29727
+ * @param entity - The entity that left.
29728
+ */
29729
+ function dropAside(state, entity) {
29730
+ if (state.aside.count > 0) state.aside.waiting.delete(entity);
29731
+ }
29732
+ /**
29733
+ * Makes the view of one queued entity, its queued parent first (spec 16 U2.3: a child made before its parent finds no
29734
+ * parent view and is detached for good). The entity leaves the waiting set before its parent is made, so a cycle of
29735
+ * parents ends. A throw costs that entity.
29736
+ *
29737
+ * @param sctx - Domain context of the sync module.
29738
+ * @param entity - The entity.
29739
+ * @param report - Logs a throw of one entity's work.
29740
+ * @returns The views made.
29741
+ */
29742
+ function makeParentFirst(sctx, entity, report) {
29743
+ const waiting = sctx.ctx.state.sync.aside.waiting;
29744
+ if (!waiting.delete(entity)) return 0;
29745
+ const parent = parentOf$1(sctx.ctx.deps.world.ecs, entity);
29746
+ let made = parent !== 0 && waiting.has(parent) ? makeParentFirst(sctx, parent, report) : 0;
29747
+ try {
29748
+ if (createView(sctx, entity)) made += 1;
29749
+ } catch (error) {
29750
+ report(entity, error);
29751
+ }
29752
+ return made;
29753
+ }
29754
+ /**
29755
+ * Makes the views of the aside queue while the frame has budget: at least one, the clock read after the first and then
29756
+ * every `CLOCK_EVERY` views. An entity that is no longer building but still alive (its build ended before its view was
29757
+ * made) goes back to `added`, for the live pass. The made views hang nowhere in the stage, so the version the live
29758
+ * tree is counted at stays as it was.
29759
+ *
29760
+ * @param sctx - Domain context of the sync module.
29761
+ * @param report - Logs a throw of one entity's work.
29762
+ * @returns The views made.
29763
+ */
29764
+ function makeAside(sctx, report) {
29765
+ const state = sctx.ctx.state.sync;
29766
+ const { aside } = state;
29767
+ const { projection, ecs } = sctx.ctx.deps.world;
29768
+ const version = state.passesVersion;
29769
+ let made = 0;
29770
+ let check = 1;
29771
+ while (aside.head < aside.count) {
29772
+ const entity = aside.entities[aside.head] ?? 0;
29773
+ aside.head += 1;
29774
+ if (!aside.waiting.has(entity)) continue;
29775
+ if (!projection.isBuilding(entity)) {
29776
+ aside.waiting.delete(entity);
29777
+ if (ecs.ownerOf(entity) !== void 0) state.added.add(entity);
29778
+ continue;
29779
+ }
29780
+ made += makeParentFirst(sctx, entity, report);
29781
+ if (made >= check) {
29782
+ if (projection.left() <= 0) break;
29783
+ check = made + CLOCK_EVERY;
29784
+ }
29785
+ }
29786
+ if (aside.head >= aside.count) {
29787
+ aside.head = 0;
29788
+ aside.count = 0;
29789
+ }
29790
+ state.passesVersion = version;
29791
+ return made;
29792
+ }
29793
+ /**
29794
+ * Tells whether a node hangs under the stage.
29795
+ *
29796
+ * @param node - The node.
29797
+ * @param stage - The stage.
29798
+ * @returns True when the stage is one of its ancestors.
29799
+ */
29800
+ function underStage(node, stage) {
29801
+ for (let parent = node.parent; parent !== null; parent = parent.parent) if (parent === stage) return true;
29802
+ return false;
29803
+ }
29804
+ /**
29805
+ * The dev check of a build frame: no view of a building entity hangs under the stage, or it would be drawn and hit
29806
+ * before the commit. Named once per build, as `renderer:building-drawn` with the entities. Dev and agent builds only.
29807
+ *
29808
+ * @param sctx - Domain context of the sync module.
29809
+ */
29810
+ function checkDrawn(sctx) {
29811
+ const state = sctx.ctx.state.sync;
29812
+ const stage = sctx.deps.host.stage();
29813
+ if (state.heldViews.reported || stage === void 0) return;
29814
+ const projection = sctx.ctx.deps.world.projection;
29815
+ const drawn = [];
29816
+ for (const [entity, view] of state.views) if (projection.isBuilding(entity) && underStage(view.wrapper ?? view.object, stage)) drawn.push(entity);
29817
+ if (drawn.length === 0) return;
29818
+ state.heldViews.reported = true;
29819
+ sctx.ctx.log.warn("renderer:building-drawn", { entities: drawn });
29820
+ }
29821
+ /**
29822
+ * The building step of a frame, after the live pass: the views of the aside queue are made under the budget, and while
29823
+ * views wait to be made or attached, the next frame is asked for. A few field reads and no allocation with no build.
29824
+ *
29825
+ * @param sctx - Domain context of the sync module.
29826
+ * @param report - Logs a throw of one entity's work.
29827
+ */
29828
+ function stepAside(sctx, report) {
29829
+ const state = sctx.ctx.state.sync;
29830
+ const { aside } = state;
29831
+ if (aside.made !== 0) aside.made = 0;
29832
+ if (aside.head >= aside.count && state.heldViews.count === 0) return;
29833
+ if (aside.head < aside.count) aside.made = makeAside(sctx, report);
29834
+ sctx.deps.demand.request();
29835
+ if (typeof __MOKU_GAME_DEV__ !== "undefined" && __MOKU_GAME_DEV__ || typeof __MOKU_GAME_AGENT__ !== "undefined" && __MOKU_GAME_AGENT__) checkDrawn(sctx);
29836
+ }
29837
+ /**
29838
+ * Tells whether held views wait for the pass that attaches them: the build their first slot belongs to has ended (a
29839
+ * commit, or a discard whose views the pass drops). Read by `still()`: a field read with no build, one set lookup
29840
+ * during one.
29841
+ *
29842
+ * @param sctx - Domain context of the sync module.
29843
+ * @returns True when the pass has held views to attach or forget.
29844
+ */
29845
+ function heldWaiting(sctx) {
29846
+ const held = sctx.ctx.state.sync.heldViews;
29847
+ return held.count > 0 && !sctx.ctx.deps.world.projection.isBuilding(held.entities[0] ?? 0);
29848
+ }
29849
+ /**
29850
+ * The swap: once the build of the held views ended, every held top is attached in one block, in the order it was made
29851
+ * (parents first), and sorted by the rule of the layer list the pass has just applied. A slot whose view went (a
29852
+ * discard, a view that left) attaches nothing. The list holds the views of one build only: a build that starts after
29853
+ * the commit makes its views after this step. Called by the pass right after `syncLayers`.
29854
+ *
29855
+ * @param sctx - Domain context of the sync module.
29856
+ */
29857
+ function attachHeld(sctx) {
29858
+ if (!heldWaiting(sctx)) return;
29859
+ const state = sctx.ctx.state.sync;
29860
+ const held = state.heldViews;
29861
+ for (let index = 0; index < held.count; index += 1) {
29862
+ const entity = held.entities[index] ?? 0;
29863
+ const view = state.views.get(entity);
29864
+ if (view === void 0 || !view.held) continue;
29865
+ view.held = false;
29866
+ attach$1(sctx, entity, view);
29867
+ resort(sctx, entity, view);
29868
+ }
29869
+ held.count = 0;
29870
+ held.reported = false;
29871
+ }
29872
+ /**
29873
+ * Tells whether an entity of a set belongs to the running build.
29874
+ *
29875
+ * @param sctx - Domain context of the sync module.
29876
+ * @param entities - The set.
29877
+ * @returns True when one does.
29878
+ */
29879
+ function holdsBuilding(sctx, entities) {
29880
+ const projection = sctx.ctx.deps.world.projection;
29881
+ for (const entity of entities) if (projection.isBuilding(entity)) return true;
29882
+ return false;
29883
+ }
29884
+ /**
29885
+ * The renderer's part of `world.projection.built()`, registered with `onBuild`: no building entity waits in `added`
29886
+ * or in the aside queue for its view. True while the renderer draws nothing.
29887
+ *
29888
+ * @param sctx - Domain context of the sync module.
29889
+ * @returns True when the renderer holds the swap back no longer.
29890
+ */
29891
+ function buildReady(sctx) {
29892
+ const state = sctx.ctx.state.sync;
29893
+ if (state.root === void 0) return true;
29894
+ if (state.aside.head < state.aside.count) return false;
29895
+ return state.added.size === 0 || !holdsBuilding(sctx, state.added);
29896
+ }
29897
+ /**
29898
+ * The render phase's half of a scene build (the body of `SyncInternal.takeBuildWork`): reads and clears the count of the
29899
+ * views the pass made aside in this frame. A field read and no allocation.
29900
+ *
29901
+ * @param aside - The aside queue.
29902
+ * @returns True when the pass made views of the build: the frame did load work.
29903
+ */
29904
+ function takeBuildWork(aside) {
29905
+ const made = aside.made > 0;
29906
+ aside.made = 0;
29907
+ return made;
29908
+ }
29909
+ //#endregion
29342
29910
  //#region src/plugins/renderer/sync/each.ts
29343
29911
  /**
29344
29912
  * Tells whether a change list is an array.
@@ -30231,7 +30799,7 @@ function expectedHostOf(sctx, entity, parent) {
30231
30799
  }
30232
30800
  /**
30233
30801
  * Checks where the views of the given entities hang and reports every one that hangs elsewhere than the ECS says,
30234
- * once per entity. An orphan hangs nowhere on purpose and is skipped.
30802
+ * once per entity. An orphan and a held view of a scene build (spec 31) hang nowhere on purpose and are skipped.
30235
30803
  *
30236
30804
  * @param sctx - Domain context of the sync module.
30237
30805
  * @param entities - The entities a pass built, hung again or took out of the orphans.
@@ -30241,7 +30809,7 @@ function checkTree(sctx, entities) {
30241
30809
  const ecs = sctx.ctx.deps.world.ecs;
30242
30810
  for (const entity of entities) {
30243
30811
  const view = state.views.get(entity);
30244
- if (view === void 0 || state.orphans.has(entity) || state.mismatched.has(entity)) continue;
30812
+ if (view === void 0 || view.held || state.orphans.has(entity) || state.mismatched.has(entity)) continue;
30245
30813
  const parent = parentOf$1(ecs, entity);
30246
30814
  const host = (view.wrapper ?? view.object).parent;
30247
30815
  const expected = expectedHostOf(sctx, entity, parent);
@@ -30516,12 +31084,13 @@ function createSyncApi(ctx, deps) {
30516
31084
  const onParent = (entity) => stepOn(STEP.parent, entity);
30517
31085
  const onAdapter = (entity) => stepOn(STEP.adapter, entity);
30518
31086
  /**
30519
- * A new `Display` object of the game replaces the old view outright, unless this pass built it.
31087
+ * A new `Display` object of the game replaces the old view outright, unless this pass built it, or it waits aside
31088
+ * for its view (spec 31): the chunk that makes it reads the new object.
30520
31089
  *
30521
31090
  * @param entity - The entity.
30522
31091
  */
30523
31092
  const onDisplay = (entity) => {
30524
- if (built.has(entity)) return;
31093
+ if (built.has(entity) || state.aside.waiting.has(entity)) return;
30525
31094
  guard(entity, () => {
30526
31095
  dropView(sctx, entity);
30527
31096
  createView(sctx, entity);
@@ -30604,12 +31173,13 @@ function createSyncApi(ctx, deps) {
30604
31173
  };
30605
31174
  /**
30606
31175
  * Tells whether the pass has nothing to do: nothing waits to be built, dropped, hung or resolved again, the layer list
30607
- * is the one built, and no watched component of any entity changed. A still frame returns on this, before any work.
31176
+ * is the one built, no held view waits for its swap, and no watched component of any entity changed. A still frame
31177
+ * returns on this, before any work. The aside queue of a scene build is not read: its work is the build step's.
30608
31178
  *
30609
31179
  * @returns True when the pass would change nothing.
30610
31180
  */
30611
31181
  const still = () => {
30612
- if (state.removed.size > 0 || state.added.size > 0 || state.reparented.size > 0 || state.orphans.size > 0 || state.invalidated.size > 0 || state.viewlessEntries || state.outlinesStale || ctx.deps.world.projection.layers() !== state.layerList) return false;
31182
+ if (state.removed.size > 0 || state.added.size > 0 || state.reparented.size > 0 || state.orphans.size > 0 || state.invalidated.size > 0 || state.viewlessEntries || state.outlinesStale || ctx.deps.world.projection.layers() !== state.layerList || heldWaiting(sctx)) return false;
30613
31183
  const ecs = ctx.deps.world.ecs;
30614
31184
  if (!isEmptyList(ecs.changed(Transform)) || !isEmptyList(ecs.changed(Sprite)) || !isEmptyList(ecs.changed(NineSlice)) || !isEmptyList(ecs.changed(Shape)) || !isEmptyList(ecs.changed(Order)) || !isEmptyList(ecs.changed(Layer)) || !isEmptyList(ecs.changed(Parent)) || !isEmptyList(ecs.changed(Display))) return false;
30615
31185
  for (let index = 0; index < state.adapters.length; index += 1) {
@@ -30644,13 +31214,16 @@ function createSyncApi(ctx, deps) {
30644
31214
  for (const [entity, view] of state.views) if (view.kind === "NineSlice") guard(entity, () => applyNineSlice(sctx, entity, view));
30645
31215
  };
30646
31216
  /**
30647
- * Takes the entities that are waiting to be built out of the change set. Called only when one waits.
31217
+ * Takes the entities that are waiting to be built out of the change set. Called only when one waits. While a scene is
31218
+ * built aside, its entities go to the aside queue instead (spec 31): world flags them right after their spawn, so
31219
+ * they are sorted here, not when their components came.
30648
31220
  *
30649
31221
  * @returns The entities of this pass, so the change step skips what the build just wrote.
30650
31222
  */
30651
31223
  const takeAdded = () => {
30652
31224
  const built = copyOf(state.added);
30653
31225
  state.added.clear();
31226
+ if (buildRuns(sctx)) sortAside(sctx, built);
30654
31227
  return built;
30655
31228
  };
30656
31229
  /**
@@ -30703,17 +31276,17 @@ function createSyncApi(ctx, deps) {
30703
31276
  return viewless;
30704
31277
  };
30705
31278
  /**
30706
- * One pass: removed, layers, added, changed, orphans, invalidated. A despawned entity takes its filter
30707
- * slots with it, also when its view left in an earlier pass; a live one keeps them for its next
30708
- * view. A still frame (nothing changed, nothing waits) returns before any work and makes nothing.
31279
+ * The live half of a pass: removed, layers, the held views of a build that ended, added, changed, orphans,
31280
+ * invalidated. A despawned entity takes its filter slots with it, also when its view left in an earlier pass; a live
31281
+ * one keeps them for its next view.
30709
31282
  */
30710
- const pass = () => {
30711
- if (still()) return;
31283
+ const livePass = () => {
30712
31284
  state.passesVersion += 1;
30713
31285
  if (state.removed.size > 0) {
30714
31286
  for (const entity of state.removed) {
30715
31287
  guard(entity, () => dropView(sctx, entity));
30716
31288
  forgetLeft(state, ctx.deps.world.ecs, entity);
31289
+ dropAside(state, entity);
30717
31290
  }
30718
31291
  state.removed.clear();
30719
31292
  }
@@ -30722,6 +31295,7 @@ function createSyncApi(ctx, deps) {
30722
31295
  deps.demand.whole("layers");
30723
31296
  deps.host.sceneChanged();
30724
31297
  }
31298
+ attachHeld(sctx);
30725
31299
  built = state.added.size === 0 ? nothingBuilt : buildParentFirst(takeAdded());
30726
31300
  const touched = typeof __MOKU_GAME_DEV__ !== "undefined" && __MOKU_GAME_DEV__ || typeof __MOKU_GAME_AGENT__ !== "undefined" && __MOKU_GAME_AGENT__ ? touchedBy() : void 0;
30727
31301
  applyChanges();
@@ -30734,6 +31308,14 @@ function createSyncApi(ctx, deps) {
30734
31308
  if (touched !== void 0) checkTree(sctx, touched);
30735
31309
  };
30736
31310
  /**
31311
+ * One pass: the live half unless the frame is still, then the build step of a scene built aside (spec 31). A still
31312
+ * frame with no build (nothing changed, nothing waits) does no work and makes nothing.
31313
+ */
31314
+ const pass = () => {
31315
+ if (!still()) livePass();
31316
+ stepAside(sctx, report);
31317
+ };
31318
+ /**
30737
31319
  * Forgets every view, layer and pooled object without touching Pixi. Used before a rebuild,
30738
31320
  * where the old application and everything in it are already gone.
30739
31321
  */
@@ -30758,6 +31340,7 @@ function createSyncApi(ctx, deps) {
30758
31340
  clearFrames(state);
30759
31341
  destroyPools(state);
30760
31342
  clearLayers(state);
31343
+ forgetBuild(state);
30761
31344
  state.root = void 0;
30762
31345
  state.passesVersion += 1;
30763
31346
  };
@@ -30859,6 +31442,7 @@ function createSyncApi(ctx, deps) {
30859
31442
  stepFilters: () => applySwitched(state, deps.kept),
30860
31443
  drainOverlayEvents: (monitor) => drainOverlayEvents(sctx, monitor),
30861
31444
  warmStep: () => warmStep(sctx),
31445
+ takeBuildWork: () => takeBuildWork(state.aside),
30862
31446
  uploadStep: () => uploadStep(sctx),
30863
31447
  onWarm: (fn) => {
30864
31448
  state.warm.listeners.push(fn);
@@ -30887,7 +31471,7 @@ function createSyncApi(ctx, deps) {
30887
31471
  start: () => {
30888
31472
  if (deps.host.stage() === void 0) return;
30889
31473
  const ecs = ctx.deps.world.ecs;
30890
- state.cleanups.push(ecs.onAdded(Sprite, markAdded), ecs.onAdded(NineSlice, markAdded), ecs.onAdded(Shape, markAdded), ecs.onAdded(Display, markAdded), ecs.onRemoved(Sprite, markRemoved), ecs.onRemoved(NineSlice, markRemoved), ecs.onRemoved(Shape, markRemoved), ecs.onRemoved(Display, markRemoved), ecs.onRemoved(Parent, markReparented), ...watchOverlays(sctx), ecs.system(createSyncSystem(pass)));
31474
+ state.cleanups.push(ecs.onAdded(Sprite, markAdded), ecs.onAdded(NineSlice, markAdded), ecs.onAdded(Shape, markAdded), ecs.onAdded(Display, markAdded), ecs.onRemoved(Sprite, markRemoved), ecs.onRemoved(NineSlice, markRemoved), ecs.onRemoved(Shape, markRemoved), ecs.onRemoved(Display, markRemoved), ecs.onRemoved(Parent, markReparented), ...watchOverlays(sctx), ecs.system(createSyncSystem(pass)), ctx.deps.world.projection.onBuild(() => buildReady(sctx)));
30891
31475
  rebuildAll();
30892
31476
  }
30893
31477
  };
@@ -31668,8 +32252,24 @@ function createUploadState() {
31668
32252
  };
31669
32253
  }
31670
32254
  /**
32255
+ * Creates the empty aside queue of a scene build: nothing waits for a view. Its own non-exported function, as
32256
+ * `emptyMap`.
32257
+ *
32258
+ * @returns The aside branch of the sync state.
32259
+ */
32260
+ function createAsideQueue() {
32261
+ return {
32262
+ entities: [],
32263
+ count: 0,
32264
+ head: 0,
32265
+ waiting: emptySet$4(),
32266
+ made: 0
32267
+ };
32268
+ }
32269
+ /**
31671
32270
  * Creates the initial sync state: no root, no layers, no view, no orphan, no pool, no provider, no filter, no
31672
- * filter area, no slice, no hidden mask, no overlay, no warm-up, no upload, no redraw counted and no unseen walk kept.
32271
+ * filter area, no slice, no hidden mask, no overlay, no warm-up, no upload, no redraw counted, no unseen walk kept and
32272
+ * no scene build.
31673
32273
  *
31674
32274
  * @param debug - The debug switches of the config; copied, so the frozen config stays untouched.
31675
32275
  * @returns The sync branch of the plugin state.
@@ -31728,6 +32328,12 @@ function createSyncState(debug) {
31728
32328
  enabled: []
31729
32329
  },
31730
32330
  hudLayer: void 0,
32331
+ aside: createAsideQueue(),
32332
+ heldViews: {
32333
+ entities: [],
32334
+ count: 0,
32335
+ reported: false
32336
+ },
31731
32337
  cleanups: []
31732
32338
  };
31733
32339
  }
@@ -31835,7 +32441,7 @@ function checkAdaptiveScale(adaptiveScale) {
31835
32441
  * number from 64 to 16384, `keptMb` is not a finite number of at least 0, `warm` is not a
31836
32442
  * whole number from 0 to 16, or `uploads` is not a whole number from 0 to 8.
31837
32443
  */
31838
- function checkConfig(config) {
32444
+ function checkConfig$1(config) {
31839
32445
  const { maxResolution, keptMb } = config;
31840
32446
  const { budgetMs, frames } = config.monitor;
31841
32447
  if (!Number.isFinite(maxResolution) || maxResolution <= 0) throw new Error(`[game] renderer.maxResolution is ${maxResolution}.\n Give a number above 0.`);
@@ -31860,7 +32466,7 @@ function checkConfig(config) {
31860
32466
  * @throws {Error} When the config fails a check of `checkConfig`.
31861
32467
  */
31862
32468
  function createRendererState(ctx) {
31863
- checkConfig(ctx.config);
32469
+ checkConfig$1(ctx.config);
31864
32470
  return {
31865
32471
  host: createHostState(),
31866
32472
  viewport: createViewportState(),
@@ -35818,7 +36424,7 @@ function usedMb(state) {
35818
36424
  }
35819
36425
  /**
35820
36426
  * Picks the bundle that leaves next: the loaded one with the smallest use counter that is not
35821
- * pinned, not named by the mounted scene, not permanent and not in the preload queue. Its
36427
+ * pinned, not named by the mounted, a leaving or the coming scene, not permanent and not in the preload queue. Its
35822
36428
  * `release` is not read: a `"leave"` bundle is a candidate like any other.
35823
36429
  *
35824
36430
  * @param state - The plugin state.
@@ -35832,6 +36438,7 @@ function pickVictim(state) {
35832
36438
  if (record.status !== "loaded" || entry === void 0) continue;
35833
36439
  if (state.pinned.has(name) || isPermanent(entry.tier)) continue;
35834
36440
  if (state.sceneNamed.includes(name) || state.sceneLeaving.includes(name)) continue;
36441
+ if (state.sceneComing.includes(name)) continue;
35835
36442
  if (state.queue?.bundles.includes(name) === true) continue;
35836
36443
  if (record.lastUsed >= smallest) continue;
35837
36444
  smallest = record.lastUsed;
@@ -35987,9 +36594,9 @@ function featuresOnStack(ctx) {
35987
36594
  }
35988
36595
  /**
35989
36596
  * Tells whether a bundle must stay where the game stands now: it is pinned (a bundle of the node
35990
- * entered last), the mounted scene names it (the last list `sceneBundles` received), a leaving
35991
- * scene named it and its elements still play their exit, its feature is in `onStack`, or its tier
35992
- * is permanent.
36597
+ * entered last), the mounted scene names it (the last list `sceneBundles` received), the scene
36598
+ * being built names it (the last list `sceneComing` received), a leaving scene named it and its
36599
+ * elements still play their exit, its feature is in `onStack`, or its tier is permanent.
35993
36600
  *
35994
36601
  * @param ctx - Domain context of the plugin.
35995
36602
  * @param name - Name of the bundle.
@@ -35997,9 +36604,9 @@ function featuresOnStack(ctx) {
35997
36604
  * @param onStack - The features on the stack, from `featuresOnStack`.
35998
36605
  * @returns True when the bundle is held.
35999
36606
  */
36000
- function isHeld(ctx, name, entry, onStack) {
36607
+ function isHeld$1(ctx, name, entry, onStack) {
36001
36608
  const state = ctx.state;
36002
- return state.pinned.has(name) || state.sceneNamed.includes(name) || state.sceneLeaving.includes(name) || onStack.has(entry.feature) || isPermanent(entry.tier);
36609
+ return state.pinned.has(name) || state.sceneNamed.includes(name) || state.sceneLeaving.includes(name) || state.sceneComing.includes(name) || onStack.has(entry.feature) || isPermanent(entry.tier);
36003
36610
  }
36004
36611
  /**
36005
36612
  * Unloads every loaded `"leave"` bundle nobody holds any more, with the reason `"leave"`. A
@@ -36018,7 +36625,7 @@ function releaseLeftBundles(ctx) {
36018
36625
  /**
36019
36626
  * Takes the list the mounted scene names now. A bundle the list named before and names no more
36020
36627
  * stays held as a leaving scene's, until nothing carries `Exiting` (F51); a name the list takes
36021
- * again is the mounted scene's once more.
36628
+ * again is the mounted scene's once more. The coming list empties: its scene is the mounted one.
36022
36629
  *
36023
36630
  * @param state - The plugin state.
36024
36631
  * @param named - The bundles the mounted scene names now.
@@ -36028,6 +36635,17 @@ function nameSceneBundles(state, named) {
36028
36635
  for (const name of state.sceneNamed) if (!named.includes(name) && !leaving.includes(name)) leaving.push(name);
36029
36636
  state.sceneLeaving = leaving;
36030
36637
  state.sceneNamed = [...named];
36638
+ state.sceneComing = [];
36639
+ }
36640
+ /**
36641
+ * Takes the list the scene being built aside names: each call replaces the one before, and `[]`
36642
+ * ends the hold of a discarded build. `nameSceneBundles` empties it at the swap (F61 S2).
36643
+ *
36644
+ * @param state - The plugin state.
36645
+ * @param named - The bundles the coming scene names.
36646
+ */
36647
+ function nameComingBundles(state, named) {
36648
+ state.sceneComing = [...named];
36031
36649
  }
36032
36650
  /**
36033
36651
  * Lets the bundles a leaving scene named go once nothing carries `Exiting` any more: each loaded
@@ -36037,14 +36655,17 @@ function nameSceneBundles(state, named) {
36037
36655
  * its feature shows (the splash's 7.5 MiB under the ward). A bundle of another release stays, as
36038
36656
  * it would have. The system `assets.leave` calls it in every phase `sync`: after the reconcile of
36039
36657
  * `layout`, which tags the leaving elements `Exiting` in the frame after the switch, so the hold
36040
- * never ends before the leave begins (F51b). It costs a length read while no scene leaves, and a
36041
- * count read while one does.
36658
+ * never ends before the leave begins (F51b). While a scene is built aside (the coming list is
36659
+ * set; a scene names its own bundle, so the list of a build is never empty) the leave has not
36660
+ * begun: the world holds the scene on screen as it stood until the swap, and what it draws still
36661
+ * needs its bundles. It costs two length reads while no scene leaves, and a count read while one
36662
+ * does.
36042
36663
  *
36043
36664
  * @param ctx - Domain context of the plugin.
36044
36665
  */
36045
36666
  function releaseSceneLeft(ctx) {
36046
36667
  const state = ctx.state;
36047
- if (state.sceneLeaving.length === 0 || state.exiting > 0) return;
36668
+ if (state.sceneLeaving.length === 0 || state.exiting > 0 || state.sceneComing.length > 0) return;
36048
36669
  const names = state.sceneLeaving;
36049
36670
  state.sceneLeaving = [];
36050
36671
  if (state.io === void 0) return;
@@ -36064,7 +36685,7 @@ function releaseSceneLeft(ctx) {
36064
36685
  function unloadIfLeft(ctx, name, onStack) {
36065
36686
  const entry = ctx.state.manifest.bundles[name];
36066
36687
  if (ctx.state.records.get(name)?.status !== "loaded" || entry?.release !== "leave") return;
36067
- if (isHeld(ctx, name, entry, onStack)) return;
36688
+ if (isHeld$1(ctx, name, entry, onStack)) return;
36068
36689
  unloadBundle(ctx, name, "leave");
36069
36690
  }
36070
36691
  //#endregion
@@ -36457,7 +37078,7 @@ function affordable(ctx, distances) {
36457
37078
  for (const name of ordered) {
36458
37079
  const entry = ctx.state.manifest.bundles[name];
36459
37080
  if (entry === void 0 || entry.tier === "lazy") continue;
36460
- if (entry.release === "leave" && !isHeld(ctx, name, entry, onStack)) continue;
37081
+ if (entry.release === "leave" && !isHeld$1(ctx, name, entry, onStack)) continue;
36461
37082
  if (!isStillWanted(ctx.state, name)) continue;
36462
37083
  if (running + entry.mb > ctx.config.textureBudgetMb) break;
36463
37084
  running += entry.mb;
@@ -36893,6 +37514,7 @@ function releaseAll(state) {
36893
37514
  state.sceneId = void 0;
36894
37515
  state.sceneNamed = [];
36895
37516
  state.sceneLeaving = [];
37517
+ state.sceneComing = [];
36896
37518
  state.exiting = 0;
36897
37519
  state.restPending = false;
36898
37520
  state.stamps = void 0;
@@ -37042,6 +37664,7 @@ function createAssetsApi(ctx) {
37042
37664
  audio: (key) => loadedRecord(assets, key)?.audio.get(key),
37043
37665
  usage: () => usage(assets),
37044
37666
  sceneBundles: (named) => nameSceneBundles(ctx.state, named),
37667
+ sceneComing: (named) => nameComingBundles(ctx.state, named),
37045
37668
  has: (bundle) => Object.hasOwn(ctx.state.manifest.bundles, bundle),
37046
37669
  companions: { add: (companion) => addCompanion(ctx.state, companion) }
37047
37670
  };
@@ -37710,6 +38333,7 @@ function createAssetsState() {
37710
38333
  swapping: void 0,
37711
38334
  sceneNamed: [],
37712
38335
  sceneLeaving: [],
38336
+ sceneComing: [],
37713
38337
  exiting: 0,
37714
38338
  companions: [],
37715
38339
  restPending: false,
@@ -37778,161 +38402,7 @@ function createScenesApi(ctx) {
37778
38402
  };
37779
38403
  }
37780
38404
  //#endregion
37781
- //#region src/plugins/scenes/switch.ts
37782
- /**
37783
- * A promise that resolves when the node was aborted. Already aborted resolves at once.
37784
- *
37785
- * @param signal - The abort signal of the node being entered.
37786
- * @returns A promise that resolves on abort and never rejects.
37787
- */
37788
- function whenAborted$1(signal) {
37789
- return new Promise((resolve) => {
37790
- if (signal.aborted) {
37791
- resolve();
37792
- return;
37793
- }
37794
- signal.addEventListener("abort", () => resolve(), { once: true });
37795
- });
37796
- }
37797
- /**
37798
- * Waits for the textures of loaded bundles to reach the GPU, or for the abort. It never rejects:
37799
- * a warm that fails (an unload during it) is logged and the switch goes on, its first draw
37800
- * uploads as before. Headless, without a device or with `uploads: 0` the warms answer at once,
37801
- * and textures already on the GPU are not waited for.
37802
- *
37803
- * @param ctx - Domain context of the plugin.
37804
- * @param bundles - The bundles the scene being entered names, all of them loaded.
37805
- * @param signal - The abort signal of the node.
37806
- * @returns A promise that resolves when every warm settled or the node was aborted.
37807
- */
37808
- async function untilWarmed(ctx, bundles, signal) {
37809
- const warms = bundles.map((bundle) => ctx.deps.assets.warm(bundle).catch((error) => {
37810
- ctx.log.warn("scenes: a bundle's textures were not uploaded before the switch", {
37811
- bundle,
37812
- error
37813
- });
37814
- }));
37815
- await Promise.race([Promise.all(warms), whenAborted$1(signal)]);
37816
- }
37817
- /**
37818
- * Loads every bundle the scene names at once and returns as soon as all loads settled or the node
37819
- * is aborted. The first load that fails while the node still runs is rethrown, so `flow` rolls
37820
- * the node back; one that fails after the abort is logged, because nobody waits for it any more.
37821
- *
37822
- * @param ctx - Domain context of the plugin.
37823
- * @param bundles - The bundles the scene being entered names, its own bundle first.
37824
- * @param signal - The abort signal of the node.
37825
- * @returns A promise that resolves when the loads settled or the node was aborted.
37826
- * @throws {Error} Whatever the first failed load rejected with, while the node was not aborted.
37827
- */
37828
- async function untilAborted(ctx, bundles, signal) {
37829
- const load = {
37830
- failed: false,
37831
- error: void 0
37832
- };
37833
- const loads = bundles.map((bundle) => ctx.deps.assets.load(bundle).catch((error) => {
37834
- if (!load.failed) {
37835
- load.failed = true;
37836
- load.error = error;
37837
- }
37838
- if (signal.aborted) ctx.log.warn("scenes: a cancelled bundle load failed", {
37839
- bundle,
37840
- error
37841
- });
37842
- }));
37843
- await Promise.race([Promise.all(loads), whenAborted$1(signal)]);
37844
- if (load.failed && !signal.aborted) throw load.error;
37845
- }
37846
- /**
37847
- * Reads the committed state a scene's `bundles` gets: the frozen trees of the store, in a frozen
37848
- * wrapper. During the scene stage no node writes, so it is the state the node is entered with.
37849
- *
37850
- * @param ctx - Domain context of the plugin.
37851
- * @returns The committed `player` and `session`.
37852
- */
37853
- function committedState(ctx) {
37854
- const { player, session } = ctx.deps.model.store.snapshot();
37855
- return Object.freeze({
37856
- player,
37857
- session
37858
- });
37859
- }
37860
- /**
37861
- * Tells whether two lists name the same bundles in the same order.
37862
- *
37863
- * @param left - One list.
37864
- * @param right - The other list.
37865
- * @returns True when both have the same names at the same places.
37866
- * @example
37867
- * ```ts
37868
- * sameNames(["location", "ep01"], ["location", "ep02"]); // false
37869
- * ```
37870
- */
37871
- function sameNames(left, right) {
37872
- return left.length === right.length && left.every((name, index) => name === right[index]);
37873
- }
37874
- /**
37875
- * Takes the expected scene and clears the record, so one `expect` mounts one scene.
37876
- *
37877
- * @param ctx - Domain context of the plugin.
37878
- * @returns The scene id that was recorded or expected, or `undefined` when there was none.
37879
- */
37880
- function takePending(ctx) {
37881
- const target = ctx.state.pending;
37882
- ctx.state.pending = void 0;
37883
- return target;
37884
- }
37885
- /**
37886
- * Tells whether the node is a transit node of a fast walk: the walk passes through it and builds
37887
- * no scene there.
37888
- *
37889
- * @param node - The node being entered.
37890
- * @param run - The runner's mode and signal.
37891
- * @returns True for a node without `rest` entered in fast mode.
37892
- */
37893
- function isFastTransit(node, run) {
37894
- return run.mode === "fast" && !node.rest;
37895
- }
37896
- /**
37897
- * Reads the scene the node asks for. In fast mode a transit node only records it: the walk passes
37898
- * through many of them and the picture is built once, at the rest point. In live mode a transit
37899
- * node without a scene of its own takes the expected one, so a restore at a popup that waits for
37900
- * its answer builds the scene under it; one with its own scene leaves the record alone.
37901
- *
37902
- * @param ctx - Domain context of the plugin.
37903
- * @param node - The node being entered.
37904
- * @param run - The runner's mode and signal.
37905
- * @returns The scene id to switch to, or `undefined` when nothing has to happen.
37906
- */
37907
- function targetOf(ctx, node, run) {
37908
- const state = ctx.state;
37909
- if (isFastTransit(node, run)) {
37910
- state.pending = node.scene ?? state.pending;
37911
- return;
37912
- }
37913
- if (!node.rest) return node.scene ?? takePending(ctx);
37914
- const target = node.scene ?? state.pending;
37915
- state.pending = void 0;
37916
- if (target === void 0 && state.current === void 0) ctx.log.warn("scenes: a rest node was entered with no scene", { path: node.path });
37917
- return target;
37918
- }
37919
- /**
37920
- * Reads the scene an `over` node asks for. An over node takes the expected scene and clears the
37921
- * record, whatever is mounted, so a restore at a popup builds the scene under it, whether the
37922
- * popup rests or waits for its answer. With nothing expected it switches nothing. In fast mode an
37923
- * over transit node takes nothing and leaves the record alone: a walk builds no scene on the way.
37924
- * A scene the over node names itself is only warned about.
37925
- *
37926
- * @param ctx - Domain context of the plugin.
37927
- * @param node - The over node being entered.
37928
- * @param run - The runner's mode and signal.
37929
- * @returns The expected scene id, or `undefined` when nothing has to happen.
37930
- */
37931
- function overTargetOf(ctx, node, run) {
37932
- if (node.scene !== void 0) ctx.log.warn("scenes: an over node cannot name a scene", { path: node.path });
37933
- if (isFastTransit(node, run)) return void 0;
37934
- return takePending(ctx);
37935
- }
38405
+ //#region src/plugins/scenes/swap.ts
37936
38406
  /**
37937
38407
  * The projections of the scene that leaves which the scene that comes does not list. A projection
37938
38408
  * both list stays mounted: the layers of a projection are the ones its spec names, and both scenes
@@ -37946,23 +38416,55 @@ function leavingOf(previous, scene) {
37946
38416
  return previous.projections.filter((name) => !scene.projections.includes(name));
37947
38417
  }
37948
38418
  /**
37949
- * Builds the scene in one synchronous block: the layers, then the projections only the old scene
37950
- * lists out, then the list of the new scene in (a projection both list stays and takes its new
37951
- * place), the new scene to the world's systems, then the list the scene names to `assets`, then
37952
- * the frame loop out of its idle cap and the adaptive frame rate told of the new screen, and only then the event.
38419
+ * Names the switch in the performance log of an agent build: the scenes it went between and,
38420
+ * for a scene built aside, the frames and the milliseconds of page clock the build took, and the
38421
+ * budget of a frame it ran with.
38422
+ *
38423
+ * @param ctx - Domain context of the plugin.
38424
+ * @param from - The scene that left, `undefined` for the first one.
38425
+ * @param to - The scene that is mounted now.
38426
+ * @param build - The build that was committed; absent for a one-block switch.
38427
+ */
38428
+ function logSwitch(ctx, from, to, build) {
38429
+ const scenes = {
38430
+ from: from ?? null,
38431
+ to
38432
+ };
38433
+ if (build === void 0) {
38434
+ ctx.deps.renderer.monitor.event("scene", scenes);
38435
+ return;
38436
+ }
38437
+ const clock = ctx.deps.time.agentClock();
38438
+ const buildMs = clock === void 0 ? 0 : clock() - build.startedAt;
38439
+ ctx.deps.renderer.monitor.event("scene", {
38440
+ ...scenes,
38441
+ buildFrames: build.frames,
38442
+ buildMs,
38443
+ budgetMs: build.budgetMs
38444
+ });
38445
+ }
38446
+ /**
38447
+ * Makes a scene the mounted one in one synchronous block: the layers, then the projections only
38448
+ * the old scene lists out, then the new scene in (a `mount` of its list, or the commit of the
38449
+ * build that made it aside; a projection both list stays and takes its new place), the new scene
38450
+ * to the world's systems, then the list the scene names to `assets`, then the frame loop out of
38451
+ * its idle cap and the adaptive frame rate told of the new screen, and only then the event.
37953
38452
  *
37954
38453
  * @param ctx - Domain context of the plugin.
37955
38454
  * @param scene - The scene whose bundles are there.
37956
38455
  * @param named - The bundles the scene names, as they were awaited.
38456
+ * @param build - The build to commit in the mount's place; absent for a one-block switch.
38457
+ * @throws {Error} Whatever the mount threw: a `view` or an unknown projection.
37957
38458
  */
37958
- function apply(ctx, scene, named) {
38459
+ function swapIn(ctx, scene, named, build) {
37959
38460
  const state = ctx.state;
37960
38461
  const projection = ctx.deps.world.projection;
37961
- const from = state.current;
38462
+ const from = build === void 0 ? state.current : build.from;
37962
38463
  const previous = from === void 0 ? void 0 : state.scenes.get(from);
37963
38464
  projection.setLayers(scene.layers);
37964
38465
  if (previous !== void 0) projection.unmount(leavingOf(previous, scene));
37965
- projection.mount(scene.projections, state.owner);
38466
+ if (build === void 0) projection.mount(scene.projections, state.owner);
38467
+ else projection.commit();
37966
38468
  state.current = scene.id;
37967
38469
  ctx.deps.world.ecs.setScene(scene.id);
37968
38470
  state.named = named;
@@ -37974,16 +38476,424 @@ function apply(ctx, scene, named) {
37974
38476
  to: scene.id,
37975
38477
  music: scene.music
37976
38478
  });
37977
- if (typeof __MOKU_GAME_AGENT__ !== "undefined" && __MOKU_GAME_AGENT__) ctx.deps.renderer.monitor.event("scene", {
37978
- from: from ?? null,
37979
- to: scene.id
38479
+ if (typeof __MOKU_GAME_AGENT__ !== "undefined" && __MOKU_GAME_AGENT__) logSwitch(ctx, from, scene.id, build);
38480
+ }
38481
+ //#endregion
38482
+ //#region src/plugins/scenes/build.ts
38483
+ /**
38484
+ * @file scenes plugin — the next scene built aside on a live switch (spec 31, F61 S2). The world
38485
+ * mounts the coming projections into a table of its own while the old scene stays current, drawn
38486
+ * and alive; `ui` and the renderer do their part under the budget of a frame (`buildMs`); the frame
38487
+ * `world.projection.built()` first answers true commits it with the swap of a one-block switch. An
38488
+ * abort of the node discards the build and leaves the world as it stood.
38489
+ */
38490
+ /** Half a second in ms: half of a frame interval is this over the frame rate. */
38491
+ const HALF_SECOND_MS = 500;
38492
+ /** The budget of a frame when `time` answers no frame rate to halve: half a 60 Hz frame, ms. */
38493
+ const FALLBACK_BUDGET_MS = HALF_SECOND_MS / 60;
38494
+ /**
38495
+ * Tells whether a value is a number a frame can count with: finite and above 0. Pure.
38496
+ *
38497
+ * @param value - The value.
38498
+ * @returns True for a finite number above 0.
38499
+ * @example
38500
+ * ```ts
38501
+ * isPositiveFinite(Number.NaN); // false
38502
+ * ```
38503
+ */
38504
+ function isPositiveFinite(value) {
38505
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
38506
+ }
38507
+ /**
38508
+ * Does nothing: the listeners of a build that commits at once, before it registered any.
38509
+ */
38510
+ function noListeners() {}
38511
+ /**
38512
+ * Reads the page clock of an agent build, for the milliseconds a build took.
38513
+ *
38514
+ * @param ctx - Domain context of the plugin.
38515
+ * @returns The page clock in ms, or 0 in a build without the flag.
38516
+ */
38517
+ function agentNow(ctx) {
38518
+ const clock = ctx.deps.time.agentClock();
38519
+ return clock === void 0 ? 0 : clock();
38520
+ }
38521
+ /**
38522
+ * The building budget of a frame for a build that starts now: the number a game configured, or
38523
+ * for `"auto"` half of the frame interval the loop runs at, `500 / time.rate().fps`, read once
38524
+ * here. A rate that is not a finite number above 0 gives half a 60 Hz frame.
38525
+ *
38526
+ * @param ctx - Domain context of the plugin.
38527
+ * @returns Milliseconds per frame the building work may take.
38528
+ */
38529
+ function budgetOf(ctx) {
38530
+ const { buildMs } = ctx.config;
38531
+ if (buildMs !== "auto") return buildMs;
38532
+ const { fps } = ctx.deps.time.rate();
38533
+ return isPositiveFinite(fps) ? HALF_SECOND_MS / fps : FALLBACK_BUDGET_MS;
38534
+ }
38535
+ /**
38536
+ * Ends the build with the swap: its listeners go and the record is dropped, then the swap of a
38537
+ * one-block switch runs, with the commit in the mount's place, or, for `"mount"`, after the build
38538
+ * was discarded, with the mount itself. A swap that throws rejects the switch, so `flow` rolls the
38539
+ * node back; the build is discarded and the coming bundles are held no more first, so the next
38540
+ * switch can build again.
38541
+ *
38542
+ * @param ctx - Domain context of the plugin.
38543
+ * @param build - The running build.
38544
+ * @param how - `"commit"` to commit the build, `"mount"` to discard it and mount in one block.
38545
+ */
38546
+ function endBuild(ctx, build, how) {
38547
+ build.off();
38548
+ delete ctx.state.build;
38549
+ if (how === "mount") ctx.deps.world.projection.discard();
38550
+ try {
38551
+ swapIn(ctx, build.scene, build.named, how === "commit" ? build : void 0);
38552
+ } catch (error) {
38553
+ ctx.deps.world.projection.discard();
38554
+ ctx.deps.assets.sceneComing([]);
38555
+ build.reject(error);
38556
+ return;
38557
+ }
38558
+ build.resolve();
38559
+ }
38560
+ /**
38561
+ * The `signals` callback of a running build: counts the frame and commits the build the frame
38562
+ * every part of it is done. A world that stopped running live during the build (a walk that went
38563
+ * fast, as the restore of a repro walks its route, or a pause) skips its `layout` phase, so `ui`
38564
+ * would never lay the coming scene out: the build is given up and the scene mounted in one block,
38565
+ * as a switch in that mode is. While the build is not done, a counter step and two calls, no
38566
+ * allocation.
38567
+ *
38568
+ * @param ctx - Domain context of the plugin.
38569
+ * @param build - The running build.
38570
+ */
38571
+ function stepBuild(ctx, build) {
38572
+ build.frames += 1;
38573
+ const world = ctx.deps.world;
38574
+ if (world.ecs.mode() !== "live") {
38575
+ endBuild(ctx, build, "mount");
38576
+ return;
38577
+ }
38578
+ if (world.projection.built()) endBuild(ctx, build, "commit");
38579
+ }
38580
+ /**
38581
+ * Drops the running build on an abort of its node (a restore, the inbox, a stop of the runner):
38582
+ * the world despawns what the build made, the coming bundles are held no more, and the switch
38583
+ * returns with the old scene current. The layers, the scene of the world, the named bundles and
38584
+ * the mounted projections were never touched. The commit and a stop remove this listener first.
38585
+ *
38586
+ * @param ctx - Domain context of the plugin.
38587
+ * @param build - The build of the aborted node.
38588
+ */
38589
+ function abortBuild(ctx, build) {
38590
+ build.off();
38591
+ delete ctx.state.build;
38592
+ ctx.deps.world.projection.discard();
38593
+ ctx.deps.assets.sceneComing([]);
38594
+ build.resolve();
38595
+ }
38596
+ /**
38597
+ * Creates the record of a build that starts now, its listeners not registered yet.
38598
+ *
38599
+ * @param ctx - Domain context of the plugin.
38600
+ * @param scene - The scene being built.
38601
+ * @param named - The bundles it names, as they were awaited.
38602
+ * @param budgetMs - The building budget of a frame the world was given.
38603
+ * @param settle - The callbacks of the switch's promise.
38604
+ * @param settle.resolve - Settles the switch.
38605
+ * @param settle.reject - Rejects the switch.
38606
+ * @returns The record, at frame 0, with no listeners to remove.
38607
+ */
38608
+ function startBuildRecord(ctx, scene, named, budgetMs, settle) {
38609
+ return {
38610
+ scene,
38611
+ named,
38612
+ from: ctx.state.current,
38613
+ frames: 0,
38614
+ budgetMs,
38615
+ startedAt: agentNow(ctx),
38616
+ off: noListeners,
38617
+ resolve: settle.resolve,
38618
+ reject: settle.reject
38619
+ };
38620
+ }
38621
+ /**
38622
+ * Registers the listeners of a running build: the `signals` callback that asks every frame
38623
+ * whether it is done, and the abort of its node, which discards it. `build.off` removes both; the
38624
+ * build becomes the plugin's running one.
38625
+ *
38626
+ * @param ctx - Domain context of the plugin.
38627
+ * @param build - The build that starts waiting for its frames.
38628
+ * @param signal - The abort signal of the node, not aborted.
38629
+ */
38630
+ function watchBuild(ctx, build, signal) {
38631
+ const onAbort = () => abortBuild(ctx, build);
38632
+ const offFrame = ctx.deps.time.onFrame("signals", () => stepBuild(ctx, build));
38633
+ signal.addEventListener("abort", onAbort, { once: true });
38634
+ build.off = () => {
38635
+ offFrame();
38636
+ signal.removeEventListener("abort", onAbort);
38637
+ };
38638
+ ctx.state.build = build;
38639
+ }
38640
+ /**
38641
+ * Starts the build of a scene aside and answers the promise of the switch. The world mounts what
38642
+ * only the coming scene lists, against the committed state, and gets the budget of a frame
38643
+ * (`budgetOf`); the coming bundles are held from now.
38644
+ * When nothing is left for `ui` and the renderer the commit runs at once, with no frame between.
38645
+ * Otherwise a `signals` callback asks every frame whether the build is done, and an abort of the
38646
+ * node discards it.
38647
+ *
38648
+ * @param ctx - Domain context of the plugin.
38649
+ * @param scene - The scene whose bundles are there.
38650
+ * @param named - The bundles it names, as they were awaited.
38651
+ * @param signal - The abort signal of the node, not aborted.
38652
+ * @returns The promise of the switch, settled at the commit or the abort; `undefined` when the
38653
+ * world refused to build (a listed projection is mounted under other layer rules), so the
38654
+ * caller switches in one block.
38655
+ * @throws {Error} Whatever `world.projection.build` threw: a `view`, an unknown projection or an
38656
+ * undeclared layer. Nothing is built then.
38657
+ */
38658
+ function buildAside(ctx, scene, named, signal) {
38659
+ const projection = ctx.deps.world.projection;
38660
+ const budgetMs = budgetOf(ctx);
38661
+ if (!projection.build(scene.projections, ctx.state.owner, scene.layers, budgetMs)) return void 0;
38662
+ ctx.deps.assets.sceneComing(named);
38663
+ if (typeof __MOKU_GAME_AGENT__ !== "undefined" && __MOKU_GAME_AGENT__) {
38664
+ const from = ctx.state.current;
38665
+ ctx.deps.renderer.monitor.event("scene:build", {
38666
+ from: from ?? null,
38667
+ to: scene.id
38668
+ });
38669
+ }
38670
+ return new Promise((resolve, reject) => {
38671
+ const build = startBuildRecord(ctx, scene, named, budgetMs, {
38672
+ resolve,
38673
+ reject
38674
+ });
38675
+ if (projection.built()) {
38676
+ endBuild(ctx, build, "commit");
38677
+ return;
38678
+ }
38679
+ watchBuild(ctx, build, signal);
38680
+ });
38681
+ }
38682
+ /**
38683
+ * Drops a running build when the plugin stops: its frame callback and its abort listener go and
38684
+ * the switch returns. Nothing else is called: a teardown context may not call another plugin, and
38685
+ * `world.onStop` drops the build table with every entity.
38686
+ *
38687
+ * @param build - The running build, if any.
38688
+ */
38689
+ function dropBuild(build) {
38690
+ if (build === void 0) return;
38691
+ build.off();
38692
+ build.resolve();
38693
+ }
38694
+ //#endregion
38695
+ //#region src/plugins/scenes/switch.ts
38696
+ /**
38697
+ * A promise that resolves when the node was aborted. Already aborted resolves at once.
38698
+ *
38699
+ * @param signal - The abort signal of the node being entered.
38700
+ * @returns A promise that resolves on abort and never rejects.
38701
+ */
38702
+ function whenAborted$1(signal) {
38703
+ return new Promise((resolve) => {
38704
+ if (signal.aborted) {
38705
+ resolve();
38706
+ return;
38707
+ }
38708
+ signal.addEventListener("abort", () => resolve(), { once: true });
38709
+ });
38710
+ }
38711
+ /**
38712
+ * Waits for the textures of loaded bundles to reach the GPU, or for the abort. It never rejects:
38713
+ * a warm that fails (an unload during it) is logged and the switch goes on, its first draw
38714
+ * uploads as before. Headless, without a device or with `uploads: 0` the warms answer at once,
38715
+ * and textures already on the GPU are not waited for.
38716
+ *
38717
+ * @param ctx - Domain context of the plugin.
38718
+ * @param bundles - The bundles the scene being entered names, all of them loaded.
38719
+ * @param signal - The abort signal of the node.
38720
+ * @returns A promise that resolves when every warm settled or the node was aborted.
38721
+ */
38722
+ async function untilWarmed(ctx, bundles, signal) {
38723
+ const warms = bundles.map((bundle) => ctx.deps.assets.warm(bundle).catch((error) => {
38724
+ ctx.log.warn("scenes: a bundle's textures were not uploaded before the switch", {
38725
+ bundle,
38726
+ error
38727
+ });
38728
+ }));
38729
+ await Promise.race([Promise.all(warms), whenAborted$1(signal)]);
38730
+ }
38731
+ /**
38732
+ * Loads every bundle the scene names at once and returns as soon as all loads settled or the node
38733
+ * is aborted. The first load that fails while the node still runs is rethrown, so `flow` rolls
38734
+ * the node back; one that fails after the abort is logged, because nobody waits for it any more.
38735
+ *
38736
+ * @param ctx - Domain context of the plugin.
38737
+ * @param bundles - The bundles the scene being entered names, its own bundle first.
38738
+ * @param signal - The abort signal of the node.
38739
+ * @returns A promise that resolves when the loads settled or the node was aborted.
38740
+ * @throws {Error} Whatever the first failed load rejected with, while the node was not aborted.
38741
+ */
38742
+ async function untilAborted(ctx, bundles, signal) {
38743
+ const load = {
38744
+ failed: false,
38745
+ error: void 0
38746
+ };
38747
+ const loads = bundles.map((bundle) => ctx.deps.assets.load(bundle).catch((error) => {
38748
+ if (!load.failed) {
38749
+ load.failed = true;
38750
+ load.error = error;
38751
+ }
38752
+ if (signal.aborted) ctx.log.warn("scenes: a cancelled bundle load failed", {
38753
+ bundle,
38754
+ error
38755
+ });
38756
+ }));
38757
+ await Promise.race([Promise.all(loads), whenAborted$1(signal)]);
38758
+ if (load.failed && !signal.aborted) throw load.error;
38759
+ }
38760
+ /**
38761
+ * Reads the committed state a scene's `bundles` gets: the frozen trees of the store, in a frozen
38762
+ * wrapper. During the scene stage no node writes, so it is the state the node is entered with.
38763
+ *
38764
+ * @param ctx - Domain context of the plugin.
38765
+ * @returns The committed `player` and `session`.
38766
+ */
38767
+ function committedState(ctx) {
38768
+ const { player, session } = ctx.deps.model.store.snapshot();
38769
+ return Object.freeze({
38770
+ player,
38771
+ session
37980
38772
  });
37981
38773
  }
37982
38774
  /**
38775
+ * Tells whether two lists name the same bundles in the same order.
38776
+ *
38777
+ * @param left - One list.
38778
+ * @param right - The other list.
38779
+ * @returns True when both have the same names at the same places.
38780
+ * @example
38781
+ * ```ts
38782
+ * sameNames(["location", "ep01"], ["location", "ep02"]); // false
38783
+ * ```
38784
+ */
38785
+ function sameNames(left, right) {
38786
+ return left.length === right.length && left.every((name, index) => name === right[index]);
38787
+ }
38788
+ /**
38789
+ * Takes the expected scene and clears the record, so one `expect` mounts one scene.
38790
+ *
38791
+ * @param ctx - Domain context of the plugin.
38792
+ * @returns The scene id that was recorded or expected, or `undefined` when there was none.
38793
+ */
38794
+ function takePending(ctx) {
38795
+ const target = ctx.state.pending;
38796
+ ctx.state.pending = void 0;
38797
+ return target;
38798
+ }
38799
+ /**
38800
+ * Tells whether the node is a transit node of a fast walk: the walk passes through it and builds
38801
+ * no scene there.
38802
+ *
38803
+ * @param node - The node being entered.
38804
+ * @param run - The runner's mode and signal.
38805
+ * @returns True for a node without `rest` entered in fast mode.
38806
+ */
38807
+ function isFastTransit(node, run) {
38808
+ return run.mode === "fast" && !node.rest;
38809
+ }
38810
+ /**
38811
+ * Reads the scene the node asks for. In fast mode a transit node only records it: the walk passes
38812
+ * through many of them and the picture is built once, at the rest point. In live mode a transit
38813
+ * node without a scene of its own takes the expected one, so a restore at a popup that waits for
38814
+ * its answer builds the scene under it; one with its own scene leaves the record alone.
38815
+ *
38816
+ * @param ctx - Domain context of the plugin.
38817
+ * @param node - The node being entered.
38818
+ * @param run - The runner's mode and signal.
38819
+ * @returns The scene id to switch to, or `undefined` when nothing has to happen.
38820
+ */
38821
+ function targetOf(ctx, node, run) {
38822
+ const state = ctx.state;
38823
+ if (isFastTransit(node, run)) {
38824
+ state.pending = node.scene ?? state.pending;
38825
+ return;
38826
+ }
38827
+ if (!node.rest) return node.scene ?? takePending(ctx);
38828
+ const target = node.scene ?? state.pending;
38829
+ state.pending = void 0;
38830
+ if (target === void 0 && state.current === void 0) ctx.log.warn("scenes: a rest node was entered with no scene", { path: node.path });
38831
+ return target;
38832
+ }
38833
+ /**
38834
+ * Reads the scene an `over` node asks for. An over node takes the expected scene and clears the
38835
+ * record, whatever is mounted, so a restore at a popup builds the scene under it, whether the
38836
+ * popup rests or waits for its answer. With nothing expected it switches nothing. In fast mode an
38837
+ * over transit node takes nothing and leaves the record alone: a walk builds no scene on the way.
38838
+ * A scene the over node names itself is only warned about.
38839
+ *
38840
+ * @param ctx - Domain context of the plugin.
38841
+ * @param node - The over node being entered.
38842
+ * @param run - The runner's mode and signal.
38843
+ * @returns The expected scene id, or `undefined` when nothing has to happen.
38844
+ */
38845
+ function overTargetOf(ctx, node, run) {
38846
+ if (node.scene !== void 0) ctx.log.warn("scenes: an over node cannot name a scene", { path: node.path });
38847
+ if (isFastTransit(node, run)) return void 0;
38848
+ return takePending(ctx);
38849
+ }
38850
+ /**
38851
+ * Tells whether a switch builds the next scene aside: a live switch while the world runs live and
38852
+ * the renderer draws. A fast walk (a restore, a test walk) and a renderer without a device build
38853
+ * in one block, as a walk or a headless app sees no frame between. So does a node entered live
38854
+ * while the world runs fast or paused by now: a restore of a repro enters its node live and walks
38855
+ * fast from there, and a world that skips its `layout` phase never lays the coming scene out.
38856
+ *
38857
+ * @param ctx - Domain context of the plugin.
38858
+ * @param run - The runner's mode and signal.
38859
+ * @returns True when the switch builds aside.
38860
+ */
38861
+ function buildsAside(ctx, run) {
38862
+ return run.mode === "live" && ctx.deps.world.ecs.mode() === "live" && ctx.deps.renderer.host.ready();
38863
+ }
38864
+ /**
38865
+ * Builds the scene: aside on a live switch with a drawing renderer, resolving at the commit or
38866
+ * the abort; in one block otherwise, and when the world refuses to build it aside because a
38867
+ * projection both scenes list is declared on other layer rules (named in a dev build).
38868
+ *
38869
+ * @param ctx - Domain context of the plugin.
38870
+ * @param scene - The scene whose bundles are there.
38871
+ * @param named - The bundles the scene names, as they were awaited.
38872
+ * @param run - The runner's mode and signal, not aborted.
38873
+ * @returns A promise that resolves once the scene stands, or the node was aborted.
38874
+ * @throws {Error} Whatever the mount or the build threw: a `view` or an unknown projection.
38875
+ */
38876
+ async function standUp(ctx, scene, named, run) {
38877
+ if (buildsAside(ctx, run)) {
38878
+ const building = buildAside(ctx, scene, named, run.signal);
38879
+ if (building !== void 0) {
38880
+ await building;
38881
+ return;
38882
+ }
38883
+ if (typeof __MOKU_GAME_DEV__ !== "undefined" && __MOKU_GAME_DEV__) ctx.log.warn("scenes:build-skipped", {
38884
+ scene: scene.id,
38885
+ from: ctx.state.current,
38886
+ projections: scene.projections
38887
+ });
38888
+ }
38889
+ swapIn(ctx, scene, named);
38890
+ }
38891
+ /**
37983
38892
  * Switches to another scene: it reads what the scene names from the committed state, awaits all
37984
38893
  * of it and only then builds the scene. In live mode it waits between the two for the textures of
37985
38894
  * those bundles to reach the GPU; a fast walk does not, so tests and restores keep their pace. An
37986
- * aborted node, or a plugin stopped during the wait, returns without touching anything.
38895
+ * aborted node, or a plugin stopped during the wait, returns without touching anything. A live
38896
+ * switch with a drawing renderer resolves at the commit of the scene built aside, frames later.
37987
38897
  *
37988
38898
  * @param ctx - Domain context of the plugin.
37989
38899
  * @param target - Id of the scene to mount.
@@ -37999,7 +38909,7 @@ async function switchTo(ctx, target, run) {
37999
38909
  if (run.mode === "live" && !run.signal.aborted) await untilWarmed(ctx, named, run.signal);
38000
38910
  const stopped = ctx.state.teardown === void 0;
38001
38911
  if (run.signal.aborted || stopped) return;
38002
- apply(ctx, scene, named);
38912
+ await standUp(ctx, scene, named, run);
38003
38913
  }
38004
38914
  /**
38005
38915
  * Reads again what the mounted scene names, at a node that keeps it: a bundle it names now and
@@ -38035,8 +38945,8 @@ async function reread(ctx, run) {
38035
38945
  * @param run - The runner's mode and signal.
38036
38946
  * @returns A promise that resolves once the scene stands, or at once when nothing changed.
38037
38947
  * @throws {Error} For a scene id no feature declared, and for a `bundles` that throws or returns
38038
- * something that is not a list of names. A failed load and a failed `mount` also reject, so
38039
- * `flow` rolls the node back with the old scene still mounted.
38948
+ * something that is not a list of names. A failed load, a failed `mount` and a build whose
38949
+ * `view` throws also reject, so `flow` rolls the node back with the old scene still mounted.
38040
38950
  */
38041
38951
  async function enterScene(ctx, node, run) {
38042
38952
  const target = node.over ? overTargetOf(ctx, node, run) : targetOf(ctx, node, run);
@@ -38050,8 +38960,9 @@ async function enterScene(ctx, node, run) {
38050
38960
  //#endregion
38051
38961
  //#region src/plugins/scenes/lifecycle.ts
38052
38962
  /**
38053
- * @file scenes plugin — lifecycle: the scenes read out of the feature descriptions in `onStart`,
38054
- * the one `onEnter` registration, and the teardown that removes exactly that registration.
38963
+ * @file scenes plugin — lifecycle: the config checked in `onInit`, the scenes read out of the
38964
+ * feature descriptions in `onStart`, the one `onEnter` registration, and the teardown that removes
38965
+ * exactly that registration.
38055
38966
  */
38056
38967
  /**
38057
38968
  * Resolves the dependency APIs `flow`, `world`, `assets`, `time`, `model` and `renderer` with
@@ -38112,6 +39023,20 @@ function readScenes(ctx) {
38112
39023
  }
38113
39024
  }
38114
39025
  /**
39026
+ * Refuses a building budget a frame cannot count down, in `onInit`, so `createApp` throws: neither
39027
+ * `"auto"` nor a finite number above 0. A game written in plain JS passes anything, so the config
39028
+ * is a boundary.
39029
+ *
39030
+ * @param config - The resolved config of the plugin.
39031
+ * @throws {Error} When `buildMs` is neither `"auto"` nor a finite number above 0.
39032
+ */
39033
+ function checkConfig(config) {
39034
+ const value = config.buildMs;
39035
+ if (value === "auto") return;
39036
+ if (isPositiveFinite(value)) return;
39037
+ throw new Error(`[game] scenes.buildMs is ${String(value)}.\n Give "auto" or the milliseconds a frame may spend on the next scene, a finite number above 0 such as 4.`);
39038
+ }
39039
+ /**
38115
39040
  * Starts the plugin in `onStart`: it collects the scenes of the features and registers the one
38116
39041
  * callback that switches them, on the `scene` stage of entering a node.
38117
39042
  *
@@ -38130,8 +39055,9 @@ function startScenes(ctx) {
38130
39055
  }
38131
39056
  /**
38132
39057
  * Removes the `onEnter` callback in `onStop` and forgets every scene and the bundles the mounted
38133
- * one named. Nothing is unmounted: a
38134
- * teardown context may not call another plugin, and `world.onStop` clears every entity anyway.
39058
+ * one named. A running build loses its frame callback and its switch returns. Nothing is unmounted
39059
+ * or discarded: a teardown context may not call another plugin, and `world.onStop` clears every
39060
+ * entity and the build table anyway.
38135
39061
  *
38136
39062
  * @param teardown - What the teardown context carries.
38137
39063
  * @param teardown.state - The plugin state.
@@ -38145,6 +39071,8 @@ function stopScenes(teardown) {
38145
39071
  const state = teardown.state;
38146
39072
  state.teardown?.();
38147
39073
  state.teardown = void 0;
39074
+ dropBuild(state.build);
39075
+ delete state.build;
38148
39076
  state.scenes.clear();
38149
39077
  state.current = void 0;
38150
39078
  state.pending = void 0;
@@ -38189,8 +39117,6 @@ function createScenesState() {
38189
39117
  teardown: void 0
38190
39118
  };
38191
39119
  }
38192
- //#endregion
38193
- //#region src/plugins/scenes/index.ts
38194
39120
  /**
38195
39121
  * Scenes plugin: `app.scenes.current()`, and `defineScene` for the game.
38196
39122
  *
@@ -38212,6 +39138,9 @@ function createScenesState() {
38212
39138
  * layers: { room: {} },
38213
39139
  * projections: [wardRoom]
38214
39140
  * });
39141
+ *
39142
+ * // A game gives the next scene a fixed 3 ms of each frame (half of it by default) while the old one stays on the screen.
39143
+ * createApp({ plugins: [...screen, boardFeature], pluginConfigs: { scenes: { buildMs: 3 } } });
38215
39144
  * ```
38216
39145
  */
38217
39146
  const scenesPlugin = /*#__PURE__*/ createPlugin$1("scenes", {
@@ -38223,9 +39152,11 @@ const scenesPlugin = /*#__PURE__*/ createPlugin$1("scenes", {
38223
39152
  modelPlugin,
38224
39153
  rendererPlugin
38225
39154
  ],
39155
+ config: { buildMs: "auto" },
38226
39156
  events: (register) => register.map({ "scenes:changed": "The mounted scene changed" }),
38227
39157
  createState: createScenesState,
38228
39158
  api: createScenesApi,
39159
+ onInit: ({ config }) => checkConfig(config),
38229
39160
  onStart: startScenes,
38230
39161
  onStop: ({ state }) => stopScenes({ state })
38231
39162
  });
@@ -52492,6 +53423,9 @@ function ownerOf(state, name) {
52492
53423
  /**
52493
53424
  * Gives the host as parent to every view of one projection that has no parent and is not held
52494
53425
  * by a drag. The parent is queued while the phase runs, so ownership is decided by name first.
53426
+ * A host of the scene `world` builds aside takes only the views of that build, and a live host
53427
+ * none of them, until the swap (spec 31): a live view under a hidden host would vanish from the
53428
+ * screen, a coming one under a live host would be drawn before its scene.
52495
53429
  *
52496
53430
  * @param ctx - Domain context of the ui plugin.
52497
53431
  * @param state - The jsx state.
@@ -52500,8 +53434,10 @@ function ownerOf(state, name) {
52500
53434
  */
52501
53435
  function parentViews(ctx, state, name, host) {
52502
53436
  const ecs = ctx.deps.world.ecs;
52503
- for (const view of ctx.deps.world.projection.entitiesOf(name)) {
52504
- if (ecs.has(view, Held)) continue;
53437
+ const projection = ctx.deps.world.projection;
53438
+ const aside = projection.isBuilding(host);
53439
+ for (const view of projection.entitiesOf(name)) {
53440
+ if (ecs.has(view, Held) || projection.isBuilding(view) !== aside) continue;
52505
53441
  if (ecs.has(view, Parent) && !state.released.has(view)) continue;
52506
53442
  ecs.add(view, Parent({ entity: host }));
52507
53443
  state.hosted.set(view, host);
@@ -52623,17 +53559,21 @@ function keptSlot(state, root) {
52623
53559
  }
52624
53560
  /**
52625
53561
  * Tells whether a host element may take a root as its slot: it names the root's projection, it is
52626
- * not leaving, and it is not an element of that root itself.
53562
+ * not leaving, it is not an element of that root itself, and both stand on the same side of a
53563
+ * scene build (spec 31): a root built aside never takes a slot in a live root of the old scene,
53564
+ * and a live root none in a root built aside. A kept host takes its coming guest after the swap.
52627
53565
  *
52628
53566
  * @param state - The jsx state.
52629
53567
  * @param root - The root.
52630
53568
  * @param host - An entity of the host set.
53569
+ * @param isAside - Whether an entity belongs to the scene `world` builds aside.
52631
53570
  * @returns The host element, or `undefined` when it cannot take the root.
52632
53571
  */
52633
- function slotFor(state, root, host) {
53572
+ function slotFor(state, root, host, isAside) {
52634
53573
  const element = state.elements.get(host);
52635
53574
  if (element === void 0 || state.exiting.has(host) || element.root === root.entity) return;
52636
- return hostsOf(element.node).includes(root.name) ? element : void 0;
53575
+ if (!hostsOf(element.node).includes(root.name)) return void 0;
53576
+ return isAside(element.root) === isAside(root.entity) ? element : void 0;
52637
53577
  }
52638
53578
  /**
52639
53579
  * The host element a root is laid out in (spec 16 U3.2). A popup root never is. A root keeps its
@@ -52642,13 +53582,14 @@ function slotFor(state, root, host) {
52642
53582
  *
52643
53583
  * @param state - The jsx state.
52644
53584
  * @param root - The root.
53585
+ * @param isAside - Whether an entity belongs to the scene `world` builds aside.
52645
53586
  * @returns The slot, or `undefined` for a root laid out on the viewport.
52646
53587
  */
52647
- function slotOf$1(state, root) {
53588
+ function slotOf$1(state, root, isAside) {
52648
53589
  if (root.popup !== void 0) return void 0;
52649
53590
  const kept = keptSlot(state, root);
52650
53591
  if (kept !== void 0) return kept;
52651
- for (const host of state.hosts) if (slotFor(state, root, host) !== void 0) return host;
53592
+ for (const host of state.hosts) if (slotFor(state, root, host, isAside) !== void 0) return host;
52652
53593
  }
52653
53594
  /**
52654
53595
  * Where an element is drawn at rest, in root coordinates: its drawn rect (`visualRectOf`), and for
@@ -53078,7 +54019,8 @@ function createFields(ctx, links) {
53078
54019
  }
53079
54020
  /**
53080
54021
  * Spawns the four parts of a field, each a child of the field: its pose, its `Parent`, its
53081
- * visual, in that order, as an element is spawned.
54022
+ * visual, in that order, as an element is spawned. The parts of a field of a scene built aside
54023
+ * are the build's too, as the field is: held with it and dropped by a discard (spec 31).
53082
54024
  *
53083
54025
  * @param entity - The field entity.
53084
54026
  * @param values - What the parts draw.
@@ -53086,15 +54028,22 @@ function createFields(ctx, links) {
53086
54028
  */
53087
54029
  function spawnParts(entity, values) {
53088
54030
  const ecs = ctx.deps.world.ecs;
54031
+ const projection = ctx.deps.world.projection;
53089
54032
  const parent = Parent({ entity });
53090
- const box = (part) => ecs.spawn(UI_OWNER, [
54033
+ const aside = projection.isBuilding(entity);
54034
+ const spawn = (components) => {
54035
+ const part = ecs.spawn(UI_OWNER, components);
54036
+ if (aside) projection.adopt(part);
54037
+ return part;
54038
+ };
54039
+ const box = (part) => spawn([
53091
54040
  Transform(part.pose),
53092
54041
  parent,
53093
54042
  Shape(part.shape)
53094
54043
  ]);
53095
54044
  return {
53096
54045
  selection: box(values.selection),
53097
- text: ecs.spawn(UI_OWNER, [
54046
+ text: spawn([
53098
54047
  Transform(values.text.pose),
53099
54048
  parent,
53100
54049
  Text({
@@ -53825,18 +54774,20 @@ function nodeOf(state, element, resolvedOf, place) {
53825
54774
  /**
53826
54775
  * Every mounted root in the order a reader meets them: the popup roots first, the highest `Order`
53827
54776
  * of them first, then the projection roots in the layer order of the scene, and by their `Order`
53828
- * inside one layer, bottom first, as they draw.
54777
+ * inside one layer, bottom first, as they draw. A root of the scene `world` builds aside is left
54778
+ * out until the swap: nothing of it is on the screen yet (spec 31).
53829
54779
  *
53830
54780
  * @param state - The jsx state.
53831
54781
  * @param layers - The layer names of the scene, in draw order.
54782
+ * @param isAside - Whether an entity belongs to the scene built aside; none leaves no root out.
53832
54783
  * @returns The roots, sorted.
53833
54784
  * @example
53834
54785
  * ```ts
53835
54786
  * sortedRoots({ roots: new Map() } as unknown as JsxState, ["ui"]); // []
53836
54787
  * ```
53837
54788
  */
53838
- function sortedRoots(state, layers) {
53839
- const roots = [...state.roots.values()];
54789
+ function sortedRoots(state, layers, isAside) {
54790
+ const roots = [...state.roots.values()].filter((root) => isAside === void 0 || !isAside(root.entity));
53840
54791
  const popups = roots.filter((root) => root.popup !== void 0);
53841
54792
  const screens = roots.filter((root) => root.popup === void 0);
53842
54793
  const indexOf = (root) => {
@@ -53861,22 +54812,24 @@ function placeOf(root, slotPose) {
53861
54812
  return (rect) => drawnRect(pose, rect);
53862
54813
  }
53863
54814
  /**
53864
- * Reads every mounted root, popups first, then the projection roots in layer order. The rects of a
53865
- * root laid out in a slot are reported in root coordinates, through the root pose of its slot.
54815
+ * Reads every mounted root, popups first, then the projection roots in layer order, and none of the
54816
+ * scene `world` builds aside. The rects of a root laid out in a slot are reported in root
54817
+ * coordinates, through the root pose of its slot.
53866
54818
  *
53867
54819
  * @param state - The jsx state.
53868
54820
  * @param layers - The layer names of the scene, in draw order.
53869
54821
  * @param resolvedOf - Reads the string `text` resolved for an entity; none in a read without a world.
53870
54822
  * @param slotPose - The root pose of a slot, `rootPoseOf` of `renderer`; none in a read without a world.
54823
+ * @param isAside - Whether an entity belongs to the scene `world` builds aside: such a root is not read.
53871
54824
  * @returns One root node, or every root under a `screen` node.
53872
54825
  * @example
53873
54826
  * ```ts
53874
54827
  * readTree({ roots: new Map(), elements: new Map() } as unknown as JsxState).type; // "screen"
53875
54828
  * ```
53876
54829
  */
53877
- function readTree(state, layers = [], resolvedOf, slotPose) {
54830
+ function readTree(state, layers = [], resolvedOf, slotPose, isAside) {
53878
54831
  const nodes = [];
53879
- for (const root of sortedRoots(state, layers)) {
54832
+ for (const root of sortedRoots(state, layers, isAside)) {
53880
54833
  const element = root.element === void 0 ? void 0 : state.elements.get(root.element);
53881
54834
  if (element !== void 0) nodes.push(nodeOf(state, element, resolvedOf, placeOf(root, slotPose)));
53882
54835
  }
@@ -53919,15 +54872,17 @@ function readingOrder(first, second) {
53919
54872
  /**
53920
54873
  * The root the keyboard works in: the first uncovered popup in reader order, else the last screen
53921
54874
  * root, the one in the top layer. A root of the HUD layer never takes it: the HUD draws above every
53922
- * overlay, so the top sheet under it does. A root on its way out does not count.
54875
+ * overlay, so the top sheet under it does. A root on its way out does not count, nor one of the
54876
+ * scene `world` builds aside: nothing of it is on the screen before the swap.
53923
54877
  *
53924
54878
  * @param state - The jsx state.
53925
54879
  * @param layers - The layer names of the scene, in draw order.
53926
54880
  * @param hud - The name of the scene's HUD layer (`above: "overlays"`), none without one.
54881
+ * @param isAside - Whether an entity belongs to the scene built aside; none leaves no root out.
53927
54882
  * @returns The root, or `undefined` when nothing is mounted.
53928
54883
  */
53929
- function focusRoot(state, layers, hud) {
53930
- const roots = sortedRoots(state, layers).filter((root) => root.element !== void 0 && !state.removing.has(root.entity));
54884
+ function focusRoot(state, layers, hud, isAside) {
54885
+ const roots = sortedRoots(state, layers, isAside).filter((root) => root.element !== void 0 && !state.removing.has(root.entity));
53931
54886
  return roots.find((root) => root.popup !== void 0 && !root.covered) ?? roots.findLast((root) => root.popup === void 0 && root.layer !== hud);
53932
54887
  }
53933
54888
  /**
@@ -54387,7 +55342,8 @@ function zIndexOnRoot(element) {
54387
55342
  };
54388
55343
  }
54389
55344
  /**
54390
- * Runs the six rules over every live element.
55345
+ * Runs the six rules over every live element on the screen: an element of a root `world` builds
55346
+ * aside is not on it before the swap (spec 31).
54391
55347
  *
54392
55348
  * @param ctx - Domain context of the ui plugin.
54393
55349
  * @returns One finding per rule and element, in element order.
@@ -54396,7 +55352,7 @@ function runLint(ctx) {
54396
55352
  const findings = [];
54397
55353
  const scale = ctx.deps.renderer.viewport.size().scale;
54398
55354
  for (const element of ctx.state.jsx.elements.values()) {
54399
- if (!element.live) continue;
55355
+ if (!element.live || ctx.deps.world.projection.isBuilding(element.root)) continue;
54400
55356
  for (const finding of [
54401
55357
  tapTarget(ctx, element, scale),
54402
55358
  textOverflow(ctx, element),
@@ -56561,6 +57517,8 @@ function createReconciler(ctx, modules, fields) {
56561
57517
  let slotsRoots = -1;
56562
57518
  let placedUnder;
56563
57519
  let rendered = 0;
57520
+ const isAside = (entity) => ctx.deps.world.projection.isBuilding(entity);
57521
+ let released = false;
56564
57522
  /**
56565
57523
  * Registers the key of an element under its root, so `entityOf` and `find` answer for it. A key
56566
57524
  * `ui` gave a node of its own, a spacer's, is never registered.
@@ -56589,7 +57547,9 @@ function createReconciler(ctx, modules, fields) {
56589
57547
  return range.rowStyle(parent, node, modules.styles.resolveElement(styleOf(node), is));
56590
57548
  }
56591
57549
  /**
56592
- * Creates one element and its subtree.
57550
+ * Creates one element and its subtree. An element of a root built aside is marked as the build's
57551
+ * right after its spawn, before `applyFirst` gives it a visual: the renderer holds its view, and a
57552
+ * discard takes it with the rest (spec 31).
56593
57553
  *
56594
57554
  * @param root - The root being reconciled.
56595
57555
  * @param parent - The parent element, or nothing for a root element.
@@ -56602,6 +57562,7 @@ function createReconciler(ctx, modules, fields) {
56602
57562
  function enter(root, parent, node, identity, instance, index) {
56603
57563
  checkTag(node);
56604
57564
  const entity = ecs.spawn(UI_OWNER, []);
57565
+ if (isAside(root.entity)) ctx.deps.world.projection.adopt(entity);
56605
57566
  const is = isFlagsOf(node, {
56606
57567
  pressed: false,
56607
57568
  hover: false,
@@ -57051,13 +58012,18 @@ function createReconciler(ctx, modules, fields) {
57051
58012
  root.needsSolve = true;
57052
58013
  }
57053
58014
  /**
57054
- * Starts the exit of an element and of everything under it.
58015
+ * Starts the exit of an element and of everything under it. An element of a root built aside was
58016
+ * never seen: it goes at once, with no exit (spec 31).
57055
58017
  *
57056
58018
  * @param element - The element that left the description.
57057
58019
  * @param cause - Why it leaves: `"change"` for a commit, `"mount"` when it goes with its screen.
57058
58020
  * @param leads - The element is the top of a root that goes with its screen.
57059
58021
  */
57060
58022
  function exitElement(element, cause, leads = false) {
58023
+ if (isAside(element.root)) {
58024
+ dropElement(element);
58025
+ return;
58026
+ }
57061
58027
  forgetSubtree(element);
57062
58028
  forgetInstances(state, element.identity);
57063
58029
  state.exiting.add(element.entity);
@@ -57147,6 +58113,17 @@ function createReconciler(ctx, modules, fields) {
57147
58113
  ecs.despawn(element.entity);
57148
58114
  }
57149
58115
  /**
58116
+ * Drops an element and everything under it at once, with no exit: its identity, its key and the
58117
+ * local state of its components are forgotten, its nodes freed and its entities despawned.
58118
+ *
58119
+ * @param element - An element of a root built aside that leaves before the swap.
58120
+ */
58121
+ function dropElement(element) {
58122
+ forgetSubtree(element);
58123
+ forgetInstances(state, element.identity);
58124
+ despawnTree(element);
58125
+ }
58126
+ /**
57150
58127
  * Despawns every exiting element whose motions finished, and closes a popup whose root is gone.
57151
58128
  */
57152
58129
  function sweep() {
@@ -57310,7 +58287,7 @@ function createReconciler(ctx, modules, fields) {
57310
58287
  */
57311
58288
  function warnTaken(root, slot) {
57312
58289
  for (const host of state.hosts) {
57313
- const element = host === slot ? void 0 : slotFor(state, root, host);
58290
+ const element = host === slot ? void 0 : slotFor(state, root, host, isAside);
57314
58291
  const once = `slot-taken:${root.name}`;
57315
58292
  if (element === void 0 || element.warned.has(once)) continue;
57316
58293
  element.warned.add(once);
@@ -57342,7 +58319,7 @@ function createReconciler(ctx, modules, fields) {
57342
58319
  slotsRoots = state.roots.size;
57343
58320
  for (const root of state.roots.values()) {
57344
58321
  if (state.removing.has(root.entity)) continue;
57345
- const slot = isCyclic(root) ? void 0 : slotOf$1(state, root);
58322
+ const slot = isCyclic(root) ? void 0 : slotOf$1(state, root, isAside);
57346
58323
  if (typeof __MOKU_GAME_DEV__ !== "undefined" && __MOKU_GAME_DEV__ && slot !== void 0) warnTaken(root, slot);
57347
58324
  if (slot === root.slot) continue;
57348
58325
  root.slot = slot;
@@ -57477,7 +58454,9 @@ function createReconciler(ctx, modules, fields) {
57477
58454
  * Keeps an element whose spawn has just been applied: the world calls it from the `Box` hook, at
57478
58455
  * the flush of phase `layout`. Its enter hook waits for `playEntered`, so the game's systems see
57479
58456
  * the entity first (F48). The hook itself plays at once, so they see the start pose it writes (F49);
57480
- * `ui.enter` plays it again when one of them changed what it read.
58457
+ * `ui.enter` plays it again when one of them changed what it read. An element of a scene built
58458
+ * aside plays nothing here: its enter waits for the swap, and `poseReleased` plays it at the same
58459
+ * point of the swap frame (spec 31).
57481
58460
  *
57482
58461
  * @param entity - The entity the `Box` was attached to.
57483
58462
  */
@@ -57487,21 +58466,45 @@ function createReconciler(ctx, modules, fields) {
57487
58466
  else entering.list.push(entity);
57488
58467
  entering.count += 1;
57489
58468
  const element = state.elements.get(entity);
57490
- if (arrives(element)) modules.layout.pose(element, overlayOf(element) === void 0 ? void 0 : createSpan());
58469
+ if (arrives(element) && !isAside(entity)) modules.layout.pose(element, overlayOf(element) === void 0 ? void 0 : createSpan());
58470
+ }
58471
+ /**
58472
+ * Plays the enter hooks of the elements a committed build released, at the end of the swap
58473
+ * frame's solve, the point of the frame where a `Box` lands: the game's systems of `sync` see the
58474
+ * start pose they write, as after a mount (F49), and `ui.enter` keeps the play or plays it again
58475
+ * (F48). An element of a build still running, one that played already and a row a range change
58476
+ * scrolled in play nothing here.
58477
+ */
58478
+ function poseReleased() {
58479
+ const entering = state.entering;
58480
+ for (let index = 0; index < entering.count; index += 1) {
58481
+ const entity = entering.list[index];
58482
+ const element = entity === void 0 ? void 0 : state.elements.get(entity);
58483
+ if (!arrives(element) || element.posed !== void 0 || isAside(element.entity)) continue;
58484
+ modules.layout.pose(element, overlayOf(element) === void 0 ? void 0 : createSpan());
58485
+ }
57491
58486
  }
57492
58487
  /**
57493
58488
  * Plays the enter hooks queued since the last call, in the order the elements came. The `ui.enter`
57494
58489
  * system calls it in phase `sync`: after the game's systems, which may have written what a hook
57495
58490
  * reads (a list's scroll), and before `renderer.sync`, so an element is never drawn at its rest
57496
- * pose by mistake. An element despawned in between plays nothing.
58491
+ * pose by mistake. An element despawned in between plays nothing. An element of a scene built
58492
+ * aside stays queued, in its order, and plays in the first call after the swap (spec 31): its
58493
+ * enter, posed by `poseReleased` in that frame's solve, its loop and the level of an overlay
58494
+ * start in the frame the scene is first drawn.
57497
58495
  */
57498
58496
  function playEntered() {
57499
58497
  const entering = state.entering;
58498
+ let held = 0;
57500
58499
  for (let index = 0; index < entering.count; index += 1) {
57501
58500
  const entity = entering.list[index];
57502
- if (entity !== void 0) playEnter(entity);
58501
+ if (entity === void 0) continue;
58502
+ if (isAside(entity)) {
58503
+ entering.list[held] = entity;
58504
+ held += 1;
58505
+ } else playEnter(entity);
57503
58506
  }
57504
- entering.count = 0;
58507
+ entering.count = held;
57505
58508
  }
57506
58509
  /**
57507
58510
  * Plays the enter hook of an element whose spawn has been applied, once. A row a range change
@@ -57642,10 +58645,16 @@ function createReconciler(ctx, modules, fields) {
57642
58645
  /**
57643
58646
  * Reconciles one dirty root: reads its current tree and projection name, then diffs it. The root
57644
58647
  * stays dirty while the layout engine is not loaded, and a failure is logged for this root only.
58648
+ * A root of the scene `world` builds aside is reconciled like any other and counted, so the
58649
+ * reconcile after the swap finds it (spec 31).
57645
58650
  *
57646
58651
  * @param root - A root with work this frame.
57647
58652
  */
57648
58653
  function reconcileDirtyRoot(root) {
58654
+ if (root.aside === void 0 && isAside(root.entity)) {
58655
+ root.aside = true;
58656
+ state.asideRoots += 1;
58657
+ }
57649
58658
  const tree = ecs.get(root.entity, Tree);
57650
58659
  const place = ctx.deps.world.projection.keyOf(root.entity);
57651
58660
  if (place !== void 0 && place.projection !== root.name) {
@@ -57663,16 +58672,55 @@ function createReconciler(ctx, modules, fields) {
57663
58672
  }
57664
58673
  }
57665
58674
  /**
57666
- * The first of the two systems of phase `layout`: the sweep, the roots a restore asked to build
57667
- * again, the roots a scene placed again, the popups that may leave, the pull of the text being
57668
- * typed, the scroll offsets and the windowed ranges, the bound texts that show a string of
57669
- * another size, the viewport, the dirty roots and the hosted views.
58675
+ * Finds the roots the last scene build committed: a root counted aside that `world` builds no more
58676
+ * loses its mark, the slots are given out again, so a kept host takes its coming guest in the
58677
+ * swap frame, and this frame's solve poses the held enters (spec 31). A root a discard dropped is
58678
+ * gone from the registry and is not counted.
58679
+ */
58680
+ function settleAside() {
58681
+ if (state.asideRoots === 0) return;
58682
+ let left = 0;
58683
+ let committed = false;
58684
+ for (const root of state.roots.values()) {
58685
+ if (root.aside === void 0) continue;
58686
+ if (isAside(root.entity)) left += 1;
58687
+ else {
58688
+ delete root.aside;
58689
+ committed = true;
58690
+ }
58691
+ }
58692
+ state.asideRoots = left;
58693
+ if (!committed) return;
58694
+ hostsMoved(state);
58695
+ released = true;
58696
+ }
58697
+ /**
58698
+ * The part of a scene build `ui` does, which `world` asks through `onBuild` (spec 31): done when no
58699
+ * root of the build is dirty (its tree not diffed yet, or the layout engine not loaded) and none
58700
+ * waits for its solve. A root whose first diff threw has no element to solve and does not hold
58701
+ * the swap.
58702
+ *
58703
+ * @returns True when every root built aside is reconciled and solved.
58704
+ */
58705
+ function asideBuilt() {
58706
+ for (const root of state.roots.values()) {
58707
+ const solves = root.needsSolve && root.element !== void 0 && lookup(root.element) !== void 0;
58708
+ if ((root.dirty || solves) && isAside(root.entity)) return false;
58709
+ }
58710
+ return true;
58711
+ }
58712
+ /**
58713
+ * The first of the two systems of phase `layout`: the sweep, the roots a scene build committed, the
58714
+ * roots a restore asked to build again, the roots a scene placed again, the popups that may leave,
58715
+ * the pull of the text being typed, the scroll offsets and the windowed ranges, the bound texts
58716
+ * that show a string of another size, the viewport, the dirty roots and the hosted views.
57670
58717
  *
57671
58718
  * @param time - The frame's `Time`: the scrolls move by its `delta`.
57672
58719
  */
57673
58720
  function reconcile(time = NO_TIME) {
57674
58721
  sweep();
57675
58722
  state.reconciles += 1;
58723
+ settleAside();
57676
58724
  if (state.remount) remountRoots();
57677
58725
  reorderRoots();
57678
58726
  settlePopups(ctx, state, unmountRoot);
@@ -57712,7 +58760,8 @@ function createReconciler(ctx, modules, fields) {
57712
58760
  * The second system of phase `layout`: every root takes its slot, then one solve per marked
57713
58761
  * root, a root in a slot after the root of its slot and in its content box, then the rects, the
57714
58762
  * rest poses and the hooks, the scrolls settled on what the solve and the render gave them, the
57715
- * hosting of the hosts that came, and a sweep of what left with nothing to wait for. The hints released since the last frame step are dropped
58763
+ * hosting of the hosts that came, a sweep of what left with nothing to wait for, and, in the swap
58764
+ * frame of a scene build, the start pose of the enters it held. The hints released since the last frame step are dropped
57716
58765
  * at its end, so the `change.Box` and `change.Transform` hooks the solve plays get the hint the
57717
58766
  * reconcile routed, and the next frame starts with none.
57718
58767
  *
@@ -57733,6 +58782,10 @@ function createReconciler(ctx, modules, fields) {
57733
58782
  state.risen.clear();
57734
58783
  }
57735
58784
  if (state.exiting.size > 0 || state.removing.size > 0) sweep();
58785
+ if (released) {
58786
+ released = false;
58787
+ poseReleased();
58788
+ }
57736
58789
  state.hints.length = 0;
57737
58790
  writeCounters();
57738
58791
  }
@@ -57786,7 +58839,9 @@ function createReconciler(ctx, modules, fields) {
57786
58839
  * nodes and despawns the root entity. A popup leaves by an event; a projection root leaves the
57787
58840
  * way its view did, which the world still answers in its removal hook: `"mount"` with its scene,
57788
58841
  * a load or a restore. A root that goes with its screen ends with the exit of its top element
57789
- * when that plays one, so a longer exit under it does not hold it and its bundles (F55).
58842
+ * when that plays one, so a longer exit under it does not hold it and its bundles (F55). A root of
58843
+ * a scene built aside was never seen: a discard (`"discard"`), or its key leaving before the swap,
58844
+ * drops it at once with no exit (spec 31).
57790
58845
  *
57791
58846
  * @param entity - The root entity.
57792
58847
  */
@@ -57795,6 +58850,10 @@ function createReconciler(ctx, modules, fields) {
57795
58850
  if (root === void 0 || state.removing.has(entity)) return;
57796
58851
  const element = root.element === void 0 ? void 0 : lookup(root.element);
57797
58852
  const cause = root.popup === void 0 ? ctx.deps.world.projection.causeOf(entity) ?? "change" : "change";
58853
+ if (cause === "discard" || isAside(entity)) {
58854
+ dropRoot(root, element);
58855
+ return;
58856
+ }
57798
58857
  state.removing.add(entity);
57799
58858
  hostsMoved(state);
57800
58859
  if (element !== void 0) {
@@ -57804,6 +58863,20 @@ function createReconciler(ctx, modules, fields) {
57804
58863
  ctx.deps.time.wake();
57805
58864
  }
57806
58865
  /**
58866
+ * Drops a root of a scene built aside at once: its elements are forgotten and despawned with no
58867
+ * exit, and the root leaves the registry. The world despawns the root entity itself: it is its
58868
+ * view, on its way out.
58869
+ *
58870
+ * @param root - The root.
58871
+ * @param element - Its root element, if it has one.
58872
+ */
58873
+ function dropRoot(root, element) {
58874
+ if (element !== void 0) dropElement(element);
58875
+ state.roots.delete(root.entity);
58876
+ state.byKey.delete(root.entity);
58877
+ hostsMoved(state);
58878
+ }
58879
+ /**
57807
58880
  * Re-resolves the style of one element whose pointer or focus flag changed: a press, a release,
57808
58881
  * the mouse coming over it or leaving it, the keyboard focus arriving or leaving. A new rect
57809
58882
  * waits for the solve; a new look and a new rest pose apply now. Only this element is restyled:
@@ -57840,7 +58913,8 @@ function createReconciler(ctx, modules, fields) {
57840
58913
  queueEnter,
57841
58914
  playEntered,
57842
58915
  sweep,
57843
- converged
58916
+ converged,
58917
+ asideBuilt
57844
58918
  };
57845
58919
  }
57846
58920
  //#endregion
@@ -57916,9 +58990,10 @@ function createJsxApi(ctx, modules) {
57916
58990
  const state = ctx.state.jsx;
57917
58991
  const sceneLayers = sceneLayersOf(ctx);
57918
58992
  const layerNames = () => sceneLayers().names;
58993
+ const isAside = (entity) => ctx.deps.world.projection.isBuilding(entity);
57919
58994
  const topRoot = () => {
57920
58995
  const scene = sceneLayers();
57921
- return focusRoot(state, scene.names, scene.hud);
58996
+ return focusRoot(state, scene.names, scene.hud, isAside);
57922
58997
  };
57923
58998
  const fields = createFields(ctx, {
57924
58999
  layout: modules.layout,
@@ -57929,13 +59004,13 @@ function createJsxApi(ctx, modules) {
57929
59004
  const resolvedOf = (entity) => ctx.deps.world.ecs.get(entity, Text)?.resolved;
57930
59005
  const lookup = (entity) => state.elements.get(entity);
57931
59006
  const find = (key) => {
57932
- for (const root of sortedRoots(state, layerNames())) {
59007
+ for (const root of sortedRoots(state, layerNames(), isAside)) {
57933
59008
  const entity = state.byKey.get(root.entity)?.get(key);
57934
59009
  if (entity !== void 0 && !state.exiting.has(entity)) return entity;
57935
59010
  }
57936
59011
  };
57937
59012
  return {
57938
- tree: () => readTree(state, layerNames(), resolvedOf, (slot) => rootPoseOf(ctx.deps.world.ecs, slot)),
59013
+ tree: () => readTree(state, layerNames(), resolvedOf, (slot) => rootPoseOf(ctx.deps.world.ecs, slot), isAside),
57939
59014
  find,
57940
59015
  lint: () => runLint(ctx),
57941
59016
  fill: (key, value) => {
@@ -57996,6 +59071,7 @@ function createJsxApi(ctx, modules) {
57996
59071
  queueEnter: frame.queueEnter,
57997
59072
  playEntered: frame.playEntered,
57998
59073
  converged: frame.converged,
59074
+ asideBuilt: frame.asideBuilt,
57999
59075
  key: (input) => focus.key(input),
58000
59076
  blur: focus.blur
58001
59077
  };
@@ -58284,6 +59360,18 @@ function writeRest(ctx, element, parent) {
58284
59360
  if (visual !== void 0) projection.setRest(element.entity, visual.component, visual.value);
58285
59361
  }
58286
59362
  /**
59363
+ * Tells whether an element waits for the swap of a scene `world` builds aside (spec 31): it has not
59364
+ * entered, and it belongs to the build. Its hooks wait for its enter at the swap; a change before
59365
+ * it plays no motion and is written at once, so the enter starts from where it stands.
59366
+ *
59367
+ * @param ctx - Domain context of the ui plugin.
59368
+ * @param element - The element a hook would play on.
59369
+ * @returns True while its motions are held.
59370
+ */
59371
+ function isHeld(ctx, element) {
59372
+ return !element.entered && ctx.deps.world.projection.isBuilding(element.entity);
59373
+ }
59374
+ /**
58287
59375
  * Asks the projection for the handle of a ui-owned entity. A foreign owner is an error in the
58288
59376
  * log and the caller writes the value directly.
58289
59377
  *
@@ -58591,6 +59679,7 @@ function noteEndless(element, motion) {
58591
59679
  * Plays the `change` hook of one extra component whose fields changed. The motion the last change
58592
59680
  * of that name started is cancelled first, so its value stays where it is and the new motion
58593
59681
  * starts from there; the new motion is kept with the element's handles, so an exit waits for it.
59682
+ * An element held for the swap of a scene build plays none: the caller writes the fields.
58594
59683
  *
58595
59684
  * @param ctx - Domain context of the ui plugin.
58596
59685
  * @param element - The element that carries the extra.
@@ -58609,7 +59698,7 @@ function playExtraChange(ctx, element, name, values, hint) {
58609
59698
  element.extraHandles.delete(name);
58610
59699
  }
58611
59700
  const hook = element.motion?.change?.[name];
58612
- if (hook === void 0) return false;
59701
+ if (hook === void 0 || isHeld(ctx, element)) return false;
58613
59702
  const motion = play(ctx, element, (view) => hook(view, values.previous, values.next, hint));
58614
59703
  if (motion === void 0) return false;
58615
59704
  element.extraHandles.set(name, motion);
@@ -58660,8 +59749,9 @@ function still(element) {
58660
59749
  * 0 ms rest track. That track writes the pose as it starts, so the pivot and the position land in
58661
59750
  * the same frame, and ends at the next frame step, where an additive loop re-bases on the new
58662
59751
  * rest. An element that has not spawned yet only records it, and so does one whose
58663
- * `change.Box` motion already played. The content of a scroll keeps its `Transform` and records
58664
- * no rest with the world: the scroll step owns it.
59752
+ * `change.Box` motion already played. An element held for the swap of a scene build takes the
59753
+ * pose at once, with no hook and no track (spec 31). The content of a scroll keeps its `Transform`
59754
+ * and records no rest with the world: the scroll step owns it.
58665
59755
  *
58666
59756
  * @param ctx - Domain context of the ui plugin.
58667
59757
  * @param element - The element whose rect, style or fit may have changed.
@@ -58677,6 +59767,13 @@ function repose(ctx, element, parent, hooked, hint) {
58677
59767
  if (element.type === "content") return;
58678
59768
  ctx.deps.world.projection.setRest(element.entity, Transform, next);
58679
59769
  if (!element.live || hooked) return;
59770
+ if (isHeld(ctx, element)) {
59771
+ ctx.deps.world.ecs.set(element.entity, Transform, {
59772
+ ...next,
59773
+ pivot: { ...next.pivot }
59774
+ });
59775
+ return;
59776
+ }
58680
59777
  const hook = element.motion?.change?.Transform;
58681
59778
  if (hook !== void 0) {
58682
59779
  play(ctx, element, (view) => hook(view, previous, next, hint));
@@ -60217,7 +61314,7 @@ function createLayoutApi(ctx) {
60217
61314
  loop: (element) => startLoop(ctx, element),
60218
61315
  change: (element, previous, hint) => {
60219
61316
  const hook = element.motion?.change?.Box;
60220
- if (hook === void 0) return false;
61317
+ if (hook === void 0 || isHeld(ctx, element)) return false;
60221
61318
  play(ctx, element, (view) => hook(view, previous, element.rect, hint));
60222
61319
  return true;
60223
61320
  },
@@ -60793,8 +61890,9 @@ function registerComponents(ctx, jsx) {
60793
61890
  * step, the three tap listeners (the `LocalWrite` patch, the pointer tap that clears the keyboard
60794
61891
  * focus, and the tap that starts or ends the editing of a text field), the pointer door that opens
60795
61892
  * the keyboard inside the DOM listener, the key listener of the focus and `LocalWrite` as an input
60796
- * control, so the cursor shows a hand over a local-state button. On an agent page also the tap
60797
- * counter of `DebugTrigger` and its check of a view that takes no press.
61893
+ * control, so the cursor shows a hand over a local-state button, and `ui`'s part of a scene build
61894
+ * (`onBuild`: the roots built aside are reconciled and solved, spec 31). On an agent page also the
61895
+ * tap counter of `DebugTrigger` and its check of a view that takes no press.
60798
61896
  *
60799
61897
  * The `Box` hook plays an element's enter hook and queues it, so the game's systems see its start
60800
61898
  * pose (F49); `ui.enter` starts its loop after them and before the frame is drawn, and plays the
@@ -60826,7 +61924,7 @@ function openRegistrations(ctx, jsx, layout) {
60826
61924
  })), ecs.onAdded(Tree, (entity) => {
60827
61925
  const place = projection.keyOf(entity);
60828
61926
  jsx.mountRoot(entity, place?.projection ?? "ui", ecs.get(entity, Layer)?.name ?? "ui");
60829
- }), ecs.onRemoved(Tree, (entity) => jsx.unmountRoot(entity)), ecs.onAdded(Box, (entity) => jsx.queueEnter(entity)), projection.onConverged(jsx.converged), ecs.onAdded(Pressed, (entity) => jsx.markPointer(entity, "pressed", true)), ecs.onRemoved(Pressed, (entity) => jsx.markPointer(entity, "pressed", false)), ecs.onAdded(PointerOver, (entity) => jsx.markPointer(entity, "hover", true)), ecs.onRemoved(PointerOver, (entity) => jsx.markPointer(entity, "hover", false)), ecs.onRemoved(Parent, () => hostsMoved(ctx.state.jsx)), ecs.onRemoved(Held, () => hostsMoved(ctx.state.jsx)), ctx.deps.flow.fx.handle("popup", layout.popupHandler(jsx)), ctx.deps.flow.fx.handle("guide", layout.guideHandler()), ctx.deps.flow.fx.onHint((hint) => {
61927
+ }), ecs.onRemoved(Tree, (entity) => jsx.unmountRoot(entity)), ecs.onAdded(Box, (entity) => jsx.queueEnter(entity)), projection.onConverged(jsx.converged), projection.onBuild(jsx.asideBuilt), ecs.onAdded(Pressed, (entity) => jsx.markPointer(entity, "pressed", true)), ecs.onRemoved(Pressed, (entity) => jsx.markPointer(entity, "pressed", false)), ecs.onAdded(PointerOver, (entity) => jsx.markPointer(entity, "hover", true)), ecs.onRemoved(PointerOver, (entity) => jsx.markPointer(entity, "hover", false)), ecs.onRemoved(Parent, () => hostsMoved(ctx.state.jsx)), ecs.onRemoved(Held, () => hostsMoved(ctx.state.jsx)), ctx.deps.flow.fx.handle("popup", layout.popupHandler(jsx)), ctx.deps.flow.fx.handle("guide", layout.guideHandler()), ctx.deps.flow.fx.onHint((hint) => {
60830
61928
  ctx.state.jsx.hints.push(hint);
60831
61929
  }), ctx.deps.input.onTap(jsx.applyTap), ctx.deps.input.onTap(() => jsx.blur()), ctx.deps.input.onTap(jsx.tapped), ctx.deps.input.onPointer(jsx.pointer), ctx.deps.input.onKey(jsx.key), ctx.deps.input.controls.add(LocalWrite), () => jsx.releaseHosted(), () => ecs.despawnOwnedBy(UI_OWNER));
60832
61930
  if (typeof __MOKU_GAME_AGENT__ !== "undefined" && __MOKU_GAME_AGENT__) ctx.state.layout.cleanups.push(ctx.deps.input.onTap((entity) => debugTap(ctx, entity)), ecs.onAdded(DebugTrigger, (entity) => checkTrigger(ctx, entity)));
@@ -60885,6 +61983,7 @@ function stopUi(state) {
60885
61983
  state.jsx.released.clear();
60886
61984
  state.jsx.scrolls.clear();
60887
61985
  state.jsx.remount = false;
61986
+ state.jsx.asideRoots = 0;
60888
61987
  state.jsx.hints.length = 0;
60889
61988
  state.jsx.entering.list.length = 0;
60890
61989
  state.jsx.entering.count = 0;
@@ -61275,7 +62374,8 @@ function createJsxState() {
61275
62374
  written: 0,
61276
62375
  debugTaps: [],
61277
62376
  debugWarned: false,
61278
- overlays: emptySet()
62377
+ overlays: emptySet(),
62378
+ asideRoots: 0
61279
62379
  };
61280
62380
  }
61281
62381
  //#endregion
@@ -62860,6 +63960,7 @@ function cubicBezier(x1, y1, x2, y2) {
62860
63960
  * | assets | preloadDepth | 2 |
62861
63961
  * | assets | baseUrl | undefined |
62862
63962
  * | assets | io | undefined, the browser fetch and the renderer textures |
63963
+ * | scenes | buildMs | "auto", half of a frame (500 / time.rate().fps), read at each build start; a number fixes it |
62863
63964
  * | anim | maxTracks | 2000 |
62864
63965
  * | anim | reducedMotion | false |
62865
63966
  * | i18n | locale | "en" |